From 4b79efa033f85c5bbc1878640535fe5a8f0eba4d Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 28 Apr 2026 23:54:53 +1000 Subject: [PATCH] RelinkDialog: Show something different when primary device --- _locales/en/messages.json | 6 +++ ts/SignalProtocolStore.preload.ts | 4 +- ts/background.preload.ts | 22 ++++---- ts/components/DialogRelink.dom.stories.tsx | 23 +++++++- ts/components/DialogRelink.dom.tsx | 18 +++++++ ts/components/LeftPane.dom.stories.tsx | 2 + ts/sql/Interface.std.ts | 2 +- ts/sql/Server.node.ts | 15 ++++-- ts/state/ducks/network.dom.ts | 21 +++++--- ts/state/smart/RelinkDialog.dom.tsx | 12 +++-- .../removeAllConfiguration_test.preload.ts | 42 ++++++++++++++- ts/textsecure/AccountManager.preload.ts | 8 ++- ts/types/StorageKeys.std.ts | 53 +++++++++++++------ ts/window.d.ts | 3 +- 14 files changed, 183 insertions(+), 48 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 1429980399..d5eb140887 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -3390,6 +3390,12 @@ "icu:unlinked": { "messageformat": "Unlinked" }, + "icu:unregisteredWarning": { + "messageformat": "Click to re-register and continue messaging. Signal Desktop is unregistered likely because you registered your phone number with Signal on a different device." + }, + "icu:unregistered": { + "messageformat": "No longer registered" + }, "icu:autoUpdateNewVersionTitle": { "messageformat": "Update Available" }, diff --git a/ts/SignalProtocolStore.preload.ts b/ts/SignalProtocolStore.preload.ts index dc9124ba10..3259e30cfa 100644 --- a/ts/SignalProtocolStore.preload.ts +++ b/ts/SignalProtocolStore.preload.ts @@ -2692,7 +2692,7 @@ export class SignalProtocolStore extends EventEmitter { this.emit('removeAllData'); } - async removeAllConfiguration(): Promise { + async removeAllConfiguration(isPrimary: boolean): Promise { // Conversations. These properties are not present in redux. window.ConversationController.getAll().forEach(conversation => { conversation.set({ @@ -2703,7 +2703,7 @@ export class SignalProtocolStore extends EventEmitter { }); }); - await DataWriter.removeAllConfiguration(); + await DataWriter.removeAllConfiguration(isPrimary); await this.hydrateCaches(); diff --git a/ts/background.preload.ts b/ts/background.preload.ts index eb2ac171e7..23baf64d97 100644 --- a/ts/background.preload.ts +++ b/ts/background.preload.ts @@ -3446,14 +3446,18 @@ async function startApp(): Promise { try { log.info('unlinkAndDisconnect: removing configuration'); - // We use username for integrity check - const ourConversation = - window.ConversationController.getOurConversation(); - if (ourConversation) { - await ourConversation.updateUsername(undefined, { - shouldSave: true, - fromStorageService: false, - }); + const weArePrimary = window.ConversationController.areWePrimaryDevice(); + if (!weArePrimary) { + log.info('unlinkAndDisconnect: removing username'); + // We use username for integrity check + const ourConversation = + window.ConversationController.getOurConversation(); + if (ourConversation) { + await ourConversation.updateUsername(undefined, { + shouldSave: true, + fromStorageService: false, + }); + } } // Then make sure outstanding conversation saves are flushed @@ -3463,7 +3467,7 @@ async function startApp(): Promise { await DataReader.getItemById('manifestVersion'); // Finally, conversations in the database, and delete all config tables - await signalProtocolStore.removeAllConfiguration(); + await signalProtocolStore.removeAllConfiguration(weArePrimary); // Re-hydrate items from memory; removeAllConfiguration above changed database await itemStorage.fetch(); diff --git a/ts/components/DialogRelink.dom.stories.tsx b/ts/components/DialogRelink.dom.stories.tsx index 1bd5e698be..6eb4fe4a22 100644 --- a/ts/components/DialogRelink.dom.stories.tsx +++ b/ts/components/DialogRelink.dom.stories.tsx @@ -15,6 +15,8 @@ const defaultProps = { containerWidthBreakpoint: WidthBreakpoint.Wide, i18n, relinkDevice: action('relink-device'), + reregister: action('reregister'), + weArePrimaryDevice: false, }; const permutations = [ @@ -39,8 +41,9 @@ export default { export function Iterations(): React.JSX.Element { return ( <> - {permutations.map(({ props, title }) => ( + {permutations.map(({ props, title }, index) => ( <> + {index > 0 &&
}

{title}

); } + +export function IterationsStandalone(): React.JSX.Element { + return ( + <> + {permutations.map(({ props, title }, index) => ( + <> + {index > 0 &&
} +

{title}

+ + + + + ))} + + ); +} diff --git a/ts/components/DialogRelink.dom.tsx b/ts/components/DialogRelink.dom.tsx index edba6d8e70..904fcd88c7 100644 --- a/ts/components/DialogRelink.dom.tsx +++ b/ts/components/DialogRelink.dom.tsx @@ -12,13 +12,31 @@ export type PropsType = { containerWidthBreakpoint: WidthBreakpoint; i18n: LocalizerType; relinkDevice: () => void; + reregister: () => void; + weArePrimaryDevice: boolean; }; export function DialogRelink({ containerWidthBreakpoint, i18n, relinkDevice, + reregister, + weArePrimaryDevice, }: PropsType): React.JSX.Element | null { + if (weArePrimaryDevice) { + return ( + + ); + } + return ( { ), diff --git a/ts/sql/Interface.std.ts b/ts/sql/Interface.std.ts index 4dacd62a9d..2f1162a2f0 100644 --- a/ts/sql/Interface.std.ts +++ b/ts/sql/Interface.std.ts @@ -1486,7 +1486,7 @@ type WritableInterface = { resetProtectedAttachmentPaths: () => void; removeAll: () => void; - removeAllConfiguration: () => void; + removeAllConfiguration: (isPrimary: boolean) => void; eraseStorageServiceState: () => void; insertJob(job: Readonly): void; diff --git a/ts/sql/Server.node.ts b/ts/sql/Server.node.ts index b8a7afc464..348fac90bc 100644 --- a/ts/sql/Server.node.ts +++ b/ts/sql/Server.node.ts @@ -30,7 +30,10 @@ import type { ReactionType } from '../types/Reactions.std.ts'; import { ReactionReadStatus } from '../types/Reactions.std.ts'; import type { AciString, ServiceIdString } from '../types/ServiceId.std.ts'; import { isServiceIdString } from '../types/ServiceId.std.ts'; -import { STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK } from '../types/StorageKeys.std.ts'; +import { + STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY, + STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK, +} from '../types/StorageKeys.std.ts'; import type { StoryDistributionIdString } from '../types/StoryDistributionId.std.ts'; import * as Errors from '../types/errors.std.ts'; import { assertDev, strictAssert } from '../util/assert.std.ts'; @@ -8546,7 +8549,7 @@ function removeAll(db: WritableDB): void { } // Anything that isn't user-visible data -function removeAllConfiguration(db: WritableDB): void { +function removeAllConfiguration(db: WritableDB, isPrimary: boolean): void { db.transaction(() => { db.exec( ` @@ -8576,7 +8579,13 @@ function removeAllConfiguration(db: WritableDB): void { }) .all(); - const allowedSet = new Set(STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK); + let allowedSet = new Set(STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK); + + if (isPrimary) { + allowedSet = allowedSet.union( + new Set(STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY) + ); + } for (const id of itemIds) { if (!allowedSet.has(id)) { removeById(db, 'items', id); diff --git a/ts/state/ducks/network.dom.ts b/ts/state/ducks/network.dom.ts index bde0745603..890ae58d7c 100644 --- a/ts/state/ducks/network.dom.ts +++ b/ts/state/ducks/network.dom.ts @@ -1,12 +1,15 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { ReadonlyDeep } from 'type-fest'; import { SocketStatus } from '../../types/SocketStatus.std.ts'; import { trigger } from '../../shims/events.dom.ts'; import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnecessaryAllocation.std.ts'; -import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.ts'; import { useBoundActions } from '../../hooks/useBoundActions.std.ts'; +import { noopAction } from './noop.std.ts'; + +import type { ReadonlyDeep } from 'type-fest'; +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.std.ts'; +import type { NoopActionType } from './noop.std.ts'; // State @@ -20,7 +23,6 @@ export type NetworkStateType = ReadonlyDeep<{ // Actions const SET_NETWORK_STATUS = 'network/SET_NETWORK_STATUS'; -const RELINK_DEVICE = 'network/RELINK_DEVICE'; const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS'; const SET_OUTAGE = 'network/SET_OUTAGE'; @@ -70,12 +72,16 @@ function setNetworkStatus( }; } -function relinkDevice(): RelinkDeviceActionType { +function relinkDevice(): NoopActionType { trigger('setupAsNewDevice'); - return { - type: RELINK_DEVICE, - }; + return noopAction('relinkDevice'); +} + +function reregister(): NoopActionType { + trigger('setupAsStandalone'); + + return noopAction('reregister'); } function setChallengeStatus( @@ -100,6 +106,7 @@ function setOutage(isOutage: boolean): SetOutageActionType { export const actions = { setNetworkStatus, relinkDevice, + reregister, setChallengeStatus, setOutage, }; diff --git a/ts/state/smart/RelinkDialog.dom.tsx b/ts/state/smart/RelinkDialog.dom.tsx index 1181565aaa..991b0a1803 100644 --- a/ts/state/smart/RelinkDialog.dom.tsx +++ b/ts/state/smart/RelinkDialog.dom.tsx @@ -1,12 +1,14 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { memo } from 'react'; + import { useSelector } from 'react-redux'; import { DialogRelink } from '../../components/DialogRelink.dom.tsx'; -import { getIntl } from '../selectors/user.std.ts'; -import type { WidthBreakpoint } from '../../components/_util.std.ts'; +import { areWePrimaryDevice, getIntl } from '../selectors/user.std.ts'; import { useNetworkActions } from '../ducks/network.dom.ts'; +import type { WidthBreakpoint } from '../../components/_util.std.ts'; + type SmartRelinkDialogProps = Readonly<{ containerWidthBreakpoint: WidthBreakpoint; }>; @@ -15,12 +17,16 @@ export const SmartRelinkDialog = memo(function SmartRelinkDialog({ containerWidthBreakpoint, }: SmartRelinkDialogProps) { const i18n = useSelector(getIntl); - const { relinkDevice } = useNetworkActions(); + const { relinkDevice, reregister } = useNetworkActions(); + const weArePrimaryDevice = useSelector(areWePrimaryDevice); + return ( ); }); diff --git a/ts/test-electron/sql/removeAllConfiguration_test.preload.ts b/ts/test-electron/sql/removeAllConfiguration_test.preload.ts index ff14e41f24..c8959e7714 100644 --- a/ts/test-electron/sql/removeAllConfiguration_test.preload.ts +++ b/ts/test-electron/sql/removeAllConfiguration_test.preload.ts @@ -29,7 +29,8 @@ describe('Remove all configuration test', () => { } ); - await DataWriter.removeAllConfiguration(); + const isPrimary = false; + await DataWriter.removeAllConfiguration(isPrimary); const convoAfter = await DataReader.getConversationById(attributes.id); assert.strictEqual(convoAfter?.expireTimerVersion, 1); @@ -59,6 +60,10 @@ describe('Remove all configuration test', () => { }); /** Should be deleted */ + await DataWriter.createOrUpdateItem({ + id: 'blocked', + value: '["a", "b", "c"]', + }); await DataWriter.createOrUpdateItem({ id: 'storageFetchComplete', value: true, @@ -69,7 +74,8 @@ describe('Remove all configuration test', () => { value: 1.5, }); - await DataWriter.removeAllConfiguration(); + const isPrimary = false; + await DataWriter.removeAllConfiguration(isPrimary); const allItems = await DataReader.getAllItems(); assert.deepStrictEqual(allItems, { @@ -78,4 +84,36 @@ describe('Remove all configuration test', () => { zoomFactor: 1.5, }); }); + + it('When isPrimary=true, preserves additional configuration', async () => { + /** Should be preserved */ + await DataWriter.createOrUpdateItem({ + id: 'zoomFactor', + value: 1.5, + }); + await DataWriter.createOrUpdateItem({ + id: 'read-receipt-setting', + value: true, + }); + + /** Should be deleted */ + await DataWriter.createOrUpdateItem({ + id: 'storageFetchComplete', + value: true, + }); + await DataWriter.createOrUpdateItem({ + // @ts-expect-error incorrect key + id: 'unknown-key', + value: 1.5, + }); + + const isPrimary = true; + await DataWriter.removeAllConfiguration(isPrimary); + + const allItems = await DataReader.getAllItems(); + assert.deepStrictEqual(allItems, { + 'read-receipt-setting': true, + zoomFactor: 1.5, + }); + }); }); diff --git a/ts/textsecure/AccountManager.preload.ts b/ts/textsecure/AccountManager.preload.ts index 45e7a657f3..291fb0631e 100644 --- a/ts/textsecure/AccountManager.preload.ts +++ b/ts/textsecure/AccountManager.preload.ts @@ -1015,6 +1015,7 @@ export default class AccountManager extends EventTarget { const previousNumber = itemStorage.user.getNumber(); const previousACI = itemStorage.user.getAci(); const previousPNI = itemStorage.user.getPni(); + const previousDeviceId = itemStorage.user.getDeviceId(); log.info( `createAccount: Number is ${number}, password has length: ${ @@ -1077,8 +1078,11 @@ export default class AccountManager extends EventTarget { ); } } else { - log.info('createAccount: Erasing configuration'); - await signalProtocolStore.removeAllConfiguration(); + const weArePrimary = isNumber(previousDeviceId) && previousDeviceId === 1; + log.info( + `createAccount: Erasing configuration (isPrimary=${weArePrimary})` + ); + await signalProtocolStore.removeAllConfiguration(weArePrimary); } await senderCertificateService.clear(); diff --git a/ts/types/StorageKeys.std.ts b/ts/types/StorageKeys.std.ts index 3d10938a73..55823c9af1 100644 --- a/ts/types/StorageKeys.std.ts +++ b/ts/types/StorageKeys.std.ts @@ -411,16 +411,31 @@ export const STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK = [ 'restoredBackupFirstAppVersion', ] as const satisfies ReadonlyArray; -const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [ +export const STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY = [ 'auto-download-attachment', 'blocked-groups', 'blocked-uuids', - 'lastCallQualitySurveyTime', - 'lastCallQualityFailureSurveyTime', - 'cqsTestMode', 'read-receipt-setting', 'blocked', 'device_name', + 'seenPinMessageDisappearingMessagesWarningCount', + 'usernameLastIntegrityCheck', + 'usernameCorrupted', + 'usernameLinkCorrupted', + 'usernameLink', + 'needProfileMovedModal', + 'notificationProfileOverride', + 'notificationProfileOverrideFromPrimary', + 'notificationProfileSyncDisabled', + 'sendEditWarningShown', + 'formattingWarningShown', + 'localDeleteWarningShown', +] as const satisfies ReadonlyArray; + +const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [ + 'lastCallQualitySurveyTime', + 'lastCallQualityFailureSurveyTime', + 'cqsTestMode', 'deviceCreatedAt', 'hasSeenNotificationProfileOnboarding', 'hasSeenKeyTransparencyOnboarding', @@ -439,7 +454,6 @@ const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [ 'registrationIdMap', 'remoteBuildExpiration', 'sessionResets', - 'seenPinMessageDisappearingMessagesWarningCount', 'signedKeyId', 'signedKeyIdPNI', 'signedKeyUpdateTime', @@ -490,16 +504,8 @@ const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [ 'backupsSubscriberId', 'backupsSubscriberPurchaseToken', 'backupsSubscriberOriginalTransactionId', - 'usernameLastIntegrityCheck', - 'usernameCorrupted', - 'usernameLinkCorrupted', - 'usernameLink', 'serverAlerts', 'needOrphanedAttachmentCheck', - 'needProfileMovedModal', - 'notificationProfileOverride', - 'notificationProfileOverrideFromPrimary', - 'notificationProfileSyncDisabled', 'observedCapabilities', 'releaseNotesNextFetchTime', 'releaseNotesVersionWatermark', @@ -524,15 +530,12 @@ const STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK = [ 'signedKeyRotationRejected', 'lastHeartbeat', 'lastStartup', - 'sendEditWarningShown', - 'formattingWarningShown', 'hasRegisterSupportForUnauthenticatedDelivery', 'masterKeyLastRequestTime', 'versionedExpirationTimer', 'primarySendsSms', 'backupMediaDownloadIdle', 'callQualitySurveyCooldownDisabled', - 'localDeleteWarningShown', 'dredDuration', 'directMaxBitrate', 'isDirectVp9Enabled', @@ -549,6 +552,8 @@ type AssertTrue = T; type StorageKeysToPreserveAfterUnlink = (typeof STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK)[number]; +type StorageKeysToKeepWhenPrimary = + (typeof STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY)[number]; type StorageKeysToRemoveAfterUnlink = (typeof STORAGE_KEYS_TO_REMOVE_AFTER_UNLINK)[number]; @@ -558,10 +563,24 @@ export type AssertStorageUnlinkKeysDoNotOverlap = AssertTrue< never > >; +export type AssertStoragePrimaryKeysDoNotOverlap = AssertTrue< + AssertSameMembers< + Extract, + never + > +>; +export type AssertPrimaryAndUnlinkDoNotOverlap = AssertTrue< + AssertSameMembers< + Extract, + never + > +>; export type AssertStorageUnlinkKeysAreExhaustive = AssertTrue< AssertSameMembers< - StorageKeysToPreserveAfterUnlink | StorageKeysToRemoveAfterUnlink, + | StorageKeysToPreserveAfterUnlink + | StorageKeysToKeepWhenPrimary + | StorageKeysToRemoveAfterUnlink, keyof StorageAccessType > >; diff --git a/ts/window.d.ts b/ts/window.d.ts index 07295666da..074bcc0b4e 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -9,6 +9,7 @@ import type { SystemPreferences } from 'electron'; import type { assert } from 'chai'; import type { MochaOptions } from 'mocha'; +import type { WhisperEventMap } from './shims/events.dom.ts'; import type { IPCRequest as IPCChallengeRequest } from './challenge.dom.ts'; import type { OSType } from './util/os/shared.std.ts'; import type { SystemThemeType, ThemeType } from './types/Util.std.ts'; @@ -282,5 +283,5 @@ declare global { } export type WhisperType = { - events: EventEmitter; + events: EventEmitter; };