Introduce TitleTransition notification

This commit is contained in:
Fedor Indutny 2024-03-06 15:59:51 -08:00 committed by GitHub
parent 09b5e6ef50
commit 3469a748fb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 336 additions and 51 deletions

View file

@ -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"

View file

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.33335 2.31672C3.4832 2.31672 1.98335 3.81657 1.98335 5.66672C1.98335 6.48907 2.27882 7.24071 2.77013 7.82381C2.93242 8.01642 3.02713 8.27476 3.00398 8.55045L2.90267 9.75728L3.85216 9.09739C4.08958 8.93239 4.37443 8.88846 4.63132 8.94318C4.72496 8.96313 4.82014 8.97918 4.91669 8.99113L4.91668 9.00006C4.91668 9.45039 4.96845 9.88864 5.06637 10.3092C4.86977 10.298 4.67621 10.2746 4.48648 10.2397L3.092 11.2088C2.40877 11.6837 1.48132 11.1482 1.55093 10.3191L1.69806 8.56648C1.06332 7.7716 0.68335 6.76276 0.68335 5.66672C0.68335 3.0986 2.76523 1.01672 5.33335 1.01672C7.07529 1.01672 8.59352 1.97456 9.39008 3.39227C8.95168 3.49166 8.53229 3.64113 8.13826 3.83436C7.54002 2.92046 6.50719 2.31672 5.33335 2.31672Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.6667 4.35C13.2349 4.35 15.3167 6.43188 15.3167 9C15.3167 10.0917 14.9397 11.097 14.3095 11.8904L14.461 13.6955C14.5306 14.5246 13.6032 15.06 12.9199 14.5852L11.4737 13.5801C11.2112 13.6261 10.9415 13.65 10.6667 13.65C8.09862 13.65 6.01675 11.5681 6.01675 9C6.01675 6.43188 8.09862 4.35 10.6667 4.35ZM14.0167 9C14.0167 7.14985 12.5169 5.65 10.6667 5.65C8.81659 5.65 7.31675 7.14985 7.31675 9C7.31675 10.8502 8.81659 12.35 10.6667 12.35C10.8977 12.35 11.1225 12.3267 11.3392 12.2826C11.5936 12.2308 11.8747 12.2757 12.1094 12.4387L13.1093 13.1337L13.0036 11.8748C12.9805 11.6001 13.0745 11.3426 13.2357 11.1502C13.7236 10.5681 14.0167 9.81914 14.0167 9Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

@ -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'

View file

@ -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<ConversationAttributesType> = 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));
}

View file

@ -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;

View file

@ -193,6 +193,12 @@ export function Notification(): JSX.Element {
timestamp: Date.now(),
},
},
{
type: 'titleTransitionNotification',
data: {
oldTitle: 'alice.01',
},
},
{
type: 'callHistory',
data: {

View file

@ -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 = (
<TitleTransitionNotification
{...reducedProps}
{...item.data}
i18n={i18n}
/>
);
} else if (item.type === 'safetyNumberNotification') {
notification = (
<SafetyNumberNotification

View file

@ -0,0 +1,19 @@
// Copyright 2024 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 { Props } from './TitleTransitionNotification';
import { TitleTransitionNotification } from './TitleTransitionNotification';
export default {
title: 'Components/Conversation/TitleTransitionNotification',
} satisfies Meta<Props>;
const i18n = setupI18n('en', enMessages);
export function Default(): JSX.Element {
return <TitleTransitionNotification oldTitle="alice.01" i18n={i18n} />;
}

View file

@ -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 (
<SystemMessage
contents={
<Intl
id="icu:TitleTransition--notification"
components={{
oldTitle: <UserText text={oldTitle} />,
}}
i18n={i18n}
/>
}
icon="thread"
/>
);
}

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

@ -191,6 +191,7 @@ export type MessageAttributesType = {
| 'timer-notification'
| 'universal-timer-notification'
| 'contact-removed-notification'
| 'title-transition-notification'
| 'verified-change';
body?: string;
attachments?: Array<AttachmentType>;
@ -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;

View file

@ -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 {

View file

@ -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<MessageAttributesType> {
!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<MessageAttributesType> {
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<MessageAttributesType> {
isProfileChangeValue ||
isUniversalTimerNotificationValue ||
isConversationMergeValue ||
isPhoneNumberDiscoveryValue;
isPhoneNumberDiscoveryValue ||
isTitleTransitionNotificationValue;
return !hasSomethingToDisplay;
}

View file

@ -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(

View file

@ -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());
}

View file

@ -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}`
);
}
});
}

View file

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