diff --git a/_locales/en/messages.json b/_locales/en/messages.json index fd2ddd7c9f81..0cf0a7041988 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1541,6 +1541,10 @@ "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:TitleTransition--notification": { + "messageformat": "You started this chat with {oldTitle}", + "description": "Shown when we've received profile information for a contact we have been messaging by username/phone number" + }, "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/images/icons/v3/thread/thread-compact.svg b/images/icons/v3/thread/thread-compact.svg new file mode 100644 index 000000000000..d85b52afb1b3 --- /dev/null +++ b/images/icons/v3/thread/thread-compact.svg @@ -0,0 +1,4 @@ + + + + diff --git a/stylesheets/components/SystemMessage.scss b/stylesheets/components/SystemMessage.scss index c3bd2300e082..5c704f90690c 100644 --- a/stylesheets/components/SystemMessage.scss +++ b/stylesheets/components/SystemMessage.scss @@ -166,6 +166,12 @@ @include system-message-icon('../images/icons/v3/refresh/refresh.svg'); } + &--icon-thread::before { + @include system-message-icon( + '../images/icons/v3/thread/thread-compact.svg' + ); + } + &--icon-timer::before { @include system-message-icon( '../images/icons/v3/timer/timer-compact.svg' diff --git a/ts/ConversationController.ts b/ts/ConversationController.ts index 197ddcff8827..835375888e39 100644 --- a/ts/ConversationController.ts +++ b/ts/ConversationController.ts @@ -720,6 +720,7 @@ export class ConversationController { (targetOldServiceIds.pni !== pni || (aci && targetOldServiceIds.aci !== aci)) ) { + targetConversation.unset('needsTitleTransition'); mergePromises.push( targetConversation.addPhoneNumberDiscoveryIfNeeded( targetOldServiceIds.pni @@ -1056,6 +1057,8 @@ export class ConversationController { } current.set('active_at', activeAt); + const currentHadMessages = (current.get('messageCount') ?? 0) > 0; + const dataToCopy: Partial = pick( obsolete.attributes, [ @@ -1067,6 +1070,7 @@ export class ConversationController { 'draftTimestamp', 'messageCount', 'messageRequestResponseType', + 'needsTitleTransition', 'profileSharing', 'quotedMessageId', 'sentMessageCount', @@ -1196,7 +1200,15 @@ export class ConversationController { const titleIsUseful = Boolean( obsoleteTitleInfo && getTitleNoDefault(obsoleteTitleInfo) ); - if (obsoleteTitleInfo && titleIsUseful && obsoleteHadMessages) { + // If both conversations had messages - add merge + if ( + titleIsUseful && + conversationType === 'private' && + currentHadMessages && + obsoleteHadMessages + ) { + assertDev(obsoleteTitleInfo, 'part of titleIsUseful boolean'); + drop(current.addConversationMerge(obsoleteTitleInfo)); } diff --git a/ts/components/conversation/SystemMessage.tsx b/ts/components/conversation/SystemMessage.tsx index 2c977166e73a..038c607af02f 100644 --- a/ts/components/conversation/SystemMessage.tsx +++ b/ts/components/conversation/SystemMessage.tsx @@ -12,7 +12,39 @@ export enum SystemMessageKind { } export type PropsType = { - icon: string; + icon: + | 'audio-incoming' + | 'audio-missed' + | 'audio-outgoing' + | 'group' + | 'group-access' + | 'group-add' + | 'group-approved' + | 'group-avatar' + | 'group-decline' + | 'group-edit' + | 'group-leave' + | 'group-remove' + | 'group-summary' + | 'info' + | 'phone' + | 'profile' + | 'safety-number' + | 'session-refresh' + | 'thread' + | 'timer' + | 'timer-disabled' + | 'unsupported' + | 'unsupported--can-process' + | 'verified' + | 'verified-not' + | 'video' + | 'video-incoming' + | 'video-missed' + | 'video-outgoing' + | 'warning' + | 'payment-event' + | 'merge'; contents: ReactNode; button?: ReactNode; kind?: SystemMessageKind; diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 9df960c28add..1ff5dc5491c5 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -193,6 +193,12 @@ export function Notification(): JSX.Element { timestamp: Date.now(), }, }, + { + type: 'titleTransitionNotification', + data: { + oldTitle: 'alice.01', + }, + }, { type: 'callHistory', data: { diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index 689443504937..af692fb2d0f1 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -20,6 +20,8 @@ import type { PropsDataType as DeliveryIssueProps } from './DeliveryIssueNotific import { DeliveryIssueNotification } from './DeliveryIssueNotification'; import type { PropsData as ChangeNumberNotificationProps } from './ChangeNumberNotification'; import { ChangeNumberNotification } from './ChangeNumberNotification'; +import type { PropsData as TitleTransitionNotificationProps } from './TitleTransitionNotification'; +import { TitleTransitionNotification } from './TitleTransitionNotification'; import type { CallingNotificationType } from '../../util/callingNotification'; import { InlineNotificationWrapper } from './InlineNotificationWrapper'; import type { PropsData as UnsupportedMessageProps } from './UnsupportedMessage'; @@ -91,6 +93,10 @@ type ChangeNumberNotificationType = { type: 'changeNumberNotification'; data: ChangeNumberNotificationProps; }; +type TitleTransitionNotificationType = { + type: 'titleTransitionNotification'; + data: TitleTransitionNotificationProps; +}; type SafetyNumberNotificationType = { type: 'safetyNumberNotification'; data: SafetyNumberNotificationProps; @@ -148,6 +154,7 @@ export type TimelineItemType = ( | SafetyNumberNotificationType | TimerNotificationType | UniversalTimerNotificationType + | TitleTransitionNotificationType | ContactRemovedNotificationType | UnsupportedMessageType | VerificationNotificationType @@ -296,6 +303,14 @@ export const TimelineItem = memo(function TimelineItem({ i18n={i18n} /> ); + } else if (item.type === 'titleTransitionNotification') { + notification = ( + + ); } else if (item.type === 'safetyNumberNotification') { notification = ( ; + +const i18n = setupI18n('en', enMessages); + +export function Default(): JSX.Element { + return ; +} diff --git a/ts/components/conversation/TitleTransitionNotification.tsx b/ts/components/conversation/TitleTransitionNotification.tsx new file mode 100644 index 000000000000..983fadf8d034 --- /dev/null +++ b/ts/components/conversation/TitleTransitionNotification.tsx @@ -0,0 +1,39 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import type { LocalizerType } from '../../types/Util'; +import { Intl } from '../Intl'; + +import { SystemMessage } from './SystemMessage'; +import { UserText } from '../UserText'; + +export type PropsData = { + oldTitle: string; +}; + +export type PropsHousekeeping = { + i18n: LocalizerType; +}; + +export type Props = PropsData & PropsHousekeeping; + +export function TitleTransitionNotification(props: Props): JSX.Element { + const { i18n, oldTitle } = props; + + return ( + , + }} + i18n={i18n} + /> + } + icon="thread" + /> + ); +} diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 905d6590cba1..daa735883c2d 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -191,6 +191,7 @@ export type MessageAttributesType = { | 'timer-notification' | 'universal-timer-notification' | 'contact-removed-notification' + | 'title-transition-notification' | 'verified-change'; body?: string; attachments?: Array; @@ -225,6 +226,9 @@ export type MessageAttributesType = { conversationMerge?: { renderInfo: ConversationRenderInfoType; }; + titleTransition?: { + renderInfo: ConversationRenderInfoType; + }; // Legacy fields for timer update notification only flags?: number; @@ -338,6 +342,7 @@ export type ConversationAttributesType = { profileKeyCredential?: string | null; profileKeyCredentialExpiration?: number | null; lastProfile?: ConversationLastProfileType; + needsTitleTransition?: boolean; quotedMessageId?: string | null; sealedSender?: unknown; sentMessageCount?: number; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 9503d93eefac..63cfd28586a8 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -95,6 +95,8 @@ import { getProfileName, getTitle, getTitleNoDefault, + hasNumberTitle, + hasUsernameTitle, canHaveUsername, } from '../util/getTitle'; import { markConversationRead } from '../util/markConversationRead'; @@ -3717,10 +3719,13 @@ export class ConversationModel extends window.Backbone }; const isEditMessage = Boolean(message.get('editHistory')); + const needsTitleTransition = + hasNumberTitle(this.attributes) || hasUsernameTitle(this.attributes); this.set({ ...draftProperties, ...(enabledProfileSharing ? { profileSharing: true } : {}), + ...(needsTitleTransition ? { needsTitleTransition: true } : {}), ...(dontAddMessage ? {} : this.incrementSentMessageCount({ dry: true })), @@ -4013,17 +4018,38 @@ export class ConversationModel extends window.Backbone const ourConversationId = window.ConversationController.getOurConversationId(); + const oldUsername = this.get('username'); + // Clear username once we have other information about the contact - if ( - canHaveUsername(this.attributes, ourConversationId) || - !this.get('username') - ) { + if (canHaveUsername(this.attributes, ourConversationId) || !oldUsername) { return; } log.info(`maybeClearUsername(${this.idForLogging()}): clearing username`); this.unset('username'); + + if (this.get('needsTitleTransition') && getProfileName(this.attributes)) { + log.info( + `maybeClearUsername(${this.idForLogging()}): adding a notification` + ); + const { type, e164, username } = this.attributes; + + this.unset('needsTitleTransition'); + + await this.addNotification('title-transition-notification', { + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Unseen, + titleTransition: { + renderInfo: { + type, + e164, + username, + }, + }, + }); + } + window.Signal.Data.updateConversation(this.attributes); this.captureChange('clearUsername'); } @@ -4684,37 +4710,63 @@ export class ConversationModel extends window.Backbone const oldProfileKey = this.get('profileKey'); // profileKey is a string so we can compare it directly - if (oldProfileKey !== profileKey) { - log.info( - `Setting sealedSender to UNKNOWN for conversation ${this.idForLogging()}` - ); - this.set({ - profileKeyCredential: null, - profileKeyCredentialExpiration: null, - accessKey: null, - sealedSender: SEALED_SENDER.UNKNOWN, - }); - - // Don't trigger immediate profile fetches when syncing to remote storage - this.set({ profileKey }, { silent: viaStorageServiceSync }); - - // If our profile key was cleared above, we don't tell our linked devices about it. - // We want linked devices to tell us what it should be, instead of telling them to - // erase their local value. - if (!viaStorageServiceSync && profileKey) { - this.captureChange('profileKey'); - } - - this.deriveAccessKeyIfNeeded(); - - // We will update the conversation during storage service sync - if (!viaStorageServiceSync) { - window.Signal.Data.updateConversation(this.attributes); - } - - return true; + if (oldProfileKey === profileKey) { + return false; } - return false; + + log.info( + `Setting sealedSender to UNKNOWN for conversation ${this.idForLogging()}` + ); + this.set({ + profileKeyCredential: null, + profileKeyCredentialExpiration: null, + accessKey: null, + sealedSender: SEALED_SENDER.UNKNOWN, + }); + + // We messaged the contact when it had either phone number or username + // title. + if (this.get('needsTitleTransition')) { + log.info( + `setProfileKey(${this.idForLogging()}): adding a ` + + 'title transition notification' + ); + + const { type, e164, username } = this.attributes; + + this.unset('needsTitleTransition'); + + await this.addNotification('title-transition-notification', { + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.Unseen, + titleTransition: { + renderInfo: { + type, + e164, + username, + }, + }, + }); + } + + // Don't trigger immediate profile fetches when syncing to remote storage + this.set({ profileKey }, { silent: viaStorageServiceSync }); + + // If our profile key was cleared above, we don't tell our linked devices about it. + // We want linked devices to tell us what it should be, instead of telling them to + // erase their local value. + if (!viaStorageServiceSync && profileKey) { + this.captureChange('profileKey'); + } + + this.deriveAccessKeyIfNeeded(); + + // We will update the conversation during storage service sync + if (!viaStorageServiceSync) { + window.Signal.Data.updateConversation(this.attributes); + } + + return true; } hasProfileKeyCredentialExpired(): boolean { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index f37b54f17d9e..94571e95eb75 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -95,6 +95,7 @@ import { isVerifiedChange, isConversationMerge, isPhoneNumberDiscovery, + isTitleTransitionNotification, } from '../state/selectors/message'; import type { ReactionAttributesType } from '../messageModifiers/Reactions'; import { isInCall } from '../state/selectors/calling'; @@ -287,6 +288,7 @@ export class MessageModel extends window.Backbone.Model { !isGroupV2Change(attributes) && !isKeyChange(attributes) && !isPhoneNumberDiscovery(attributes) && + !isTitleTransitionNotification(attributes) && !isProfileChange(attributes) && !isUniversalTimerNotification(attributes) && !isUnsupportedMessage(attributes) && @@ -616,6 +618,8 @@ export class MessageModel extends window.Backbone.Model { isUniversalTimerNotification(attributes); const isConversationMergeValue = isConversationMerge(attributes); const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes); + const isTitleTransitionNotificationValue = + isTitleTransitionNotification(attributes); const isPayment = messageHasPaymentEvent(attributes); @@ -648,7 +652,8 @@ export class MessageModel extends window.Backbone.Model { isProfileChangeValue || isUniversalTimerNotificationValue || isConversationMergeValue || - isPhoneNumberDiscoveryValue; + isPhoneNumberDiscoveryValue || + isTitleTransitionNotificationValue; return !hasSomethingToDisplay; } diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index 39cff5ae4f2c..d01326d3a862 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -29,6 +29,7 @@ import type { PropsData as TimerNotificationProps } from '../../components/conve import type { PropsData as ChangeNumberNotificationProps } from '../../components/conversation/ChangeNumberNotification'; import type { PropsData as SafetyNumberNotificationProps } from '../../components/conversation/SafetyNumberNotification'; import type { PropsData as VerificationNotificationProps } from '../../components/conversation/VerificationNotification'; +import type { PropsData as TitleTransitionNotificationProps } from '../../components/conversation/TitleTransitionNotification'; import type { PropsDataType as GroupsV2Props } from '../../components/conversation/GroupV2Change'; import type { PropsDataType as GroupV1MigrationPropsType } from '../../components/conversation/GroupV1Migration'; import type { PropsDataType as DeliveryIssuePropsType } from '../../components/conversation/DeliveryIssueNotification'; @@ -129,6 +130,7 @@ import type { AnyPaymentEvent } from '../../types/Payment'; import { isPaymentNotificationEvent } from '../../types/Payment'; import { getTitleNoDefault, + getTitle, getNumber, renderNumber, } from '../../util/getTitle'; @@ -922,6 +924,13 @@ export function getPropsForBubble( timestamp, }; } + if (isTitleTransitionNotification(message)) { + return { + type: 'titleTransitionNotification', + data: getPropsForTitleTransitionNotification(message), + timestamp, + }; + } if (isChatSessionRefreshed(message)) { return { type: 'chatSessionRefreshed', @@ -1490,6 +1499,31 @@ function getPropsForChangeNumberNotification( }; } +// Title Transition Notification + +export function isTitleTransitionNotification( + message: MessageWithUIFieldsType +): boolean { + return ( + message.type === 'title-transition-notification' && + message.titleTransition != null + ); +} + +function getPropsForTitleTransitionNotification( + message: MessageWithUIFieldsType +): TitleTransitionNotificationProps { + strictAssert( + message.titleTransition != null, + 'Invalid attributes for title-transition-notification' + ); + const { renderInfo } = message.titleTransition; + const oldTitle = getTitle(renderInfo); + return { + oldTitle, + }; +} + // Chat Session Refreshed export function isChatSessionRefreshed( diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts index 0bcd084172ed..b746f802e525 100644 --- a/ts/test-mock/pnp/pni_signature_test.ts +++ b/ts/test-mock/pnp/pni_signature_test.ts @@ -58,18 +58,12 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) { whitelisted: true, serviceE164: pniContact.device.number, identityKey: pniContact.getPublicKey(ServiceIdKind.PNI).serialize(), - givenName: 'PNI Contact', + givenName: undefined, + familyName: undefined, }, ServiceIdKind.PNI ); - state = state.addContact(pniContact, { - whitelisted: true, - serviceE164: undefined, - identityKey: pniContact.publicKey.serialize(), - profileKey: pniContact.profileKey.serialize(), - }); - // Just to make PNI Contact visible in the left pane state = state.pin(pniContact, ServiceIdKind.PNI); @@ -319,6 +313,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) { await pniContact.sendText(desktop, 'Hello Desktop!', { timestamp: bootstrap.getTimestamp(), withPniSignature: true, + withProfileKey: true, }); debug('Wait for merge to happen'); @@ -377,15 +372,12 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) { const messages = window.locator('.module-message__text'); assert.strictEqual(await messages.count(), 3, 'messages'); - // Merge notification + // Title transition notification const notifications = window.locator('.SystemMessage'); assert.strictEqual(await notifications.count(), 1, 'notifications'); const first = await notifications.first(); - assert.match( - await first.innerText(), - /Your message history with ACI Contact and their number .* has been merged./ - ); + assert.match(await first.innerText(), /You started this chat with/); assert.isEmpty(await phone.getOrphanedStorageKeys()); } diff --git a/ts/test-mock/pnp/username_test.ts b/ts/test-mock/pnp/username_test.ts index 3d20a83f7c5b..0e4762666f8b 100644 --- a/ts/test-mock/pnp/username_test.ts +++ b/ts/test-mock/pnp/username_test.ts @@ -93,7 +93,15 @@ describe('pnp/username', function (this: Mocha.Suite) { .locator( `[data-testid="${usernameContact.device.aci}"] >> "${USERNAME}"` ) - .waitFor(); + .click(); + + debug('Send message to username'); + { + const compositionInput = await app.waitForEnabledComposer(); + + await compositionInput.type('Hello username'); + await compositionInput.press('Enter'); + } let state = await phone.expectStorageState('consistency check'); @@ -141,6 +149,31 @@ describe('pnp/username', function (this: Mocha.Suite) { assert.strictEqual(removed[0].contact?.aci, usernameContact.device.aci); assert.strictEqual(removed[0].contact?.username, USERNAME); } + + if (type === 'system') { + // No notifications + const notifications = window.locator('.SystemMessage'); + assert.strictEqual( + await notifications.count(), + 0, + 'notification count' + ); + } else { + // One notification - the username transition + const notifications = window.locator('.SystemMessage'); + await notifications.waitFor(); + assert.strictEqual( + await notifications.count(), + 1, + 'notification count' + ); + + const first = await notifications.first(); + assert.strictEqual( + await first.innerText(), + `You started this chat with ${USERNAME}` + ); + } }); } diff --git a/ts/util/getTitle.ts b/ts/util/getTitle.ts index aaa5a33a479d..ed232d9c3eac 100644 --- a/ts/util/getTitle.ts +++ b/ts/util/getTitle.ts @@ -143,3 +143,30 @@ export function renderNumber(e164: string): string | undefined { return undefined; } } + +export function hasNumberTitle( + attributes: Pick< + ConversationAttributesType, + 'e164' | 'type' | 'sharingPhoneNumber' | 'profileKey' + > +): boolean { + return ( + !getSystemName(attributes) && + !getProfileName(attributes) && + Boolean(getNumber(attributes)) + ); +} + +export function hasUsernameTitle( + attributes: Pick< + ConversationAttributesType, + 'e164' | 'type' | 'sharingPhoneNumber' | 'profileKey' | 'username' + > +): boolean { + return ( + !getSystemName(attributes) && + !getProfileName(attributes) && + !getNumber(attributes) && + Boolean(attributes.username) + ); +}