Improved relink prompt

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
trevor-signal
2026-05-11 10:30:56 -04:00
committed by GitHub
co-authored by Scott Nonnenberg
parent e0ef26bb4f
commit e15c3adddc
9 changed files with 311 additions and 11 deletions
+44
View File
@@ -11022,6 +11022,50 @@
"messageformat": "Slide",
"description": "aria-label for a slide within a carousel UI layout"
},
"icu:MaybeTransferModal__title": {
"messageformat": "Transfer your phones message history to this desktop?",
"description": "Title for dialog shown when user is unlinked and clicks to relink"
},
"icu:MaybeTransferModal__description": {
"messageformat": "When you re-link this desktop, you can transfer your phones message history to this device.",
"description": "Description for dialog shown when user is unlinked and clicks to relink"
},
"icu:MaybeTransferModal__learnMore": {
"messageformat": "Learn more",
"description": "Text for link to a support article about relinking a linked device"
},
"icu:MaybeTransferModal__replaceHistory": {
"messageformat": "This will <bold>replace</bold> the message history on this desktop.",
"description": "Bullet point in relink dialog for what will happen if you link & sync"
},
"icu:MaybeTransferModal__scanQrCode": {
"messageformat": "Youll need to relink by scanning a QR code.",
"description": "Bullet point in relink dialog for what will happen if you link & sync"
},
"icu:MaybeTransferModal__media": {
"messageformat": "If you have paid Signal Secure Backups, all of your media will be transferred. Otherwise, your last 45 days of media will be included.",
"description": "Bullet point in relink dialog for what will happen if you link & sync"
},
"icu:MaybeTransferModal__transfer": {
"messageformat": "Transfer",
"description": "Button text when user wants to transfer data from their phone"
},
"icu:MaybeTransferModal__dontTransfer": {
"messageformat": "Don't transfer",
"description": "Button text when user does not want to transfer data from their phone"
},
"icu:DeleteDataAndRelinkConfirmationDialog__title": {
"messageformat": "Delete all data?",
"description": "Title in confirmation dialog shown when user is unlinked and chose to delete data and relink"
},
"icu:DeleteDataAndRelinkConfirmationDialog__pending": {
"messageformat": "Deleting all data",
"description": "Accessiblity label for button when data is in progress of being deleted"
},
"icu:DeleteDataAndRelinkConfirmationDialog__body": {
"messageformat": "All current data in Signal Desktop will be deleted. You can then scan a QR code and transfer your message history from your phone.",
"description": "Description in confirmation dialog shown when user is unlinked and chose to delete data and relink"
},
"icu:WhatsNew__bugfixes": {
"messageformat": "This version contains a number of small tweaks and bug fixes to keep Signal running smoothly.",
"description": "Release notes for releases that only include bug fixes",
@@ -16,6 +16,7 @@ const defaultProps = {
containerWidthBreakpoint: WidthBreakpoint.Wide,
i18n,
relinkDevice: action('relink-device'),
renderClearingDataView: action('render-clearing-data-view'),
reregister: action('reregister'),
weArePrimaryDevice: false,
};
+40 -10
View File
@@ -1,17 +1,22 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { JSX } from 'react';
import { useState, type JSX } from 'react';
import type { LocalizerType } from '../types/Util.std.ts';
import type { WidthBreakpoint } from './_util.std.ts';
import { LeftPaneDialog } from './LeftPaneDialog.dom.tsx';
import {
DeleteDataAndRelinkConfirmationDialog,
MaybeTransferModal,
} from './MaybeTransferModal.dom.tsx';
export type PropsType = {
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
relinkDevice: () => void;
renderClearingDataView: () => void;
reregister: () => void;
weArePrimaryDevice: boolean;
};
@@ -20,9 +25,14 @@ export function DialogRelink({
containerWidthBreakpoint,
i18n,
relinkDevice,
renderClearingDataView,
reregister,
weArePrimaryDevice,
}: PropsType): JSX.Element | null {
const [relinkDialogStep, setRelinkDialogStep] = useState<
'maybe-transfer' | 'confirm-deletion' | null
>(null);
if (weArePrimaryDevice) {
return (
<LeftPaneDialog
@@ -38,14 +48,34 @@ export function DialogRelink({
}
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="relink"
clickLabel={i18n('icu:unlinkedWarning')}
onClick={relinkDevice}
title={i18n('icu:unlinked')}
hasAction
/>
<>
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="relink"
clickLabel={i18n('icu:unlinkedWarning')}
onClick={() => setRelinkDialogStep('maybe-transfer')}
title={i18n('icu:unlinked')}
hasAction
/>
<MaybeTransferModal
open={relinkDialogStep === 'maybe-transfer'}
i18n={i18n}
onTransfer={() => setRelinkDialogStep('confirm-deletion')}
onDontTransfer={relinkDevice}
onCancel={() => setRelinkDialogStep(null)}
/>
{relinkDialogStep === 'confirm-deletion' ? (
<DeleteDataAndRelinkConfirmationDialog
open={relinkDialogStep === 'confirm-deletion'}
i18n={i18n}
onCancel={() => setRelinkDialogStep('maybe-transfer')}
onConfirm={() => {
renderClearingDataView();
setRelinkDialogStep(null);
}}
/>
) : null}
</>
);
}
+1
View File
@@ -260,6 +260,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
<DialogRelink
i18n={i18n}
relinkDevice={action('relinkDevice')}
renderClearingDataView={action('renderClearingDataView')}
reregister={action('reregister')}
weArePrimaryDevice={false}
{...props}
@@ -0,0 +1,40 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { JSX } from 'react';
import { action } from '@storybook/addon-actions';
import { type Meta } from '@storybook/react';
import {
MaybeTransferModal,
DeleteDataAndRelinkConfirmationDialog,
} from './MaybeTransferModal.dom.tsx';
const { i18n } = window.SignalContext;
export default {
title: 'Components/MaybeTransferModal',
} satisfies Meta;
export function UnlinkedMaybeTransferModal(): JSX.Element {
return (
<MaybeTransferModal
open
onDontTransfer={action('onDontTransfer')}
onTransfer={action('onTransfer')}
onCancel={action('onCancel')}
i18n={i18n}
/>
);
}
export function DeleteDataConfirmationDialog(): JSX.Element {
return (
<DeleteDataAndRelinkConfirmationDialog
open
onCancel={action('onCancel')}
onConfirm={async () => action('onConfirm')()}
i18n={i18n}
/>
);
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2026 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { type JSX, type ReactNode } from 'react';
import type { LocalizerType } from '../types/Util.std.ts';
import { AxoDialog } from '../axo/AxoDialog.dom.tsx';
import { tw } from '../axo/tw.dom.tsx';
import { type AxoSymbolIconName } from '../axo/_internal/AxoSymbolDefs.generated.std.ts';
import { AxoSymbol } from '../axo/AxoSymbol.dom.tsx';
import { I18n } from './I18n.dom.tsx';
import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.tsx';
import { AxoButton } from '../axo/AxoButton.dom.tsx';
const LEARN_MORE_LINK =
'https://support.signal.org/hc/articles/360007320551-Linked-Devices';
export function MaybeTransferModal({
i18n,
onCancel,
onDontTransfer,
onTransfer,
open,
}: {
i18n: LocalizerType;
onCancel: () => void;
onDontTransfer: () => void;
onTransfer: () => void;
open: boolean;
}): JSX.Element {
return (
<AxoDialog.Root
open={open}
onOpenChange={isOpen => {
if (!isOpen) {
onCancel();
}
}}
>
<AxoDialog.Content
size="sm"
escape="cancel-is-noop"
disableMissingAriaDescriptionWarning
>
<AxoDialog.Body maxHeight={600}>
<div className={tw('mt-8 flex flex-col items-center')}>
<img
src="images/desktop-and-phone.svg"
height="80"
width="100"
alt=""
/>
<AxoDialog.Title screenReaderOnly>
{i18n('icu:MaybeTransferModal__title')}
</AxoDialog.Title>
<div className={tw('mt-4 text-center type-title-medium')}>
{i18n('icu:MaybeTransferModal__title')}
</div>
</div>
<div
className={tw('mt-3 mb-5 flex flex-col gap-5 text-label-secondary')}
>
<div className={tw('text-center')}>
<div>{i18n('icu:MaybeTransferModal__description')}</div>
<a
className={tw('text-label-primary')}
href={LEARN_MORE_LINK}
rel="noreferrer"
target="_blank"
>
{i18n('icu:MaybeTransferModal__learnMore')}
</a>
</div>
<ul className={tw('flex flex-col gap-5 px-4')}>
<ListItemWithIcon
iconName="message-thread"
content={
<I18n
i18n={i18n}
id="icu:MaybeTransferModal__replaceHistory"
components={{ bold: Bold }}
/>
}
/>
<ListItemWithIcon
iconName="qrcode"
content={i18n('icu:MaybeTransferModal__scanQrCode')}
/>
<ListItemWithIcon
iconName="backup"
content={i18n('icu:MaybeTransferModal__media')}
/>
</ul>
</div>
</AxoDialog.Body>
<AxoDialog.Footer>
<AxoDialog.Actions>
<AxoDialog.Action variant="secondary" onClick={onDontTransfer}>
{i18n('icu:MaybeTransferModal__dontTransfer')}
</AxoDialog.Action>
<AxoDialog.Action variant="primary" onClick={onTransfer}>
{i18n('icu:MaybeTransferModal__transfer')}
</AxoDialog.Action>
</AxoDialog.Actions>
</AxoDialog.Footer>
</AxoDialog.Content>
</AxoDialog.Root>
);
}
function ListItemWithIcon({
iconName,
content,
}: {
iconName: AxoSymbolIconName;
content: ReactNode;
}): ReactNode {
return (
<li className={tw('flex gap-2')}>
<div
className={tw(
'flex size-8 shrink-0 items-center justify-center rounded-full bg-fill-secondary'
)}
>
<AxoSymbol.Icon size={20} symbol={iconName} label={null} />
</div>
<div>{content}</div>
</li>
);
}
function Bold(parts: Array<string | JSX.Element>) {
return <strong>{parts}</strong>;
}
export function DeleteDataAndRelinkConfirmationDialog({
i18n,
onCancel,
onConfirm,
open,
}: {
i18n: LocalizerType;
onCancel: () => void;
onConfirm: () => void;
open: boolean;
}): JSX.Element {
return (
<AxoAlertDialog.Root
open={open}
onOpenChange={isOpen => {
if (!isOpen) {
onCancel();
}
}}
>
<AxoAlertDialog.Content escape="cancel-is-noop">
<AxoAlertDialog.Body>
<AxoAlertDialog.Title>
{i18n('icu:DeleteDataAndRelinkConfirmationDialog__title')}
</AxoAlertDialog.Title>
<AxoAlertDialog.Description>
{i18n('icu:DeleteDataAndRelinkConfirmationDialog__body')}
</AxoAlertDialog.Description>
</AxoAlertDialog.Body>
<AxoAlertDialog.Footer>
<AxoAlertDialog.Action variant="secondary" onClick={onCancel}>
{i18n('icu:cancel')}
</AxoAlertDialog.Action>
<AxoButton.Root
variant="destructive"
size="md"
width="grow"
onClick={onConfirm}
>
{i18n('icu:delete')}
</AxoButton.Root>
</AxoAlertDialog.Footer>
</AxoAlertDialog.Content>
</AxoAlertDialog.Root>
);
}
+1 -1
View File
@@ -104,7 +104,7 @@ import { SmartCaptchaDialog } from './CaptchaDialog.preload.tsx';
import { SmartCrashReportDialog } from './CrashReportDialog.preload.tsx';
import { SmartMessageSearchResult } from './MessageSearchResult.preload.tsx';
import { SmartNetworkStatus } from './NetworkStatus.preload.tsx';
import { SmartRelinkDialog } from './RelinkDialog.dom.tsx';
import { SmartRelinkDialog } from './RelinkDialog.preload.tsx';
import {
renderToastManagerWithoutMegaphone,
SmartToastManager,
@@ -8,6 +8,7 @@ import { areWePrimaryDevice, getIntl } from '../selectors/user.std.ts';
import { useNetworkActions } from '../ducks/network.dom.ts';
import type { WidthBreakpoint } from '../../components/_util.std.ts';
import { renderClearingDataView } from '../../shims/renderClearingDataView.preload.tsx';
type SmartRelinkDialogProps = Readonly<{
containerWidthBreakpoint: WidthBreakpoint;
@@ -25,6 +26,7 @@ export const SmartRelinkDialog = memo(function SmartRelinkDialog({
i18n={i18n}
containerWidthBreakpoint={containerWidthBreakpoint}
relinkDevice={relinkDevice}
renderClearingDataView={renderClearingDataView}
reregister={reregister}
weArePrimaryDevice={weArePrimaryDevice}
/>
+1
View File
@@ -425,6 +425,7 @@ export class Bootstrap {
return;
}
await relinkButton.click();
await window.getByRole('button', { name: "Don't transfer" }).click();
} catch {
// Ignore, provision will fail if QR code was never generated
}