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)
+ );
+}