Show attachment download progress, new stop button to cancel

Co-authored-by: Jamie Kyle <jamie@signal.org>
This commit is contained in:
Scott Nonnenberg 2024-12-10 08:54:18 +10:00 committed by GitHub
parent 025841e5bb
commit 2741fbb5d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
78 changed files with 2192 additions and 562 deletions

View file

@ -1502,6 +1502,30 @@
"messageformat": "Icon showing that this image has a caption", "messageformat": "Icon showing that this image has a caption",
"description": "Used for the icon layered on top of an image in message bubbles" "description": "Used for the icon layered on top of an image in message bubbles"
}, },
"icu:imageOpenAlt": {
"messageformat": "Open this attachment in a larger view",
"description": "Used for the button that overlays all attachments in the timeline"
},
"icu:startDownload": {
"messageformat": "Start download",
"description": "Describes a button shown on an an attachment to kick off the download"
},
"icu:cancelDownload": {
"messageformat": "Cancel download",
"description": "Describes a button shown on an existing download to stop that in-progress or pending download"
},
"icu:retryDownload": {
"messageformat": "Retry download",
"description": "Label for button shown on an existing download to restart a download that was partially completed"
},
"icu:retryDownloadShort": {
"messageformat": "Retry",
"description": "Describes a button shown on an existing download to restart a download that was partially completed"
},
"icu:downloadNItems": {
"messageformat": "{count, plural, one {# item} other {# items}}",
"description": "Describes a button shown on an existing download to restart a download that was partially completed"
},
"icu:save": { "icu:save": {
"messageformat": "Save", "messageformat": "Save",
"description": "Used on save buttons" "description": "Used on save buttons"

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M5.417 3.75c-.92 0-1.667.746-1.667 1.667v9.166c0 .92.746 1.667 1.667 1.667h9.166c.92 0 1.667-.746 1.667-1.667V5.417c0-.92-.746-1.667-1.667-1.667H5.417Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 256 B

View file

@ -2677,9 +2677,9 @@ button.ConversationDetails__action-button {
align-items: center; align-items: center;
display: flex; display: flex;
justify-content: center; justify-content: center;
border-radius: 48px; border-radius: 50px;
height: 48px; height: 50px;
width: 48px; width: 50px;
background-color: variables.$color-black-alpha-70; background-color: variables.$color-black-alpha-70;
} }
@ -2719,52 +2719,38 @@ button.ConversationDetails__action-button {
align-items: center; align-items: center;
content: 'GIF'; content: 'GIF';
height: 24px;
width: 24px;
@include mixins.font-body-1; @include mixins.font-body-1;
color: variables.$color-white; color: variables.$color-white;
} }
} }
.module-image__download-pending { .module-image__progress-circle-wrapper {
position: relative; @include mixins.position-absolute-center;
&--spinner-container { .ProgressCircle .ProgressCircle__background {
align-items: center; stroke: variables.$color-white-alpha-20;
display: flex;
height: 100%;
justify-content: center;
inset-inline-start: 0;
position: absolute;
top: 0;
width: 100%;
} }
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white;
}
}
&--spinner { .module-image__spinner-container {
background-color: variables.$color-gray-75; @include mixins.position-absolute-center;
border-radius: 48px;
height: 48px;
width: 48px;
.module-image-spinner { .module-image-spinner {
&__container { &__arc {
margin-block: 12px; background-color: variables.$color-black-alpha-80;
margin-inline: auto; }
}
&__circle {
background-color: variables.$color-white;
}
@include mixins.dark-theme {
&__arc { &__arc {
background-color: variables.$color-gray-75; background-color: variables.$color-black-alpha-80;
}
&__circle {
background-color: variables.$color-white;
}
@include mixins.dark-theme {
&__arc {
background-color: variables.$color-gray-75;
}
} }
} }
} }
@ -2791,10 +2777,10 @@ button.ConversationDetails__action-button {
.module-image__border-overlay { .module-image__border-overlay {
@include mixins.button-reset; @include mixins.button-reset;
& { & {
width: 100%; width: 100%;
cursor: inherit; cursor: inherit;
pointer-events: none;
position: absolute; position: absolute;
top: 0; top: 0;
@ -2806,6 +2792,7 @@ button.ConversationDetails__action-button {
.module-image__border-overlay--with-click-handler { .module-image__border-overlay--with-click-handler {
cursor: pointer; cursor: pointer;
pointer-events: all;
} }
.module-image__border-overlay--with-border { .module-image__border-overlay--with-border {
@ -2818,24 +2805,6 @@ button.ConversationDetails__action-button {
} }
.module-image--gif { .module-image--gif {
&__filesize {
position: absolute;
top: 10px;
inset-inline-start: 10px;
padding-block: 2px;
padding-inline: 8px;
color: variables.$color-white;
background: variables.$color-black-alpha-70;
/* The height is: 14px + 2x2px from the padding */
border-radius: 9px;
font-size: 11px;
line-height: 14px;
user-select: none;
}
video { video {
cursor: pointer; cursor: pointer;
object-fit: cover; object-fit: cover;
@ -2897,22 +2866,46 @@ button.module-image__border-overlay:focus {
inset-inline: 0; inset-inline: 0;
} }
.module-image__play-overlay__circle { .module-image__overlay-circle {
@include mixins.position-absolute-center; @include mixins.position-absolute-center;
width: 48px; @include mixins.button-reset;
height: 48px; & {
background-color: variables.$color-white; width: 50px;
border-radius: 24px; height: 50px;
background-color: variables.$color-black-alpha-80;
border-radius: 25px;
}
} }
.module-image__play-overlay__icon { .module-image__play-icon {
@include mixins.position-absolute-center; @include mixins.position-absolute-center;
height: 24px; height: 24px;
width: 24px; width: 24px;
@include mixins.color-svg( @include mixins.color-svg(
'../images/icons/v3/play/play-fill.svg', '../images/icons/v3/play/play-fill.svg',
variables.$color-ultramarine variables.$color-white
);
}
.module-image__stop-icon {
@include mixins.position-absolute-center;
// Smaller to fit within the spinner
height: 24px;
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/stop/stop-fill.svg',
variables.$color-white
);
}
.module-image__download-icon {
@include mixins.position-absolute-center;
height: 24px;
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/arrow/arrow-down.svg',
variables.$color-white
); );
} }
@ -2962,6 +2955,7 @@ button.module-image__border-overlay:focus {
// Module: Image Grid // Module: Image Grid
.module-image-grid { .module-image-grid {
position: relative;
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -2969,6 +2963,43 @@ button.module-image__border-overlay:focus {
gap: 1px; gap: 1px;
} }
.module-image-grid__download-pill {
@include mixins.position-absolute-center;
@include mixins.button-reset;
& {
background-color: variables.$color-black-alpha-80;
color: variables.$color-white;
height: 44px;
border-radius: 44px;
display: inline-flex;
flex-direction: row;
align-items: center;
}
}
.module-image-grid__download_pill__icon-wrapper {
position: relative;
width: 44px;
height: 44px;
margin-inline-end: -6px;
}
.module-image-grid__download_pill__download-icon {
@include mixins.position-absolute-center;
height: 24px;
width: 24px;
@include mixins.color-svg(
'../images/icons/v3/arrow/arrow-down.svg',
variables.$color-white
);
}
.module-image-grid__download_pill__text-wrapper {
@include mixins.font-body-1;
margin-inline-end: 14px;
}
.module-image-grid--one-image { .module-image-grid--one-image {
margin-bottom: -5px; margin-bottom: -5px;
} }

View file

@ -0,0 +1,87 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
@use '../variables';
// This needs to go before the top-level class, so it doesn't interfere
.AttachmentDetailPill--interactive {
@include mixins.button-reset;
}
.AttachmentDetailPill {
position: absolute;
top: 6px;
/* stylelint-disable-next-line liberty/use-logical-spec */
left: 6px;
height: 32px;
border-radius: 32px;
background-color: variables.$color-black-alpha-80;
display: inline-flex;
flex-direction: row;
align-items: center;
z-index: variables.$z-index-above-base;
@include mixins.font-caption;
color: variables.$color-white;
transition: width 400ms ease-out;
}
.AttachmentDetailPill__spinner-wrapper {
position: relative;
margin: 4px;
margin-inline-end: -4px;
.ProgressCircle .ProgressCircle__background {
stroke: variables.$color-white-alpha-20;
}
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white;
}
.module-spinner__circle {
background-color: variables.$color-white-alpha-20;
}
.module-spinner__arc {
background-color: variables.$color-white;
}
}
.AttachmentDetailPill__text-wrapper {
margin-inline-start: 10px;
margin-inline-end: 10px;
}
.AttachmentDetailPill__icon-wrapper {
position: relative;
margin-inline-start: 4px;
margin-inline-end: -11px;
width: 24px;
height: 24px;
}
.AttachmentDetailPill__stop-icon {
@include mixins.position-absolute-center;
// Smaller to fit within the spinner
height: 12px;
width: 12px;
@include mixins.color-svg(
'../images/icons/v3/stop/stop-fill.svg',
variables.$color-white
);
}
.AttachmentDetailPill__download-icon {
@include mixins.position-absolute-center;
height: 16px;
width: 16px;
@include mixins.color-svg(
'../images/icons/v3/arrow/arrow-down.svg',
variables.$color-white
);
}

View file

@ -25,6 +25,7 @@
@use 'components/AddUserToAnotherGroupModal.scss'; @use 'components/AddUserToAnotherGroupModal.scss';
@use 'components/AnnouncementsOnlyGroupBanner.scss'; @use 'components/AnnouncementsOnlyGroupBanner.scss';
@use 'components/App.scss'; @use 'components/App.scss';
@use 'components/AttachmentDetailPill.scss';
@use 'components/AudioCapture.scss'; @use 'components/AudioCapture.scss';
@use 'components/AutoSizeInput.scss'; @use 'components/AutoSizeInput.scss';
@use 'components/Avatar.scss'; @use 'components/Avatar.scss';

View file

@ -244,8 +244,10 @@ export async function encryptAttachmentV2({
}), }),
peekAndUpdateHash(digest), peekAndUpdateHash(digest),
incrementalDigestCreator, incrementalDigestCreator,
measureSize(finalSize => { measureSize({
ciphertextSize = finalSize; onComplete: finalSize => {
ciphertextSize = finalSize;
},
}), }),
sink ?? new PassThrough().resume(), sink ?? new PassThrough().resume(),
].filter(isNotNil) ].filter(isNotNil)
@ -434,6 +436,7 @@ export async function decryptAttachmentV2ToSink(
let isPaddingAllZeros = false; let isPaddingAllZeros = false;
let readFd; let readFd;
let iv: Uint8Array | undefined; let iv: Uint8Array | undefined;
try { try {
try { try {
readFd = await open(ciphertextPath, 'r'); readFd = await open(ciphertextPath, 'r');
@ -652,15 +655,27 @@ function peekAndUpdateHash(hash: Hash) {
}); });
} }
export function measureSize(onComplete: (size: number) => void): Transform { export function measureSize({
downloadOffset = 0,
onComplete,
onSizeUpdate,
}: {
downloadOffset?: number;
onComplete: (size: number) => void;
onSizeUpdate?: (size: number) => void;
}): Transform {
let totalBytes = 0; let totalBytes = 0;
const passthrough = new PassThrough(); const passthrough = new PassThrough();
passthrough.on('data', chunk => { passthrough.on('data', chunk => {
totalBytes += chunk.length; totalBytes += chunk.length;
onSizeUpdate?.(totalBytes + downloadOffset);
}); });
passthrough.on('end', () => { passthrough.on('end', () => {
onComplete(totalBytes); onComplete(totalBytes);
}); });
return passthrough; return passthrough;
} }

View file

@ -23,10 +23,8 @@ export type PropsType = {
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType; i18n: LocalizerType;
platform: string; platform: string;
kickOffAttachmentDownload: (options: { kickOffAttachmentDownload: (options: { messageId: string }) => void;
attachment: AttachmentType; cancelAttachmentDownload: (options: { messageId: string }) => void;
messageId: string;
}) => void;
showLightbox: (options: { showLightbox: (options: {
attachment: AttachmentType; attachment: AttachmentType;
messageId: string; messageId: string;
@ -73,6 +71,7 @@ const MESSAGE_DEFAULT_PROPS = {
}; };
export function EditHistoryMessagesModal({ export function EditHistoryMessagesModal({
cancelAttachmentDownload,
closeEditHistoryModal, closeEditHistoryModal,
getPreferredBadge, getPreferredBadge,
editHistoryMessages, editHistoryMessages,
@ -127,12 +126,8 @@ export function EditHistoryMessagesModal({
isEditedMessage isEditedMessage
isSpoilerExpanded={revealedSpoilersById[currentMessageId] || {}} isSpoilerExpanded={revealedSpoilersById[currentMessageId] || {}}
key={currentMessage.timestamp} key={currentMessage.timestamp}
kickOffAttachmentDownload={({ attachment }) => kickOffAttachmentDownload={kickOffAttachmentDownload}
kickOffAttachmentDownload({ cancelAttachmentDownload={cancelAttachmentDownload}
attachment,
messageId: currentMessage.id,
})
}
messageExpanded={(messageId, displayLimit) => { messageExpanded={(messageId, displayLimit) => {
const update = { const update = {
...displayLimitById, ...displayLimitById,
@ -195,12 +190,8 @@ export function EditHistoryMessagesModal({
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
isSpoilerExpanded={revealedSpoilersById[syntheticId] || {}} isSpoilerExpanded={revealedSpoilersById[syntheticId] || {}}
kickOffAttachmentDownload={({ attachment }) => kickOffAttachmentDownload={kickOffAttachmentDownload}
kickOffAttachmentDownload({ cancelAttachmentDownload={cancelAttachmentDownload}
attachment,
messageId: messageAttributes.id,
})
}
messageExpanded={(messageId, displayLimit) => { messageExpanded={(messageId, displayLimit) => {
const update = { const update = {
...displayLimitById, ...displayLimitById,

View file

@ -55,6 +55,7 @@ const MESSAGE_DEFAULT_PROPS = {
onToggleSelect: shouldNeverBeCalled, onToggleSelect: shouldNeverBeCalled,
onReplyToMessage: shouldNeverBeCalled, onReplyToMessage: shouldNeverBeCalled,
kickOffAttachmentDownload: shouldNeverBeCalled, kickOffAttachmentDownload: shouldNeverBeCalled,
cancelAttachmentDownload: shouldNeverBeCalled,
markAttachmentAsCorrupted: shouldNeverBeCalled, markAttachmentAsCorrupted: shouldNeverBeCalled,
messageExpanded: shouldNeverBeCalled, messageExpanded: shouldNeverBeCalled,
openGiftBadge: shouldNeverBeCalled, openGiftBadge: shouldNeverBeCalled,

View file

@ -0,0 +1,91 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import { type PropsType, AttachmentDetailPill } from './AttachmentDetailPill';
import { type ComponentMeta } from '../../storybook/types';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { fakeAttachment } from '../../test-both/helpers/fakeAttachment';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/Conversation/AttachmentDetailPill',
component: AttachmentDetailPill,
argTypes: {
isGif: { control: { type: 'boolean' } },
},
args: {
i18n,
attachments: [],
isGif: false,
startDownload: action('startDownload'),
cancelDownload: action('cancelDownload'),
},
} satisfies ComponentMeta<PropsType>;
export function NoneDefaultsBlank(args: PropsType): JSX.Element {
return <AttachmentDetailPill {...args} />;
}
export function OneDownloadedBlank(args: PropsType): JSX.Element {
return <AttachmentDetailPill {...args} attachments={[fakeAttachment()]} />;
}
export function OneNotPendingNotDownloaded(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
path: undefined,
}),
]}
/>
);
}
export function OnePendingNotDownloading(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
pending: true,
path: undefined,
}),
]}
/>
);
}
export function OneDownloading(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
pending: true,
path: undefined,
totalDownloaded: 5000,
}),
]}
/>
);
}
export function OneNotPendingSomeDownloaded(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
path: undefined,
totalDownloaded: 5000,
}),
]}
/>
);
}

View file

@ -0,0 +1,57 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { formatFileSize } from '../../util/formatFileSize';
import type { AttachmentForUIType } from '../../types/Attachment';
import type { LocalizerType } from '../../types/I18N';
export type PropsType = {
attachments: ReadonlyArray<AttachmentForUIType>;
i18n: LocalizerType;
isGif?: boolean;
startDownload: () => void;
cancelDownload: () => void;
};
export function AttachmentDetailPill({
attachments,
isGif,
}: PropsType): JSX.Element | null {
const areAllDownloaded = attachments.every(attachment => attachment.path);
const totalSize = attachments.reduce(
(total: number, attachment: AttachmentForUIType) => {
return total + (attachment.size ?? 0);
},
0
);
if (areAllDownloaded || totalSize === 0) {
return null;
}
const totalDownloadedSize = attachments.reduce(
(total: number, attachment: AttachmentForUIType) => {
return (
total +
(attachment.path ? attachment.size : (attachment.totalDownloaded ?? 0))
);
},
0
);
const areAnyPending = attachments.some(attachment => attachment.pending);
return (
<div className="AttachmentDetailPill">
<div className="AttachmentDetailPill__text-wrapper">
{totalDownloadedSize > 0 && areAnyPending
? `${formatFileSize(totalDownloadedSize, 2)} / `
: undefined}
{formatFileSize(totalSize, 2)}
{isGif ? ' · GIF' : undefined}
</div>
</div>
);
}

View file

@ -91,7 +91,6 @@ export function AttachmentList<T extends AttachmentType | AttachmentDraftType>({
isVideo || isVideo ||
attachment.pending attachment.pending
) { ) {
const isDownloaded = !attachment.pending;
const imageUrl = const imageUrl =
url || (isVideo ? BLANK_VIDEO_THUMBNAIL : undefined); url || (isVideo ? BLANK_VIDEO_THUMBNAIL : undefined);
@ -108,7 +107,6 @@ export function AttachmentList<T extends AttachmentType | AttachmentDraftType>({
className="module-staged-attachment" className="module-staged-attachment"
i18n={i18n} i18n={i18n}
attachment={attachment} attachment={attachment}
isDownloaded={isDownloaded}
curveBottomLeft={CurveType.Tiny} curveBottomLeft={CurveType.Tiny}
curveBottomRight={CurveType.Tiny} curveBottomRight={CurveType.Tiny}
curveTopLeft={CurveType.Tiny} curveTopLeft={CurveType.Tiny}
@ -118,7 +116,7 @@ export function AttachmentList<T extends AttachmentType | AttachmentDraftType>({
width={IMAGE_WIDTH} width={IMAGE_WIDTH}
url={imageUrl} url={imageUrl}
closeButton closeButton
onClick={clickAttachment} showVisualAttachment={clickAttachment}
onClickClose={closeAttachment} onClickClose={closeAttachment}
onError={closeAttachment} onError={closeAttachment}
/> />

View file

@ -1,14 +1,13 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useState, useEffect } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Blurhash } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import { Spinner } from '../Spinner';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentForUIType } from '../../types/Attachment';
import { import {
hasNotResolved, hasNotResolved,
getImageDimensions, getImageDimensions,
@ -17,21 +16,26 @@ import {
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { useReducedMotion } from '../../hooks/useReducedMotion'; import { useReducedMotion } from '../../hooks/useReducedMotion';
import { AttachmentDetailPill } from './AttachmentDetailPill';
import { getSpinner } from './Image';
const MAX_GIF_REPEAT = 4; const MAX_GIF_REPEAT = 4;
const MAX_GIF_TIME = 8; const MAX_GIF_TIME = 8;
export type Props = { export type Props = {
readonly attachment: AttachmentType; readonly attachment: AttachmentForUIType;
readonly size?: number; readonly size?: number;
readonly tabIndex: number; readonly tabIndex: number;
// test-only, to force reduced motion experience
readonly _forceTapToPlay?: boolean;
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
readonly theme?: ThemeType; readonly theme?: ThemeType;
onError(): void; onError(): void;
showVisualAttachment(): void; showVisualAttachment(): void;
kickOffAttachmentDownload(): void; startDownload(): void;
cancelDownload(): void;
}; };
type MediaEvent = React.SyntheticEvent<HTMLVideoElement, Event>; type MediaEvent = React.SyntheticEvent<HTMLVideoElement, Event>;
@ -41,16 +45,18 @@ export function GIF(props: Props): JSX.Element {
attachment, attachment,
size, size,
tabIndex, tabIndex,
_forceTapToPlay,
i18n, i18n,
theme, theme,
onError, onError,
showVisualAttachment, showVisualAttachment,
kickOffAttachmentDownload, startDownload,
cancelDownload,
} = props; } = props;
const tapToPlay = useReducedMotion(); const tapToPlay = useReducedMotion() || _forceTapToPlay;
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const { height, width } = getImageDimensions(attachment, size); const { height, width } = getImageDimensions(attachment, size);
@ -142,7 +148,7 @@ export function GIF(props: Props): JSX.Element {
event.stopPropagation(); event.stopPropagation();
if (!attachment.url) { if (!attachment.url) {
kickOffAttachmentDownload(); startDownload();
} else if (tapToPlay) { } else if (tapToPlay) {
setPlayTime(0); setPlayTime(0);
setCurrentTime(0); setCurrentTime(0);
@ -158,21 +164,18 @@ export function GIF(props: Props): JSX.Element {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
kickOffAttachmentDownload(); if (!attachment.url) {
startDownload();
} else if (tapToPlay) {
setPlayTime(0);
setCurrentTime(0);
setRepeatCount(0);
}
}; };
const isPending = Boolean(attachment.pending); const isPending = Boolean(attachment.pending);
const isNotResolved = hasNotResolved(attachment) && !isPending; const isNotResolved = hasNotResolved(attachment) && !isPending;
let fileSize: JSX.Element | undefined;
if (isNotResolved && attachment.fileSize) {
fileSize = (
<div className="module-image--gif__filesize">
{attachment.fileSize} · GIF
</div>
);
}
let gif: JSX.Element | undefined; let gif: JSX.Element | undefined;
if (isNotResolved || isPending) { if (isNotResolved || isPending) {
gif = ( gif = (
@ -208,6 +211,35 @@ export function GIF(props: Props): JSX.Element {
); );
} }
const cancelDownloadClick = useCallback(
(event: React.MouseEvent) => {
if (cancelDownload) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const cancelDownloadKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const spinner = getSpinner({
attachment,
i18n,
cancelDownloadClick,
cancelDownloadKeyDown,
tabIndex,
});
let overlay: JSX.Element | undefined; let overlay: JSX.Element | undefined;
if ((tapToPlay && !isPlaying) || isNotResolved) { if ((tapToPlay && !isPlaying) || isNotResolved) {
const className = classNames([ const className = classNames([
@ -232,26 +264,22 @@ export function GIF(props: Props): JSX.Element {
); );
} }
let spinner: JSX.Element | undefined; const detailPill = (
if (isPending) { <AttachmentDetailPill
spinner = ( attachments={[attachment]}
<div className="module-image__download-pending--spinner-container"> cancelDownload={cancelDownload}
<div i18n={i18n}
className="module-image__download-pending--spinner" isGif
title={i18n('icu:loading')} startDownload={startDownload}
> />
<Spinner moduleClassName="module-image-spinner" svgSize="small" /> );
</div>
</div>
);
}
return ( return (
<div className="module-image module-image--gif"> <div className="module-image module-image--gif">
{gif} {gif}
{overlay}
{spinner} {spinner}
{fileSize} {overlay}
{detailPill}
</div> </div>
); );
} }

View file

@ -38,11 +38,13 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
curveTopLeft: overrideProps.curveTopLeft || CurveType.None, curveTopLeft: overrideProps.curveTopLeft || CurveType.None,
curveTopRight: overrideProps.curveTopRight || CurveType.None, curveTopRight: overrideProps.curveTopRight || CurveType.None,
darkOverlay: overrideProps.darkOverlay || false, darkOverlay: overrideProps.darkOverlay || false,
height: overrideProps.height || 100, height: overrideProps.height || 200,
i18n, i18n,
noBackground: overrideProps.noBackground || false, noBackground: overrideProps.noBackground || false,
noBorder: overrideProps.noBorder || false, noBorder: overrideProps.noBorder || false,
onClick: action('onClick'), showVisualAttachment: action('showVisualAttachment'),
startDownload: action('startDownload'),
cancelDownload: action('cancelDownload'),
onClickClose: action('onClickClose'), onClickClose: action('onClickClose'),
onError: action('onError'), onError: action('onError'),
overlayText: overrideProps.overlayText || '', overlayText: overrideProps.overlayText || '',
@ -50,7 +52,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
tabIndex: overrideProps.tabIndex || 0, tabIndex: overrideProps.tabIndex || 0,
theme: overrideProps.theme || ('light' as ThemeType), theme: overrideProps.theme || ('light' as ThemeType),
url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl, url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl,
width: overrideProps.width || 100, width: overrideProps.width || 300,
}); });
export function UrlWithHeightWidth(): JSX.Element { export function UrlWithHeightWidth(): JSX.Element {
@ -107,37 +109,68 @@ export function NoBorderOrBackground(): JSX.Element {
); );
} }
export function Pending(): JSX.Element { export function NotDownloadedNotIncrementalNotPending(): JSX.Element {
const props = createProps({ const props = createProps({
attachment: fakeAttachment({ attachment: fakeAttachment({
contentType: IMAGE_PNG, contentType: IMAGE_PNG,
fileName: 'sax.png', fileName: 'sax.png',
url: pngUrl, path: undefined,
pending: true, size: 5300000,
}), }),
url: undefined,
blurHash: 'thisisafakeblurhashthatwasmadeup',
}); });
return <Image {...props} />; return <Image {...props} />;
} }
export function PendingWBlurhash(): JSX.Element { export function PendingWDownloadQueuedNotIncremental(): JSX.Element {
const props = createProps({ const props = createProps({
attachment: fakeAttachment({ attachment: fakeAttachment({
contentType: IMAGE_PNG, contentType: IMAGE_PNG,
fileName: 'sax.png', fileName: 'sax.png',
url: pngUrl, path: undefined,
pending: true, pending: true,
size: 5300000,
}), }),
url: undefined,
blurHash: 'thisisafakeblurhashthatwasmadeup',
}); });
return ( return <Image {...props} />;
<Image }
{...props}
blurHash="LDA,FDBnm+I=p{tkIUI;~UkpELV]" export function PendingWDownloadProgress(): JSX.Element {
width={300} const props = createProps({
height={400} attachment: fakeAttachment({
/> contentType: IMAGE_PNG,
); fileName: 'sax.png',
path: undefined,
pending: true,
size: 5300000,
totalDownloaded: 1230000,
}),
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
}
export function NotPendingWDownloadProgress(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
size: 5300000,
totalDownloaded: 1230000,
}),
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
} }
export function CurvedCorners(): JSX.Element { export function CurvedCorners(): JSX.Element {
@ -188,11 +221,14 @@ export function FullOverlayWithText(): JSX.Element {
} }
export function Blurhash(): JSX.Element { export function Blurhash(): JSX.Element {
const defaultProps = createProps(); const props = createProps({
const props = { attachment: fakeAttachment({
...defaultProps, contentType: IMAGE_PNG,
fileName: 'sax.png',
}),
blurHash: 'thisisafakeblurhashthatwasmadeup', blurHash: 'thisisafakeblurhashthatwasmadeup',
}; url: undefined,
});
return <Image {...props} />; return <Image {...props} />;
} }
@ -213,12 +249,10 @@ export function UndefinedBlurHash(): JSX.Element {
} }
export function MissingImage(): JSX.Element { export function MissingImage(): JSX.Element {
const defaultProps = createProps(); const props = createProps({
const props = { attachment: undefined,
...defaultProps, url: 'random',
// eslint-disable-next-line @typescript-eslint/no-explicit-any });
attachment: undefined as any,
};
return <Image {...props} />; return <Image {...props} />;
} }

View file

@ -2,17 +2,18 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import React, { useCallback, useMemo } from 'react'; import React, { useCallback } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Blurhash } from 'react-blurhash'; import { Blurhash } from 'react-blurhash';
import { Spinner } from '../Spinner'; import { Spinner } from '../Spinner';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment'; import type {
import { AttachmentForUIType,
isDownloaded as isDownloadedFunction, AttachmentType,
defaultBlurHash,
} from '../../types/Attachment'; } from '../../types/Attachment';
import { defaultBlurHash, isReadyToView } from '../../types/Attachment';
import { ProgressCircle } from '../ProgressCircle';
export enum CurveType { export enum CurveType {
None = 0, None = 0,
@ -23,10 +24,9 @@ export enum CurveType {
export type Props = { export type Props = {
alt: string; alt: string;
attachment: AttachmentType; attachment: AttachmentForUIType;
url?: string; url?: string;
isDownloaded?: boolean;
className?: string; className?: string;
height?: number; height?: number;
width?: number; width?: number;
@ -51,7 +51,9 @@ export type Props = {
i18n: LocalizerType; i18n: LocalizerType;
theme?: ThemeType; theme?: ThemeType;
onClick?: (attachment: AttachmentType) => void; showVisualAttachment?: (attachment: AttachmentType) => void;
cancelDownload?: () => void;
startDownload?: () => void;
onClickClose?: (attachment: AttachmentType) => void; onClickClose?: (attachment: AttachmentType) => void;
onError?: () => void; onError?: () => void;
}; };
@ -68,12 +70,13 @@ export function Image({
curveTopLeft, curveTopLeft,
curveTopRight, curveTopRight,
darkOverlay, darkOverlay,
isDownloaded,
height = 0, height = 0,
i18n, i18n,
noBackground, noBackground,
noBorder, noBorder,
onClick, showVisualAttachment,
startDownload,
cancelDownload,
onClickClose, onClickClose,
onError, onError,
overlayText, overlayText,
@ -85,11 +88,6 @@ export function Image({
cropWidth = 0, cropWidth = 0,
cropHeight = 0, cropHeight = 0,
}: Props): JSX.Element { }: Props): JSX.Element {
const { caption, pending } = attachment || { caption: null, pending: true };
const imgNotDownloaded = isDownloaded
? false
: !isDownloadedFunction(attachment);
const resolvedBlurHash = blurHash || defaultBlurHash(theme); const resolvedBlurHash = blurHash || defaultBlurHash(theme);
const curveStyles: CSSProperties = { const curveStyles: CSSProperties = {
@ -99,48 +97,112 @@ export function Image({
borderEndEndRadius: curveBottomRight || CurveType.None, borderEndEndRadius: curveBottomRight || CurveType.None,
}; };
const canClick = useMemo(() => { const showVisualAttachmentClick = useCallback(
return onClick != null && !pending;
}, [pending, onClick]);
const handleClick = useCallback(
(event: React.MouseEvent) => { (event: React.MouseEvent) => {
if (!canClick) { if (showVisualAttachment) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
showVisualAttachment(attachment);
return;
}
if (onClick) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
} }
}, },
[attachment, canClick, onClick] [attachment, showVisualAttachment]
); );
const showVisualAttachmentKeyDown = useCallback(
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => { (event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!canClick) { if (
showVisualAttachment &&
(event.key === 'Enter' || event.key === 'Space')
) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
showVisualAttachment(attachment);
return;
}
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
} }
}, },
[attachment, canClick, onClick] [attachment, showVisualAttachment]
);
const cancelDownloadClick = useCallback(
(event: React.MouseEvent) => {
if (cancelDownload) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const cancelDownloadKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const startDownloadClick = useCallback(
(event: React.MouseEvent) => {
if (startDownload) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
const startDownloadKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (startDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
); );
/* eslint-disable no-nested-ternary */ const imageOrBlurHash = url ? (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : (
<Blurhash
hash={resolvedBlurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
);
const startDownloadButton =
startDownload && !attachment.path && !attachment.pending ? (
<button
type="button"
className="module-image__overlay-circle"
aria-label={i18n('icu:startDownload')}
onClick={startDownloadClick}
onKeyDown={startDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__download-icon" />
</button>
) : undefined;
const spinner = !cancelDownload
? undefined
: getSpinner({
attachment,
i18n,
cancelDownloadClick,
cancelDownloadKeyDown,
tabIndex,
});
return ( return (
<div <div
className={classNames( className={classNames(
@ -155,70 +217,11 @@ export function Image({
...curveStyles, ...curveStyles,
}} }}
> >
{pending ? ( {imageOrBlurHash}
url || blurHash ? ( {startDownloadButton}
<div className="module-image__download-pending"> {spinner}
{url ? (
<img {attachment.caption ? (
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : blurHash ? (
<Blurhash
hash={blurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : undefined}
<div className="module-image__download-pending--spinner-container">
<div
className="module-image__download-pending--spinner"
title={i18n('icu:loading')}
>
<Spinner
moduleClassName="module-image-spinner"
svgSize="small"
/>
</div>
</div>
</div>
) : (
<div
className="module-image__loading-placeholder"
style={{
height: `${height}px`,
width: `${width}px`,
lineHeight: `${height}px`,
textAlign: 'center',
}}
title={i18n('icu:loading')}
>
<Spinner svgSize="normal" />
</div>
)
) : url ? (
<img
onError={onError}
className="module-image__image"
alt={alt}
height={height}
width={width}
src={url}
/>
) : resolvedBlurHash ? (
<Blurhash
hash={resolvedBlurHash}
width={width}
height={height}
style={{ display: 'block' }}
/>
) : null}
{caption ? (
<img <img
className="module-image__caption-icon" className="module-image__caption-icon"
src="images/caption-shadow.svg" src="images/caption-shadow.svg"
@ -234,9 +237,9 @@ export function Image({
}} }}
/> />
) : null} ) : null}
{!pending && !imgNotDownloaded && playIconOverlay ? ( {attachment.path && playIconOverlay ? (
<div className="module-image__play-overlay__circle"> <div className="module-image__overlay-circle">
<div className="module-image__play-overlay__icon" /> <div className="module-image__play-icon" />
</div> </div>
) : null} ) : null}
{overlayText ? ( {overlayText ? (
@ -247,22 +250,27 @@ export function Image({
{overlayText} {overlayText}
</div> </div>
) : null} ) : null}
{canClick ? ( {darkOverlay || !noBorder ? (
<div
className={classNames('module-image__border-overlay', {
'module-image__border-overlay--with-border': !noBorder,
'module-image__border-overlay--dark': darkOverlay,
})}
style={curveStyles}
/>
) : null}
{showVisualAttachment && isReadyToView(attachment) ? (
<button <button
type="button" type="button"
className={classNames('module-image__border-overlay', { className={classNames('module-image__border-overlay', {
'module-image__border-overlay--with-border': !noBorder, 'module-image__border-overlay--with-click-handler': true,
'module-image__border-overlay--with-click-handler': canClick,
'module-image__border-overlay--dark': darkOverlay,
'module-image--not-downloaded': imgNotDownloaded,
})} })}
aria-label={i18n('icu:imageOpenAlt')}
style={curveStyles} style={curveStyles}
onClick={handleClick} onClick={showVisualAttachmentClick}
onKeyDown={handleKeyDown} onKeyDown={showVisualAttachmentKeyDown}
tabIndex={tabIndex} tabIndex={tabIndex}
> />
{imgNotDownloaded ? <span /> : null}
</button>
) : null} ) : null}
{closeButton ? ( {closeButton ? (
<button <button
@ -282,5 +290,71 @@ export function Image({
) : null} ) : null}
</div> </div>
); );
/* eslint-enable no-nested-ternary */ }
export function getSpinner({
attachment,
cancelDownloadClick,
cancelDownloadKeyDown,
i18n,
tabIndex,
}: {
attachment: AttachmentForUIType;
cancelDownloadClick: (event: React.MouseEvent) => void;
cancelDownloadKeyDown: (
event: React.KeyboardEvent<HTMLButtonElement>
) => void;
i18n: LocalizerType;
tabIndex: number | undefined;
}): JSX.Element | undefined {
const downloadFraction =
attachment.pending && attachment.size && attachment.totalDownloaded
? attachment.totalDownloaded / attachment.size
: undefined;
if (downloadFraction) {
return (
<button
type="button"
className="module-image__overlay-circle"
aria-label={i18n('icu:cancelDownload')}
onClick={cancelDownloadClick}
onKeyDown={cancelDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__stop-icon" />
<div className="module-image__progress-circle-wrapper">
<ProgressCircle
fractionComplete={downloadFraction}
width={44}
strokeWidth={2}
/>
</div>
</button>
);
}
if (!attachment.pending) {
return undefined;
}
return (
<button
type="button"
className="module-image__overlay-circle"
aria-label={i18n('icu:cancelDownload')}
onClick={cancelDownloadClick}
onKeyDown={cancelDownloadKeyDown}
tabIndex={tabIndex}
>
<div className="module-image__spinner-container">
<Spinner
moduleClassName="module-image-spinner"
svgSize="normal"
size="44px"
/>
<div className="module-image__stop-icon" />
</div>
</button>
);
} }

View file

@ -44,7 +44,9 @@ export default {
direction: 'incoming', direction: 'incoming',
i18n, i18n,
isSticker: false, isSticker: false,
onClick: action('onClick'), showVisualAttachment: action('showVisualAttachment'),
startDownload: action('startDownload'),
cancelDownload: action('cancelDownload'),
onError: action('onError'), onError: action('onError'),
stickerSize: 0, stickerSize: 0,
tabIndex: 0, tabIndex: 0,
@ -57,13 +59,111 @@ export function OneImage(args: Props): JSX.Element {
return <ImageGrid {...args} />; return <ImageGrid {...args} />;
} }
export function OneVideo(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
url: pngUrl,
width: 800,
screenshot: {
path: 'something',
url: pngUrl,
contentType: IMAGE_PNG,
height: 1200,
width: 800,
},
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoNotDownloadedNotPending(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoPendingWDownloadQueued(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
url: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoDownloadProgressNotPending(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function TwoImages(args: Props): JSX.Element { export function TwoImages(args: Props): JSX.Element {
return ( return (
<ImageGrid <ImageGrid
{...args} {...args}
attachments={[ attachments={[
fakeAttachment({ fakeAttachment({
contentType: IMAGE_PNG, contentType: VIDEO_MP4,
fileName: 'sax.png', fileName: 'sax.png',
height: 1200, height: 1200,
url: pngUrl, url: pngUrl,
@ -81,6 +181,62 @@ export function TwoImages(args: Props): JSX.Element {
); );
} }
export function TwoImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function TwoImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function ThreeImages(args: Props): JSX.Element { export function ThreeImages(args: Props): JSX.Element {
return ( return (
<ImageGrid <ImageGrid
@ -112,6 +268,74 @@ export function ThreeImages(args: Props): JSX.Element {
); );
} }
export function ThreeImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
],
};
return <ImageGrid {...props} />;
}
export function ThreeImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function FourImages(args: Props): JSX.Element { export function FourImages(args: Props): JSX.Element {
return ( return (
<ImageGrid <ImageGrid
@ -150,6 +374,89 @@ export function FourImages(args: Props): JSX.Element {
); );
} }
export function FourImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
],
};
return <ImageGrid {...props} />;
}
export function FourImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function FiveImages(args: Props): JSX.Element { export function FiveImages(args: Props): JSX.Element {
return ( return (
<ImageGrid <ImageGrid
@ -195,6 +502,104 @@ export function FiveImages(args: Props): JSX.Element {
); );
} }
export function FiveImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
],
};
return <ImageGrid {...props} />;
}
export function FiveImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export const _6Images = (args: Props): JSX.Element => { export const _6Images = (args: Props): JSX.Element => {
return ( return (
<ImageGrid <ImageGrid
@ -254,6 +659,63 @@ export const _6Images = (args: Props): JSX.Element => {
); );
}; };
export function _6ImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
url: '/fixtures/tina-rolf-269345-unsplash.jpg',
width: 3000,
}),
fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
totalDownloaded: 300000,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function MixedContentTypes(args: Props): JSX.Element { export function MixedContentTypes(args: Props): JSX.Element {
return ( return (
<ImageGrid <ImageGrid
@ -295,6 +757,80 @@ export function MixedContentTypes(args: Props): JSX.Element {
); );
} }
export function EightImagesNotDownloaded(args: Props): JSX.Element {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
width: 800,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
height: 1680,
width: 3000,
path: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
]}
/>
);
}
export function Sticker(args: Props): JSX.Element { export function Sticker(args: Props): JSX.Element {
return ( return (
<ImageGrid <ImageGrid

View file

@ -20,6 +20,7 @@ import {
import { Image, CurveType } from './Image'; import { Image, CurveType } from './Image';
import type { LocalizerType, ThemeType } from '../../types/Util'; import type { LocalizerType, ThemeType } from '../../types/Util';
import { AttachmentDetailPill } from './AttachmentDetailPill';
export type DirectionType = 'incoming' | 'outgoing'; export type DirectionType = 'incoming' | 'outgoing';
@ -39,7 +40,9 @@ export type Props = {
theme?: ThemeType; theme?: ThemeType;
onError: () => void; onError: () => void;
onClick?: (attachment: AttachmentType) => void; showVisualAttachment: (attachment: AttachmentType) => void;
cancelDownload: () => void;
startDownload: () => void;
}; };
const GAP = 1; const GAP = 1;
@ -108,7 +111,9 @@ export function ImageGrid({
isSticker, isSticker,
stickerSize, stickerSize,
onError, onError,
onClick, showVisualAttachment,
cancelDownload,
startDownload,
shouldCollapseAbove, shouldCollapseAbove,
shouldCollapseBelow, shouldCollapseBelow,
tabIndex, tabIndex,
@ -127,10 +132,46 @@ export function ImageGrid({
const withBottomOverlay = Boolean(bottomOverlay && !withContentBelow); const withBottomOverlay = Boolean(bottomOverlay && !withContentBelow);
const startDownloadClick = React.useCallback(
(event: React.MouseEvent) => {
if (startDownload) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
const startDownloadKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (startDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
if (!attachments || !attachments.length) { if (!attachments || !attachments.length) {
return null; return null;
} }
const detailPill = (
<AttachmentDetailPill
attachments={attachments}
i18n={i18n}
startDownload={startDownload}
cancelDownload={cancelDownload}
/>
);
const downloadPill = renderDownloadPill({
attachments,
i18n,
startDownloadClick,
startDownloadKeyDown,
});
if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) { if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) {
const { height, width } = getImageDimensions( const { height, width } = getImageDimensions(
attachments[0], attachments[0],
@ -165,9 +206,12 @@ export function ImageGrid({
getUrl(attachments[0]) ?? attachments[0].thumbnailFromBackup?.url getUrl(attachments[0]) ?? attachments[0].thumbnailFromBackup?.url
} }
tabIndex={tabIndex} tabIndex={tabIndex}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={startDownload}
onError={onError} onError={onError}
/> />
{detailPill}
</div> </div>
); );
} }
@ -190,7 +234,9 @@ export function ImageGrid({
width={150} width={150}
cropWidth={GAP} cropWidth={GAP}
url={getThumbnailUrl(attachments[0])} url={getThumbnailUrl(attachments[0])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<Image <Image
@ -207,9 +253,13 @@ export function ImageGrid({
width={150} width={150}
attachment={attachments[1]} attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
{detailPill}
{downloadPill}
</div> </div>
); );
} }
@ -232,7 +282,9 @@ export function ImageGrid({
width={200} width={200}
cropWidth={GAP} cropWidth={GAP}
url={getUrl(attachments[0])} url={getUrl(attachments[0])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<div className="module-image-grid__column"> <div className="module-image-grid__column">
@ -248,7 +300,9 @@ export function ImageGrid({
attachment={attachments[1]} attachment={attachments[1]}
playIconOverlay={isVideoAttachment(attachments[1])} playIconOverlay={isVideoAttachment(attachments[1])}
url={getThumbnailUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<Image <Image
@ -264,10 +318,14 @@ export function ImageGrid({
attachment={attachments[2]} attachment={attachments[2]}
playIconOverlay={isVideoAttachment(attachments[2])} playIconOverlay={isVideoAttachment(attachments[2])}
url={getThumbnailUrl(attachments[2])} url={getThumbnailUrl(attachments[2])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
</div> </div>
{detailPill}
{downloadPill}
</div> </div>
); );
} }
@ -291,7 +349,9 @@ export function ImageGrid({
cropHeight={GAP} cropHeight={GAP}
cropWidth={GAP} cropWidth={GAP}
url={getThumbnailUrl(attachments[0])} url={getThumbnailUrl(attachments[0])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<Image <Image
@ -307,7 +367,9 @@ export function ImageGrid({
cropHeight={GAP} cropHeight={GAP}
attachment={attachments[1]} attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
</div> </div>
@ -326,7 +388,9 @@ export function ImageGrid({
cropWidth={GAP} cropWidth={GAP}
attachment={attachments[2]} attachment={attachments[2]}
url={getThumbnailUrl(attachments[2])} url={getThumbnailUrl(attachments[2])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<Image <Image
@ -342,11 +406,15 @@ export function ImageGrid({
width={150} width={150}
attachment={attachments[3]} attachment={attachments[3]}
url={getThumbnailUrl(attachments[3])} url={getThumbnailUrl(attachments[3])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
</div> </div>
</div> </div>
{detailPill}
{downloadPill}
</div> </div>
); );
} }
@ -372,7 +440,9 @@ export function ImageGrid({
width={150} width={150}
cropWidth={GAP} cropWidth={GAP}
url={getThumbnailUrl(attachments[0])} url={getThumbnailUrl(attachments[0])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<Image <Image
@ -386,7 +456,9 @@ export function ImageGrid({
width={150} width={150}
attachment={attachments[1]} attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])} url={getThumbnailUrl(attachments[1])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
</div> </div>
@ -405,7 +477,9 @@ export function ImageGrid({
cropWidth={GAP} cropWidth={GAP}
attachment={attachments[2]} attachment={attachments[2]}
url={getThumbnailUrl(attachments[2])} url={getThumbnailUrl(attachments[2])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<Image <Image
@ -421,7 +495,9 @@ export function ImageGrid({
cropWidth={GAP} cropWidth={GAP}
attachment={attachments[3]} attachment={attachments[3]}
url={getThumbnailUrl(attachments[3])} url={getThumbnailUrl(attachments[3])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError} onError={onError}
/> />
<Image <Image
@ -439,11 +515,51 @@ export function ImageGrid({
overlayText={moreMessagesOverlayText} overlayText={moreMessagesOverlayText}
attachment={attachments[4]} attachment={attachments[4]}
url={getThumbnailUrl(attachments[4])} url={getThumbnailUrl(attachments[4])}
onClick={onClick} showVisualAttachment={showVisualAttachment}
cancelDownload={undefined}
startDownload={undefined}
onError={onError} onError={onError}
/> />
</div> </div>
</div> </div>
{detailPill}
{downloadPill}
</div> </div>
); );
} }
function renderDownloadPill({
attachments,
i18n,
startDownloadClick,
startDownloadKeyDown,
}: {
attachments: ReadonlyArray<AttachmentForUIType>;
i18n: LocalizerType;
startDownloadClick: (event: React.MouseEvent) => void;
startDownloadKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
}): JSX.Element | null {
const downloadedOrPending = attachments.some(
attachment => attachment.path || attachment.pending
);
if (downloadedOrPending) {
return null;
}
return (
<button
type="button"
className="module-image-grid__download-pill"
aria-label={i18n('icu:startDownload')}
onClick={startDownloadClick}
onKeyDown={startDownloadKeyDown}
>
<div className="module-image-grid__download_pill__icon-wrapper">
<div className="module-image-grid__download_pill__download-icon" />
</div>
<div className="module-image-grid__download_pill__text-wrapper">
{i18n('icu:downloadNItems', { count: attachments.length })}
</div>
</button>
);
}

View file

@ -13,7 +13,7 @@ import React from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import classNames from 'classnames'; import classNames from 'classnames';
import getDirection from 'direction'; import getDirection from 'direction';
import { drop, groupBy, noop, orderBy, take, unescape } from 'lodash'; import { drop, groupBy, orderBy, take, unescape } from 'lodash';
import { Manager, Popper, Reference } from 'react-popper'; import { Manager, Popper, Reference } from 'react-popper';
import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow'; import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
@ -52,7 +52,10 @@ import type { WidthBreakpoint } from '../_util';
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal'; import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { StoryViewModeType } from '../../types/Stories'; import { StoryViewModeType } from '../../types/Stories';
import type { AttachmentType } from '../../types/Attachment'; import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import { import {
canDisplayImage, canDisplayImage,
getExtensionForDisplay, getExtensionForDisplay,
@ -101,6 +104,7 @@ import { UserText } from '../UserText';
import { getColorForCallLink } from '../../util/getColorForCallLink'; import { getColorForCallLink } from '../../util/getColorForCallLink';
import { getKeyFromCallLink } from '../../util/callLinks'; import { getKeyFromCallLink } from '../../util/callLinks';
import { InAnotherCallTooltip } from './InAnotherCallTooltip'; import { InAnotherCallTooltip } from './InAnotherCallTooltip';
import { formatFileSize } from '../../util/formatFileSize';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@ -173,7 +177,7 @@ export type AudioAttachmentProps = {
i18n: LocalizerType; i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>; buttonRef: React.RefObject<HTMLButtonElement>;
theme: ThemeType | undefined; theme: ThemeType | undefined;
attachment: AttachmentType; attachment: AttachmentForUIType;
collapseMetadata: boolean; collapseMetadata: boolean;
withContentAbove: boolean; withContentAbove: boolean;
withContentBelow: boolean; withContentBelow: boolean;
@ -226,7 +230,7 @@ export type PropsData = {
activeCallConversationId?: string; activeCallConversationId?: string;
text?: string; text?: string;
textDirection: TextDirection; textDirection: TextDirection;
textAttachment?: AttachmentType; textAttachment?: AttachmentForUIType;
isEditedMessage?: boolean; isEditedMessage?: boolean;
isSticker?: boolean; isSticker?: boolean;
isTargeted?: boolean; isTargeted?: boolean;
@ -255,7 +259,7 @@ export type PropsData = {
| 'unblurredAvatarUrl' | 'unblurredAvatarUrl'
>; >;
conversationType: ConversationTypeType; conversationType: ConversationTypeType;
attachments?: ReadonlyArray<AttachmentType>; attachments?: ReadonlyArray<AttachmentForUIType>;
giftBadge?: GiftBadgeType; giftBadge?: GiftBadgeType;
payment?: AnyPaymentEvent; payment?: AnyPaymentEvent;
quote?: { quote?: {
@ -312,6 +316,8 @@ export type PropsData = {
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void; onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
item?: never; item?: never;
// test-only, to force GIF's reduced motion experience
_forceTapToPlay?: boolean;
}; };
export type PropsHousekeeping = { export type PropsHousekeeping = {
@ -344,10 +350,8 @@ export type PropsActions = {
showContactModal: (contactId: string, conversationId?: string) => void; showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string, data: Record<number, boolean>) => void; showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
kickOffAttachmentDownload: (options: { cancelAttachmentDownload: (options: { messageId: string }) => void;
attachment: AttachmentType; kickOffAttachmentDownload: (options: { messageId: string }) => void;
messageId: string;
}) => void;
markAttachmentAsCorrupted: (options: { markAttachmentAsCorrupted: (options: {
attachment: AttachmentType; attachment: AttachmentType;
messageId: string; messageId: string;
@ -919,10 +923,12 @@ export class Message extends React.PureComponent<Props, State> {
const { const {
attachments, attachments,
attachmentDroppedDueToSize, attachmentDroppedDueToSize,
cancelAttachmentDownload,
conversationId, conversationId,
direction, direction,
expirationLength, expirationLength,
expirationTimestamp, expirationTimestamp,
_forceTapToPlay,
i18n, i18n,
id, id,
isSticker, isSticker,
@ -978,9 +984,10 @@ export class Message extends React.PureComponent<Props, State> {
<GIF <GIF
attachment={firstAttachment} attachment={firstAttachment}
size={GIF_SIZE} size={GIF_SIZE}
tabIndex={0}
_forceTapToPlay={_forceTapToPlay}
theme={theme} theme={theme}
i18n={i18n} i18n={i18n}
tabIndex={0}
onError={this.handleImageError} onError={this.handleImageError}
showVisualAttachment={() => { showVisualAttachment={() => {
showLightbox({ showLightbox({
@ -988,9 +995,13 @@ export class Message extends React.PureComponent<Props, State> {
messageId: id, messageId: id,
}); });
}} }}
kickOffAttachmentDownload={() => { startDownload={() => {
kickOffAttachmentDownload({ kickOffAttachmentDownload({
attachment: firstAttachment, messageId: id,
});
}}
cancelDownload={() => {
cancelAttachmentDownload({
messageId: id, messageId: id,
}); });
}} }}
@ -1026,12 +1037,14 @@ export class Message extends React.PureComponent<Props, State> {
shouldCollapseAbove={shouldCollapseAbove} shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow} shouldCollapseBelow={shouldCollapseBelow}
tabIndex={tabIndex} tabIndex={tabIndex}
onClick={attachment => { showVisualAttachment={attachment => {
if (!isDownloaded(attachment)) { showLightbox({ attachment, messageId: id });
kickOffAttachmentDownload({ attachment, messageId: id }); }}
} else { startDownload={() => {
showLightbox({ attachment, messageId: id }); kickOffAttachmentDownload({ messageId: id });
} }}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}} }}
/> />
</div> </div>
@ -1063,10 +1076,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp, timestamp,
kickOffAttachmentDownload() { kickOffAttachmentDownload() {
kickOffAttachmentDownload({ kickOffAttachmentDownload({ messageId: id });
attachment: firstAttachment,
messageId: id,
});
}, },
onCorrupted() { onCorrupted() {
markAttachmentAsCorrupted({ markAttachmentAsCorrupted({
@ -1076,7 +1086,7 @@ export class Message extends React.PureComponent<Props, State> {
}, },
}); });
} }
const { pending, fileName, fileSize, contentType } = firstAttachment; const { pending, fileName, size, contentType } = firstAttachment;
const extension = getExtensionForDisplay({ contentType, fileName }); const extension = getExtensionForDisplay({ contentType, fileName });
const isDangerous = isFileDangerous(fileName || ''); const isDangerous = isFileDangerous(fileName || '');
@ -1100,7 +1110,6 @@ export class Message extends React.PureComponent<Props, State> {
if (!isDownloaded(firstAttachment)) { if (!isDownloaded(firstAttachment)) {
kickOffAttachmentDownload({ kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id, messageId: id,
}); });
} else { } else {
@ -1143,7 +1152,7 @@ export class Message extends React.PureComponent<Props, State> {
`module-message__generic-attachment__file-size--${direction}` `module-message__generic-attachment__file-size--${direction}`
)} )}
> >
{fileSize} {formatFileSize(size)}
</div> </div>
</div> </div>
</button> </button>
@ -1158,6 +1167,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n, i18n,
id, id,
kickOffAttachmentDownload, kickOffAttachmentDownload,
cancelAttachmentDownload,
previews, previews,
quote, quote,
shouldCollapseAbove, shouldCollapseAbove,
@ -1209,18 +1219,6 @@ export class Message extends React.PureComponent<Props, State> {
'module-message__link-preview--nonclickable': !isClickable, 'module-message__link-preview--nonclickable': !isClickable,
} }
); );
const onPreviewImageClick = isClickable
? () => {
if (first.image && !isDownloaded(first.image)) {
kickOffAttachmentDownload({
attachment: first.image,
messageId: id,
});
return;
}
openLinkInWebBrowser(first.url);
}
: noop;
const contents = ( const contents = (
<> <>
{first.image && previewHasImage && isFullSizeImage ? ( {first.image && previewHasImage && isFullSizeImage ? (
@ -1233,7 +1231,15 @@ export class Message extends React.PureComponent<Props, State> {
onError={this.handleImageError} onError={this.handleImageError}
i18n={i18n} i18n={i18n}
theme={theme} theme={theme}
onClick={onPreviewImageClick} showVisualAttachment={() => {
openLinkInWebBrowser(first.url);
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/> />
) : null} ) : null}
<div dir="auto" className="module-message__link-preview__content"> <div dir="auto" className="module-message__link-preview__content">
@ -1261,7 +1267,15 @@ export class Message extends React.PureComponent<Props, State> {
blurHash={first.image.blurHash} blurHash={first.image.blurHash}
onError={this.handleImageError} onError={this.handleImageError}
i18n={i18n} i18n={i18n}
onClick={onPreviewImageClick} showVisualAttachment={() => {
openLinkInWebBrowser(first.url);
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/> />
</div> </div>
) : null} ) : null}
@ -1970,7 +1984,6 @@ export class Message extends React.PureComponent<Props, State> {
return; return;
} }
kickOffAttachmentDownload({ kickOffAttachmentDownload({
attachment: textAttachment,
messageId: id, messageId: id,
}); });
}} }}
@ -2574,10 +2587,7 @@ export class Message extends React.PureComponent<Props, State> {
} }
if (attachments && !isDownloaded(attachments[0])) { if (attachments && !isDownloaded(attachments[0])) {
kickOffAttachmentDownload({ kickOffAttachmentDownload({ messageId: id });
attachment: attachments[0],
messageId: id,
});
return; return;
} }
@ -2597,9 +2607,7 @@ export class Message extends React.PureComponent<Props, State> {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
const attachment = attachments[0]; kickOffAttachmentDownload({ messageId: id });
kickOffAttachmentDownload({ attachment, messageId: id });
return; return;
} }
@ -2699,10 +2707,7 @@ export class Message extends React.PureComponent<Props, State> {
const attachment = attachments[0]; const attachment = attachments[0];
if (!isDownloaded(attachment)) { if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({ kickOffAttachmentDownload({ messageId: id });
attachment,
messageId: id,
});
return; return;
} }

View file

@ -85,6 +85,7 @@ export type PropsSmartActions = Pick<MessagePropsType, 'renderAudioAttachment'>;
export type PropsReduxActions = Pick< export type PropsReduxActions = Pick<
MessagePropsType, MessagePropsType,
| 'cancelAttachmentDownload'
| 'checkForAccount' | 'checkForAccount'
| 'clearTargetedMessage' | 'clearTargetedMessage'
| 'doubleCheckMissingQuoteReference' | 'doubleCheckMissingQuoteReference'
@ -125,6 +126,7 @@ export function MessageDetail({
message, message,
receivedAt, receivedAt,
sentAt, sentAt,
cancelAttachmentDownload,
checkForAccount, checkForAccount,
clearTargetedMessage, clearTargetedMessage,
contactNameColor, contactNameColor,
@ -330,6 +332,7 @@ export function MessageDetail({
<Message <Message
{...message} {...message}
renderingContext="conversation/MessageDetail" renderingContext="conversation/MessageDetail"
cancelAttachmentDownload={cancelAttachmentDownload}
checkForAccount={checkForAccount} checkForAccount={checkForAccount}
clearTargetedMessage={clearTargetedMessage} clearTargetedMessage={clearTargetedMessage}
contactNameColor={contactNameColor} contactNameColor={contactNameColor}

View file

@ -108,6 +108,7 @@ const defaultMessageProps: TimelineMessagesProps = {
isSMS: false, isSMS: false,
isSpoilerExpanded: {}, isSpoilerExpanded: {},
toggleSelectMessage: action('toggleSelectMessage'), toggleSelectMessage: action('toggleSelectMessage'),
cancelAttachmentDownload: action('default--cancelAttachmentDownload'),
kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'), kickOffAttachmentDownload: action('default--kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('default--markAttachmentAsCorrupted'),
messageExpanded: action('default--message-expanded'), messageExpanded: action('default--message-expanded'),

View file

@ -296,6 +296,7 @@ const actions = () => ({
showContactDetail: action('showContactDetail'), showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'), showContactModal: action('showContactModal'),
showConversation: action('showConversation'), showConversation: action('showConversation'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'), messageExpanded: action('messageExpanded'),

View file

@ -76,6 +76,7 @@ const getDefaultProps = () => ({
retryDeleteForEveryone: action('retryDeleteForEveryone'), retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'), retryMessageSend: action('retryMessageSend'),
blockGroupLinkRequests: action('blockGroupLinkRequests'), blockGroupLinkRequests: action('blockGroupLinkRequests'),
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'), messageExpanded: action('messageExpanded'),

View file

@ -300,6 +300,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isTapToView: overrideProps.isTapToView, isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError, isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired, isTapToViewExpired: overrideProps.isTapToViewExpired,
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'), messageExpanded: action('messageExpanded'),
@ -1400,6 +1401,22 @@ Gif.args = {
status: 'sent', status: 'sent',
}; };
export const GifReducedMotion = Template.bind({});
GifReducedMotion.args = {
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
url: '/fixtures/cat-gif.mp4',
width: 400,
height: 332,
}),
],
status: 'sent',
_forceTapToPlay: true,
};
export const GifInAGroup = Template.bind({}); export const GifInAGroup = Template.bind({});
GifInAGroup.args = { GifInAGroup.args = {
attachments: [ attachments: [
@ -1423,10 +1440,10 @@ NotDownloadedGif.args = {
contentType: VIDEO_MP4, contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF, flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4', fileName: 'cat-gif.mp4',
fileSize: '188.61 KB',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400, width: 400,
height: 332, height: 332,
path: undefined,
}), }),
], ],
status: 'sent', status: 'sent',
@ -1440,10 +1457,48 @@ PendingGif.args = {
contentType: VIDEO_MP4, contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF, flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4', fileName: 'cat-gif.mp4',
fileSize: '188.61 KB', size: 188610,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400, width: 400,
height: 332, height: 332,
path: undefined,
}),
],
status: 'sent',
};
export const DownloadingGif = Template.bind({});
DownloadingGif.args = {
attachments: [
fakeAttachment({
pending: true,
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
size: 188610,
totalDownloaded: 101010,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
height: 332,
path: undefined,
}),
],
status: 'sent',
};
export const PartialDownloadNotPendingGif = Template.bind({});
PartialDownloadNotPendingGif.args = {
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
size: 188610,
totalDownloaded: 101010,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
height: 332,
path: undefined,
}), }),
], ],
status: 'sent', status: 'sent',
@ -1553,7 +1608,6 @@ OtherFileType.args = {
contentType: stringToMIMEType('text/plain'), contentType: stringToMIMEType('text/plain'),
fileName: 'my-resume.txt', fileName: 'my-resume.txt',
url: 'my-resume.txt', url: 'my-resume.txt',
fileSize: '10MB',
}), }),
], ],
status: 'sent', status: 'sent',
@ -1566,7 +1620,6 @@ OtherFileTypeWithCaption.args = {
contentType: stringToMIMEType('text/plain'), contentType: stringToMIMEType('text/plain'),
fileName: 'my-resume.txt', fileName: 'my-resume.txt',
url: 'my-resume.txt', url: 'my-resume.txt',
fileSize: '10MB',
}), }),
], ],
status: 'sent', status: 'sent',
@ -1581,7 +1634,6 @@ OtherFileTypeWithLongFilename.args = {
fileName: fileName:
'INSERT-APP-NAME_INSERT-APP-APPLE-ID_AppStore_AppsGamesWatch.psd.zip', 'INSERT-APP-NAME_INSERT-APP-APPLE-ID_AppStore_AppsGamesWatch.psd.zip',
url: 'a2/a2334324darewer4234', url: 'a2/a2334324darewer4234',
fileSize: '10MB',
}), }),
], ],
status: 'sent', status: 'sent',

View file

@ -221,10 +221,7 @@ export function TimelineMessage(props: Props): JSX.Element {
// check if any attachment needs to be downloaded from servers // check if any attachment needs to be downloaded from servers
for (const attachment of attachments) { for (const attachment of attachments) {
if (!isDownloaded(attachment)) { if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({ kickOffAttachmentDownload({ messageId: id });
attachment,
messageId: id,
});
attachmentsInProgress += 1; attachmentsInProgress += 1;
} }

View file

@ -3461,7 +3461,10 @@ async function appendChangeMessages(
let newMessages = 0; let newMessages = 0;
for (const changeMessage of mergedMessages) { for (const changeMessage of mergedMessages) {
const existing = window.MessageCache.__DEPRECATED$getById(changeMessage.id); const existing = window.MessageCache.__DEPRECATED$getById(
changeMessage.id,
'appendChangeMessages'
);
// Update existing message // Update existing message
if (existing) { if (existing) {

View file

@ -190,7 +190,10 @@ type RunAttachmentBackupJobDependenciesType = {
export async function runAttachmentBackupJob( export async function runAttachmentBackupJob(
job: AttachmentBackupJobType, job: AttachmentBackupJobType,
_isLastAttempt: boolean, _options: {
isLastAttempt: boolean;
abortSignal: AbortSignal;
},
dependencies: RunAttachmentBackupJobDependenciesType = { dependencies: RunAttachmentBackupJobDependenciesType = {
getAbsoluteAttachmentPath: getAbsoluteAttachmentPath:
window.Signal.Migrations.getAbsoluteAttachmentPath, window.Signal.Migrations.getAbsoluteAttachmentPath,

View file

@ -1,6 +1,6 @@
// 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 { omit } from 'lodash'; import { debounce, noop, omit } from 'lodash';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
import * as log from '../logging/log'; import * as log from '../logging/log';
@ -37,6 +37,7 @@ import {
JobManager, JobManager,
type JobManagerParamsType, type JobManagerParamsType,
type JobManagerJobResultType, type JobManagerJobResultType,
type JobManagerJobType,
} from './JobManager'; } from './JobManager';
import { import {
isImageTypeSupported, isImageTypeSupported,
@ -93,7 +94,10 @@ type AttachmentDownloadManagerParamsType = Omit<
runDownloadAttachmentJob: (args: { runDownloadAttachmentJob: (args: {
job: AttachmentDownloadJobType; job: AttachmentDownloadJobType;
isLastAttempt: boolean; isLastAttempt: boolean;
options?: { isForCurrentlyVisibleMessage: boolean }; options: {
abortSignal: AbortSignal;
isForCurrentlyVisibleMessage: boolean;
};
dependencies?: DependenciesType; dependencies?: DependenciesType;
}) => Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>>; }) => Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>>;
}; };
@ -164,7 +168,13 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
timestamp: Date.now(), timestamp: Date.now(),
}); });
}, },
runJob: (job: AttachmentDownloadJobType, isLastAttempt: boolean) => { runJob: (
job: AttachmentDownloadJobType,
{
abortSignal,
isLastAttempt,
}: { abortSignal: AbortSignal; isLastAttempt: boolean }
) => {
const isForCurrentlyVisibleMessage = this.visibleTimelineMessages.has( const isForCurrentlyVisibleMessage = this.visibleTimelineMessages.has(
job.messageId job.messageId
); );
@ -172,6 +182,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
job, job,
isLastAttempt, isLastAttempt,
options: { options: {
abortSignal,
isForCurrentlyVisibleMessage, isForCurrentlyVisibleMessage,
}, },
}); });
@ -268,6 +279,14 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
return AttachmentDownloadManager.instance.addJob(newJob); return AttachmentDownloadManager.instance.addJob(newJob);
} }
static async cancelJobs(
predicate: (
job: CoreAttachmentDownloadJobType & JobManagerJobType
) => boolean
): Promise<void> {
return AttachmentDownloadManager.instance.cancelJobs(predicate);
}
static updateVisibleTimelineMessages(messageIds: Array<string>): void { static updateVisibleTimelineMessages(messageIds: Array<string>): void {
AttachmentDownloadManager.instance.updateVisibleTimelineMessages( AttachmentDownloadManager.instance.updateVisibleTimelineMessages(
messageIds messageIds
@ -283,6 +302,7 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
} }
type DependenciesType = { type DependenciesType = {
deleteDownloadData: typeof window.Signal.Migrations.deleteDownloadData;
downloadAttachment: typeof downloadAttachmentUtil; downloadAttachment: typeof downloadAttachmentUtil;
processNewAttachment: typeof window.Signal.Migrations.processNewAttachment; processNewAttachment: typeof window.Signal.Migrations.processNewAttachment;
}; };
@ -291,19 +311,26 @@ async function runDownloadAttachmentJob({
isLastAttempt, isLastAttempt,
options, options,
dependencies = { dependencies = {
deleteDownloadData: window.Signal.Migrations.deleteDownloadData,
downloadAttachment: downloadAttachmentUtil, downloadAttachment: downloadAttachmentUtil,
processNewAttachment: window.Signal.Migrations.processNewAttachment, processNewAttachment: window.Signal.Migrations.processNewAttachment,
}, },
}: { }: {
job: AttachmentDownloadJobType; job: AttachmentDownloadJobType;
isLastAttempt: boolean; isLastAttempt: boolean;
options?: { isForCurrentlyVisibleMessage: boolean }; options: {
abortSignal: AbortSignal;
isForCurrentlyVisibleMessage: boolean;
};
dependencies?: DependenciesType; dependencies?: DependenciesType;
}): Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>> { }): Promise<JobManagerJobResultType<CoreAttachmentDownloadJobType>> {
const jobIdForLogging = getJobIdForLogging(job); const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`; const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`;
const message = await __DEPRECATED$getMessageById(job.messageId); const message = await __DEPRECATED$getMessageById(
job.messageId,
'runDownloadAttachmentJob'
);
if (!message) { if (!message) {
log.error(`${logId} message not found`); log.error(`${logId} message not found`);
@ -315,6 +342,7 @@ async function runDownloadAttachmentJob({
const result = await runDownloadAttachmentJobInner({ const result = await runDownloadAttachmentJobInner({
job, job,
abortSignal: options.abortSignal,
isForCurrentlyVisibleMessage: isForCurrentlyVisibleMessage:
options?.isForCurrentlyVisibleMessage ?? false, options?.isForCurrentlyVisibleMessage ?? false,
dependencies, dependencies,
@ -342,6 +370,14 @@ async function runDownloadAttachmentJob({
status: 'finished', status: 'finished',
}; };
} catch (error) { } catch (error) {
if (options.abortSignal.aborted) {
log.warn(
`${logId}: Cancelled attempt ${job.attempts}. Not scheduling a retry. Error:`,
Errors.toLogFormat(error)
);
return { status: 'finished' };
}
log.error( log.error(
`${logId}: Failed to download attachment, attempt ${job.attempts}:`, `${logId}: Failed to download attachment, attempt ${job.attempts}:`,
Errors.toLogFormat(error) Errors.toLogFormat(error)
@ -407,10 +443,12 @@ type DownloadAttachmentResultType =
export async function runDownloadAttachmentJobInner({ export async function runDownloadAttachmentJobInner({
job, job,
abortSignal,
isForCurrentlyVisibleMessage, isForCurrentlyVisibleMessage,
dependencies, dependencies,
}: { }: {
job: AttachmentDownloadJobType; job: AttachmentDownloadJobType;
abortSignal: AbortSignal;
isForCurrentlyVisibleMessage: boolean; isForCurrentlyVisibleMessage: boolean;
dependencies: DependenciesType; dependencies: DependenciesType;
}): Promise<DownloadAttachmentResultType> { }): Promise<DownloadAttachmentResultType> {
@ -458,6 +496,7 @@ export async function runDownloadAttachmentJobInner({
try { try {
const attachmentWithThumbnail = await downloadBackupThumbnail({ const attachmentWithThumbnail = await downloadBackupThumbnail({
attachment, attachment,
abortSignal,
dependencies, dependencies,
}); });
await addAttachmentToMessage(messageId, attachmentWithThumbnail, logId, { await addAttachmentToMessage(messageId, attachmentWithThumbnail, logId, {
@ -482,9 +521,29 @@ export async function runDownloadAttachmentJobInner({
); );
try { try {
let totalDownloaded = 0;
const onSizeUpdate = async (totalBytes: number) => {
if (abortSignal.aborted) {
return;
}
totalDownloaded = Math.min(totalBytes, attachment.size);
await addAttachmentToMessage(
messageId,
{ ...attachment, totalDownloaded, pending: true },
logId,
{ type: attachmentType }
);
};
const downloaded = await dependencies.downloadAttachment({ const downloaded = await dependencies.downloadAttachment({
attachment, attachment,
variant: AttachmentVariant.Default, options: {
variant: AttachmentVariant.Default,
onSizeUpdate: debounce(onSizeUpdate, 200),
abortSignal,
},
}); });
const upgradedAttachment = await dependencies.processNewAttachment({ const upgradedAttachment = await dependencies.processNewAttachment({
@ -510,6 +569,7 @@ export async function runDownloadAttachmentJobInner({
const attachmentWithThumbnail = omit( const attachmentWithThumbnail = omit(
await downloadBackupThumbnail({ await downloadBackupThumbnail({
attachment, attachment,
abortSignal,
dependencies, dependencies,
}), }),
'pending' 'pending'
@ -539,14 +599,20 @@ export async function runDownloadAttachmentJobInner({
async function downloadBackupThumbnail({ async function downloadBackupThumbnail({
attachment, attachment,
abortSignal,
dependencies, dependencies,
}: { }: {
attachment: AttachmentType; attachment: AttachmentType;
abortSignal: AbortSignal;
dependencies: { downloadAttachment: typeof downloadAttachmentUtil }; dependencies: { downloadAttachment: typeof downloadAttachmentUtil };
}): Promise<AttachmentType> { }): Promise<AttachmentType> {
const downloadedThumbnail = await dependencies.downloadAttachment({ const downloadedThumbnail = await dependencies.downloadAttachment({
attachment, attachment,
variant: AttachmentVariant.ThumbnailFromBackup, options: {
onSizeUpdate: noop,
variant: AttachmentVariant.ThumbnailFromBackup,
abortSignal,
},
}); });
const attachmentWithThumbnail = { const attachmentWithThumbnail = {

View file

@ -184,7 +184,7 @@ async function removeJob(
async function runJob( async function runJob(
job: CallLinkDeleteJobType, job: CallLinkDeleteJobType,
_isLastAttempt: boolean _options: { isLastAttempt: boolean; abortSignal: AbortSignal }
): Promise<JobManagerJobResultType<CoreCallLinkDeleteJobType>> { ): Promise<JobManagerJobResultType<CoreCallLinkDeleteJobType>> {
const logId = `CallLinkDeleteJobType/runJob/${getJobId(job)}`; const logId = `CallLinkDeleteJobType/runJob/${getJobId(job)}`;

View file

@ -1,7 +1,7 @@
// 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 * as z from 'zod'; import * as z from 'zod';
import { MINUTE } from '../util/durations'; import { MINUTE, SECOND } from '../util/durations';
import { import {
explodePromise, explodePromise,
type ExplodePromiseResultType, type ExplodePromiseResultType,
@ -15,6 +15,7 @@ import {
exponentialBackoffSleepTime, exponentialBackoffSleepTime,
} from '../util/exponentialBackoff'; } from '../util/exponentialBackoff';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import { sleep } from '../util/sleep';
export type JobManagerJobType = { export type JobManagerJobType = {
active: boolean; active: boolean;
@ -46,7 +47,10 @@ export type JobManagerParamsType<
removeJob: (job: JobType) => Promise<void>; removeJob: (job: JobType) => Promise<void>;
runJob: ( runJob: (
job: JobType, job: JobType,
isLastAttempt: boolean options: {
abortSignal: AbortSignal;
isLastAttempt: boolean;
}
) => Promise<JobManagerJobResultType<CoreJobType>>; ) => Promise<JobManagerJobResultType<CoreJobType>>;
shouldHoldOffOnStartingQueuedJobs?: () => boolean; shouldHoldOffOnStartingQueuedJobs?: () => boolean;
getJobId: (job: CoreJobType) => string; getJobId: (job: CoreJobType) => string;
@ -66,15 +70,15 @@ export type JobManagerJobResultType<CoreJobType> =
| { status: 'finished'; newJob?: CoreJobType } | { status: 'finished'; newJob?: CoreJobType }
| { status: 'rate-limited'; pauseDurationMs: number }; | { status: 'rate-limited'; pauseDurationMs: number };
export type ActiveJobData<CoreJobType> = {
completionPromise: ExplodePromiseResultType<void>;
abortController: AbortController;
job: CoreJobType & JobManagerJobType;
};
export abstract class JobManager<CoreJobType> { export abstract class JobManager<CoreJobType> {
private enabled: boolean = false; private enabled: boolean = false;
private activeJobs: Map< private activeJobs: Map<string, ActiveJobData<CoreJobType>> = new Map();
string,
{
completionPromise: ExplodePromiseResultType<void>;
job: CoreJobType & JobManagerJobType;
}
> = new Map();
private jobStartPromises: Map<string, ExplodePromiseResultType<void>> = private jobStartPromises: Map<string, ExplodePromiseResultType<void>> =
new Map(); new Map();
private jobCompletePromises: Map<string, ExplodePromiseResultType<void>> = private jobCompletePromises: Map<string, ExplodePromiseResultType<void>> =
@ -108,7 +112,10 @@ export abstract class JobManager<CoreJobType> {
clearTimeoutIfNecessary(this.tickTimeout); clearTimeoutIfNecessary(this.tickTimeout);
this.tickTimeout = null; this.tickTimeout = null;
await Promise.all( await Promise.all(
activeJobs.map(({ completionPromise }) => completionPromise.promise) activeJobs.map(async ({ abortController, completionPromise }) => {
abortController.abort();
await completionPromise.promise;
})
); );
} }
@ -291,9 +298,12 @@ export abstract class JobManager<CoreJobType> {
let jobRunResult: JobManagerJobResultType<CoreJobType> | undefined; let jobRunResult: JobManagerJobResultType<CoreJobType> | undefined;
try { try {
log.info(`${logId}: starting job`); log.info(`${logId}: starting job`);
this.addRunningJob(job); const { abortController } = this.addRunningJob(job);
await this.params.saveJob({ ...job, active: true }); await this.params.saveJob({ ...job, active: true });
const runJobPromise = this.params.runJob(job, isLastAttempt); const runJobPromise = this.params.runJob(job, {
abortSignal: abortController.signal,
isLastAttempt,
});
this.handleJobStartPromises(job); this.handleJobStartPromises(job);
jobRunResult = await runJobPromise; jobRunResult = await runJobPromise;
const { status } = jobRunResult; const { status } = jobRunResult;
@ -388,17 +398,71 @@ export abstract class JobManager<CoreJobType> {
this.activeJobs.delete(id); this.activeJobs.delete(id);
} }
private addRunningJob(job: CoreJobType & JobManagerJobType) { public async cancelJobs(
predicate: (job: CoreJobType & JobManagerJobType) => boolean
): Promise<void> {
const logId = `${this.logPrefix}/cancelJobs`;
const jobs = Array.from(this.activeJobs.values()).filter(data =>
predicate(data.job)
);
if (jobs.length === 0) {
log.warn(`${logId}: found no target jobs`);
return;
}
await Promise.all(
jobs.map(async jobData => {
const { abortController, completionPromise, job } = jobData;
abortController.abort();
// First tell those waiting for the job that it's not happening
const rejectionError = new Error('Cancelled at JobManager.cancelJobs');
const idWithAttempts = this.getJobIdIncludingAttempts(job);
this.jobCompletePromises.get(idWithAttempts)?.reject(rejectionError);
this.jobCompletePromises.delete(idWithAttempts);
// Give the job 1 second to cancel itself
await Promise.race([completionPromise.promise, sleep(SECOND)]);
const jobId = this.params.getJobId(job);
const hasCompleted = Boolean(this.activeJobs.get(jobId));
if (!hasCompleted) {
const jobIdForLogging = this.params.getJobIdForLogging(job);
log.warn(
`${logId}: job ${jobIdForLogging} didn't complete; rejecting promises`
);
completionPromise.reject(rejectionError);
this.activeJobs.delete(jobId);
}
await this.params.removeJob(job);
})
);
log.warn(`${logId}: Successfully cancelled ${jobs.length} jobs`);
}
private addRunningJob(
job: CoreJobType & JobManagerJobType
): ActiveJobData<CoreJobType> {
if (this.isJobRunning(job)) { if (this.isJobRunning(job)) {
const jobIdForLogging = this.params.getJobIdForLogging(job); const jobIdForLogging = this.params.getJobIdForLogging(job);
log.warn( log.warn(
`${this.logPrefix}/addRunningJob: job ${jobIdForLogging} is already running` `${this.logPrefix}/addRunningJob: job ${jobIdForLogging} is already running`
); );
} }
this.activeJobs.set(this.params.getJobId(job), {
const activeJob = {
completionPromise: explodePromise<void>(), completionPromise: explodePromise<void>(),
abortController: new AbortController(),
job, job,
}); };
this.activeJobs.set(this.params.getJobId(job), activeJob);
return activeJob;
} }
private handleJobStartPromises(job: CoreJobType & JobManagerJobType) { private handleJobStartPromises(job: CoreJobType & JobManagerJobType) {

View file

@ -60,7 +60,7 @@ export async function sendDeleteForEveryone(
const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`; const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`;
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(messageId, logId);
if (!message) { if (!message) {
log.error(`${logId}: Failed to fetch message. Failing job.`); log.error(`${logId}: Failed to fetch message. Failing job.`);
return; return;

View file

@ -46,7 +46,10 @@ export async function sendDeleteStoryForEveryone(
const logId = `sendDeleteStoryForEveryone(${storyId})`; const logId = `sendDeleteStoryForEveryone(${storyId})`;
const message = await __DEPRECATED$getMessageById(storyId); const message = await __DEPRECATED$getMessageById(
storyId,
'sendDeleteStoryForEveryone'
);
if (!message) { if (!message) {
log.error(`${logId}: Failed to fetch message. Failing job.`); log.error(`${logId}: Failed to fetch message. Failing job.`);
return; return;

View file

@ -73,7 +73,10 @@ export async function sendNormalMessage(
const { Message } = window.Signal.Types; const { Message } = window.Signal.Types;
const { messageId, revision, editedMessageTimestamp } = data; const { messageId, revision, editedMessageTimestamp } = data;
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'sendNormalMessage'
);
if (!message) { if (!message) {
log.info( log.info(
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending it` `message ${messageId} was not found, maybe because it was deleted. Giving up on sending it`
@ -654,7 +657,9 @@ async function getMessageSendData({
uploadQueue, uploadQueue,
}), }),
uploadMessageSticker(message, uploadQueue), uploadMessageSticker(message, uploadQueue),
storyId ? __DEPRECATED$getMessageById(storyId) : undefined, storyId
? __DEPRECATED$getMessageById(storyId, 'sendNormalMessage')
: undefined,
]); ]);
// Save message after uploading attachments // Save message after uploading attachments

View file

@ -61,7 +61,7 @@ export async function sendReaction(
const ourConversationId = const ourConversationId =
window.ConversationController.getOurConversationIdOrThrow(); window.ConversationController.getOurConversationIdOrThrow();
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(messageId, 'sendReaction');
if (!message) { if (!message) {
log.info( log.info(
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions` `message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions`

View file

@ -71,38 +71,40 @@ export async function sendStory(
} }
const notFound = new Set(messageIds); const notFound = new Set(messageIds);
const messages = (await getMessagesById(messageIds)).filter(message => { const messages = (await getMessagesById(messageIds, 'sendStory')).filter(
notFound.delete(message.id); message => {
notFound.delete(message.id);
const distributionId = message.get('storyDistributionListId'); const distributionId = message.get('storyDistributionListId');
const logId = `stories.sendStory(${timestamp}/${distributionId})`; const logId = `stories.sendStory(${timestamp}/${distributionId})`;
const messageConversation = message.getConversation(); const messageConversation = message.getConversation();
if (messageConversation !== conversation) { if (messageConversation !== conversation) {
log.error( log.error(
`${logId}: Message conversation ` + `${logId}: Message conversation ` +
`'${messageConversation?.idForLogging()}' does not match job ` + `'${messageConversation?.idForLogging()}' does not match job ` +
`conversation ${conversation.idForLogging()}` `conversation ${conversation.idForLogging()}`
); );
return false; return false;
}
if (message.get('timestamp') !== timestamp) {
log.error(
`${logId}: Message timestamp ${message.get(
'timestamp'
)} does not match job timestamp`
);
return false;
}
if (message.isErased() || message.get('deletedForEveryone')) {
log.info(`${logId}: message was erased. Giving up on sending it`);
return false;
}
return true;
} }
);
if (message.get('timestamp') !== timestamp) {
log.error(
`${logId}: Message timestamp ${message.get(
'timestamp'
)} does not match job timestamp`
);
return false;
}
if (message.isErased() || message.get('deletedForEveryone')) {
log.info(`${logId}: message was erased. Giving up on sending it`);
return false;
}
return true;
});
for (const messageId of notFound) { for (const messageId of notFound) {
log.info( log.info(

View file

@ -14,13 +14,13 @@ export async function addAttachmentToMessage(
jobLogId: string, jobLogId: string,
{ type }: { type: AttachmentDownloadJobTypeType } { type }: { type: AttachmentDownloadJobTypeType }
): Promise<void> { ): Promise<void> {
const message = await __DEPRECATED$getMessageById(messageId); const logPrefix = `${jobLogId}/addAttachmentToMessage`;
const message = await __DEPRECATED$getMessageById(messageId, logPrefix);
if (!message) { if (!message) {
return; return;
} }
const logPrefix = `${jobLogId}/addAttachmentToMessage`;
const attachmentSignature = getAttachmentSignature(attachment); const attachmentSignature = getAttachmentSignature(attachment);
if (type === 'long-message') { if (type === 'long-message') {

View file

@ -8,9 +8,14 @@ import * as Errors from '../types/errors';
import type { MessageModel } from '../models/messages'; import type { MessageModel } from '../models/messages';
export async function __DEPRECATED$getMessageById( export async function __DEPRECATED$getMessageById(
messageId: string messageId: string,
location: string
): Promise<MessageModel | undefined> { ): Promise<MessageModel | undefined> {
const message = window.MessageCache.__DEPRECATED$getById(messageId); const innerLocation = `__DEPRECATED$getMessageById/${location}`;
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
innerLocation
);
if (message) { if (message) {
return message; return message;
} }
@ -32,6 +37,6 @@ export async function __DEPRECATED$getMessageById(
return window.MessageCache.__DEPRECATED$register( return window.MessageCache.__DEPRECATED$register(
found.id, found.id,
found, found,
'__DEPRECATED$getMessageById' innerLocation
); );
} }

View file

@ -8,13 +8,18 @@ import type { MessageAttributesType } from '../model-types.d';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
export async function getMessagesById( export async function getMessagesById(
messageIds: Iterable<string> messageIds: Iterable<string>,
location: string
): Promise<Array<MessageModel>> { ): Promise<Array<MessageModel>> {
const innerLocation = `getMessagesById/${location}`;
const messagesFromMemory: Array<MessageModel> = []; const messagesFromMemory: Array<MessageModel> = [];
const messageIdsToLookUpInDatabase: Array<string> = []; const messageIdsToLookUpInDatabase: Array<string> = [];
for (const messageId of messageIds) { for (const messageId of messageIds) {
const message = window.MessageCache.__DEPRECATED$getById(messageId); const message = window.MessageCache.__DEPRECATED$getById(
messageId,
innerLocation
);
if (message) { if (message) {
messagesFromMemory.push(message); messagesFromMemory.push(message);
} else { } else {
@ -43,7 +48,7 @@ export async function getMessagesById(
return window.MessageCache.__DEPRECATED$register( return window.MessageCache.__DEPRECATED$register(
message.id, message.id,
message, message,
'getMessagesById' innerLocation
); );
}); });

View file

@ -3556,7 +3556,10 @@ export class ConversationModel extends window.Backbone
`maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification` `maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification`
); );
const message = window.MessageCache.__DEPRECATED$getById(notificationId); const message = window.MessageCache.__DEPRECATED$getById(
notificationId,
'maybeRemoveUniversalTimer'
);
if (message) { if (message) {
await DataWriter.removeMessage(message.id, { await DataWriter.removeMessage(message.id, {
singleProtoJobQueue, singleProtoJobQueue,
@ -3599,7 +3602,10 @@ export class ConversationModel extends window.Backbone
`maybeClearContactRemoved(${this.idForLogging()}): removed notification` `maybeClearContactRemoved(${this.idForLogging()}): removed notification`
); );
const message = window.MessageCache.__DEPRECATED$getById(notificationId); const message = window.MessageCache.__DEPRECATED$getById(
notificationId,
'maybeClearContactRemoved'
);
if (message) { if (message) {
await DataWriter.removeMessage(message.id, { await DataWriter.removeMessage(message.id, {
singleProtoJobQueue, singleProtoJobQueue,

View file

@ -393,7 +393,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
`doubleCheckMissingQuoteReference/${logId}: missing story reference` `doubleCheckMissingQuoteReference/${logId}: missing story reference`
); );
const message = window.MessageCache.__DEPRECATED$getById(storyId); const message = window.MessageCache.__DEPRECATED$getById(
storyId,
'doubleCheckMissingQuoteReference'
);
if (!message) { if (!message) {
return; return;
} }

View file

@ -29,7 +29,10 @@ export async function enqueueReactionForSend({
messageId: string; messageId: string;
remove: boolean; remove: boolean;
}>): Promise<void> { }>): Promise<void> {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'enqueueReactionForSend'
);
strictAssert(message, 'enqueueReactionForSend: no message found'); strictAssert(message, 'enqueueReactionForSend: no message found');
const targetAuthorAci = getSourceServiceId(message.attributes); const targetAuthorAci = getSourceServiceId(message.attributes);

View file

@ -420,7 +420,7 @@ export class MessageCache {
); );
} }
const existing = this.__DEPRECATED$getById(id); const existing = this.__DEPRECATED$getById(id, location);
if (existing) { if (existing) {
this.addMessageToCache(existing.attributes); this.addMessageToCache(existing.attributes);
@ -447,13 +447,18 @@ export class MessageCache {
} }
// Finds a message in the cache by Id // Finds a message in the cache by Id
public __DEPRECATED$getById(id: string): MessageModel | undefined { public __DEPRECATED$getById(
id: string,
location: string
): MessageModel | undefined {
const data = this.state.messages.get(id); const data = this.state.messages.get(id);
if (!data) { if (!data) {
return undefined; return undefined;
} }
return this.toModel(data); const model = this.toModel(data);
model.registerLocations.add(location);
return model;
} }
public async upgradeSchema( public async upgradeSchema(
@ -513,9 +518,9 @@ export class MessageCache {
model.attributes = { ...messageAttributes }; model.attributes = { ...messageAttributes };
if (getEnvironment() === Environment.Development) { if (getEnvironment() === Environment.Development) {
log.warn('MessageCache: stale model', { log.warn('MessageCache: updating cached backbone model', {
cid: model.cid, cid: model.cid,
locations: Array.from(model.registerLocations).join('+'), locations: Array.from(model.registerLocations).join(', '),
}); });
} }
} }

View file

@ -669,8 +669,10 @@ export class BackupsService {
createCipheriv(CipherType.AES256CBC, aesKey, iv), createCipheriv(CipherType.AES256CBC, aesKey, iv),
prependStream(iv), prependStream(iv),
appendMacStream(macKey), appendMacStream(macKey),
measureSize(size => { measureSize({
totalBytes = size; onComplete: size => {
totalBytes = size;
},
}), }),
sink sink
); );

View file

@ -40,6 +40,7 @@ import { bytesToUuid } from '../../../util/uuidToBytes';
import { createName } from '../../../util/attachmentPath'; import { createName } from '../../../util/attachmentPath';
import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable'; import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable';
import type { ReencryptionInfo } from '../../../AttachmentCrypto'; import type { ReencryptionInfo } from '../../../AttachmentCrypto';
import { dropZero } from '../../../util/dropZero';
export function convertFilePointerToAttachment( export function convertFilePointerToAttachment(
filePointer: Backups.FilePointer, filePointer: Backups.FilePointer,
@ -72,7 +73,7 @@ export function convertFilePointerToAttachment(
incrementalMac: incrementalMac?.length incrementalMac: incrementalMac?.length
? Bytes.toBase64(incrementalMac) ? Bytes.toBase64(incrementalMac)
: undefined, : undefined,
incrementalMacChunkSize: incrementalMacChunkSize ?? undefined, chunkSize: dropZero(incrementalMacChunkSize),
downloadPath: doCreateName(), downloadPath: doCreateName(),
}; };
@ -182,7 +183,7 @@ export async function getFilePointerForAttachment({
incrementalMac: attachment.incrementalMac incrementalMac: attachment.incrementalMac
? Bytes.fromBase64(attachment.incrementalMac) ? Bytes.fromBase64(attachment.incrementalMac)
: undefined, : undefined,
incrementalMacChunkSize: attachment.incrementalMacChunkSize, incrementalMacChunkSize: dropZero(attachment.chunkSize),
fileName: attachment.fileName, fileName: attachment.fileName,
width: attachment.width, width: attachment.width,
height: attachment.height, height: attachment.height,

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import PQueue from 'p-queue'; import PQueue from 'p-queue';
import { noop } from 'lodash';
import { DataWriter } from '../sql/Client'; import { DataWriter } from '../sql/Client';
import type { ContactSyncEvent } from '../textsecure/messageReceiverEvents'; import type { ContactSyncEvent } from '../textsecure/messageReceiverEvents';
@ -23,6 +24,7 @@ import { downloadAttachment } from '../textsecure/downloadAttachment';
import { strictAssert } from '../util/assert'; import { strictAssert } from '../util/assert';
import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto'; import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto';
import { SECOND } from '../util/durations'; import { SECOND } from '../util/durations';
import { AttachmentVariant } from '../types/Attachment';
// When true - we are running the very first storage and contact sync after // When true - we are running the very first storage and contact sync after
// linking. // linking.
@ -103,12 +105,16 @@ async function downloadAndParseContactAttachment(
strictAssert(window.textsecure.server, 'server must exist'); strictAssert(window.textsecure.server, 'server must exist');
let downloaded: ReencryptedAttachmentV2 | undefined; let downloaded: ReencryptedAttachmentV2 | undefined;
try { try {
const abortController = new AbortController();
downloaded = await downloadAttachment( downloaded = await downloadAttachment(
window.textsecure.server, window.textsecure.server,
contactAttachment, contactAttachment,
{ {
variant: AttachmentVariant.Default,
onSizeUpdate: noop,
disableRetries: true, disableRetries: true,
timeout: 90 * SECOND, timeout: 90 * SECOND,
abortSignal: abortController.signal,
} }
); );

View file

@ -873,6 +873,7 @@ type WritableInterface = {
saveAttachmentDownloadJobs: (jobs: Array<AttachmentDownloadJobType>) => void; saveAttachmentDownloadJobs: (jobs: Array<AttachmentDownloadJobType>) => void;
resetAttachmentDownloadActive: () => void; resetAttachmentDownloadActive: () => void;
removeAttachmentDownloadJob: (job: AttachmentDownloadJobType) => void; removeAttachmentDownloadJob: (job: AttachmentDownloadJobType) => void;
removeAttachmentDownloadJobsForMessage: (messageId: string) => void;
removeAllBackupAttachmentDownloadJobs: () => void; removeAllBackupAttachmentDownloadJobs: () => void;
getNextAttachmentBackupJobs: (options: { getNextAttachmentBackupJobs: (options: {

View file

@ -488,6 +488,7 @@ export const DataWriter: ServerWritableInterface = {
saveAttachmentDownloadJobs, saveAttachmentDownloadJobs,
resetAttachmentDownloadActive, resetAttachmentDownloadActive,
removeAttachmentDownloadJob, removeAttachmentDownloadJob,
removeAttachmentDownloadJobsForMessage,
removeAllBackupAttachmentDownloadJobs, removeAllBackupAttachmentDownloadJobs,
getNextAttachmentBackupJobs, getNextAttachmentBackupJobs,
@ -5129,6 +5130,18 @@ function removeAttachmentDownloadJob(
db.prepare(query).run(params); db.prepare(query).run(params);
} }
function removeAttachmentDownloadJobsForMessage(
db: WritableDB,
messageId: string
): void {
const [query, params] = sql`
DELETE FROM attachment_downloads
WHERE messageId = ${messageId}
`;
db.prepare(query).run(params);
}
// Backup Attachments // Backup Attachments
function clearAllAttachmentBackupJobs(db: WritableDB): void { function clearAllAttachmentBackupJobs(db: WritableDB): void {

View file

@ -731,7 +731,7 @@ export function setQuoteByMessageId(
} }
const message = messageId const message = messageId
? await __DEPRECATED$getMessageById(messageId) ? await __DEPRECATED$getMessageById(messageId, 'setQuoteByMessageId')
: undefined; : undefined;
const state = getState(); const state = getState();

View file

@ -187,7 +187,10 @@ import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPe
import { getConversationIdForLogging } from '../../util/idForLogging'; import { getConversationIdForLogging } from '../../util/idForLogging';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage'; import MessageSender from '../../textsecure/SendMessage';
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager'; import {
AttachmentDownloadManager,
AttachmentDownloadUrgency,
} from '../../jobs/AttachmentDownloadManager';
import type { import type {
DeleteForMeSyncEventData, DeleteForMeSyncEventData,
MessageToDelete, MessageToDelete,
@ -1083,6 +1086,7 @@ export const actions = {
blockAndReportSpam, blockAndReportSpam,
blockConversation, blockConversation,
blockGroupLinkRequests, blockGroupLinkRequests,
cancelAttachmentDownload,
cancelConversationVerification, cancelConversationVerification,
changeHasGroupLink, changeHasGroupLink,
clearCancelledConversationVerification, clearCancelledConversationVerification,
@ -1405,7 +1409,10 @@ function markMessageRead(
return; return;
} }
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'markMessageRead'
);
if (!message) { if (!message) {
throw new Error(`markMessageRead: failed to load message ${messageId}`); throw new Error(`markMessageRead: failed to load message ${messageId}`);
} }
@ -1759,7 +1766,10 @@ function deleteMessages({
await Promise.all( await Promise.all(
messageIds.map( messageIds.map(
async (messageId): Promise<MessageToDelete | undefined> => { async (messageId): Promise<MessageToDelete | undefined> => {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'deleteMessages'
);
if (!message) { if (!message) {
throw new Error(`deleteMessages: Message ${messageId} missing!`); throw new Error(`deleteMessages: Message ${messageId} missing!`);
} }
@ -1919,7 +1929,9 @@ function setMessageToEdit(
return; return;
} }
const message = (await __DEPRECATED$getMessageById(messageId))?.attributes; const message = (
await __DEPRECATED$getMessageById(messageId, 'setMessageToEdit')
)?.attributes;
if (!message) { if (!message) {
return; return;
} }
@ -2012,7 +2024,10 @@ function generateNewGroupLink(
* replace it with an actual action that fits in with the redux approach. * replace it with an actual action that fits in with the redux approach.
*/ */
export const markViewed = (messageId: string): void => { export const markViewed = (messageId: string): void => {
const message = window.MessageCache.__DEPRECATED$getById(messageId); const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'markViewed'
);
if (!message) { if (!message) {
throw new Error(`markViewed: Message ${messageId} missing!`); throw new Error(`markViewed: Message ${messageId} missing!`);
} }
@ -2276,7 +2291,10 @@ function kickOffAttachmentDownload(
options: Readonly<{ messageId: string }> options: Readonly<{ messageId: string }>
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => { return async dispatch => {
const message = await __DEPRECATED$getMessageById(options.messageId); const message = await __DEPRECATED$getMessageById(
options.messageId,
'kickOffAttachmentDownload'
);
if (!message) { if (!message) {
throw new Error( throw new Error(
`kickOffAttachmentDownload: Message ${options.messageId} missing!` `kickOffAttachmentDownload: Message ${options.messageId} missing!`
@ -2301,6 +2319,47 @@ function kickOffAttachmentDownload(
}; };
} }
function cancelAttachmentDownload({
messageId,
}: Readonly<{ messageId: string }>): ThunkAction<
void,
RootStateType,
unknown,
NoopActionType
> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
messageId,
'cancelAttachmentDownload'
);
if (!message) {
log.warn(`cancelAttachmentDownload: Message ${messageId} missing!`);
} else {
message.set({
attachments: (message.get('attachments') || []).map(attachment => ({
...attachment,
pending: false,
})),
});
const ourAci = window.textsecure.storage.user.getCheckedAci();
await DataWriter.saveMessage(message.attributes, { ourAci });
}
// A click kicks off downloads for every attachment in a message, so cancel does too
await AttachmentDownloadManager.cancelJobs(job => {
return job.messageId === messageId;
});
await DataWriter.removeAttachmentDownloadJobsForMessage(messageId);
dispatch({
type: 'NOOP',
payload: null,
});
};
}
type AttachmentOptions = ReadonlyDeep<{ type AttachmentOptions = ReadonlyDeep<{
messageId: string; messageId: string;
attachment: AttachmentType; attachment: AttachmentType;
@ -2310,7 +2369,10 @@ function markAttachmentAsCorrupted(
options: AttachmentOptions options: AttachmentOptions
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => { return async dispatch => {
const message = await __DEPRECATED$getMessageById(options.messageId); const message = await __DEPRECATED$getMessageById(
options.messageId,
'markAttachmentAsCorrupted'
);
if (!message) { if (!message) {
throw new Error( throw new Error(
`markAttachmentAsCorrupted: Message ${options.messageId} missing!` `markAttachmentAsCorrupted: Message ${options.messageId} missing!`
@ -2329,7 +2391,10 @@ function openGiftBadge(
messageId: string messageId: string
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> { ): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async dispatch => { return async dispatch => {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'openGiftBadge'
);
if (!message) { if (!message) {
throw new Error(`openGiftBadge: Message ${messageId} missing!`); throw new Error(`openGiftBadge: Message ${messageId} missing!`);
} }
@ -2349,7 +2414,10 @@ function retryMessageSend(
messageId: string messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => { return async dispatch => {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'retryMessageSend'
);
if (!message) { if (!message) {
throw new Error(`retryMessageSend: Message ${messageId} missing!`); throw new Error(`retryMessageSend: Message ${messageId} missing!`);
} }
@ -2366,7 +2434,10 @@ export function copyMessageText(
messageId: string messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => { return async dispatch => {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'copyMessageText'
);
if (!message) { if (!message) {
throw new Error(`copy: Message ${messageId} missing!`); throw new Error(`copy: Message ${messageId} missing!`);
} }
@ -2385,7 +2456,10 @@ export function retryDeleteForEveryone(
messageId: string messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => { return async dispatch => {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'retryDeleteForEveryone'
);
if (!message) { if (!message) {
throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`); throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`);
} }
@ -3172,7 +3246,12 @@ function pushPanelForConversation(
const message = const message =
conversations.messagesLookup[messageId] || conversations.messagesLookup[messageId] ||
(await __DEPRECATED$getMessageById(messageId))?.attributes; (
await __DEPRECATED$getMessageById(
messageId,
'pushPanelForConversation'
)
)?.attributes;
if (!message) { if (!message) {
throw new Error( throw new Error(
'pushPanelForConversation: could not find message for MessageDetails' 'pushPanelForConversation: could not find message for MessageDetails'
@ -3248,7 +3327,10 @@ function deleteMessagesForEveryone(
await Promise.all( await Promise.all(
messageIds.map(async messageId => { messageIds.map(async messageId => {
try { try {
const message = window.MessageCache.__DEPRECATED$getById(messageId); const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'deleteMessagesForEveryone'
);
if (!message) { if (!message) {
throw new Error( throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!` `deleteMessageForEveryone: Message ${messageId} missing!`
@ -3959,7 +4041,10 @@ export function saveAttachmentFromMessage(
providedAttachment?: AttachmentType providedAttachment?: AttachmentType
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> { ): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'saveAttachmentFromMessage'
);
if (!message) { if (!message) {
throw new Error( throw new Error(
`saveAttachmentFromMessage: Message ${messageId} missing!` `saveAttachmentFromMessage: Message ${messageId} missing!`
@ -4052,7 +4137,10 @@ export function scrollToMessage(
throw new Error('scrollToMessage: No conversation found'); throw new Error('scrollToMessage: No conversation found');
} }
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'scrollToMessage'
);
if (!message) { if (!message) {
throw new Error(`scrollToMessage: failed to load message ${messageId}`); throw new Error(`scrollToMessage: failed to load message ${messageId}`);
} }
@ -4066,7 +4154,12 @@ export function scrollToMessage(
let isInMemory = true; let isInMemory = true;
if (!window.MessageCache.__DEPRECATED$getById(messageId)) { if (
!window.MessageCache.__DEPRECATED$getById(
messageId,
'scrollToMessage/notInMemory'
)
) {
isInMemory = false; isInMemory = false;
} }
@ -4497,7 +4590,10 @@ function onConversationOpened(
log.info(`${logId}: Updating newly opened conversation state`); log.info(`${logId}: Updating newly opened conversation state`);
if (messageId) { if (messageId) {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'onConversationOpened'
);
if (message) { if (message) {
drop(conversation.loadAndScroll(messageId)); drop(conversation.loadAndScroll(messageId));
@ -4636,7 +4732,10 @@ function showArchivedConversations(): ShowArchivedConversationsActionType {
} }
function doubleCheckMissingQuoteReference(messageId: string): NoopActionType { function doubleCheckMissingQuoteReference(messageId: string): NoopActionType {
const message = window.MessageCache.__DEPRECATED$getById(messageId); const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'doubleCheckMissingQuoteReference'
);
if (message) { if (message) {
void message.doubleCheckMissingQuoteReference(); void message.doubleCheckMissingQuoteReference();
} }

View file

@ -156,7 +156,10 @@ function showLightboxForViewOnceMedia(
return async dispatch => { return async dispatch => {
log.info('showLightboxForViewOnceMedia: attempting to display message'); log.info('showLightboxForViewOnceMedia: attempting to display message');
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'showLightboxForViewOnceMedia'
);
if (!message) { if (!message) {
throw new Error( throw new Error(
`showLightboxForViewOnceMedia: Message ${messageId} missing!` `showLightboxForViewOnceMedia: Message ${messageId} missing!`
@ -250,7 +253,10 @@ function showLightbox(opts: {
return async (dispatch, getState) => { return async (dispatch, getState) => {
const { attachment, messageId } = opts; const { attachment, messageId } = opts;
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'showLightbox'
);
if (!message) { if (!message) {
throw new Error(`showLightbox: Message ${messageId} missing!`); throw new Error(`showLightbox: Message ${messageId} missing!`);
} }
@ -387,7 +393,10 @@ function showLightboxForAdjacentMessage(
const [media] = lightbox.media; const [media] = lightbox.media;
const { id: messageId, receivedAt, sentAt } = media.message; const { id: messageId, receivedAt, sentAt } = media.message;
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'showLightboxForAdjacentMessage'
);
if (!message) { if (!message) {
log.warn('showLightboxForAdjacentMessage: original message is gone'); log.warn('showLightboxForAdjacentMessage: original message is gone');
dispatch({ dispatch({

View file

@ -382,7 +382,10 @@ function markStoryRead(
return; return;
} }
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'markStoryRead'
);
if (!message) { if (!message) {
log.warn(`markStoryRead: no message found ${messageId}`); log.warn(`markStoryRead: no message found ${messageId}`);
@ -521,7 +524,10 @@ function queueStoryDownload(
return; return;
} }
const message = await __DEPRECATED$getMessageById(storyId); const message = await __DEPRECATED$getMessageById(
storyId,
'queueStoryDownload'
);
if (message) { if (message) {
// We want to ensure that we re-hydrate the story reply context with the // We want to ensure that we re-hydrate the story reply context with the
@ -1396,7 +1402,10 @@ function removeAllContactStories(
const messages = ( const messages = (
await Promise.all( await Promise.all(
messageIds.map(async messageId => { messageIds.map(async messageId => {
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'removeAllContactStories'
);
if (!message) { if (!message) {
log.warn(`${logId}: no message found ${messageId}`); log.warn(`${logId}: no message found ${messageId}`);

View file

@ -152,7 +152,6 @@ import { CallMode, CallDirection } from '../../types/CallDisposition';
import { getCallIdFromEra } from '../../util/callDisposition'; import { getCallIdFromEra } from '../../util/callDisposition';
import { LONG_MESSAGE } from '../../types/MIME'; import { LONG_MESSAGE } from '../../types/MIME';
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification'; import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification';
import { formatFileSize } from '../../util/formatFileSize';
export { isIncoming, isOutgoing, isStory }; export { isIncoming, isOutgoing, isStory };
@ -1837,12 +1836,11 @@ export function getPropsForAttachment(
return undefined; return undefined;
} }
const { path, pending, size, screenshot, thumbnail, thumbnailFromBackup } = const { path, pending, screenshot, thumbnail, thumbnailFromBackup } =
attachment; attachment;
return { return {
...attachment, ...attachment,
fileSize: size ? formatFileSize(size) : undefined,
isVoiceMessage: isVoiceMessage(attachment), isVoiceMessage: isVoiceMessage(attachment),
pending, pending,
url: path ? getLocalAttachmentUrl(attachment) : undefined, url: path ? getLocalAttachmentUrl(attachment) : undefined,

View file

@ -20,7 +20,8 @@ export const SmartEditHistoryMessagesModal = memo(
const platform = useSelector(getPlatform); const platform = useSelector(getPlatform);
const { closeEditHistoryModal } = useGlobalModalActions(); const { closeEditHistoryModal } = useGlobalModalActions();
const { kickOffAttachmentDownload } = useConversationsActions(); const { cancelAttachmentDownload, kickOffAttachmentDownload } =
useConversationsActions();
const { showLightbox } = useLightboxActions(); const { showLightbox } = useLightboxActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector); const getPreferredBadge = useSelector(getPreferredBadgeSelector);
@ -46,6 +47,7 @@ export const SmartEditHistoryMessagesModal = memo(
return ( return (
<EditHistoryMessagesModal <EditHistoryMessagesModal
cancelAttachmentDownload={cancelAttachmentDownload}
closeEditHistoryModal={closeEditHistoryModal} closeEditHistoryModal={closeEditHistoryModal}
editHistoryMessages={editHistoryMessages} editHistoryMessages={editHistoryMessages}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}

View file

@ -118,7 +118,8 @@ function SmartForwardMessagesModalInner({
return { draft, originalMessage: null }; return { draft, originalMessage: null };
} }
const message = await __DEPRECATED$getMessageById( const message = await __DEPRECATED$getMessageById(
draft.originalMessageId draft.originalMessageId,
'doForwardMessages'
); );
strictAssert(message, 'no message found'); strictAssert(message, 'no message found');
return { return {

View file

@ -39,19 +39,20 @@ export const SmartMessageDetail = memo(
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const { checkForAccount } = useAccountsActions(); const { checkForAccount } = useAccountsActions();
const { const {
cancelAttachmentDownload,
clearTargetedMessage: clearSelectedMessage, clearTargetedMessage: clearSelectedMessage,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
messageExpanded, messageExpanded,
openGiftBadge, openGiftBadge,
retryMessageSend,
popPanelForConversation, popPanelForConversation,
pushPanelForConversation, pushPanelForConversation,
retryMessageSend,
saveAttachment, saveAttachment,
saveAttachments, saveAttachments,
showConversation,
showAttachmentDownloadStillInProgressToast, showAttachmentDownloadStillInProgressToast,
showConversation,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showSpoiler, showSpoiler,
@ -91,6 +92,7 @@ export const SmartMessageDetail = memo(
i18n={i18n} i18n={i18n}
platform={platform} platform={platform}
interactionMode={interactionMode} interactionMode={interactionMode}
cancelAttachmentDownload={cancelAttachmentDownload}
kickOffAttachmentDownload={kickOffAttachmentDownload} kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted} markAttachmentAsCorrupted={markAttachmentAsCorrupted}
message={message} message={message}

View file

@ -115,27 +115,28 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
const { const {
blockGroupLinkRequests, blockGroupLinkRequests,
cancelAttachmentDownload,
clearTargetedMessage: clearSelectedMessage, clearTargetedMessage: clearSelectedMessage,
copyMessageText,
doubleCheckMissingQuoteReference, doubleCheckMissingQuoteReference,
kickOffAttachmentDownload, kickOffAttachmentDownload,
markAttachmentAsCorrupted, markAttachmentAsCorrupted,
messageExpanded, messageExpanded,
openGiftBadge, openGiftBadge,
pushPanelForConversation, pushPanelForConversation,
copyMessageText,
retryDeleteForEveryone, retryDeleteForEveryone,
retryMessageSend, retryMessageSend,
saveAttachment, saveAttachment,
saveAttachments, saveAttachments,
targetMessage,
toggleSelectMessage,
setMessageToEdit, setMessageToEdit,
showConversation,
showAttachmentDownloadStillInProgressToast, showAttachmentDownloadStillInProgressToast,
showConversation,
showExpiredIncomingTapToViewToast, showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast, showExpiredOutgoingTapToViewToast,
showSpoiler, showSpoiler,
startConversation, startConversation,
targetMessage,
toggleSelectMessage,
} = useConversationsActions(); } = useConversationsActions();
const { reactToMessage, scrollToQuotedMessage, setQuoteByMessageId } = const { reactToMessage, scrollToQuotedMessage, setQuoteByMessageId } =
@ -203,6 +204,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
checkForAccount={checkForAccount} checkForAccount={checkForAccount}
clearTargetedMessage={clearSelectedMessage} clearTargetedMessage={clearSelectedMessage}
doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference} doubleCheckMissingQuoteReference={doubleCheckMissingQuoteReference}
cancelAttachmentDownload={cancelAttachmentDownload}
kickOffAttachmentDownload={kickOffAttachmentDownload} kickOffAttachmentDownload={kickOffAttachmentDownload}
markAttachmentAsCorrupted={markAttachmentAsCorrupted} markAttachmentAsCorrupted={markAttachmentAsCorrupted}
messageExpanded={messageExpanded} messageExpanded={messageExpanded}

View file

@ -4,7 +4,6 @@
import { v4 as generateUuid } from 'uuid'; import { v4 as generateUuid } from 'uuid';
import type { import type {
AttachmentType,
AttachmentDraftType, AttachmentDraftType,
ThumbnailType, ThumbnailType,
AttachmentForUIType, AttachmentForUIType,
@ -12,7 +11,7 @@ import type {
import { IMAGE_JPEG } from '../../types/MIME'; import { IMAGE_JPEG } from '../../types/MIME';
export const fakeAttachment = ( export const fakeAttachment = (
overrides: Partial<AttachmentType> = {} overrides: Partial<AttachmentForUIType> = {}
): AttachmentForUIType => ({ ): AttachmentForUIType => ({
contentType: IMAGE_JPEG, contentType: IMAGE_JPEG,
width: 800, width: 800,

View file

@ -100,9 +100,9 @@ describe('processDataMessage', () => {
assert.deepStrictEqual(out.attachments, [ assert.deepStrictEqual(out.attachments, [
{ {
...PROCESSED_ATTACHMENT, ...PROCESSED_ATTACHMENT,
chunkSize: 2,
downloadPath: 'random-path', downloadPath: 'random-path',
incrementalMac: 'AAAA', incrementalMac: 'AAAA',
chunkSize: 2,
}, },
]); ]);
}); });

View file

@ -60,7 +60,7 @@ describe('convertFilePointerToAttachment', () => {
digest: Bytes.toBase64(Bytes.fromString('digest')), digest: Bytes.toBase64(Bytes.fromString('digest')),
uploadTimestamp: 1970, uploadTimestamp: 1970,
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')), incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
incrementalMacChunkSize: 1000, chunkSize: 1000,
downloadPath: 'downloadPath', downloadPath: 'downloadPath',
}); });
}); });
@ -102,7 +102,7 @@ describe('convertFilePointerToAttachment', () => {
key: Bytes.toBase64(Bytes.fromString('key')), key: Bytes.toBase64(Bytes.fromString('key')),
digest: Bytes.toBase64(Bytes.fromString('digest')), digest: Bytes.toBase64(Bytes.fromString('digest')),
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')), incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
incrementalMacChunkSize: 1000, chunkSize: 1000,
backupLocator: { backupLocator: {
mediaName: 'mediaName', mediaName: 'mediaName',
cdnNumber: 3, cdnNumber: 3,
@ -135,7 +135,7 @@ describe('convertFilePointerToAttachment', () => {
fileName: 'filename', fileName: 'filename',
caption: 'caption', caption: 'caption',
incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')), incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')),
incrementalMacChunkSize: 1000, chunkSize: 1000,
size: 0, size: 0,
error: true, error: true,
}); });
@ -163,7 +163,7 @@ describe('convertFilePointerToAttachment', () => {
key: undefined, key: undefined,
digest: undefined, digest: undefined,
incrementalMac: undefined, incrementalMac: undefined,
incrementalMacChunkSize: undefined, chunkSize: undefined,
backupLocator: undefined, backupLocator: undefined,
}); });
}); });
@ -190,7 +190,7 @@ function composeAttachment(
fileName: 'filename', fileName: 'filename',
caption: 'caption', caption: 'caption',
incrementalMac: 'incrementalMac', incrementalMac: 'incrementalMac',
incrementalMacChunkSize: 1000, chunkSize: 1000,
uploadTimestamp: 1234, uploadTimestamp: 1234,
localKey: Bytes.toBase64(generateKeys()), localKey: Bytes.toBase64(generateKeys()),
isReencryptableToSameDigest: true, isReencryptableToSameDigest: true,

View file

@ -142,15 +142,20 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager(
const decryptAttachmentV2ToSink = sinon.stub(); const decryptAttachmentV2ToSink = sinon.stub();
const { getAbsoluteAttachmentPath } = window.Signal.Migrations; const { getAbsoluteAttachmentPath } = window.Signal.Migrations;
const abortController = new AbortController();
runJob = sandbox.stub().callsFake((job: AttachmentBackupJobType) => { runJob = sandbox.stub().callsFake((job: AttachmentBackupJobType) => {
return runAttachmentBackupJob(job, false, { return runAttachmentBackupJob(
// @ts-expect-error incomplete stubbing job,
backupsService, { abortSignal: abortController.signal, isLastAttempt: false },
backupMediaBatch, {
getAbsoluteAttachmentPath, // @ts-expect-error incomplete stubbing
encryptAndUploadAttachment, backupsService,
decryptAttachmentV2ToSink, backupMediaBatch,
}); getAbsoluteAttachmentPath,
encryptAndUploadAttachment,
decryptAttachmentV2ToSink,
}
);
}); });
backupManager = new AttachmentBackupManager({ backupManager = new AttachmentBackupManager({

View file

@ -444,8 +444,11 @@ describe('AttachmentDownloadManager/JobManager', () => {
describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
let sandbox: sinon.SinonSandbox; let sandbox: sinon.SinonSandbox;
let deleteDownloadData: sinon.SinonStub;
let downloadAttachment: sinon.SinonStub; let downloadAttachment: sinon.SinonStub;
let processNewAttachment: sinon.SinonStub; let processNewAttachment: sinon.SinonStub;
const abortController = new AbortController();
beforeEach(async () => { beforeEach(async () => {
sandbox = sinon.createSandbox(); sandbox = sinon.createSandbox();
downloadAttachment = sandbox.stub().returns({ downloadAttachment = sandbox.stub().returns({
@ -470,7 +473,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
const result = await runDownloadAttachmentJobInner({ const result = await runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: true, isForCurrentlyVisibleMessage: true,
abortSignal: abortController.signal,
dependencies: { dependencies: {
deleteDownloadData,
downloadAttachment, downloadAttachment,
processNewAttachment, processNewAttachment,
}, },
@ -478,10 +483,13 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default); assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
assert.strictEqual(downloadAttachment.callCount, 1); assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment, const downloadCallArgs = downloadAttachment.getCall(0).args[0];
variant: AttachmentVariant.Default, assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
}); assert.deepStrictEqual(
downloadCallArgs.options.variant,
AttachmentVariant.Default
);
}); });
it('will download thumbnail if attachment is from backup', async () => { it('will download thumbnail if attachment is from backup', async () => {
const job = composeJob({ const job = composeJob({
@ -497,7 +505,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
const result = await runDownloadAttachmentJobInner({ const result = await runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: true, isForCurrentlyVisibleMessage: true,
abortSignal: abortController.signal,
dependencies: { dependencies: {
deleteDownloadData,
downloadAttachment, downloadAttachment,
processNewAttachment, processNewAttachment,
}, },
@ -521,10 +531,13 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
'/path/to/file' '/path/to/file'
); );
assert.strictEqual(downloadAttachment.callCount, 1); assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment, const downloadCallArgs = downloadAttachment.getCall(0).args[0];
variant: AttachmentVariant.ThumbnailFromBackup, assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
}); assert.deepStrictEqual(
downloadCallArgs.options.variant,
AttachmentVariant.ThumbnailFromBackup
);
}); });
it('will download full size if thumbnail already backed up', async () => { it('will download full size if thumbnail already backed up', async () => {
const job = composeJob({ const job = composeJob({
@ -543,17 +556,22 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
const result = await runDownloadAttachmentJobInner({ const result = await runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: true, isForCurrentlyVisibleMessage: true,
abortSignal: abortController.signal,
dependencies: { dependencies: {
deleteDownloadData,
downloadAttachment, downloadAttachment,
processNewAttachment, processNewAttachment,
}, },
}); });
assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default); assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
assert.strictEqual(downloadAttachment.callCount, 1); assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment, const downloadCallArgs = downloadAttachment.getCall(0).args[0];
variant: AttachmentVariant.Default, assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
}); assert.deepStrictEqual(
downloadCallArgs.options.variant,
AttachmentVariant.Default
);
}); });
it('will attempt to download full size if thumbnail fails', async () => { it('will attempt to download full size if thumbnail fails', async () => {
@ -575,7 +593,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
runDownloadAttachmentJobInner({ runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: true, isForCurrentlyVisibleMessage: true,
abortSignal: abortController.signal,
dependencies: { dependencies: {
deleteDownloadData,
downloadAttachment, downloadAttachment,
processNewAttachment, processNewAttachment,
}, },
@ -583,14 +603,20 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
); );
assert.strictEqual(downloadAttachment.callCount, 2); assert.strictEqual(downloadAttachment.callCount, 2);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment, const downloadCallArgs0 = downloadAttachment.getCall(0).args[0];
variant: AttachmentVariant.ThumbnailFromBackup, assert.deepStrictEqual(downloadCallArgs0.attachment, job.attachment);
}); assert.deepStrictEqual(
assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], { downloadCallArgs0.options.variant,
attachment: job.attachment, AttachmentVariant.ThumbnailFromBackup
variant: AttachmentVariant.Default, );
});
const downloadCallArgs1 = downloadAttachment.getCall(1).args[0];
assert.deepStrictEqual(downloadCallArgs1.attachment, job.attachment);
assert.deepStrictEqual(
downloadCallArgs1.options.variant,
AttachmentVariant.Default
);
}); });
}); });
describe('message not visible', () => { describe('message not visible', () => {
@ -608,21 +634,26 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
const result = await runDownloadAttachmentJobInner({ const result = await runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: false, isForCurrentlyVisibleMessage: false,
abortSignal: abortController.signal,
dependencies: { dependencies: {
deleteDownloadData,
downloadAttachment, downloadAttachment,
processNewAttachment, processNewAttachment,
}, },
}); });
assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default); assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default);
assert.strictEqual(downloadAttachment.callCount, 1); assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment, const downloadCallArgs = downloadAttachment.getCall(0).args[0];
variant: AttachmentVariant.Default, assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
}); assert.deepStrictEqual(
downloadCallArgs.options.variant,
AttachmentVariant.Default
);
}); });
it('will fallback to thumbnail if main download fails and backuplocator exists', async () => { it('will fallback to thumbnail if main download fails and backuplocator exists', async () => {
downloadAttachment = sandbox.stub().callsFake(({ variant }) => { downloadAttachment = sandbox.stub().callsFake(({ options }) => {
if (variant === AttachmentVariant.Default) { if (options.variant === AttachmentVariant.Default) {
throw new Error('error while downloading'); throw new Error('error while downloading');
} }
return { return {
@ -645,7 +676,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
const result = await runDownloadAttachmentJobInner({ const result = await runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: false, isForCurrentlyVisibleMessage: false,
abortSignal: abortController.signal,
dependencies: { dependencies: {
deleteDownloadData,
downloadAttachment, downloadAttachment,
processNewAttachment, processNewAttachment,
}, },
@ -655,19 +688,25 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
AttachmentVariant.ThumbnailFromBackup AttachmentVariant.ThumbnailFromBackup
); );
assert.strictEqual(downloadAttachment.callCount, 2); assert.strictEqual(downloadAttachment.callCount, 2);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment, const downloadCallArgs0 = downloadAttachment.getCall(0).args[0];
variant: AttachmentVariant.Default, assert.deepStrictEqual(downloadCallArgs0.attachment, job.attachment);
}); assert.deepStrictEqual(
assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], { downloadCallArgs0.options.variant,
attachment: job.attachment, AttachmentVariant.Default
variant: AttachmentVariant.ThumbnailFromBackup, );
});
const downloadCallArgs1 = downloadAttachment.getCall(1).args[0];
assert.deepStrictEqual(downloadCallArgs1.attachment, job.attachment);
assert.deepStrictEqual(
downloadCallArgs1.options.variant,
AttachmentVariant.ThumbnailFromBackup
);
}); });
it("won't fallback to thumbnail if main download fails and no backup locator", async () => { it("won't fallback to thumbnail if main download fails and no backup locator", async () => {
downloadAttachment = sandbox.stub().callsFake(({ variant }) => { downloadAttachment = sandbox.stub().callsFake(({ options }) => {
if (variant === AttachmentVariant.Default) { if (options.variant === AttachmentVariant.Default) {
throw new Error('error while downloading'); throw new Error('error while downloading');
} }
return { return {
@ -686,7 +725,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
runDownloadAttachmentJobInner({ runDownloadAttachmentJobInner({
job, job,
isForCurrentlyVisibleMessage: false, isForCurrentlyVisibleMessage: false,
abortSignal: abortController.signal,
dependencies: { dependencies: {
deleteDownloadData,
downloadAttachment, downloadAttachment,
processNewAttachment, processNewAttachment,
}, },
@ -694,10 +735,13 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => {
); );
assert.strictEqual(downloadAttachment.callCount, 1); assert.strictEqual(downloadAttachment.callCount, 1);
assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], {
attachment: job.attachment, const downloadCallArgs = downloadAttachment.getCall(0).args[0];
variant: AttachmentVariant.Default, assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment);
}); assert.deepStrictEqual(
downloadCallArgs.options.variant,
AttachmentVariant.Default
);
}); });
}); });
}); });

View file

@ -97,7 +97,10 @@ describe('MessageCache', () => {
'same objects from mc.__DEPRECATED$register' 'same objects from mc.__DEPRECATED$register'
); );
const messageById = window.MessageCache.__DEPRECATED$getById(message1.id); const messageById = window.MessageCache.__DEPRECATED$getById(
message1.id,
'test'
);
assert.strictEqual(message1, messageById, 'same objects from mc.getById'); assert.strictEqual(message1, messageById, 'same objects from mc.getById');
@ -123,7 +126,8 @@ describe('MessageCache', () => {
); );
const newMessageById = window.MessageCache.__DEPRECATED$getById( const newMessageById = window.MessageCache.__DEPRECATED$getById(
message1.id message1.id,
'test'
); );
assert.deepEqual( assert.deepEqual(
message1.attributes, message1.attributes,

View file

@ -3,6 +3,8 @@
import { assert } from 'chai'; import { assert } from 'chai';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { noop } from 'lodash';
import { DataWriter } from '../../sql/Client'; import { DataWriter } from '../../sql/Client';
import { IMAGE_PNG } from '../../types/MIME'; import { IMAGE_PNG } from '../../types/MIME';
import { import {
@ -22,6 +24,7 @@ describe('utils/downloadAttachment', () => {
contentType: IMAGE_PNG, contentType: IMAGE_PNG,
digest: 'digest', digest: 'digest',
}; };
const abortController = new AbortController();
let sandbox: sinon.SinonSandbox; let sandbox: sinon.SinonSandbox;
const fakeServer = {}; const fakeServer = {};
@ -42,6 +45,10 @@ describe('utils/downloadAttachment', () => {
}; };
await downloadAttachment({ await downloadAttachment({
attachment, attachment,
options: {
onSizeUpdate: noop,
abortSignal: abortController.signal,
},
dependencies: { dependencies: {
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
@ -53,6 +60,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.STANDARD, mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -72,6 +81,10 @@ describe('utils/downloadAttachment', () => {
await assert.isRejected( await assert.isRejected(
downloadAttachment({ downloadAttachment({
attachment, attachment,
options: {
onSizeUpdate: noop,
abortSignal: abortController.signal,
},
dependencies: { dependencies: {
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
@ -86,6 +99,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.STANDARD, mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -103,6 +118,10 @@ describe('utils/downloadAttachment', () => {
}; };
await downloadAttachment({ await downloadAttachment({
attachment, attachment,
options: {
onSizeUpdate: noop,
abortSignal: abortController.signal,
},
dependencies: { dependencies: {
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
@ -114,6 +133,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.BACKUP, mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -135,6 +156,10 @@ describe('utils/downloadAttachment', () => {
}; };
await downloadAttachment({ await downloadAttachment({
attachment, attachment,
options: {
onSizeUpdate: noop,
abortSignal: abortController.signal,
},
dependencies: { dependencies: {
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
@ -146,6 +171,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.BACKUP, mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -155,6 +182,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.STANDARD, mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -176,6 +205,10 @@ describe('utils/downloadAttachment', () => {
}; };
await downloadAttachment({ await downloadAttachment({
attachment, attachment,
options: {
onSizeUpdate: noop,
abortSignal: abortController.signal,
},
dependencies: { dependencies: {
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
@ -187,6 +220,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.BACKUP, mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -196,6 +231,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.STANDARD, mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -218,6 +255,10 @@ describe('utils/downloadAttachment', () => {
await assert.isRejected( await assert.isRejected(
downloadAttachment({ downloadAttachment({
attachment, attachment,
options: {
onSizeUpdate: noop,
abortSignal: abortController.signal,
},
dependencies: { dependencies: {
downloadAttachmentFromServer: stubDownload, downloadAttachmentFromServer: stubDownload,
}, },
@ -231,6 +272,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.BACKUP, mediaTier: MediaTier.BACKUP,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);
@ -240,6 +283,8 @@ describe('utils/downloadAttachment', () => {
{ {
mediaTier: MediaTier.STANDARD, mediaTier: MediaTier.STANDARD,
variant: AttachmentVariant.Default, variant: AttachmentVariant.Default,
onSizeUpdate: noop,
abortSignal: abortController.signal,
logPrefix: '[REDACTED]est', logPrefix: '[REDACTED]est',
}, },
]); ]);

View file

@ -1328,6 +1328,7 @@ export type WebAPIType = {
disableRetries?: boolean; disableRetries?: boolean;
timeout?: number; timeout?: number;
downloadOffset?: number; downloadOffset?: number;
abortSignal: AbortSignal;
}; };
}) => Promise<Readable>; }) => Promise<Readable>;
getAttachment: (args: { getAttachment: (args: {
@ -1337,6 +1338,7 @@ export type WebAPIType = {
disableRetries?: boolean; disableRetries?: boolean;
timeout?: number; timeout?: number;
downloadOffset?: number; downloadOffset?: number;
abortSignal?: AbortSignal;
}; };
}) => Promise<Readable>; }) => Promise<Readable>;
getAttachmentUploadForm: () => Promise<AttachmentUploadFormResponseType>; getAttachmentUploadForm: () => Promise<AttachmentUploadFormResponseType>;
@ -3784,6 +3786,7 @@ export function initialize({
disableRetries?: boolean; disableRetries?: boolean;
timeout?: number; timeout?: number;
downloadOffset?: number; downloadOffset?: number;
abortSignal?: AbortSignal;
}; };
}) { }) {
return _getAttachment({ return _getAttachment({

View file

@ -102,16 +102,18 @@ export async function downloadAttachment(
server: WebAPIType, server: WebAPIType,
attachment: ProcessedAttachment, attachment: ProcessedAttachment,
options: { options: {
variant?: AttachmentVariant;
disableRetries?: boolean; disableRetries?: boolean;
timeout?: number;
mediaTier?: MediaTier;
logPrefix?: string; logPrefix?: string;
} = { variant: AttachmentVariant.Default } mediaTier?: MediaTier;
onSizeUpdate: (totalBytes: number) => void;
timeout?: number;
variant: AttachmentVariant;
abortSignal: AbortSignal;
}
): Promise<ReencryptedAttachmentV2 & { size?: number }> { ): Promise<ReencryptedAttachmentV2 & { size?: number }> {
const logId = `downloadAttachment/${options.logPrefix ?? ''}`; const logId = `downloadAttachment/${options.logPrefix ?? ''}`;
const { chunkSize, digest, incrementalMac, key, size } = attachment; const { digest, incrementalMac, chunkSize, key, size } = attachment;
strictAssert(digest, `${logId}: missing digest`); strictAssert(digest, `${logId}: missing digest`);
strictAssert(key, `${logId}: missing key`); strictAssert(key, `${logId}: missing key`);
@ -127,7 +129,7 @@ export async function downloadAttachment(
let downloadOffset = 0; let downloadOffset = 0;
if (downloadPath) { if (downloadPath) {
const absoluteDownloadPath = const absoluteDownloadPath =
window.Signal.Migrations.getAbsoluteAttachmentPath(downloadPath); window.Signal.Migrations.getAbsoluteDownloadsPath(downloadPath);
try { try {
({ size: downloadOffset } = await stat(absoluteDownloadPath)); ({ size: downloadOffset } = await stat(absoluteDownloadPath));
} catch (error) { } catch (error) {
@ -173,10 +175,11 @@ export async function downloadAttachment(
}, },
}); });
downloadResult = await downloadToDisk({ downloadResult = await downloadToDisk({
downloadStream,
size,
downloadPath,
downloadOffset, downloadOffset,
downloadPath,
downloadStream,
onSizeUpdate: options.onSizeUpdate,
size,
}); });
} else { } else {
const mediaId = const mediaId =
@ -209,6 +212,7 @@ export async function downloadAttachment(
downloadStream, downloadStream,
downloadPath, downloadPath,
downloadOffset, downloadOffset,
onSizeUpdate: options.onSizeUpdate,
size: getAttachmentCiphertextLength( size: getAttachmentCiphertextLength(
options.variant === AttachmentVariant.ThumbnailFromBackup options.variant === AttachmentVariant.ThumbnailFromBackup
? // be generous, accept downloads of up to twice what we expect for thumbnail ? // be generous, accept downloads of up to twice what we expect for thumbnail
@ -275,19 +279,23 @@ export async function downloadAttachment(
} }
} }
} finally { } finally {
await safeUnlink(cipherTextAbsolutePath); if (!downloadPath) {
await safeUnlink(cipherTextAbsolutePath);
}
} }
} }
async function downloadToDisk({ async function downloadToDisk({
downloadStream,
downloadPath,
downloadOffset = 0, downloadOffset = 0,
downloadPath,
downloadStream,
onSizeUpdate,
size, size,
}: { }: {
downloadStream: Readable;
downloadPath?: string;
downloadOffset?: number; downloadOffset?: number;
downloadPath?: string;
downloadStream: Readable;
onSizeUpdate: (totalBytes: number) => void;
size: number; size: number;
}): Promise<{ absolutePath: string; downloadSize: number }> { }): Promise<{ absolutePath: string; downloadSize: number }> {
const absoluteTargetPath = downloadPath const absoluteTargetPath = downloadPath
@ -317,8 +325,12 @@ async function downloadToDisk({
await pipeline( await pipeline(
downloadStream, downloadStream,
checkSize(targetSize), checkSize(targetSize),
measureSize(bytesSeen => { measureSize({
downloadSize = bytesSeen; downloadOffset,
onSizeUpdate,
onComplete: bytesSeen => {
downloadSize = bytesSeen;
},
}), }),
writeStream writeStream
); );

View file

@ -66,7 +66,6 @@ export type AttachmentType = {
/** For messages not already on disk, this will be a data url */ /** For messages not already on disk, this will be a data url */
url?: string; url?: string;
size: number; size: number;
fileSize?: string;
pending?: boolean; pending?: boolean;
width?: number; width?: number;
height?: number; height?: number;
@ -88,8 +87,9 @@ export type AttachmentType = {
textAttachment?: TextAttachmentType; textAttachment?: TextAttachmentType;
wasTooBig?: boolean; wasTooBig?: boolean;
totalDownloaded?: number;
incrementalMac?: string; incrementalMac?: string;
incrementalMacChunkSize?: number; chunkSize?: number;
backupLocator?: { backupLocator?: {
mediaName: string; mediaName: string;
@ -779,6 +779,21 @@ export function isDownloaded(
return Boolean(resolved && (resolved.path || resolved.textAttachment)); return Boolean(resolved && (resolved.path || resolved.textAttachment));
} }
export function isReadyToView(
attachment?: Pick<
AttachmentType,
'incrementalMac' | 'chunkSize' | 'path' | 'textAttachment'
>
): boolean {
const fullyDownloaded = isDownloaded(attachment);
if (fullyDownloaded) {
return fullyDownloaded;
}
const resolved = resolveNestedAttachment(attachment);
return Boolean(resolved && (resolved.path || resolved.textAttachment));
}
export function hasNotResolved(attachment?: AttachmentType): boolean { export function hasNotResolved(attachment?: AttachmentType): boolean {
const resolved = resolveNestedAttachment(attachment); const resolved = resolveNestedAttachment(attachment);
return Boolean(resolved && !resolved.url && !resolved.textAttachment); return Boolean(resolved && !resolved.url && !resolved.textAttachment);

View file

@ -12,7 +12,7 @@ export function getMessageModelLogger(model: MessageModel): MessageModel {
} }
const proxyHandler: ProxyHandler<MessageModel> = { const proxyHandler: ProxyHandler<MessageModel> = {
get(target: MessageModel, property: keyof MessageModel) { get(_: MessageModel, property: keyof MessageModel) {
// Allowed set of attributes & methods // Allowed set of attributes & methods
if (property === 'attributes') { if (property === 'attributes') {
return model.attributes; return model.attributes;
@ -31,17 +31,17 @@ export function getMessageModelLogger(model: MessageModel): MessageModel {
} }
if (property === 'registerLocations') { if (property === 'registerLocations') {
return target.registerLocations; return model.registerLocations;
} }
// Disallowed set of methods & attributes // Disallowed set of methods & attributes
if (typeof target[property] === 'function') { if (typeof model[property] === 'function') {
return target[property].bind(target); return model[property].bind(model);
} }
if (typeof target[property] !== 'undefined') { if (typeof model[property] !== 'undefined') {
return target[property]; return model[property];
} }
return undefined; return undefined;

View file

@ -66,7 +66,10 @@ export async function flushAttachmentDownloadQueue(): Promise<void> {
let numMessagesQueued = 0; let numMessagesQueued = 0;
await Promise.all( await Promise.all(
messageIdsToDownload.map(async messageId => { messageIdsToDownload.map(async messageId => {
const message = window.MessageCache.__DEPRECATED$getById(messageId); const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'flushAttachmentDownloadQueue'
);
if (!message) { if (!message) {
log.warn( log.warn(
'attachmentDownloadQueue: message not found in messageCache, maybe it was deleted?' 'attachmentDownloadQueue: message not found in messageCache, maybe it was deleted?'

View file

@ -83,7 +83,7 @@ export type CdnFieldsType = Pick<
| 'cdnNumber' | 'cdnNumber'
| 'digest' | 'digest'
| 'incrementalMac' | 'incrementalMac'
| 'incrementalMacChunkSize' | 'chunkSize'
| 'isReencryptableToSameDigest' | 'isReencryptableToSameDigest'
| 'iv' | 'iv'
| 'key' | 'key'
@ -104,7 +104,7 @@ export function copyCdnFields(
incrementalMac: uploaded.incrementalMac incrementalMac: uploaded.incrementalMac
? Bytes.toBase64(uploaded.incrementalMac) ? Bytes.toBase64(uploaded.incrementalMac)
: undefined, : undefined,
incrementalMacChunkSize: dropNull(uploaded.chunkSize), chunkSize: dropNull(uploaded.chunkSize),
isReencryptableToSameDigest: uploaded.isReencryptableToSameDigest, isReencryptableToSameDigest: uploaded.isReencryptableToSameDigest,
iv: Bytes.toBase64(uploaded.iv), iv: Bytes.toBase64(uploaded.iv),
key: Bytes.toBase64(uploaded.key), key: Bytes.toBase64(uploaded.key),

View file

@ -1356,7 +1356,10 @@ export async function updateCallHistoryFromLocalEvent(
export function updateDeletedMessages(messageIds: ReadonlyArray<string>): void { export function updateDeletedMessages(messageIds: ReadonlyArray<string>): void {
messageIds.forEach(messageId => { messageIds.forEach(messageId => {
const message = window.MessageCache.__DEPRECATED$getById(messageId); const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'updateDeletedMessages'
);
const conversation = message?.getConversation(); const conversation = message?.getConversation();
if (message == null || conversation == null) { if (message == null || conversation == null) {
return; return;

View file

@ -9,7 +9,10 @@ import * as log from '../logging/log';
export async function deleteGroupStoryReplyForEveryone( export async function deleteGroupStoryReplyForEveryone(
replyMessageId: string replyMessageId: string
): Promise<void> { ): Promise<void> {
const messageModel = await __DEPRECATED$getMessageById(replyMessageId); const messageModel = await __DEPRECATED$getMessageById(
replyMessageId,
'deleteGroupStoryReplyForEveryone'
);
if (!messageModel) { if (!messageModel) {
log.warn( log.warn(

View file

@ -47,7 +47,10 @@ export async function deleteStoryForEveryone(
} }
const logId = `deleteStoryForEveryone(${story.messageId})`; const logId = `deleteStoryForEveryone(${story.messageId})`;
const message = await __DEPRECATED$getMessageById(story.messageId); const message = await __DEPRECATED$getMessageById(
story.messageId,
'deleteStoryForEveryone'
);
if (!message) { if (!message) {
throw new Error('Story not found'); throw new Error('Story not found');
} }

View file

@ -18,11 +18,15 @@ export class AttachmentPermanentlyUndownloadableError extends Error {}
export async function downloadAttachment({ export async function downloadAttachment({
attachment, attachment,
variant = AttachmentVariant.Default, options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal },
dependencies = { downloadAttachmentFromServer: doDownloadAttachment }, dependencies = { downloadAttachmentFromServer: doDownloadAttachment },
}: { }: {
attachment: AttachmentType; attachment: AttachmentType;
variant?: AttachmentVariant; options: {
variant?: AttachmentVariant;
onSizeUpdate: (totalBytes: number) => void;
abortSignal: AbortSignal;
};
dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment }; dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment };
}): Promise<ReencryptedAttachmentV2> { }): Promise<ReencryptedAttachmentV2> {
const attachmentId = getAttachmentIdForLogging(attachment); const attachmentId = getAttachmentIdForLogging(attachment);
@ -54,9 +58,11 @@ export async function downloadAttachment({
server, server,
migratedAttachment, migratedAttachment,
{ {
variant,
mediaTier: MediaTier.BACKUP,
logPrefix: dataId, logPrefix: dataId,
mediaTier: MediaTier.BACKUP,
onSizeUpdate,
variant,
abortSignal,
} }
); );
} catch (error) { } catch (error) {
@ -80,9 +86,11 @@ export async function downloadAttachment({
server, server,
migratedAttachment, migratedAttachment,
{ {
variant,
mediaTier: MediaTier.STANDARD,
logPrefix: dataId, logPrefix: dataId,
mediaTier: MediaTier.STANDARD,
onSizeUpdate,
variant,
abortSignal,
} }
); );
} catch (error) { } catch (error) {

11
ts/util/dropZero.ts Normal file
View file

@ -0,0 +1,11 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber } from 'lodash';
export function dropZero(value: number | null | undefined): number | undefined {
if (isNumber(value) && value !== 0) {
return value;
}
return undefined;
}

View file

@ -2,6 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import filesize from 'filesize'; import filesize from 'filesize';
export function formatFileSize(size: number): string { export function formatFileSize(size: number, decimals = 0): string {
return filesize(size, { round: 0 }); return filesize(size, { round: decimals });
} }

View file

@ -104,7 +104,8 @@ export async function markConversationRead(
const allReadMessagesSync = allUnreadMessages const allReadMessagesSync = allUnreadMessages
.map(messageSyncData => { .map(messageSyncData => {
const message = window.MessageCache.__DEPRECATED$getById( const message = window.MessageCache.__DEPRECATED$getById(
messageSyncData.id messageSyncData.id,
'markConversationRead'
); );
// we update the in-memory MessageModel with fresh read/seen status // we update the in-memory MessageModel with fresh read/seen status
if (message) { if (message) {

View file

@ -20,7 +20,9 @@ export async function markOnboardingStoryAsRead(): Promise<boolean> {
} }
const messages = await Promise.all( const messages = await Promise.all(
existingOnboardingStoryMessageIds.map(__DEPRECATED$getMessageById) existingOnboardingStoryMessageIds.map(id =>
__DEPRECATED$getMessageById(id, 'markOnboardingStoryAsRead')
)
); );
const storyReadDate = Date.now(); const storyReadDate = Date.now();

View file

@ -35,7 +35,10 @@ export async function sendDeleteForEveryoneMessage(
timestamp: targetTimestamp, timestamp: targetTimestamp,
id: messageId, id: messageId,
} = options; } = options;
const message = await __DEPRECATED$getMessageById(messageId); const message = await __DEPRECATED$getMessageById(
messageId,
'sendDeleteForEveryoneMessage'
);
if (!message) { if (!message) {
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!'); throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
} }

View file

@ -65,7 +65,10 @@ export async function sendEditedMessage(
conversation.attributes conversation.attributes
)})`; )})`;
const targetMessage = await __DEPRECATED$getMessageById(targetMessageId); const targetMessage = await __DEPRECATED$getMessageById(
targetMessageId,
'sendEditedMessage'
);
strictAssert(targetMessage, 'could not find message to edit'); strictAssert(targetMessage, 'could not find message to edit');
if (isGroupV1(conversation.attributes)) { if (isGroupV1(conversation.attributes)) {

View file

@ -66,7 +66,7 @@ if (
}, },
getConversation: (id: string) => window.ConversationController.get(id), getConversation: (id: string) => window.ConversationController.get(id),
getMessageById: (id: string) => getMessageById: (id: string) =>
window.MessageCache.__DEPRECATED$getById(id), window.MessageCache.__DEPRECATED$getById(id, 'SignalDebug'),
getMessageBySentAt: (timestamp: number) => getMessageBySentAt: (timestamp: number) =>
window.MessageCache.findBySentAt(timestamp, () => true), window.MessageCache.findBySentAt(timestamp, () => true),
getReduxState: () => window.reduxStore.getState(), getReduxState: () => window.reduxStore.getState(),