Backup import cancel UI
Co-authored-by: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com>
This commit is contained in:
parent
7700953777
commit
d2c02b1246
9 changed files with 183 additions and 25 deletions
|
@ -4687,10 +4687,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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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<PropsType> = (args: PropsType) => (
|
||||
<InstallScreenBackupImportStep {...args} i18n={i18n} />
|
||||
<InstallScreenBackupImportStep
|
||||
{...args}
|
||||
i18n={i18n}
|
||||
onCancel={action('onCancel')}
|
||||
/>
|
||||
);
|
||||
|
||||
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,
|
||||
};
|
||||
|
|
|
@ -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 = (
|
||||
<>
|
||||
<ProgressBar fractionComplete={percentage} />
|
||||
<div className="BackupImportScreen__progressbar-hint">
|
||||
<div className="InstallScreenBackupImportStep__progressbar-hint">
|
||||
{i18n('icu:BackupImportScreen__progressbar-hint', {
|
||||
currentSize: formatFileSize(currentBytes),
|
||||
totalSize: formatFileSize(totalBytes),
|
||||
|
@ -51,31 +72,60 @@ export function InstallScreenBackupImportStep({
|
|||
progress = (
|
||||
<>
|
||||
<ProgressBar fractionComplete={0} />
|
||||
<div className="BackupImportScreen__progressbar-hint BackupImportScreen__progressbar-hint--hidden">
|
||||
{i18n('icu:BackupImportScreen__progressbar-hint', {
|
||||
currentSize: '',
|
||||
totalSize: '',
|
||||
fractionComplete: 0,
|
||||
})}
|
||||
<div className="InstallScreenBackupImportStep__progressbar-hint">
|
||||
{i18n('icu:BackupImportScreen__progressbar-hint--preparing')}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="BackupImportScreen">
|
||||
<div className="InstallScreenBackupImportStep">
|
||||
<TitlebarDragArea />
|
||||
|
||||
<InstallScreenSignalLogo />
|
||||
|
||||
<div className="BackupImportScreen__content">
|
||||
<h3 className="BackupImportScreen__title">
|
||||
<div className="InstallScreenBackupImportStep__content">
|
||||
<h3 className="InstallScreenBackupImportStep__title">
|
||||
{i18n('icu:BackupImportScreen__title')}
|
||||
</h3>
|
||||
{progress}
|
||||
<div className="BackupImportScreen__description">
|
||||
<div className="InstallScreenBackupImportStep__description">
|
||||
{i18n('icu:BackupImportScreen__description')}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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<Readable> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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<DownloadOptionsType, 'downloadOffset'>
|
||||
): Promise<boolean> {
|
||||
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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -1175,6 +1175,7 @@ export type GetBackupStreamOptionsType = Readonly<{
|
|||
headers: Record<string, string>;
|
||||
downloadOffset: number;
|
||||
onProgress: (currentBytes: number, totalBytes: number) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
}>;
|
||||
|
||||
export const getBackupInfoResponseSchema = z.object({
|
||||
|
@ -2815,6 +2816,7 @@ export function initialize({
|
|||
backupName,
|
||||
downloadOffset,
|
||||
onProgress,
|
||||
abortSignal,
|
||||
}: GetBackupStreamOptionsType): Promise<Readable> {
|
||||
return _getAttachment({
|
||||
cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`,
|
||||
|
@ -2824,6 +2826,7 @@ export function initialize({
|
|||
options: {
|
||||
downloadOffset,
|
||||
onProgress,
|
||||
abortSignal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
@ -3550,6 +3553,7 @@ export function initialize({
|
|||
timeout?: number;
|
||||
downloadOffset?: number;
|
||||
onProgress?: (currentBytes: number, totalBytes: number) => void;
|
||||
abortSignal?: AbortSignal;
|
||||
};
|
||||
}): Promise<Readable> {
|
||||
const abortController = new AbortController();
|
||||
|
@ -3561,6 +3565,8 @@ export function initialize({
|
|||
abortController.abort();
|
||||
};
|
||||
|
||||
options?.abortSignal?.addEventListener('abort', cancelRequest);
|
||||
|
||||
registerInflightRequest(cancelRequest);
|
||||
|
||||
let totalBytes = 0;
|
||||
|
|
Loading…
Reference in a new issue