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);