Backup import cancel UI

This commit is contained in:
Fedor Indutny 2024-09-11 11:03:18 -07:00 committed by GitHub
parent c901f47dd1
commit 9687aee2ca
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 183 additions and 25 deletions

View File

@ -4695,10 +4695,34 @@
"messageformat": "Downloading {currentSize} of {totalSize} ({fractionComplete, number, percent})...", "messageformat": "Downloading {currentSize} of {totalSize} ({fractionComplete, number, percent})...",
"description": "Hint under the progressbar in the backup import screen" "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": { "icu:BackupImportScreen__description": {
"messageformat": "This may take a few minutes depending on the size of your backup", "messageformat": "This may take a few minutes depending on the size of your backup",
"description": "Description at the bottom of backup import screen" "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": { "icu:BackupMediaDownloadProgress__title": {
"messageformat": "Restoring media", "messageformat": "Restoring media",
"description": "Label above a progress bar showing media (attachment) download progress after restoring from backup" "description": "Label above a progress bar showing media (attachment) download progress after restoring from backup"

View File

@ -1,8 +1,10 @@
// Copyright 2024 Signal Messenger, LLC // Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.BackupImportScreen { .InstallScreenBackupImportStep {
position: relative;
display: flex; display: flex;
width: 100vw; width: 100vw;
height: 100vh; height: 100vh;
@ -10,20 +12,20 @@
align-items: center; align-items: center;
} }
.BackupImportScreen__content { .InstallScreenBackupImportStep__content {
text-align: center; text-align: center;
} }
.BackupImportScreen__title { .InstallScreenBackupImportStep__title {
@include font-title-2; @include font-title-2;
margin-block: 0 20px; margin-block: 0 20px;
} }
.BackupImportScreen .ProgressBar { .InstallScreenBackupImportStep .ProgressBar {
margin-block-end: 14px; margin-block-end: 14px;
} }
.BackupImportScreen__progressbar-hint { .InstallScreenBackupImportStep__progressbar-hint {
@include font-caption; @include font-caption;
margin-block-end: 22px; margin-block-end: 22px;
@ -36,11 +38,7 @@
} }
} }
.BackupImportScreen__progressbar-hint--hidden { .InstallScreenBackupImportStep__description {
visibility: hidden;
}
.BackupImportScreen__description {
@include font-body-1; @include font-body-1;
@include light-theme { @include light-theme {
@ -51,3 +49,20 @@
color: $color-gray-25; 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;
}
}

View File

@ -32,7 +32,6 @@
@import './components/AvatarModalButtons.scss'; @import './components/AvatarModalButtons.scss';
@import './components/AvatarPreview.scss'; @import './components/AvatarPreview.scss';
@import './components/AvatarTextEditor.scss'; @import './components/AvatarTextEditor.scss';
@import './components/BackupImportScreen.scss';
@import './components/BackupMediaDownloadProgress.scss'; @import './components/BackupMediaDownloadProgress.scss';
@import './components/BadgeCarouselIndex.scss'; @import './components/BadgeCarouselIndex.scss';
@import './components/BadgeDialog.scss'; @import './components/BadgeDialog.scss';
@ -106,6 +105,7 @@
@import './components/Inbox.scss'; @import './components/Inbox.scss';
@import './components/IncomingCallBar.scss'; @import './components/IncomingCallBar.scss';
@import './components/Input.scss'; @import './components/Input.scss';
@import './components/InstallScreenBackupImportStep.scss';
@import './components/InstallScreenChoosingDeviceNameStep.scss'; @import './components/InstallScreenChoosingDeviceNameStep.scss';
@import './components/InstallScreenErrorStep.scss'; @import './components/InstallScreenErrorStep.scss';
@import './components/InstallScreenLinkInProgressStep.scss'; @import './components/InstallScreenLinkInProgressStep.scss';

View File

@ -3,6 +3,7 @@
import React from 'react'; import React from 'react';
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import type { PropsType } from './InstallScreenBackupImportStep'; import type { PropsType } from './InstallScreenBackupImportStep';
@ -16,7 +17,11 @@ export default {
// eslint-disable-next-line react/function-component-definition // eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = (args: PropsType) => ( const Template: StoryFn<PropsType> = (args: PropsType) => (
<InstallScreenBackupImportStep {...args} i18n={i18n} /> <InstallScreenBackupImportStep
{...args}
i18n={i18n}
onCancel={action('onCancel')}
/>
); );
export const NoBytes = Template.bind({}); export const NoBytes = Template.bind({});
@ -27,6 +32,12 @@ NoBytes.args = {
export const Bytes = Template.bind({}); export const Bytes = Template.bind({});
Bytes.args = { Bytes.args = {
currentBytes: 500, currentBytes: 500 * 1024,
totalBytes: 1024 * 1024,
};
export const Full = Template.bind({});
Full.args = {
currentBytes: 1024,
totalBytes: 1024, totalBytes: 1024,
}; };

View File

@ -1,12 +1,13 @@
// Copyright 2024 Signal Messenger, LLC // Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useState, useCallback } from 'react';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { formatFileSize } from '../../util/formatFileSize'; import { formatFileSize } from '../../util/formatFileSize';
import { TitlebarDragArea } from '../TitlebarDragArea'; import { TitlebarDragArea } from '../TitlebarDragArea';
import { ProgressBar } from '../ProgressBar'; import { ProgressBar } from '../ProgressBar';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
// We can't always use destructuring assignment because of the complexity of this props // We can't always use destructuring assignment because of the complexity of this props
@ -16,16 +17,36 @@ export type PropsType = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
currentBytes?: number; currentBytes?: number;
totalBytes?: number; totalBytes?: number;
onCancel: () => void;
}>; }>;
export function InstallScreenBackupImportStep({ export function InstallScreenBackupImportStep({
i18n, i18n,
currentBytes, currentBytes,
totalBytes, totalBytes,
onCancel,
}: PropsType): JSX.Element { }: 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 percentage = 0;
let progress: JSX.Element; let progress: JSX.Element;
let isCancelPossible = true;
if (currentBytes != null && totalBytes != null) { if (currentBytes != null && totalBytes != null) {
isCancelPossible = currentBytes !== totalBytes;
percentage = Math.max(0, Math.min(1, currentBytes / totalBytes)); percentage = Math.max(0, Math.min(1, currentBytes / totalBytes));
if (percentage > 0 && percentage <= 0.01) { if (percentage > 0 && percentage <= 0.01) {
percentage = 0.01; percentage = 0.01;
@ -38,7 +59,7 @@ export function InstallScreenBackupImportStep({
progress = ( progress = (
<> <>
<ProgressBar fractionComplete={percentage} /> <ProgressBar fractionComplete={percentage} />
<div className="BackupImportScreen__progressbar-hint"> <div className="InstallScreenBackupImportStep__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint', { {i18n('icu:BackupImportScreen__progressbar-hint', {
currentSize: formatFileSize(currentBytes), currentSize: formatFileSize(currentBytes),
totalSize: formatFileSize(totalBytes), totalSize: formatFileSize(totalBytes),
@ -51,31 +72,60 @@ export function InstallScreenBackupImportStep({
progress = ( progress = (
<> <>
<ProgressBar fractionComplete={0} /> <ProgressBar fractionComplete={0} />
<div className="BackupImportScreen__progressbar-hint BackupImportScreen__progressbar-hint--hidden"> <div className="InstallScreenBackupImportStep__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint', { {i18n('icu:BackupImportScreen__progressbar-hint--preparing')}
currentSize: '',
totalSize: '',
fractionComplete: 0,
})}
</div> </div>
</> </>
); );
} }
return ( return (
<div className="BackupImportScreen"> <div className="InstallScreenBackupImportStep">
<TitlebarDragArea /> <TitlebarDragArea />
<InstallScreenSignalLogo /> <InstallScreenSignalLogo />
<div className="BackupImportScreen__content"> <div className="InstallScreenBackupImportStep__content">
<h3 className="BackupImportScreen__title"> <h3 className="InstallScreenBackupImportStep__title">
{i18n('icu:BackupImportScreen__title')} {i18n('icu:BackupImportScreen__title')}
</h3> </h3>
{progress} {progress}
<div className="BackupImportScreen__description"> <div className="InstallScreenBackupImportStep__description">
{i18n('icu:BackupImportScreen__description')} {i18n('icu:BackupImportScreen__description')}
</div> </div>
</div> </div>
{isCancelPossible && (
<button
className="InstallScreenBackupImportStep__cancel"
type="button"
onClick={confirmCancel}
>
{i18n('icu:BackupImportScreen__cancel')}
</button>
)}
{isConfirmingCancel && (
<ConfirmationDialog
dialogName="InstallScreenBackupImportStep.confirmCancel"
title={i18n('icu:BackupImportScreen__cancel-confirmation__title')}
cancelText={i18n(
'icu:BackupImportScreen__cancel-confirmation__cancel'
)}
actions={[
{
action: onCancelWrap,
style: 'negative',
text: i18n(
'icu:BackupImportScreen__cancel-confirmation__confirm'
),
},
]}
i18n={i18n}
onClose={abortCancel}
>
{i18n('icu:BackupImportScreen__cancel-confirmation__body')}
</ConfirmationDialog>
)}
</div> </div>
); );
} }

View File

@ -17,6 +17,7 @@ import { uploadFile } from '../../util/uploadAttachment';
export type DownloadOptionsType = Readonly<{ export type DownloadOptionsType = Readonly<{
downloadOffset: number; downloadOffset: number;
onProgress: (currentBytes: number, totalBytes: number) => void; onProgress: (currentBytes: number, totalBytes: number) => void;
abortSignal?: AbortSignal;
}>; }>;
export class BackupAPI { export class BackupAPI {
@ -75,6 +76,7 @@ export class BackupAPI {
public async download({ public async download({
downloadOffset, downloadOffset,
onProgress, onProgress,
abortSignal,
}: DownloadOptionsType): Promise<Readable> { }: DownloadOptionsType): Promise<Readable> {
const { cdn, backupDir, backupName } = await this.getInfo(); const { cdn, backupDir, backupName } = await this.getInfo();
const { headers } = await this.credentials.getCDNReadCredentials(cdn); const { headers } = await this.credentials.getCDNReadCredentials(cdn);
@ -86,6 +88,7 @@ export class BackupAPI {
headers, headers,
downloadOffset, downloadOffset,
onProgress, onProgress,
abortSignal,
}); });
} }

View File

@ -51,6 +51,7 @@ export enum BackupType {
export class BackupsService { export class BackupsService {
private isStarted = false; private isStarted = false;
private isRunning = false; private isRunning = false;
private downloadController: AbortController | undefined;
public readonly credentials = new BackupCredentials(); public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials); public readonly api = new BackupAPI(this.credentials);
@ -145,10 +146,26 @@ export class BackupsService {
return backupsService.importBackup(() => createReadStream(backupFile)); 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( public async download(
downloadPath: string, downloadPath: string,
{ onProgress }: Omit<DownloadOptionsType, 'downloadOffset'> { onProgress }: Omit<DownloadOptionsType, 'downloadOffset'>
): Promise<boolean> { ): Promise<boolean> {
const controller = new AbortController();
// Abort previous download
this.downloadController?.abort();
this.downloadController = controller;
let downloadOffset = 0; let downloadOffset = 0;
try { try {
({ size: downloadOffset } = await stat(downloadPath)); ({ size: downloadOffset } = await stat(downloadPath));
@ -163,11 +180,20 @@ export class BackupsService {
try { try {
await ensureFile(downloadPath); await ensureFile(downloadPath);
if (controller.signal.aborted) {
return false;
}
const stream = await this.api.download({ const stream = await this.api.download({
downloadOffset, downloadOffset,
onProgress, onProgress,
abortSignal: controller.signal,
}); });
if (controller.signal.aborted) {
return false;
}
await pipeline( await pipeline(
stream, stream,
createWriteStream(downloadPath, { createWriteStream(downloadPath, {
@ -176,12 +202,24 @@ export class BackupsService {
}) })
); );
if (controller.signal.aborted) {
return false;
}
this.downloadController = undefined;
// Too late to cancel now
try { try {
await this.importFromDisk(downloadPath); await this.importFromDisk(downloadPath);
} finally { } finally {
await unlink(downloadPath); await unlink(downloadPath);
} }
} catch (error) { } catch (error) {
// Download canceled
if (error.name === 'AbortError') {
return false;
}
// No backup on the server // No backup on the server
if (error instanceof HTTPError && error.code === 404) { if (error instanceof HTTPError && error.code === 404) {
return false; return false;

View File

@ -8,10 +8,12 @@ import { useSelector } from 'react-redux';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getUpdatesState } from '../selectors/updates'; import { getUpdatesState } from '../selectors/updates';
import { getInstallerState } from '../selectors/installer'; import { getInstallerState } from '../selectors/installer';
import { useAppActions } from '../ducks/app';
import { useInstallerActions } from '../ducks/installer'; import { useInstallerActions } from '../ducks/installer';
import { useUpdatesActions } from '../ducks/updates'; import { useUpdatesActions } from '../ducks/updates';
import { hasExpired as hasExpiredSelector } from '../selectors/expiration'; import { hasExpired as hasExpiredSelector } from '../selectors/expiration';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { backupsService } from '../../services/backups';
import { InstallScreen } from '../../components/InstallScreen'; import { InstallScreen } from '../../components/InstallScreen';
import { WidthBreakpoint } from '../../components/_util'; import { WidthBreakpoint } from '../../components/_util';
import { InstallScreenStep } from '../../types/InstallScreen'; import { InstallScreenStep } from '../../types/InstallScreen';
@ -27,6 +29,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const installerState = useSelector(getInstallerState); const installerState = useSelector(getInstallerState);
const updates = useSelector(getUpdatesState); const updates = useSelector(getUpdatesState);
const { openInbox } = useAppActions();
const { startInstaller, finishInstall } = useInstallerActions(); const { startInstaller, finishInstall } = useInstallerActions();
const { startUpdate } = useUpdatesActions(); const { startUpdate } = useUpdatesActions();
const hasExpired = useSelector(hasExpiredSelector); const hasExpired = useSelector(hasExpiredSelector);
@ -43,6 +46,13 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
} }
}, [backupFile, deviceName, finishInstall]); }, [backupFile, deviceName, finishInstall]);
const onCancelBackupImport = useCallback((): void => {
backupsService.cancelDownload();
if (installerState.step === InstallScreenStep.BackupImport) {
openInbox();
}
}, [installerState.step, openInbox]);
const suggestedDeviceName = const suggestedDeviceName =
installerState.step === InstallScreenStep.ChoosingDeviceName installerState.step === InstallScreenStep.ChoosingDeviceName
? installerState.deviceName ? installerState.deviceName
@ -100,6 +110,7 @@ export const SmartInstallScreen = memo(function SmartInstallScreen() {
i18n, i18n,
currentBytes: installerState.currentBytes, currentBytes: installerState.currentBytes,
totalBytes: installerState.totalBytes, totalBytes: installerState.totalBytes,
onCancel: onCancelBackupImport,
}, },
}; };
break; break;

View File

@ -1182,6 +1182,7 @@ export type GetBackupStreamOptionsType = Readonly<{
headers: Record<string, string>; headers: Record<string, string>;
downloadOffset: number; downloadOffset: number;
onProgress: (currentBytes: number, totalBytes: number) => void; onProgress: (currentBytes: number, totalBytes: number) => void;
abortSignal?: AbortSignal;
}>; }>;
export const getBackupInfoResponseSchema = z.object({ export const getBackupInfoResponseSchema = z.object({
@ -2833,6 +2834,7 @@ export function initialize({
backupName, backupName,
downloadOffset, downloadOffset,
onProgress, onProgress,
abortSignal,
}: GetBackupStreamOptionsType): Promise<Readable> { }: GetBackupStreamOptionsType): Promise<Readable> {
return _getAttachment({ return _getAttachment({
cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`, cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`,
@ -2842,6 +2844,7 @@ export function initialize({
options: { options: {
downloadOffset, downloadOffset,
onProgress, onProgress,
abortSignal,
}, },
}); });
} }
@ -3583,6 +3586,7 @@ export function initialize({
timeout?: number; timeout?: number;
downloadOffset?: number; downloadOffset?: number;
onProgress?: (currentBytes: number, totalBytes: number) => void; onProgress?: (currentBytes: number, totalBytes: number) => void;
abortSignal?: AbortSignal;
}; };
}): Promise<Readable> { }): Promise<Readable> {
const abortController = new AbortController(); const abortController = new AbortController();
@ -3594,6 +3598,8 @@ export function initialize({
abortController.abort(); abortController.abort();
}; };
options?.abortSignal?.addEventListener('abort', cancelRequest);
registerInflightRequest(cancelRequest); registerInflightRequest(cancelRequest);
let totalBytes = 0; let totalBytes = 0;