Show Session Switchover Events
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
70cd073a72
commit
dd2493a353
13 changed files with 455 additions and 17 deletions
|
@ -1489,6 +1489,14 @@
|
||||||
"messageformat": "Your message history for both chats have been merged here.",
|
"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."
|
"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": {
|
"icu:quoteThumbnailAlt": {
|
||||||
"messageformat": "Thumbnail of image from quoted message",
|
"messageformat": "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"
|
||||||
|
|
|
@ -471,7 +471,7 @@ export class ConversationController {
|
||||||
e164,
|
e164,
|
||||||
pni: providedPni,
|
pni: providedPni,
|
||||||
reason,
|
reason,
|
||||||
fromPniSignature,
|
fromPniSignature = false,
|
||||||
mergeOldAndNew = safeCombineConversations,
|
mergeOldAndNew = safeCombineConversations,
|
||||||
}: {
|
}: {
|
||||||
aci?: AciString;
|
aci?: AciString;
|
||||||
|
@ -526,6 +526,12 @@ export class ConversationController {
|
||||||
let unusedMatches: Array<ConvoMatchType> = [];
|
let unusedMatches: Array<ConvoMatchType> = [];
|
||||||
|
|
||||||
let targetConversation: ConversationModel | undefined;
|
let targetConversation: ConversationModel | undefined;
|
||||||
|
let targetOldServiceIds:
|
||||||
|
| {
|
||||||
|
aci?: AciString;
|
||||||
|
pni?: PniString;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
let matchCount = 0;
|
let matchCount = 0;
|
||||||
matches.forEach(item => {
|
matches.forEach(item => {
|
||||||
const { key, value, match } = item;
|
const { key, value, match } = item;
|
||||||
|
@ -597,6 +603,11 @@ export class ConversationController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
targetOldServiceIds = {
|
||||||
|
aci: targetConversation.getAci(),
|
||||||
|
pni: targetConversation.getPni(),
|
||||||
|
};
|
||||||
|
|
||||||
log.info(
|
log.info(
|
||||||
`${logId}: Applying new value for ${unused.key} to target conversation`
|
`${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()}`
|
// `${logId}: Match on ${key} is target conversation - ${match.idForLogging()}`
|
||||||
// );
|
// );
|
||||||
targetConversation = match;
|
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) {
|
if (targetConversation) {
|
||||||
return { conversation: targetConversation, mergePromises };
|
return { conversation: targetConversation, mergePromises };
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
async getOpenDevices(
|
||||||
ourServiceId: ServiceIdString,
|
ourServiceId: ServiceIdString,
|
||||||
serviceIds: ReadonlyArray<ServiceIdString>,
|
serviceIds: ReadonlyArray<ServiceIdString>,
|
||||||
|
|
|
@ -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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
|
@ -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} />} />;
|
||||||
|
}
|
|
@ -50,6 +50,8 @@ import type { PropsType as PaymentEventNotificationPropsType } from './PaymentEv
|
||||||
import { PaymentEventNotification } from './PaymentEventNotification';
|
import { PaymentEventNotification } from './PaymentEventNotification';
|
||||||
import type { PropsDataType as ConversationMergeNotificationPropsType } from './ConversationMergeNotification';
|
import type { PropsDataType as ConversationMergeNotificationPropsType } from './ConversationMergeNotification';
|
||||||
import { ConversationMergeNotification } from './ConversationMergeNotification';
|
import { ConversationMergeNotification } from './ConversationMergeNotification';
|
||||||
|
import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from './PhoneNumberDiscoveryNotification';
|
||||||
|
import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification';
|
||||||
import { SystemMessage } from './SystemMessage';
|
import { SystemMessage } from './SystemMessage';
|
||||||
import type { FullJSXType } from '../Intl';
|
import type { FullJSXType } from '../Intl';
|
||||||
import { TimelineMessage } from './TimelineMessage';
|
import { TimelineMessage } from './TimelineMessage';
|
||||||
|
@ -122,6 +124,10 @@ type ConversationMergeNotificationType = {
|
||||||
type: 'conversationMerge';
|
type: 'conversationMerge';
|
||||||
data: ConversationMergeNotificationPropsType;
|
data: ConversationMergeNotificationPropsType;
|
||||||
};
|
};
|
||||||
|
type PhoneNumberDiscoveryNotificationType = {
|
||||||
|
type: 'phoneNumberDiscovery';
|
||||||
|
data: PhoneNumberDiscoveryNotificationPropsType;
|
||||||
|
};
|
||||||
type PaymentEventType = {
|
type PaymentEventType = {
|
||||||
type: 'paymentEvent';
|
type: 'paymentEvent';
|
||||||
data: Omit<PaymentEventNotificationPropsType, 'i18n'>;
|
data: Omit<PaymentEventNotificationPropsType, 'i18n'>;
|
||||||
|
@ -137,6 +143,7 @@ export type TimelineItemType = (
|
||||||
| GroupV1MigrationType
|
| GroupV1MigrationType
|
||||||
| GroupV2ChangeType
|
| GroupV2ChangeType
|
||||||
| MessageType
|
| MessageType
|
||||||
|
| PhoneNumberDiscoveryNotificationType
|
||||||
| ProfileChangeNotificationType
|
| ProfileChangeNotificationType
|
||||||
| ResetSessionNotificationType
|
| ResetSessionNotificationType
|
||||||
| SafetyNumberNotificationType
|
| SafetyNumberNotificationType
|
||||||
|
@ -330,6 +337,14 @@ export const TimelineItem = memo(function TimelineItem({
|
||||||
i18n={i18n}
|
i18n={i18n}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
} else if (item.type === 'phoneNumberDiscovery') {
|
||||||
|
notification = (
|
||||||
|
<PhoneNumberDiscoveryNotification
|
||||||
|
{...reducedProps}
|
||||||
|
{...item.data}
|
||||||
|
i18n={i18n}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (item.type === 'resetSessionNotification') {
|
} else if (item.type === 'resetSessionNotification') {
|
||||||
notification = <ResetSessionNotification {...reducedProps} i18n={i18n} />;
|
notification = <ResetSessionNotification {...reducedProps} i18n={i18n} />;
|
||||||
} else if (item.type === 'profileChange') {
|
} else if (item.type === 'profileChange') {
|
||||||
|
|
4
ts/model-types.d.ts
vendored
4
ts/model-types.d.ts
vendored
|
@ -181,6 +181,7 @@ export type MessageAttributesType = {
|
||||||
| 'incoming'
|
| 'incoming'
|
||||||
| 'keychange'
|
| 'keychange'
|
||||||
| 'outgoing'
|
| 'outgoing'
|
||||||
|
| 'phone-number-discovery'
|
||||||
| 'profile-change'
|
| 'profile-change'
|
||||||
| 'story'
|
| 'story'
|
||||||
| 'timer-notification'
|
| 'timer-notification'
|
||||||
|
@ -214,6 +215,9 @@ export type MessageAttributesType = {
|
||||||
source?: string;
|
source?: string;
|
||||||
sourceServiceId?: ServiceIdString;
|
sourceServiceId?: ServiceIdString;
|
||||||
};
|
};
|
||||||
|
phoneNumberDiscovery?: {
|
||||||
|
e164: string;
|
||||||
|
};
|
||||||
conversationMerge?: {
|
conversationMerge?: {
|
||||||
renderInfo: ConversationRenderInfoType;
|
renderInfo: ConversationRenderInfoType;
|
||||||
};
|
};
|
||||||
|
|
|
@ -3032,6 +3032,59 @@ export class ConversationModel extends window.Backbone
|
||||||
this.trigger('newmessage', model);
|
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(
|
async addVerifiedChange(
|
||||||
verifiedChangeId: string,
|
verifiedChangeId: string,
|
||||||
verified: boolean,
|
verified: boolean,
|
||||||
|
|
|
@ -98,6 +98,7 @@ import {
|
||||||
isUnsupportedMessage,
|
isUnsupportedMessage,
|
||||||
isVerifiedChange,
|
isVerifiedChange,
|
||||||
isConversationMerge,
|
isConversationMerge,
|
||||||
|
isPhoneNumberDiscovery,
|
||||||
} from '../state/selectors/message';
|
} from '../state/selectors/message';
|
||||||
import type { ReactionAttributesType } from '../messageModifiers/Reactions';
|
import type { ReactionAttributesType } from '../messageModifiers/Reactions';
|
||||||
import { isInCall } from '../state/selectors/calling';
|
import { isInCall } from '../state/selectors/calling';
|
||||||
|
@ -139,6 +140,7 @@ import { SeenStatus } from '../MessageSeenStatus';
|
||||||
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
import { isNewReactionReplacingPrevious } from '../reactions/util';
|
||||||
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
|
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
|
||||||
import type { StickerWithHydratedData } from '../types/Stickers';
|
import type { StickerWithHydratedData } from '../types/Stickers';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
addToAttachmentDownloadQueue,
|
addToAttachmentDownloadQueue,
|
||||||
shouldUseAttachmentDownloadQueue,
|
shouldUseAttachmentDownloadQueue,
|
||||||
|
@ -313,6 +315,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
!isGroupV1Migration(attributes) &&
|
!isGroupV1Migration(attributes) &&
|
||||||
!isGroupV2Change(attributes) &&
|
!isGroupV2Change(attributes) &&
|
||||||
!isKeyChange(attributes) &&
|
!isKeyChange(attributes) &&
|
||||||
|
!isPhoneNumberDiscovery(attributes) &&
|
||||||
!isProfileChange(attributes) &&
|
!isProfileChange(attributes) &&
|
||||||
!isUniversalTimerNotification(attributes) &&
|
!isUniversalTimerNotification(attributes) &&
|
||||||
!isUnsupportedMessage(attributes) &&
|
!isUnsupportedMessage(attributes) &&
|
||||||
|
@ -643,6 +646,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
const isUniversalTimerNotificationValue =
|
const isUniversalTimerNotificationValue =
|
||||||
isUniversalTimerNotification(attributes);
|
isUniversalTimerNotification(attributes);
|
||||||
const isConversationMergeValue = isConversationMerge(attributes);
|
const isConversationMergeValue = isConversationMerge(attributes);
|
||||||
|
const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes);
|
||||||
|
|
||||||
const isPayment = messageHasPaymentEvent(attributes);
|
const isPayment = messageHasPaymentEvent(attributes);
|
||||||
|
|
||||||
|
@ -674,7 +678,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
isKeyChangeValue ||
|
isKeyChangeValue ||
|
||||||
isProfileChangeValue ||
|
isProfileChangeValue ||
|
||||||
isUniversalTimerNotificationValue ||
|
isUniversalTimerNotificationValue ||
|
||||||
isConversationMergeValue;
|
isConversationMergeValue ||
|
||||||
|
isPhoneNumberDiscoveryValue;
|
||||||
|
|
||||||
return !hasSomethingToDisplay;
|
return !hasSomethingToDisplay;
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,6 +34,7 @@ import type { PropsDataType as GroupV1MigrationPropsType } from '../../component
|
||||||
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 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,
|
||||||
|
@ -130,7 +131,11 @@ 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 { getTitleNoDefault, getNumber } from '../../util/getTitle';
|
import {
|
||||||
|
getTitleNoDefault,
|
||||||
|
getNumber,
|
||||||
|
renderNumber,
|
||||||
|
} from '../../util/getTitle';
|
||||||
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
|
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
|
||||||
import type { CallHistorySelectorType } from './callHistory';
|
import type { CallHistorySelectorType } from './callHistory';
|
||||||
import { CallMode } from '../../types/Calling';
|
import { CallMode } from '../../types/Calling';
|
||||||
|
@ -951,6 +956,13 @@ export function getPropsForBubble(
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (isPhoneNumberDiscovery(message)) {
|
||||||
|
return {
|
||||||
|
type: 'phoneNumberDiscovery',
|
||||||
|
data: getPropsForPhoneNumberDiscovery(message, options),
|
||||||
|
timestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
messageHasPaymentEvent(message) &&
|
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
|
// Delivery Issue
|
||||||
|
|
||||||
export function isDeliveryIssue(message: MessageWithUIFieldsType): boolean {
|
export function isDeliveryIssue(message: MessageWithUIFieldsType): boolean {
|
||||||
|
|
155
ts/test-mock/pnp/phone_discovery_test.ts
Normal file
155
ts/test-mock/pnp/phone_discovery_test.ts
Normal 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/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
|
@ -13,6 +13,9 @@ import type { App } from '../bootstrap';
|
||||||
|
|
||||||
export const debug = createDebug('mock:test:pni-change');
|
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) {
|
describe('pnp/PNI Change', function (this: Mocha.Suite) {
|
||||||
this.timeout(durations.MINUTE);
|
this.timeout(durations.MINUTE);
|
||||||
this.retries(4);
|
this.retries(4);
|
||||||
|
@ -68,7 +71,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
|
||||||
await bootstrap.teardown();
|
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 { desktop, phone } = bootstrap;
|
||||||
|
|
||||||
const window = await app.getWindow();
|
const window = await app.getWindow();
|
||||||
|
@ -162,13 +165,16 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
|
||||||
const messages = window.locator('.module-message__text');
|
const messages = window.locator('.module-message__text');
|
||||||
assert.strictEqual(await messages.count(), 1, 'message count');
|
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');
|
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 { desktop, phone } = bootstrap;
|
||||||
|
|
||||||
const window = await app.getWindow();
|
const window = await app.getWindow();
|
||||||
|
@ -262,16 +268,19 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
|
||||||
const messages = window.locator('.module-message__text');
|
const messages = window.locator('.module-message__text');
|
||||||
assert.strictEqual(await messages.count(), 1, 'message count');
|
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');
|
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();
|
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 { desktop, phone } = bootstrap;
|
||||||
|
|
||||||
const window = await app.getWindow();
|
const window = await app.getWindow();
|
||||||
|
@ -395,16 +404,19 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
|
||||||
const messages = window.locator('.module-message__text');
|
const messages = window.locator('.module-message__text');
|
||||||
assert.strictEqual(await messages.count(), 2, 'message count');
|
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');
|
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();
|
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 { desktop, phone } = bootstrap;
|
||||||
|
|
||||||
const window = await app.getWindow();
|
const window = await app.getWindow();
|
||||||
|
@ -551,9 +563,12 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
|
||||||
const messages = window.locator('.module-message__text');
|
const messages = window.locator('.module-message__text');
|
||||||
assert.strictEqual(await messages.count(), 2, 'message count');
|
assert.strictEqual(await messages.count(), 2, 'message count');
|
||||||
|
|
||||||
// No notifications - the key is the same
|
// Only a PhoneNumberDiscovery notification
|
||||||
const notifications = window.locator('.SystemMessage');
|
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/);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
29
ts/util/getStringForPhoneNumberDiscovery.ts
Normal file
29
ts/util/getStringForPhoneNumberDiscovery.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue