diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 16a0279a1..a0adeb94b 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 85b95a352..0d40772c3 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -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 = []; 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 }; } diff --git a/ts/SignalProtocolStore.ts b/ts/SignalProtocolStore.ts index 4aee1dd42..cd12b6515 100644 --- a/ts/SignalProtocolStore.ts +++ b/ts/SignalProtocolStore.ts @@ -1467,6 +1467,18 @@ export class SignalProtocolStore extends EventEmitter { }); } + async hasSessionWith(serviceId: ServiceIdString): Promise { + 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, diff --git a/ts/components/conversation/PhoneNumberDiscoveryNotification.stories.tsx b/ts/components/conversation/PhoneNumberDiscoveryNotification.stories.tsx new file mode 100644 index 000000000..f60145762 --- /dev/null +++ b/ts/components/conversation/PhoneNumberDiscoveryNotification.stories.tsx @@ -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; + +const createProps = (overrideProps: Partial = {}): PropsType => ({ + i18n, + conversationTitle: overrideProps.conversationTitle || 'John Fire', + phoneNumber: '(555) 333-1111', +}); + +export function WithoutSharedGroup(): JSX.Element { + return ; +} + +export function WithSharedGroup(): JSX.Element { + return ( + + ); +} diff --git a/ts/components/conversation/PhoneNumberDiscoveryNotification.tsx b/ts/components/conversation/PhoneNumberDiscoveryNotification.tsx new file mode 100644 index 000000000..9bc247621 --- /dev/null +++ b/ts/components/conversation/PhoneNumberDiscoveryNotification.tsx @@ -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 } />; +} diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index db8682dbf..6918bb668 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -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; @@ -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 = ( + + ); } else if (item.type === 'resetSessionNotification') { notification = ; } else if (item.type === 'profileChange') { diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 258552abc..3dda1acfc 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -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; }; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 8cabc1715..f76471f0a 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3032,6 +3032,59 @@ export class ConversationModel extends window.Backbone this.trigger('newmessage', model); } + async addPhoneNumberDiscoveryIfNeeded(originalPni: PniString): Promise { + 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, diff --git a/ts/models/messages.ts b/ts/models/messages.ts index 6789e2574..893137f57 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -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 { !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 { 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 { isKeyChangeValue || isProfileChangeValue || isUniversalTimerNotificationValue || - isConversationMergeValue; + isConversationMergeValue || + isPhoneNumberDiscoveryValue; return !hasSomethingToDisplay; } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 569378821..e43a44350 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -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 { diff --git a/ts/test-mock/pnp/phone_discovery_test.ts b/ts/test-mock/pnp/phone_discovery_test.ts new file mode 100644 index 000000000..bfe72514a --- /dev/null +++ b/ts/test-mock/pnp/phone_discovery_test.ts @@ -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/); + } + }); +}); diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts index d67184113..266038c0f 100644 --- a/ts/test-mock/pnp/pni_change_test.ts +++ b/ts/test-mock/pnp/pni_change_test.ts @@ -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/); } }); }); diff --git a/ts/util/getStringForPhoneNumberDiscovery.ts b/ts/util/getStringForPhoneNumberDiscovery.ts new file mode 100644 index 000000000..4681774a4 --- /dev/null +++ b/ts/util/getStringForPhoneNumberDiscovery.ts @@ -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, + }); +}