From 9687aee2ca14158c0b7f8a260d063fd71df942b1 Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Wed, 11 Sep 2024 11:03:18 -0700 Subject: [PATCH] Backup import cancel UI --- _locales/en/messages.json | 24 ++++++ ...css => InstallScreenBackupImportStep.scss} | 35 ++++++--- stylesheets/manifest.scss | 2 +- .../InstallScreenBackupImportStep.stories.tsx | 15 +++- .../InstallScreenBackupImportStep.tsx | 74 ++++++++++++++++--- ts/services/backups/api.ts | 3 + ts/services/backups/index.ts | 38 ++++++++++ ts/state/smart/InstallScreen.tsx | 11 +++ ts/textsecure/WebAPI.ts | 6 ++ 9 files changed, 183 insertions(+), 25 deletions(-) rename stylesheets/components/{BackupImportScreen.scss => InstallScreenBackupImportStep.scss} (51%) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 19bb626bcf..db10067ad9 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -4695,10 +4695,34 @@ "messageformat": "Downloading {currentSize} of {totalSize} ({fractionComplete, number, percent})...", "description": "Hint under the progressbar in the backup import screen" }, + "icu:BackupImportScreen__progressbar-hint--preparing": { + "messageformat": "Preparing to download...", + "description": "Hint under the progressbar in the backup import screen when download size is not yet known" + }, "icu:BackupImportScreen__description": { "messageformat": "This may take a few minutes depending on the size of your backup", "description": "Description at the bottom of backup import screen" }, + "icu:BackupImportScreen__cancel": { + "messageformat": "Cancel transfer", + "description": "Text of the cancel button at the bottom of backup import screen" + }, + "icu:BackupImportScreen__cancel-confirmation__title": { + "messageformat": "Cancel transfer?", + "description": "Title of the cancel confirmation modal in the backup import screen" + }, + "icu:BackupImportScreen__cancel-confirmation__body": { + "messageformat": "Your messages and media have not completed restoring. If you choose to cancel, you can transfer again from Settings.", + "description": "Body of the cancel confirmation modal in the backup import screen" + }, + "icu:BackupImportScreen__cancel-confirmation__cancel": { + "messageformat": "Continue transfer", + "description": "Text of the continue button of the cancel confirmation modal in the backup import screen" + }, + "icu:BackupImportScreen__cancel-confirmation__confirm": { + "messageformat": "Cancel transfer", + "description": "Text of the confirmation button of the cancel confirmation modal in the backup import screen" + }, "icu:BackupMediaDownloadProgress__title": { "messageformat": "Restoring media", "description": "Label above a progress bar showing media (attachment) download progress after restoring from backup" diff --git a/stylesheets/components/BackupImportScreen.scss b/stylesheets/components/InstallScreenBackupImportStep.scss similarity index 51% rename from stylesheets/components/BackupImportScreen.scss rename to stylesheets/components/InstallScreenBackupImportStep.scss index d627c92b77..555e19d515 100644 --- a/stylesheets/components/BackupImportScreen.scss +++ b/stylesheets/components/InstallScreenBackupImportStep.scss @@ -1,8 +1,10 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -.BackupImportScreen { +.InstallScreenBackupImportStep { + position: relative; display: flex; + width: 100vw; height: 100vh; @@ -10,20 +12,20 @@ align-items: center; } -.BackupImportScreen__content { +.InstallScreenBackupImportStep__content { text-align: center; } -.BackupImportScreen__title { +.InstallScreenBackupImportStep__title { @include font-title-2; margin-block: 0 20px; } -.BackupImportScreen .ProgressBar { +.InstallScreenBackupImportStep .ProgressBar { margin-block-end: 14px; } -.BackupImportScreen__progressbar-hint { +.InstallScreenBackupImportStep__progressbar-hint { @include font-caption; margin-block-end: 22px; @@ -36,11 +38,7 @@ } } -.BackupImportScreen__progressbar-hint--hidden { - visibility: hidden; -} - -.BackupImportScreen__description { +.InstallScreenBackupImportStep__description { @include font-body-1; @include light-theme { @@ -51,3 +49,20 @@ color: $color-gray-25; } } + +.InstallScreenBackupImportStep__cancel { + @include button-reset(); + @include button-focus-outline; + @include font-body-1-bold; + + position: absolute; + bottom: 48px; + + @include light-theme() { + color: $color-ultramarine; + } + + @include dark-theme() { + color: $color-ultramarine-light; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 5d8033b5a2..7c0b8657f1 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -32,7 +32,6 @@ @import './components/AvatarModalButtons.scss'; @import './components/AvatarPreview.scss'; @import './components/AvatarTextEditor.scss'; -@import './components/BackupImportScreen.scss'; @import './components/BackupMediaDownloadProgress.scss'; @import './components/BadgeCarouselIndex.scss'; @import './components/BadgeDialog.scss'; @@ -106,6 +105,7 @@ @import './components/Inbox.scss'; @import './components/IncomingCallBar.scss'; @import './components/Input.scss'; +@import './components/InstallScreenBackupImportStep.scss'; @import './components/InstallScreenChoosingDeviceNameStep.scss'; @import './components/InstallScreenErrorStep.scss'; @import './components/InstallScreenLinkInProgressStep.scss'; diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx index bf43330a5c..06d9470b85 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.stories.tsx @@ -3,6 +3,7 @@ import React from 'react'; import type { Meta, StoryFn } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; import type { PropsType } from './InstallScreenBackupImportStep'; @@ -16,7 +17,11 @@ export default { // eslint-disable-next-line react/function-component-definition const Template: StoryFn = (args: PropsType) => ( - + ); export const NoBytes = Template.bind({}); @@ -27,6 +32,12 @@ NoBytes.args = { export const Bytes = Template.bind({}); Bytes.args = { - currentBytes: 500, + currentBytes: 500 * 1024, + totalBytes: 1024 * 1024, +}; + +export const Full = Template.bind({}); +Full.args = { + currentBytes: 1024, totalBytes: 1024, }; diff --git a/ts/components/installScreen/InstallScreenBackupImportStep.tsx b/ts/components/installScreen/InstallScreenBackupImportStep.tsx index 0f03cb94b9..d9c3e0edc2 100644 --- a/ts/components/installScreen/InstallScreenBackupImportStep.tsx +++ b/ts/components/installScreen/InstallScreenBackupImportStep.tsx @@ -1,12 +1,13 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useState, useCallback } from 'react'; import type { LocalizerType } from '../../types/Util'; import { formatFileSize } from '../../util/formatFileSize'; import { TitlebarDragArea } from '../TitlebarDragArea'; import { ProgressBar } from '../ProgressBar'; +import { ConfirmationDialog } from '../ConfirmationDialog'; import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; // We can't always use destructuring assignment because of the complexity of this props @@ -16,16 +17,36 @@ export type PropsType = Readonly<{ i18n: LocalizerType; currentBytes?: number; totalBytes?: number; + onCancel: () => void; }>; export function InstallScreenBackupImportStep({ i18n, currentBytes, totalBytes, + onCancel, }: PropsType): JSX.Element { + const [isConfirmingCancel, setIsConfirmingCancel] = useState(false); + + const confirmCancel = useCallback(() => { + setIsConfirmingCancel(true); + }, []); + + const abortCancel = useCallback(() => { + setIsConfirmingCancel(false); + }, []); + + const onCancelWrap = useCallback(() => { + onCancel(); + setIsConfirmingCancel(false); + }, [onCancel]); + let percentage = 0; let progress: JSX.Element; + let isCancelPossible = true; if (currentBytes != null && totalBytes != null) { + isCancelPossible = currentBytes !== totalBytes; + percentage = Math.max(0, Math.min(1, currentBytes / totalBytes)); if (percentage > 0 && percentage <= 0.01) { percentage = 0.01; @@ -38,7 +59,7 @@ export function InstallScreenBackupImportStep({ progress = ( <> -
+
{i18n('icu:BackupImportScreen__progressbar-hint', { currentSize: formatFileSize(currentBytes), totalSize: formatFileSize(totalBytes), @@ -51,31 +72,60 @@ export function InstallScreenBackupImportStep({ progress = ( <> -
- {i18n('icu:BackupImportScreen__progressbar-hint', { - currentSize: '', - totalSize: '', - fractionComplete: 0, - })} +
+ {i18n('icu:BackupImportScreen__progressbar-hint--preparing')}
); } return ( -
+
-
-

+
+

{i18n('icu:BackupImportScreen__title')}

{progress} -
+
{i18n('icu:BackupImportScreen__description')}
+ + {isCancelPossible && ( + + )} + + {isConfirmingCancel && ( + + {i18n('icu:BackupImportScreen__cancel-confirmation__body')} + + )}
); } diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts index 9a46a947e1..e022c9a042 100644 --- a/ts/services/backups/api.ts +++ b/ts/services/backups/api.ts @@ -17,6 +17,7 @@ import { uploadFile } from '../../util/uploadAttachment'; export type DownloadOptionsType = Readonly<{ downloadOffset: number; onProgress: (currentBytes: number, totalBytes: number) => void; + abortSignal?: AbortSignal; }>; export class BackupAPI { @@ -75,6 +76,7 @@ export class BackupAPI { public async download({ downloadOffset, onProgress, + abortSignal, }: DownloadOptionsType): Promise { const { cdn, backupDir, backupName } = await this.getInfo(); const { headers } = await this.credentials.getCDNReadCredentials(cdn); @@ -86,6 +88,7 @@ export class BackupAPI { headers, downloadOffset, onProgress, + abortSignal, }); } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index ff5cf354d7..4aa7536c3b 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -51,6 +51,7 @@ export enum BackupType { export class BackupsService { private isStarted = false; private isRunning = false; + private downloadController: AbortController | undefined; public readonly credentials = new BackupCredentials(); public readonly api = new BackupAPI(this.credentials); @@ -145,10 +146,26 @@ export class BackupsService { return backupsService.importBackup(() => createReadStream(backupFile)); } + public cancelDownload(): void { + if (this.downloadController) { + log.warn('importBackup: canceling download'); + this.downloadController.abort(); + this.downloadController = undefined; + } else { + log.error('importBackup: not canceling download, not running'); + } + } + public async download( downloadPath: string, { onProgress }: Omit ): Promise { + const controller = new AbortController(); + + // Abort previous download + this.downloadController?.abort(); + this.downloadController = controller; + let downloadOffset = 0; try { ({ size: downloadOffset } = await stat(downloadPath)); @@ -163,11 +180,20 @@ export class BackupsService { try { await ensureFile(downloadPath); + if (controller.signal.aborted) { + return false; + } + const stream = await this.api.download({ downloadOffset, onProgress, + abortSignal: controller.signal, }); + if (controller.signal.aborted) { + return false; + } + await pipeline( stream, createWriteStream(downloadPath, { @@ -176,12 +202,24 @@ export class BackupsService { }) ); + if (controller.signal.aborted) { + return false; + } + + this.downloadController = undefined; + + // Too late to cancel now try { await this.importFromDisk(downloadPath); } finally { await unlink(downloadPath); } } catch (error) { + // Download canceled + if (error.name === 'AbortError') { + return false; + } + // No backup on the server if (error instanceof HTTPError && error.code === 404) { return false; diff --git a/ts/state/smart/InstallScreen.tsx b/ts/state/smart/InstallScreen.tsx index 59c495970d..53e15764dc 100644 --- a/ts/state/smart/InstallScreen.tsx +++ b/ts/state/smart/InstallScreen.tsx @@ -8,10 +8,12 @@ import { useSelector } from 'react-redux'; import { getIntl } from '../selectors/user'; import { getUpdatesState } from '../selectors/updates'; import { getInstallerState } from '../selectors/installer'; +import { useAppActions } from '../ducks/app'; import { useInstallerActions } from '../ducks/installer'; import { useUpdatesActions } from '../ducks/updates'; import { hasExpired as hasExpiredSelector } from '../selectors/expiration'; import { missingCaseError } from '../../util/missingCaseError'; +import { backupsService } from '../../services/backups'; import { InstallScreen } from '../../components/InstallScreen'; import { WidthBreakpoint } from '../../components/_util'; import { InstallScreenStep } from '../../types/InstallScreen'; @@ -27,6 +29,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { const i18n = useSelector(getIntl); const installerState = useSelector(getInstallerState); const updates = useSelector(getUpdatesState); + const { openInbox } = useAppActions(); const { startInstaller, finishInstall } = useInstallerActions(); const { startUpdate } = useUpdatesActions(); const hasExpired = useSelector(hasExpiredSelector); @@ -43,6 +46,13 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { } }, [backupFile, deviceName, finishInstall]); + const onCancelBackupImport = useCallback((): void => { + backupsService.cancelDownload(); + if (installerState.step === InstallScreenStep.BackupImport) { + openInbox(); + } + }, [installerState.step, openInbox]); + const suggestedDeviceName = installerState.step === InstallScreenStep.ChoosingDeviceName ? installerState.deviceName @@ -100,6 +110,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() { i18n, currentBytes: installerState.currentBytes, totalBytes: installerState.totalBytes, + onCancel: onCancelBackupImport, }, }; break; diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index d1a2504dc2..fdcd77a9bd 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1182,6 +1182,7 @@ export type GetBackupStreamOptionsType = Readonly<{ headers: Record; downloadOffset: number; onProgress: (currentBytes: number, totalBytes: number) => void; + abortSignal?: AbortSignal; }>; export const getBackupInfoResponseSchema = z.object({ @@ -2833,6 +2834,7 @@ export function initialize({ backupName, downloadOffset, onProgress, + abortSignal, }: GetBackupStreamOptionsType): Promise { return _getAttachment({ cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`, @@ -2842,6 +2844,7 @@ export function initialize({ options: { downloadOffset, onProgress, + abortSignal, }, }); } @@ -3583,6 +3586,7 @@ export function initialize({ timeout?: number; downloadOffset?: number; onProgress?: (currentBytes: number, totalBytes: number) => void; + abortSignal?: AbortSignal; }; }): Promise { const abortController = new AbortController(); @@ -3594,6 +3598,8 @@ export function initialize({ abortController.abort(); }; + options?.abortSignal?.addEventListener('abort', cancelRequest); + registerInflightRequest(cancelRequest); let totalBytes = 0;