RelinkDialog: Show something different when primary device

This commit is contained in:
Scott Nonnenberg
2026-04-28 23:54:53 +10:00
committed by GitHub
parent d791bb880c
commit 4b79efa033
14 changed files with 183 additions and 48 deletions
+6
View File
@@ -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"
},
+2 -2
View File
@@ -2692,7 +2692,7 @@ export class SignalProtocolStore extends EventEmitter {
this.emit('removeAllData');
}
async removeAllConfiguration(): Promise<void> {
async removeAllConfiguration(isPrimary: boolean): Promise<void> {
// 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();
+13 -9
View File
@@ -3446,14 +3446,18 @@ async function startApp(): Promise<void> {
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<void> {
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();
+22 -1
View File
@@ -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 && <br />}
<h3>{title}</h3>
<FakeLeftPaneContainer
containerWidthBreakpoint={props.containerWidthBreakpoint}
@@ -52,3 +55,21 @@ export function Iterations(): React.JSX.Element {
</>
);
}
export function IterationsStandalone(): React.JSX.Element {
return (
<>
{permutations.map(({ props, title }, index) => (
<>
{index > 0 && <br />}
<h3>{title}</h3>
<FakeLeftPaneContainer
containerWidthBreakpoint={props.containerWidthBreakpoint}
>
<DialogRelink {...defaultProps} {...props} weArePrimaryDevice />
</FakeLeftPaneContainer>
</>
))}
</>
);
}
+18
View File
@@ -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 (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="relink"
clickLabel={i18n('icu:unregisteredWarning')}
onClick={reregister}
title={i18n('icu:unregistered')}
hasAction
/>
);
}
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
+2
View File
@@ -260,6 +260,8 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
<DialogRelink
i18n={i18n}
relinkDevice={action('relinkDevice')}
reregister={action('reregister')}
weArePrimaryDevice={false}
{...props}
/>
),
+1 -1
View File
@@ -1486,7 +1486,7 @@ type WritableInterface = {
resetProtectedAttachmentPaths: () => void;
removeAll: () => void;
removeAllConfiguration: () => void;
removeAllConfiguration: (isPrimary: boolean) => void;
eraseStorageServiceState: () => void;
insertJob(job: Readonly<StoredJob>): void;
+12 -3
View File
@@ -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<string>(STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK);
let allowedSet = new Set<string>(STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK);
if (isPrimary) {
allowedSet = allowedSet.union(
new Set<string>(STORAGE_KEYS_TO_PRESERVE_WHEN_PRIMARY)
);
}
for (const id of itemIds) {
if (!allowedSet.has(id)) {
removeById(db, 'items', id);
+14 -7
View File
@@ -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,
};
+9 -3
View File
@@ -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 (
<DialogRelink
i18n={i18n}
containerWidthBreakpoint={containerWidthBreakpoint}
relinkDevice={relinkDevice}
reregister={reregister}
weArePrimaryDevice={weArePrimaryDevice}
/>
);
});
@@ -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,
});
});
});
+6 -2
View File
@@ -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();
+36 -17
View File
@@ -411,16 +411,31 @@ export const STORAGE_KEYS_TO_PRESERVE_AFTER_UNLINK = [
'restoredBackupFirstAppVersion',
] as const satisfies ReadonlyArray<keyof StorageAccessType>;
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<keyof StorageAccessType>;
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 extends true> = 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<StorageKeysToPreserveAfterUnlink, StorageKeysToKeepWhenPrimary>,
never
>
>;
export type AssertPrimaryAndUnlinkDoNotOverlap = AssertTrue<
AssertSameMembers<
Extract<StorageKeysToKeepWhenPrimary, StorageKeysToRemoveAfterUnlink>,
never
>
>;
export type AssertStorageUnlinkKeysAreExhaustive = AssertTrue<
AssertSameMembers<
StorageKeysToPreserveAfterUnlink | StorageKeysToRemoveAfterUnlink,
| StorageKeysToPreserveAfterUnlink
| StorageKeysToKeepWhenPrimary
| StorageKeysToRemoveAfterUnlink,
keyof StorageAccessType
>
>;
+2 -1
View File
@@ -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<WhisperEventMap>;
};