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

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