diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d4306e609b56..b650be5d9142 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1764,6 +1764,10 @@ } }, + "ConversationListItem--message-request": { + "message": "Message Request", + "description": "Preview shown for conversation if the user has not yet accepted an incoming message request" + }, "ConversationListItem--draft-prefix": { "message": "Draft:", "description": "Prefix shown in italic in conversation view when a draft is saved" diff --git a/js/background.js b/js/background.js index 5d8cd336e8e8..edb7daf51853 100644 --- a/js/background.js +++ b/js/background.js @@ -1885,6 +1885,11 @@ logger: window.log, }); + // Force a re-fetch here when we've processed our queue. Without this, we won't try + // again for two hours after our first attempt. Which might have been while we were + // offline or didn't have credentials. + window.Signal.RemoteConfig.refreshRemoteConfig(); + let interval = setInterval(() => { const view = window.owsDesktopApp.appView; if (view) { diff --git a/js/models/conversations.js b/js/models/conversations.js index 79c27e5f8be1..be986d01899a 100644 --- a/js/models/conversations.js +++ b/js/models/conversations.js @@ -28,7 +28,7 @@ }; const { Util } = window.Signal; - const { Conversation, Contact, Message } = window.Signal.Types; + const { Contact, Message } = window.Signal.Types; const { deleteAttachmentData, doesAttachmentExist, @@ -134,12 +134,6 @@ this.updateLastMessage.bind(this), 200 ); - this.throttledUpdateSharedGroups = - this.throttledUpdateSharedGroups || - _.throttle( - this.updateSharedGroups.bind(this), - 1000 * 60 * 5 // five minutes - ); this.listenTo( this.messageCollection, @@ -175,10 +169,11 @@ this.typingPauseTimer = null; // Keep props ready - this.generateProps = () => { + const generateProps = () => { this.cachedProps = this.getProps(); }; - this.on('change', this.generateProps); + this.on('change', generateProps); + generateProps(); }, isMe() { @@ -451,8 +446,6 @@ getProps() { const color = this.getColor(); - this.throttledUpdateSharedGroups(); - const typingValues = _.values(this.contactTypingTimers || {}); const typingMostRecent = _.first(_.sortBy(typingValues, 'timestamp')); const typingContact = typingMostRecent @@ -575,15 +568,16 @@ async handleReadAndDownloadAttachments() { let messages; do { + const first = messages ? messages.first() : null; + // eslint-disable-next-line no-await-in-loop messages = await window.Signal.Data.getOlderMessagesByConversation( this.get('id'), { MessageCollection: Whisper.MessageCollection, limit: 100, - receivedAt: messages - ? messages.first().get('received_at') - : undefined, + receivedAt: first ? first.get('received_at') : null, + messageId: first ? first.id : null, } ); @@ -959,13 +953,18 @@ (this.get('messageCountBeforeMessageRequests') || 0) > 0; const hasNoMessages = (this.get('messageCount') || 0) === 0; + const isEmptyPrivateConvo = hasNoMessages && this.isPrivate(); + const isEmptyWhitelistedGroup = + hasNoMessages && !this.isPrivate() && this.get('profileSharing'); + return ( isFromOrAddedByTrustedContact || hasSentMessages || hasMessagesBeforeMessageRequests || - // an empty conversation is the scenario where we need to rely on + // an empty group is the scenario where we need to rely on // whether the profile has already been shared or not - (hasNoMessages && this.get('profileSharing')) + isEmptyPrivateConvo || + isEmptyWhitelistedGroup ); }, @@ -1868,37 +1867,40 @@ return; } - const messages = await window.Signal.Data.getOlderMessagesByConversation( - this.id, - { limit: 1, MessageCollection: Whisper.MessageCollection } - ); + const [previewMessage, activityMessage] = await Promise.all([ + window.Signal.Data.getLastConversationPreview(this.id, { + Message: Whisper.Message, + }), + window.Signal.Data.getLastConversationActivity(this.id, { + Message: Whisper.Message, + }), + ]); + + // This is the less-restrictive of these two fetches; if it's falsey, both will be + if (!previewMessage) { + return; + } - const lastMessageModel = messages.at(0); if ( this.hasDraft() && this.get('draftTimestamp') && - (!lastMessageModel || - lastMessageModel.get('sent_at') < this.get('draftTimestamp')) + previewMessage.get('sent_at') < this.get('draftTimestamp') ) { return; } - const lastMessageJSON = lastMessageModel - ? lastMessageModel.toJSON() - : null; - const lastMessageStatusModel = lastMessageModel - ? lastMessageModel.getMessagePropStatus() - : null; - const lastMessageUpdate = Conversation.createLastMessageUpdate({ - currentTimestamp: this.get('timestamp') || null, - lastMessage: lastMessageJSON, - lastMessageStatus: lastMessageStatusModel, - lastMessageNotificationText: lastMessageModel - ? lastMessageModel.getNotificationText() - : null, + const currentTimestamp = this.get('timestamp') || null; + const timestamp = activityMessage + ? activityMessage.sent_at || currentTimestamp + : currentTimestamp; + + this.set({ + lastMessage: previewMessage.getNotificationText() || '', + lastMessageStatus: previewMessage.getMessagePropStatus() || null, + timestamp, + lastMessageDeletedForEveryone: previewMessage.deletedForEveryone, }); - this.set(lastMessageUpdate); window.Signal.Data.updateConversation(this.attributes); }, diff --git a/js/modules/types/conversation.js b/js/modules/types/conversation.js index 7a8c2fe5a87f..c48a7c6c00a3 100644 --- a/js/modules/types/conversation.js +++ b/js/modules/types/conversation.js @@ -1,7 +1,6 @@ /* global crypto, window */ const { isFunction, isNumber } = require('lodash'); -const { createLastMessageUpdate } = require('../../../ts/types/Conversation'); const { arrayBufferToBase64, base64ToArrayBuffer, @@ -161,7 +160,7 @@ module.exports = { arrayBufferToBase64, base64ToArrayBuffer, computeHash, - createLastMessageUpdate, + deleteExternalFiles, maybeUpdateAvatar, maybeUpdateProfileAvatar, diff --git a/js/views/conversation_view.js b/js/views/conversation_view.js index 5c6f13468cd3..f50dc88258f1 100644 --- a/js/views/conversation_view.js +++ b/js/views/conversation_view.js @@ -16,6 +16,8 @@ (function() { 'use strict'; + const FIVE_MINUTES = 1000 * 60 * 5; + window.Whisper = window.Whisper || {}; const { Message, MIME, VisualAttachment } = window.Signal.Types; const { @@ -304,9 +306,12 @@ ); this.model.throttledGetProfiles = this.model.throttledGetProfiles || + _.throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); + this.model.throttledUpdateSharedGroups = + this.model.throttledUpdateSharedGroups || _.throttle( - this.model.getProfiles.bind(this.model), - 1000 * 60 * 5 // five minutes + this.model.updateSharedGroups.bind(this.model), + FIVE_MINUTES ); this.debouncedMaybeGrabLinkPreview = _.debounce( this.maybeGrabLinkPreview.bind(this), @@ -720,6 +725,7 @@ showVisualAttachment, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, + updateSharedGroups: this.model.throttledUpdateSharedGroups, }), }); diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 87b22315661b..d427b6487901 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -3750,6 +3750,17 @@ $timer-icons: '55', '50', '45', '40', '35', '30', '25', '20', '15', '10', '05', align-items: center; } +.module-conversation-list-item__message-request { + @include font-body-2-bold; + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} + .module-conversation-list-item__message__text { flex-grow: 1; flex-shrink: 1; diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 7a1c17665e06..f2e6e166a47b 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -636,8 +636,6 @@ export class ConversationController { await Promise.all( this._conversations.map(async conversation => { - conversation.generateProps(); - if (!conversation.get('lastMessage')) { await conversation.updateLastMessage(); } diff --git a/ts/RemoteConfig.ts b/ts/RemoteConfig.ts index b6f54d66f37d..8414a3b9a87a 100644 --- a/ts/RemoteConfig.ts +++ b/ts/RemoteConfig.ts @@ -42,7 +42,7 @@ export function onChange(key: ConfigKeyType, fn: ConfigListenerType) { }; } -const refreshRemoteConfig = async () => { +export const refreshRemoteConfig = async () => { const now = Date.now(); const server = getServer(); const newConfig = await server.getConfig(); diff --git a/ts/components/ConversationListItem.md b/ts/components/ConversationListItem.md index f0fe1d7e8cef..2c43d076b145 100644 --- a/ts/components/ConversationListItem.md +++ b/ts/components/ConversationListItem.md @@ -4,6 +4,8 @@ console.log('onClick', result)} + i18n={util.i18n} + /> + + +``` + +#### Message Request + +```jsx + +
+ console.log('onClick', result)} + i18n={util.i18n} + /> +
+
+ { const { draftPreview, i18n, + isAccepted, lastMessage, shouldShowDraft, typingContact, @@ -187,7 +189,11 @@ export class ConversationListItem extends React.PureComponent { : null )} > - {typingContact ? ( + {!isAccepted ? ( + + {i18n('ConversationListItem--message-request')} + + ) : typingContact ? ( ) : ( <> diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index e797a8d7790c..0cf4a1b019c3 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -13,6 +13,7 @@ export type Props = { membersCount?: number; phoneNumber?: string; onHeightChange?: () => unknown; + updateSharedGroups?: () => unknown; } & Omit; const renderMembershipRow = ({ @@ -113,6 +114,7 @@ export const ConversationHero = ({ profileName, title, onHeightChange, + updateSharedGroups, }: Props) => { const firstRenderRef = React.useRef(true); @@ -121,6 +123,11 @@ export const ConversationHero = ({ // component may have changed. The cleanup function notifies listeners of // any potential height changes. return () => { + // Kick off the expensive hydration of the current sharedGroupNames + if (updateSharedGroups) { + updateSharedGroups(); + } + if (onHeightChange && !firstRenderRef.current) { onHeightChange(); } else { @@ -135,7 +142,7 @@ export const ConversationHero = ({ `mc-${membersCount}`, `n-${name}`, `pn-${profileName}`, - ...sharedGroupNames.map(g => `g-${g}`), + sharedGroupNames.map(g => `g-${g}`).join(' '), ]); const phoneNumberOnly = Boolean( diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index c1830c1a2c4a..6164267937fb 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -50,7 +50,11 @@ type PropsHousekeepingType = { actions: Object ) => JSX.Element; renderLastSeenIndicator: (id: string) => JSX.Element; - renderHeroRow: (id: string, resizeHeroRow: () => unknown) => JSX.Element; + renderHeroRow: ( + id: string, + resizeHeroRow: () => unknown, + updateSharedGroups: () => unknown + ) => JSX.Element; renderLoadingRow: (id: string) => JSX.Element; renderTypingBubble: (id: string) => JSX.Element; }; @@ -70,6 +74,7 @@ type PropsActionsType = { markMessageRead: (messageId: string) => unknown; selectMessage: (messageId: string, conversationId: string) => unknown; clearSelectedMessage: () => unknown; + updateSharedGroups: () => unknown; } & MessageActionsType & SafetyNumberActionsType; @@ -510,6 +515,7 @@ export class Timeline extends React.PureComponent { renderLoadingRow, renderLastSeenIndicator, renderTypingBubble, + updateSharedGroups, } = this.props; const styleWithWidth = { @@ -524,7 +530,7 @@ export class Timeline extends React.PureComponent { if (haveOldest && row === 0) { rowContents = (
- {renderHeroRow(id, this.resizeHeroRow)} + {renderHeroRow(id, this.resizeHeroRow, updateSharedGroups)}
); } else if (!haveOldest && row === 0) { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index a4127ad64bfa..47cfcbbd85f7 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -85,7 +85,6 @@ declare class ConversationModelType extends Backbone.Model< cleanup(): Promise; disableProfileSharing(): void; dropProfileKey(): Promise; - generateProps(): void; getAccepted(): boolean; getAvatarPath(): string | undefined; getColor(): ColorType | undefined; diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index f9be5258a2dc..8789228f96e9 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -156,6 +156,8 @@ const dataInterface: ClientInterface = { getTapToViewMessagesNeedingErase, getOlderMessagesByConversation, getNewerMessagesByConversation, + getLastConversationActivity, + getLastConversationPreview, getMessageMetricsForConversation, migrateConversationMessages, @@ -1022,6 +1024,32 @@ async function getNewerMessagesByConversation( return new MessageCollection(handleMessageJSON(messages)); } +async function getLastConversationActivity( + conversationId: string, + options: { + Message: typeof MessageModelType; + } +): Promise { + const { Message } = options; + const result = await channels.getLastConversationActivity(conversationId); + if (result) { + return new Message(result); + } + return; +} +async function getLastConversationPreview( + conversationId: string, + options: { + Message: typeof MessageModelType; + } +): Promise { + const { Message } = options; + const result = await channels.getLastConversationPreview(conversationId); + if (result) { + return new Message(result); + } + return; +} async function getMessageMetricsForConversation(conversationId: string) { const result = await channels.getMessageMetricsForConversation( conversationId diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index e028e67324a2..4635bdc4a7b5 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -210,6 +210,12 @@ export type ServerInterface = DataInterface & { conversationId: string, options?: { limit?: number; receivedAt?: number } ) => Promise>; + getLastConversationActivity: ( + conversationId: string + ) => Promise; + getLastConversationPreview: ( + conversationId: string + ) => Promise; getNextExpiringMessage: () => Promise; getNextTapToViewMessageToAgeOut: () => Promise; getOutgoingWithoutExpiresAt: () => Promise>; @@ -308,6 +314,18 @@ export type ClientInterface = DataInterface & { MessageCollection: typeof MessageModelCollectionType; } ) => Promise; + getLastConversationActivity: ( + conversationId: string, + options: { + Message: typeof MessageModelType; + } + ) => Promise; + getLastConversationPreview: ( + conversationId: string, + options: { + Message: typeof MessageModelType; + } + ) => Promise; getNextExpiringMessage: ({ Message, }: { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 3e4610911f9b..00fce6399d7c 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -132,6 +132,8 @@ const dataInterface: ServerInterface = { getOlderMessagesByConversation, getNewerMessagesByConversation, getMessageMetricsForConversation, + getLastConversationActivity, + getLastConversationPreview, migrateConversationMessages, getUnprocessedCount, @@ -2749,6 +2751,50 @@ async function getNewestMessageForConversation(conversationId: string) { return row; } + +async function getLastConversationActivity( + conversationId: string +): Promise { + const db = getInstance(); + const row = await db.get( + `SELECT * FROM messages WHERE + conversationId = $conversationId AND + type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced') AND + json_extract(json, '$.expirationTimerUpdate.fromSync') != true + ORDER BY received_at DESC + LIMIT 1;`, + { + $conversationId: conversationId, + } + ); + + if (!row) { + return null; + } + + return jsonToObject(row.json); +} +async function getLastConversationPreview( + conversationId: string +): Promise { + const db = getInstance(); + const row = await db.get( + `SELECT * FROM messages WHERE + conversationId = $conversationId AND + type NOT IN ('profile-change', 'verified-change', 'message-history-unsynced') + ORDER BY received_at DESC + LIMIT 1;`, + { + $conversationId: conversationId, + } + ); + + if (!row) { + return null; + } + + return jsonToObject(row.json); +} async function getOldestUnreadMessageForConversation(conversationId: string) { const db = getInstance(); const row = await db.get( diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index f82de38fdb33..5286cab0f61e 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -68,8 +68,18 @@ function renderEmojiPicker({ function renderLastSeenIndicator(id: string): JSX.Element { return ; } -function renderHeroRow(id: string, onHeightChange: () => unknown): JSX.Element { - return ; +function renderHeroRow( + id: string, + onHeightChange: () => unknown, + updateSharedGroups: () => unknown +): JSX.Element { + return ( + + ); } function renderLoadingRow(id: string): JSX.Element { return ; diff --git a/ts/test/types/Conversation_test.ts b/ts/test/types/Conversation_test.ts deleted file mode 100644 index 5a186ac3c0d5..000000000000 --- a/ts/test/types/Conversation_test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { assert } from 'chai'; - -import * as Conversation from '../../types/Conversation'; -import { - IncomingMessage, - MessageHistoryUnsyncedMessage, - OutgoingMessage, - ProfileChangeNotificationMessage, - VerifiedChangeMessage, -} from '../../types/Message'; - -describe('Conversation', () => { - describe('createLastMessageUpdate', () => { - it('should reset last message if conversation has no messages', () => { - const input = {}; - const expected = { - lastMessage: '', - lastMessageStatus: null, - timestamp: null, - }; - - const actual = Conversation.createLastMessageUpdate(input); - assert.deepEqual(actual, expected); - }); - - context('for regular message', () => { - it('should update last message text and timestamp', () => { - const input = { - currentTimestamp: 555, - lastMessageStatus: 'read', - lastMessage: { - type: 'outgoing', - conversationId: 'foo', - sent_at: 666, - timestamp: 666, - } as OutgoingMessage, - lastMessageNotificationText: 'New outgoing message', - }; - const expected = { - lastMessage: 'New outgoing message', - lastMessageStatus: 'read', - lastMessageDeletedForEveryone: undefined, - timestamp: 666, - }; - - const actual = Conversation.createLastMessageUpdate(input); - assert.deepEqual(actual, expected); - }); - }); - - context('for message history unsynced message', () => { - it('should skip update', () => { - const input = { - currentTimestamp: 555, - lastMessage: { - type: 'message-history-unsynced', - conversationId: 'foo', - sent_at: 666, - timestamp: 666, - } as MessageHistoryUnsyncedMessage, - lastMessageNotificationText: 'xoxoxoxo', - }; - const expected = { - lastMessage: 'xoxoxoxo', - lastMessageStatus: null, - lastMessageDeletedForEveryone: undefined, - timestamp: 555, - }; - - const actual = Conversation.createLastMessageUpdate(input); - assert.deepEqual(actual, expected); - }); - }); - - context('for verified change message', () => { - it('should skip update', () => { - const input = { - currentTimestamp: 555, - lastMessage: { - type: 'verified-change', - conversationId: 'foo', - sent_at: 666, - timestamp: 666, - } as VerifiedChangeMessage, - lastMessageNotificationText: 'Verified Changed', - }; - const expected = { - lastMessage: '', - lastMessageStatus: null, - lastMessageDeletedForEveryone: undefined, - timestamp: 555, - }; - - const actual = Conversation.createLastMessageUpdate(input); - assert.deepEqual(actual, expected); - }); - }); - - context('for expire timer update from sync', () => { - it('should update message but not timestamp (to prevent bump to top)', () => { - const input = { - currentTimestamp: 555, - lastMessage: { - type: 'incoming', - conversationId: 'foo', - sent_at: 666, - timestamp: 666, - expirationTimerUpdate: { - expireTimer: 111, - fromSync: true, - source: '+12223334455', - }, - } as IncomingMessage, - lastMessageNotificationText: 'Last message before expired', - }; - const expected = { - lastMessage: 'Last message before expired', - lastMessageStatus: null, - lastMessageDeletedForEveryone: undefined, - timestamp: 555, - }; - - const actual = Conversation.createLastMessageUpdate(input); - assert.deepEqual(actual, expected); - }); - }); - - context('for profile change message', () => { - it('should update message but not timestamp (to prevent bump to top)', () => { - const input = { - currentTimestamp: 555, - lastMessage: { - type: 'profile-change', - conversationId: 'foo', - sent_at: 666, - timestamp: 666, - } as ProfileChangeNotificationMessage, - lastMessageNotificationText: 'John changed their profile name', - }; - const expected = { - lastMessage: 'John changed their profile name', - lastMessageStatus: null, - lastMessageDeletedForEveryone: undefined, - timestamp: 555, - }; - - const actual = Conversation.createLastMessageUpdate(input); - assert.deepEqual(actual, expected); - }); - }); - }); -}); diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index e8a692c1f539..2f12be4d6d73 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1401,7 +1401,7 @@ class MessageReceiverInner extends EventTarget { } const to = sentMessage.message.group ? `group(${sentMessage.message.group.id.toBinary()})` - : sentMessage.destination; + : sentMessage.destination || sentMessage.destinationUuid; window.log.info( 'sent message to', diff --git a/ts/types/Conversation.ts b/ts/types/Conversation.ts deleted file mode 100644 index 6f3e8079b01f..000000000000 --- a/ts/types/Conversation.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { Message } from './Message'; - -interface ConversationLastMessageUpdate { - lastMessage: string; - lastMessageStatus: string | null; - timestamp: number | null; - lastMessageDeletedForEveryone?: boolean; -} - -export const createLastMessageUpdate = ({ - currentTimestamp, - lastMessage, - lastMessageStatus, - lastMessageNotificationText, -}: { - currentTimestamp?: number; - lastMessage?: Message; - lastMessageStatus?: string; - lastMessageNotificationText?: string; -}): ConversationLastMessageUpdate => { - if (!lastMessage) { - return { - lastMessage: '', - lastMessageStatus: null, - timestamp: null, - }; - } - - const { type, expirationTimerUpdate, deletedForEveryone } = lastMessage; - const isMessageHistoryUnsynced = type === 'message-history-unsynced'; - const isProfileChangedMessage = type === 'profile-change'; - const isVerifiedChangeMessage = type === 'verified-change'; - const isExpireTimerUpdateFromSync = Boolean( - expirationTimerUpdate && expirationTimerUpdate.fromSync - ); - - const shouldUpdateTimestamp = Boolean( - !isMessageHistoryUnsynced && - !isProfileChangedMessage && - !isVerifiedChangeMessage && - !isExpireTimerUpdateFromSync - ); - const newTimestamp = shouldUpdateTimestamp - ? lastMessage.sent_at - : currentTimestamp; - - const shouldUpdateLastMessageText = !isVerifiedChangeMessage; - const newLastMessageText = shouldUpdateLastMessageText - ? lastMessageNotificationText - : ''; - - return { - lastMessage: deletedForEveryone ? '' : newLastMessageText || '', - lastMessageStatus: lastMessageStatus || null, - timestamp: newTimestamp || null, - lastMessageDeletedForEveryone: deletedForEveryone, - }; -}; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 1083632d26ca..88fb195f9b90 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -207,7 +207,7 @@ "rule": "jQuery-wrap(", "path": "js/models/conversations.js", "line": " await wrap(", - "lineNumber": 671, + "lineNumber": 665, "reasonCategory": "falseMatch", "updated": "2020-06-09T20:26:46.515Z" },