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

@ -4723,14 +4723,54 @@
"messageformat": "Cancel transfer", "messageformat": "Cancel transfer",
"description": "Text of the confirmation button of the cancel confirmation modal in the backup import screen" "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", "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": { "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" "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": { "icu:CompositionArea--expand": {
"messageformat": "Expand", "messageformat": "Expand",
"description": "Aria label for expanding composition area" "description": "Aria label for expanding composition area"

View file

@ -1,16 +1,20 @@
// Copyright 2024 Signal Messenger, LLC // Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.BackupMediaDownloadProgressBanner { .BackupMediaDownloadProgress {
@include font-body-2;
border-radius: 10px; border-radius: 10px;
display: flex; display: flex;
gap: 12px; align-items: center;
padding: 12px; gap: 10px;
padding: 11px;
padding-inline-end: 16px; padding-inline-end: 16px;
margin-inline: 10px; margin-inline: 10px;
user-select: none; user-select: none;
position: relative;
&__title {
@include font-body-2-bold;
}
@include light-theme { @include light-theme {
background-color: $color-white; background-color: $color-white;
@ -22,69 +26,84 @@
} }
} }
.BackupMediaDownloadProgressBanner__icon { .BackupMediaDownloadProgress__icon--complete {
background: rgba($color-ultramarine, 0.2);
width: 30px;
height: 30px;
padding: 6px;
border-radius: 50%;
@include dark-theme {
background: $color-gray-60;
}
&::after { &::after {
content: ''; content: '';
display: inline-block; display: block;
width: 18px; width: 24px;
height: 18px; height: 24px;
@include light-theme { @include light-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/backup/backup-bold.svg', '../images/icons/v3/check/check-circle.svg',
$color-ultramarine $color-ultramarine
); );
} }
@include dark-theme { @include dark-theme {
@include color-svg( @include color-svg(
'../images/icons/v3/backup/backup-bold.svg', '../images/icons/v3/check/check-circle.svg',
$color-ultramarine-pale $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; display: flex;
flex-direction: column; flex-direction: column;
flex: 1; justify-content: center;
gap: 7px; gap: 2px;
min-height: 36px;
} }
.BackupMediaDownloadProgressBanner .Progressbar { .BackupMediaDownloadProgress__progressbar-hint {
overflow: hidden; @include font-subtitle;
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;
@include light-theme { @include light-theme {
color: rgba($color-gray-60, 0.8); color: rgba($color-gray-60, 0.8);
@ -94,3 +113,7 @@
color: $color-gray-25; color: $color-gray-25;
} }
} }
.BackupMediaDownloadCancelConfirmation {
min-width: 440px;
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
.ProgressBar { .ProgressBar {
position: relative;
overflow: hidden; overflow: hidden;
background: rgba($color-ultramarine, 0.2); background: rgba($color-ultramarine, 0.2);
height: 5px; height: 5px;
@ -9,9 +10,11 @@
} }
.ProgressBar__fill { .ProgressBar__fill {
position: absolute;
background-color: $color-ultramarine; background-color: $color-ultramarine;
border-radius: 2px; border-radius: 2px;
display: block; display: block;
height: 100%; 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/Preferences.scss';
@import './components/ProfileEditor.scss'; @import './components/ProfileEditor.scss';
@import './components/ProgressBar.scss'; @import './components/ProgressBar.scss';
@import './components/ProgressCircle.scss';
@import './components/Quote.scss'; @import './components/Quote.scss';
@import './components/ReactionPickerPicker.scss'; @import './components/ReactionPickerPicker.scss';
@import './components/RecordingComposer.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 // SPDX-License-Identifier: AGPL-3.0-only
import React, { type ComponentProps } from 'react'; 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 { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json'; 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); const i18n = setupI18n('en', enMessages);
type PropsType = ComponentProps<typeof BackupMediaDownloadProgressBanner>; type PropsType = ComponentProps<typeof BackupMediaDownloadProgress>;
export default { export default {
title: 'Components/BackupMediaDownloadProgress', 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>; } satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition export function InProgress(args: PropsType): JSX.Element {
const Template: StoryFn<PropsType> = (args: PropsType) => ( return <BackupMediaDownloadProgress {...args} />;
<BackupMediaDownloadProgressBanner {...args} i18n={i18n} /> }
);
export const InProgress = Template.bind({}); export function Increasing(args: PropsType): JSX.Element {
InProgress.args = { return (
downloadedBytes: 92048023, <BackupMediaDownloadProgress
totalBytes: 1024102532, {...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 // 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 } 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 { ProgressBar } from './ProgressBar'; import { roundFractionForProgressBar } from '../util/numbers';
import { ProgressCircle } from './ProgressCircle';
import { ContextMenu } from './ContextMenu';
import { BackupMediaDownloadCancelConfirmationDialog } from './BackupMediaDownloadCancelConfirmationDialog';
export type PropsType = Readonly<{ export type PropsType = Readonly<{
i18n: LocalizerType; i18n: LocalizerType;
downloadedBytes: number; downloadedBytes: number;
totalBytes: number; totalBytes: number;
isPaused: boolean;
handleCancel: VoidFunction;
handleClose: VoidFunction;
handleResume: VoidFunction;
handlePause: VoidFunction;
}>; }>;
export function BackupMediaDownloadProgressBanner({ export function BackupMediaDownloadProgress({
i18n, i18n,
downloadedBytes, downloadedBytes,
totalBytes, totalBytes,
isPaused,
handleCancel: handleConfirmedCancel,
handleClose,
handleResume,
handlePause,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const [isShowingCancelConfirmation, setIsShowingCancelConfirmation] =
useState(false);
if (totalBytes === 0) { if (totalBytes === 0) {
return null; return null;
} }
const fractionComplete = Math.max( function handleCancel() {
0, setIsShowingCancelConfirmation(true);
Math.min(1, downloadedBytes / totalBytes) }
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 ( return (
<div className="BackupMediaDownloadProgressBanner"> <div className="BackupMediaDownloadProgress">
<div className="BackupMediaDownloadProgressBanner__icon" /> {icon}
<div className="BackupMediaDownloadProgressBanner__content"> <div className="BackupMediaDownloadProgress__content">{content}</div>
<div className="BackupMediaDownloadProgressBanner__title"> {actionButton}
{i18n('icu:BackupMediaDownloadProgress__title')} {isShowingCancelConfirmation ? (
</div> <BackupMediaDownloadCancelConfirmationDialog
<ProgressBar fractionComplete={fractionComplete} /> i18n={i18n}
<div className="BackupMediaDownloadProgressBanner__progressbar-hint"> handleDialogClose={() => setIsShowingCancelConfirmation(false)}
{i18n('icu:BackupMediaDownloadProgress__progressbar-hint', { handleConfirmCancel={handleConfirmedCancel}
currentSize: formatFileSize(downloadedBytes), />
totalSize: formatFileSize(totalBytes), ) : null}
fractionComplete,
})}
</div>
</div>
</div> </div>
); );
} }

View file

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

View file

@ -1,13 +1,7 @@
// Copyright 2019 Signal Messenger, LLC // Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { import React, { useEffect, useCallback, useMemo, useRef } from 'react';
useEffect,
useCallback,
useMemo,
useRef,
useState,
} from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -62,10 +56,15 @@ import {
import { ContextMenu } from './ContextMenu'; import { ContextMenu } from './ContextMenu';
import { EditState as ProfileEditorEditState } from './ProfileEditor'; import { EditState as ProfileEditorEditState } from './ProfileEditor';
import type { UnreadStats } from '../util/countUnreadStats'; import type { UnreadStats } from '../util/countUnreadStats';
import { BackupMediaDownloadProgressBanner } from './BackupMediaDownloadProgress'; import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress';
export type PropsType = { export type PropsType = {
backupMediaDownloadProgress: { totalBytes: number; downloadedBytes: number }; backupMediaDownloadProgress: {
totalBytes: number;
downloadedBytes: number;
isPaused: boolean;
downloadBannerDismissed: boolean;
};
otherTabsUnreadStats: UnreadStats; otherTabsUnreadStats: UnreadStats;
hasExpiredDialog: boolean; hasExpiredDialog: boolean;
hasFailedStorySends: boolean; hasFailedStorySends: boolean;
@ -128,6 +127,10 @@ export type PropsType = {
composeReplaceAvatar: ReplaceAvatarActionType; composeReplaceAvatar: ReplaceAvatarActionType;
composeSaveAvatarToDisk: SaveAvatarToDiskActionType; composeSaveAvatarToDisk: SaveAvatarToDiskActionType;
createGroup: () => void; createGroup: () => void;
dismissBackupMediaDownloadBanner: () => void;
pauseBackupMediaDownload: () => void;
resumeBackupMediaDownload: () => void;
cancelBackupMediaDownload: () => void;
endConversationSearch: () => void; endConversationSearch: () => void;
endSearch: () => void; endSearch: () => void;
navTabsCollapsed: boolean; navTabsCollapsed: boolean;
@ -184,6 +187,7 @@ export function LeftPane({
backupMediaDownloadProgress, backupMediaDownloadProgress,
otherTabsUnreadStats, otherTabsUnreadStats,
blockConversation, blockConversation,
cancelBackupMediaDownload,
challengeStatus, challengeStatus,
clearConversationSearch, clearConversationSearch,
clearGroupCreationError, clearGroupCreationError,
@ -214,6 +218,7 @@ export function LeftPane({
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
openUsernameReservationModal, openUsernameReservationModal,
pauseBackupMediaDownload,
preferredWidthFromStorage, preferredWidthFromStorage,
preloadConversation, preloadConversation,
removeConversation, removeConversation,
@ -226,6 +231,7 @@ export function LeftPane({
renderRelinkDialog, renderRelinkDialog,
renderUpdateDialog, renderUpdateDialog,
renderToastManager, renderToastManager,
resumeBackupMediaDownload,
savePreferredLeftPaneWidth, savePreferredLeftPaneWidth,
searchInConversation, searchInConversation,
selectedConversationId, selectedConversationId,
@ -256,6 +262,7 @@ export function LeftPane({
usernameCorrupted, usernameCorrupted,
usernameLinkCorrupted, usernameLinkCorrupted,
updateSearchTerm, updateSearchTerm,
dismissBackupMediaDownloadBanner,
}: PropsType): JSX.Element { }: PropsType): JSX.Element {
const previousModeSpecificProps = usePrevious( const previousModeSpecificProps = usePrevious(
modeSpecificProps, modeSpecificProps,
@ -645,27 +652,25 @@ export function LeftPane({
// We'll show the backup media download progress banner if the download is currently or // 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 // was ongoing at some point during the lifecycle of this component
const [
hasMediaBackupDownloadBeenOngoing,
setHasMediaBackupDownloadBeenOngoing,
] = useState(false);
const isMediaBackupDownloadOngoing = const isMediaBackupDownloadIncomplete =
backupMediaDownloadProgress?.totalBytes > 0 && backupMediaDownloadProgress?.totalBytes > 0 &&
backupMediaDownloadProgress.downloadedBytes < backupMediaDownloadProgress.downloadedBytes <
backupMediaDownloadProgress.totalBytes; backupMediaDownloadProgress.totalBytes;
if (
if (isMediaBackupDownloadOngoing && !hasMediaBackupDownloadBeenOngoing) { isMediaBackupDownloadIncomplete &&
setHasMediaBackupDownloadBeenOngoing(true); !backupMediaDownloadProgress.downloadBannerDismissed
} ) {
if (hasMediaBackupDownloadBeenOngoing) {
dialogs.push({ dialogs.push({
key: 'backupMediaDownload', key: 'backupMediaDownload',
dialog: ( dialog: (
<BackupMediaDownloadProgressBanner <BackupMediaDownloadProgress
i18n={i18n} i18n={i18n}
{...backupMediaDownloadProgress} {...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({ export function ProgressBar({
fractionComplete, fractionComplete,
isRTL,
}: { }: {
fractionComplete: number; fractionComplete: number;
isRTL: boolean;
}): JSX.Element { }): JSX.Element {
return ( return (
<div className="ProgressBar"> <div className="ProgressBar">
<div <div
className="ProgressBar__fill" className="ProgressBar__fill"
style={{ style={{
marginInlineEnd: `${(1 - fractionComplete) * 100}%`, transform: `translateX(${(isRTL ? -1 : 1) * (fractionComplete - 1) * 100}%)`,
}} }}
/> />
</div> </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 { ProgressBar } from '../ProgressBar';
import { ConfirmationDialog } from '../ConfirmationDialog'; import { ConfirmationDialog } from '../ConfirmationDialog';
import { InstallScreenSignalLogo } from './InstallScreenSignalLogo'; import { InstallScreenSignalLogo } from './InstallScreenSignalLogo';
import { roundFractionForProgressBar } from '../../util/numbers';
// 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
// type. // type.
@ -41,29 +42,26 @@ export function InstallScreenBackupImportStep({
setIsConfirmingCancel(false); setIsConfirmingCancel(false);
}, [onCancel]); }, [onCancel]);
let percentage = 0;
let progress: JSX.Element; let progress: JSX.Element;
let isCancelPossible = true; let isCancelPossible = true;
if (currentBytes != null && totalBytes != null) { if (currentBytes != null && totalBytes != null) {
isCancelPossible = currentBytes !== totalBytes; isCancelPossible = currentBytes !== totalBytes;
percentage = Math.max(0, Math.min(1, currentBytes / totalBytes)); const fractionComplete = roundFractionForProgressBar(
if (percentage > 0 && percentage <= 0.01) { currentBytes / totalBytes
percentage = 0.01; );
} else if (percentage >= 0.99 && percentage < 1) {
percentage = 0.99;
} else {
percentage = Math.round(percentage * 100) / 100;
}
progress = ( progress = (
<> <>
<ProgressBar fractionComplete={percentage} /> <ProgressBar
fractionComplete={fractionComplete}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
<div className="InstallScreenBackupImportStep__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),
fractionComplete: percentage, fractionComplete,
})} })}
</div> </div>
</> </>
@ -71,7 +69,10 @@ export function InstallScreenBackupImportStep({
} else { } else {
progress = ( progress = (
<> <>
<ProgressBar fractionComplete={0} /> <ProgressBar
fractionComplete={0}
isRTL={i18n.getLocaleDirection() === 'rtl'}
/>
<div className="InstallScreenBackupImportStep__progressbar-hint"> <div className="InstallScreenBackupImportStep__progressbar-hint">
{i18n('icu:BackupImportScreen__progressbar-hint--preparing')} {i18n('icu:BackupImportScreen__progressbar-hint--preparing')}
</div> </div>

View file

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

View file

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

View file

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

View file

@ -4785,18 +4785,28 @@ function getNextAttachmentDownloadJobs(
db: WritableDB, db: WritableDB,
{ {
limit = 3, limit = 3,
sources,
prioritizeMessageIds, prioritizeMessageIds,
timestamp = Date.now(), timestamp = Date.now(),
maxLastAttemptForPrioritizedMessages, maxLastAttemptForPrioritizedMessages,
}: { }: {
limit: number; limit: number;
prioritizeMessageIds?: Array<string>; prioritizeMessageIds?: Array<string>;
sources?: Array<AttachmentDownloadSource>;
timestamp?: number; timestamp?: number;
maxLastAttemptForPrioritizedMessages?: number; maxLastAttemptForPrioritizedMessages?: number;
} }
): Array<AttachmentDownloadJobType> { ): Array<AttachmentDownloadJobType> {
let priorityJobs = []; 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) // First, try to get jobs for prioritized messages (e.g. those currently user-visible)
if (prioritizeMessageIds?.length) { if (prioritizeMessageIds?.length) {
const [priorityQuery, priorityParams] = sql` const [priorityQuery, priorityParams] = sql`
@ -4813,6 +4823,8 @@ function getNextAttachmentDownloadJobs(
}) })
AND AND
messageId IN (${sqlJoin(prioritizeMessageIds)}) messageId IN (${sqlJoin(prioritizeMessageIds)})
AND
${sourceWhereFragment}
-- for priority messages, let's load them oldest first; this helps, e.g. for stories where we -- for priority messages, let's load them oldest first; this helps, e.g. for stories where we
-- want the oldest one first -- want the oldest one first
ORDER BY receivedAt ASC ORDER BY receivedAt ASC
@ -4831,6 +4843,8 @@ function getNextAttachmentDownloadJobs(
active = 0 active = 0
AND AND
(retryAfter is NULL OR retryAfter <= ${timestamp}) (retryAfter is NULL OR retryAfter <= ${timestamp})
AND
${sourceWhereFragment}
ORDER BY receivedAt DESC ORDER BY receivedAt DESC
LIMIT ${numJobsRemaining} 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 { updateToSchemaVersion1160 } from './1160-optimize-calls-unread-count';
import { updateToSchemaVersion1170 } from './1170-update-call-history-unread-index'; import { updateToSchemaVersion1170 } from './1170-update-call-history-unread-index';
import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source'; import { updateToSchemaVersion1180 } from './1180-add-attachment-download-source';
import { updateToSchemaVersion1190 } from './1190-call-links-storage';
import { import {
updateToSchemaVersion1190, updateToSchemaVersion1200,
version as MAX_VERSION, version as MAX_VERSION,
} from './1190-call-links-storage'; } from './1200-attachment-download-source-index';
function updateToSchemaVersion1( function updateToSchemaVersion1(
currentVersion: number, currentVersion: number,
@ -2062,6 +2063,7 @@ export const SCHEMA_VERSIONS = [
updateToSchemaVersion1170, updateToSchemaVersion1170,
updateToSchemaVersion1180, updateToSchemaVersion1180,
updateToSchemaVersion1190, updateToSchemaVersion1190,
updateToSchemaVersion1200,
]; ];
export class DBVersionFromFutureError extends Error { export class DBVersionFromFutureError extends Error {

View file

@ -251,8 +251,17 @@ export const getLocalDeleteWarningShown = createSelector(
export const getBackupMediaDownloadProgress = createSelector( export const getBackupMediaDownloadProgress = createSelector(
getItems, getItems,
(state: ItemsStateType): { totalBytes: number; downloadedBytes: number } => ({ (
totalBytes: state.backupAttachmentsTotalSizeToDownload ?? 0, state: ItemsStateType
downloadedBytes: state.backupAttachmentsSuccessfullyDownloadedSize ?? 0, ): {
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 type { PropsType as SmartUnsupportedOSDialogPropsType } from './UnsupportedOSDialog';
import { SmartUnsupportedOSDialog } from './UnsupportedOSDialog'; import { SmartUnsupportedOSDialog } from './UnsupportedOSDialog';
import { SmartUpdateDialog } from './UpdateDialog'; import { SmartUpdateDialog } from './UpdateDialog';
import {
cancelBackupMediaDownload,
dismissBackupMediaDownloadBanner,
pauseBackupMediaDownload,
resumeBackupMediaDownload,
} from '../../util/backupMediaDownload';
function renderMessageSearchResult(id: string): JSX.Element { function renderMessageSearchResult(id: string): JSX.Element {
return <SmartMessageSearchResult id={id} />; return <SmartMessageSearchResult id={id} />;
@ -366,6 +372,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
<LeftPane <LeftPane
backupMediaDownloadProgress={backupMediaDownloadProgress} backupMediaDownloadProgress={backupMediaDownloadProgress}
blockConversation={blockConversation} blockConversation={blockConversation}
cancelBackupMediaDownload={cancelBackupMediaDownload}
challengeStatus={challengeStatus} challengeStatus={challengeStatus}
clearConversationSearch={clearConversationSearch} clearConversationSearch={clearConversationSearch}
clearGroupCreationError={clearGroupCreationError} clearGroupCreationError={clearGroupCreationError}
@ -377,6 +384,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
composeSaveAvatarToDisk={composeSaveAvatarToDisk} composeSaveAvatarToDisk={composeSaveAvatarToDisk}
crashReportCount={crashReportCount} crashReportCount={crashReportCount}
createGroup={createGroup} createGroup={createGroup}
dismissBackupMediaDownloadBanner={dismissBackupMediaDownloadBanner}
endConversationSearch={endConversationSearch} endConversationSearch={endConversationSearch}
endSearch={endSearch} endSearch={endSearch}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
@ -396,6 +404,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
openUsernameReservationModal={openUsernameReservationModal} openUsernameReservationModal={openUsernameReservationModal}
otherTabsUnreadStats={otherTabsUnreadStats} otherTabsUnreadStats={otherTabsUnreadStats}
pauseBackupMediaDownload={pauseBackupMediaDownload}
preferredWidthFromStorage={preferredWidthFromStorage} preferredWidthFromStorage={preferredWidthFromStorage}
preloadConversation={preloadConversation} preloadConversation={preloadConversation}
removeConversation={removeConversation} removeConversation={removeConversation}
@ -408,6 +417,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
renderToastManager={renderToastManager} renderToastManager={renderToastManager}
renderUnsupportedOSDialog={renderUnsupportedOSDialog} renderUnsupportedOSDialog={renderUnsupportedOSDialog}
renderUpdateDialog={renderUpdateDialog} renderUpdateDialog={renderUpdateDialog}
resumeBackupMediaDownload={resumeBackupMediaDownload}
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth} savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
searchInConversation={searchInConversation} searchInConversation={searchInConversation}
selectedConversationId={selectedConversationId} selectedConversationId={selectedConversationId}

View file

@ -27,8 +27,10 @@ function composeJob({
messageId, messageId,
receivedAt, receivedAt,
attachmentOverrides, attachmentOverrides,
jobOverrides,
}: Pick<NewAttachmentDownloadJobType, 'messageId' | 'receivedAt'> & { }: Pick<NewAttachmentDownloadJobType, 'messageId' | 'receivedAt'> & {
attachmentOverrides?: Partial<AttachmentType>; attachmentOverrides?: Partial<AttachmentType>;
jobOverrides?: Partial<AttachmentDownloadJobType>;
}): AttachmentDownloadJobType { }): AttachmentDownloadJobType {
const digest = `digestFor${messageId}`; const digest = `digestFor${messageId}`;
const size = 128; const size = 128;
@ -53,6 +55,7 @@ function composeJob({
digest: `digestFor${messageId}`, digest: `digestFor${messageId}`,
...attachmentOverrides, ...attachmentOverrides,
}, },
...jobOverrides,
}; };
} }
@ -123,13 +126,19 @@ describe('AttachmentDownloadManager/JobManager', () => {
}); });
} }
async function addJobs( async function addJobs(
num: number num: number,
jobOverrides?:
| Partial<AttachmentDownloadJobType>
| ((idx: number) => Partial<AttachmentDownloadJobType>)
): Promise<Array<AttachmentDownloadJobType>> { ): Promise<Array<AttachmentDownloadJobType>> {
const jobs = new Array(num) const jobs = new Array(num).fill(null).map((_, idx) =>
.fill(null) composeJob({
.map((_, idx) => messageId: `message-${idx}`,
composeJob({ messageId: `message-${idx}`, receivedAt: idx }) receivedAt: idx,
); jobOverrides:
typeof jobOverrides === 'function' ? jobOverrides(idx) : jobOverrides,
})
);
for (const job of jobs) { for (const job of jobs) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await addJob(job, AttachmentDownloadUrgency.STANDARD); await addJob(job, AttachmentDownloadUrgency.STANDARD);
@ -392,6 +401,35 @@ describe('AttachmentDownloadManager/JobManager', () => {
// Ensure it's been removed // Ensure it's been removed
assert.isUndefined(await DataReader.getAttachmentDownloadJob(jobs[0])); 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', () => { 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

@ -142,8 +142,10 @@ export type StorageAccessType = {
callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>; callLinkAuthCredentials: ReadonlyArray<GroupCredentialType>;
backupCredentials: ReadonlyArray<BackupCredentialType>; backupCredentials: ReadonlyArray<BackupCredentialType>;
backupCredentialsLastRequestTime: number; backupCredentialsLastRequestTime: number;
backupAttachmentsSuccessfullyDownloadedSize: number; backupMediaDownloadTotalBytes: number;
backupAttachmentsTotalSizeToDownload: number; backupMediaDownloadCompletedBytes: number;
backupMediaDownloadPaused: boolean;
backupMediaDownloadBannerDismissed: boolean;
setBackupSignatureKey: boolean; setBackupSignatureKey: boolean;
lastReceivedAtCounter: number; lastReceivedAtCounter: number;
preferredReactionEmoji: ReadonlyArray<string>; 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); 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;
}