From e0c324e4bafca0704d5b1bfcce87d6d1eaabfbd2 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 6 May 2021 18:15:25 -0700 Subject: [PATCH] Send/Receive support for reaction read syncs --- js/delivery_receipts.js | 1 + js/message_controller.js | 2 +- js/notifications.js | 21 +- js/read_receipts.js | 1 + js/read_syncs.js | 43 +++- ts/ConversationController.ts | 2 +- ts/background.ts | 8 +- ts/model-types.d.ts | 6 +- ts/models/conversations.ts | 411 +++--------------------------- ts/models/messages.ts | 96 +++---- ts/services/MessageUpdater.ts | 87 +++++++ ts/sql/Client.ts | 47 +++- ts/sql/Interface.ts | 59 ++++- ts/sql/Server.ts | 356 ++++++++++++++++++++++++-- ts/types/Reactions.ts | 11 + ts/util/getConversationMembers.ts | 57 +++++ ts/util/getSendOptions.ts | 135 ++++++++++ ts/util/handleMessageSend.ts | 86 +++++++ ts/util/isConversationAccepted.ts | 84 ++++++ ts/util/markConversationRead.ts | 111 ++++++++ ts/util/sendReadReceiptsFor.ts | 43 ++++ ts/util/whatTypeOfConversation.ts | 17 ++ ts/window.d.ts | 2 +- 23 files changed, 1188 insertions(+), 498 deletions(-) create mode 100644 ts/services/MessageUpdater.ts create mode 100644 ts/types/Reactions.ts create mode 100644 ts/util/getConversationMembers.ts create mode 100644 ts/util/getSendOptions.ts create mode 100644 ts/util/handleMessageSend.ts create mode 100644 ts/util/isConversationAccepted.ts create mode 100644 ts/util/markConversationRead.ts create mode 100644 ts/util/sendReadReceiptsFor.ts create mode 100644 ts/util/whatTypeOfConversation.ts diff --git a/js/delivery_receipts.js b/js/delivery_receipts.js index 09fd1826fb..bad8eb5730 100644 --- a/js/delivery_receipts.js +++ b/js/delivery_receipts.js @@ -97,6 +97,7 @@ }); if (message.isExpiring() && !expirationStartTimestamp) { + // TODO DESKTOP-1509: use setToExpire once this is TS await message.setToExpire(false, { skipSave: true }); } diff --git a/js/message_controller.js b/js/message_controller.js index 43f2bd85f3..e43c3422ee 100644 --- a/js/message_controller.js +++ b/js/message_controller.js @@ -67,7 +67,7 @@ function getById(id) { const existing = messageLookup[id]; - return existing && existing.message ? existing.message : null; + return existing && existing.message ? existing.message : undefined; } function findBySentAt(sentAt) { diff --git a/js/notifications.js b/js/notifications.js index 13685bb594..5a5dc1b567 100644 --- a/js/notifications.js +++ b/js/notifications.js @@ -60,8 +60,14 @@ // Remove the last notification if both conditions hold: // // 1. Either `conversationId` or `messageId` matches (if present) - // 2. `reactionFromId` matches (if present) - removeBy({ conversationId, messageId, reactionFromId }) { + // 2. `emoji`, `targetAuthorUuid`, `targetTimestamp` matches (if present) + removeBy({ + conversationId, + messageId, + emoji, + targetAuthorUuid, + targetTimestamp, + }) { if (!this.notificationData) { return; } @@ -81,10 +87,15 @@ return; } + const { reaction } = this.notificationData; if ( - reactionFromId && - this.notificationData.reaction && - this.notificationData.reaction.fromId !== reactionFromId + reaction && + emoji && + targetAuthorUuid && + targetTimestamp && + (reaction.emoji !== emoji || + reaction.targetAuthorUuid !== targetAuthorUuid || + reaction.targetTimestamp !== targetTimestamp) ) { return; } diff --git a/js/read_receipts.js b/js/read_receipts.js index 7bca12c425..b9a727bd16 100644 --- a/js/read_receipts.js +++ b/js/read_receipts.js @@ -98,6 +98,7 @@ }); if (message.isExpiring() && !expirationStartTimestamp) { + // TODO DESKTOP-1509: use setToExpire once this is TS await message.setToExpire(false, { skipSave: true }); } diff --git a/js/read_syncs.js b/js/read_syncs.js index c90063c652..3af480c032 100644 --- a/js/read_syncs.js +++ b/js/read_syncs.js @@ -11,6 +11,30 @@ // eslint-disable-next-line func-names (function () { + async function maybeItIsAReactionReadSync(receipt) { + const readReaction = await window.Signal.Data.markReactionAsRead( + receipt.get('senderUuid'), + Number(receipt.get('timestamp')) + ); + + if (!readReaction) { + window.log.info( + 'Nothing found for read sync', + receipt.get('senderId'), + receipt.get('sender'), + receipt.get('senderUuid'), + receipt.get('timestamp') + ); + } + + Whisper.Notifications.removeBy({ + conversationId: readReaction.conversationId, + emoji: readReaction.emoji, + targetAuthorUuid: readReaction.targetAuthorUuid, + targetTimestamp: readReaction.targetTimestamp, + }); + } + window.Whisper = window.Whisper || {}; Whisper.ReadSyncs = new (Backbone.Collection.extend({ forMessage(message) { @@ -47,19 +71,14 @@ return item.isIncoming() && senderId === receipt.get('senderId'); }); - if (found) { - Whisper.Notifications.removeBy({ messageId: found.id }); - } else { - window.log.info( - 'No message for read sync', - receipt.get('senderId'), - receipt.get('sender'), - receipt.get('senderUuid'), - receipt.get('timestamp') - ); + + if (!found) { + await maybeItIsAReactionReadSync(receipt); return; } + Whisper.Notifications.removeBy({ messageId: found.id }); + const message = MessageController.register(found.id, found); const readAt = receipt.get('read_at'); @@ -67,7 +86,8 @@ // timer to the time specified by the read sync if it's earlier than // the previous read time. if (message.isUnread()) { - await message.markRead(readAt, { skipSave: true }); + // TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS + message.markRead(readAt, { skipSave: true }); const updateConversation = () => { // onReadMessage may result in messages older than this one being @@ -100,6 +120,7 @@ message.set({ expirationStartTimestamp }); const force = true; + // TODO DESKTOP-1509: use setToExpire once this is TS await message.setToExpire(force, { skipSave: true }); const conversation = message.getConversation(); diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index b14fa5137d..6f62b5e2ed 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -723,7 +723,7 @@ export class ConversationController { async prepareForSend( id: string | undefined, - options?: { syncMessage?: boolean; disableMeCheck?: boolean } + options?: { syncMessage?: boolean } ): Promise<{ wrap: ( promise: Promise diff --git a/ts/background.ts b/ts/background.ts index 4f962f0ae2..9a775de8c2 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -20,6 +20,7 @@ import { initializeAllJobQueues } from './jobs/initializeAllJobQueues'; import { removeStorageKeyJobQueue } from './jobs/removeStorageKeyJobQueue'; import { ourProfileKeyService } from './services/ourProfileKey'; import { shouldRespondWithProfileKey } from './util/shouldRespondWithProfileKey'; +import { setToExpire } from './services/MessageUpdater'; const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000; @@ -1525,10 +1526,11 @@ export async function startApp(): Promise { `Cleanup: Starting timer for delivered message ${sentAt}` ); message.set( - 'expirationStartTimestamp', - expirationStartTimestamp || sentAt + setToExpire({ + ...message.attributes, + expirationStartTimestamp: expirationStartTimestamp || sentAt, + }) ); - await message.setToExpire(); return; } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 8456e6c6f5..79deda1da1 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -109,8 +109,6 @@ export type MessageAttributesType = { quote?: QuotedMessageType; reactions?: Array<{ emoji: string; - timestamp: number; - fromId: string; from: { id: string; color?: string; @@ -120,6 +118,10 @@ export type MessageAttributesType = { isMe?: boolean; phoneNumber?: string; }; + fromId: string; + targetAuthorUuid: string; + targetTimestamp: number; + timestamp: number; }>; read_by: Array; requiredProtocolVersion: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 04316f587b..739dd3e76f 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -4,6 +4,7 @@ /* eslint-disable class-methods-use-this */ /* eslint-disable camelcase */ import { ProfileKeyCredentialRequestContext } from 'zkgroup'; +import { compact } from 'lodash'; import { MessageModelCollectionType, WhatIsThis, @@ -15,7 +16,6 @@ import { CallMode, CallHistoryDetailsType } from '../types/Calling'; import { CallbackResultType, GroupV2InfoType, - SendMetadataType, SendOptionsType, } from '../textsecure/SendMessage'; import { @@ -26,7 +26,6 @@ import { ColorType } from '../types/Colors'; import { MessageModel } from './messages'; import { isMuted } from '../util/isMuted'; import { isConversationUnregistered } from '../util/isConversationUnregistered'; -import { assert } from '../util/assert'; import { missingCaseError } from '../util/missingCaseError'; import { sniffImageMimeType } from '../util/sniffImageMimeType'; import { MIMEType, IMAGE_WEBP } from '../types/MIME'; @@ -35,7 +34,6 @@ import { base64ToArrayBuffer, deriveAccessKey, fromEncodedBinaryToArrayBuffer, - getRandomBytes, stringFromBytes, trimForDisplay, verifyAccessKey, @@ -45,16 +43,13 @@ import { BodyRangesType } from '../types/Util'; import { getTextWithMentions } from '../util'; import { migrateColor } from '../util/migrateColor'; import { isNotNil } from '../util/isNotNil'; -import { - PhoneNumberSharingMode, - parsePhoneNumberSharingMode, -} from '../util/phoneNumberSharingMode'; -import { - SenderCertificateMode, - SerializedCertificateType, -} from '../textsecure/OutgoingMessage'; -import { senderCertificateService } from '../services/senderCertificate'; import { ourProfileKeyService } from '../services/ourProfileKey'; +import { getSendOptions } from '../util/getSendOptions'; +import { isConversationAccepted } from '../util/isConversationAccepted'; +import { markConversationRead } from '../util/markConversationRead'; +import { handleMessageSend } from '../util/handleMessageSend'; +import { getConversationMembers } from '../util/getConversationMembers'; +import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -1211,6 +1206,7 @@ export class ConversationModel extends window.Backbone // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const model = this.messageCollection!.add(message, { merge: true }); + // TODO use MessageUpdater.setToExpire model.setToExpire(); if (!existing) { @@ -1535,7 +1531,7 @@ export class ConversationModel extends window.Backbone if (isLocalAction) { // eslint-disable-next-line no-await-in-loop - await this.sendReadReceiptsFor(receiptSpecs); + await sendReadReceiptsFor(this.attributes, receiptSpecs); } // eslint-disable-next-line no-await-in-loop @@ -2304,43 +2300,7 @@ export class ConversationModel extends window.Backbone * of message requests */ getAccepted(): boolean { - const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled( - 'desktop.messageRequests' - ); - - if (!messageRequestsEnabled) { - return true; - } - - if (this.isMe()) { - return true; - } - - const messageRequestEnum = - window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; - if (this.getMessageRequestResponseType() === messageRequestEnum.ACCEPT) { - return true; - } - - const isFromOrAddedByTrustedContact = this.isFromOrAddedByTrustedContact(); - const hasSentMessages = this.getSentMessageCount() > 0; - const hasMessagesBeforeMessageRequests = - (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 group is the scenario where we need to rely on - // whether the profile has already been shared or not - isEmptyPrivateConvo || - isEmptyWhitelistedGroup - ); + return isConversationAccepted(this.attributes); } onMemberVerifiedChange(): void { @@ -2631,12 +2591,6 @@ export class ConversationModel extends window.Backbone ); } - getUnread(): Promise { - return window.Signal.Data.getUnreadByConversation(this.id, { - MessageCollection: window.Whisper.MessageCollection, - }); - } - validate(attributes = this.attributes): string | null { const required = ['type']; const missing = window._.filter(required, attr => !attributes[attr]); @@ -2785,50 +2739,11 @@ export class ConversationModel extends window.Backbone getMembers( options: { includePendingMembers?: boolean } = {} ): Array { - if (this.isPrivate()) { - return [this]; - } - - if (this.get('membersV2')) { - const { includePendingMembers } = options; - const members: Array<{ conversationId: string }> = includePendingMembers - ? [ - ...(this.get('membersV2') || []), - ...(this.get('pendingMembersV2') || []), - ] - : this.get('membersV2') || []; - - return window._.compact( - members.map(member => { - const c = window.ConversationController.get(member.conversationId); - - // In groups we won't sent to contacts we believe are unregistered - if (c && c.isUnregistered()) { - return null; - } - - return c; - }) - ); - } - - if (this.get('members')) { - return window._.compact( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.get('members')!.map(id => { - const c = window.ConversationController.get(id); - - // In groups we won't send to contacts we believe are unregistered - if (c && c.isUnregistered()) { - return null; - } - - return c; - }) - ); - } - - return []; + return compact( + getConversationMembers(this.attributes, options).map(conversationAttrs => + window.ConversationController.get(conversationAttrs.id) + ) + ); } getMemberIds(): Array { @@ -3477,198 +3392,13 @@ export class ConversationModel extends window.Backbone async wrapSend( promise: Promise ): Promise { - return promise.then( - async result => { - // success - if (result) { - await this.handleMessageSendResult( - result.failoverIdentifiers, - result.unidentifiedDeliveries - ); - } - return result; - }, - async result => { - // failure - if (result) { - await this.handleMessageSendResult( - result.failoverIdentifiers, - result.unidentifiedDeliveries - ); - } - throw result; - } - ); + return handleMessageSend(promise); } - async handleMessageSendResult( - failoverIdentifiers: Array | undefined, - unidentifiedDeliveries: Array | undefined - ): Promise { - await Promise.all( - (failoverIdentifiers || []).map(async identifier => { - const conversation = window.ConversationController.get(identifier); - - if ( - conversation && - conversation.get('sealedSender') !== SEALED_SENDER.DISABLED - ) { - window.log.info( - `Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}` - ); - conversation.set({ - sealedSender: SEALED_SENDER.DISABLED, - }); - window.Signal.Data.updateConversation(conversation.attributes); - } - }) - ); - - await Promise.all( - (unidentifiedDeliveries || []).map(async identifier => { - const conversation = window.ConversationController.get(identifier); - - if ( - conversation && - conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN - ) { - if (conversation.get('accessKey')) { - window.log.info( - `Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}` - ); - conversation.set({ - sealedSender: SEALED_SENDER.ENABLED, - }); - } else { - window.log.info( - `Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}` - ); - conversation.set({ - sealedSender: SEALED_SENDER.UNRESTRICTED, - }); - } - window.Signal.Data.updateConversation(conversation.attributes); - } - }) - ); - } - - async getSendOptions(options = {}): Promise { - const sendMetadata = await this.getSendMetadata(options); - - return { - sendMetadata, - }; - } - - async getSendMetadata( - options: { syncMessage?: boolean; disableMeCheck?: boolean } = {} - ): Promise { - const { syncMessage, disableMeCheck } = options; - - // START: this code has an Expiration date of ~2018/11/21 - // We don't want to enable unidentified delivery for send unless it is - // also enabled for our own account. - const myId = window.ConversationController.getOurConversationId(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const me = window.ConversationController.get(myId)!; - if (!disableMeCheck && me.get('sealedSender') === SEALED_SENDER.DISABLED) { - return undefined; - } - // END - - if (!this.isPrivate()) { - assert( - this.contactCollection, - 'getSendMetadata: expected contactCollection to be defined' - ); - const result: SendMetadataType = {}; - await Promise.all( - this.contactCollection.map(async conversation => { - const sendMetadata = - (await conversation.getSendMetadata(options)) || {}; - Object.assign(result, sendMetadata); - }) - ); - return result; - } - - const accessKey = this.get('accessKey'); - const sealedSender = this.get('sealedSender'); - - // We never send sync messages as sealed sender - if (syncMessage && this.isMe()) { - return undefined; - } - - const e164 = this.get('e164'); - const uuid = this.get('uuid'); - - const senderCertificate = await this.getSenderCertificateForDirectConversation(); - - // If we've never fetched user's profile, we default to what we have - if (sealedSender === SEALED_SENDER.UNKNOWN) { - const info = { - accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)), - senderCertificate, - }; - return { - ...(e164 ? { [e164]: info } : {}), - ...(uuid ? { [uuid]: info } : {}), - }; - } - - if (sealedSender === SEALED_SENDER.DISABLED) { - return undefined; - } - - const info = { - accessKey: - accessKey && sealedSender === SEALED_SENDER.ENABLED - ? accessKey - : arrayBufferToBase64(getRandomBytes(16)), - senderCertificate, - }; - - return { - ...(e164 ? { [e164]: info } : {}), - ...(uuid ? { [uuid]: info } : {}), - }; - } - - private getSenderCertificateForDirectConversation(): Promise< - undefined | SerializedCertificateType - > { - if (!this.isPrivate()) { - throw new Error( - 'getSenderCertificateForDirectConversation should only be called for direct conversations' - ); - } - - const phoneNumberSharingMode = parsePhoneNumberSharingMode( - window.storage.get('phoneNumberSharingMode') - ); - - let certificateMode: SenderCertificateMode; - switch (phoneNumberSharingMode) { - case PhoneNumberSharingMode.Everybody: - certificateMode = SenderCertificateMode.WithE164; - break; - case PhoneNumberSharingMode.ContactsOnly: { - const isInSystemContacts = Boolean(this.get('name')); - certificateMode = isInSystemContacts - ? SenderCertificateMode.WithE164 - : SenderCertificateMode.WithoutE164; - break; - } - case PhoneNumberSharingMode.Nobody: - certificateMode = SenderCertificateMode.WithoutE164; - break; - default: - throw missingCaseError(phoneNumberSharingMode); - } - - return senderCertificateService.get(certificateMode); + async getSendOptions( + options: { syncMessage?: boolean } = {} + ): Promise { + return getSendOptions(this.attributes, options); } // Is this someone who is a contact, or are we sharing our profile with them? @@ -4234,100 +3964,18 @@ export class ConversationModel extends window.Backbone } async markRead( - newestUnreadDate: number, - providedOptions: { readAt?: number; sendReadReceipts: boolean } + newestUnreadId: number, + options: { readAt?: number; sendReadReceipts: boolean } = { + sendReadReceipts: true, + } ): Promise { - const options = providedOptions || {}; - window._.defaults(options, { sendReadReceipts: true }); - - const conversationId = this.id; - window.Whisper.Notifications.removeBy({ conversationId }); - - let unreadMessages: - | MessageModelCollectionType - | Array = await this.getUnread(); - const oldUnread = unreadMessages.filter( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - message => message.get('received_at')! <= newestUnreadDate + const unreadCount = await markConversationRead( + this.attributes, + newestUnreadId, + options ); - - let read = await Promise.all( - window._.map(oldUnread, async providedM => { - const m = window.MessageController.register(providedM.id, providedM); - - // Note that this will update the message in the database - await m.markRead(options.readAt); - - return { - senderE164: m.get('source'), - senderUuid: m.get('sourceUuid'), - senderId: window.ConversationController.ensureContactIds({ - e164: m.get('source'), - uuid: m.get('sourceUuid'), - }), - timestamp: m.get('sent_at'), - hasErrors: m.hasErrors(), - }; - }) - ); - - // Some messages we're marking read are local notifications with no sender - read = window._.filter(read, m => Boolean(m.senderId)); - unreadMessages = unreadMessages.filter(m => Boolean(m.isIncoming())); - - const unreadCount = unreadMessages.length - read.length; this.set({ unreadCount }); window.Signal.Data.updateConversation(this.attributes); - - // If a message has errors, we don't want to send anything out about it. - // read syncs - let's wait for a client that really understands the message - // to mark it read. we'll mark our local error read locally, though. - // read receipts - here we can run into infinite loops, where each time the - // conversation is viewed, another error message shows up for the contact - read = read.filter(item => !item.hasErrors); - - if (read.length && options.sendReadReceipts) { - window.log.info(`Sending ${read.length} read syncs`); - // Because syncReadMessages sends to our other devices, and sendReadReceipts goes - // to a contact, we need accessKeys for both. - const { - sendOptions, - } = await window.ConversationController.prepareForSend( - window.ConversationController.getOurConversationId(), - { syncMessage: true } - ); - await this.wrapSend( - window.textsecure.messaging.syncReadMessages(read, sendOptions) - ); - await this.sendReadReceiptsFor(read); - } - } - - async sendReadReceiptsFor(items: Array): Promise { - // Only send read receipts for accepted conversations - if (window.storage.get('read-receipt-setting') && this.getAccepted()) { - window.log.info(`Sending ${items.length} read receipts`); - const convoSendOptions = await this.getSendOptions(); - const receiptsBySender = window._.groupBy(items, 'senderId'); - - await Promise.all( - window._.map(receiptsBySender, async (receipts, senderId) => { - const timestamps = window._.map(receipts, 'timestamp'); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const c = window.ConversationController.get(senderId)!; - await this.wrapSend( - window.textsecure.messaging.sendReadReceipts( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - c.get('e164')!, - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - c.get('uuid')!, - timestamps, - convoSendOptions - ) - ); - }) - ); - } } // This is an expensive operation we use to populate the message request hero row. It @@ -4443,8 +4091,7 @@ export class ConversationModel extends window.Backbone )); } - const sendMetadata = - (await c.getSendMetadata({ disableMeCheck: true })) || {}; + const { sendMetadata = {} } = await c.getSendOptions(); const getInfo = // eslint-disable-next-line @typescript-eslint/no-non-null-assertion sendMetadata[c.get('uuid')!] || sendMetadata[c.get('e164')!] || {}; diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 0d9a5c5256..67a93a62c8 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -28,6 +28,7 @@ import { missingCaseError } from '../util/missingCaseError'; import { ColorType } from '../types/Colors'; import { CallMode } from '../types/Calling'; import { BodyRangesType } from '../types/Util'; +import { ReactionType } from '../types/Reactions'; import { PropsDataType as GroupsV2Props } from '../components/conversation/GroupV2Change'; import { PropsData as TimerNotificationProps, @@ -50,6 +51,7 @@ import { AttachmentType, isImage, isVideo } from '../types/Attachment'; import { MIMEType } from '../types/MIME'; import { LinkPreviewType } from '../types/message/LinkPreviews'; import { ourProfileKeyService } from '../services/ourProfileKey'; +import { markRead, setToExpire } from '../services/MessageUpdater'; /* eslint-disable camelcase */ /* eslint-disable more/no-then */ @@ -1755,7 +1757,7 @@ export class MessageModel extends window.Backbone.Model { } if (this.get('unread')) { - await this.markRead(); + this.set(markRead(this.attributes)); } await this.eraseContents(); @@ -2030,33 +2032,18 @@ export class MessageModel extends window.Backbone.Model { } } - async markRead( - readAt?: number, - options: { skipSave?: boolean } = {} - ): Promise { - const { skipSave } = options; - - this.unset('unread'); - - if (this.get('expireTimer') && !this.get('expirationStartTimestamp')) { - const expirationStartTimestamp = Math.min( - Date.now(), - readAt || Date.now() - ); - this.set({ expirationStartTimestamp }); - } - - window.Whisper.Notifications.removeBy({ messageId: this.id }); - - if (!skipSave) { - window.Signal.Util.queueUpdateMessage(this.attributes); - } + markRead(readAt?: number, options = {}): void { + this.set(markRead(this.attributes, readAt, options)); } isExpiring(): number | null { return this.get('expireTimer') && this.get('expirationStartTimestamp'); } + setToExpire(force = false, options = {}): void { + this.set(setToExpire(this.attributes, { ...options, force })); + } + isExpired(): boolean { return this.msTilExpire() <= 0; } @@ -2076,33 +2063,6 @@ export class MessageModel extends window.Backbone.Model { return msFromNow; } - async setToExpire( - force = false, - options: { skipSave?: boolean } = {} - ): Promise { - const { skipSave } = options || {}; - - if (this.isExpiring() && (force || !this.get('expires_at'))) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const start = this.get('expirationStartTimestamp')!; - const delta = this.get('expireTimer') * 1000; - const expiresAt = start + delta; - - this.set({ expires_at: expiresAt }); - - window.log.info('Set message expiration', { - start, - expiresAt, - sentAt: this.get('sent_at'), - }); - - const id = this.get('id'); - if (id && !skipSave) { - window.Signal.Util.queueUpdateMessage(this.attributes); - } - } - } - getIncomingContact(): ConversationModel | undefined | null { if (!this.isIncoming()) { return null; @@ -4110,7 +4070,7 @@ export class MessageModel extends window.Backbone.Model { this.get('conversationId') ); - let staleReactionFromId: string | undefined; + let reactionToRemove: Partial | undefined; if (reaction.get('remove')) { window.log.info('Removing reaction for message', messageId); @@ -4121,7 +4081,18 @@ export class MessageModel extends window.Backbone.Model { ); this.set({ reactions: newReactions }); - staleReactionFromId = reaction.get('fromId'); + reactionToRemove = { + emoji: reaction.get('emoji'), + targetAuthorUuid: reaction.get('targetAuthorUuid'), + targetTimestamp: reaction.get('targetTimestamp'), + }; + + await window.Signal.Data.removeReactionFromConversation({ + emoji: reaction.get('emoji'), + fromId: reaction.get('fromId'), + targetAuthorUuid: reaction.get('targetAuthorUuid'), + targetTimestamp: reaction.get('targetTimestamp'), + }); } else { window.log.info('Adding reaction for message', messageId); const newReactions = reactions.filter( @@ -4134,17 +4105,30 @@ export class MessageModel extends window.Backbone.Model { re => re.fromId === reaction.get('fromId') ); if (oldReaction) { - staleReactionFromId = oldReaction.fromId; + reactionToRemove = { + emoji: oldReaction.emoji, + targetAuthorUuid: oldReaction.targetAuthorUuid, + targetTimestamp: oldReaction.targetTimestamp, + }; } + await window.Signal.Data.addReaction({ + conversationId: this.get('conversationId'), + emoji: reaction.get('emoji'), + fromId: reaction.get('fromId'), + messageReceivedAt: this.get('received_at'), + targetAuthorUuid: reaction.get('targetAuthorUuid'), + targetTimestamp: reaction.get('targetTimestamp'), + }); + // Only notify for reactions to our own messages if (conversation && this.isOutgoing() && !reaction.get('fromSync')) { conversation.notify(this, reaction); } } - if (staleReactionFromId) { - this.clearNotifications(reaction.get('fromId')); + if (reactionToRemove) { + this.clearNotifications(reactionToRemove); } const newCount = (this.get('reactions') || []).length; @@ -4184,10 +4168,10 @@ export class MessageModel extends window.Backbone.Model { this.getConversation()!.updateLastMessage(); } - clearNotifications(reactionFromId?: string): void { + clearNotifications(reaction: Partial = {}): void { window.Whisper.Notifications.removeBy({ + ...reaction, messageId: this.id, - reactionFromId, }); } } diff --git a/ts/services/MessageUpdater.ts b/ts/services/MessageUpdater.ts new file mode 100644 index 0000000000..849ddfbf4f --- /dev/null +++ b/ts/services/MessageUpdater.ts @@ -0,0 +1,87 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { MessageAttributesType } from '../model-types.d'; + +export function markRead( + messageAttrs: MessageAttributesType, + readAt?: number, + { skipSave = false } = {} +): MessageAttributesType { + const nextMessageAttributes = { + ...messageAttrs, + unread: false, + }; + + const { id: messageId, expireTimer, expirationStartTimestamp } = messageAttrs; + + if (expireTimer && !expirationStartTimestamp) { + nextMessageAttributes.expirationStartTimestamp = Math.min( + Date.now(), + readAt || Date.now() + ); + } + + window.Whisper.Notifications.removeBy({ messageId }); + + if (!skipSave) { + window.Signal.Util.queueUpdateMessage(nextMessageAttributes); + } + + return nextMessageAttributes; +} + +export function getExpiresAt( + messageAttrs: Pick< + MessageAttributesType, + 'expireTimer' | 'expirationStartTimestamp' + > +): number | undefined { + const expireTimerMs = messageAttrs.expireTimer * 1000; + return messageAttrs.expirationStartTimestamp + ? messageAttrs.expirationStartTimestamp + expireTimerMs + : undefined; +} + +export function setToExpire( + messageAttrs: MessageAttributesType, + { force = false, skipSave = false } = {} +): MessageAttributesType { + if (!isExpiring(messageAttrs) || (!force && messageAttrs.expires_at)) { + return messageAttrs; + } + + const expiresAt = getExpiresAt(messageAttrs); + + if (!expiresAt) { + return messageAttrs; + } + + const nextMessageAttributes = { + ...messageAttrs, + expires_at: expiresAt, + }; + + window.log.info('Set message expiration', { + start: messageAttrs.expirationStartTimestamp, + expiresAt, + sentAt: messageAttrs.sent_at, + }); + + if (messageAttrs.id && !skipSave) { + window.Signal.Util.queueUpdateMessage(nextMessageAttributes); + } + + return nextMessageAttributes; +} + +function isExpiring( + messageAttrs: Pick< + MessageAttributesType, + 'expireTimer' | 'expirationStartTimestamp' + > +): boolean { + return Boolean( + messageAttrs.expireTimer && messageAttrs.expirationStartTimestamp + ); +} diff --git a/ts/sql/Client.ts b/ts/sql/Client.ts index 675bf629f5..f4a0a8d3f5 100644 --- a/ts/sql/Client.ts +++ b/ts/sql/Client.ts @@ -28,6 +28,7 @@ import { CURRENT_SCHEMA_VERSION } from '../../js/modules/types/message'; import { createBatcher } from '../util/batcher'; import { assert } from '../util/assert'; import { cleanDataForIpc } from './cleanDataForIpc'; +import { ReactionType } from '../types/Reactions'; import { ConversationModelCollectionType, @@ -166,7 +167,11 @@ const dataInterface: ClientInterface = { saveMessages, removeMessage, removeMessages, - getUnreadByConversation, + getUnreadByConversationAndMarkRead, + getUnreadReactionsAndMarkRead, + markReactionAsRead, + removeReactionFromConversation, + addReaction, getMessageBySender, getMessageById, @@ -1041,15 +1046,43 @@ async function getMessageBySender( return new Message(messages[0]); } -async function getUnreadByConversation( +async function getUnreadByConversationAndMarkRead( conversationId: string, - { - MessageCollection, - }: { MessageCollection: typeof MessageModelCollectionType } + newestUnreadId: number, + readAt?: number ) { - const messages = await channels.getUnreadByConversation(conversationId); + return channels.getUnreadByConversationAndMarkRead( + conversationId, + newestUnreadId, + readAt + ); +} - return new MessageCollection(messages); +async function getUnreadReactionsAndMarkRead( + conversationId: string, + newestUnreadId: number +) { + return channels.getUnreadReactionsAndMarkRead(conversationId, newestUnreadId); +} + +async function markReactionAsRead( + targetAuthorUuid: string, + targetTimestamp: number +) { + return channels.markReactionAsRead(targetAuthorUuid, targetTimestamp); +} + +async function removeReactionFromConversation(reaction: { + emoji: string; + fromId: string; + targetAuthorUuid: string; + targetTimestamp: number; +}) { + return channels.removeReactionFromConversation(reaction); +} + +async function addReaction(reactionObj: ReactionType) { + return channels.addReaction(reactionObj); } function handleMessageJSON(messages: Array) { diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index b75235b908..7b39ee8949 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -4,7 +4,6 @@ /* eslint-disable @typescript-eslint/ban-types */ /* eslint-disable camelcase */ /* eslint-disable @typescript-eslint/no-explicit-any */ - import { ConversationAttributesType, ConversationModelCollectionType, @@ -14,6 +13,7 @@ import { import { MessageModel } from '../models/messages'; import { ConversationModel } from '../models/conversations'; import { StoredJob } from '../jobs/types'; +import { ReactionType } from '../types/Reactions'; export type AttachmentDownloadJobType = { id: string; @@ -343,9 +343,32 @@ export type ServerInterface = DataInterface & { getNextTapToViewMessageToAgeOut: () => Promise; getOutgoingWithoutExpiresAt: () => Promise>; getTapToViewMessagesNeedingErase: () => Promise>; - getUnreadByConversation: ( - conversationId: string - ) => Promise>; + getUnreadByConversationAndMarkRead: ( + conversationId: string, + newestUnreadId: number, + readAt?: number + ) => Promise< + Array< + Pick + > + >; + getUnreadReactionsAndMarkRead: ( + conversationId: string, + newestUnreadId: number + ) => Promise< + Array> + >; + markReactionAsRead: ( + targetAuthorUuid: string, + targetTimestamp: number + ) => Promise; + removeReactionFromConversation: (reaction: { + emoji: string; + fromId: string; + targetAuthorUuid: string; + targetTimestamp: number; + }) => Promise; + addReaction: (reactionObj: ReactionType) => Promise; removeConversation: (id: Array | string) => Promise; removeMessage: (id: string) => Promise; removeMessages: (ids: Array) => Promise; @@ -463,10 +486,32 @@ export type ClientInterface = DataInterface & { getTapToViewMessagesNeedingErase: (options: { MessageCollection: typeof MessageModelCollectionType; }) => Promise; - getUnreadByConversation: ( + getUnreadByConversationAndMarkRead: ( conversationId: string, - options: { MessageCollection: typeof MessageModelCollectionType } - ) => Promise; + newestUnreadId: number, + readAt?: number + ) => Promise< + Array< + Pick + > + >; + getUnreadReactionsAndMarkRead: ( + conversationId: string, + newestUnreadId: number + ) => Promise< + Array> + >; + markReactionAsRead: ( + targetAuthorUuid: string, + targetTimestamp: number + ) => Promise; + removeReactionFromConversation: (reaction: { + emoji: string; + fromId: string; + targetAuthorUuid: string; + targetTimestamp: number; + }) => Promise; + addReaction: (reactionObj: ReactionType) => Promise; removeConversation: ( id: string, options: { Conversation: typeof ConversationModel } diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 906adfe7ce..c8d2a00e05 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -28,13 +28,14 @@ import { omit, } from 'lodash'; -import { assert } from '../util/assert'; -import { isNormalNumber } from '../util/isNormalNumber'; -import { combineNames } from '../util/combineNames'; -import { isNotNil } from '../util/isNotNil'; - import { GroupV2MemberType } from '../model-types.d'; +import { ReactionType } from '../types/Reactions'; import { StoredJob } from '../jobs/types'; +import { assert } from '../util/assert'; +import { combineNames } from '../util/combineNames'; +import { getExpiresAt } from '../services/MessageUpdater'; +import { isNormalNumber } from '../util/isNormalNumber'; +import { isNotNil } from '../util/isNotNil'; import { AttachmentDownloadJobType, @@ -156,7 +157,11 @@ const dataInterface: ServerInterface = { saveMessages, removeMessage, removeMessages, - getUnreadByConversation, + getUnreadByConversationAndMarkRead, + getUnreadReactionsAndMarkRead, + markReactionAsRead, + addReaction, + removeReactionFromConversation, getMessageBySender, getMessageById, _getAllMessages, @@ -1714,6 +1719,39 @@ function updateToSchemaVersion28(currentVersion: number, db: Database) { })(); } +function updateToSchemaVersion29(currentVersion: number, db: Database) { + if (currentVersion >= 29) { + return; + } + + db.transaction(() => { + db.exec(` + CREATE TABLE reactions( + conversationId STRING, + emoji STRING, + fromId STRING, + messageReceivedAt INTEGER, + targetAuthorUuid STRING, + targetTimestamp INTEGER, + unread INTEGER + ); + + CREATE INDEX reactions_unread ON reactions ( + unread, + conversationId + ); + + CREATE INDEX reaction_identifier ON reactions ( + emoji, + targetAuthorUuid, + targetTimestamp + ); + `); + + db.pragma('user_version = 29'); + })(); +} + const SCHEMA_VERSIONS = [ updateToSchemaVersion1, updateToSchemaVersion2, @@ -1743,6 +1781,7 @@ const SCHEMA_VERSIONS = [ updateToSchemaVersion26, updateToSchemaVersion27, updateToSchemaVersion28, + updateToSchemaVersion29, ]; function updateSchema(db: Database): void { @@ -2961,25 +3000,298 @@ async function getMessageBySender({ return rows.map(row => jsonToObject(row.json)); } -async function getUnreadByConversation( - conversationId: string -): Promise> { +function getExpireData( + messageExpireTimer: number, + readAt?: number +): { + expirationStartTimestamp: number; + expiresAt: number; +} { + const expirationStartTimestamp = Math.min(Date.now(), readAt || Date.now()); + const expiresAt = getExpiresAt({ + expireTimer: messageExpireTimer, + expirationStartTimestamp, + }); + + // We are guaranteeing an expirationStartTimestamp above so this should + // definitely return a number. + if (!expiresAt || typeof expiresAt !== 'number') { + assert(false, 'Expected expiresAt to be a number'); + } + + return { + expirationStartTimestamp, + expiresAt, + }; +} + +function updateExpirationTimers( + messageExpireTimer: number, + messagesWithExpireTimer: Set, + readAt?: number +) { + const { expirationStartTimestamp, expiresAt } = getExpireData( + messageExpireTimer, + readAt + ); + const db = getInstance(); - const rows: JSONRows = db - .prepare( - ` - SELECT json FROM messages WHERE - unread = $unread AND - conversationId = $conversationId - ORDER BY received_at DESC, sent_at DESC; - ` - ) - .all({ - unread: 1, - conversationId, + const stmt = db.prepare( + ` + UPDATE messages + SET + unread = 0, + expires_at = $expiresAt, + expirationStartTimestamp = $expirationStartTimestamp, + json = json_patch(json, $jsonPatch) + WHERE + id = $id + ` + ); + messagesWithExpireTimer.forEach(id => { + stmt.run({ + id, + expirationStartTimestamp, + expiresAt, + jsonPatch: JSON.stringify({ + expirationStartTimestamp, + expires_at: expiresAt, + unread: 0, + }), + }); + }); +} + +async function getUnreadByConversationAndMarkRead( + conversationId: string, + newestUnreadId: number, + readAt?: number +): Promise< + Array> +> { + const db = getInstance(); + return db.transaction(() => { + const rows = db + .prepare( + ` + SELECT id, expireTimer, expirationStartTimestamp, json + FROM messages WHERE + unread = $unread AND + conversationId = $conversationId AND + received_at <= $newestUnreadId + ORDER BY received_at DESC, sent_at DESC; + ` + ) + .all({ + unread: 1, + conversationId, + newestUnreadId, + }); + + let messageExpireTimer: number | undefined; + const messagesWithExpireTimer: Set = new Set(); + const messagesToMarkRead: Array = []; + + rows.forEach(row => { + if (row.expireTimer && !row.expirationStartTimestamp) { + messageExpireTimer = row.expireTimer; + messagesWithExpireTimer.add(row.id); + } + messagesToMarkRead.push(row.id); }); - return rows.map(row => jsonToObject(row.json)); + if (messagesToMarkRead.length) { + const stmt = db.prepare( + ` + UPDATE messages + SET + unread = 0, + json = json_patch(json, $jsonPatch) + WHERE + id = $id; + ` + ); + + messagesToMarkRead.forEach(id => + stmt.run({ + id, + jsonPatch: JSON.stringify({ unread: 0 }), + }) + ); + } + + if (messageExpireTimer && messagesWithExpireTimer.size) { + // We use the messageExpireTimer set above from whichever row we have + // in the database. Since this is the same conversation the expireTimer + // should be the same for all messages within it. + updateExpirationTimers( + messageExpireTimer, + messagesWithExpireTimer, + readAt + ); + } + + return rows.map(row => { + const json = jsonToObject(row.json); + const expireAttrs = {}; + if (messageExpireTimer && messagesWithExpireTimer.has(row.id)) { + const { expirationStartTimestamp, expiresAt } = getExpireData( + messageExpireTimer, + readAt + ); + Object.assign(expireAttrs, { + expirationStartTimestamp, + expires_at: expiresAt, + }); + } + + return { + unread: false, + ...pick(json, ['id', 'sent_at', 'source', 'sourceUuid', 'type']), + ...expireAttrs, + }; + }); + })(); +} + +async function getUnreadReactionsAndMarkRead( + conversationId: string, + newestUnreadId: number +): Promise>> { + const db = getInstance(); + return db.transaction(() => { + const unreadMessages = db + .prepare( + ` + SELECT targetAuthorUuid, targetTimestamp + FROM reactions WHERE + unread = 1 AND + conversationId = $conversationId AND + messageReceivedAt <= $newestUnreadId; + ` + ) + .all({ + conversationId, + newestUnreadId, + }); + + db.exec(` + UPDATE reactions SET + unread = 0 WHERE + $conversationId = conversationId AND + $messageReceivedAt <= messageReceivedAt; + `); + + return unreadMessages; + })(); +} + +async function markReactionAsRead( + targetAuthorUuid: string, + targetTimestamp: number +): Promise { + const db = getInstance(); + return db.transaction(() => { + const readReaction = db + .prepare( + ` + SELECT * + FROM reactions + WHERE + targetAuthorUuid = $targetAuthorUuid AND + targetTimestamp = $targetTimestamp AND + unread = 1 + ORDER BY rowId DESC + LIMIT 1; + ` + ) + .get({ + targetAuthorUuid, + targetTimestamp, + }); + + db.prepare( + ` + UPDATE reactions SET + unread = 0 WHERE + $targetAuthorUuid = targetAuthorUuid AND + $targetTimestamp = targetTimestamp; + ` + ).run({ + targetAuthorUuid, + targetTimestamp, + }); + + return readReaction; + })(); +} + +async function addReaction({ + conversationId, + emoji, + fromId, + messageReceivedAt, + targetAuthorUuid, + targetTimestamp, +}: ReactionType): Promise { + const db = getInstance(); + await db + .prepare( + `INSERT INTO reactions ( + conversationId, + emoji, + fromId, + messageReceivedAt, + targetAuthorUuid, + targetTimestamp, + unread + ) VALUES ( + $conversationId, + $emoji, + $fromId, + $messageReceivedAt, + $targetAuthorUuid, + $targetTimestamp, + $unread + );` + ) + .run({ + conversationId, + emoji, + fromId, + messageReceivedAt, + targetAuthorUuid, + targetTimestamp, + unread: 1, + }); +} + +async function removeReactionFromConversation({ + emoji, + fromId, + targetAuthorUuid, + targetTimestamp, +}: { + emoji: string; + fromId: string; + targetAuthorUuid: string; + targetTimestamp: number; +}): Promise { + const db = getInstance(); + await db + .prepare( + `DELETE FROM reactions WHERE + emoji = $emoji AND + fromId = $fromId AND + targetAuthorUuid = $targetAuthorUuid AND + targetTimestamp = $targetTimestamp;` + ) + .run({ + emoji, + fromId, + targetAuthorUuid, + targetTimestamp, + }); } async function getOlderMessagesByConversation( diff --git a/ts/types/Reactions.ts b/ts/types/Reactions.ts new file mode 100644 index 0000000000..67996c9e15 --- /dev/null +++ b/ts/types/Reactions.ts @@ -0,0 +1,11 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export type ReactionType = Readonly<{ + conversationId: string; + emoji: string; + fromId: string; + messageReceivedAt: number; + targetAuthorUuid: string; + targetTimestamp: number; +}>; diff --git a/ts/util/getConversationMembers.ts b/ts/util/getConversationMembers.ts new file mode 100644 index 0000000000..b36e796da8 --- /dev/null +++ b/ts/util/getConversationMembers.ts @@ -0,0 +1,57 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { compact } from 'lodash'; +import { ConversationAttributesType } from '../model-types.d'; +import { isDirectConversation } from './whatTypeOfConversation'; + +export function getConversationMembers( + conversationAttrs: ConversationAttributesType, + options: { includePendingMembers?: boolean } = {} +): Array { + if (isDirectConversation(conversationAttrs)) { + return [conversationAttrs]; + } + + if (conversationAttrs.membersV2) { + const { includePendingMembers } = options; + const members: Array<{ conversationId: string }> = includePendingMembers + ? [ + ...(conversationAttrs.membersV2 || []), + ...(conversationAttrs.pendingMembersV2 || []), + ] + : conversationAttrs.membersV2 || []; + + return compact( + members.map(member => { + const conversation = window.ConversationController.get( + member.conversationId + ); + + // In groups we won't sent to contacts we believe are unregistered + if (conversation && conversation.isUnregistered()) { + return null; + } + + return conversation?.attributes; + }) + ); + } + + if (conversationAttrs.members) { + return compact( + conversationAttrs.members.map(id => { + const conversation = window.ConversationController.get(id); + + // In groups we won't send to contacts we believe are unregistered + if (conversation && conversation.isUnregistered()) { + return null; + } + + return conversation?.attributes; + }) + ); + } + + return []; +} diff --git a/ts/util/getSendOptions.ts b/ts/util/getSendOptions.ts new file mode 100644 index 0000000000..a9596b0e22 --- /dev/null +++ b/ts/util/getSendOptions.ts @@ -0,0 +1,135 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ConversationAttributesType } from '../model-types.d'; +import { SendMetadataType, SendOptionsType } from '../textsecure/SendMessage'; +import { arrayBufferToBase64, getRandomBytes } from '../Crypto'; +import { getConversationMembers } from './getConversationMembers'; +import { isDirectConversation, isMe } from './whatTypeOfConversation'; +import { missingCaseError } from './missingCaseError'; +import { senderCertificateService } from '../services/senderCertificate'; +import { + PhoneNumberSharingMode, + parsePhoneNumberSharingMode, +} from './phoneNumberSharingMode'; +import { + SenderCertificateMode, + SerializedCertificateType, +} from '../textsecure/OutgoingMessage'; + +const SEALED_SENDER = { + UNKNOWN: 0, + ENABLED: 1, + DISABLED: 2, + UNRESTRICTED: 3, +}; + +export async function getSendOptions( + conversationAttrs: ConversationAttributesType, + options: { syncMessage?: boolean } = {} +): Promise { + const { syncMessage } = options; + + if (!isDirectConversation(conversationAttrs)) { + const contactCollection = getConversationMembers(conversationAttrs); + const sendMetadata: SendMetadataType = {}; + await Promise.all( + contactCollection.map(async contactAttrs => { + const conversation = window.ConversationController.get(contactAttrs.id); + if (!conversation) { + return; + } + const { + sendMetadata: conversationSendMetadata, + } = await conversation.getSendOptions(options); + Object.assign(sendMetadata, conversationSendMetadata || {}); + }) + ); + return { sendMetadata }; + } + + const { accessKey, sealedSender } = conversationAttrs; + + // We never send sync messages as sealed sender + if (syncMessage && isMe(conversationAttrs)) { + return { + sendMetadata: undefined, + }; + } + + const { e164, uuid } = conversationAttrs; + + const senderCertificate = await getSenderCertificateForDirectConversation( + conversationAttrs + ); + + // If we've never fetched user's profile, we default to what we have + if (sealedSender === SEALED_SENDER.UNKNOWN) { + const identifierData = { + accessKey: accessKey || arrayBufferToBase64(getRandomBytes(16)), + senderCertificate, + }; + return { + sendMetadata: { + ...(e164 ? { [e164]: identifierData } : {}), + ...(uuid ? { [uuid]: identifierData } : {}), + }, + }; + } + + if (sealedSender === SEALED_SENDER.DISABLED) { + return { + sendMetadata: undefined, + }; + } + + const identifierData = { + accessKey: + accessKey && sealedSender === SEALED_SENDER.ENABLED + ? accessKey + : arrayBufferToBase64(getRandomBytes(16)), + senderCertificate, + }; + + return { + sendMetadata: { + ...(e164 ? { [e164]: identifierData } : {}), + ...(uuid ? { [uuid]: identifierData } : {}), + }, + }; +} + +function getSenderCertificateForDirectConversation( + conversationAttrs: ConversationAttributesType +): Promise { + if (!isDirectConversation(conversationAttrs)) { + throw new Error( + 'getSenderCertificateForDirectConversation should only be called for direct conversations' + ); + } + + const phoneNumberSharingMode = parsePhoneNumberSharingMode( + window.storage.get('phoneNumberSharingMode') + ); + + let certificateMode: SenderCertificateMode; + switch (phoneNumberSharingMode) { + case PhoneNumberSharingMode.Everybody: + certificateMode = SenderCertificateMode.WithE164; + break; + case PhoneNumberSharingMode.ContactsOnly: { + const isInSystemContacts = Boolean(conversationAttrs.name); + certificateMode = isInSystemContacts + ? SenderCertificateMode.WithE164 + : SenderCertificateMode.WithoutE164; + break; + } + case PhoneNumberSharingMode.Nobody: + certificateMode = SenderCertificateMode.WithoutE164; + break; + default: + throw missingCaseError(phoneNumberSharingMode); + } + + return senderCertificateService.get(certificateMode); +} diff --git a/ts/util/handleMessageSend.ts b/ts/util/handleMessageSend.ts new file mode 100644 index 0000000000..939a5cd973 --- /dev/null +++ b/ts/util/handleMessageSend.ts @@ -0,0 +1,86 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { CallbackResultType } from '../textsecure/SendMessage'; + +const SEALED_SENDER = { + UNKNOWN: 0, + ENABLED: 1, + DISABLED: 2, + UNRESTRICTED: 3, +}; + +export async function handleMessageSend( + promise: Promise +): Promise { + try { + const result = await promise; + if (result) { + await handleMessageSendResult( + result.failoverIdentifiers, + result.unidentifiedDeliveries + ); + } + return result; + } catch (err) { + if (err) { + await handleMessageSendResult( + err.failoverIdentifiers, + err.unidentifiedDeliveries + ); + } + throw err; + } +} + +async function handleMessageSendResult( + failoverIdentifiers: Array | undefined, + unidentifiedDeliveries: Array | undefined +): Promise { + await Promise.all( + (failoverIdentifiers || []).map(async identifier => { + const conversation = window.ConversationController.get(identifier); + + if ( + conversation && + conversation.get('sealedSender') !== SEALED_SENDER.DISABLED + ) { + window.log.info( + `Setting sealedSender to DISABLED for conversation ${conversation.idForLogging()}` + ); + conversation.set({ + sealedSender: SEALED_SENDER.DISABLED, + }); + window.Signal.Data.updateConversation(conversation.attributes); + } + }) + ); + + await Promise.all( + (unidentifiedDeliveries || []).map(async identifier => { + const conversation = window.ConversationController.get(identifier); + + if ( + conversation && + conversation.get('sealedSender') === SEALED_SENDER.UNKNOWN + ) { + if (conversation.get('accessKey')) { + window.log.info( + `Setting sealedSender to ENABLED for conversation ${conversation.idForLogging()}` + ); + conversation.set({ + sealedSender: SEALED_SENDER.ENABLED, + }); + } else { + window.log.info( + `Setting sealedSender to UNRESTRICTED for conversation ${conversation.idForLogging()}` + ); + conversation.set({ + sealedSender: SEALED_SENDER.UNRESTRICTED, + }); + } + window.Signal.Data.updateConversation(conversation.attributes); + } + }) + ); +} diff --git a/ts/util/isConversationAccepted.ts b/ts/util/isConversationAccepted.ts new file mode 100644 index 0000000000..268f1dfc5b --- /dev/null +++ b/ts/util/isConversationAccepted.ts @@ -0,0 +1,84 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ConversationAttributesType } from '../model-types.d'; +import { isDirectConversation, isMe } from './whatTypeOfConversation'; + +/** + * Determine if this conversation should be considered "accepted" in terms + * of message requests + */ +export function isConversationAccepted( + conversationAttrs: ConversationAttributesType +): boolean { + const messageRequestsEnabled = window.Signal.RemoteConfig.isEnabled( + 'desktop.messageRequests' + ); + + if (!messageRequestsEnabled) { + return true; + } + + if (isMe(conversationAttrs)) { + return true; + } + + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + + const { messageRequestResponseType } = conversationAttrs; + if (messageRequestResponseType === messageRequestEnum.ACCEPT) { + return true; + } + + const { sentMessageCount } = conversationAttrs; + + const hasSentMessages = sentMessageCount > 0; + const hasMessagesBeforeMessageRequests = + (conversationAttrs.messageCountBeforeMessageRequests || 0) > 0; + const hasNoMessages = (conversationAttrs.messageCount || 0) === 0; + + const isEmptyPrivateConvo = + hasNoMessages && isDirectConversation(conversationAttrs); + const isEmptyWhitelistedGroup = + hasNoMessages && + !isDirectConversation(conversationAttrs) && + conversationAttrs.profileSharing; + + return ( + isFromOrAddedByTrustedContact(conversationAttrs) || + hasSentMessages || + hasMessagesBeforeMessageRequests || + // an empty group is the scenario where we need to rely on + // whether the profile has already been shared or not + isEmptyPrivateConvo || + isEmptyWhitelistedGroup + ); +} + +// Is this someone who is a contact, or are we sharing our profile with them? +// Or is the person who added us to this group a contact or are we sharing profile +// with them? +function isFromOrAddedByTrustedContact( + conversationAttrs: ConversationAttributesType +): boolean { + if (isDirectConversation(conversationAttrs)) { + return Boolean(conversationAttrs.name || conversationAttrs.profileSharing); + } + + const { addedBy } = conversationAttrs; + if (!addedBy) { + return false; + } + + const conversation = window.ConversationController.get(addedBy); + if (!conversation) { + return false; + } + + return Boolean( + isMe(conversation.attributes) || + conversation.get('name') || + conversation.get('profileSharing') + ); +} diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts new file mode 100644 index 0000000000..668011b53e --- /dev/null +++ b/ts/util/markConversationRead.ts @@ -0,0 +1,111 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ConversationAttributesType } from '../model-types.d'; +import { handleMessageSend } from './handleMessageSend'; +import { sendReadReceiptsFor } from './sendReadReceiptsFor'; + +export async function markConversationRead( + conversationAttrs: ConversationAttributesType, + newestUnreadId: number, + options: { readAt?: number; sendReadReceipts: boolean } = { + sendReadReceipts: true, + } +): Promise { + const { id: conversationId } = conversationAttrs; + window.Whisper.Notifications.removeBy({ conversationId }); + + const [unreadMessages, unreadReactions] = await Promise.all([ + window.Signal.Data.getUnreadByConversationAndMarkRead( + conversationId, + newestUnreadId, + options.readAt + ), + window.Signal.Data.getUnreadReactionsAndMarkRead( + conversationId, + newestUnreadId + ), + ]); + + const unreadReactionSyncData = new Map< + string, + { + senderUuid?: string; + senderE164?: string; + timestamp: number; + } + >(); + unreadReactions.forEach(reaction => { + const targetKey = `${reaction.targetAuthorUuid}/${reaction.targetTimestamp}`; + if (unreadReactionSyncData.has(targetKey)) { + return; + } + unreadReactionSyncData.set(targetKey, { + senderE164: undefined, + senderUuid: reaction.targetAuthorUuid, + timestamp: reaction.targetTimestamp, + }); + }); + + const allReadMessagesSync = unreadMessages.map(messageSyncData => { + const message = window.MessageController.getById(messageSyncData.id); + // we update the in-memory MessageModel with the fresh database call data + if (message) { + message.set(messageSyncData); + } + + return { + senderE164: messageSyncData.source, + senderUuid: messageSyncData.sourceUuid, + senderId: window.ConversationController.ensureContactIds({ + e164: messageSyncData.source, + uuid: messageSyncData.sourceUuid, + }), + timestamp: messageSyncData.sent_at, + hasErrors: message ? message.hasErrors() : false, + }; + }); + + // Some messages we're marking read are local notifications with no sender + const messagesWithSenderId = allReadMessagesSync.filter(syncMessage => + Boolean(syncMessage.senderId) + ); + const incomingUnreadMessages = unreadMessages.filter( + message => message.type === 'incoming' + ); + const unreadCount = + incomingUnreadMessages.length - messagesWithSenderId.length; + + // If a message has errors, we don't want to send anything out about it. + // read syncs - let's wait for a client that really understands the message + // to mark it read. we'll mark our local error read locally, though. + // read receipts - here we can run into infinite loops, where each time the + // conversation is viewed, another error message shows up for the contact + const unreadMessagesSyncData = messagesWithSenderId.filter( + item => !item.hasErrors + ); + + const readSyncs = [ + ...unreadMessagesSyncData, + ...Array.from(unreadReactionSyncData.values()), + ]; + + if (readSyncs.length && options.sendReadReceipts) { + window.log.info(`Sending ${readSyncs.length} read syncs`); + // Because syncReadMessages sends to our other devices, and sendReadReceipts goes + // to a contact, we need accessKeys for both. + const { + sendOptions, + } = await window.ConversationController.prepareForSend( + window.ConversationController.getOurConversationId(), + { syncMessage: true } + ); + + await handleMessageSend( + window.textsecure.messaging.syncReadMessages(readSyncs, sendOptions) + ); + await sendReadReceiptsFor(conversationAttrs, unreadMessagesSyncData); + } + + return unreadCount; +} diff --git a/ts/util/sendReadReceiptsFor.ts b/ts/util/sendReadReceiptsFor.ts new file mode 100644 index 0000000000..53669f3679 --- /dev/null +++ b/ts/util/sendReadReceiptsFor.ts @@ -0,0 +1,43 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { groupBy, map } from 'lodash'; +import { ConversationAttributesType } from '../model-types.d'; +import { getSendOptions } from './getSendOptions'; +import { handleMessageSend } from './handleMessageSend'; +import { isConversationAccepted } from './isConversationAccepted'; + +export async function sendReadReceiptsFor( + conversationAttrs: ConversationAttributesType, + items: Array +): Promise { + // Only send read receipts for accepted conversations + if ( + window.storage.get('read-receipt-setting') && + isConversationAccepted(conversationAttrs) + ) { + window.log.info(`Sending ${items.length} read receipts`); + const convoSendOptions = await getSendOptions(conversationAttrs); + const receiptsBySender = groupBy(items, 'senderId'); + + await Promise.all( + map(receiptsBySender, async (receipts, senderId) => { + const timestamps = map(receipts, 'timestamp'); + const conversation = window.ConversationController.get(senderId); + + if (conversation) { + await handleMessageSend( + window.textsecure.messaging.sendReadReceipts( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + conversation.get('e164')!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + conversation.get('uuid')!, + timestamps, + convoSendOptions + ) + ); + } + }) + ); + } +} diff --git a/ts/util/whatTypeOfConversation.ts b/ts/util/whatTypeOfConversation.ts new file mode 100644 index 0000000000..bcd724971d --- /dev/null +++ b/ts/util/whatTypeOfConversation.ts @@ -0,0 +1,17 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ConversationAttributesType } from '../model-types.d'; + +export function isDirectConversation( + conversationAttrs: ConversationAttributesType +): boolean { + return conversationAttrs.type === 'private'; +} + +export function isMe(conversationAttrs: ConversationAttributesType): boolean { + const { e164, uuid } = conversationAttrs; + const ourNumber = window.textsecure.storage.user.getNumber(); + const ourUuid = window.textsecure.storage.user.getUuid(); + return Boolean((e164 && e164 === ourNumber) || (uuid && uuid === ourUuid)); +} diff --git a/ts/window.d.ts b/ts/window.d.ts index 8d39e6e8c2..3a53388c16 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -587,9 +587,9 @@ export type DCodeIOType = { }; type MessageControllerType = { - getById: (id: string) => MessageModel | undefined; findBySender: (sender: string) => MessageModel | null; findBySentAt: (sentAt: number) => MessageModel | null; + getById: (id: string) => MessageModel | undefined; register: (id: string, model: MessageModel) => MessageModel; unregister: (id: string) => void; };