diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f2347b3a1..f395b9fe7 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1222,6 +1222,34 @@ "message": "$sender$ changed their phone number", "description": "Shown in timeline when a member of a conversation changes their phone number" }, + "icu:ConversationMerge--notification": { + "messageformat": "{obsoleteConversationTitle} and {conversationTitle} are the same account. Your message history for both chats are here.", + "description": "Shown when we've discovered that two local conversations are the same remote account in an unusual way" + }, + "icu:ConversationMerge--notification--no-e164": { + "messageformat": "Your message history with {conversationTitle} and another chat that belonged to them has been merged.", + "description": "Shown when we've discovered that two local conversations are the same remote account in an unusual way, but we don't have the phone number for the old conversation" + }, + "icu:ConversationMerge--learn-more": { + "messageformat": "Learn More", + "description": "Shown on a button below a 'conversations were merged' timeline notification" + }, + "icu:ConversationMerge--explainer-dialog--line-1": { + "messageformat": "After messaging with {obsoleteConversationTitle} you learned this number belongs to {conversationTitle}. Their phone number is private.", + "description": "Contents of a dialog shown after clicking 'learn more' button on a conversation merge event." + }, + "icu:ConversationMerge--explainer-dialog--line-2": { + "messageformat": "Your message history for both conversations have been merged here.", + "description": "Contents of a dialog shown after clicking 'learn more' button on a conversation merge event." + }, + "icu:PhoneNumberDiscovery--notification--withSharedGroup": { + "messageformat": "{phoneNumber} belongs to {conversationTitle}. You're both members of {sharedGroup}.", + "description": "Shown when we've discovered a phone number for a contact you've been communicating with." + }, + "icu:PhoneNumberDiscovery--notification--noSharedGroup": { + "messageformat": "{phoneNumber} belongs to {conversationTitle}", + "description": "Shown when we've discovered a phone number for a contact you've been communicating with, but you have no shared groups." + }, "quoteThumbnailAlt": { "message": "Thumbnail of image from quoted message", "description": "Used in alt tag of thumbnail images inside of an embedded message quote" diff --git a/images/merged-chat.svg b/images/merged-chat.svg new file mode 100644 index 000000000..06dab7955 --- /dev/null +++ b/images/merged-chat.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/stylesheets/components/ConversationMergeNotification.scss b/stylesheets/components/ConversationMergeNotification.scss new file mode 100644 index 000000000..90a1f11c5 --- /dev/null +++ b/stylesheets/components/ConversationMergeNotification.scss @@ -0,0 +1,29 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-conversation-merge-notification { + &__dialog__image { + text-align: center; + + margin-left: auto; + margin-right: auto; + } + + &__dialog__text-1 { + text-align: center; + + margin-top: 32px; + + margin-left: 5px; + margin-right: 5px; + } + &__dialog__text-2 { + text-align: center; + + margin-top: 24px; + margin-bottom: 37px; + + margin-left: 5px; + margin-right: 5px; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index c72c59138..51a6771f1 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -61,6 +61,7 @@ @import './components/ConversationDetails.scss'; @import './components/ConversationHeader.scss'; @import './components/ConversationHero.scss'; +@import './components/ConversationMergeNotification.scss'; @import './components/ConversationView.scss'; @import './components/CustomColorEditor.scss'; @import './components/CustomizingPreferredReactionsModal.scss'; diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index e028a87c2..36dc7f98a 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -8,6 +8,7 @@ import type { ConversationModelCollectionType, ConversationAttributesType, ConversationAttributesTypeType, + ConversationRenderInfoType, } from './model-types.d'; import type { ConversationModel } from './models/conversations'; import type { MessageModel } from './models/messages'; @@ -22,13 +23,12 @@ import { assertDev, strictAssert } from './util/assert'; import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge'; import { UUID, isValidUuid, UUIDKind } from './types/UUID'; -import { Address } from './types/Address'; -import { QualifiedAddress } from './types/QualifiedAddress'; import { sleep } from './util/sleep'; import { isNotNil } from './util/isNotNil'; import { MINUTE, SECOND } from './util/durations'; import { getUuidsForE164s } from './util/getUuidsForE164s'; import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation'; +import { getTitleNoDefault } from './util/getTitle'; type ConvoMatchType = | { @@ -48,7 +48,8 @@ function applyChangeToConversation( conversation: ConversationModel, suggestedChange: Partial< Pick - > + >, + disableDiscoveryNotification?: boolean ) { const change = { ...suggestedChange }; @@ -82,7 +83,9 @@ function applyChangeToConversation( conversation.updateUuid(change.uuid); } if (hasOwnProperty.call(change, 'e164')) { - conversation.updateE164(change.e164); + conversation.updateE164(change.e164, { + disableDiscoveryNotification, + }); } if (hasOwnProperty.call(change, 'pni')) { conversation.updatePni(change.pni); @@ -91,23 +94,23 @@ function applyChangeToConversation( // Note: we don't do a conversation.set here, because change is limited to these fields } -async function safeCombineConversations({ - logId, - oldConversation, - newConversation, -}: { - logId: string; - oldConversation: ConversationModel; - newConversation: ConversationModel; -}) { +export type CombineConversationsParams = Readonly<{ + current: ConversationModel; + fromPniSignature?: boolean; + obsolete: ConversationModel; + obsoleteTitleInfo?: ConversationRenderInfoType; +}>; +export type SafeCombineConversationsParams = Readonly<{ logId: string }> & + CombineConversationsParams; + +async function safeCombineConversations( + options: SafeCombineConversationsParams +) { try { - await window.ConversationController.combineConversations( - newConversation, - oldConversation - ); + await window.ConversationController.combineConversations(options); } catch (error) { log.warn( - `${logId}: error combining contacts: ${Errors.toLogFormat(error)}` + `${options.logId}: error combining contacts: ${Errors.toLogFormat(error)}` ); } } @@ -373,7 +376,7 @@ export class ConversationController { return undefined; } - const conversation = this.maybeMergeContacts({ + const { conversation } = this.maybeMergeContacts({ aci, e164, pni, @@ -454,19 +457,19 @@ export class ConversationController { e164, pni: providedPni, reason, + fromPniSignature, mergeOldAndNew = safeCombineConversations, }: { aci?: string; e164?: string; pni?: string; reason: string; - recursionCount?: number; - mergeOldAndNew?: (options: { - logId: string; - oldConversation: ConversationModel; - newConversation: ConversationModel; - }) => Promise; - }): ConversationModel | undefined { + fromPniSignature?: boolean; + mergeOldAndNew?: (options: SafeCombineConversationsParams) => Promise; + }): { + conversation: ConversationModel | undefined; + mergePromises: Array>; + } { const dataProvided = []; if (providedAci) { dataProvided.push('aci'); @@ -481,6 +484,8 @@ export class ConversationController { const aci = providedAci ? UUID.cast(providedAci) : undefined; const pni = providedPni ? UUID.cast(providedPni) : undefined; + let targetConversationWasCreated = false; + const mergePromises: Array> = []; if (!aci && !e164 && !pni) { throw new Error( @@ -518,9 +523,13 @@ export class ConversationController { `${logId}: No match for ${key}, applying to target conversation` ); // Note: This line might erase a known e164 or PNI - applyChangeToConversation(targetConversation, { - [key]: value, - }); + applyChangeToConversation( + targetConversation, + { + [key]: value, + }, + targetConversationWasCreated + ); } else { unusedMatches.push(item); } @@ -532,22 +541,43 @@ export class ConversationController { strictAssert(unused.value, 'An unused value should always be truthy'); // Example: If we find that our PNI match has no ACI, then it will be our target. - // Tricky: PNI can end up in UUID slot, so we need to special-case it - if ( - !targetConversation && - (!match.get(unused.key) || - (unused.key === 'uuid' && match.get(unused.key) === pni)) - ) { + + if (!targetConversation && !match.get(unused.key)) { log.info( `${logId}: Match on ${key} does not have ${unused.key}, ` + `so it will be our target conversation - ${match.idForLogging()}` ); targetConversation = match; } + // Tricky: PNI can end up in UUID slot, so we need to special-case it + if ( + !targetConversation && + unused.key === 'uuid' && + match.get(unused.key) === pni + ) { + log.info( + `${logId}: Match on ${key} has uuid matching incoming pni, ` + + `so it will be our target conversation - ${match.idForLogging()}` + ); + targetConversation = match; + } + // Tricky: PNI can end up in UUID slot, so we need to special-case it + if ( + !targetConversation && + unused.key === 'uuid' && + match.get(unused.key) === match.get('pni') + ) { + log.info( + `${logId}: Match on ${key} has pni/uuid which are the same value, ` + + `so it will be our target conversation - ${match.idForLogging()}` + ); + targetConversation = match; + } // If PNI match already has an ACI, then we need to create a new one if (!targetConversation) { targetConversation = this.getOrCreate(unused.value, 'private'); + targetConversationWasCreated = true; log.info( `${logId}: Match on ${key} already had ${unused.key}, ` + `so created new target conversation - ${targetConversation.idForLogging()}` @@ -557,14 +587,36 @@ export class ConversationController { log.info( `${logId}: Applying new value for ${unused.key} to target conversation` ); - applyChangeToConversation(targetConversation, { - [unused.key]: unused.value, - }); + applyChangeToConversation( + targetConversation, + { + [unused.key]: unused.value, + }, + targetConversationWasCreated + ); }); unusedMatches = []; if (targetConversation && targetConversation !== match) { + // We need to grab this before we start taking key data from it. If we're merging + // by e164, we want to be sure that is what is rendered in the notification. + const obsoleteTitleInfo = + key === 'e164' + ? pick(match.attributes as ConversationAttributesType, [ + 'e164', + 'type', + ]) + : pick(match.attributes as ConversationAttributesType, [ + 'e164', + 'name', + 'profileFamilyName', + 'profileName', + 'systemGivenName', + 'type', + 'username', + ]); + // Clear the value on the current match, since it belongs on targetConversation! // Note: we need to do the remove first, because it will clear the lookup! log.info( @@ -577,34 +629,49 @@ export class ConversationController { [key]: undefined, }; // When the PNI is being used in the uuid field alone, we need to clear it - if (key === 'pni' && match.get('uuid') === pni) { + if ((key === 'pni' || key === 'e164') && match.get('uuid') === pni) { change.uuid = undefined; } - applyChangeToConversation(match, change); - - applyChangeToConversation(targetConversation, { - [key]: value, - }); + applyChangeToConversation(match, change, targetConversationWasCreated); // Note: The PNI check here is just to be bulletproof; if we know a UUID is a PNI, // then that should be put in the UUID field as well! - if (!match.get('uuid') && !match.get('e164') && !match.get('pni')) { + const willMerge = + !match.get('uuid') && !match.get('e164') && !match.get('pni'); + + applyChangeToConversation( + targetConversation, + { + [key]: value, + }, + willMerge || targetConversationWasCreated + ); + + if (willMerge) { log.warn( `${logId}: Removing old conversation which matched on ${key}. ` + 'Merging with target conversation.' ); - mergeOldAndNew({ - logId, - oldConversation: match, - newConversation: targetConversation, - }); + mergePromises.push( + mergeOldAndNew({ + current: targetConversation, + fromPniSignature, + logId, + obsolete: match, + obsoleteTitleInfo, + }) + ); } } else if (targetConversation && !targetConversation?.get(key)) { // This is mostly for the situation where PNI was erased when updating e164 log.debug(`${logId}: Re-adding ${key} on target conversation`); - applyChangeToConversation(targetConversation, { - [key]: value, - }); + applyChangeToConversation( + targetConversation, + { + [key]: value, + }, + targetConversationWasCreated + ); } if (!targetConversation) { @@ -616,7 +683,7 @@ export class ConversationController { }); if (targetConversation) { - return targetConversation; + return { conversation: targetConversation, mergePromises }; } strictAssert( @@ -631,7 +698,10 @@ export class ConversationController { const identifier = aci || pni || e164; strictAssert(identifier, `${logId}: identifier must be truthy!`); - return this.getOrCreate(identifier, 'private', { e164, pni }); + return { + conversation: this.getOrCreate(identifier, 'private', { e164, pni }), + mergePromises, + }; } /** @@ -668,7 +738,7 @@ export class ConversationController { // `identifier` would resolve to uuid if we had both, so fix up e164 if (normalizedUuid && e164) { - newConvo.updateE164(e164); + newConvo.updateE164(e164, { disableDiscoveryNotification: true }); } return newConvo; @@ -710,8 +780,8 @@ export class ConversationController { ); } - // Note: `doCombineConversations` is used within this function since both - // run on `_combineConversationsQueue` queue and we don't want deadlocks. + // Note: `doCombineConversations` is directly used within this function since both + // run on `_combineConversationsQueue` queue and we don't want deadlocks. private async doCheckForConflicts(): Promise { log.info('checkForConflicts: starting...'); const byUuid = Object.create(null); @@ -746,12 +816,18 @@ export class ConversationController { if (conversation.get('e164')) { // Keep new one // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(conversation, existing); + await this.doCombineConversations({ + current: conversation, + obsolete: existing, + }); byUuid[uuid] = conversation; } else { // Keep existing - note that this applies if neither had an e164 // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(existing, conversation); + await this.doCombineConversations({ + current: existing, + obsolete: conversation, + }); } } } @@ -774,12 +850,18 @@ export class ConversationController { if (conversation.get('e164') || conversation.get('pni')) { // Keep new one // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(conversation, existing); + await this.doCombineConversations({ + current: conversation, + obsolete: existing, + }); byUuid[pni] = conversation; } else { // Keep existing - note that this applies if neither had an e164 // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(existing, conversation); + await this.doCombineConversations({ + current: existing, + obsolete: conversation, + }); } } } @@ -814,12 +896,18 @@ export class ConversationController { if (conversation.get('uuid')) { // Keep new one // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(conversation, existing); + await this.doCombineConversations({ + current: conversation, + obsolete: existing, + }); byE164[e164] = conversation; } else { // Keep existing - note that this applies if neither had a UUID // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(existing, conversation); + await this.doCombineConversations({ + current: existing, + obsolete: conversation, + }); } } } @@ -854,11 +942,17 @@ export class ConversationController { !isGroupV2(existing.attributes) ) { // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(conversation, existing); + await this.doCombineConversations({ + current: conversation, + obsolete: existing, + }); byGroupV2Id[groupV2Id] = conversation; } else { // eslint-disable-next-line no-await-in-loop - await this.doCombineConversations(existing, conversation); + await this.doCombineConversations({ + current: existing, + obsolete: conversation, + }); } } } @@ -868,24 +962,26 @@ export class ConversationController { } async combineConversations( - current: ConversationModel, - obsolete: ConversationModel + options: CombineConversationsParams ): Promise { return this._combineConversationsQueue.add(() => - this.doCombineConversations(current, obsolete) + this.doCombineConversations(options) ); } - private async doCombineConversations( - current: ConversationModel, - obsolete: ConversationModel - ): Promise { + private async doCombineConversations({ + current, + obsolete, + obsoleteTitleInfo, + fromPniSignature, + }: CombineConversationsParams): Promise { const logId = `combineConversations/${obsolete.id}->${current.id}`; const conversationType = current.get('type'); if (!this.get(obsolete.id)) { log.warn(`${logId}: Already combined obsolete conversation`); + return; } if (obsolete.get('type') !== conversationType) { @@ -896,6 +992,21 @@ export class ConversationController { return; } + log.warn( + `${logId}: Combining two conversations -`, + `old: ${obsolete.idForLogging()} -> new: ${current.idForLogging()}` + ); + + const obsoleteActiveAt = obsolete.get('active_at'); + const currentActiveAt = current.get('active_at'); + const activeAt = + !obsoleteActiveAt || + !currentActiveAt || + currentActiveAt > obsoleteActiveAt + ? currentActiveAt + : obsoleteActiveAt; + current.set('active_at', activeAt); + const dataToCopy: Partial = pick( obsolete.attributes, [ @@ -937,10 +1048,6 @@ export class ConversationController { const obsoleteId = obsolete.get('id'); const obsoleteUuid = obsolete.getUuid(); const currentId = current.get('id'); - log.warn( - `${logId}: Combining two conversations -`, - `old: ${obsolete.idForLogging()} -> new: ${current.idForLogging()}` - ); if (conversationType === 'private' && obsoleteUuid) { if (!current.get('profileKey') && obsolete.get('profileKey')) { @@ -954,34 +1061,12 @@ export class ConversationController { } log.warn(`${logId}: Delete all sessions tied to old conversationId`); - const ourACI = window.textsecure.storage.user.getUuid(UUIDKind.ACI); - const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); - await Promise.all( - [ourACI, ourPNI].map(async ourUuid => { - if (!ourUuid) { - return; - } - const deviceIds = - await window.textsecure.storage.protocol.getDeviceIds({ - ourUuid, - identifier: obsoleteUuid.toString(), - }); - await Promise.all( - deviceIds.map(async deviceId => { - const addr = new QualifiedAddress( - ourUuid, - new Address(obsoleteUuid, deviceId) - ); - await window.textsecure.storage.protocol.removeSession(addr); - }) - ); - }) - ); + // Note: we use the conversationId here in case we've already lost our uuid. + await window.textsecure.storage.protocol.removeAllSessions(obsoleteId); log.warn( `${logId}: Delete all identity information tied to old conversationId` ); - if (obsoleteUuid) { await window.textsecure.storage.protocol.removeIdentityKey( obsoleteUuid @@ -1032,6 +1117,14 @@ export class ConversationController { this._conversations.resetLookups(); current.captureChange('combineConversations'); + current.updateLastMessage(); + + const titleIsUseful = Boolean( + obsoleteTitleInfo && getTitleNoDefault(obsoleteTitleInfo) + ); + if (!fromPniSignature && obsoleteTitleInfo && titleIsUseful) { + current.addConversationMerge(obsoleteTitleInfo); + } log.warn(`${logId}: Complete!`); } diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index 06d9c01ce..7d7c03db3 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -1523,7 +1523,11 @@ export class SignalProtocolStore extends EventEmitter { switch (direction) { case Direction.Sending: - return this.isTrustedForSending(publicKey, identityRecord); + return this.isTrustedForSending( + encodedAddress.uuid, + publicKey, + identityRecord + ); case Direction.Receiving: return true; default: @@ -1533,11 +1537,31 @@ export class SignalProtocolStore extends EventEmitter { // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L233 isTrustedForSending( + uuid: UUID, publicKey: Uint8Array, identityRecord?: IdentityKeyType ): boolean { if (!identityRecord) { - log.info('isTrustedForSending: No previous record, returning true...'); + // To track key changes across session switches, we save an old identity key on the + // conversation. + const conversation = window.ConversationController.get(uuid.toString()); + const previousIdentityKeyBase64 = conversation?.get( + 'previousIdentityKey' + ); + if (conversation && previousIdentityKeyBase64) { + const previousIdentityKey = Bytes.fromBase64(previousIdentityKeyBase64); + + if (!constantTimeEqual(previousIdentityKey, publicKey)) { + log.info( + 'isTrustedForSending: previousIdentityKey does not match, returning false' + ); + return false; + } + } + + log.info( + 'isTrustedForSending: No previous record or previousIdentityKey, returning true' + ); return true; } @@ -1552,7 +1576,7 @@ export class SignalProtocolStore extends EventEmitter { return false; } if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { - log.error('isTrustedIdentity: Needs unverified approval!'); + log.error('isTrustedForSending: Needs unverified approval!'); return false; } if (this.isNonBlockingApprovalRequired(identityRecord)) { @@ -1648,6 +1672,8 @@ export class SignalProtocolStore extends EventEmitter { nonblockingApproval, }); + this.checkPreviousKey(encodedAddress.uuid, publicKey, 'saveIdentity'); + return false; } @@ -1690,7 +1716,7 @@ export class SignalProtocolStore extends EventEmitter { // See `addKeyChange` in `ts/models/conversations.ts` for sender key info // update caused by this. try { - this.emit('keychange', encodedAddress.uuid); + this.emit('keychange', encodedAddress.uuid, 'saveIdentity - change'); } catch (error) { log.error( 'saveIdentity: error triggering keychange:', @@ -1822,6 +1848,37 @@ export class SignalProtocolStore extends EventEmitter { return VerifiedStatus.DEFAULT; } + // To track key changes across session switches, we save an old identity key on the + // conversation. Whenever we get a new identity key for that contact, we need to + // check it against that saved key - no need to pop a key change warning if it is + // the same! + checkPreviousKey(uuid: UUID, publicKey: Uint8Array, context: string): void { + const conversation = window.ConversationController.get(uuid.toString()); + const previousIdentityKeyBase64 = conversation?.get('previousIdentityKey'); + if (conversation && previousIdentityKeyBase64) { + const previousIdentityKey = Bytes.fromBase64(previousIdentityKeyBase64); + + try { + if (!constantTimeEqual(previousIdentityKey, publicKey)) { + this.emit( + 'keychange', + uuid, + `${context} - previousIdentityKey check` + ); + } + + // We only want to clear previousIdentityKey on a match, or on successfully emit. + conversation.set({ previousIdentityKey: undefined }); + window.Signal.Data.updateConversation(conversation.attributes); + } catch (error) { + log.error( + 'saveIdentity: error triggering keychange:', + error && error.stack ? error.stack : error + ); + } + } + } + // See https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/IdentityDatabase.java#L184 async updateIdentityAfterSync( uuid: UUID, @@ -1851,10 +1908,11 @@ export class SignalProtocolStore extends EventEmitter { nonblockingApproval: true, }); } - - if (hadEntry && !keyMatches) { + if (!hadEntry) { + this.checkPreviousKey(uuid, publicKey, 'updateIdentityAfterSync'); + } else if (hadEntry && !keyMatches) { try { - this.emit('keychange', uuid); + this.emit('keychange', uuid, 'updateIdentityAfterSync - change'); } catch (error) { log.error( 'updateIdentityAfterSync: error triggering keychange:', @@ -1903,7 +1961,10 @@ export class SignalProtocolStore extends EventEmitter { return false; } - async removeIdentityKey(uuid: UUID): Promise { + async removeIdentityKey( + uuid: UUID, + options?: { disableSessionDeletion: boolean } + ): Promise { if (!this.identityKeys) { throw new Error('removeIdentityKey: this.identityKeys not yet cached!'); } @@ -1911,7 +1972,9 @@ export class SignalProtocolStore extends EventEmitter { const id = uuid.toString(); this.identityKeys.delete(id); await window.Signal.Data.removeIdentityKeyById(id); - await this.removeAllSessions(id); + if (!options?.disableSessionDeletion) { + await this.removeAllSessions(id); + } } // Not yet processed messages - for resiliency @@ -2197,7 +2260,7 @@ export class SignalProtocolStore extends EventEmitter { public override on( name: 'keychange', - handler: (theirUuid: UUID) => unknown + handler: (theirUuid: UUID, reason: string) => unknown ): this; public override on(name: 'removeAllData', handler: () => unknown): this; @@ -2212,7 +2275,11 @@ export class SignalProtocolStore extends EventEmitter { public override emit(name: 'removePreKey', ourUuid: UUID): boolean; - public override emit(name: 'keychange', theirUuid: UUID): boolean; + public override emit( + name: 'keychange', + theirUuid: UUID, + reason: string + ): boolean; public override emit(name: 'removeAllData'): boolean; diff --git a/ts/background.ts b/ts/background.ts index 5c050d94e..ebbc48dc1 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -2634,13 +2634,12 @@ export async function startApp(): Promise { let conversation; - const senderConversation = window.ConversationController.maybeMergeContacts( - { + const { conversation: senderConversation } = + window.ConversationController.maybeMergeContacts({ e164: sender, aci: senderUuid, reason: `onTyping(${typing.timestamp})`, - } - ); + }); // We multiplex between GV1/GV2 groups here, but we don't kick off migrations if (groupV2Id) { @@ -2874,14 +2873,21 @@ export async function startApp(): Promise { maxSize: Infinity, }); - function onEnvelopeReceived({ envelope }: EnvelopeEvent): void { + async function onEnvelopeReceived({ + envelope, + }: EnvelopeEvent): Promise { const ourUuid = window.textsecure.storage.user.getUuid()?.toString(); if (envelope.sourceUuid && envelope.sourceUuid !== ourUuid) { - window.ConversationController.maybeMergeContacts({ - e164: envelope.source, - aci: envelope.sourceUuid, - reason: `onEnvelopeReceived(${envelope.timestamp})`, - }); + const { mergePromises } = + window.ConversationController.maybeMergeContacts({ + e164: envelope.source, + aci: envelope.sourceUuid, + reason: `onEnvelopeReceived(${envelope.timestamp})`, + }); + + if (mergePromises.length > 0) { + await Promise.all(mergePromises); + } } } @@ -3028,7 +3034,7 @@ export async function startApp(): Promise { data, confirm, }: ProfileKeyUpdateEvent): Promise { - const conversation = window.ConversationController.maybeMergeContacts({ + const { conversation } = window.ConversationController.maybeMergeContacts({ aci: data.sourceUuid, e164: data.source, reason: 'onProfileKeyUpdate', @@ -3250,11 +3256,12 @@ export async function startApp(): Promise { } // If we can't find one, we treat this as a normal GroupV1 group - const fromContact = window.ConversationController.maybeMergeContacts({ - aci: sourceUuid, - e164: source, - reason: `getMessageDescriptor(${message.timestamp}): group v1`, - }); + const { conversation: fromContact } = + window.ConversationController.maybeMergeContacts({ + aci: sourceUuid, + e164: source, + reason: `getMessageDescriptor(${message.timestamp}): group v1`, + }); const conversationId = window.ConversationController.ensureGroup(id, { addedBy: fromContact?.id, @@ -3266,7 +3273,7 @@ export async function startApp(): Promise { }; } - const conversation = window.ConversationController.maybeMergeContacts({ + const { conversation } = window.ConversationController.maybeMergeContacts({ aci: destinationUuid, e164: destination, reason: `getMessageDescriptor(${message.timestamp}): private`, @@ -3696,13 +3703,12 @@ export async function startApp(): Promise { sourceDevice, wasSentEncrypted, } = event.receipt; - const sourceConversation = window.ConversationController.maybeMergeContacts( - { + const { conversation: sourceConversation } = + window.ConversationController.maybeMergeContacts({ aci: sourceUuid, e164: source, reason: `onReadOrViewReceipt(${envelopeTimestamp})`, - } - ); + }); log.info( logTitle, `${sourceUuid || source}.${sourceDevice}`, @@ -3830,13 +3836,12 @@ export async function startApp(): Promise { ev.confirm(); - const sourceConversation = window.ConversationController.maybeMergeContacts( - { + const { conversation: sourceConversation } = + window.ConversationController.maybeMergeContacts({ aci: sourceUuid, e164: source, reason: `onDeliveryReceipt(${envelopeTimestamp})`, - } - ); + }); log.info( 'delivery receipt from', diff --git a/ts/components/conversation/ConversationMergeNotification.stories.tsx b/ts/components/conversation/ConversationMergeNotification.stories.tsx new file mode 100644 index 000000000..5bb408315 --- /dev/null +++ b/ts/components/conversation/ConversationMergeNotification.stories.tsx @@ -0,0 +1,35 @@ +// Copyright 2020-2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { setupI18n } from '../../util/setupI18n'; +import enMessages from '../../../_locales/en/messages.json'; +import type { PropsType } from './ConversationMergeNotification'; +import { ConversationMergeNotification } from './ConversationMergeNotification'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/Conversation/ConversationMergeNotification', +}; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + i18n, + conversationTitle: overrideProps.conversationTitle || 'John Fire', + obsoleteConversationTitle: + overrideProps.obsoleteConversationTitle || '(555) 333-1111', +}); + +export function Basic(): JSX.Element { + return ; +} + +export function WithNoObsoleteTitle(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/conversation/ConversationMergeNotification.tsx b/ts/components/conversation/ConversationMergeNotification.tsx new file mode 100644 index 000000000..6c20ccb6b --- /dev/null +++ b/ts/components/conversation/ConversationMergeNotification.tsx @@ -0,0 +1,87 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../../types/Util'; +import { getStringForConversationMerge } from '../../util/getStringForConversationMerge'; +import { Button, ButtonSize, ButtonVariant } from '../Button'; +import { SystemMessage } from './SystemMessage'; +import { Emojify } from './Emojify'; +import { Modal } from '../Modal'; +import { Intl } from '../Intl'; + +export type PropsDataType = { + conversationTitle: string; + obsoleteConversationTitle: string | undefined; +}; +export type PropsType = PropsDataType & { + i18n: LocalizerType; +}; + +export function ConversationMergeNotification(props: PropsType): JSX.Element { + const { conversationTitle, obsoleteConversationTitle, i18n } = props; + const message = getStringForConversationMerge({ + conversationTitle, + obsoleteConversationTitle, + i18n, + }); + + const [showingDialog, setShowingDialog] = React.useState(false); + + const showDialog = React.useCallback(() => { + setShowingDialog(true); + }, [setShowingDialog]); + + const dismissDialog = React.useCallback(() => { + setShowingDialog(false); + }, [setShowingDialog]); + + return ( + <> + } + button={ + obsoleteConversationTitle ? ( + + ) : undefined + } + /> + {showingDialog && obsoleteConversationTitle ? ( + +
+ +
+ +
+
+ +
+
+
+ ) : null} + + ); +} diff --git a/ts/components/conversation/PhoneNumberDiscoveryNotification.stories.tsx b/ts/components/conversation/PhoneNumberDiscoveryNotification.stories.tsx new file mode 100644 index 000000000..f782619a7 --- /dev/null +++ b/ts/components/conversation/PhoneNumberDiscoveryNotification.stories.tsx @@ -0,0 +1,36 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import { setupI18n } from '../../util/setupI18n'; +import enMessages from '../../../_locales/en/messages.json'; +import type { PropsType } from './PhoneNumberDiscoveryNotification'; +import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/Conversation/PhoneNumberDiscoveryNotification', +}; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + i18n, + conversationTitle: overrideProps.conversationTitle || 'Mr. Fire', + phoneNumber: overrideProps.phoneNumber || '+1 (000) 123-4567', + sharedGroup: overrideProps.sharedGroup, +}); + +export function Basic(): JSX.Element { + return ; +} + +export function WithSharedGroup(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/conversation/PhoneNumberDiscoveryNotification.tsx b/ts/components/conversation/PhoneNumberDiscoveryNotification.tsx new file mode 100644 index 000000000..6580aaa9b --- /dev/null +++ b/ts/components/conversation/PhoneNumberDiscoveryNotification.tsx @@ -0,0 +1,32 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../../types/Util'; +import { SystemMessage } from './SystemMessage'; +import { Emojify } from './Emojify'; +import { getStringForPhoneNumberDiscovery } from '../../util/getStringForPhoneNumberDiscovery'; + +export type PropsDataType = { + conversationTitle: string; + phoneNumber: string; + sharedGroup?: string; +}; +export type PropsType = PropsDataType & { + i18n: LocalizerType; +}; + +export function PhoneNumberDiscoveryNotification( + props: PropsType +): JSX.Element { + const { conversationTitle, i18n, sharedGroup, phoneNumber } = props; + const message = getStringForPhoneNumberDiscovery({ + conversationTitle, + i18n, + phoneNumber, + sharedGroup, + }); + + return } />; +} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 7ac032193..64ff35712 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -55,6 +55,10 @@ import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileC import { ProfileChangeNotification } from './ProfileChangeNotification'; import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEventNotification'; import { PaymentEventNotification } from './PaymentEventNotification'; +import type { PropsDataType as ConversationMergeNotificationPropsType } from './ConversationMergeNotification'; +import { ConversationMergeNotification } from './ConversationMergeNotification'; +import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from './PhoneNumberDiscoveryNotification'; +import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification'; import type { FullJSXType } from '../Intl'; import { TimelineMessage } from './TimelineMessage'; @@ -118,6 +122,14 @@ type ProfileChangeNotificationType = { type: 'profileChange'; data: ProfileChangeNotificationPropsType; }; +type ConversationMergeNotificationType = { + type: 'conversationMerge'; + data: ConversationMergeNotificationPropsType; +}; +type PhoneNumberDiscoveryNotificationType = { + type: 'phoneNumberDiscovery'; + data: PhoneNumberDiscoveryNotificationPropsType; +}; type PaymentEventType = { type: 'paymentEvent'; data: Omit; @@ -125,18 +137,20 @@ type PaymentEventType = { export type TimelineItemType = ( | CallHistoryType + | ChangeNumberNotificationType | ChatSessionRefreshedType + | ConversationMergeNotificationType | DeliveryIssueType | GroupNotificationType | GroupV1MigrationType | GroupV2ChangeType | MessageType + | PhoneNumberDiscoveryNotificationType | ProfileChangeNotificationType | ResetSessionNotificationType | SafetyNumberNotificationType | TimerNotificationType | UniversalTimerNotificationType - | ChangeNumberNotificationType | UnsupportedMessageType | VerificationNotificationType | PaymentEventType @@ -300,6 +314,22 @@ export class TimelineItem extends React.PureComponent { notification = ( ); + } else if (item.type === 'conversationMerge') { + notification = ( + + ); + } else if (item.type === 'phoneNumberDiscovery') { + notification = ( + + ); } else if (item.type === 'resetSessionNotification') { notification = ; } else if (item.type === 'profileChange') { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 08ff21d9a..4cc675c3b 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -166,19 +166,21 @@ export type MessageAttributesType = { id: string; type: | 'call-history' + | 'change-number-notification' | 'chat-session-refreshed' + | 'conversation-merge' | 'delivery-issue' - | 'group' | 'group-v1-migration' | 'group-v2-change' + | 'group' | 'incoming' | 'keychange' | 'outgoing' + | 'phone-number-discovery' | 'profile-change' | 'story' | 'timer-notification' | 'universal-timer-notification' - | 'change-number-notification' | 'verified-change'; body?: string; attachments?: Array; @@ -207,6 +209,13 @@ export type MessageAttributesType = { source?: string; sourceUuid?: string; }; + phoneNumberDiscovery?: { + e164: string; + }; + conversationMerge?: { + renderInfo: ConversationRenderInfoType; + }; + // Legacy fields for timer update notification only flags?: number; groupV2Change?: GroupV2ChangeType; @@ -349,6 +358,7 @@ export type ConversationAttributesType = { pendingUniversalTimer?: string; username?: string; shareMyPhoneNumber?: boolean; + previousIdentityKey?: string; // Group-only groupId?: string; @@ -409,6 +419,17 @@ export type ConversationAttributesType = { }; /* eslint-enable camelcase */ +export type ConversationRenderInfoType = Pick< + ConversationAttributesType, + | 'e164' + | 'name' + | 'profileFamilyName' + | 'profileName' + | 'systemGivenName' + | 'type' + | 'username' +>; + export type GroupV2MemberType = { uuid: UUIDStringType; role: MemberRoleEnum; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 6e693210f..6e0640035 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -17,6 +17,7 @@ import PQueue from 'p-queue'; import type { ConversationAttributesType, ConversationLastProfileType, + ConversationRenderInfoType, LastMessageStatus, MessageAttributesType, QuotedMessageType, @@ -24,7 +25,6 @@ import type { } from '../model-types.d'; import { getInitials } from '../util/getInitials'; import { normalizeUuid } from '../util/normalizeUuid'; -import { getRegionCodeForNumber } from '../util/libphonenumberUtil'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import type { AttachmentType, ThumbnailType } from '../types/Attachment'; import { toDayMillis } from '../util/timestamp'; @@ -70,7 +70,12 @@ import type { MIMEType } from '../types/MIME'; import { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME'; import { UUID, UUIDKind } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID'; -import { deriveAccessKey, decryptProfileName, decryptProfile } from '../Crypto'; +import { + constantTimeEqual, + decryptProfile, + decryptProfileName, + deriveAccessKey, +} from '../Crypto'; import * as Bytes from '../Bytes'; import type { BodyRangesType } from '../types/Util'; import { getTextWithMentions } from '../util/getTextWithMentions'; @@ -81,6 +86,12 @@ import { notificationService } from '../services/notifications'; import { storageServiceUploadJob } from '../services/storage'; import { getSendOptions } from '../util/getSendOptions'; import { isConversationAccepted } from '../util/isConversationAccepted'; +import { + getNumber, + getProfileName, + getTitle, + getTitleNoDefault, +} from '../util/getTitle'; import { markConversationRead } from '../util/markConversationRead'; import { handleMessageSend } from '../util/handleMessageSend'; import { getConversationMembers } from '../util/getConversationMembers'; @@ -88,7 +99,7 @@ import { updateConversationsWithUuidLookup } from '../updateConversationsWithUui import { ReadStatus } from '../messages/MessageReadStatus'; import { SendStatus } from '../messages/MessageSendState'; import type { LinkPreviewType } from '../types/message/LinkPreviews'; -import { MINUTE, DurationInSeconds } from '../util/durations'; +import { MINUTE, SECOND, DurationInSeconds } from '../util/durations'; import { concat, filter, @@ -220,6 +231,8 @@ export class ConversationModel extends window.Backbone throttledGetProfiles?: () => Promise; + throttledUpdateVerified?: () => void; + typingRefreshTimer?: NodeJS.Timer | null; typingPauseTimer?: NodeJS.Timer | null; @@ -301,14 +314,10 @@ export class ConversationModel extends window.Backbone // our first save to the database. Or first fetch from the database. this.initialPromise = Promise.resolve(); - this.throttledBumpTyping = throttle(this.bumpTyping, 300); this.debouncedUpdateLastMessage = debounce( this.updateLastMessage.bind(this), 200 ); - this.throttledUpdateSharedGroups = - this.throttledUpdateSharedGroups || - throttle(this.updateSharedGroups.bind(this), FIVE_MINUTES); this.contactCollection = this.getContactCollection(); this.contactCollection.on( @@ -370,6 +379,11 @@ export class ConversationModel extends window.Backbone // conversation for the first time. this.isFetchingUUID = this.isSMSOnly(); + this.throttledBumpTyping = throttle(this.bumpTyping, 300); + this.throttledUpdateSharedGroups = throttle( + this.updateSharedGroups.bind(this), + FIVE_MINUTES + ); this.throttledFetchSMSOnlyUUID = throttle( this.fetchSMSOnlyUUID.bind(this), FIVE_MINUTES @@ -378,6 +392,16 @@ export class ConversationModel extends window.Backbone this.maybeMigrateV1Group.bind(this), FIVE_MINUTES ); + this.throttledGetProfiles = throttle( + this.getProfiles.bind(this), + FIVE_MINUTES + ); + this.throttledUpdateVerified = throttle( + this.updateVerified.bind(this), + SECOND + ); + + this.on('newmessage', this.throttledUpdateVerified); const migratedColor = this.getColor(); if (this.get('color') !== migratedColor) { @@ -1956,49 +1980,166 @@ export class ConversationModel extends window.Backbone }; } - updateE164(e164?: string | null): void { + updateE164( + e164?: string | null, + { + disableDiscoveryNotification, + }: { + disableDiscoveryNotification?: boolean; + } = {} + ): void { const oldValue = this.get('e164'); - if (e164 !== oldValue) { - this.set('e164', e164 || undefined); - - if (oldValue && e164) { - this.addChangeNumberNotification(oldValue, e164); - } - - window.Signal.Data.updateConversation(this.attributes); - this.trigger('idUpdated', this, 'e164', oldValue); - this.captureChange('updateE164'); + if (e164 === oldValue) { + return; } + + this.set('e164', e164 || undefined); + + // We just discovered a new phone number for this account. If we're not merging + // then we'll add a standalone notification here. + const haveSentMessage = Boolean( + this.get('profileSharing') || this.get('sentMessageCount') + ); + if (!oldValue && e164 && haveSentMessage && !disableDiscoveryNotification) { + this.addPhoneNumberDiscovery(e164); + } + + // This user changed their phone number + if (oldValue && e164) { + this.addChangeNumberNotification(oldValue, e164); + } + + window.Signal.Data.updateConversation(this.attributes); + this.trigger('idUpdated', this, 'e164', oldValue); + this.captureChange('updateE164'); } updateUuid(uuid?: string): void { const oldValue = this.get('uuid'); - if (uuid !== oldValue) { - this.set('uuid', uuid ? UUID.cast(uuid.toLowerCase()) : undefined); - window.Signal.Data.updateConversation(this.attributes); - this.trigger('idUpdated', this, 'uuid', oldValue); - this.captureChange('updateUuid'); + if (uuid === oldValue) { + return; } + + this.set('uuid', uuid ? UUID.cast(uuid.toLowerCase()) : undefined); + window.Signal.Data.updateConversation(this.attributes); + this.trigger('idUpdated', this, 'uuid', oldValue); + + // We should delete the old sessions and identity information in all situations except + // for the case where we need to do old and new PNI comparisons. We'll wait + // for the PNI update to do that. + if (oldValue && oldValue !== this.get('pni')) { + // We've already changed our UUID, so we need account for lookups on that old UUID + // to returng nothing: pass conversationId into removeAllSessions, and disable + // auto-deletion in removeIdentityKey. + window.textsecure.storage.protocol.removeAllSessions(this.id); + window.textsecure.storage.protocol.removeIdentityKey( + UUID.cast(oldValue), + { disableSessionDeletion: true } + ); + } + + this.captureChange('updateUuid'); + } + + trackPreviousIdentityKey(publicKey: Uint8Array): void { + const logId = `trackPreviousIdentityKey/${this.idForLogging()}`; + const identityKey = Bytes.toBase64(publicKey); + + if (!isDirectConversation(this.attributes)) { + throw new Error(`${logId}: Called for non-private conversation`); + } + + const existingIdentityKey = this.get('previousIdentityKey'); + if (existingIdentityKey && existingIdentityKey !== identityKey) { + log.warn( + `${logId}: Already had previousIdentityKey, new one does not match` + ); + this.addKeyChange('trackPreviousIdentityKey - change'); + } + + log.warn(`${logId}: Setting new previousIdentityKey`); + this.set({ + previousIdentityKey: identityKey, + }); + window.Signal.Data.updateConversation(this.attributes); } updatePni(pni?: string): void { const oldValue = this.get('pni'); - if (pni !== oldValue) { - this.set('pni', pni ? UUID.cast(pni.toLowerCase()) : undefined); + if (pni === oldValue) { + return; + } - if ( - oldValue && - pni && - (!this.get('uuid') || this.get('uuid') === oldValue) - ) { - // TODO: DESKTOP-3974 - this.addKeyChange(UUID.checkedLookup(oldValue)); + this.set('pni', pni ? UUID.cast(pni.toLowerCase()) : undefined); + + const pniIsPrimaryId = + !this.get('uuid') || + this.get('uuid') === oldValue || + this.get('uuid') === pni; + const haveSentMessage = Boolean( + this.get('profileSharing') || this.get('sentMessageCount') + ); + + if (oldValue && pniIsPrimaryId && haveSentMessage) { + // We're going from an old PNI to a new PNI + if (pni) { + const oldIdentityRecord = + window.textsecure.storage.protocol.getIdentityRecord( + UUID.cast(oldValue) + ); + const newIdentityRecord = + window.textsecure.storage.protocol.getIdentityRecord( + UUID.checkedLookup(pni) + ); + + if ( + newIdentityRecord && + oldIdentityRecord && + !constantTimeEqual( + oldIdentityRecord.publicKey, + newIdentityRecord.publicKey + ) + ) { + this.addKeyChange('updatePni - change'); + } else if (!newIdentityRecord && oldIdentityRecord) { + this.trackPreviousIdentityKey(oldIdentityRecord.publicKey); + } } - window.Signal.Data.updateConversation(this.attributes); - this.trigger('idUpdated', this, 'pni', oldValue); - this.captureChange('updatePni'); + // We're just dropping the PNI + if (!pni) { + const oldIdentityRecord = + window.textsecure.storage.protocol.getIdentityRecord( + UUID.cast(oldValue) + ); + + if (oldIdentityRecord) { + this.trackPreviousIdentityKey(oldIdentityRecord.publicKey); + } + } } + + // If this PNI is going away or going to someone else, we'll delete all its sessions + if (oldValue) { + // We've already changed our UUID, so we need account for lookups on that old UUID + // to returng nothing: pass conversationId into removeAllSessions, and disable + // auto-deletion in removeIdentityKey. + window.textsecure.storage.protocol.removeAllSessions(this.id); + window.textsecure.storage.protocol.removeIdentityKey( + UUID.cast(oldValue), + { disableSessionDeletion: true } + ); + } + + if (pni && !this.get('uuid')) { + log.warn( + `updatePni/${this.idForLogging()}: pni field set to ${pni}, but uuid field is empty!` + ); + } + + window.Signal.Data.updateConversation(this.attributes); + this.trigger('idUpdated', this, 'pni', oldValue); + this.captureChange('updatePni'); } updateGroupId(groupId?: string): void { @@ -3044,40 +3185,47 @@ export class ConversationModel extends window.Backbone this.updateUnread(); } - async addKeyChange(keyChangedId: UUID): Promise { - const keyChangedIdString = keyChangedId.toString(); + async addKeyChange(reason: string, keyChangedId?: UUID): Promise { + const keyChangedIdString = keyChangedId?.toString(); return this.queueJob(`addKeyChange(${keyChangedIdString})`, async () => { log.info( - 'adding key change advisory for', + 'adding key change advisory in', this.idForLogging(), - keyChangedIdString, - this.get('timestamp') + 'for', + keyChangedIdString || 'this conversation', + this.get('timestamp'), + 'reason:', + reason ); + if (!keyChangedId && !isDirectConversation(this.attributes)) { + throw new Error( + 'addKeyChange: Cannot omit keyChangedId in group conversation!' + ); + } + const timestamp = Date.now(); - const message = { + const message: MessageAttributesType = { + id: generateGuid(), conversationId: this.id, type: 'keychange', - sent_at: this.get('timestamp'), + sent_at: timestamp, + timestamp, received_at: window.Signal.Util.incrementMessageCounter(), received_at_ms: timestamp, key_changed: keyChangedIdString, readStatus: ReadStatus.Read, seenStatus: SeenStatus.Unseen, schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, - // TODO: DESKTOP-722 - // this type does not fully implement the interface it is expected to - } as unknown as MessageAttributesType; + }; - const id = await window.Signal.Data.saveMessage(message, { + await window.Signal.Data.saveMessage(message, { ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + forceSave: true, }); const model = window.MessageController.register( - id, - new window.Whisper.Message({ - ...message, - id, - }) + message.id, + new window.Whisper.Message(message) ); const isUntrusted = await this.isUntrusted(); @@ -3090,6 +3238,17 @@ export class ConversationModel extends window.Backbone window.reduxActions.calling.keyChanged({ uuid }); } + if (isDirectConversation(this.attributes) && uuid) { + const parsedUuid = UUID.checkedLookup(uuid); + const groups = + await window.ConversationController.getAllGroupsInvolvingUuid( + parsedUuid + ); + groups.forEach(group => { + group.addKeyChange('addKeyChange - group fan-out', parsedUuid); + }); + } + // Drop a member from sender key distribution list. const senderKeyInfo = this.get('senderKeyInfo'); if (senderKeyInfo) { @@ -3108,6 +3267,82 @@ export class ConversationModel extends window.Backbone }); } + async addPhoneNumberDiscovery(e164: string): Promise { + log.info( + `addPhoneNumberDiscovery/${this.idForLogging()}: Adding for ${e164}` + ); + + const timestamp = Date.now(); + const message: MessageAttributesType = { + id: generateGuid(), + conversationId: this.id, + type: 'phone-number-discovery', + sent_at: timestamp, + timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, + phoneNumberDiscovery: { + e164, + }, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Unseen, + schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, + }; + + const id = await window.Signal.Data.saveMessage(message, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + forceSave: true, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + } + + async addConversationMerge( + renderInfo: ConversationRenderInfoType + ): Promise { + log.info( + `addConversationMerge/${this.idForLogging()}: Adding notification` + ); + + const timestamp = Date.now(); + const message: MessageAttributesType = { + id: generateGuid(), + conversationId: this.id, + type: 'conversation-merge', + sent_at: timestamp, + timestamp, + received_at: window.Signal.Util.incrementMessageCounter(), + received_at_ms: timestamp, + conversationMerge: { + renderInfo, + }, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Unseen, + schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, + }; + + const id = await window.Signal.Data.saveMessage(message, { + ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(), + forceSave: true, + }); + const model = window.MessageController.register( + id, + new window.Whisper.Message({ + ...message, + id, + }) + ); + + this.trigger('newmessage', model); + } + async addVerifiedChange( verifiedChangeId: string, verified: boolean, @@ -4985,69 +5220,19 @@ export class ConversationModel extends window.Backbone } getTitle(options?: { isShort?: boolean }): string { - const title = this.getTitleNoDefault(options); - if (title) { - return title; - } - - if (isDirectConversation(this.attributes)) { - return window.i18n('unknownContact'); - } - return window.i18n('unknownGroup'); + return getTitle(this.attributes, options); } - getTitleNoDefault({ isShort = false }: { isShort?: boolean } = {}): - | string - | undefined { - if (isDirectConversation(this.attributes)) { - const username = this.get('username'); - - return ( - (isShort ? this.get('systemGivenName') : undefined) || - this.get('name') || - (isShort ? this.get('profileName') : undefined) || - this.getProfileName() || - this.getNumber() || - (username && window.i18n('at-username', { username })) - ); - } - return this.get('name'); + getTitleNoDefault(options?: { isShort?: boolean }): string | undefined { + return getTitleNoDefault(this.attributes, options); } getProfileName(): string | undefined { - if (isDirectConversation(this.attributes)) { - return Util.combineNames( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - this.get('profileName')!, - this.get('profileFamilyName') - ); - } - - return undefined; + return getProfileName(this.attributes); } getNumber(): string { - if (!isDirectConversation(this.attributes)) { - return ''; - } - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const number = this.get('e164')!; - try { - const parsedNumber = window.libphonenumberInstance.parse(number); - const regionCode = getRegionCodeForNumber(number); - if (regionCode === window.storage.get('regionCode')) { - return window.libphonenumberInstance.format( - parsedNumber, - window.libphonenumberFormat.NATIONAL - ); - } - return window.libphonenumberInstance.format( - parsedNumber, - window.libphonenumberFormat.INTERNATIONAL - ); - } catch (e) { - return number; - } + return getNumber(this.attributes); } getColor(): AvatarColorType { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 74d873a96..2e419ff7e 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -122,6 +122,8 @@ import { isUnsupportedMessage, isVerifiedChange, processBodyRanges, + isConversationMerge, + isPhoneNumberDiscovery, } from '../state/selectors/message'; import { isInCall, @@ -181,6 +183,9 @@ import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer'; import { GiftBadgeStates } from '../components/conversation/Message'; import { downloadAttachment } from '../util/downloadAttachment'; import type { StickerWithHydratedData } from '../types/Stickers'; +import { getStringForConversationMerge } from '../util/getStringForConversationMerge'; +import { getStringForPhoneNumberDiscovery } from '../util/getStringForPhoneNumberDiscovery'; +import { getTitle, renderNumber } from '../util/getTitle'; import { DurationInSeconds } from '../util/durations'; import dataInterface from '../sql/Client'; @@ -415,12 +420,14 @@ export class MessageModel extends window.Backbone.Model { return ( !isCallHistory(attributes) && !isChatSessionRefreshed(attributes) && + !isConversationMerge(attributes) && !isEndSession(attributes) && !isExpirationTimerUpdate(attributes) && !isGroupUpdate(attributes) && - !isGroupV2Change(attributes) && !isGroupV1Migration(attributes) && + !isGroupV2Change(attributes) && !isKeyChange(attributes) && + !isPhoneNumberDiscovery(attributes) && !isProfileChange(attributes) && !isUniversalTimerNotification(attributes) && !isUnsupportedMessage(attributes) && @@ -622,7 +629,8 @@ export class MessageModel extends window.Backbone.Model { } getNotificationData(): { emoji?: string; text: string } { - const { attributes } = this; + // eslint-disable-next-line prefer-destructuring + const attributes: MessageAttributesType = this.attributes; if (isDeliveryIssue(attributes)) { return { @@ -631,6 +639,46 @@ export class MessageModel extends window.Backbone.Model { }; } + if (isConversationMerge(attributes)) { + const conversation = this.getConversation(); + strictAssert( + conversation, + 'getNotificationData/isConversationMerge/conversation' + ); + strictAssert( + attributes.conversationMerge, + 'getNotificationData/isConversationMerge/conversationMerge' + ); + + return { + text: getStringForConversationMerge({ + obsoleteConversationTitle: getTitle( + attributes.conversationMerge.renderInfo + ), + conversationTitle: conversation.getTitle(), + i18n: window.i18n, + }), + }; + } + + if (isPhoneNumberDiscovery(attributes)) { + const conversation = this.getConversation(); + strictAssert(conversation, 'getNotificationData/isPhoneNumberDiscovery'); + strictAssert( + attributes.phoneNumberDiscovery, + 'getNotificationData/isPhoneNumberDiscovery/phoneNumberDiscovery' + ); + + return { + text: getStringForPhoneNumberDiscovery({ + phoneNumber: renderNumber(attributes.phoneNumberDiscovery.e164), + conversationTitle: conversation.getTitle(), + sharedGroup: conversation.get('sharedGroupNames')?.[0], + i18n: window.i18n, + }), + }; + } + if (isChatSessionRefreshed(attributes)) { return { emoji: '🔁', @@ -1323,6 +1371,8 @@ export class MessageModel extends window.Backbone.Model { const isProfileChangeValue = isProfileChange(attributes); const isUniversalTimerNotificationValue = isUniversalTimerNotification(attributes); + const isConversationMergeValue = isConversationMerge(attributes); + const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes); const isPayment = messageHasPaymentEvent(attributes); @@ -1353,7 +1403,9 @@ export class MessageModel extends window.Backbone.Model { // Locally-generated notifications isKeyChangeValue || isProfileChangeValue || - isUniversalTimerNotificationValue; + isUniversalTimerNotificationValue || + isConversationMergeValue || + isPhoneNumberDiscoveryValue; return !hasSomethingToDisplay; } @@ -2320,7 +2372,7 @@ export class MessageModel extends window.Backbone.Model { return; } - const destinationConversation = + const { conversation: destinationConversation } = window.ConversationController.maybeMergeContacts({ aci: destinationUuid, e164: destination || undefined, diff --git a/ts/services/contactSync.ts b/ts/services/contactSync.ts index 41bacbc08..7a62e848d 100644 --- a/ts/services/contactSync.ts +++ b/ts/services/contactSync.ts @@ -117,7 +117,7 @@ async function doContactSync({ continue; } - const conversation = window.ConversationController.maybeMergeContacts({ + const { conversation } = window.ConversationController.maybeMergeContacts({ e164: details.number, aci: details.uuid, reason: logId, diff --git a/ts/services/storage.ts b/ts/services/storage.ts index 26fd865dc..cc7ff258b 100644 --- a/ts/services/storage.ts +++ b/ts/services/storage.ts @@ -1732,6 +1732,12 @@ async function sync( // We now know that we've successfully completed a storage service fetch await window.storage.put('storageFetchComplete', true); + + if (window.CI) { + window.CI.handleEvent('storageServiceComplete', { + manifestVersion: version, + }); + } } catch (err) { log.error( 'storageService.sync: error processing manifest', diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index df8c878e4..a0d86747f 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -957,7 +957,7 @@ export async function mergeContactRecord( return { hasConflict: false, shouldDrop: true, details: ['our own uuid'] }; } - const conversation = window.ConversationController.maybeMergeContacts({ + const { conversation } = window.ConversationController.maybeMergeContacts({ aci: uuid, e164, pni, diff --git a/ts/sql/migrations/71-merge-notifications.ts b/ts/sql/migrations/71-merge-notifications.ts new file mode 100644 index 000000000..460fc673c --- /dev/null +++ b/ts/sql/migrations/71-merge-notifications.ts @@ -0,0 +1,123 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Database } from 'better-sqlite3'; + +import type { LoggerType } from '../../types/Logging'; + +export default function updateToSchemaVersion71( + currentVersion: number, + db: Database, + logger: LoggerType +): void { + if (currentVersion >= 71) { + return; + } + + db.transaction(() => { + db.exec( + ` + --- These will be re-added below + DROP INDEX messages_preview; + DROP INDEX messages_activity; + DROP INDEX message_user_initiated; + + --- Thse will also be re-added below + ALTER TABLE messages DROP COLUMN shouldAffectActivity; + ALTER TABLE messages DROP COLUMN shouldAffectPreview; + ALTER TABLE messages DROP COLUMN isUserInitiatedMessage; + + --- Note: These generated columns were originally introduced in migration 47, and + --- are mostly the same + + --- Based on the current list (model-types.ts), the types which DO affect activity: + --- NULL (old, malformed data) + --- call-history + --- chat-session-refreshed (deprecated) + --- delivery-issue + --- group (deprecated) + --- group-v2-change + --- incoming + --- outgoing + --- timer-notification + + --- (change: added conversation-merge, keychange, and phone-number-discovery) + ALTER TABLE messages + ADD COLUMN shouldAffectActivity INTEGER + GENERATED ALWAYS AS ( + type IS NULL + OR + type NOT IN ( + 'change-number-notification', + 'conversation-merge', + 'group-v1-migration', + 'keychange', + 'message-history-unsynced', + 'phone-number-discovery', + 'profile-change', + 'story', + 'universal-timer-notification', + 'verified-change' + ) + ); + + --- (change: added conversation-merge and phone-number-discovery + --- (now matches the above list) + ALTER TABLE messages + ADD COLUMN shouldAffectPreview INTEGER + GENERATED ALWAYS AS ( + type IS NULL + OR + type NOT IN ( + 'change-number-notification', + 'conversation-merge', + 'group-v1-migration', + 'keychange', + 'message-history-unsynced', + 'phone-number-discovery', + 'profile-change', + 'story', + 'universal-timer-notification', + 'verified-change' + ) + ); + + --- Note: This list only differs from the above on these types: + --- group-v2-change + + --- (change: added conversation-merge and phone-number-discovery + ALTER TABLE messages + ADD COLUMN isUserInitiatedMessage INTEGER + GENERATED ALWAYS AS ( + type IS NULL + OR + type NOT IN ( + 'change-number-notification', + 'conversation-merge', + 'group-v1-migration', + 'group-v2-change', + 'keychange', + 'message-history-unsynced', + 'phone-number-discovery', + 'profile-change', + 'story', + 'universal-timer-notification', + 'verified-change' + ) + ); + + CREATE INDEX messages_preview ON messages + (conversationId, shouldAffectPreview, isGroupLeaveEventFromOther, expiresAt, received_at, sent_at); + + CREATE INDEX messages_activity ON messages + (conversationId, shouldAffectActivity, isTimerChangeFromSync, isGroupLeaveEventFromOther, received_at, sent_at); + + CREATE INDEX message_user_initiated ON messages (isUserInitiatedMessage); + ` + ); + + db.pragma('user_version = 71'); + })(); + + logger.info('updateToSchemaVersion71: success!'); +} diff --git a/ts/sql/migrations/index.ts b/ts/sql/migrations/index.ts index 415543d93..6ac32e9ca 100644 --- a/ts/sql/migrations/index.ts +++ b/ts/sql/migrations/index.ts @@ -46,6 +46,7 @@ import updateToSchemaVersion67 from './67-add-story-to-unprocessed'; import updateToSchemaVersion68 from './68-drop-deprecated-columns'; import updateToSchemaVersion69 from './69-group-call-ring-cancellations'; import updateToSchemaVersion70 from './70-story-reply-index'; +import updateToSchemaVersion71 from './71-merge-notifications'; function updateToSchemaVersion1( currentVersion: number, @@ -1893,6 +1894,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion7, updateToSchemaVersion8, updateToSchemaVersion9, + updateToSchemaVersion10, updateToSchemaVersion11, updateToSchemaVersion12, @@ -1903,6 +1905,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion17, updateToSchemaVersion18, updateToSchemaVersion19, + updateToSchemaVersion20, updateToSchemaVersion21, updateToSchemaVersion22, @@ -1913,6 +1916,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion27, updateToSchemaVersion28, updateToSchemaVersion29, + updateToSchemaVersion30, updateToSchemaVersion31, updateToSchemaVersion32, @@ -1923,6 +1927,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion37, updateToSchemaVersion38, updateToSchemaVersion39, + updateToSchemaVersion40, updateToSchemaVersion41, updateToSchemaVersion42, @@ -1933,6 +1938,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion47, updateToSchemaVersion48, updateToSchemaVersion49, + updateToSchemaVersion50, updateToSchemaVersion51, updateToSchemaVersion52, @@ -1943,6 +1949,7 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion57, updateToSchemaVersion58, updateToSchemaVersion59, + updateToSchemaVersion60, updateToSchemaVersion61, updateToSchemaVersion62, @@ -1953,7 +1960,9 @@ export const SCHEMA_VERSIONS = [ updateToSchemaVersion67, updateToSchemaVersion68, updateToSchemaVersion69, + updateToSchemaVersion70, + updateToSchemaVersion71, ]; export function updateSchema(db: Database, logger: LoggerType): void { diff --git a/ts/state/ducks/accounts.ts b/ts/state/ducks/accounts.ts index b1ddfed17..94db31ebf 100644 --- a/ts/state/ducks/accounts.ts +++ b/ts/state/ducks/accounts.ts @@ -90,12 +90,14 @@ function checkForAccount( const maybePair = uuidLookup.get(phoneNumber); if (maybePair) { - uuid = window.ConversationController.maybeMergeContacts({ - aci: maybePair.aci, - pni: maybePair.pni, - e164: phoneNumber, - reason: 'checkForAccount', - })?.get('uuid'); + const { conversation: maybeMerged } = + window.ConversationController.maybeMergeContacts({ + aci: maybePair.aci, + pni: maybePair.pni, + e164: phoneNumber, + reason: 'checkForAccount', + }); + uuid = maybeMerged?.get('uuid'); } } catch (error) { log.error('checkForAccount:', Errors.toLogFormat(error)); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index be8047a86..f8d6c9217 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -26,6 +26,8 @@ import type { PropsDataType as GroupsV2Props } from '../../components/conversati import type { PropsDataType as GroupV1MigrationPropsType } from '../../components/conversation/GroupV1Migration'; import type { PropsDataType as DeliveryIssuePropsType } from '../../components/conversation/DeliveryIssueNotification'; import type { PropsType as PaymentEventNotificationPropsType } from '../../components/conversation/PaymentEventNotification'; +import type { PropsDataType as ConversationMergePropsType } from '../../components/conversation/ConversationMergeNotification'; +import type { PropsDataType as PhoneNumberDiscoveryPropsType } from '../../components/conversation/PhoneNumberDiscoveryNotification'; import type { PropsData as GroupNotificationProps, ChangeType, @@ -108,6 +110,7 @@ import { calculateExpirationTimestamp } from '../../util/expirationTimer'; import { isSignalConversation } from '../../util/isSignalConversation'; import type { AnyPaymentEvent } from '../../types/Payment'; import { isPaymentNotificationEvent } from '../../types/Payment'; +import { getTitle, renderNumber } from '../../util/getTitle'; export { isIncoming, isOutgoing, isStory }; @@ -990,6 +993,20 @@ export function getPropsForBubble( timestamp, }; } + if (isConversationMerge(message)) { + return { + type: 'conversationMerge', + data: getPropsForConversationMerge(message, options), + timestamp, + }; + } + if (isPhoneNumberDiscovery(message)) { + return { + type: 'phoneNumberDiscovery', + data: getPhoneNumberDiscovery(message, options), + timestamp, + }; + } if ( messageHasPaymentEvent(message) && @@ -1214,7 +1231,14 @@ function getPropsForSafetyNumberNotification( const conversation = getConversation(message, conversationSelector); const isGroup = conversation?.type === 'group'; const identifier = message.key_changed; - const contact = conversationSelector(identifier); + + if (isGroup && !identifier) { + throw new Error( + 'getPropsForSafetyNumberNotification: isGroup = true, but no identifier!' + ); + } + + const contact = identifier ? conversationSelector(identifier) : conversation; return { isGroup, @@ -1477,6 +1501,55 @@ export function isChatSessionRefreshed( // Note: props are null +export function isConversationMerge(message: MessageWithUIFieldsType): boolean { + return message.type === 'conversation-merge'; +} +export function getPropsForConversationMerge( + message: MessageWithUIFieldsType, + { conversationSelector }: GetPropsForBubbleOptions +): ConversationMergePropsType { + const { conversationMerge } = message; + if (!conversationMerge) { + throw new Error( + 'getPropsForConversationMerge: message is missing conversationMerge!' + ); + } + + const conversation = getConversation(message, conversationSelector); + const conversationTitle = conversation.title; + + const { type, e164 } = conversationMerge.renderInfo; + const obsoleteConversationTitle = e164 ? getTitle({ type, e164 }) : undefined; + + return { + conversationTitle, + obsoleteConversationTitle, + }; +} +export function isPhoneNumberDiscovery( + message: MessageWithUIFieldsType +): boolean { + return message.type === 'phone-number-discovery'; +} +export function getPhoneNumberDiscovery( + message: MessageWithUIFieldsType, + { conversationSelector }: GetPropsForBubbleOptions +): PhoneNumberDiscoveryPropsType { + const { phoneNumberDiscovery } = message; + if (!phoneNumberDiscovery) { + throw new Error( + 'getPhoneNumberDiscovery: message is missing phoneNumberDiscovery!' + ); + } + + const conversation = getConversation(message, conversationSelector); + const conversationTitle = conversation.title; + const sharedGroup = conversation.sharedGroupNames[0]; + const phoneNumber = renderNumber(phoneNumberDiscovery.e164); + + return { conversationTitle, sharedGroup, phoneNumber }; +} + // Delivery Issue export function isDeliveryIssue(message: MessageWithUIFieldsType): boolean { diff --git a/ts/test-electron/ConversationController_test.ts b/ts/test-electron/ConversationController_test.ts index 993d4af94..f6c9f5907 100644 --- a/ts/test-electron/ConversationController_test.ts +++ b/ts/test-electron/ConversationController_test.ts @@ -8,6 +8,7 @@ import { strictAssert } from '../util/assert'; import type { ConversationModel } from '../models/conversations'; import type { UUIDStringType } from '../types/UUID'; +import type { SafeCombineConversationsParams } from '../ConversationController'; const ACI_1 = UUID.generate().toString(); const ACI_2 = UUID.generate().toString(); @@ -26,17 +27,16 @@ type ParamsType = { describe('ConversationController', () => { describe('maybeMergeContacts', () => { - let mergeOldAndNew: (options: { - logId: string; - oldConversation: ConversationModel; - newConversation: ConversationModel; - }) => Promise; + let mergeOldAndNew: ( + options: SafeCombineConversationsParams + ) => Promise; beforeEach(async () => { await window.Signal.Data._removeAllConversations(); window.ConversationController.reset(); await window.ConversationController.load(); + await window.textsecure.storage.protocol.hydrateCaches(); mergeOldAndNew = () => { throw new Error('mergeOldAndNew: Should not be called!'); @@ -145,21 +145,23 @@ describe('ConversationController', () => { describe('non-destructive updates', () => { it('creates a new conversation with just ACI if no matches', () => { - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, }); - const second = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - reason, - }); + const { conversation: second } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + reason, + }); expectPropsAndLookups(second, 'second', { aci: ACI_1, @@ -168,21 +170,23 @@ describe('ConversationController', () => { assert.strictEqual(result?.id, second?.id, 'result and second match'); }); it('creates a new conversation with just e164 if no matches', () => { - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + reason, + }); expectPropsAndLookups(result, 'result', { e164: E164_1, }); - const second = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - reason, - }); + const { conversation: second } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + reason, + }); expectPropsAndLookups(second, 'second', { e164: E164_1, @@ -191,28 +195,30 @@ describe('ConversationController', () => { assert.strictEqual(result?.id, second?.id, 'result and second match'); }); it('creates a new conversation with e164+PNI if no matches', () => { - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { - aci: PNI_1, + uuid: PNI_1, e164: E164_1, pni: PNI_1, }); - const second = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: second } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(second, 'second', { - aci: PNI_1, + uuid: PNI_1, e164: E164_1, pni: PNI_1, }); @@ -220,13 +226,14 @@ describe('ConversationController', () => { assert.strictEqual(result?.id, second?.id, 'result and second match'); }); it('creates a new conversation with all data if no matches', () => { - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, @@ -234,13 +241,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const second = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: second } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(second, 'second', { uuid: ACI_1, @@ -258,11 +266,12 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, @@ -280,12 +289,13 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, @@ -302,13 +312,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -328,12 +339,13 @@ describe('ConversationController', () => { e164: E164_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -348,13 +360,14 @@ describe('ConversationController', () => { uuid: ACI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -369,13 +382,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -390,13 +404,14 @@ describe('ConversationController', () => { e164: E164_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -410,12 +425,13 @@ describe('ConversationController', () => { e164: E164_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: PNI_1, e164: E164_1, @@ -429,13 +445,14 @@ describe('ConversationController', () => { e164: E164_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { aci: ACI_1, e164: E164_1, @@ -449,13 +466,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { aci: ACI_1, e164: E164_1, @@ -475,12 +493,13 @@ describe('ConversationController', () => { e164: E164_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: PNI_1, e164: E164_1, @@ -499,13 +518,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_2, - pni: PNI_2, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_2, + pni: PNI_2, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_2, @@ -530,12 +550,14 @@ describe('ConversationController', () => { e164: E164_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - pni: PNI_2, - e164: E164_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: PNI_2, + pni: PNI_2, + e164: E164_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: PNI_2, e164: E164_1, @@ -556,13 +578,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_2, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_2, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -584,13 +607,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_2, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_2, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_2, e164: E164_1, @@ -615,13 +639,14 @@ describe('ConversationController', () => { uuid: ACI_2, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_2, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_2, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(aciOnly, 'aciOnly', { uuid: ACI_2, e164: E164_1, @@ -646,12 +671,13 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: PNI_1, e164: E164_1, @@ -672,12 +698,13 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_2, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_2, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: PNI_1, e164: E164_2, @@ -693,10 +720,8 @@ describe('ConversationController', () => { ); }); it('deletes PNI-only previous conversation, adds it to e164 match', () => { - mergeOldAndNew = ({ oldConversation }) => { - window.ConversationController.dangerouslyRemoveById( - oldConversation.id - ); + mergeOldAndNew = ({ obsolete }) => { + window.ConversationController.dangerouslyRemoveById(obsolete.id); return Promise.resolve(); }; @@ -707,12 +732,13 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: PNI_1, e164: E164_1, @@ -728,10 +754,8 @@ describe('ConversationController', () => { ); }); it('deletes previous conversation with PNI as UUID only, adds it to e164 match', () => { - mergeOldAndNew = ({ oldConversation }) => { - window.ConversationController.dangerouslyRemoveById( - oldConversation.id - ); + mergeOldAndNew = ({ obsolete }) => { + window.ConversationController.dangerouslyRemoveById(obsolete.id); return Promise.resolve(); }; @@ -742,12 +766,13 @@ describe('ConversationController', () => { uuid: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: PNI_1, e164: E164_1, @@ -763,10 +788,8 @@ describe('ConversationController', () => { ); }); it('deletes e164+PNI previous conversation, adds data to ACI match', () => { - mergeOldAndNew = ({ oldConversation }) => { - window.ConversationController.dangerouslyRemoveById( - oldConversation.id - ); + mergeOldAndNew = ({ obsolete }) => { + window.ConversationController.dangerouslyRemoveById(obsolete.id); return Promise.resolve(); }; @@ -778,13 +801,14 @@ describe('ConversationController', () => { aci: ACI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -813,13 +837,14 @@ describe('ConversationController', () => { e164: E164_2, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -831,10 +856,8 @@ describe('ConversationController', () => { assert.strictEqual(result?.id, withACI?.id, 'result and withACI match'); }); it('handles three matching conversations: ACI-only, E164-only (deleted), and with PNI', () => { - mergeOldAndNew = ({ oldConversation }) => { - window.ConversationController.dangerouslyRemoveById( - oldConversation.id - ); + mergeOldAndNew = ({ obsolete }) => { + window.ConversationController.dangerouslyRemoveById(obsolete.id); return Promise.resolve(); }; @@ -849,13 +872,14 @@ describe('ConversationController', () => { e164: E164_2, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, @@ -868,10 +892,8 @@ describe('ConversationController', () => { assert.strictEqual(result?.id, withACI?.id, 'result and withACI match'); }); it('merges three matching conversations: ACI-only, E164-only (deleted), PNI-only (deleted)', () => { - mergeOldAndNew = ({ oldConversation }) => { - window.ConversationController.dangerouslyRemoveById( - oldConversation.id - ); + mergeOldAndNew = ({ obsolete }) => { + window.ConversationController.dangerouslyRemoveById(obsolete.id); return Promise.resolve(); }; @@ -885,13 +907,14 @@ describe('ConversationController', () => { pni: PNI_1, }); - const result = window.ConversationController.maybeMergeContacts({ - mergeOldAndNew, - aci: ACI_1, - e164: E164_1, - pni: PNI_1, - reason, - }); + const { conversation: result } = + window.ConversationController.maybeMergeContacts({ + mergeOldAndNew, + aci: ACI_1, + e164: E164_1, + pni: PNI_1, + reason, + }); expectPropsAndLookups(result, 'result', { uuid: ACI_1, e164: E164_1, diff --git a/ts/test-electron/textsecure/KeyChangeListener_test.ts b/ts/test-electron/textsecure/KeyChangeListener_test.ts index aa08aab61..bdd14072f 100644 --- a/ts/test-electron/textsecure/KeyChangeListener_test.ts +++ b/ts/test-electron/textsecure/KeyChangeListener_test.ts @@ -10,6 +10,7 @@ import { UUID } from '../../types/UUID'; import { SignalProtocolStore } from '../../SignalProtocolStore'; import type { ConversationModel } from '../../models/conversations'; import * as KeyChangeListener from '../../textsecure/KeyChangeListener'; +import * as Bytes from '../../Bytes'; describe('KeyChangeListener', () => { let oldNumberId: string | undefined; @@ -54,11 +55,10 @@ describe('KeyChangeListener', () => { window.ConversationController.reset(); await window.ConversationController.load(); - convo = window.ConversationController.dangerouslyCreateAndAdd({ - id: uuidWithKeyChange, - type: 'private', - }); - await window.Signal.Data.saveConversation(convo.attributes); + convo = await window.ConversationController.getOrCreateAndWait( + uuidWithKeyChange, + 'private' + ); store = new SignalProtocolStore(); await store.hydrateCaches(); @@ -78,8 +78,7 @@ describe('KeyChangeListener', () => { describe('When we have a conversation with this contact', () => { it('generates a key change notice in the private conversation with this contact', done => { const original = convo.addKeyChange; - convo.addKeyChange = async keyChangedId => { - assert.equal(uuidWithKeyChange, keyChangedId.toString()); + convo.addKeyChange = async () => { convo.addKeyChange = original; done(); }; @@ -91,12 +90,15 @@ describe('KeyChangeListener', () => { let groupConvo: ConversationModel; beforeEach(async () => { - groupConvo = window.ConversationController.dangerouslyCreateAndAdd({ - id: 'groupId', - type: 'group', - members: [convo.id], - }); - await window.Signal.Data.saveConversation(groupConvo.attributes); + groupConvo = await window.ConversationController.getOrCreateAndWait( + Bytes.toBinary( + new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5]) + ), + 'group', + { + members: [uuidWithKeyChange], + } + ); }); afterEach(async () => { @@ -108,8 +110,8 @@ describe('KeyChangeListener', () => { it('generates a key change notice in the group conversation with this contact', done => { const original = groupConvo.addKeyChange; - groupConvo.addKeyChange = async keyChangedId => { - assert.equal(uuidWithKeyChange, keyChangedId.toString()); + groupConvo.addKeyChange = async (_, keyChangedId) => { + assert.equal(uuidWithKeyChange, keyChangedId?.toString()); groupConvo.addKeyChange = original; done(); }; diff --git a/ts/test-electron/updateConversationsWithUuidLookup_test.ts b/ts/test-electron/updateConversationsWithUuidLookup_test.ts index a3e299474..54f661dc6 100644 --- a/ts/test-electron/updateConversationsWithUuidLookup_test.ts +++ b/ts/test-electron/updateConversationsWithUuidLookup_test.ts @@ -35,7 +35,10 @@ describe('updateConversationsWithUuidLookup', () => { e164?: string | null; aci?: string | null; reason?: string; - }): ConversationModel | undefined { + }): { + conversation: ConversationModel | undefined; + mergePromises: Array>; + } { assert( e164, 'FakeConversationController is not set up for this case (E164 must be provided)' @@ -59,21 +62,21 @@ describe('updateConversationsWithUuidLookup', () => { if (convoE164 && convoUuid) { if (convoE164 === convoUuid) { - return convoUuid; + return { conversation: convoUuid, mergePromises: [] }; } convoE164.unset('e164'); convoUuid.updateE164(e164); - return convoUuid; + return { conversation: convoUuid, mergePromises: [] }; } if (convoE164 && !convoUuid) { convoE164.updateUuid(normalizedUuid); - return convoE164; + return { conversation: convoE164, mergePromises: [] }; } assert.fail('FakeConversationController should never get here'); - return undefined; + return { conversation: undefined, mergePromises: [] }; } lookupOrCreate({ diff --git a/ts/test-mock/playwright.ts b/ts/test-mock/playwright.ts index e860d142c..7f73211b3 100644 --- a/ts/test-mock/playwright.ts +++ b/ts/test-mock/playwright.ts @@ -23,6 +23,10 @@ export type ConversationOpenInfoType = Readonly<{ delta: number; }>; +export type StorageServiceInfoType = Readonly<{ + manifestVersion: number; +}>; + export type AppOptionsType = Readonly<{ main: string; args: ReadonlyArray; @@ -66,6 +70,21 @@ export class App { return this.waitForEvent('challenge'); } + public async waitForStorageService(): Promise { + return this.waitForEvent('storageServiceComplete'); + } + + public async waitForManifestVersion(version: number): Promise { + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { manifestVersion } = await this.waitForStorageService(); + if (manifestVersion >= version) { + break; + } + } + } + public async solveChallenge(response: ChallengeResponseType): Promise { const window = await this.getWindow(); diff --git a/ts/test-mock/pnp/learn_test.ts b/ts/test-mock/pnp/learn_test.ts new file mode 100644 index 000000000..3c128a46f --- /dev/null +++ b/ts/test-mock/pnp/learn_test.ts @@ -0,0 +1,239 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { UUIDKind, Proto, StorageState } from '@signalapp/mock-server'; +import type { PrimaryDevice } from '@signalapp/mock-server'; +import createDebug from 'debug'; + +import * as durations from '../../util/durations'; +import { Bootstrap } from '../bootstrap'; +import type { App } from '../bootstrap'; + +export const debug = createDebug('mock:test:pni-signature'); + +describe('pnp/learn', function needsName() { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + let contactA: PrimaryDevice; + + beforeEach(async () => { + bootstrap = new Bootstrap(); + await bootstrap.init(); + + const { server, phone } = bootstrap; + + contactA = await server.createPrimaryDevice({ + profileName: 'contactA', + }); + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + e164: phone.device.number, + }); + + state = state.addContact( + contactA, + { + whitelisted: false, + identityKey: contactA.getPublicKey(UUIDKind.ACI).serialize(), + serviceE164: undefined, + givenName: 'ContactA', + }, + UUIDKind.ACI + ); + + // Just to make PNI Contact visible in the left pane + state = state.pin(contactA, UUIDKind.ACI); + + await phone.setStorageState(state); + + app = await bootstrap.link(); + }); + + afterEach(async function after() { + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(app); + } + + await app.close(); + await bootstrap.teardown(); + }); + + it('shows Learned Number notification if we find out number later', async () => { + const { desktop, phone } = bootstrap; + + const window = await app.getWindow(); + + debug('Open conversation with contactA'); + { + const leftPane = window.locator('.left-pane-wrapper'); + + await leftPane + .locator('_react=ConversationListItem[title = "ContactA"]') + .click(); + + await window.locator('.module-conversation-hero').waitFor(); + } + + debug('Verify starting state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + + debug('Send message to contactA'); + { + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('message to contactA'); + await compositionInput.press('Enter'); + } + + debug('Wait for the message to contactA'); + { + const { source, body } = await contactA.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual( + body, + 'message to contactA', + 'message must have correct body' + ); + } + + debug('Add phone number to contactA via storage service'); + { + const state = await phone.expectStorageState('consistency check'); + const updated = await phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + contactA.device.getUUIDByKind(UUIDKind.ACI) + ) + .addContact( + contactA, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + identityKey: contactA.getPublicKey(UUIDKind.ACI).serialize(), + givenName: 'ContactA', + serviceE164: contactA.device.number, + }, + UUIDKind.ACI + ) + ); + + const updatedStorageVersion = updated.version; + + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + await app.waitForManifestVersion(updatedStorageVersion); + } + + debug('Verify final state'); + { + // One outgoing message + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 1, 'messages'); + + // One 'learned number' notification + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 1, 'notifications'); + + const first = await notifications.first(); + assert.match(await first.innerText(), /belongs to ContactA$/); + } + }); + + it('Does not show Learned Number notification if no sent, not in allowlist', async () => { + const { phone } = bootstrap; + + const window = await app.getWindow(); + + debug('Open conversation with contactA'); + { + const leftPane = window.locator('.left-pane-wrapper'); + + await leftPane + .locator('_react=ConversationListItem[title = "ContactA"]') + .click(); + + await window.locator('.module-conversation-hero').waitFor(); + } + + debug('Verify starting state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + + debug('Add phone number to contactA via storage service'); + { + const state = await phone.expectStorageState('consistency check'); + const updated = await phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + contactA.device.getUUIDByKind(UUIDKind.ACI) + ) + .addContact( + contactA, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: false, + identityKey: contactA.getPublicKey(UUIDKind.ACI).serialize(), + givenName: 'ContactA', + serviceE164: contactA.device.number, + }, + UUIDKind.ACI + ) + ); + + const updatedStorageVersion = updated.version; + + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + await app.waitForManifestVersion(updatedStorageVersion); + } + + debug('Verify final state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'messages'); + + // No 'learned number' notification + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notifications'); + } + }); +}); diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts new file mode 100644 index 000000000..0dc1fd655 --- /dev/null +++ b/ts/test-mock/pnp/merge_test.ts @@ -0,0 +1,179 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { UUIDKind, Proto, StorageState } from '@signalapp/mock-server'; +import type { PrimaryDevice } from '@signalapp/mock-server'; +import createDebug from 'debug'; + +import * as durations from '../../util/durations'; +import { uuidToBytes } from '../../util/uuidToBytes'; +import { MY_STORY_ID } from '../../types/Stories'; +import { Bootstrap } from '../bootstrap'; +import type { App } from '../bootstrap'; + +export const debug = createDebug('mock:test:merge'); + +const IdentifierType = Proto.ManifestRecord.Identifier.Type; + +describe('pnp/merge', function needsName() { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + let pniContact: PrimaryDevice; + let pniIdentityKey: Uint8Array; + let aciIdentityKey: Uint8Array; + + beforeEach(async () => { + bootstrap = new Bootstrap(); + await bootstrap.init(); + + const { server, phone } = bootstrap; + + pniContact = await server.createPrimaryDevice({ + profileName: 'ACI Contact', + }); + pniIdentityKey = pniContact.getPublicKey(UUIDKind.PNI).serialize(); + aciIdentityKey = pniContact.publicKey.serialize(); + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + e164: phone.device.number, + }); + + state = state.addContact( + pniContact, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + + identityKey: pniIdentityKey, + + serviceE164: pniContact.device.number, + givenName: 'PNI Contact', + }, + UUIDKind.PNI + ); + + state = state.addContact(pniContact, { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + + serviceE164: undefined, + identityKey: aciIdentityKey, + profileKey: pniContact.profileKey.serialize(), + }); + + // Put both contacts in left pane + state = state.pin(pniContact, UUIDKind.PNI); + state = state.pin(pniContact, UUIDKind.ACI); + + // Add my story + state = state.addRecord({ + type: IdentifierType.STORY_DISTRIBUTION_LIST, + record: { + storyDistributionList: { + allowsReplies: true, + identifier: uuidToBytes(MY_STORY_ID), + isBlockList: true, + name: MY_STORY_ID, + recipientUuids: [], + }, + }, + }); + + await phone.setStorageState(state); + + app = await bootstrap.link(); + }); + + afterEach(async function after() { + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(app); + } + + await app.close(); + await bootstrap.teardown(); + }); + + it('happens via storage service, with notification', async () => { + const { phone } = bootstrap; + + const window = await app.getWindow(); + + debug('opening conversation with the pni contact'); + { + const leftPane = window.locator('.left-pane-wrapper'); + + await leftPane + .locator('_react=ConversationListItem[title = "PNI Contact"]') + .click(); + + await window.locator('.module-conversation-hero').waitFor(); + } + + debug('Verify starting state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + + debug( + 'removing both contacts from storage service, adding one combined contact' + ); + { + const state = await phone.expectStorageState('consistency check'); + phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + pniContact.device.getUUIDByKind(UUIDKind.ACI) + ) + .removeRecord( + item => + item.record.contact?.serviceUuid === + pniContact.device.getUUIDByKind(UUIDKind.PNI) + ) + .addContact(pniContact, { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + pni: pniContact.device.getUUIDByKind(UUIDKind.PNI), + identityKey: pniContact.publicKey.serialize(), + profileKey: pniContact.profileKey.serialize(), + }) + ); + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + } + + // wait for desktop to process these changes + await window.locator('.SystemMessage').waitFor(); + + debug('Verify final state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // One notification - the merge + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 1, 'notification count'); + + const first = await notifications.first(); + assert.match( + await first.innerText(), + /and ACI Contact are the same account. Your message history for both chats are here./ + ); + } + }); +}); diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts new file mode 100644 index 000000000..c04252139 --- /dev/null +++ b/ts/test-mock/pnp/pni_change_test.ts @@ -0,0 +1,575 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { UUIDKind, StorageState, Proto } from '@signalapp/mock-server'; +import type { PrimaryDevice } from '@signalapp/mock-server'; +import createDebug from 'debug'; + +import * as durations from '../../util/durations'; +import { Bootstrap } from '../bootstrap'; +import type { App } from '../bootstrap'; +import { UUID } from '../../types/UUID'; + +export const debug = createDebug('mock:test:pni-change'); + +describe('pnp/PNI Change', function needsName() { + this.timeout(durations.MINUTE); + + let bootstrap: Bootstrap; + let app: App; + let contactA: PrimaryDevice; + let contactB: PrimaryDevice; + + beforeEach(async () => { + bootstrap = new Bootstrap(); + await bootstrap.init(); + + const { server, phone } = bootstrap; + + contactA = await server.createPrimaryDevice({ + profileName: 'contactA', + }); + contactB = await server.createPrimaryDevice({ + profileName: 'contactB', + }); + + let state = StorageState.getEmpty(); + + state = state.updateAccount({ + profileKey: phone.profileKey.serialize(), + e164: phone.device.number, + }); + + state = state.addContact( + contactA, + { + whitelisted: true, + serviceE164: contactA.device.number, + identityKey: contactA.getPublicKey(UUIDKind.PNI).serialize(), + pni: contactA.device.getUUIDByKind(UUIDKind.PNI), + givenName: 'ContactA', + }, + UUIDKind.PNI + ); + + // Just to make PNI Contact visible in the left pane + state = state.pin(contactA, UUIDKind.PNI); + + await phone.setStorageState(state); + + app = await bootstrap.link(); + }); + + afterEach(async function after() { + if (this.currentTest?.state !== 'passed') { + await bootstrap.saveLogs(app); + } + + await app.close(); + await bootstrap.teardown(); + }); + + it('shows no identity change if identity key is the same', async () => { + const { desktop, phone } = bootstrap; + + const window = await app.getWindow(); + + debug('Open conversation with contactA'); + { + const leftPane = window.locator('.left-pane-wrapper'); + + await leftPane + .locator('_react=ConversationListItem[title = "ContactA"]') + .click(); + + await window.locator('.module-conversation-hero').waitFor(); + } + + debug('Verify starting state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + + debug('Send message to contactA'); + { + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('message to contactA'); + await compositionInput.press('Enter'); + } + + debug('Wait for the message to contactA'); + { + const { source, body } = await contactA.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual( + body, + 'message to contactA', + 'message must have correct body' + ); + } + + debug('Update pni on contactA via storage service'); + { + const updatedUuid = UUID.generate().toString(); + + const state = await phone.expectStorageState('consistency check'); + const updated = await phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + contactA.device.getUUIDByKind(UUIDKind.PNI) + ) + .addContact( + contactA, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + serviceE164: contactA.device.number, + serviceUuid: updatedUuid, + pni: updatedUuid, + identityKey: contactA.getPublicKey(UUIDKind.PNI).serialize(), + }, + UUIDKind.PNI + ) + ); + + const updatedStorageVersion = updated.version; + + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + await app.waitForManifestVersion(updatedStorageVersion); + } + + debug('Verify final state'); + { + // One sent message + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 1, 'message count'); + + // No notifications - PNI changed, but identity key is the same + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + }); + + it('shows identity change if identity key has changed', async () => { + const { desktop, phone } = bootstrap; + + const window = await app.getWindow(); + + debug('Open conversation with contactA'); + { + const leftPane = window.locator('.left-pane-wrapper'); + + await leftPane + .locator('_react=ConversationListItem[title = "ContactA"]') + .click(); + + await window.locator('.module-conversation-hero').waitFor(); + } + + debug('Verify starting state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + + debug('Send message to contactA'); + { + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('message to contactA'); + await compositionInput.press('Enter'); + } + + debug('Wait for the message to contactA'); + { + const { source, body } = await contactA.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual( + body, + 'message to contactA', + 'message must have correct body' + ); + } + + debug('Switch e164 to contactB via storage service'); + { + const state = await phone.expectStorageState('consistency check'); + const updated = await phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + contactA.device.getUUIDByKind(UUIDKind.PNI) + ) + .addContact( + contactB, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + serviceE164: contactA.device.number, + pni: contactB.device.getUUIDByKind(UUIDKind.PNI), + + // Key change - different identity key + identityKey: contactB.publicKey.serialize(), + }, + UUIDKind.PNI + ) + ); + + const updatedStorageVersion = updated.version; + + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + await app.waitForManifestVersion(updatedStorageVersion); + } + + debug('Verify final state'); + { + // One sent message + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 1, 'message count'); + + // One notification - the safety number change + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 1, 'notification count'); + + const first = await notifications.first(); + assert.match(await first.innerText(), /Safety Number has changed/); + } + }); + + it('shows identity change when sending to contact', async () => { + const { desktop, phone } = bootstrap; + + const window = await app.getWindow(); + + debug('Open conversation with contactA'); + { + const leftPane = window.locator('.left-pane-wrapper'); + + await leftPane + .locator('_react=ConversationListItem[title = "ContactA"]') + .click(); + + await window.locator('.module-conversation-hero').waitFor(); + } + + debug('Verify starting state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + + debug('Send message to contactA'); + { + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('message to contactA'); + await compositionInput.press('Enter'); + } + + debug('Wait for the message to contactA'); + { + const { source, body } = await contactA.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual( + body, + 'message to contactA', + 'message must have correct body' + ); + } + + debug('Switch e164 to contactB via storage service'); + { + const state = await phone.expectStorageState('consistency check'); + const updated = await phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + contactA.device.getUUIDByKind(UUIDKind.PNI) + ) + .addContact( + contactB, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + serviceE164: contactA.device.number, + pni: contactB.device.getUUIDByKind(UUIDKind.PNI), + + // Note: No identityKey key provided here! + }, + UUIDKind.PNI + ) + ); + + const updatedStorageVersion = updated.version; + + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + await app.waitForManifestVersion(updatedStorageVersion); + } + + debug('Send message to contactB'); + { + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('message to contactB'); + await compositionInput.press('Enter'); + + // We get a safety number change warning, because we get a different identity key! + await window + .locator('.module-SafetyNumberChangeDialog__confirm-dialog') + .waitFor(); + + await window.locator('.module-Button--primary').click(); + } + + debug('Wait for the message to contactB'); + { + const { source, body } = await contactB.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual( + body, + 'message to contactB', + 'message must have correct body' + ); + } + + debug('Verify final state'); + { + // First message and second message + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 2, 'message count'); + + // One notification - the safety number change + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 1, 'notification count'); + + const first = await notifications.first(); + assert.match(await first.innerText(), /Safety Number has changed/); + } + }); + + it('Sends with no warning when key is the same', async () => { + const { desktop, phone } = bootstrap; + + const window = await app.getWindow(); + + debug('Open conversation with contactA'); + { + const leftPane = window.locator('.left-pane-wrapper'); + + await leftPane + .locator('_react=ConversationListItem[title = "ContactA"]') + .click(); + + await window.locator('.module-conversation-hero').waitFor(); + } + + debug('Verify starting state'); + { + // No messages + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 0, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + + debug('Send message to contactA'); + { + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('message to contactA'); + await compositionInput.press('Enter'); + } + + debug('Wait for the message to contactA'); + { + const { source, body } = await contactA.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual( + body, + 'message to contactA', + 'message must have correct body' + ); + } + + debug('Switch e164 to contactB via storage service'); + { + const state = await phone.expectStorageState('consistency check'); + const updated = await phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + contactA.device.getUUIDByKind(UUIDKind.PNI) + ) + .addContact( + contactB, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + serviceE164: contactA.device.number, + pni: contactB.device.getUUIDByKind(UUIDKind.PNI), + + // Note: No identityKey key provided here! + }, + UUIDKind.PNI + ) + ); + + const updatedStorageVersion = updated.version; + + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + await app.waitForManifestVersion(updatedStorageVersion); + } + + debug('Switch e164 back to contactA via storage service'); + { + const state = await phone.expectStorageState('consistency check'); + const updated = await phone.setStorageState( + state + .removeRecord( + item => + item.record.contact?.serviceUuid === + contactB.device.getUUIDByKind(UUIDKind.PNI) + ) + .addContact( + contactB, + { + identityState: Proto.ContactRecord.IdentityState.DEFAULT, + whitelisted: true, + serviceE164: contactA.device.number, + pni: contactA.device.getUUIDByKind(UUIDKind.PNI), + }, + UUIDKind.PNI + ) + ); + + const updatedStorageVersion = updated.version; + + await phone.sendFetchStorage({ + timestamp: bootstrap.getTimestamp(), + }); + + await app.waitForManifestVersion(updatedStorageVersion); + } + + debug('Send message to contactA'); + { + const composeArea = window.locator( + '.composition-area-wrapper, ' + + '.ConversationView__template .react-wrapper' + ); + const compositionInput = composeArea.locator('_react=CompositionInput'); + + await compositionInput.type('second message to contactA'); + await compositionInput.press('Enter'); + } + + debug('Wait for the message to contactA'); + { + const { source, body } = await contactA.waitForMessage(); + + assert.strictEqual( + source, + desktop, + 'first message must have valid source' + ); + assert.strictEqual( + body, + 'second message to contactA', + 'message must have correct body' + ); + } + + debug('Verify final state'); + { + // First message and second message + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 2, 'message count'); + + // No notifications - the key is the same + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } + }); +}); diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts index 3425e1ef7..206fed6d7 100644 --- a/ts/test-mock/pnp/pni_signature_test.ts +++ b/ts/test-mock/pnp/pni_signature_test.ts @@ -249,6 +249,17 @@ describe('pnp/PNI Signature', function needsName() { 'third message must not have pni signature message' ); } + + debug('Verify final state'); + { + // One incoming, three outgoing + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 4, 'message count'); + + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 0, 'notification count'); + } }); it('should be received by Desktop and trigger contact merge', async () => { @@ -345,6 +356,27 @@ describe('pnp/PNI Signature', function needsName() { assert.strictEqual(aci?.serviceUuid, pniContact.device.uuid); assert.strictEqual(aci?.pni, pniContact.device.pni); + + // Two outgoing, one incoming + const messages = window.locator('.module-message__text'); + assert.strictEqual(await messages.count(), 3, 'messages'); + + // Two 'verify contact' and nothing else + const notifications = window.locator('.SystemMessage'); + assert.strictEqual(await notifications.count(), 2, 'notifications'); + + // TODO: DESKTOP-4663 + const first = await notifications.first(); + assert.match( + await first.innerText(), + /You marked your Safety Number with Unknown contact as verified from another device/ + ); + + const second = await notifications.nth(1); + assert.match( + await second.innerText(), + /You marked your Safety Number with ACI Contact as verified from another device/ + ); } }); }); diff --git a/ts/test-node/sql_migrations_test.ts b/ts/test-node/sql_migrations_test.ts index e2dcfed9d..c2f6b4bf7 100644 --- a/ts/test-node/sql_migrations_test.ts +++ b/ts/test-node/sql_migrations_test.ts @@ -2422,4 +2422,76 @@ describe('SQL migrations test', () => { }); }); }); + + describe('updateToSchemaVersion71', () => { + it('deletes and re-creates auto-generated shouldAffectActivity/shouldAffectPreview/isUserInitiatedMessage fields', () => { + const MESSAGE_ID_0 = generateGuid(); + const MESSAGE_ID_1 = generateGuid(); + const MESSAGE_ID_2 = generateGuid(); + const MESSAGE_ID_3 = generateGuid(); + const MESSAGE_ID_4 = generateGuid(); + const MESSAGE_ID_5 = generateGuid(); + const MESSAGE_ID_6 = generateGuid(); + const MESSAGE_ID_7 = generateGuid(); + const CONVERSATION_ID = generateGuid(); + + updateToVersion(71); + + db.exec( + ` + INSERT INTO messages + (id, conversationId, type) + VALUES + ('${MESSAGE_ID_0}', '${CONVERSATION_ID}', NULL), + ('${MESSAGE_ID_1}', '${CONVERSATION_ID}', 'story'), + ('${MESSAGE_ID_2}', '${CONVERSATION_ID}', 'keychange'), + ('${MESSAGE_ID_3}', '${CONVERSATION_ID}', 'outgoing'), + ('${MESSAGE_ID_4}', '${CONVERSATION_ID}', 'group-v2-change'), + ('${MESSAGE_ID_5}', '${CONVERSATION_ID}', 'phone-number-discovery'), + ('${MESSAGE_ID_6}', '${CONVERSATION_ID}', 'conversation-merge'), + ('${MESSAGE_ID_7}', '${CONVERSATION_ID}', 'incoming'); + ` + ); + + assert.strictEqual( + db.prepare('SELECT COUNT(*) FROM messages;').pluck().get(), + 8, + 'total' + ); + + // Four: NULL, incoming, outgoing, and group-v2-change + assert.strictEqual( + db + .prepare( + 'SELECT COUNT(*) FROM messages WHERE shouldAffectPreview IS 1;' + ) + .pluck() + .get(), + 4, + 'shouldAffectPreview' + ); + assert.strictEqual( + db + .prepare( + 'SELECT COUNT(*) FROM messages WHERE shouldAffectActivity IS 1;' + ) + .pluck() + .get(), + 4, + 'shouldAffectActivity' + ); + + // Three: NULL, incoming, outgoing + assert.strictEqual( + db + .prepare( + 'SELECT COUNT(*) FROM messages WHERE isUserInitiatedMessage IS 1;' + ) + .pluck() + .get(), + 3, + 'isUserInitiatedMessage' + ); + }); + }); }); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index d4bca9006..90cac426d 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -620,15 +620,15 @@ export default class AccountManager extends EventTarget { // This needs to be done very early, because it changes how things are saved in the // database. Your identity, for example, in the saveIdentityWithAttributes call // below. - const conversationId = window.ConversationController.maybeMergeContacts({ + const { conversation } = window.ConversationController.maybeMergeContacts({ aci: ourUuid, pni: ourPni, e164: number, reason: 'createAccount', }); - if (!conversationId) { - throw new Error('registrationDone: no conversationId!'); + if (!conversation) { + throw new Error('registrationDone: no conversation!'); } const identityAttrs = { diff --git a/ts/textsecure/KeyChangeListener.ts b/ts/textsecure/KeyChangeListener.ts index 7a4394c17..abda4861e 100644 --- a/ts/textsecure/KeyChangeListener.ts +++ b/ts/textsecure/KeyChangeListener.ts @@ -5,17 +5,15 @@ import type { UUID } from '../types/UUID'; import type { SignalProtocolStore } from '../SignalProtocolStore'; export function init(signalProtocolStore: SignalProtocolStore): void { - signalProtocolStore.on('keychange', async (uuid: UUID): Promise => { - const conversation = await window.ConversationController.getOrCreateAndWait( - uuid.toString(), - 'private' - ); - conversation.addKeyChange(uuid); - - const groups = - await window.ConversationController.getAllGroupsInvolvingUuid(uuid); - for (const group of groups) { - group.addKeyChange(uuid); + signalProtocolStore.on( + 'keychange', + async (uuid: UUID, reason: string): Promise => { + const conversation = + await window.ConversationController.getOrCreateAndWait( + uuid.toString(), + 'private' + ); + conversation.addKeyChange(reason); } - }); + ); } diff --git a/ts/textsecure/MessageReceiver.ts b/ts/textsecure/MessageReceiver.ts index 1194cedfe..fe21f8b1b 100644 --- a/ts/textsecure/MessageReceiver.ts +++ b/ts/textsecure/MessageReceiver.ts @@ -1154,9 +1154,11 @@ export default class MessageReceiver logId = getEnvelopeId(unsealedEnvelope); + const taskId = `dispatchEvent(EnvelopeEvent(${logId}))`; this.addToQueue( - async () => this.dispatchEvent(new EnvelopeEvent(unsealedEnvelope)), - `dispatchEvent(EnvelopeEvent(${logId}))`, + async () => + this.dispatchAndWait(taskId, new EnvelopeEvent(unsealedEnvelope)), + taskId, TaskType.Decrypted ); @@ -2514,12 +2516,18 @@ export default class MessageReceiver if (isValid) { log.info(`${logId}: merging pni=${pni} aci=${aci}`); - window.ConversationController.maybeMergeContacts({ - pni, - aci, - e164: window.ConversationController.get(pni)?.get('e164'), - reason: logId, - }); + const { mergePromises } = + window.ConversationController.maybeMergeContacts({ + pni, + aci, + e164: window.ConversationController.get(pni)?.get('e164'), + fromPniSignature: true, + reason: logId, + }); + + if (mergePromises.length) { + await Promise.all(mergePromises); + } } } diff --git a/ts/updateConversationsWithUuidLookup.ts b/ts/updateConversationsWithUuidLookup.ts index 7bd2b2fc3..4f53e82f3 100644 --- a/ts/updateConversationsWithUuidLookup.ts +++ b/ts/updateConversationsWithUuidLookup.ts @@ -40,7 +40,7 @@ export async function updateConversationsWithUuidLookup({ const pairFromServer = serverLookup.get(e164); if (pairFromServer) { - const maybeFinalConversation = + const { conversation: maybeFinalConversation } = conversationController.maybeMergeContacts({ aci: pairFromServer.aci, pni: pairFromServer.pni, diff --git a/ts/util/getStringForConversationMerge.ts b/ts/util/getStringForConversationMerge.ts new file mode 100644 index 000000000..4bcd695ef --- /dev/null +++ b/ts/util/getStringForConversationMerge.ts @@ -0,0 +1,25 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { LocalizerType } from '../types/Util'; + +export function getStringForConversationMerge({ + obsoleteConversationTitle, + conversationTitle, + i18n, +}: { + obsoleteConversationTitle: string | undefined; + conversationTitle: string; + i18n: LocalizerType; +}): string { + if (!obsoleteConversationTitle) { + return i18n('icu:ConversationMerge--notification--no-e164', { + conversationTitle, + }); + } + + return i18n('icu:ConversationMerge--notification', { + obsoleteConversationTitle, + conversationTitle, + }); +} diff --git a/ts/util/getStringForPhoneNumberDiscovery.ts b/ts/util/getStringForPhoneNumberDiscovery.ts new file mode 100644 index 000000000..d8291d926 --- /dev/null +++ b/ts/util/getStringForPhoneNumberDiscovery.ts @@ -0,0 +1,29 @@ +// Copyright 2020 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { LocalizerType } from '../types/Util'; + +export function getStringForPhoneNumberDiscovery({ + phoneNumber, + i18n, + conversationTitle, + sharedGroup, +}: { + phoneNumber: string; + i18n: LocalizerType; + conversationTitle: string; + sharedGroup?: string; +}): string { + if (sharedGroup) { + return i18n('icu:PhoneNumberDiscovery--notification--withSharedGroup', { + phoneNumber, + conversationTitle, + sharedGroup, + }); + } + + return i18n('icu:PhoneNumberDiscovery--notification--noSharedGroup', { + phoneNumber, + conversationTitle, + }); +} diff --git a/ts/util/getTitle.ts b/ts/util/getTitle.ts new file mode 100644 index 000000000..5d7190bea --- /dev/null +++ b/ts/util/getTitle.ts @@ -0,0 +1,92 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { + ConversationAttributesType, + ConversationRenderInfoType, +} from '../model-types.d'; +import { combineNames } from './combineNames'; +import { getRegionCodeForNumber } from './libphonenumberUtil'; +import { isDirectConversation } from './whatTypeOfConversation'; + +export function getTitle( + attributes: ConversationRenderInfoType, + options?: { isShort?: boolean } +): string { + const title = getTitleNoDefault(attributes, options); + if (title) { + return title; + } + + if (isDirectConversation(attributes)) { + return window.i18n('unknownContact'); + } + return window.i18n('unknownGroup'); +} + +export function getTitleNoDefault( + attributes: ConversationRenderInfoType, + { isShort = false }: { isShort?: boolean } = {} +): string | undefined { + if (!isDirectConversation(attributes)) { + return attributes.name; + } + + const { username } = attributes; + + return ( + (isShort ? attributes.systemGivenName : undefined) || + attributes.name || + (isShort ? attributes.profileName : undefined) || + getProfileName(attributes) || + getNumber(attributes) || + (username && window.i18n('at-username', { username })) + ); +} + +export function getProfileName( + attributes: Pick< + ConversationAttributesType, + 'profileName' | 'profileFamilyName' | 'type' + > +): string | undefined { + if (isDirectConversation(attributes)) { + return combineNames(attributes.profileName, attributes.profileFamilyName); + } + + return undefined; +} + +export function getNumber( + attributes: Pick +): string { + if (!isDirectConversation(attributes)) { + return ''; + } + + const { e164 } = attributes; + if (!e164) { + return ''; + } + + return renderNumber(e164); +} + +export function renderNumber(e164: string): string { + try { + const parsedNumber = window.libphonenumberInstance.parse(e164); + const regionCode = getRegionCodeForNumber(e164); + if (regionCode === window.storage.get('regionCode')) { + return window.libphonenumberInstance.format( + parsedNumber, + window.libphonenumberFormat.NATIONAL + ); + } + return window.libphonenumberInstance.format( + parsedNumber, + window.libphonenumberFormat.INTERNATIONAL + ); + } catch (e) { + return e164; + } +} diff --git a/ts/util/lookupConversationWithoutUuid.ts b/ts/util/lookupConversationWithoutUuid.ts index cb9e5c2c5..719287ae9 100644 --- a/ts/util/lookupConversationWithoutUuid.ts +++ b/ts/util/lookupConversationWithoutUuid.ts @@ -76,13 +76,14 @@ export async function lookupConversationWithoutUuid( const maybePair = serverLookup.get(options.e164); if (maybePair) { - const convo = window.ConversationController.maybeMergeContacts({ - aci: maybePair.aci, - pni: maybePair.pni, - e164: options.e164, - reason: 'startNewConversationWithoutUuid(e164)', - }); - conversationId = convo?.id; + const { conversation } = + window.ConversationController.maybeMergeContacts({ + aci: maybePair.aci, + pni: maybePair.pni, + e164: options.e164, + reason: 'startNewConversationWithoutUuid(e164)', + }); + conversationId = conversation?.id; } } else { const foundUsername = await checkForUsername(options.username); diff --git a/ts/views/conversation_view.tsx b/ts/views/conversation_view.tsx index 3efd2b699..92b81195d 100644 --- a/ts/views/conversation_view.tsx +++ b/ts/views/conversation_view.tsx @@ -6,7 +6,7 @@ import type * as Backbone from 'backbone'; import type { ComponentProps } from 'react'; import * as React from 'react'; -import { debounce, flatten, throttle } from 'lodash'; +import { debounce, flatten } from 'lodash'; import { render } from 'mustache'; import type { AttachmentType } from '../types/Attachment'; @@ -122,8 +122,6 @@ type AttachmentOptions = { type PanelType = { view: Backbone.View; headerTitle?: string }; -const FIVE_MINUTES = 1000 * 60 * 5; - const { Message } = window.Signal.Types; const { @@ -206,7 +204,6 @@ export class ConversationView extends window.Backbone.View { messageText: string, bodyRanges: DraftBodyRangesType ) => Promise; - private lazyUpdateVerified: () => void; // Composing messages private compositionApi: { @@ -233,19 +230,10 @@ export class ConversationView extends window.Backbone.View { constructor(...args: Array) { super(...args); - this.lazyUpdateVerified = debounce( - this.model.updateVerified.bind(this.model), - 1000 // one second - ); - this.model.throttledGetProfiles = - this.model.throttledGetProfiles || - throttle(this.model.getProfiles.bind(this.model), FIVE_MINUTES); - this.debouncedSaveDraft = debounce(this.saveDraft.bind(this), 200); // Events on Conversation model this.listenTo(this.model, 'destroy', this.stopListening); - this.listenTo(this.model, 'newmessage', this.lazyUpdateVerified); // These are triggered by InboxView this.listenTo(this.model, 'opened', this.onOpened);