Pause, cancel & resume backup media download
Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
This commit is contained in:
parent
ced1962879
commit
ec0d64de6e
28 changed files with 958 additions and 141 deletions
|
@ -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"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
25
stylesheets/components/ProgressCircle.scss
Normal file
25
stylesheets/components/ProgressCircle.scss
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
|
53
ts/components/ProgressBar.stories.tsx
Normal file
53
ts/components/ProgressBar.stories.tsx
Normal 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;
|
||||
}
|
|
@ -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>
|
||||
|
|
44
ts/components/ProgressCircle.stories.tsx
Normal file
44
ts/components/ProgressCircle.stories.tsx
Normal 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;
|
||||
}
|
41
ts/components/ProgressCircle.tsx
Normal file
41
ts/components/ProgressCircle.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
)
|
||||
);
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
|
||||
|
|
|
@ -852,6 +852,7 @@ type WritableInterface = {
|
|||
getNextAttachmentDownloadJobs: (options: {
|
||||
limit: number;
|
||||
prioritizeMessageIds?: Array<string>;
|
||||
sources?: Array<AttachmentDownloadSource>;
|
||||
timestamp?: number;
|
||||
}) => Array<AttachmentDownloadJobType>;
|
||||
saveAttachmentDownloadJob: (job: AttachmentDownloadJobType) => void;
|
||||
|
|
|
@ -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}
|
||||
`;
|
||||
|
|
29
ts/sql/migrations/1200-attachment-download-source-index.ts
Normal file
29
ts/sql/migrations/1200-attachment-download-source-index.ts
Normal 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!');
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
213
ts/test-node/sql/migration_1200_test.ts
Normal file
213
ts/test-node/sql/migration_1200_test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
6
ts/types/Storage.d.ts
vendored
6
ts/types/Storage.d.ts
vendored
|
@ -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>;
|
||||
|
|
34
ts/util/backupMediaDownload.ts
Normal file
34
ts/util/backupMediaDownload.ts
Normal 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);
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue