Notifications for a few merge-related scenarios

This commit is contained in:
Scott Nonnenberg 2022-12-05 14:46:54 -08:00 committed by GitHub
parent 78ce34b9d3
commit a49a6f2057
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
40 changed files with 2764 additions and 553 deletions

View file

@ -1222,6 +1222,34 @@
"message": "$sender$ changed their phone number", "message": "$sender$ changed their phone number",
"description": "Shown in timeline when a member of a conversation changes 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": { "quoteThumbnailAlt": {
"message": "Thumbnail of image from quoted message", "message": "Thumbnail of image from quoted message",
"description": "Used in alt tag of thumbnail images inside of an embedded message quote" "description": "Used in alt tag of thumbnail images inside of an embedded message quote"

12
images/merged-chat.svg Normal file
View file

@ -0,0 +1,12 @@
<svg width="44" height="88" viewBox="0 0 44 88" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1_395)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M36 3H8C5.23858 3 3 5.23858 3 8V80C3 82.7614 5.23858 85 8 85H36C38.7614 85 41 82.7614 41 80V8C41 5.23858 38.7614 3 36 3ZM8 0C3.58172 0 0 3.58172 0 8V80C0 84.4183 3.58172 88 8 88H36C40.4183 88 44 84.4183 44 80V8C44 3.58172 40.4183 0 36 0H8Z" fill="#B9B9B9"/>
<circle cx="22" cy="43" r="14" fill="#2C6BED"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M21.5 36.1667C21.75 35.8333 22.25 35.8333 22.5 36.1667L25 39.5C25.309 39.912 25.015 40.5 24.5 40.5H22.9375C22.9375 40.5115 22.9375 40.523 22.9375 40.5344C22.9376 42.3155 22.95 44.0508 23.5087 45.6282C24.0488 47.1531 25.1219 48.5801 27.3992 49.6517C27.8676 49.8722 28.0687 50.4307 27.8482 50.8992C27.6278 51.3677 27.0693 51.5687 26.6008 51.3483C24.1751 50.2068 22.778 48.647 22 46.9028C21.222 48.647 19.8249 50.2068 17.3992 51.3483C16.9307 51.5687 16.3722 51.3677 16.1517 50.8992C15.9312 50.4307 16.1323 49.8722 16.6008 49.6517C18.878 48.5801 19.9512 47.1531 20.4913 45.6282C21.0499 44.0508 21.0623 42.3155 21.0625 40.5344C21.0625 40.523 21.0625 40.5115 21.0625 40.5H19.5C18.9849 40.5 18.691 39.912 19 39.5L21.5 36.1667Z" fill="white"/>
</g>
<defs>
<clipPath id="clip0_1_395">
<rect width="44" height="88" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -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;
}
}

View file

@ -61,6 +61,7 @@
@import './components/ConversationDetails.scss'; @import './components/ConversationDetails.scss';
@import './components/ConversationHeader.scss'; @import './components/ConversationHeader.scss';
@import './components/ConversationHero.scss'; @import './components/ConversationHero.scss';
@import './components/ConversationMergeNotification.scss';
@import './components/ConversationView.scss'; @import './components/ConversationView.scss';
@import './components/CustomColorEditor.scss'; @import './components/CustomColorEditor.scss';
@import './components/CustomizingPreferredReactionsModal.scss'; @import './components/CustomizingPreferredReactionsModal.scss';

View file

@ -8,6 +8,7 @@ import type {
ConversationModelCollectionType, ConversationModelCollectionType,
ConversationAttributesType, ConversationAttributesType,
ConversationAttributesTypeType, ConversationAttributesTypeType,
ConversationRenderInfoType,
} from './model-types.d'; } from './model-types.d';
import type { ConversationModel } from './models/conversations'; import type { ConversationModel } from './models/conversations';
import type { MessageModel } from './models/messages'; import type { MessageModel } from './models/messages';
@ -22,13 +23,12 @@ import { assertDev, strictAssert } from './util/assert';
import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation'; import { isGroupV1, isGroupV2 } from './util/whatTypeOfConversation';
import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge'; import { getConversationUnreadCountForAppBadge } from './util/getConversationUnreadCountForAppBadge';
import { UUID, isValidUuid, UUIDKind } from './types/UUID'; import { UUID, isValidUuid, UUIDKind } from './types/UUID';
import { Address } from './types/Address';
import { QualifiedAddress } from './types/QualifiedAddress';
import { sleep } from './util/sleep'; import { sleep } from './util/sleep';
import { isNotNil } from './util/isNotNil'; import { isNotNil } from './util/isNotNil';
import { MINUTE, SECOND } from './util/durations'; import { MINUTE, SECOND } from './util/durations';
import { getUuidsForE164s } from './util/getUuidsForE164s'; import { getUuidsForE164s } from './util/getUuidsForE164s';
import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation'; import { SIGNAL_ACI, SIGNAL_AVATAR_PATH } from './types/SignalConversation';
import { getTitleNoDefault } from './util/getTitle';
type ConvoMatchType = type ConvoMatchType =
| { | {
@ -48,7 +48,8 @@ function applyChangeToConversation(
conversation: ConversationModel, conversation: ConversationModel,
suggestedChange: Partial< suggestedChange: Partial<
Pick<ConversationAttributesType, 'uuid' | 'e164' | 'pni'> Pick<ConversationAttributesType, 'uuid' | 'e164' | 'pni'>
> >,
disableDiscoveryNotification?: boolean
) { ) {
const change = { ...suggestedChange }; const change = { ...suggestedChange };
@ -82,7 +83,9 @@ function applyChangeToConversation(
conversation.updateUuid(change.uuid); conversation.updateUuid(change.uuid);
} }
if (hasOwnProperty.call(change, 'e164')) { if (hasOwnProperty.call(change, 'e164')) {
conversation.updateE164(change.e164); conversation.updateE164(change.e164, {
disableDiscoveryNotification,
});
} }
if (hasOwnProperty.call(change, 'pni')) { if (hasOwnProperty.call(change, 'pni')) {
conversation.updatePni(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 // Note: we don't do a conversation.set here, because change is limited to these fields
} }
async function safeCombineConversations({ export type CombineConversationsParams = Readonly<{
logId, current: ConversationModel;
oldConversation, fromPniSignature?: boolean;
newConversation, obsolete: ConversationModel;
}: { obsoleteTitleInfo?: ConversationRenderInfoType;
logId: string; }>;
oldConversation: ConversationModel; export type SafeCombineConversationsParams = Readonly<{ logId: string }> &
newConversation: ConversationModel; CombineConversationsParams;
}) {
async function safeCombineConversations(
options: SafeCombineConversationsParams
) {
try { try {
await window.ConversationController.combineConversations( await window.ConversationController.combineConversations(options);
newConversation,
oldConversation
);
} catch (error) { } catch (error) {
log.warn( 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; return undefined;
} }
const conversation = this.maybeMergeContacts({ const { conversation } = this.maybeMergeContacts({
aci, aci,
e164, e164,
pni, pni,
@ -454,19 +457,19 @@ export class ConversationController {
e164, e164,
pni: providedPni, pni: providedPni,
reason, reason,
fromPniSignature,
mergeOldAndNew = safeCombineConversations, mergeOldAndNew = safeCombineConversations,
}: { }: {
aci?: string; aci?: string;
e164?: string; e164?: string;
pni?: string; pni?: string;
reason: string; reason: string;
recursionCount?: number; fromPniSignature?: boolean;
mergeOldAndNew?: (options: { mergeOldAndNew?: (options: SafeCombineConversationsParams) => Promise<void>;
logId: string; }): {
oldConversation: ConversationModel; conversation: ConversationModel | undefined;
newConversation: ConversationModel; mergePromises: Array<Promise<void>>;
}) => Promise<void>; } {
}): ConversationModel | undefined {
const dataProvided = []; const dataProvided = [];
if (providedAci) { if (providedAci) {
dataProvided.push('aci'); dataProvided.push('aci');
@ -481,6 +484,8 @@ export class ConversationController {
const aci = providedAci ? UUID.cast(providedAci) : undefined; const aci = providedAci ? UUID.cast(providedAci) : undefined;
const pni = providedPni ? UUID.cast(providedPni) : undefined; const pni = providedPni ? UUID.cast(providedPni) : undefined;
let targetConversationWasCreated = false;
const mergePromises: Array<Promise<void>> = [];
if (!aci && !e164 && !pni) { if (!aci && !e164 && !pni) {
throw new Error( throw new Error(
@ -518,9 +523,13 @@ export class ConversationController {
`${logId}: No match for ${key}, applying to target conversation` `${logId}: No match for ${key}, applying to target conversation`
); );
// Note: This line might erase a known e164 or PNI // Note: This line might erase a known e164 or PNI
applyChangeToConversation(targetConversation, { applyChangeToConversation(
[key]: value, targetConversation,
}); {
[key]: value,
},
targetConversationWasCreated
);
} else { } else {
unusedMatches.push(item); unusedMatches.push(item);
} }
@ -532,22 +541,43 @@ export class ConversationController {
strictAssert(unused.value, 'An unused value should always be truthy'); 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. // 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 ( if (!targetConversation && !match.get(unused.key)) {
!targetConversation &&
(!match.get(unused.key) ||
(unused.key === 'uuid' && match.get(unused.key) === pni))
) {
log.info( log.info(
`${logId}: Match on ${key} does not have ${unused.key}, ` + `${logId}: Match on ${key} does not have ${unused.key}, ` +
`so it will be our target conversation - ${match.idForLogging()}` `so it will be our target conversation - ${match.idForLogging()}`
); );
targetConversation = match; 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 PNI match already has an ACI, then we need to create a new one
if (!targetConversation) { if (!targetConversation) {
targetConversation = this.getOrCreate(unused.value, 'private'); targetConversation = this.getOrCreate(unused.value, 'private');
targetConversationWasCreated = true;
log.info( log.info(
`${logId}: Match on ${key} already had ${unused.key}, ` + `${logId}: Match on ${key} already had ${unused.key}, ` +
`so created new target conversation - ${targetConversation.idForLogging()}` `so created new target conversation - ${targetConversation.idForLogging()}`
@ -557,14 +587,36 @@ export class ConversationController {
log.info( log.info(
`${logId}: Applying new value for ${unused.key} to target conversation` `${logId}: Applying new value for ${unused.key} to target conversation`
); );
applyChangeToConversation(targetConversation, { applyChangeToConversation(
[unused.key]: unused.value, targetConversation,
}); {
[unused.key]: unused.value,
},
targetConversationWasCreated
);
}); });
unusedMatches = []; unusedMatches = [];
if (targetConversation && targetConversation !== match) { 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! // 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! // Note: we need to do the remove first, because it will clear the lookup!
log.info( log.info(
@ -577,34 +629,49 @@ export class ConversationController {
[key]: undefined, [key]: undefined,
}; };
// When the PNI is being used in the uuid field alone, we need to clear it // 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; change.uuid = undefined;
} }
applyChangeToConversation(match, change); applyChangeToConversation(match, change, targetConversationWasCreated);
applyChangeToConversation(targetConversation, {
[key]: value,
});
// Note: The PNI check here is just to be bulletproof; if we know a UUID is a PNI, // 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! // 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( log.warn(
`${logId}: Removing old conversation which matched on ${key}. ` + `${logId}: Removing old conversation which matched on ${key}. ` +
'Merging with target conversation.' 'Merging with target conversation.'
); );
mergeOldAndNew({ mergePromises.push(
logId, mergeOldAndNew({
oldConversation: match, current: targetConversation,
newConversation: targetConversation, fromPniSignature,
}); logId,
obsolete: match,
obsoleteTitleInfo,
})
);
} }
} else if (targetConversation && !targetConversation?.get(key)) { } else if (targetConversation && !targetConversation?.get(key)) {
// This is mostly for the situation where PNI was erased when updating e164 // This is mostly for the situation where PNI was erased when updating e164
log.debug(`${logId}: Re-adding ${key} on target conversation`); log.debug(`${logId}: Re-adding ${key} on target conversation`);
applyChangeToConversation(targetConversation, { applyChangeToConversation(
[key]: value, targetConversation,
}); {
[key]: value,
},
targetConversationWasCreated
);
} }
if (!targetConversation) { if (!targetConversation) {
@ -616,7 +683,7 @@ export class ConversationController {
}); });
if (targetConversation) { if (targetConversation) {
return targetConversation; return { conversation: targetConversation, mergePromises };
} }
strictAssert( strictAssert(
@ -631,7 +698,10 @@ export class ConversationController {
const identifier = aci || pni || e164; const identifier = aci || pni || e164;
strictAssert(identifier, `${logId}: identifier must be truthy!`); 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 // `identifier` would resolve to uuid if we had both, so fix up e164
if (normalizedUuid && e164) { if (normalizedUuid && e164) {
newConvo.updateE164(e164); newConvo.updateE164(e164, { disableDiscoveryNotification: true });
} }
return newConvo; return newConvo;
@ -710,8 +780,8 @@ export class ConversationController {
); );
} }
// Note: `doCombineConversations` is used within this function since both // Note: `doCombineConversations` is directly used within this function since both
// run on `_combineConversationsQueue` queue and we don't want deadlocks. // run on `_combineConversationsQueue` queue and we don't want deadlocks.
private async doCheckForConflicts(): Promise<void> { private async doCheckForConflicts(): Promise<void> {
log.info('checkForConflicts: starting...'); log.info('checkForConflicts: starting...');
const byUuid = Object.create(null); const byUuid = Object.create(null);
@ -746,12 +816,18 @@ export class ConversationController {
if (conversation.get('e164')) { if (conversation.get('e164')) {
// Keep new one // Keep new one
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations(conversation, existing); await this.doCombineConversations({
current: conversation,
obsolete: existing,
});
byUuid[uuid] = conversation; byUuid[uuid] = conversation;
} else { } else {
// Keep existing - note that this applies if neither had an e164 // Keep existing - note that this applies if neither had an e164
// eslint-disable-next-line no-await-in-loop // 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')) { if (conversation.get('e164') || conversation.get('pni')) {
// Keep new one // Keep new one
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations(conversation, existing); await this.doCombineConversations({
current: conversation,
obsolete: existing,
});
byUuid[pni] = conversation; byUuid[pni] = conversation;
} else { } else {
// Keep existing - note that this applies if neither had an e164 // Keep existing - note that this applies if neither had an e164
// eslint-disable-next-line no-await-in-loop // 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')) { if (conversation.get('uuid')) {
// Keep new one // Keep new one
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations(conversation, existing); await this.doCombineConversations({
current: conversation,
obsolete: existing,
});
byE164[e164] = conversation; byE164[e164] = conversation;
} else { } else {
// Keep existing - note that this applies if neither had a UUID // Keep existing - note that this applies if neither had a UUID
// eslint-disable-next-line no-await-in-loop // 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) !isGroupV2(existing.attributes)
) { ) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await this.doCombineConversations(conversation, existing); await this.doCombineConversations({
current: conversation,
obsolete: existing,
});
byGroupV2Id[groupV2Id] = conversation; byGroupV2Id[groupV2Id] = conversation;
} else { } else {
// eslint-disable-next-line no-await-in-loop // 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( async combineConversations(
current: ConversationModel, options: CombineConversationsParams
obsolete: ConversationModel
): Promise<void> { ): Promise<void> {
return this._combineConversationsQueue.add(() => return this._combineConversationsQueue.add(() =>
this.doCombineConversations(current, obsolete) this.doCombineConversations(options)
); );
} }
private async doCombineConversations( private async doCombineConversations({
current: ConversationModel, current,
obsolete: ConversationModel obsolete,
): Promise<void> { obsoleteTitleInfo,
fromPniSignature,
}: CombineConversationsParams): Promise<void> {
const logId = `combineConversations/${obsolete.id}->${current.id}`; const logId = `combineConversations/${obsolete.id}->${current.id}`;
const conversationType = current.get('type'); const conversationType = current.get('type');
if (!this.get(obsolete.id)) { if (!this.get(obsolete.id)) {
log.warn(`${logId}: Already combined obsolete conversation`); log.warn(`${logId}: Already combined obsolete conversation`);
return;
} }
if (obsolete.get('type') !== conversationType) { if (obsolete.get('type') !== conversationType) {
@ -896,6 +992,21 @@ export class ConversationController {
return; 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<ConversationAttributesType> = pick( const dataToCopy: Partial<ConversationAttributesType> = pick(
obsolete.attributes, obsolete.attributes,
[ [
@ -937,10 +1048,6 @@ export class ConversationController {
const obsoleteId = obsolete.get('id'); const obsoleteId = obsolete.get('id');
const obsoleteUuid = obsolete.getUuid(); const obsoleteUuid = obsolete.getUuid();
const currentId = current.get('id'); const currentId = current.get('id');
log.warn(
`${logId}: Combining two conversations -`,
`old: ${obsolete.idForLogging()} -> new: ${current.idForLogging()}`
);
if (conversationType === 'private' && obsoleteUuid) { if (conversationType === 'private' && obsoleteUuid) {
if (!current.get('profileKey') && obsolete.get('profileKey')) { if (!current.get('profileKey') && obsolete.get('profileKey')) {
@ -954,34 +1061,12 @@ export class ConversationController {
} }
log.warn(`${logId}: Delete all sessions tied to old conversationId`); log.warn(`${logId}: Delete all sessions tied to old conversationId`);
const ourACI = window.textsecure.storage.user.getUuid(UUIDKind.ACI); // Note: we use the conversationId here in case we've already lost our uuid.
const ourPNI = window.textsecure.storage.user.getUuid(UUIDKind.PNI); await window.textsecure.storage.protocol.removeAllSessions(obsoleteId);
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);
})
);
})
);
log.warn( log.warn(
`${logId}: Delete all identity information tied to old conversationId` `${logId}: Delete all identity information tied to old conversationId`
); );
if (obsoleteUuid) { if (obsoleteUuid) {
await window.textsecure.storage.protocol.removeIdentityKey( await window.textsecure.storage.protocol.removeIdentityKey(
obsoleteUuid obsoleteUuid
@ -1032,6 +1117,14 @@ export class ConversationController {
this._conversations.resetLookups(); this._conversations.resetLookups();
current.captureChange('combineConversations'); current.captureChange('combineConversations');
current.updateLastMessage();
const titleIsUseful = Boolean(
obsoleteTitleInfo && getTitleNoDefault(obsoleteTitleInfo)
);
if (!fromPniSignature && obsoleteTitleInfo && titleIsUseful) {
current.addConversationMerge(obsoleteTitleInfo);
}
log.warn(`${logId}: Complete!`); log.warn(`${logId}: Complete!`);
} }

View file

@ -1523,7 +1523,11 @@ export class SignalProtocolStore extends EventEmitter {
switch (direction) { switch (direction) {
case Direction.Sending: case Direction.Sending:
return this.isTrustedForSending(publicKey, identityRecord); return this.isTrustedForSending(
encodedAddress.uuid,
publicKey,
identityRecord
);
case Direction.Receiving: case Direction.Receiving:
return true; return true;
default: 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 // https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/crypto/storage/SignalBaseIdentityKeyStore.java#L233
isTrustedForSending( isTrustedForSending(
uuid: UUID,
publicKey: Uint8Array, publicKey: Uint8Array,
identityRecord?: IdentityKeyType identityRecord?: IdentityKeyType
): boolean { ): boolean {
if (!identityRecord) { 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; return true;
} }
@ -1552,7 +1576,7 @@ export class SignalProtocolStore extends EventEmitter {
return false; return false;
} }
if (identityRecord.verified === VerifiedStatus.UNVERIFIED) { if (identityRecord.verified === VerifiedStatus.UNVERIFIED) {
log.error('isTrustedIdentity: Needs unverified approval!'); log.error('isTrustedForSending: Needs unverified approval!');
return false; return false;
} }
if (this.isNonBlockingApprovalRequired(identityRecord)) { if (this.isNonBlockingApprovalRequired(identityRecord)) {
@ -1648,6 +1672,8 @@ export class SignalProtocolStore extends EventEmitter {
nonblockingApproval, nonblockingApproval,
}); });
this.checkPreviousKey(encodedAddress.uuid, publicKey, 'saveIdentity');
return false; return false;
} }
@ -1690,7 +1716,7 @@ export class SignalProtocolStore extends EventEmitter {
// See `addKeyChange` in `ts/models/conversations.ts` for sender key info // See `addKeyChange` in `ts/models/conversations.ts` for sender key info
// update caused by this. // update caused by this.
try { try {
this.emit('keychange', encodedAddress.uuid); this.emit('keychange', encodedAddress.uuid, 'saveIdentity - change');
} catch (error) { } catch (error) {
log.error( log.error(
'saveIdentity: error triggering keychange:', 'saveIdentity: error triggering keychange:',
@ -1822,6 +1848,37 @@ export class SignalProtocolStore extends EventEmitter {
return VerifiedStatus.DEFAULT; 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 // See https://github.com/signalapp/Signal-Android/blob/fc3db538bcaa38dc149712a483d3032c9c1f3998/app/src/main/java/org/thoughtcrime/securesms/database/IdentityDatabase.java#L184
async updateIdentityAfterSync( async updateIdentityAfterSync(
uuid: UUID, uuid: UUID,
@ -1851,10 +1908,11 @@ export class SignalProtocolStore extends EventEmitter {
nonblockingApproval: true, nonblockingApproval: true,
}); });
} }
if (!hadEntry) {
if (hadEntry && !keyMatches) { this.checkPreviousKey(uuid, publicKey, 'updateIdentityAfterSync');
} else if (hadEntry && !keyMatches) {
try { try {
this.emit('keychange', uuid); this.emit('keychange', uuid, 'updateIdentityAfterSync - change');
} catch (error) { } catch (error) {
log.error( log.error(
'updateIdentityAfterSync: error triggering keychange:', 'updateIdentityAfterSync: error triggering keychange:',
@ -1903,7 +1961,10 @@ export class SignalProtocolStore extends EventEmitter {
return false; return false;
} }
async removeIdentityKey(uuid: UUID): Promise<void> { async removeIdentityKey(
uuid: UUID,
options?: { disableSessionDeletion: boolean }
): Promise<void> {
if (!this.identityKeys) { if (!this.identityKeys) {
throw new Error('removeIdentityKey: this.identityKeys not yet cached!'); throw new Error('removeIdentityKey: this.identityKeys not yet cached!');
} }
@ -1911,7 +1972,9 @@ export class SignalProtocolStore extends EventEmitter {
const id = uuid.toString(); const id = uuid.toString();
this.identityKeys.delete(id); this.identityKeys.delete(id);
await window.Signal.Data.removeIdentityKeyById(id); await window.Signal.Data.removeIdentityKeyById(id);
await this.removeAllSessions(id); if (!options?.disableSessionDeletion) {
await this.removeAllSessions(id);
}
} }
// Not yet processed messages - for resiliency // Not yet processed messages - for resiliency
@ -2197,7 +2260,7 @@ export class SignalProtocolStore extends EventEmitter {
public override on( public override on(
name: 'keychange', name: 'keychange',
handler: (theirUuid: UUID) => unknown handler: (theirUuid: UUID, reason: string) => unknown
): this; ): this;
public override on(name: 'removeAllData', handler: () => 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: '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; public override emit(name: 'removeAllData'): boolean;

View file

@ -2634,13 +2634,12 @@ export async function startApp(): Promise<void> {
let conversation; let conversation;
const senderConversation = window.ConversationController.maybeMergeContacts( const { conversation: senderConversation } =
{ window.ConversationController.maybeMergeContacts({
e164: sender, e164: sender,
aci: senderUuid, aci: senderUuid,
reason: `onTyping(${typing.timestamp})`, reason: `onTyping(${typing.timestamp})`,
} });
);
// We multiplex between GV1/GV2 groups here, but we don't kick off migrations // We multiplex between GV1/GV2 groups here, but we don't kick off migrations
if (groupV2Id) { if (groupV2Id) {
@ -2874,14 +2873,21 @@ export async function startApp(): Promise<void> {
maxSize: Infinity, maxSize: Infinity,
}); });
function onEnvelopeReceived({ envelope }: EnvelopeEvent): void { async function onEnvelopeReceived({
envelope,
}: EnvelopeEvent): Promise<void> {
const ourUuid = window.textsecure.storage.user.getUuid()?.toString(); const ourUuid = window.textsecure.storage.user.getUuid()?.toString();
if (envelope.sourceUuid && envelope.sourceUuid !== ourUuid) { if (envelope.sourceUuid && envelope.sourceUuid !== ourUuid) {
window.ConversationController.maybeMergeContacts({ const { mergePromises } =
e164: envelope.source, window.ConversationController.maybeMergeContacts({
aci: envelope.sourceUuid, e164: envelope.source,
reason: `onEnvelopeReceived(${envelope.timestamp})`, aci: envelope.sourceUuid,
}); reason: `onEnvelopeReceived(${envelope.timestamp})`,
});
if (mergePromises.length > 0) {
await Promise.all(mergePromises);
}
} }
} }
@ -3028,7 +3034,7 @@ export async function startApp(): Promise<void> {
data, data,
confirm, confirm,
}: ProfileKeyUpdateEvent): Promise<void> { }: ProfileKeyUpdateEvent): Promise<void> {
const conversation = window.ConversationController.maybeMergeContacts({ const { conversation } = window.ConversationController.maybeMergeContacts({
aci: data.sourceUuid, aci: data.sourceUuid,
e164: data.source, e164: data.source,
reason: 'onProfileKeyUpdate', reason: 'onProfileKeyUpdate',
@ -3250,11 +3256,12 @@ export async function startApp(): Promise<void> {
} }
// If we can't find one, we treat this as a normal GroupV1 group // If we can't find one, we treat this as a normal GroupV1 group
const fromContact = window.ConversationController.maybeMergeContacts({ const { conversation: fromContact } =
aci: sourceUuid, window.ConversationController.maybeMergeContacts({
e164: source, aci: sourceUuid,
reason: `getMessageDescriptor(${message.timestamp}): group v1`, e164: source,
}); reason: `getMessageDescriptor(${message.timestamp}): group v1`,
});
const conversationId = window.ConversationController.ensureGroup(id, { const conversationId = window.ConversationController.ensureGroup(id, {
addedBy: fromContact?.id, addedBy: fromContact?.id,
@ -3266,7 +3273,7 @@ export async function startApp(): Promise<void> {
}; };
} }
const conversation = window.ConversationController.maybeMergeContacts({ const { conversation } = window.ConversationController.maybeMergeContacts({
aci: destinationUuid, aci: destinationUuid,
e164: destination, e164: destination,
reason: `getMessageDescriptor(${message.timestamp}): private`, reason: `getMessageDescriptor(${message.timestamp}): private`,
@ -3696,13 +3703,12 @@ export async function startApp(): Promise<void> {
sourceDevice, sourceDevice,
wasSentEncrypted, wasSentEncrypted,
} = event.receipt; } = event.receipt;
const sourceConversation = window.ConversationController.maybeMergeContacts( const { conversation: sourceConversation } =
{ window.ConversationController.maybeMergeContacts({
aci: sourceUuid, aci: sourceUuid,
e164: source, e164: source,
reason: `onReadOrViewReceipt(${envelopeTimestamp})`, reason: `onReadOrViewReceipt(${envelopeTimestamp})`,
} });
);
log.info( log.info(
logTitle, logTitle,
`${sourceUuid || source}.${sourceDevice}`, `${sourceUuid || source}.${sourceDevice}`,
@ -3830,13 +3836,12 @@ export async function startApp(): Promise<void> {
ev.confirm(); ev.confirm();
const sourceConversation = window.ConversationController.maybeMergeContacts( const { conversation: sourceConversation } =
{ window.ConversationController.maybeMergeContacts({
aci: sourceUuid, aci: sourceUuid,
e164: source, e164: source,
reason: `onDeliveryReceipt(${envelopeTimestamp})`, reason: `onDeliveryReceipt(${envelopeTimestamp})`,
} });
);
log.info( log.info(
'delivery receipt from', 'delivery receipt from',

View file

@ -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> = {}): PropsType => ({
i18n,
conversationTitle: overrideProps.conversationTitle || 'John Fire',
obsoleteConversationTitle:
overrideProps.obsoleteConversationTitle || '(555) 333-1111',
});
export function Basic(): JSX.Element {
return <ConversationMergeNotification {...createProps()} />;
}
export function WithNoObsoleteTitle(): JSX.Element {
return (
<ConversationMergeNotification
{...createProps()}
obsoleteConversationTitle={undefined}
/>
);
}

View file

@ -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 (
<>
<SystemMessage
icon="profile"
contents={<Emojify text={message} />}
button={
obsoleteConversationTitle ? (
<Button
onClick={showDialog}
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
>
{i18n('icu:ConversationMerge--learn-more')}
</Button>
) : undefined
}
/>
{showingDialog && obsoleteConversationTitle ? (
<Modal
hasXButton
modalName="ConversationMergeExplainer"
onClose={dismissDialog}
i18n={i18n}
>
<div className="module-conversation-merge-notification__dialog__image">
<img src="images/merged-chat.svg" alt="" />
<div className="module-conversation-merge-notification__dialog__text-1">
<Intl
i18n={i18n}
id="icu:ConversationMerge--explainer-dialog--line-1"
components={{
conversationTitle,
obsoleteConversationTitle,
}}
/>
</div>
<div className="module-conversation-merge-notification__dialog__text-2">
<Intl
i18n={i18n}
id="icu:ConversationMerge--explainer-dialog--line-2"
/>
</div>
</div>
</Modal>
) : null}
</>
);
}

View file

@ -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> = {}): PropsType => ({
i18n,
conversationTitle: overrideProps.conversationTitle || 'Mr. Fire',
phoneNumber: overrideProps.phoneNumber || '+1 (000) 123-4567',
sharedGroup: overrideProps.sharedGroup,
});
export function Basic(): JSX.Element {
return <PhoneNumberDiscoveryNotification {...createProps()} />;
}
export function WithSharedGroup(): JSX.Element {
return (
<PhoneNumberDiscoveryNotification
{...createProps({
sharedGroup: 'Animal Lovers',
})}
/>
);
}

View file

@ -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 <SystemMessage icon="profile" contents={<Emojify text={message} />} />;
}

View file

@ -55,6 +55,10 @@ import type { PropsType as ProfileChangeNotificationPropsType } from './ProfileC
import { ProfileChangeNotification } from './ProfileChangeNotification'; import { ProfileChangeNotification } from './ProfileChangeNotification';
import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEventNotification'; import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEventNotification';
import { PaymentEventNotification } 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 type { FullJSXType } from '../Intl';
import { TimelineMessage } from './TimelineMessage'; import { TimelineMessage } from './TimelineMessage';
@ -118,6 +122,14 @@ type ProfileChangeNotificationType = {
type: 'profileChange'; type: 'profileChange';
data: ProfileChangeNotificationPropsType; data: ProfileChangeNotificationPropsType;
}; };
type ConversationMergeNotificationType = {
type: 'conversationMerge';
data: ConversationMergeNotificationPropsType;
};
type PhoneNumberDiscoveryNotificationType = {
type: 'phoneNumberDiscovery';
data: PhoneNumberDiscoveryNotificationPropsType;
};
type PaymentEventType = { type PaymentEventType = {
type: 'paymentEvent'; type: 'paymentEvent';
data: Omit<PaymentEventNotificationPropsType, 'i18n'>; data: Omit<PaymentEventNotificationPropsType, 'i18n'>;
@ -125,18 +137,20 @@ type PaymentEventType = {
export type TimelineItemType = ( export type TimelineItemType = (
| CallHistoryType | CallHistoryType
| ChangeNumberNotificationType
| ChatSessionRefreshedType | ChatSessionRefreshedType
| ConversationMergeNotificationType
| DeliveryIssueType | DeliveryIssueType
| GroupNotificationType | GroupNotificationType
| GroupV1MigrationType | GroupV1MigrationType
| GroupV2ChangeType | GroupV2ChangeType
| MessageType | MessageType
| PhoneNumberDiscoveryNotificationType
| ProfileChangeNotificationType | ProfileChangeNotificationType
| ResetSessionNotificationType | ResetSessionNotificationType
| SafetyNumberNotificationType | SafetyNumberNotificationType
| TimerNotificationType | TimerNotificationType
| UniversalTimerNotificationType | UniversalTimerNotificationType
| ChangeNumberNotificationType
| UnsupportedMessageType | UnsupportedMessageType
| VerificationNotificationType | VerificationNotificationType
| PaymentEventType | PaymentEventType
@ -300,6 +314,22 @@ export class TimelineItem extends React.PureComponent<PropsType> {
notification = ( notification = (
<GroupV1Migration {...this.props} {...item.data} i18n={i18n} /> <GroupV1Migration {...this.props} {...item.data} i18n={i18n} />
); );
} else if (item.type === 'conversationMerge') {
notification = (
<ConversationMergeNotification
{...this.props}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'phoneNumberDiscovery') {
notification = (
<PhoneNumberDiscoveryNotification
{...this.props}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'resetSessionNotification') { } else if (item.type === 'resetSessionNotification') {
notification = <ResetSessionNotification {...this.props} i18n={i18n} />; notification = <ResetSessionNotification {...this.props} i18n={i18n} />;
} else if (item.type === 'profileChange') { } else if (item.type === 'profileChange') {

25
ts/model-types.d.ts vendored
View file

@ -166,19 +166,21 @@ export type MessageAttributesType = {
id: string; id: string;
type: type:
| 'call-history' | 'call-history'
| 'change-number-notification'
| 'chat-session-refreshed' | 'chat-session-refreshed'
| 'conversation-merge'
| 'delivery-issue' | 'delivery-issue'
| 'group'
| 'group-v1-migration' | 'group-v1-migration'
| 'group-v2-change' | 'group-v2-change'
| 'group'
| 'incoming' | 'incoming'
| 'keychange' | 'keychange'
| 'outgoing' | 'outgoing'
| 'phone-number-discovery'
| 'profile-change' | 'profile-change'
| 'story' | 'story'
| 'timer-notification' | 'timer-notification'
| 'universal-timer-notification' | 'universal-timer-notification'
| 'change-number-notification'
| 'verified-change'; | 'verified-change';
body?: string; body?: string;
attachments?: Array<AttachmentType>; attachments?: Array<AttachmentType>;
@ -207,6 +209,13 @@ export type MessageAttributesType = {
source?: string; source?: string;
sourceUuid?: string; sourceUuid?: string;
}; };
phoneNumberDiscovery?: {
e164: string;
};
conversationMerge?: {
renderInfo: ConversationRenderInfoType;
};
// Legacy fields for timer update notification only // Legacy fields for timer update notification only
flags?: number; flags?: number;
groupV2Change?: GroupV2ChangeType; groupV2Change?: GroupV2ChangeType;
@ -349,6 +358,7 @@ export type ConversationAttributesType = {
pendingUniversalTimer?: string; pendingUniversalTimer?: string;
username?: string; username?: string;
shareMyPhoneNumber?: boolean; shareMyPhoneNumber?: boolean;
previousIdentityKey?: string;
// Group-only // Group-only
groupId?: string; groupId?: string;
@ -409,6 +419,17 @@ export type ConversationAttributesType = {
}; };
/* eslint-enable camelcase */ /* eslint-enable camelcase */
export type ConversationRenderInfoType = Pick<
ConversationAttributesType,
| 'e164'
| 'name'
| 'profileFamilyName'
| 'profileName'
| 'systemGivenName'
| 'type'
| 'username'
>;
export type GroupV2MemberType = { export type GroupV2MemberType = {
uuid: UUIDStringType; uuid: UUIDStringType;
role: MemberRoleEnum; role: MemberRoleEnum;

View file

@ -17,6 +17,7 @@ import PQueue from 'p-queue';
import type { import type {
ConversationAttributesType, ConversationAttributesType,
ConversationLastProfileType, ConversationLastProfileType,
ConversationRenderInfoType,
LastMessageStatus, LastMessageStatus,
MessageAttributesType, MessageAttributesType,
QuotedMessageType, QuotedMessageType,
@ -24,7 +25,6 @@ import type {
} from '../model-types.d'; } from '../model-types.d';
import { getInitials } from '../util/getInitials'; import { getInitials } from '../util/getInitials';
import { normalizeUuid } from '../util/normalizeUuid'; import { normalizeUuid } from '../util/normalizeUuid';
import { getRegionCodeForNumber } from '../util/libphonenumberUtil';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import type { AttachmentType, ThumbnailType } from '../types/Attachment'; import type { AttachmentType, ThumbnailType } from '../types/Attachment';
import { toDayMillis } from '../util/timestamp'; 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 { IMAGE_JPEG, IMAGE_GIF, IMAGE_WEBP } from '../types/MIME';
import { UUID, UUIDKind } from '../types/UUID'; import { UUID, UUIDKind } from '../types/UUID';
import type { UUIDStringType } 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 * as Bytes from '../Bytes';
import type { BodyRangesType } from '../types/Util'; import type { BodyRangesType } from '../types/Util';
import { getTextWithMentions } from '../util/getTextWithMentions'; import { getTextWithMentions } from '../util/getTextWithMentions';
@ -81,6 +86,12 @@ import { notificationService } from '../services/notifications';
import { storageServiceUploadJob } from '../services/storage'; import { storageServiceUploadJob } from '../services/storage';
import { getSendOptions } from '../util/getSendOptions'; import { getSendOptions } from '../util/getSendOptions';
import { isConversationAccepted } from '../util/isConversationAccepted'; import { isConversationAccepted } from '../util/isConversationAccepted';
import {
getNumber,
getProfileName,
getTitle,
getTitleNoDefault,
} from '../util/getTitle';
import { markConversationRead } from '../util/markConversationRead'; import { markConversationRead } from '../util/markConversationRead';
import { handleMessageSend } from '../util/handleMessageSend'; import { handleMessageSend } from '../util/handleMessageSend';
import { getConversationMembers } from '../util/getConversationMembers'; import { getConversationMembers } from '../util/getConversationMembers';
@ -88,7 +99,7 @@ import { updateConversationsWithUuidLookup } from '../updateConversationsWithUui
import { ReadStatus } from '../messages/MessageReadStatus'; import { ReadStatus } from '../messages/MessageReadStatus';
import { SendStatus } from '../messages/MessageSendState'; import { SendStatus } from '../messages/MessageSendState';
import type { LinkPreviewType } from '../types/message/LinkPreviews'; import type { LinkPreviewType } from '../types/message/LinkPreviews';
import { MINUTE, DurationInSeconds } from '../util/durations'; import { MINUTE, SECOND, DurationInSeconds } from '../util/durations';
import { import {
concat, concat,
filter, filter,
@ -220,6 +231,8 @@ export class ConversationModel extends window.Backbone
throttledGetProfiles?: () => Promise<void>; throttledGetProfiles?: () => Promise<void>;
throttledUpdateVerified?: () => void;
typingRefreshTimer?: NodeJS.Timer | null; typingRefreshTimer?: NodeJS.Timer | null;
typingPauseTimer?: 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. // our first save to the database. Or first fetch from the database.
this.initialPromise = Promise.resolve(); this.initialPromise = Promise.resolve();
this.throttledBumpTyping = throttle(this.bumpTyping, 300);
this.debouncedUpdateLastMessage = debounce( this.debouncedUpdateLastMessage = debounce(
this.updateLastMessage.bind(this), this.updateLastMessage.bind(this),
200 200
); );
this.throttledUpdateSharedGroups =
this.throttledUpdateSharedGroups ||
throttle(this.updateSharedGroups.bind(this), FIVE_MINUTES);
this.contactCollection = this.getContactCollection(); this.contactCollection = this.getContactCollection();
this.contactCollection.on( this.contactCollection.on(
@ -370,6 +379,11 @@ export class ConversationModel extends window.Backbone
// conversation for the first time. // conversation for the first time.
this.isFetchingUUID = this.isSMSOnly(); this.isFetchingUUID = this.isSMSOnly();
this.throttledBumpTyping = throttle(this.bumpTyping, 300);
this.throttledUpdateSharedGroups = throttle(
this.updateSharedGroups.bind(this),
FIVE_MINUTES
);
this.throttledFetchSMSOnlyUUID = throttle( this.throttledFetchSMSOnlyUUID = throttle(
this.fetchSMSOnlyUUID.bind(this), this.fetchSMSOnlyUUID.bind(this),
FIVE_MINUTES FIVE_MINUTES
@ -378,6 +392,16 @@ export class ConversationModel extends window.Backbone
this.maybeMigrateV1Group.bind(this), this.maybeMigrateV1Group.bind(this),
FIVE_MINUTES 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(); const migratedColor = this.getColor();
if (this.get('color') !== migratedColor) { 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'); const oldValue = this.get('e164');
if (e164 !== oldValue) { if (e164 === oldValue) {
this.set('e164', e164 || undefined); return;
if (oldValue && e164) {
this.addChangeNumberNotification(oldValue, e164);
}
window.Signal.Data.updateConversation(this.attributes);
this.trigger('idUpdated', this, 'e164', oldValue);
this.captureChange('updateE164');
} }
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 { updateUuid(uuid?: string): void {
const oldValue = this.get('uuid'); const oldValue = this.get('uuid');
if (uuid !== oldValue) { if (uuid === oldValue) {
this.set('uuid', uuid ? UUID.cast(uuid.toLowerCase()) : undefined); return;
window.Signal.Data.updateConversation(this.attributes);
this.trigger('idUpdated', this, 'uuid', oldValue);
this.captureChange('updateUuid');
} }
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 { updatePni(pni?: string): void {
const oldValue = this.get('pni'); const oldValue = this.get('pni');
if (pni !== oldValue) { if (pni === oldValue) {
this.set('pni', pni ? UUID.cast(pni.toLowerCase()) : undefined); return;
}
if ( this.set('pni', pni ? UUID.cast(pni.toLowerCase()) : undefined);
oldValue &&
pni && const pniIsPrimaryId =
(!this.get('uuid') || this.get('uuid') === oldValue) !this.get('uuid') ||
) { this.get('uuid') === oldValue ||
// TODO: DESKTOP-3974 this.get('uuid') === pni;
this.addKeyChange(UUID.checkedLookup(oldValue)); 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); // We're just dropping the PNI
this.trigger('idUpdated', this, 'pni', oldValue); if (!pni) {
this.captureChange('updatePni'); 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 { updateGroupId(groupId?: string): void {
@ -3044,40 +3185,47 @@ export class ConversationModel extends window.Backbone
this.updateUnread(); this.updateUnread();
} }
async addKeyChange(keyChangedId: UUID): Promise<void> { async addKeyChange(reason: string, keyChangedId?: UUID): Promise<void> {
const keyChangedIdString = keyChangedId.toString(); const keyChangedIdString = keyChangedId?.toString();
return this.queueJob(`addKeyChange(${keyChangedIdString})`, async () => { return this.queueJob(`addKeyChange(${keyChangedIdString})`, async () => {
log.info( log.info(
'adding key change advisory for', 'adding key change advisory in',
this.idForLogging(), this.idForLogging(),
keyChangedIdString, 'for',
this.get('timestamp') 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 timestamp = Date.now();
const message = { const message: MessageAttributesType = {
id: generateGuid(),
conversationId: this.id, conversationId: this.id,
type: 'keychange', type: 'keychange',
sent_at: this.get('timestamp'), sent_at: timestamp,
timestamp,
received_at: window.Signal.Util.incrementMessageCounter(), received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: timestamp, received_at_ms: timestamp,
key_changed: keyChangedIdString, key_changed: keyChangedIdString,
readStatus: ReadStatus.Read, readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen, seenStatus: SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY, 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(), ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
forceSave: true,
}); });
const model = window.MessageController.register( const model = window.MessageController.register(
id, message.id,
new window.Whisper.Message({ new window.Whisper.Message(message)
...message,
id,
})
); );
const isUntrusted = await this.isUntrusted(); const isUntrusted = await this.isUntrusted();
@ -3090,6 +3238,17 @@ export class ConversationModel extends window.Backbone
window.reduxActions.calling.keyChanged({ uuid }); 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. // Drop a member from sender key distribution list.
const senderKeyInfo = this.get('senderKeyInfo'); const senderKeyInfo = this.get('senderKeyInfo');
if (senderKeyInfo) { if (senderKeyInfo) {
@ -3108,6 +3267,82 @@ export class ConversationModel extends window.Backbone
}); });
} }
async addPhoneNumberDiscovery(e164: string): Promise<void> {
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<void> {
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( async addVerifiedChange(
verifiedChangeId: string, verifiedChangeId: string,
verified: boolean, verified: boolean,
@ -4985,69 +5220,19 @@ export class ConversationModel extends window.Backbone
} }
getTitle(options?: { isShort?: boolean }): string { getTitle(options?: { isShort?: boolean }): string {
const title = this.getTitleNoDefault(options); return getTitle(this.attributes, options);
if (title) {
return title;
}
if (isDirectConversation(this.attributes)) {
return window.i18n('unknownContact');
}
return window.i18n('unknownGroup');
} }
getTitleNoDefault({ isShort = false }: { isShort?: boolean } = {}): getTitleNoDefault(options?: { isShort?: boolean }): string | undefined {
| string return getTitleNoDefault(this.attributes, options);
| 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');
} }
getProfileName(): string | undefined { getProfileName(): string | undefined {
if (isDirectConversation(this.attributes)) { return getProfileName(this.attributes);
return Util.combineNames(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
this.get('profileName')!,
this.get('profileFamilyName')
);
}
return undefined;
} }
getNumber(): string { getNumber(): string {
if (!isDirectConversation(this.attributes)) { return getNumber(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;
}
} }
getColor(): AvatarColorType { getColor(): AvatarColorType {

View file

@ -122,6 +122,8 @@ import {
isUnsupportedMessage, isUnsupportedMessage,
isVerifiedChange, isVerifiedChange,
processBodyRanges, processBodyRanges,
isConversationMerge,
isPhoneNumberDiscovery,
} from '../state/selectors/message'; } from '../state/selectors/message';
import { import {
isInCall, isInCall,
@ -181,6 +183,9 @@ import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
import { GiftBadgeStates } from '../components/conversation/Message'; import { GiftBadgeStates } from '../components/conversation/Message';
import { downloadAttachment } from '../util/downloadAttachment'; import { downloadAttachment } from '../util/downloadAttachment';
import type { StickerWithHydratedData } from '../types/Stickers'; 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 { DurationInSeconds } from '../util/durations';
import dataInterface from '../sql/Client'; import dataInterface from '../sql/Client';
@ -415,12 +420,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return ( return (
!isCallHistory(attributes) && !isCallHistory(attributes) &&
!isChatSessionRefreshed(attributes) && !isChatSessionRefreshed(attributes) &&
!isConversationMerge(attributes) &&
!isEndSession(attributes) && !isEndSession(attributes) &&
!isExpirationTimerUpdate(attributes) && !isExpirationTimerUpdate(attributes) &&
!isGroupUpdate(attributes) && !isGroupUpdate(attributes) &&
!isGroupV2Change(attributes) &&
!isGroupV1Migration(attributes) && !isGroupV1Migration(attributes) &&
!isGroupV2Change(attributes) &&
!isKeyChange(attributes) && !isKeyChange(attributes) &&
!isPhoneNumberDiscovery(attributes) &&
!isProfileChange(attributes) && !isProfileChange(attributes) &&
!isUniversalTimerNotification(attributes) && !isUniversalTimerNotification(attributes) &&
!isUnsupportedMessage(attributes) && !isUnsupportedMessage(attributes) &&
@ -622,7 +629,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
} }
getNotificationData(): { emoji?: string; text: string } { getNotificationData(): { emoji?: string; text: string } {
const { attributes } = this; // eslint-disable-next-line prefer-destructuring
const attributes: MessageAttributesType = this.attributes;
if (isDeliveryIssue(attributes)) { if (isDeliveryIssue(attributes)) {
return { return {
@ -631,6 +639,46 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}; };
} }
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)) { if (isChatSessionRefreshed(attributes)) {
return { return {
emoji: '🔁', emoji: '🔁',
@ -1323,6 +1371,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const isProfileChangeValue = isProfileChange(attributes); const isProfileChangeValue = isProfileChange(attributes);
const isUniversalTimerNotificationValue = const isUniversalTimerNotificationValue =
isUniversalTimerNotification(attributes); isUniversalTimerNotification(attributes);
const isConversationMergeValue = isConversationMerge(attributes);
const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes);
const isPayment = messageHasPaymentEvent(attributes); const isPayment = messageHasPaymentEvent(attributes);
@ -1353,7 +1403,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
// Locally-generated notifications // Locally-generated notifications
isKeyChangeValue || isKeyChangeValue ||
isProfileChangeValue || isProfileChangeValue ||
isUniversalTimerNotificationValue; isUniversalTimerNotificationValue ||
isConversationMergeValue ||
isPhoneNumberDiscoveryValue;
return !hasSomethingToDisplay; return !hasSomethingToDisplay;
} }
@ -2320,7 +2372,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return; return;
} }
const destinationConversation = const { conversation: destinationConversation } =
window.ConversationController.maybeMergeContacts({ window.ConversationController.maybeMergeContacts({
aci: destinationUuid, aci: destinationUuid,
e164: destination || undefined, e164: destination || undefined,

View file

@ -117,7 +117,7 @@ async function doContactSync({
continue; continue;
} }
const conversation = window.ConversationController.maybeMergeContacts({ const { conversation } = window.ConversationController.maybeMergeContacts({
e164: details.number, e164: details.number,
aci: details.uuid, aci: details.uuid,
reason: logId, reason: logId,

View file

@ -1732,6 +1732,12 @@ async function sync(
// We now know that we've successfully completed a storage service fetch // We now know that we've successfully completed a storage service fetch
await window.storage.put('storageFetchComplete', true); await window.storage.put('storageFetchComplete', true);
if (window.CI) {
window.CI.handleEvent('storageServiceComplete', {
manifestVersion: version,
});
}
} catch (err) { } catch (err) {
log.error( log.error(
'storageService.sync: error processing manifest', 'storageService.sync: error processing manifest',

View file

@ -957,7 +957,7 @@ export async function mergeContactRecord(
return { hasConflict: false, shouldDrop: true, details: ['our own uuid'] }; return { hasConflict: false, shouldDrop: true, details: ['our own uuid'] };
} }
const conversation = window.ConversationController.maybeMergeContacts({ const { conversation } = window.ConversationController.maybeMergeContacts({
aci: uuid, aci: uuid,
e164, e164,
pni, pni,

View file

@ -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!');
}

View file

@ -46,6 +46,7 @@ import updateToSchemaVersion67 from './67-add-story-to-unprocessed';
import updateToSchemaVersion68 from './68-drop-deprecated-columns'; import updateToSchemaVersion68 from './68-drop-deprecated-columns';
import updateToSchemaVersion69 from './69-group-call-ring-cancellations'; import updateToSchemaVersion69 from './69-group-call-ring-cancellations';
import updateToSchemaVersion70 from './70-story-reply-index'; import updateToSchemaVersion70 from './70-story-reply-index';
import updateToSchemaVersion71 from './71-merge-notifications';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -1893,6 +1894,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion7, updateToSchemaVersion7,
updateToSchemaVersion8, updateToSchemaVersion8,
updateToSchemaVersion9, updateToSchemaVersion9,
updateToSchemaVersion10, updateToSchemaVersion10,
updateToSchemaVersion11, updateToSchemaVersion11,
updateToSchemaVersion12, updateToSchemaVersion12,
@ -1903,6 +1905,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion17, updateToSchemaVersion17,
updateToSchemaVersion18, updateToSchemaVersion18,
updateToSchemaVersion19, updateToSchemaVersion19,
updateToSchemaVersion20, updateToSchemaVersion20,
updateToSchemaVersion21, updateToSchemaVersion21,
updateToSchemaVersion22, updateToSchemaVersion22,
@ -1913,6 +1916,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion27, updateToSchemaVersion27,
updateToSchemaVersion28, updateToSchemaVersion28,
updateToSchemaVersion29, updateToSchemaVersion29,
updateToSchemaVersion30, updateToSchemaVersion30,
updateToSchemaVersion31, updateToSchemaVersion31,
updateToSchemaVersion32, updateToSchemaVersion32,
@ -1923,6 +1927,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion37, updateToSchemaVersion37,
updateToSchemaVersion38, updateToSchemaVersion38,
updateToSchemaVersion39, updateToSchemaVersion39,
updateToSchemaVersion40, updateToSchemaVersion40,
updateToSchemaVersion41, updateToSchemaVersion41,
updateToSchemaVersion42, updateToSchemaVersion42,
@ -1933,6 +1938,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion47, updateToSchemaVersion47,
updateToSchemaVersion48, updateToSchemaVersion48,
updateToSchemaVersion49, updateToSchemaVersion49,
updateToSchemaVersion50, updateToSchemaVersion50,
updateToSchemaVersion51, updateToSchemaVersion51,
updateToSchemaVersion52, updateToSchemaVersion52,
@ -1943,6 +1949,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion57, updateToSchemaVersion57,
updateToSchemaVersion58, updateToSchemaVersion58,
updateToSchemaVersion59, updateToSchemaVersion59,
updateToSchemaVersion60, updateToSchemaVersion60,
updateToSchemaVersion61, updateToSchemaVersion61,
updateToSchemaVersion62, updateToSchemaVersion62,
@ -1953,7 +1960,9 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion67, updateToSchemaVersion67,
updateToSchemaVersion68, updateToSchemaVersion68,
updateToSchemaVersion69, updateToSchemaVersion69,
updateToSchemaVersion70, updateToSchemaVersion70,
updateToSchemaVersion71,
]; ];
export function updateSchema(db: Database, logger: LoggerType): void { export function updateSchema(db: Database, logger: LoggerType): void {

View file

@ -90,12 +90,14 @@ function checkForAccount(
const maybePair = uuidLookup.get(phoneNumber); const maybePair = uuidLookup.get(phoneNumber);
if (maybePair) { if (maybePair) {
uuid = window.ConversationController.maybeMergeContacts({ const { conversation: maybeMerged } =
aci: maybePair.aci, window.ConversationController.maybeMergeContacts({
pni: maybePair.pni, aci: maybePair.aci,
e164: phoneNumber, pni: maybePair.pni,
reason: 'checkForAccount', e164: phoneNumber,
})?.get('uuid'); reason: 'checkForAccount',
});
uuid = maybeMerged?.get('uuid');
} }
} catch (error) { } catch (error) {
log.error('checkForAccount:', Errors.toLogFormat(error)); log.error('checkForAccount:', Errors.toLogFormat(error));

View file

@ -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 GroupV1MigrationPropsType } from '../../components/conversation/GroupV1Migration';
import type { PropsDataType as DeliveryIssuePropsType } from '../../components/conversation/DeliveryIssueNotification'; import type { PropsDataType as DeliveryIssuePropsType } from '../../components/conversation/DeliveryIssueNotification';
import type { PropsType as PaymentEventNotificationPropsType } from '../../components/conversation/PaymentEventNotification'; 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 { import type {
PropsData as GroupNotificationProps, PropsData as GroupNotificationProps,
ChangeType, ChangeType,
@ -108,6 +110,7 @@ import { calculateExpirationTimestamp } from '../../util/expirationTimer';
import { isSignalConversation } from '../../util/isSignalConversation'; import { isSignalConversation } from '../../util/isSignalConversation';
import type { AnyPaymentEvent } from '../../types/Payment'; import type { AnyPaymentEvent } from '../../types/Payment';
import { isPaymentNotificationEvent } from '../../types/Payment'; import { isPaymentNotificationEvent } from '../../types/Payment';
import { getTitle, renderNumber } from '../../util/getTitle';
export { isIncoming, isOutgoing, isStory }; export { isIncoming, isOutgoing, isStory };
@ -990,6 +993,20 @@ export function getPropsForBubble(
timestamp, timestamp,
}; };
} }
if (isConversationMerge(message)) {
return {
type: 'conversationMerge',
data: getPropsForConversationMerge(message, options),
timestamp,
};
}
if (isPhoneNumberDiscovery(message)) {
return {
type: 'phoneNumberDiscovery',
data: getPhoneNumberDiscovery(message, options),
timestamp,
};
}
if ( if (
messageHasPaymentEvent(message) && messageHasPaymentEvent(message) &&
@ -1214,7 +1231,14 @@ function getPropsForSafetyNumberNotification(
const conversation = getConversation(message, conversationSelector); const conversation = getConversation(message, conversationSelector);
const isGroup = conversation?.type === 'group'; const isGroup = conversation?.type === 'group';
const identifier = message.key_changed; 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 { return {
isGroup, isGroup,
@ -1477,6 +1501,55 @@ export function isChatSessionRefreshed(
// Note: props are null // 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 // Delivery Issue
export function isDeliveryIssue(message: MessageWithUIFieldsType): boolean { export function isDeliveryIssue(message: MessageWithUIFieldsType): boolean {

View file

@ -8,6 +8,7 @@ import { strictAssert } from '../util/assert';
import type { ConversationModel } from '../models/conversations'; import type { ConversationModel } from '../models/conversations';
import type { UUIDStringType } from '../types/UUID'; import type { UUIDStringType } from '../types/UUID';
import type { SafeCombineConversationsParams } from '../ConversationController';
const ACI_1 = UUID.generate().toString(); const ACI_1 = UUID.generate().toString();
const ACI_2 = UUID.generate().toString(); const ACI_2 = UUID.generate().toString();
@ -26,17 +27,16 @@ type ParamsType = {
describe('ConversationController', () => { describe('ConversationController', () => {
describe('maybeMergeContacts', () => { describe('maybeMergeContacts', () => {
let mergeOldAndNew: (options: { let mergeOldAndNew: (
logId: string; options: SafeCombineConversationsParams
oldConversation: ConversationModel; ) => Promise<void>;
newConversation: ConversationModel;
}) => Promise<void>;
beforeEach(async () => { beforeEach(async () => {
await window.Signal.Data._removeAllConversations(); await window.Signal.Data._removeAllConversations();
window.ConversationController.reset(); window.ConversationController.reset();
await window.ConversationController.load(); await window.ConversationController.load();
await window.textsecure.storage.protocol.hydrateCaches();
mergeOldAndNew = () => { mergeOldAndNew = () => {
throw new Error('mergeOldAndNew: Should not be called!'); throw new Error('mergeOldAndNew: Should not be called!');
@ -145,21 +145,23 @@ describe('ConversationController', () => {
describe('non-destructive updates', () => { describe('non-destructive updates', () => {
it('creates a new conversation with just ACI if no matches', () => { it('creates a new conversation with just ACI if no matches', () => {
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
reason, aci: ACI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
}); });
const second = window.ConversationController.maybeMergeContacts({ const { conversation: second } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
reason, aci: ACI_1,
}); reason,
});
expectPropsAndLookups(second, 'second', { expectPropsAndLookups(second, 'second', {
aci: ACI_1, aci: ACI_1,
@ -168,21 +170,23 @@ describe('ConversationController', () => {
assert.strictEqual(result?.id, second?.id, 'result and second match'); assert.strictEqual(result?.id, second?.id, 'result and second match');
}); });
it('creates a new conversation with just e164 if no matches', () => { it('creates a new conversation with just e164 if no matches', () => {
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
reason, e164: E164_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
e164: E164_1, e164: E164_1,
}); });
const second = window.ConversationController.maybeMergeContacts({ const { conversation: second } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
reason, e164: E164_1,
}); reason,
});
expectPropsAndLookups(second, 'second', { expectPropsAndLookups(second, 'second', {
e164: E164_1, e164: E164_1,
@ -191,28 +195,30 @@ describe('ConversationController', () => {
assert.strictEqual(result?.id, second?.id, 'result and second match'); assert.strictEqual(result?.id, second?.id, 'result and second match');
}); });
it('creates a new conversation with e164+PNI if no matches', () => { it('creates a new conversation with e164+PNI if no matches', () => {
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
aci: PNI_1, uuid: PNI_1,
e164: E164_1, e164: E164_1,
pni: PNI_1, pni: PNI_1,
}); });
const second = window.ConversationController.maybeMergeContacts({ const { conversation: second } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(second, 'second', { expectPropsAndLookups(second, 'second', {
aci: PNI_1, uuid: PNI_1,
e164: E164_1, e164: E164_1,
pni: PNI_1, pni: PNI_1,
}); });
@ -220,13 +226,14 @@ describe('ConversationController', () => {
assert.strictEqual(result?.id, second?.id, 'result and second match'); assert.strictEqual(result?.id, second?.id, 'result and second match');
}); });
it('creates a new conversation with all data if no matches', () => { it('creates a new conversation with all data if no matches', () => {
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
@ -234,13 +241,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const second = window.ConversationController.maybeMergeContacts({ const { conversation: second } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(second, 'second', { expectPropsAndLookups(second, 'second', {
uuid: ACI_1, uuid: ACI_1,
@ -258,11 +266,12 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
reason, aci: ACI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
@ -280,12 +289,13 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
@ -302,13 +312,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -328,12 +339,13 @@ describe('ConversationController', () => {
e164: E164_1, e164: E164_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
pni: PNI_1, aci: ACI_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -348,13 +360,14 @@ describe('ConversationController', () => {
uuid: ACI_1, uuid: ACI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -369,13 +382,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -390,13 +404,14 @@ describe('ConversationController', () => {
e164: E164_1, e164: E164_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -410,12 +425,13 @@ describe('ConversationController', () => {
e164: E164_1, e164: E164_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: PNI_1, uuid: PNI_1,
e164: E164_1, e164: E164_1,
@ -429,13 +445,14 @@ describe('ConversationController', () => {
e164: E164_1, e164: E164_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
aci: ACI_1, aci: ACI_1,
e164: E164_1, e164: E164_1,
@ -449,13 +466,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
aci: ACI_1, aci: ACI_1,
e164: E164_1, e164: E164_1,
@ -475,12 +493,13 @@ describe('ConversationController', () => {
e164: E164_1, e164: E164_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: PNI_1, uuid: PNI_1,
e164: E164_1, e164: E164_1,
@ -499,13 +518,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_2, aci: ACI_1,
pni: PNI_2, e164: E164_2,
reason, pni: PNI_2,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_2, e164: E164_2,
@ -530,12 +550,14 @@ describe('ConversationController', () => {
e164: E164_1, e164: E164_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
pni: PNI_2, mergeOldAndNew,
e164: E164_1, aci: PNI_2,
reason, pni: PNI_2,
}); e164: E164_1,
reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: PNI_2, uuid: PNI_2,
e164: E164_1, e164: E164_1,
@ -556,13 +578,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_2, e164: E164_1,
reason, pni: PNI_2,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -584,13 +607,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_2, mergeOldAndNew,
e164: E164_1, aci: ACI_2,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_2, uuid: ACI_2,
e164: E164_1, e164: E164_1,
@ -615,13 +639,14 @@ describe('ConversationController', () => {
uuid: ACI_2, uuid: ACI_2,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_2, mergeOldAndNew,
e164: E164_1, aci: ACI_2,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(aciOnly, 'aciOnly', { expectPropsAndLookups(aciOnly, 'aciOnly', {
uuid: ACI_2, uuid: ACI_2,
e164: E164_1, e164: E164_1,
@ -646,12 +671,13 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: PNI_1, uuid: PNI_1,
e164: E164_1, e164: E164_1,
@ -672,12 +698,13 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_2, mergeOldAndNew,
pni: PNI_1, e164: E164_2,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: PNI_1, uuid: PNI_1,
e164: E164_2, e164: E164_2,
@ -693,10 +720,8 @@ describe('ConversationController', () => {
); );
}); });
it('deletes PNI-only previous conversation, adds it to e164 match', () => { it('deletes PNI-only previous conversation, adds it to e164 match', () => {
mergeOldAndNew = ({ oldConversation }) => { mergeOldAndNew = ({ obsolete }) => {
window.ConversationController.dangerouslyRemoveById( window.ConversationController.dangerouslyRemoveById(obsolete.id);
oldConversation.id
);
return Promise.resolve(); return Promise.resolve();
}; };
@ -707,12 +732,13 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: PNI_1, uuid: PNI_1,
e164: E164_1, e164: E164_1,
@ -728,10 +754,8 @@ describe('ConversationController', () => {
); );
}); });
it('deletes previous conversation with PNI as UUID only, adds it to e164 match', () => { it('deletes previous conversation with PNI as UUID only, adds it to e164 match', () => {
mergeOldAndNew = ({ oldConversation }) => { mergeOldAndNew = ({ obsolete }) => {
window.ConversationController.dangerouslyRemoveById( window.ConversationController.dangerouslyRemoveById(obsolete.id);
oldConversation.id
);
return Promise.resolve(); return Promise.resolve();
}; };
@ -742,12 +766,13 @@ describe('ConversationController', () => {
uuid: PNI_1, uuid: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
e164: E164_1, mergeOldAndNew,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: PNI_1, uuid: PNI_1,
e164: E164_1, e164: E164_1,
@ -763,10 +788,8 @@ describe('ConversationController', () => {
); );
}); });
it('deletes e164+PNI previous conversation, adds data to ACI match', () => { it('deletes e164+PNI previous conversation, adds data to ACI match', () => {
mergeOldAndNew = ({ oldConversation }) => { mergeOldAndNew = ({ obsolete }) => {
window.ConversationController.dangerouslyRemoveById( window.ConversationController.dangerouslyRemoveById(obsolete.id);
oldConversation.id
);
return Promise.resolve(); return Promise.resolve();
}; };
@ -778,13 +801,14 @@ describe('ConversationController', () => {
aci: ACI_1, aci: ACI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -813,13 +837,14 @@ describe('ConversationController', () => {
e164: E164_2, e164: E164_2,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -831,10 +856,8 @@ describe('ConversationController', () => {
assert.strictEqual(result?.id, withACI?.id, 'result and withACI match'); assert.strictEqual(result?.id, withACI?.id, 'result and withACI match');
}); });
it('handles three matching conversations: ACI-only, E164-only (deleted), and with PNI', () => { it('handles three matching conversations: ACI-only, E164-only (deleted), and with PNI', () => {
mergeOldAndNew = ({ oldConversation }) => { mergeOldAndNew = ({ obsolete }) => {
window.ConversationController.dangerouslyRemoveById( window.ConversationController.dangerouslyRemoveById(obsolete.id);
oldConversation.id
);
return Promise.resolve(); return Promise.resolve();
}; };
@ -849,13 +872,14 @@ describe('ConversationController', () => {
e164: E164_2, e164: E164_2,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,
@ -868,10 +892,8 @@ describe('ConversationController', () => {
assert.strictEqual(result?.id, withACI?.id, 'result and withACI match'); assert.strictEqual(result?.id, withACI?.id, 'result and withACI match');
}); });
it('merges three matching conversations: ACI-only, E164-only (deleted), PNI-only (deleted)', () => { it('merges three matching conversations: ACI-only, E164-only (deleted), PNI-only (deleted)', () => {
mergeOldAndNew = ({ oldConversation }) => { mergeOldAndNew = ({ obsolete }) => {
window.ConversationController.dangerouslyRemoveById( window.ConversationController.dangerouslyRemoveById(obsolete.id);
oldConversation.id
);
return Promise.resolve(); return Promise.resolve();
}; };
@ -885,13 +907,14 @@ describe('ConversationController', () => {
pni: PNI_1, pni: PNI_1,
}); });
const result = window.ConversationController.maybeMergeContacts({ const { conversation: result } =
mergeOldAndNew, window.ConversationController.maybeMergeContacts({
aci: ACI_1, mergeOldAndNew,
e164: E164_1, aci: ACI_1,
pni: PNI_1, e164: E164_1,
reason, pni: PNI_1,
}); reason,
});
expectPropsAndLookups(result, 'result', { expectPropsAndLookups(result, 'result', {
uuid: ACI_1, uuid: ACI_1,
e164: E164_1, e164: E164_1,

View file

@ -10,6 +10,7 @@ import { UUID } from '../../types/UUID';
import { SignalProtocolStore } from '../../SignalProtocolStore'; import { SignalProtocolStore } from '../../SignalProtocolStore';
import type { ConversationModel } from '../../models/conversations'; import type { ConversationModel } from '../../models/conversations';
import * as KeyChangeListener from '../../textsecure/KeyChangeListener'; import * as KeyChangeListener from '../../textsecure/KeyChangeListener';
import * as Bytes from '../../Bytes';
describe('KeyChangeListener', () => { describe('KeyChangeListener', () => {
let oldNumberId: string | undefined; let oldNumberId: string | undefined;
@ -54,11 +55,10 @@ describe('KeyChangeListener', () => {
window.ConversationController.reset(); window.ConversationController.reset();
await window.ConversationController.load(); await window.ConversationController.load();
convo = window.ConversationController.dangerouslyCreateAndAdd({ convo = await window.ConversationController.getOrCreateAndWait(
id: uuidWithKeyChange, uuidWithKeyChange,
type: 'private', 'private'
}); );
await window.Signal.Data.saveConversation(convo.attributes);
store = new SignalProtocolStore(); store = new SignalProtocolStore();
await store.hydrateCaches(); await store.hydrateCaches();
@ -78,8 +78,7 @@ describe('KeyChangeListener', () => {
describe('When we have a conversation with this contact', () => { describe('When we have a conversation with this contact', () => {
it('generates a key change notice in the private conversation with this contact', done => { it('generates a key change notice in the private conversation with this contact', done => {
const original = convo.addKeyChange; const original = convo.addKeyChange;
convo.addKeyChange = async keyChangedId => { convo.addKeyChange = async () => {
assert.equal(uuidWithKeyChange, keyChangedId.toString());
convo.addKeyChange = original; convo.addKeyChange = original;
done(); done();
}; };
@ -91,12 +90,15 @@ describe('KeyChangeListener', () => {
let groupConvo: ConversationModel; let groupConvo: ConversationModel;
beforeEach(async () => { beforeEach(async () => {
groupConvo = window.ConversationController.dangerouslyCreateAndAdd({ groupConvo = await window.ConversationController.getOrCreateAndWait(
id: 'groupId', Bytes.toBinary(
type: 'group', new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 1, 2, 3, 4, 5])
members: [convo.id], ),
}); 'group',
await window.Signal.Data.saveConversation(groupConvo.attributes); {
members: [uuidWithKeyChange],
}
);
}); });
afterEach(async () => { afterEach(async () => {
@ -108,8 +110,8 @@ describe('KeyChangeListener', () => {
it('generates a key change notice in the group conversation with this contact', done => { it('generates a key change notice in the group conversation with this contact', done => {
const original = groupConvo.addKeyChange; const original = groupConvo.addKeyChange;
groupConvo.addKeyChange = async keyChangedId => { groupConvo.addKeyChange = async (_, keyChangedId) => {
assert.equal(uuidWithKeyChange, keyChangedId.toString()); assert.equal(uuidWithKeyChange, keyChangedId?.toString());
groupConvo.addKeyChange = original; groupConvo.addKeyChange = original;
done(); done();
}; };

View file

@ -35,7 +35,10 @@ describe('updateConversationsWithUuidLookup', () => {
e164?: string | null; e164?: string | null;
aci?: string | null; aci?: string | null;
reason?: string; reason?: string;
}): ConversationModel | undefined { }): {
conversation: ConversationModel | undefined;
mergePromises: Array<Promise<void>>;
} {
assert( assert(
e164, e164,
'FakeConversationController is not set up for this case (E164 must be provided)' 'FakeConversationController is not set up for this case (E164 must be provided)'
@ -59,21 +62,21 @@ describe('updateConversationsWithUuidLookup', () => {
if (convoE164 && convoUuid) { if (convoE164 && convoUuid) {
if (convoE164 === convoUuid) { if (convoE164 === convoUuid) {
return convoUuid; return { conversation: convoUuid, mergePromises: [] };
} }
convoE164.unset('e164'); convoE164.unset('e164');
convoUuid.updateE164(e164); convoUuid.updateE164(e164);
return convoUuid; return { conversation: convoUuid, mergePromises: [] };
} }
if (convoE164 && !convoUuid) { if (convoE164 && !convoUuid) {
convoE164.updateUuid(normalizedUuid); convoE164.updateUuid(normalizedUuid);
return convoE164; return { conversation: convoE164, mergePromises: [] };
} }
assert.fail('FakeConversationController should never get here'); assert.fail('FakeConversationController should never get here');
return undefined; return { conversation: undefined, mergePromises: [] };
} }
lookupOrCreate({ lookupOrCreate({

View file

@ -23,6 +23,10 @@ export type ConversationOpenInfoType = Readonly<{
delta: number; delta: number;
}>; }>;
export type StorageServiceInfoType = Readonly<{
manifestVersion: number;
}>;
export type AppOptionsType = Readonly<{ export type AppOptionsType = Readonly<{
main: string; main: string;
args: ReadonlyArray<string>; args: ReadonlyArray<string>;
@ -66,6 +70,21 @@ export class App {
return this.waitForEvent('challenge'); return this.waitForEvent('challenge');
} }
public async waitForStorageService(): Promise<StorageServiceInfoType> {
return this.waitForEvent('storageServiceComplete');
}
public async waitForManifestVersion(version: number): Promise<void> {
// 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<void> { public async solveChallenge(response: ChallengeResponseType): Promise<void> {
const window = await this.getWindow(); const window = await this.getWindow();

View file

@ -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');
}
});
});

View file

@ -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./
);
}
});
});

View file

@ -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');
}
});
});

View file

@ -249,6 +249,17 @@ describe('pnp/PNI Signature', function needsName() {
'third message must not have pni signature message' '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 () => { 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?.serviceUuid, pniContact.device.uuid);
assert.strictEqual(aci?.pni, pniContact.device.pni); 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/
);
} }
}); });
}); });

View file

@ -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'
);
});
});
}); });

View file

@ -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 // 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 // database. Your identity, for example, in the saveIdentityWithAttributes call
// below. // below.
const conversationId = window.ConversationController.maybeMergeContacts({ const { conversation } = window.ConversationController.maybeMergeContacts({
aci: ourUuid, aci: ourUuid,
pni: ourPni, pni: ourPni,
e164: number, e164: number,
reason: 'createAccount', reason: 'createAccount',
}); });
if (!conversationId) { if (!conversation) {
throw new Error('registrationDone: no conversationId!'); throw new Error('registrationDone: no conversation!');
} }
const identityAttrs = { const identityAttrs = {

View file

@ -5,17 +5,15 @@ import type { UUID } from '../types/UUID';
import type { SignalProtocolStore } from '../SignalProtocolStore'; import type { SignalProtocolStore } from '../SignalProtocolStore';
export function init(signalProtocolStore: SignalProtocolStore): void { export function init(signalProtocolStore: SignalProtocolStore): void {
signalProtocolStore.on('keychange', async (uuid: UUID): Promise<void> => { signalProtocolStore.on(
const conversation = await window.ConversationController.getOrCreateAndWait( 'keychange',
uuid.toString(), async (uuid: UUID, reason: string): Promise<void> => {
'private' const conversation =
); await window.ConversationController.getOrCreateAndWait(
conversation.addKeyChange(uuid); uuid.toString(),
'private'
const groups = );
await window.ConversationController.getAllGroupsInvolvingUuid(uuid); conversation.addKeyChange(reason);
for (const group of groups) {
group.addKeyChange(uuid);
} }
}); );
} }

View file

@ -1154,9 +1154,11 @@ export default class MessageReceiver
logId = getEnvelopeId(unsealedEnvelope); logId = getEnvelopeId(unsealedEnvelope);
const taskId = `dispatchEvent(EnvelopeEvent(${logId}))`;
this.addToQueue( this.addToQueue(
async () => this.dispatchEvent(new EnvelopeEvent(unsealedEnvelope)), async () =>
`dispatchEvent(EnvelopeEvent(${logId}))`, this.dispatchAndWait(taskId, new EnvelopeEvent(unsealedEnvelope)),
taskId,
TaskType.Decrypted TaskType.Decrypted
); );
@ -2514,12 +2516,18 @@ export default class MessageReceiver
if (isValid) { if (isValid) {
log.info(`${logId}: merging pni=${pni} aci=${aci}`); log.info(`${logId}: merging pni=${pni} aci=${aci}`);
window.ConversationController.maybeMergeContacts({ const { mergePromises } =
pni, window.ConversationController.maybeMergeContacts({
aci, pni,
e164: window.ConversationController.get(pni)?.get('e164'), aci,
reason: logId, e164: window.ConversationController.get(pni)?.get('e164'),
}); fromPniSignature: true,
reason: logId,
});
if (mergePromises.length) {
await Promise.all(mergePromises);
}
} }
} }

View file

@ -40,7 +40,7 @@ export async function updateConversationsWithUuidLookup({
const pairFromServer = serverLookup.get(e164); const pairFromServer = serverLookup.get(e164);
if (pairFromServer) { if (pairFromServer) {
const maybeFinalConversation = const { conversation: maybeFinalConversation } =
conversationController.maybeMergeContacts({ conversationController.maybeMergeContacts({
aci: pairFromServer.aci, aci: pairFromServer.aci,
pni: pairFromServer.pni, pni: pairFromServer.pni,

View file

@ -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,
});
}

View file

@ -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,
});
}

92
ts/util/getTitle.ts Normal file
View file

@ -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<ConversationAttributesType, 'e164' | 'type'>
): 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;
}
}

View file

@ -76,13 +76,14 @@ export async function lookupConversationWithoutUuid(
const maybePair = serverLookup.get(options.e164); const maybePair = serverLookup.get(options.e164);
if (maybePair) { if (maybePair) {
const convo = window.ConversationController.maybeMergeContacts({ const { conversation } =
aci: maybePair.aci, window.ConversationController.maybeMergeContacts({
pni: maybePair.pni, aci: maybePair.aci,
e164: options.e164, pni: maybePair.pni,
reason: 'startNewConversationWithoutUuid(e164)', e164: options.e164,
}); reason: 'startNewConversationWithoutUuid(e164)',
conversationId = convo?.id; });
conversationId = conversation?.id;
} }
} else { } else {
const foundUsername = await checkForUsername(options.username); const foundUsername = await checkForUsername(options.username);

View file

@ -6,7 +6,7 @@
import type * as Backbone from 'backbone'; import type * as Backbone from 'backbone';
import type { ComponentProps } from 'react'; import type { ComponentProps } from 'react';
import * as React from 'react'; import * as React from 'react';
import { debounce, flatten, throttle } from 'lodash'; import { debounce, flatten } from 'lodash';
import { render } from 'mustache'; import { render } from 'mustache';
import type { AttachmentType } from '../types/Attachment'; import type { AttachmentType } from '../types/Attachment';
@ -122,8 +122,6 @@ type AttachmentOptions = {
type PanelType = { view: Backbone.View; headerTitle?: string }; type PanelType = { view: Backbone.View; headerTitle?: string };
const FIVE_MINUTES = 1000 * 60 * 5;
const { Message } = window.Signal.Types; const { Message } = window.Signal.Types;
const { const {
@ -206,7 +204,6 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
messageText: string, messageText: string,
bodyRanges: DraftBodyRangesType bodyRanges: DraftBodyRangesType
) => Promise<void>; ) => Promise<void>;
private lazyUpdateVerified: () => void;
// Composing messages // Composing messages
private compositionApi: { private compositionApi: {
@ -233,19 +230,10 @@ export class ConversationView extends window.Backbone.View<ConversationModel> {
constructor(...args: Array<any>) { constructor(...args: Array<any>) {
super(...args); 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); this.debouncedSaveDraft = debounce(this.saveDraft.bind(this), 200);
// Events on Conversation model // Events on Conversation model
this.listenTo(this.model, 'destroy', this.stopListening); this.listenTo(this.model, 'destroy', this.stopListening);
this.listenTo(this.model, 'newmessage', this.lazyUpdateVerified);
// These are triggered by InboxView // These are triggered by InboxView
this.listenTo(this.model, 'opened', this.onOpened); this.listenTo(this.model, 'opened', this.onOpened);