Pause, cancel & resume backup media download

This commit is contained in:
trevor-signal 2024-09-16 15:38:12 -04:00 committed by GitHub
parent 65539b1419
commit 028a3f3ef0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 958 additions and 141 deletions

View file

@ -0,0 +1,41 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { ConfirmationDialog } from './ConfirmationDialog';
import type { LocalizerType } from '../types/I18N';
export function BackupMediaDownloadCancelConfirmationDialog({
i18n,
handleConfirmCancel,
handleDialogClose,
}: {
i18n: LocalizerType;
handleConfirmCancel: VoidFunction;
handleDialogClose: VoidFunction;
}): JSX.Element | null {
return (
<ConfirmationDialog
moduleClassName="BackupMediaDownloadCancelConfirmation"
dialogName="BackupMediaDownloadCancelConfirmation"
cancelText={i18n(
'icu:BackupMediaDownloadCancelConfirmation__button-continue'
)}
actions={[
{
text: i18n(
'icu:BackupMediaDownloadCancelConfirmation__button-confirm-cancel'
),
action: handleConfirmCancel,
style: 'negative',
},
]}
i18n={i18n}
onClose={handleDialogClose}
title={i18n('icu:BackupMediaDownloadCancelConfirmation__title')}
>
{i18n('icu:BackupMediaDownloadCancelConfirmation__description')}
</ConfirmationDialog>
);
}

View file

@ -2,26 +2,65 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { type ComponentProps } from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import type { Meta } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
import { BackupMediaDownloadProgressBanner } from './BackupMediaDownloadProgress';
import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress';
import { KIBIBYTE } from '../types/AttachmentSize';
const i18n = setupI18n('en', enMessages);
type PropsType = ComponentProps<typeof BackupMediaDownloadProgressBanner>;
type PropsType = ComponentProps<typeof BackupMediaDownloadProgress>;
export default {
title: 'Components/BackupMediaDownloadProgress',
args: {
isPaused: false,
downloadedBytes: 600 * KIBIBYTE,
totalBytes: 1000 * KIBIBYTE,
handleClose: action('handleClose'),
handlePause: action('handlePause'),
handleResume: action('handleResume'),
handleCancel: action('handleCancel'),
i18n,
},
} satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = (args: PropsType) => (
<BackupMediaDownloadProgressBanner {...args} i18n={i18n} />
);
export function InProgress(args: PropsType): JSX.Element {
return <BackupMediaDownloadProgress {...args} />;
}
export const InProgress = Template.bind({});
InProgress.args = {
downloadedBytes: 92048023,
totalBytes: 1024102532,
};
export function Increasing(args: PropsType): JSX.Element {
return (
<BackupMediaDownloadProgress
{...args}
{...useIncreasingFractionComplete()}
/>
);
}
export function Paused(args: PropsType): JSX.Element {
return <BackupMediaDownloadProgress {...args} isPaused />;
}
export function Complete(args: PropsType): JSX.Element {
return (
<BackupMediaDownloadProgress {...args} downloadedBytes={args.totalBytes} />
);
}
function useIncreasingFractionComplete() {
const [fractionComplete, setFractionComplete] = React.useState(0);
React.useEffect(() => {
if (fractionComplete >= 1) {
return;
}
const timeout = setTimeout(() => {
setFractionComplete(cur => Math.min(1, cur + 0.1));
}, 300);
return () => clearTimeout(timeout);
}, [fractionComplete]);
return { downloadedBytes: 1e10 * fractionComplete, totalBytes: 1e10 };
}

View file

@ -1,48 +1,160 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useState } from 'react';
import type { LocalizerType } from '../types/Util';
import { formatFileSize } from '../util/formatFileSize';
import { ProgressBar } from './ProgressBar';
import { roundFractionForProgressBar } from '../util/numbers';
import { ProgressCircle } from './ProgressCircle';
import { ContextMenu } from './ContextMenu';
import { BackupMediaDownloadCancelConfirmationDialog } from './BackupMediaDownloadCancelConfirmationDialog';
export type PropsType = Readonly<{
i18n: LocalizerType;
downloadedBytes: number;
totalBytes: number;
isPaused: boolean;
handleCancel: VoidFunction;
handleClose: VoidFunction;
handleResume: VoidFunction;
handlePause: VoidFunction;
}>;
export function BackupMediaDownloadProgressBanner({
export function BackupMediaDownloadProgress({
i18n,
downloadedBytes,
totalBytes,
isPaused,
handleCancel: handleConfirmedCancel,
handleClose,
handleResume,
handlePause,
}: PropsType): JSX.Element | null {
const [isShowingCancelConfirmation, setIsShowingCancelConfirmation] =
useState(false);
if (totalBytes === 0) {
return null;
}
const fractionComplete = Math.max(
0,
Math.min(1, downloadedBytes / totalBytes)
function handleCancel() {
setIsShowingCancelConfirmation(true);
}
const fractionComplete = roundFractionForProgressBar(
downloadedBytes / totalBytes
);
let content: JSX.Element | undefined;
let icon: JSX.Element | undefined;
let actionButton: JSX.Element | undefined;
if (fractionComplete === 1) {
icon = <div className="BackupMediaDownloadProgress__icon--complete" />;
content = (
<>
<div className="BackupMediaDownloadProgress__title">
{i18n('icu:BackupMediaDownloadProgress__title-complete')}
</div>
<div className="BackupMediaDownloadProgress__progressbar-hint">
{formatFileSize(downloadedBytes)}
</div>
</>
);
actionButton = (
<button
type="button"
onClick={handleClose}
className="BackupMediaDownloadProgress__button-close"
aria-label={i18n('icu:close')}
/>
);
} else {
icon = <ProgressCircle fractionComplete={fractionComplete} />;
if (isPaused) {
content = (
<>
<div className="BackupMediaDownloadProgress__title">
{i18n('icu:BackupMediaDownloadProgress__title-paused')}
</div>
<button
type="button"
onClick={handleResume}
className="BackupMediaDownloadProgress__button"
aria-label={i18n('icu:BackupMediaDownloadProgress__button-resume')}
>
{i18n('icu:BackupMediaDownloadProgress__button-resume')}
</button>
</>
);
} else {
content = (
<>
<div className="BackupMediaDownloadProgress__title">
{i18n('icu:BackupMediaDownloadProgress__title-in-progress')}
</div>
<div className="BackupMediaDownloadProgress__progressbar-hint">
{i18n('icu:BackupMediaDownloadProgress__progressbar-hint', {
currentSize: formatFileSize(downloadedBytes),
totalSize: formatFileSize(totalBytes),
})}
</div>
</>
);
}
actionButton = (
<ContextMenu
i18n={i18n}
menuOptions={[
isPaused
? {
label: i18n('icu:BackupMediaDownloadProgress__button-resume'),
onClick: handleResume,
}
: {
label: i18n('icu:BackupMediaDownloadProgress__button-pause'),
onClick: handlePause,
},
{
label: i18n('icu:BackupMediaDownloadProgress__button-cancel'),
onClick: handleCancel,
},
]}
moduleClassName="Stories__pane__settings"
popperOptions={{
placement: 'bottom-end',
strategy: 'absolute',
}}
portalToRoot
>
{({ onClick }) => {
return (
<button
type="button"
onClick={onClick}
className="BackupMediaDownloadProgress__button-more"
aria-label={i18n('icu:BackupMediaDownloadProgress__button-more')}
/>
);
}}
</ContextMenu>
);
}
return (
<div className="BackupMediaDownloadProgressBanner">
<div className="BackupMediaDownloadProgressBanner__icon" />
<div className="BackupMediaDownloadProgressBanner__content">
<div className="BackupMediaDownloadProgressBanner__title">
{i18n('icu:BackupMediaDownloadProgress__title')}
</div>
<ProgressBar fractionComplete={fractionComplete} />
<div className="BackupMediaDownloadProgressBanner__progressbar-hint">
{i18n('icu:BackupMediaDownloadProgress__progressbar-hint', {
currentSize: formatFileSize(downloadedBytes),
totalSize: formatFileSize(totalBytes),
fractionComplete,
})}
</div>
</div>
<div className="BackupMediaDownloadProgress">
{icon}
<div className="BackupMediaDownloadProgress__content">{content}</div>
{actionButton}
{isShowingCancelConfirmation ? (
<BackupMediaDownloadCancelConfirmationDialog
i18n={i18n}
handleDialogClose={() => setIsShowingCancelConfirmation(false)}
handleConfirmCancel={handleConfirmedCancel}
/>
) : null}
</div>
);
}

View file

@ -137,7 +137,12 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
unreadMentionsCount: 0,
markedUnread: false,
},
backupMediaDownloadProgress: { totalBytes: 0, downloadedBytes: 0 },
backupMediaDownloadProgress: {
downloadBannerDismissed: false,
isPaused: false,
totalBytes: 0,
downloadedBytes: 0,
},
clearConversationSearch: action('clearConversationSearch'),
clearGroupCreationError: action('clearGroupCreationError'),
clearSearch: action('clearSearch'),
@ -147,6 +152,12 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
composeReplaceAvatar: action('composeReplaceAvatar'),
composeSaveAvatarToDisk: action('composeSaveAvatarToDisk'),
createGroup: action('createGroup'),
dismissBackupMediaDownloadBanner: action(
'dismissBackupMediaDownloadBanner'
),
pauseBackupMediaDownload: action('pauseBackupMediaDownload'),
resumeBackupMediaDownload: action('resumeBackupMediaDownload'),
cancelBackupMediaDownload: action('cancelBackupMediaDownload'),
endConversationSearch: action('endConversationSearch'),
endSearch: action('endSearch'),
getPreferredBadge: () => undefined,

View file

@ -1,13 +1,7 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, {
useEffect,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import React, { useEffect, useCallback, useMemo, useRef } from 'react';
import classNames from 'classnames';
import { isNumber } from 'lodash';
@ -62,10 +56,15 @@ import {
import { ContextMenu } from './ContextMenu';
import { EditState as ProfileEditorEditState } from './ProfileEditor';
import type { UnreadStats } from '../util/countUnreadStats';
import { BackupMediaDownloadProgressBanner } from './BackupMediaDownloadProgress';
import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress';
export type PropsType = {
backupMediaDownloadProgress: { totalBytes: number; downloadedBytes: number };
backupMediaDownloadProgress: {
totalBytes: number;
downloadedBytes: number;
isPaused: boolean;
downloadBannerDismissed: boolean;
};
otherTabsUnreadStats: UnreadStats;
hasExpiredDialog: boolean;
hasFailedStorySends: boolean;
@ -128,6 +127,10 @@ export type PropsType = {
composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => void;
dismissBackupMediaDownloadBanner: () => void;
pauseBackupMediaDownload: () => void;
resumeBackupMediaDownload: () => void;
cancelBackupMediaDownload: () => void;
endConversationSearch: () => void;
endSearch: () => void;
navTabsCollapsed: boolean;
@ -184,6 +187,7 @@ export function LeftPane({
backupMediaDownloadProgress,
otherTabsUnreadStats,
blockConversation,
cancelBackupMediaDownload,
challengeStatus,
clearConversationSearch,
clearGroupCreationError,
@ -214,6 +218,7 @@ export function LeftPane({
onOutgoingVideoCallInConversation,
openUsernameReservationModal,
pauseBackupMediaDownload,
preferredWidthFromStorage,
preloadConversation,
removeConversation,
@ -226,6 +231,7 @@ export function LeftPane({
renderRelinkDialog,
renderUpdateDialog,
renderToastManager,
resumeBackupMediaDownload,
savePreferredLeftPaneWidth,
searchInConversation,
selectedConversationId,
@ -256,6 +262,7 @@ export function LeftPane({
usernameCorrupted,
usernameLinkCorrupted,
updateSearchTerm,
dismissBackupMediaDownloadBanner,
}: PropsType): JSX.Element {
const previousModeSpecificProps = usePrevious(
modeSpecificProps,
@ -645,27 +652,25 @@ export function LeftPane({
// We'll show the backup media download progress banner if the download is currently or
// was ongoing at some point during the lifecycle of this component
const [
hasMediaBackupDownloadBeenOngoing,
setHasMediaBackupDownloadBeenOngoing,
] = useState(false);
const isMediaBackupDownloadOngoing =
const isMediaBackupDownloadIncomplete =
backupMediaDownloadProgress?.totalBytes > 0 &&
backupMediaDownloadProgress.downloadedBytes <
backupMediaDownloadProgress.totalBytes;
if (isMediaBackupDownloadOngoing && !hasMediaBackupDownloadBeenOngoing) {
setHasMediaBackupDownloadBeenOngoing(true);
}
if (hasMediaBackupDownloadBeenOngoing) {
if (
isMediaBackupDownloadIncomplete &&
!backupMediaDownloadProgress.downloadBannerDismissed
) {
dialogs.push({
key: 'backupMediaDownload',
dialog: (
<BackupMediaDownloadProgressBanner
<BackupMediaDownloadProgress
i18n={i18n}
{...backupMediaDownloadProgress}
handleClose={dismissBackupMediaDownloadBanner}
handlePause={pauseBackupMediaDownload}
handleResume={resumeBackupMediaDownload}
handleCancel={cancelBackupMediaDownload}
/>
),
});

View file

@ -0,0 +1,53 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { ProgressBar } from './ProgressBar';
import type { ComponentMeta } from '../storybook/types';
type Props = React.ComponentProps<typeof ProgressBar>;
export default {
title: 'Components/ProgressBar',
component: ProgressBar,
args: {
fractionComplete: 0,
isRTL: false,
},
} satisfies ComponentMeta<Props>;
export function Zero(args: Props): JSX.Element {
return <ProgressBar {...args} />;
}
export function Thirty(args: Props): JSX.Element {
return <ProgressBar {...args} fractionComplete={0.3} />;
}
export function Done(args: Props): JSX.Element {
return <ProgressBar {...args} fractionComplete={1} />;
}
export function Increasing(args: Props): JSX.Element {
const fractionComplete = useIncreasingFractionComplete();
return <ProgressBar {...args} fractionComplete={fractionComplete} />;
}
export function RTLIncreasing(args: Props): JSX.Element {
const fractionComplete = useIncreasingFractionComplete();
return <ProgressBar {...args} fractionComplete={fractionComplete} isRTL />;
}
function useIncreasingFractionComplete() {
const [fractionComplete, setFractionComplete] = React.useState(0);
React.useEffect(() => {
if (fractionComplete >= 1) {
return;
}
const timeout = setTimeout(() => {
setFractionComplete(cur => Math.min(1, cur + 0.1));
}, 300);
return () => clearTimeout(timeout);
}, [fractionComplete]);
return fractionComplete;
}

View file

@ -5,15 +5,17 @@ import React from 'react';
export function ProgressBar({
fractionComplete,
isRTL,
}: {
fractionComplete: number;
isRTL: boolean;
}): JSX.Element {
return (
<div className="ProgressBar">
<div
className="ProgressBar__fill"
style={{
marginInlineEnd: `${(1 - fractionComplete) * 100}%`,
transform: `translateX(${(isRTL ? -1 : 1) * (fractionComplete - 1) * 100}%)`,
}}
/>
</div>

View file

@ -0,0 +1,44 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { ProgressCircle } from './ProgressCircle';
import type { ComponentMeta } from '../storybook/types';
type Props = React.ComponentProps<typeof ProgressCircle>;
export default {
title: 'Components/ProgressCircle',
component: ProgressCircle,
args: { fractionComplete: 0, width: undefined, strokeWidth: undefined },
} satisfies ComponentMeta<Props>;
export function Zero(args: Props): JSX.Element {
return <ProgressCircle {...args} />;
}
export function Thirty(args: Props): JSX.Element {
return <ProgressCircle {...args} fractionComplete={0.3} />;
}
export function Done(args: Props): JSX.Element {
return <ProgressCircle {...args} fractionComplete={1} />;
}
export function Increasing(args: Props): JSX.Element {
const fractionComplete = useIncreasingFractionComplete();
return <ProgressCircle {...args} fractionComplete={fractionComplete} />;
}
function useIncreasingFractionComplete() {
const [fractionComplete, setFractionComplete] = React.useState(0);
React.useEffect(() => {
if (fractionComplete >= 1) {
return;
}
const timeout = setTimeout(() => {
setFractionComplete(cur => Math.min(1, cur + 0.1));
}, 300);
return () => clearTimeout(timeout);
}, [fractionComplete]);
return fractionComplete;
}

View file

@ -0,0 +1,41 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
export function ProgressCircle({
fractionComplete,
width = 24,
strokeWidth = 3,
}: {
fractionComplete: number;
width?: number;
strokeWidth?: number;
}): JSX.Element {
const radius = width / 2 - strokeWidth / 2;
const circumference = radius * 2 * Math.PI;
return (
<svg className="ProgressCircle" width={width} height={width}>
<circle
className="ProgressCircle__background"
strokeWidth={strokeWidth}
r={radius}
cx="50%"
cy="50%"
/>
<circle
className="ProgressCircle__fill"
r={radius}
cx="50%"
cy="50%"
strokeWidth={strokeWidth}
// setting the strokeDashArray to be the circumference of the ring means each dash
// will cover the whole ring
strokeDasharray={circumference}
// offsetting the dash as a fraction of the circumference allows showing the
// progress
strokeDashoffset={(1 - fractionComplete) * circumference}
/>
</svg>
);
}

View file

@ -9,6 +9,7 @@ import { TitlebarDragArea } from '../TitlebarDragArea';
import { ProgressBar } from '../ProgressBar';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
import { roundFractionForProgressBar } from '../../util/numbers';
// We can't always use destructuring assignment because of the complexity of this props
// type.
@ -41,29 +42,26 @@ export function InstallScreenBackupImportStep({
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;
} else if (percentage >= 0.99 && percentage < 1) {
percentage = 0.99;
} else {
percentage = Math.round(percentage * 100) / 100;
}
const fractionComplete = roundFractionForProgressBar(
currentBytes / totalBytes
);
progress = (
<>
<ProgressBar fractionComplete={percentage} />
<ProgressBar
fractionComplete={fractionComplete}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
<div className="InstallScreenBackupImportStep__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint', {
currentSize: formatFileSize(currentBytes),
totalSize: formatFileSize(totalBytes),
fractionComplete: percentage,
fractionComplete,
})}
</div>
</>
@ -71,7 +69,10 @@ export function InstallScreenBackupImportStep({
} else {
progress = (
<>
<ProgressBar fractionComplete={0} />
<ProgressBar
fractionComplete={0}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
<div className="InstallScreenBackupImportStep__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint--preparing')}
</div>