Pause, cancel & resume backup media download
This commit is contained in:
parent
65539b1419
commit
028a3f3ef0
28 changed files with 958 additions and 141 deletions
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
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/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';
|
||||||
|
|
|
@ -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
|
// 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 };
|
||||||
|
}
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
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({
|
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>
|
||||||
|
|
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 { 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>
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
@ -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()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}
|
||||||
`;
|
`;
|
||||||
|
|
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 { 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 {
|
||||||
|
|
|
@ -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,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
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
|
@ -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>;
|
||||||
|
|
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);
|
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…
Add table
Add a link
Reference in a new issue