Show Session Switchover Events

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Fedor Indutny 2023-10-23 19:40:42 +02:00 committed by GitHub
parent 70cd073a72
commit dd2493a353
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 455 additions and 17 deletions

View file

@ -1489,6 +1489,14 @@
"messageformat": "Your message history for both chats 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."
},
"icu:quoteThumbnailAlt": {
"messageformat": "Thumbnail of image from quoted message",
"description": "Used in alt tag of thumbnail images inside of an embedded message quote"

View file

@ -471,7 +471,7 @@ export class ConversationController {
e164,
pni: providedPni,
reason,
fromPniSignature,
fromPniSignature = false,
mergeOldAndNew = safeCombineConversations,
}: {
aci?: AciString;
@ -526,6 +526,12 @@ export class ConversationController {
let unusedMatches: Array<ConvoMatchType> = [];
let targetConversation: ConversationModel | undefined;
let targetOldServiceIds:
| {
aci?: AciString;
pni?: PniString;
}
| undefined;
let matchCount = 0;
matches.forEach(item => {
const { key, value, match } = item;
@ -597,6 +603,11 @@ export class ConversationController {
);
}
targetOldServiceIds = {
aci: targetConversation.getAci(),
pni: targetConversation.getPni(),
};
log.info(
`${logId}: Applying new value for ${unused.key} to target conversation`
);
@ -686,9 +697,32 @@ export class ConversationController {
// `${logId}: Match on ${key} is target conversation - ${match.idForLogging()}`
// );
targetConversation = match;
targetOldServiceIds = {
aci: targetConversation.getAci(),
pni: targetConversation.getPni(),
};
}
});
// If the change is not coming from PNI Signature, and target conversation
// had PNI and has acquired new ACI and/or PNI we should check if it had
// a PNI session on the original PNI. If yes - add a PhoneNumberDiscovery notification
if (
e164 &&
pni &&
targetConversation &&
targetOldServiceIds?.pni &&
!fromPniSignature &&
(targetOldServiceIds.pni !== pni ||
(aci && targetOldServiceIds.aci !== aci))
) {
mergePromises.push(
targetConversation.addPhoneNumberDiscoveryIfNeeded(
targetOldServiceIds.pni
)
);
}
if (targetConversation) {
return { conversation: targetConversation, mergePromises };
}

View file

@ -1467,6 +1467,18 @@ export class SignalProtocolStore extends EventEmitter {
});
}
async hasSessionWith(serviceId: ServiceIdString): Promise<boolean> {
return this.withZone(GLOBAL_ZONE, 'hasSessionWith', async () => {
if (!this.sessions) {
throw new Error('getOpenDevices: this.sessions not yet cached!');
}
return this._getAllSessions().some(
({ fromDB }) => fromDB.serviceId === serviceId
);
});
}
async getOpenDevices(
ourServiceId: ServiceIdString,
serviceIds: ReadonlyArray<ServiceIdString>,

View file

@ -0,0 +1,34 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import type { Meta } from '@storybook/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',
} satisfies Meta<PropsType>;
const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
i18n,
conversationTitle: overrideProps.conversationTitle || 'John Fire',
phoneNumber: '(555) 333-1111',
});
export function WithoutSharedGroup(): JSX.Element {
return <PhoneNumberDiscoveryNotification {...createProps()} />;
}
export function WithSharedGroup(): JSX.Element {
return (
<PhoneNumberDiscoveryNotification
{...createProps()}
sharedGroup="Fun Times"
/>
);
}

View file

@ -0,0 +1,34 @@
// Copyright 2023 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;
};
// Also known as a Session Switchover Event (SSE)
export function PhoneNumberDiscoveryNotification(
props: PropsType
): JSX.Element {
const { conversationTitle, i18n, sharedGroup, phoneNumber } = props;
const message = getStringForPhoneNumberDiscovery({
conversationTitle,
i18n,
phoneNumber,
sharedGroup,
});
return <SystemMessage icon="info" contents={<Emojify text={message} />} />;
}

View file

@ -50,6 +50,8 @@ import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEv
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 { SystemMessage } from './SystemMessage';
import type { FullJSXType } from '../Intl';
import { TimelineMessage } from './TimelineMessage';
@ -122,6 +124,10 @@ type ConversationMergeNotificationType = {
type: 'conversationMerge';
data: ConversationMergeNotificationPropsType;
};
type PhoneNumberDiscoveryNotificationType = {
type: 'phoneNumberDiscovery';
data: PhoneNumberDiscoveryNotificationPropsType;
};
type PaymentEventType = {
type: 'paymentEvent';
data: Omit<PaymentEventNotificationPropsType, 'i18n'>;
@ -137,6 +143,7 @@ export type TimelineItemType = (
| GroupV1MigrationType
| GroupV2ChangeType
| MessageType
| PhoneNumberDiscoveryNotificationType
| ProfileChangeNotificationType
| ResetSessionNotificationType
| SafetyNumberNotificationType
@ -330,6 +337,14 @@ export const TimelineItem = memo(function TimelineItem({
i18n={i18n}
/>
);
} else if (item.type === 'phoneNumberDiscovery') {
notification = (
<PhoneNumberDiscoveryNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'resetSessionNotification') {
notification = <ResetSessionNotification {...reducedProps} i18n={i18n} />;
} else if (item.type === 'profileChange') {

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

@ -181,6 +181,7 @@ export type MessageAttributesType = {
| 'incoming'
| 'keychange'
| 'outgoing'
| 'phone-number-discovery'
| 'profile-change'
| 'story'
| 'timer-notification'
@ -214,6 +215,9 @@ export type MessageAttributesType = {
source?: string;
sourceServiceId?: ServiceIdString;
};
phoneNumberDiscovery?: {
e164: string;
};
conversationMerge?: {
renderInfo: ConversationRenderInfoType;
};

View file

@ -3032,6 +3032,59 @@ export class ConversationModel extends window.Backbone
this.trigger('newmessage', model);
}
async addPhoneNumberDiscoveryIfNeeded(originalPni: PniString): Promise<void> {
const logId = `addPhoneNumberDiscoveryIfNeeded(${this.idForLogging()}, ${originalPni})`;
const e164 = this.get('e164');
if (!e164) {
log.info(`${logId}: not adding, no e164`);
return;
}
const hadSession = await window.textsecure.storage.protocol.hasSessionWith(
originalPni
);
if (!hadSession) {
log.info(`${logId}: not adding, no PNI session`);
return;
}
log.info(`${logId}: adding notification`);
const timestamp = Date.now();
const message: MessageAttributesType = {
id: generateGuid(),
conversationId: this.id,
type: 'phone-number-discovery',
sent_at: timestamp,
timestamp,
received_at: 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, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
});
const model = window.MessageCache.__DEPRECATED$register(
id,
new window.Whisper.Message({
...message,
id,
}),
'addPhoneNumberDiscoveryIfNeeded'
);
this.trigger('newmessage', model);
}
async addVerifiedChange(
verifiedChangeId: string,
verified: boolean,

View file

@ -98,6 +98,7 @@ import {
isUnsupportedMessage,
isVerifiedChange,
isConversationMerge,
isPhoneNumberDiscovery,
} from '../state/selectors/message';
import type { ReactionAttributesType } from '../messageModifiers/Reactions';
import { isInCall } from '../state/selectors/calling';
@ -139,6 +140,7 @@ import { SeenStatus } from '../MessageSeenStatus';
import { isNewReactionReplacingPrevious } from '../reactions/util';
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
import type { StickerWithHydratedData } from '../types/Stickers';
import {
addToAttachmentDownloadQueue,
shouldUseAttachmentDownloadQueue,
@ -313,6 +315,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
!isGroupV1Migration(attributes) &&
!isGroupV2Change(attributes) &&
!isKeyChange(attributes) &&
!isPhoneNumberDiscovery(attributes) &&
!isProfileChange(attributes) &&
!isUniversalTimerNotification(attributes) &&
!isUnsupportedMessage(attributes) &&
@ -643,6 +646,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
const isUniversalTimerNotificationValue =
isUniversalTimerNotification(attributes);
const isConversationMergeValue = isConversationMerge(attributes);
const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes);
const isPayment = messageHasPaymentEvent(attributes);
@ -674,7 +678,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
isKeyChangeValue ||
isProfileChangeValue ||
isUniversalTimerNotificationValue ||
isConversationMergeValue;
isConversationMergeValue ||
isPhoneNumberDiscoveryValue;
return !hasSomethingToDisplay;
}

View file

@ -34,6 +34,7 @@ import type { PropsDataType as GroupV1MigrationPropsType } from '../../component
import type { PropsDataType as DeliveryIssuePropsType } from '../../components/conversation/DeliveryIssueNotification';
import type { PropsType as PaymentEventNotificationPropsType } from '../../components/conversation/PaymentEventNotification';
import type { PropsDataType as ConversationMergePropsType } from '../../components/conversation/ConversationMergeNotification';
import type { PropsDataType as PhoneNumberDiscoveryPropsType } from '../../components/conversation/PhoneNumberDiscoveryNotification';
import type {
PropsData as GroupNotificationProps,
ChangeType,
@ -130,7 +131,11 @@ import { calculateExpirationTimestamp } from '../../util/expirationTimer';
import { isSignalConversation } from '../../util/isSignalConversation';
import type { AnyPaymentEvent } from '../../types/Payment';
import { isPaymentNotificationEvent } from '../../types/Payment';
import { getTitleNoDefault, getNumber } from '../../util/getTitle';
import {
getTitleNoDefault,
getNumber,
renderNumber,
} from '../../util/getTitle';
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
import type { CallHistorySelectorType } from './callHistory';
import { CallMode } from '../../types/Calling';
@ -951,6 +956,13 @@ export function getPropsForBubble(
timestamp,
};
}
if (isPhoneNumberDiscovery(message)) {
return {
type: 'phoneNumberDiscovery',
data: getPropsForPhoneNumberDiscovery(message, options),
timestamp,
};
}
if (
messageHasPaymentEvent(message) &&
@ -1517,6 +1529,34 @@ export function getPropsForConversationMerge(
};
}
export function isPhoneNumberDiscovery(
message: MessageWithUIFieldsType
): boolean {
return message.type === 'phone-number-discovery';
}
export function getPropsForPhoneNumberDiscovery(
message: MessageWithUIFieldsType,
{ conversationSelector }: GetPropsForBubbleOptions
): PhoneNumberDiscoveryPropsType {
const { phoneNumberDiscovery } = message;
if (!phoneNumberDiscovery) {
throw new Error(
'getPropsForPhoneNumberDiscovery: message is missing phoneNumberDiscovery!'
);
}
const conversation = getConversation(message, conversationSelector);
const conversationTitle = conversation.title;
const sharedGroup = conversation.sharedGroupNames[0];
const { e164 } = phoneNumberDiscovery;
return {
conversationTitle,
phoneNumber: renderNumber(e164) ?? e164,
sharedGroup,
};
}
// Delivery Issue
export function isDeliveryIssue(message: MessageWithUIFieldsType): boolean {

View file

@ -0,0 +1,155 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { ServiceIdKind, 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 { toUntaggedPni } from '../../types/ServiceId';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
export const debug = createDebug('mock:test:merge');
const IdentifierType = Proto.ManifestRecord.Identifier.Type;
// PhoneNumberDiscovery notifications are also known as a Session Switchover Events (SSE).
describe('pnp/phone discovery', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
this.retries(4);
let bootstrap: Bootstrap;
let app: App;
let pniContact: PrimaryDevice;
let pniIdentityKey: Uint8Array;
beforeEach(async () => {
bootstrap = new Bootstrap({ contactCount: 0 });
await bootstrap.init();
const { server, phone } = bootstrap;
pniContact = await server.createPrimaryDevice({
profileName: 'ACI Contact',
});
pniIdentityKey = pniContact.getPublicKey(ServiceIdKind.PNI).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,
},
ServiceIdKind.PNI
);
// Put contact in left pane
state = state.pin(pniContact, ServiceIdKind.PNI);
// 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,
recipientServiceIds: [],
},
},
});
await phone.setStorageState(state);
app = await bootstrap.link();
});
afterEach(async function (this: Mocha.Context) {
await bootstrap.maybeSaveLogs(this.currentTest, app);
await app.close();
await bootstrap.teardown();
});
it('adds phone number discovery when we detect ACI/PNI association via Storage Service', async () => {
const { phone } = bootstrap;
const window = await app.getWindow();
const leftPane = window.locator('#LeftPane');
debug('opening conversation with the PNI');
await leftPane.locator(`[data-testid="${pniContact.device.pni}"]`).click();
debug('Send message to PNI and establish a session');
{
const compositionInput = await app.waitForEnabledComposer();
await compositionInput.type('Hello PNI');
await compositionInput.press('Enter');
}
debug(
'adding both contacts from storage service, adding one combined contact'
);
{
const state = await phone.expectStorageState('consistency check');
await phone.setStorageState(
state
.removeContact(pniContact, ServiceIdKind.PNI)
.addContact(pniContact, {
identityState: Proto.ContactRecord.IdentityState.DEFAULT,
whitelisted: true,
identityKey: pniContact.publicKey.serialize(),
profileKey: pniContact.profileKey.serialize(),
pni: toUntaggedPni(pniContact.device.pni),
})
);
await phone.sendFetchStorage({
timestamp: bootstrap.getTimestamp(),
});
}
await window.locator('.module-conversation-hero').waitFor();
debug('Open ACI conversation');
await leftPane.locator(`[data-testid="${pniContact.device.aci}"]`).click();
debug('Wait for PNI conversation to go away');
await window
.locator(`.module-conversation-hero >> ${pniContact.profileName}`)
.waitFor({
state: 'hidden',
});
debug('Verify final state');
{
// Should have PNI message
await window.locator('.module-message__text >> "Hello PNI"').waitFor();
const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 1, 'message count');
// One notification - the PhoneNumberDiscovery
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 1, 'notification count');
const first = await notifications.first();
assert.match(await first.innerText(), /.* belongs to ACI Contact/);
}
});
});

View file

@ -13,6 +13,9 @@ import type { App } from '../bootstrap';
export const debug = createDebug('mock:test:pni-change');
// Note that all tests also generate an PhoneNumberDiscovery notification, also known as a
// Session Switchover Event (SSE). See for reference:
// https://github.com/signalapp/Signal-Android-Private/blob/df83c941804512c613a1010b7d8e5ce4f0aec71c/app/src/androidTest/java/org/thoughtcrime/securesms/database/RecipientTableTest_getAndPossiblyMerge.kt#L266-L270
describe('pnp/PNI Change', function (this: Mocha.Suite) {
this.timeout(durations.MINUTE);
this.retries(4);
@ -68,7 +71,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
await bootstrap.teardown();
});
it('shows no identity change if identity key is the same', async () => {
it('shows phone number change if identity key is the same, learned via storage service', async () => {
const { desktop, phone } = bootstrap;
const window = await app.getWindow();
@ -162,13 +165,16 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
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
// Only a PhoneNumberDiscovery notification
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 0, 'notification count');
assert.strictEqual(await notifications.count(), 1, 'notification count');
const first = await notifications.first();
assert.match(await first.innerText(), /.* belongs to ContactA/);
}
});
it('shows identity change if identity key has changed', async () => {
it('shows identity and phone number change if identity key has changed', async () => {
const { desktop, phone } = bootstrap;
const window = await app.getWindow();
@ -262,16 +268,19 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 1, 'message count');
// One notification - the safety number change
// Two notifications - the safety number change and PhoneNumberDiscovery
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 1, 'notification count');
assert.strictEqual(await notifications.count(), 2, 'notification count');
const first = await notifications.first();
assert.match(await first.innerText(), /Safety Number has changed/);
assert.match(await first.innerText(), /.* belongs to ContactA/);
const second = await notifications.nth(1);
assert.match(await second.innerText(), /Safety Number has changed/);
}
});
it('shows identity change when sending to contact', async () => {
it('shows identity and phone number change on send to contact when e165 has changed owners', async () => {
const { desktop, phone } = bootstrap;
const window = await app.getWindow();
@ -395,16 +404,19 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 2, 'message count');
// One notification - the safety number change
// Two notifications - the safety number change and PhoneNumberDiscovery
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 1, 'notification count');
assert.strictEqual(await notifications.count(), 2, 'notification count');
const first = await notifications.first();
assert.match(await first.innerText(), /Safety Number has changed/);
assert.match(await first.innerText(), /.* belongs to ContactA/);
const second = await notifications.nth(1);
assert.match(await second.innerText(), /Safety Number has changed/);
}
});
it('Sends with no warning when key is the same', async () => {
it('Get phone number change warning when e164 leaves contact then goes back to same contact', async () => {
const { desktop, phone } = bootstrap;
const window = await app.getWindow();
@ -551,9 +563,12 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 2, 'message count');
// No notifications - the key is the same
// Only a PhoneNumberDiscovery notification
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 0, 'notification count');
assert.strictEqual(await notifications.count(), 1, 'notification count');
const first = await notifications.first();
assert.match(await first.innerText(), /.* belongs to ContactA/);
}
});
});

View file

@ -0,0 +1,29 @@
// Copyright 2023 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,
});
}