Pause, cancel & resume backup media download

Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-09-16 16:44:56 -05:00 committed by GitHub
parent ced1962879
commit ec0d64de6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
28 changed files with 958 additions and 141 deletions

View file

@ -4723,14 +4723,54 @@
"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-in-progress": {
"messageformat": "Restoring media",
"description": "Label above a progress bar showing media (attachment) download progress after restoring from backup"
"description": "Label next to a progress bar showing active media (attachment) download progress after restoring from backup"
},
"icu:BackupMediaDownloadProgress__title-paused": {
"messageformat": "Restore paused",
"description": "Label indicating media (attachment) download progress has been paused (due to user interaction)"
},
"icu:BackupMediaDownloadProgress__button-pause": {
"messageformat": "Pause transfer",
"description": "Text for button to pause media (attachment) download after backup impor"
},
"icu:BackupMediaDownloadProgress__button-resume": {
"messageformat": "Resume transfer",
"description": "Text for button to resume media (attachment) download after backup import"
},
"icu:BackupMediaDownloadProgress__button-cancel": {
"messageformat": "Cancel transfer",
"description": "Text for button to cancel (pause) media (attachment) download after backup import"
},
"icu:BackupMediaDownloadProgress__button-more": {
"messageformat": "More options",
"description": "Alt text for button that opens menu to allow user to select to pause or cancel media download"
},
"icu:BackupMediaDownloadProgress__title-complete": {
"messageformat": "Restore complete",
"description": "Label above a progress bar showing active media (attachment) download progress after restoring from backup"
},
"icu:BackupMediaDownloadProgress__progressbar-hint": {
"messageformat": "{currentSize} of {totalSize} ({fractionComplete, number, percent})",
"messageformat": "{currentSize} of {totalSize}",
"description": "Hint under the progressbar showing media (attachment) download progress after restoring from backup"
},
"icu:BackupMediaDownloadCancelConfirmation__title": {
"messageformat": "Cancel media transfer?",
"description": "Text for button to cancel (pause) media (attachment) download after backup import"
},
"icu:BackupMediaDownloadCancelConfirmation__description": {
"messageformat": "Your messages and media have not completed restoring. If you choose to cancel, you can transfer again from Settings.",
"description": "Text for button to cancel (pause) media (attachment) download after backup import"
},
"icu:BackupMediaDownloadCancelConfirmation__button-continue": {
"messageformat": "Continue transfer",
"description": "Text for button to close confirmation dialog and continue media (attachment) download"
},
"icu:BackupMediaDownloadCancelConfirmation__button-confirm-cancel": {
"messageformat": "Cancel transfer",
"description": "Text for button to confirm cancellation of media (attachment) download"
},
"icu:CompositionArea--expand": {
"messageformat": "Expand",
"description": "Aria label for expanding composition area"

View file

@ -1,16 +1,20 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.BackupMediaDownloadProgressBanner {
@include font-body-2;
.BackupMediaDownloadProgress {
border-radius: 10px;
display: flex;
gap: 12px;
padding: 12px;
align-items: center;
gap: 10px;
padding: 11px;
padding-inline-end: 16px;
margin-inline: 10px;
user-select: none;
position: relative;
&__title {
@include font-body-2-bold;
}
@include light-theme {
background-color: $color-white;
@ -22,69 +26,84 @@
}
}
.BackupMediaDownloadProgressBanner__icon {
background: rgba($color-ultramarine, 0.2);
width: 30px;
height: 30px;
padding: 6px;
border-radius: 50%;
@include dark-theme {
background: $color-gray-60;
}
.BackupMediaDownloadProgress__icon--complete {
&::after {
content: '';
display: inline-block;
width: 18px;
height: 18px;
display: block;
width: 24px;
height: 24px;
@include light-theme {
@include color-svg(
'../images/icons/v3/backup/backup-bold.svg',
'../images/icons/v3/check/check-circle.svg',
$color-ultramarine
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/backup/backup-bold.svg',
$color-ultramarine-pale
'../images/icons/v3/check/check-circle.svg',
$color-ultramarine-light
);
}
}
}
button.BackupMediaDownloadProgress__button {
@include button-reset;
@include font-subtitle-bold;
@include light-theme {
color: $color-ultramarine;
}
@include dark-theme {
color: $color-ultramarine-light;
}
}
.BackupMediaDownloadProgressBanner__content {
button.BackupMediaDownloadProgress__button-more {
position: absolute;
inset-inline-end: 14px;
inset-block-start: 10px;
@include button-reset;
&::after {
content: '';
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v3/more/more.svg', $color-gray-20);
}
}
}
button.BackupMediaDownloadProgress__button-close {
position: absolute;
inset-inline-end: 14px;
inset-block-start: 10px;
@include button-reset;
&::after {
content: '';
display: block;
width: 20px;
height: 20px;
@include light-theme {
@include color-svg('../images/icons/v3/x/x.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v3/x/x.svg', $color-gray-20);
}
}
}
.BackupMediaDownloadProgress__content {
display: flex;
flex-direction: column;
flex: 1;
gap: 7px;
justify-content: center;
gap: 2px;
min-height: 36px;
}
.BackupMediaDownloadProgressBanner .Progressbar {
overflow: hidden;
background: rgba($color-ultramarine, 0.2);
height: 5px;
border-radius: 2px;
}
.BackupMediaDownloadProgressBanner__progressbar__fill {
background-color: $color-ultramarine;
border-radius: 2px;
display: block;
height: 100%;
width: 100%;
&:dir(ltr) {
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
transform: translateX(-100%);
}
&:dir(rtl) {
/* stylelint-disable-next-line declaration-property-value-disallowed-list */
transform: translateX(100%);
}
transition: transform 500ms ease-out;
}
.BackupMediaDownloadProgressBanner__progressbar-hint {
@include font-caption;
.BackupMediaDownloadProgress__progressbar-hint {
@include font-subtitle;
@include light-theme {
color: rgba($color-gray-60, 0.8);
@ -94,3 +113,7 @@
color: $color-gray-25;
}
}
.BackupMediaDownloadCancelConfirmation {
min-width: 440px;
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
.ProgressBar {
position: relative;
overflow: hidden;
background: rgba($color-ultramarine, 0.2);
height: 5px;
@ -9,9 +10,11 @@
}
.ProgressBar__fill {
position: absolute;
background-color: $color-ultramarine;
border-radius: 2px;
display: block;
height: 100%;
transition: margin 500ms ease-out;
width: 100%;
transition: transform 500ms ease-out;
}

View file

@ -0,0 +1,25 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.ProgressCircle {
fill: none;
transform: rotate(-90deg);
.ProgressCircle__fill,
.ProgressCircle__background {
fill: none;
}
.ProgressCircle__background {
stroke: $color-gray-20;
@include dark-theme() {
stroke: $color-gray-60;
}
}
.ProgressCircle__fill {
stroke: $color-ultramarine;
stroke-linecap: round;
transition: stroke-dashoffset 500ms ease-out;
}
}

View file

@ -140,6 +140,7 @@
@import './components/Preferences.scss';
@import './components/ProfileEditor.scss';
@import './components/ProgressBar.scss';
@import './components/ProgressCircle.scss';
@import './components/Quote.scss';
@import './components/ReactionPickerPicker.scss';
@import './components/RecordingComposer.scss';

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>

View file

@ -42,7 +42,7 @@ import {
isVideoTypeSupported,
} from '../util/GoogleChrome';
import type { MIMEType } from '../types/MIME';
import type { AttachmentDownloadSource } from '../sql/Interface';
import { AttachmentDownloadSource } from '../sql/Interface';
import { drop } from '../util/drop';
import { getAttachmentCiphertextLength } from '../AttachmentCrypto';
@ -84,6 +84,7 @@ type AttachmentDownloadManagerParamsType = Omit<
getNextJobs: (options: {
limit: number;
prioritizeMessageIds?: Array<string>;
sources?: Array<AttachmentDownloadSource>;
timestamp?: number;
}) => Promise<Array<AttachmentDownloadJobType>>;
runDownloadAttachmentJob: (args: {
@ -139,6 +140,9 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
return params.getNextJobs({
limit,
prioritizeMessageIds: [...this.visibleTimelineMessages],
sources: window.storage.get('backupMediaDownloadPaused')
? [AttachmentDownloadSource.STANDARD]
: undefined,
timestamp: Date.now(),
});
},
@ -274,10 +278,10 @@ async function runDownloadAttachmentJob({
if (job.attachment.backupLocator?.mediaName) {
const currentDownloadedSize =
window.storage.get('backupAttachmentsSuccessfullyDownloadedSize') ?? 0;
window.storage.get('backupMediaDownloadCompletedBytes') ?? 0;
drop(
window.storage.put(
'backupAttachmentsSuccessfullyDownloadedSize',
'backupMediaDownloadCompletedBytes',
currentDownloadedSize + job.ciphertextSize
)
);

View file

@ -302,8 +302,8 @@ export class BackupImportStream extends Writable {
public static async create(): Promise<BackupImportStream> {
await AttachmentDownloadManager.stop();
await DataWriter.removeAllBackupAttachmentDownloadJobs();
await window.storage.put('backupAttachmentsSuccessfullyDownloadedSize', 0);
await window.storage.put('backupAttachmentsTotalSizeToDownload', 0);
await window.storage.put('backupMediaDownloadCompletedBytes', 0);
await window.storage.put('backupMediaDownloadTotalBytes', 0);
return new BackupImportStream();
}
@ -399,7 +399,7 @@ export class BackupImportStream extends Writable {
reinitializeRedux(getParametersForRedux());
await window.storage.put(
'backupAttachmentsTotalSizeToDownload',
'backupMediaDownloadTotalBytes',
await DataReader.getSizeOfPendingBackupAttachmentDownloadJobs()
);

View file

@ -852,6 +852,7 @@ type WritableInterface = {
getNextAttachmentDownloadJobs: (options: {
limit: number;
prioritizeMessageIds?: Array<string>;
sources?: Array<AttachmentDownloadSource>;
timestamp?: number;
}) => Array<AttachmentDownloadJobType>;
saveAttachmentDownloadJob: (job: AttachmentDownloadJobType) => void;

View file

@ -4785,18 +4785,28 @@ function getNextAttachmentDownloadJobs(
db: WritableDB,
{
limit = 3,
sources,
prioritizeMessageIds,
timestamp = Date.now(),
maxLastAttemptForPrioritizedMessages,
}: {
limit: number;
prioritizeMessageIds?: Array<string>;
sources?: Array<AttachmentDownloadSource>;
timestamp?: number;
maxLastAttemptForPrioritizedMessages?: number;
}
): Array<AttachmentDownloadJobType> {
let priorityJobs = [];
const sourceWhereFragment = sources
? sqlFragment`
source IN (${sqlJoin(sources)})
`
: sqlFragment`
TRUE
`;
// First, try to get jobs for prioritized messages (e.g. those currently user-visible)
if (prioritizeMessageIds?.length) {
const [priorityQuery, priorityParams] = sql`
@ -4813,6 +4823,8 @@ function getNextAttachmentDownloadJobs(
})
AND
messageId IN (${sqlJoin(prioritizeMessageIds)})
AND
${sourceWhereFragment}
-- for priority messages, let's load them oldest first; this helps, e.g. for stories where we
-- want the oldest one first
ORDER BY receivedAt ASC
@ -4831,6 +4843,8 @@ function getNextAttachmentDownloadJobs(
active = 0
AND
(retryAfter is NULL OR retryAfter <= ${timestamp})
AND
${sourceWhereFragment}
ORDER BY receivedAt DESC
LIMIT ${numJobsRemaining}
`;

View file

@ -0,0 +1,29 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Database } from '@signalapp/better-sqlite3';
import type { LoggerType } from '../../types/Logging';
export const version = 1200;
export function updateToSchemaVersion1200(
currentVersion: number,
db: Database,
logger: LoggerType
): void {
if (currentVersion >= 1200) {
return;
}
db.transaction(() => {
// The standard getNextAttachmentDownloadJobs query uses active & source conditions,
// ordered by received_at
db.exec(`
CREATE INDEX attachment_downloads_active_source_receivedAt
ON attachment_downloads (
active, source, receivedAt
);
`);
db.pragma('user_version = 1200');
})();
logger.info('updateToSchemaVersion1200: success!');
}

View file

@ -95,10 +95,11 @@ import { updateToSchemaVersion1150 } from './1150-expire-timer-version';
import { updateToSchemaVersion1160 } from './1160-optimize-calls-unread-count';
import { updateToSchemaVersion1170 } from './1170-update-call-history-unread-index';
import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source';
import { updateToSchemaVersion1190 } from './1190-call-links-storage';
import {
updateToSchemaVersion1190,
updateToSchemaVersion1200,
version as MAX_VERSION,
} from './1190-call-links-storage';
} from './1200-attachment-download-source-index';
function updateToSchemaVersion1(
currentVersion: number,
@ -2062,6 +2063,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1170,
updateToSchemaVersion1180,
updateToSchemaVersion1190,
updateToSchemaVersion1200,
];
export class DBVersionFromFutureError extends Error {

View file

@ -251,8 +251,17 @@ export const getLocalDeleteWarningShown = createSelector(
export const getBackupMediaDownloadProgress = createSelector(
getItems,
(state: ItemsStateType): { totalBytes: number; downloadedBytes: number } => ({
totalBytes: state.backupAttachmentsTotalSizeToDownload ?? 0,
downloadedBytes: state.backupAttachmentsSuccessfullyDownloadedSize ?? 0,
(
state: ItemsStateType
): {
totalBytes: number;
downloadedBytes: number;
isPaused: boolean;
downloadBannerDismissed: boolean;
} => ({
totalBytes: state.backupMediaDownloadTotalBytes ?? 0,
downloadedBytes: state.backupMediaDownloadCompletedBytes ?? 0,
isPaused: state.backupMediaDownloadPaused ?? false,
downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false,
})
);

View file

@ -97,6 +97,12 @@ import { SmartToastManager } from './ToastManager';
import type { PropsType as SmartUnsupportedOSDialogPropsType } from './UnsupportedOSDialog';
import { SmartUnsupportedOSDialog } from './UnsupportedOSDialog';
import { SmartUpdateDialog } from './UpdateDialog';
import {
cancelBackupMediaDownload,
dismissBackupMediaDownloadBanner,
pauseBackupMediaDownload,
resumeBackupMediaDownload,
} from '../../util/backupMediaDownload';
function renderMessageSearchResult(id: string): JSX.Element {
return <SmartMessageSearchResult id={id} />;
@ -366,6 +372,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
<LeftPane
backupMediaDownloadProgress={backupMediaDownloadProgress}
blockConversation={blockConversation}
cancelBackupMediaDownload={cancelBackupMediaDownload}
challengeStatus={challengeStatus}
clearConversationSearch={clearConversationSearch}
clearGroupCreationError={clearGroupCreationError}
@ -377,6 +384,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
composeSaveAvatarToDisk={composeSaveAvatarToDisk}
crashReportCount={crashReportCount}
createGroup={createGroup}
dismissBackupMediaDownloadBanner={dismissBackupMediaDownloadBanner}
endConversationSearch={endConversationSearch}
endSearch={endSearch}
getPreferredBadge={getPreferredBadge}
@ -396,6 +404,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
openUsernameReservationModal={openUsernameReservationModal}
otherTabsUnreadStats={otherTabsUnreadStats}
pauseBackupMediaDownload={pauseBackupMediaDownload}
preferredWidthFromStorage={preferredWidthFromStorage}
preloadConversation={preloadConversation}
removeConversation={removeConversation}
@ -408,6 +417,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
renderToastManager={renderToastManager}
renderUnsupportedOSDialog={renderUnsupportedOSDialog}
renderUpdateDialog={renderUpdateDialog}
resumeBackupMediaDownload={resumeBackupMediaDownload}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
searchInConversation={searchInConversation}
selectedConversationId={selectedConversationId}

View file

@ -27,8 +27,10 @@ function composeJob({
messageId,
receivedAt,
attachmentOverrides,
jobOverrides,
}: Pick<NewAttachmentDownloadJobType, 'messageId' | 'receivedAt'> & {
attachmentOverrides?: Partial<AttachmentType>;
jobOverrides?: Partial<AttachmentDownloadJobType>;
}): AttachmentDownloadJobType {
const digest = `digestFor${messageId}`;
const size = 128;
@ -53,6 +55,7 @@ function composeJob({
digest: `digestFor${messageId}`,
...attachmentOverrides,
},
...jobOverrides,
};
}
@ -123,13 +126,19 @@ describe('AttachmentDownloadManager/JobManager', () => {
});
}
async function addJobs(
num: number
num: number,
jobOverrides?:
| Partial<AttachmentDownloadJobType>
| ((idx: number) => Partial<AttachmentDownloadJobType>)
): Promise<Array<AttachmentDownloadJobType>> {
const jobs = new Array(num)
.fill(null)
.map((_, idx) =>
composeJob({ messageId: `message-${idx}`, receivedAt: idx })
);
const jobs = new Array(num).fill(null).map((_, idx) =>
composeJob({
messageId: `message-${idx}`,
receivedAt: idx,
jobOverrides:
typeof jobOverrides === 'function' ? jobOverrides(idx) : jobOverrides,
})
);
for (const job of jobs) {
// eslint-disable-next-line no-await-in-loop
await addJob(job, AttachmentDownloadUrgency.STANDARD);
@ -392,6 +401,35 @@ describe('AttachmentDownloadManager/JobManager', () => {
// Ensure it's been removed
assert.isUndefined(await DataReader.getAttachmentDownloadJob(jobs[0]));
});
it('only selects backup_import jobs if the mediaDownload is not paused', async () => {
await window.storage.put('backupMediaDownloadPaused', true);
const jobs = await addJobs(6, idx => ({
source:
idx % 2 === 0
? AttachmentDownloadSource.BACKUP_IMPORT
: AttachmentDownloadSource.STANDARD,
}));
// make one of the backup job messages visible to test that code path as well
downloadManager?.updateVisibleTimelineMessages(['message-0', 'message-1']);
await downloadManager?.start();
await waitForJobToBeCompleted(jobs[3]);
assertRunJobCalledWith([jobs[1], jobs[5], jobs[3]]);
await advanceTime((downloadManager?.tickInterval ?? MINUTE) * 5);
assertRunJobCalledWith([jobs[1], jobs[5], jobs[3]]);
// resume backups
await window.storage.put('backupMediaDownloadPaused', false);
await advanceTime((downloadManager?.tickInterval ?? MINUTE) * 5);
assertRunJobCalledWith([
jobs[1],
jobs[5],
jobs[3],
jobs[0],
jobs[4],
jobs[2],
]);
});
});
describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {

View file

@ -0,0 +1,213 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { AttachmentDownloadSource, type WritableDB } from '../../sql/Interface';
import { objectToJSON, sql } from '../../sql/util';
import { createDB, updateToVersion } from './helpers';
import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
import { IMAGE_JPEG } from '../../types/MIME';
type UnflattenedAttachmentDownloadJobType = Omit<
AttachmentDownloadJobType,
'digest' | 'contentType' | 'size' | 'ciphertextSize'
>;
function createJob(
index: number,
overrides?: Partial<UnflattenedAttachmentDownloadJobType>
): UnflattenedAttachmentDownloadJobType {
return {
messageId: `message${index}`,
attachmentType: 'attachment',
attachment: {
digest: `digest${index}`,
contentType: IMAGE_JPEG,
size: 128,
},
receivedAt: 100 + index,
sentAt: 100 + index,
attempts: 0,
active: false,
retryAfter: null,
lastAttemptTimestamp: null,
source: AttachmentDownloadSource.STANDARD,
...overrides,
};
}
function insertJob(
db: WritableDB,
index: number,
overrides?: Partial<UnflattenedAttachmentDownloadJobType>
): void {
const job = createJob(index, overrides);
try {
db.prepare('INSERT INTO messages (id) VALUES ($id)').run({
id: job.messageId,
});
} catch (e) {
// pass; message has already been inserted
}
const [query, params] = sql`
INSERT INTO attachment_downloads
(
messageId,
attachmentType,
attachmentJson,
digest,
contentType,
size,
receivedAt,
sentAt,
active,
attempts,
retryAfter,
lastAttemptTimestamp,
source
)
VALUES
(
${job.messageId},
${job.attachmentType},
${objectToJSON(job.attachment)},
${job.attachment.digest},
${job.attachment.contentType},
${job.attachment.size},
${job.receivedAt},
${job.sentAt},
${job.active ? 1 : 0},
${job.attempts},
${job.retryAfter},
${job.lastAttemptTimestamp},
${job.source}
);
`;
db.prepare(query).run(params);
}
const NUM_STANDARD_JOBS = 100;
describe('SQL/updateToSchemaVersion1200', () => {
let db: WritableDB;
after(() => {
db.close();
});
before(() => {
db = createDB();
updateToVersion(db, 1200);
db.transaction(() => {
for (let i = 0; i < 10_000; i += 1) {
insertJob(db, i, {
source:
i < NUM_STANDARD_JOBS
? AttachmentDownloadSource.STANDARD
: AttachmentDownloadSource.BACKUP_IMPORT,
});
}
})();
});
it('uses correct index for standard query', () => {
const now = Date.now();
const [query, params] = sql`
SELECT * FROM attachment_downloads
WHERE
active = 0
AND
(retryAfter is NULL OR retryAfter <= ${now})
ORDER BY receivedAt DESC
LIMIT 3
`;
const details = db
.prepare(`EXPLAIN QUERY PLAN ${query}`)
.all(params)
.map(step => step.detail)
.join(', ');
assert.equal(
details,
'SEARCH attachment_downloads USING INDEX attachment_downloads_active_receivedAt (active=?)'
);
});
it('uses correct index for standard query with sources', () => {
const now = Date.now();
// query with sources (e.g. when backup-import is paused)
const [query, params] = sql`
SELECT * FROM attachment_downloads
WHERE
active IS 0
AND
source IN ('standard')
AND
(retryAfter is NULL OR retryAfter <= ${now})
ORDER BY receivedAt DESC
LIMIT 3
`;
const details = db
.prepare(`EXPLAIN QUERY PLAN ${query}`)
.all(params)
.map(step => step.detail)
.join(', ');
assert.equal(
details,
'SEARCH attachment_downloads USING INDEX attachment_downloads_active_source_receivedAt (active=? AND source=?)'
);
});
it('uses provided index for prioritized query with sources', () => {
// prioritize visible messages with sources (e.g. when backup-import is paused)
const [query, params] = sql`
SELECT * FROM attachment_downloads
INDEXED BY attachment_downloads_active_messageId
WHERE
active IS 0
AND
messageId IN ('message12', 'message101')
AND
(lastAttemptTimestamp is NULL OR lastAttemptTimestamp <= ${Date.now()})
AND
source IN ('standard')
ORDER BY receivedAt ASC
LIMIT 3
`;
const result = db.prepare(query).all(params);
assert.strictEqual(result.length, 1);
assert.deepStrictEqual(result[0].messageId, 'message12');
const details = db
.prepare(`EXPLAIN QUERY PLAN ${query}`)
.all(params)
.map(step => step.detail)
.join(', ');
assert.equal(
details,
'SEARCH attachment_downloads USING INDEX attachment_downloads_active_messageId (active=? AND messageId=?), USE TEMP B-TREE FOR ORDER BY'
);
});
it('uses existing index to remove all backup jobs ', () => {
// prioritize visible messages with sources (e.g. when backup-import is paused)
const [query, params] = sql`
DELETE FROM attachment_downloads
WHERE source = 'backup_import';
`;
const details = db
.prepare(`EXPLAIN QUERY PLAN ${query}`)
.all(params)
.map(step => step.detail)
.join(', ');
assert.equal(
details,
'SEARCH attachment_downloads USING COVERING INDEX attachment_downloads_source_ciphertextSize (source=?)'
);
db.prepare(query).run(params);
assert.equal(
db.prepare('SELECT * FROM attachment_downloads').all().length,
NUM_STANDARD_JOBS
);
});
});

View file

@ -141,8 +141,10 @@ export type StorageAccessType = {
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
backupCredentials: ReadonlyArray<BackupCredentialType>;
backupCredentialsLastRequestTime: number;
backupAttachmentsSuccessfullyDownloadedSize: number;
backupAttachmentsTotalSizeToDownload: number;
backupMediaDownloadTotalBytes: number;
backupMediaDownloadCompletedBytes: number;
backupMediaDownloadPaused: boolean;
backupMediaDownloadBannerDismissed: boolean;
setBackupSignatureKey: boolean;
lastReceivedAtCounter: number;
preferredReactionEmoji: ReadonlyArray<string>;

View file

@ -0,0 +1,34 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { DataWriter } from '../sql/Client';
export async function pauseBackupMediaDownload(): Promise<void> {
await window.storage.put('backupMediaDownloadPaused', true);
}
export async function resumeBackupMediaDownload(): Promise<void> {
await window.storage.put('backupMediaDownloadPaused', false);
}
export async function resetBackupMediaDownloadItems(): Promise<void> {
await Promise.all([
window.storage.remove('backupMediaDownloadTotalBytes'),
window.storage.remove('backupMediaDownloadCompletedBytes'),
window.storage.remove('backupMediaDownloadBannerDismissed'),
window.storage.remove('backupMediaDownloadPaused'),
]);
}
export async function cancelBackupMediaDownload(): Promise<void> {
await DataWriter.removeAllBackupAttachmentDownloadJobs();
await resetBackupMediaDownloadItems();
}
export async function resetBackupMediaDownload(): Promise<void> {
await resetBackupMediaDownloadItems();
}
export async function dismissBackupMediaDownloadBanner(): Promise<void> {
await window.storage.put('backupMediaDownloadBannerDismissed', true);
}

View file

@ -57,3 +57,23 @@ export function safeParseBigint(
}
return BigInt(value);
}
export function roundFractionForProgressBar(fractionComplete: number): number {
if (fractionComplete <= 0) {
return 0;
}
if (fractionComplete >= 1) {
return 1;
}
if (fractionComplete <= 0.01) {
return 0.01;
}
if (fractionComplete >= 0.99) {
return 0.99;
}
return Math.round(fractionComplete * 100) / 100;
}