Show attachment download progress, new stop button to cancel

This commit is contained in:
automated-signal 2024-12-10 12:21:40 -06:00 committed by GitHub
parent 7124ba36cd
commit 5cb6b3f686
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",
"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": {
"messageformat": "Save",
"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;
display: flex;
justify-content: center;
border-radius: 48px;
height: 48px;
width: 48px;
border-radius: 50px;
height: 50px;
width: 50px;
background-color: variables.$color-black-alpha-70;
}
@ -2719,42 +2719,29 @@ button.ConversationDetails__action-button {
align-items: center;
content: 'GIF';
height: 24px;
width: 24px;
@include mixins.font-body-1;
color: variables.$color-white;
}
}
.module-image__download-pending {
position: relative;
.module-image__progress-circle-wrapper {
@include mixins.position-absolute-center;
&--spinner-container {
align-items: center;
display: flex;
height: 100%;
justify-content: center;
inset-inline-start: 0;
position: absolute;
top: 0;
width: 100%;
.ProgressCircle .ProgressCircle__background {
stroke: variables.$color-white-alpha-20;
}
.ProgressCircle .ProgressCircle__fill {
stroke: variables.$color-white;
}
}
&--spinner {
background-color: variables.$color-gray-75;
border-radius: 48px;
height: 48px;
width: 48px;
.module-image__spinner-container {
@include mixins.position-absolute-center;
.module-image-spinner {
&__container {
margin-block: 12px;
margin-inline: auto;
}
&__arc {
background-color: variables.$color-gray-75;
background-color: variables.$color-black-alpha-80;
}
&__circle {
@ -2763,8 +2750,7 @@ button.ConversationDetails__action-button {
@include mixins.dark-theme {
&__arc {
background-color: variables.$color-gray-75;
}
background-color: variables.$color-black-alpha-80;
}
}
}
@ -2791,10 +2777,10 @@ button.ConversationDetails__action-button {
.module-image__border-overlay {
@include mixins.button-reset;
& {
width: 100%;
cursor: inherit;
pointer-events: none;
position: absolute;
top: 0;
@ -2806,6 +2792,7 @@ button.ConversationDetails__action-button {
.module-image__border-overlay--with-click-handler {
cursor: pointer;
pointer-events: all;
}
.module-image__border-overlay--with-border {
@ -2818,24 +2805,6 @@ button.ConversationDetails__action-button {
}
.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 {
cursor: pointer;
object-fit: cover;
@ -2897,22 +2866,46 @@ button.module-image__border-overlay:focus {
inset-inline: 0;
}
.module-image__play-overlay__circle {
.module-image__overlay-circle {
@include mixins.position-absolute-center;
width: 48px;
height: 48px;
background-color: variables.$color-white;
border-radius: 24px;
@include mixins.button-reset;
& {
width: 50px;
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;
height: 24px;
width: 24px;
@include mixins.color-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 {
position: relative;
display: inline-flex;
flex-direction: row;
align-items: center;
@ -2969,6 +2963,43 @@ button.module-image__border-overlay:focus {
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 {
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/AnnouncementsOnlyGroupBanner.scss';
@use 'components/App.scss';
@use 'components/AttachmentDetailPill.scss';
@use 'components/AudioCapture.scss';
@use 'components/AutoSizeInput.scss';
@use 'components/Avatar.scss';

View file

@ -244,8 +244,10 @@ export async function encryptAttachmentV2({
}),
peekAndUpdateHash(digest),
incrementalDigestCreator,
measureSize(finalSize => {
measureSize({
onComplete: finalSize => {
ciphertextSize = finalSize;
},
}),
sink ?? new PassThrough().resume(),
].filter(isNotNil)
@ -434,6 +436,7 @@ export async function decryptAttachmentV2ToSink(
let isPaddingAllZeros = false;
let readFd;
let iv: Uint8Array | undefined;
try {
try {
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;
const passthrough = new PassThrough();
passthrough.on('data', chunk => {
totalBytes += chunk.length;
onSizeUpdate?.(totalBytes + downloadOffset);
});
passthrough.on('end', () => {
onComplete(totalBytes);
});
return passthrough;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -2,17 +2,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { CSSProperties } from 'react';
import React, { useCallback, useMemo } from 'react';
import React, { useCallback } from 'react';
import classNames from 'classnames';
import { Blurhash } from 'react-blurhash';
import { Spinner } from '../Spinner';
import type { LocalizerType, ThemeType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment';
import {
isDownloaded as isDownloadedFunction,
defaultBlurHash,
import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import { defaultBlurHash, isReadyToView } from '../../types/Attachment';
import { ProgressCircle } from '../ProgressCircle';
export enum CurveType {
None = 0,
@ -23,10 +24,9 @@ export enum CurveType {
export type Props = {
alt: string;
attachment: AttachmentType;
attachment: AttachmentForUIType;
url?: string;
isDownloaded?: boolean;
className?: string;
height?: number;
width?: number;
@ -51,7 +51,9 @@ export type Props = {
i18n: LocalizerType;
theme?: ThemeType;
onClick?: (attachment: AttachmentType) => void;
showVisualAttachment?: (attachment: AttachmentType) => void;
cancelDownload?: () => void;
startDownload?: () => void;
onClickClose?: (attachment: AttachmentType) => void;
onError?: () => void;
};
@ -68,12 +70,13 @@ export function Image({
curveTopLeft,
curveTopRight,
darkOverlay,
isDownloaded,
height = 0,
i18n,
noBackground,
noBorder,
onClick,
showVisualAttachment,
startDownload,
cancelDownload,
onClickClose,
onError,
overlayText,
@ -85,11 +88,6 @@ export function Image({
cropWidth = 0,
cropHeight = 0,
}: Props): JSX.Element {
const { caption, pending } = attachment || { caption: null, pending: true };
const imgNotDownloaded = isDownloaded
? false
: !isDownloadedFunction(attachment);
const resolvedBlurHash = blurHash || defaultBlurHash(theme);
const curveStyles: CSSProperties = {
@ -99,48 +97,112 @@ export function Image({
borderEndEndRadius: curveBottomRight || CurveType.None,
};
const canClick = useMemo(() => {
return onClick != null && !pending;
}, [pending, onClick]);
const handleClick = useCallback(
const showVisualAttachmentClick = useCallback(
(event: React.MouseEvent) => {
if (!canClick) {
if (showVisualAttachment) {
event.preventDefault();
event.stopPropagation();
return;
}
if (onClick) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
showVisualAttachment(attachment);
}
},
[attachment, canClick, onClick]
[attachment, showVisualAttachment]
);
const handleKeyDown = useCallback(
const showVisualAttachmentKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (!canClick) {
if (
showVisualAttachment &&
(event.key === 'Enter' || event.key === 'Space')
) {
event.preventDefault();
event.stopPropagation();
return;
}
if (onClick && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
onClick(attachment);
showVisualAttachment(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 (
<div
className={classNames(
@ -155,70 +217,11 @@ export function Image({
...curveStyles,
}}
>
{pending ? (
url || blurHash ? (
<div className="module-image__download-pending">
{url ? (
<img
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 ? (
{imageOrBlurHash}
{startDownloadButton}
{spinner}
{attachment.caption ? (
<img
className="module-image__caption-icon"
src="images/caption-shadow.svg"
@ -234,9 +237,9 @@ export function Image({
}}
/>
) : null}
{!pending && !imgNotDownloaded && playIconOverlay ? (
<div className="module-image__play-overlay__circle">
<div className="module-image__play-overlay__icon" />
{attachment.path && playIconOverlay ? (
<div className="module-image__overlay-circle">
<div className="module-image__play-icon" />
</div>
) : null}
{overlayText ? (
@ -247,22 +250,27 @@ export function Image({
{overlayText}
</div>
) : 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
type="button"
className={classNames('module-image__border-overlay', {
'module-image__border-overlay--with-border': !noBorder,
'module-image__border-overlay--with-click-handler': canClick,
'module-image__border-overlay--dark': darkOverlay,
'module-image--not-downloaded': imgNotDownloaded,
'module-image__border-overlay--with-click-handler': true,
})}
aria-label={i18n('icu:imageOpenAlt')}
style={curveStyles}
onClick={handleClick}
onKeyDown={handleKeyDown}
onClick={showVisualAttachmentClick}
onKeyDown={showVisualAttachmentKeyDown}
tabIndex={tabIndex}
>
{imgNotDownloaded ? <span /> : null}
</button>
/>
) : null}
{closeButton ? (
<button
@ -282,5 +290,71 @@ export function Image({
) : null}
</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',
i18n,
isSticker: false,
onClick: action('onClick'),
showVisualAttachment: action('showVisualAttachment'),
startDownload: action('startDownload'),
cancelDownload: action('cancelDownload'),
onError: action('onError'),
stickerSize: 0,
tabIndex: 0,
@ -57,13 +59,111 @@ export function OneImage(args: Props): JSX.Element {
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 {
return (
<ImageGrid
{...args}
attachments={[
fakeAttachment({
contentType: IMAGE_PNG,
contentType: VIDEO_MP4,
fileName: 'sax.png',
height: 1200,
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 {
return (
<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 {
return (
<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 {
return (
<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 => {
return (
<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 {
return (
<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 {
return (
<ImageGrid

View file

@ -20,6 +20,7 @@ import {
import { Image, CurveType } from './Image';
import type { LocalizerType, ThemeType } from '../../types/Util';
import { AttachmentDetailPill } from './AttachmentDetailPill';
export type DirectionType = 'incoming' | 'outgoing';
@ -39,7 +40,9 @@ export type Props = {
theme?: ThemeType;
onError: () => void;
onClick?: (attachment: AttachmentType) => void;
showVisualAttachment: (attachment: AttachmentType) => void;
cancelDownload: () => void;
startDownload: () => void;
};
const GAP = 1;
@ -108,7 +111,9 @@ export function ImageGrid({
isSticker,
stickerSize,
onError,
onClick,
showVisualAttachment,
cancelDownload,
startDownload,
shouldCollapseAbove,
shouldCollapseBelow,
tabIndex,
@ -127,10 +132,46 @@ export function ImageGrid({
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) {
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)) {
const { height, width } = getImageDimensions(
attachments[0],
@ -165,9 +206,12 @@ export function ImageGrid({
getUrl(attachments[0]) ?? attachments[0].thumbnailFromBackup?.url
}
tabIndex={tabIndex}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={startDownload}
onError={onError}
/>
{detailPill}
</div>
);
}
@ -190,7 +234,9 @@ export function ImageGrid({
width={150}
cropWidth={GAP}
url={getThumbnailUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -207,9 +253,13 @@ export function ImageGrid({
width={150}
attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
{detailPill}
{downloadPill}
</div>
);
}
@ -232,7 +282,9 @@ export function ImageGrid({
width={200}
cropWidth={GAP}
url={getUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<div className="module-image-grid__column">
@ -248,7 +300,9 @@ export function ImageGrid({
attachment={attachments[1]}
playIconOverlay={isVideoAttachment(attachments[1])}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -264,10 +318,14 @@ export function ImageGrid({
attachment={attachments[2]}
playIconOverlay={isVideoAttachment(attachments[2])}
url={getThumbnailUrl(attachments[2])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
{detailPill}
{downloadPill}
</div>
);
}
@ -291,7 +349,9 @@ export function ImageGrid({
cropHeight={GAP}
cropWidth={GAP}
url={getThumbnailUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -307,7 +367,9 @@ export function ImageGrid({
cropHeight={GAP}
attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
@ -326,7 +388,9 @@ export function ImageGrid({
cropWidth={GAP}
attachment={attachments[2]}
url={getThumbnailUrl(attachments[2])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -342,11 +406,15 @@ export function ImageGrid({
width={150}
attachment={attachments[3]}
url={getThumbnailUrl(attachments[3])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
</div>
{detailPill}
{downloadPill}
</div>
);
}
@ -372,7 +440,9 @@ export function ImageGrid({
width={150}
cropWidth={GAP}
url={getThumbnailUrl(attachments[0])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -386,7 +456,9 @@ export function ImageGrid({
width={150}
attachment={attachments[1]}
url={getThumbnailUrl(attachments[1])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
</div>
@ -405,7 +477,9 @@ export function ImageGrid({
cropWidth={GAP}
attachment={attachments[2]}
url={getThumbnailUrl(attachments[2])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -421,7 +495,9 @@ export function ImageGrid({
cropWidth={GAP}
attachment={attachments[3]}
url={getThumbnailUrl(attachments[3])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={cancelDownload}
startDownload={downloadPill ? undefined : startDownload}
onError={onError}
/>
<Image
@ -439,11 +515,51 @@ export function ImageGrid({
overlayText={moreMessagesOverlayText}
attachment={attachments[4]}
url={getThumbnailUrl(attachments[4])}
onClick={onClick}
showVisualAttachment={showVisualAttachment}
cancelDownload={undefined}
startDownload={undefined}
onError={onError}
/>
</div>
</div>
{detailPill}
{downloadPill}
</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 classNames from 'classnames';
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 type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow';
import type { ReadonlyDeep } from 'type-fest';
@ -52,7 +52,10 @@ import type { WidthBreakpoint } from '../_util';
import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal';
import * as log from '../../logging/log';
import { StoryViewModeType } from '../../types/Stories';
import type { AttachmentType } from '../../types/Attachment';
import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import {
canDisplayImage,
getExtensionForDisplay,
@ -101,6 +104,7 @@ import { UserText } from '../UserText';
import { getColorForCallLink } from '../../util/getColorForCallLink';
import { getKeyFromCallLink } from '../../util/callLinks';
import { InAnotherCallTooltip } from './InAnotherCallTooltip';
import { formatFileSize } from '../../util/formatFileSize';
const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16;
const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18;
@ -173,7 +177,7 @@ export type AudioAttachmentProps = {
i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>;
theme: ThemeType | undefined;
attachment: AttachmentType;
attachment: AttachmentForUIType;
collapseMetadata: boolean;
withContentAbove: boolean;
withContentBelow: boolean;
@ -226,7 +230,7 @@ export type PropsData = {
activeCallConversationId?: string;
text?: string;
textDirection: TextDirection;
textAttachment?: AttachmentType;
textAttachment?: AttachmentForUIType;
isEditedMessage?: boolean;
isSticker?: boolean;
isTargeted?: boolean;
@ -255,7 +259,7 @@ export type PropsData = {
| 'unblurredAvatarUrl'
>;
conversationType: ConversationTypeType;
attachments?: ReadonlyArray<AttachmentType>;
attachments?: ReadonlyArray<AttachmentForUIType>;
giftBadge?: GiftBadgeType;
payment?: AnyPaymentEvent;
quote?: {
@ -312,6 +316,8 @@ export type PropsData = {
onKeyDown?: (event: React.KeyboardEvent<HTMLDivElement>) => void;
item?: never;
// test-only, to force GIF's reduced motion experience
_forceTapToPlay?: boolean;
};
export type PropsHousekeeping = {
@ -344,10 +350,8 @@ export type PropsActions = {
showContactModal: (contactId: string, conversationId?: string) => void;
showSpoiler: (messageId: string, data: Record<number, boolean>) => void;
kickOffAttachmentDownload: (options: {
attachment: AttachmentType;
messageId: string;
}) => void;
cancelAttachmentDownload: (options: { messageId: string }) => void;
kickOffAttachmentDownload: (options: { messageId: string }) => void;
markAttachmentAsCorrupted: (options: {
attachment: AttachmentType;
messageId: string;
@ -919,10 +923,12 @@ export class Message extends React.PureComponent<Props, State> {
const {
attachments,
attachmentDroppedDueToSize,
cancelAttachmentDownload,
conversationId,
direction,
expirationLength,
expirationTimestamp,
_forceTapToPlay,
i18n,
id,
isSticker,
@ -978,9 +984,10 @@ export class Message extends React.PureComponent<Props, State> {
<GIF
attachment={firstAttachment}
size={GIF_SIZE}
tabIndex={0}
_forceTapToPlay={_forceTapToPlay}
theme={theme}
i18n={i18n}
tabIndex={0}
onError={this.handleImageError}
showVisualAttachment={() => {
showLightbox({
@ -988,9 +995,13 @@ export class Message extends React.PureComponent<Props, State> {
messageId: id,
});
}}
kickOffAttachmentDownload={() => {
startDownload={() => {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
}}
cancelDownload={() => {
cancelAttachmentDownload({
messageId: id,
});
}}
@ -1026,12 +1037,14 @@ export class Message extends React.PureComponent<Props, State> {
shouldCollapseAbove={shouldCollapseAbove}
shouldCollapseBelow={shouldCollapseBelow}
tabIndex={tabIndex}
onClick={attachment => {
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({ attachment, messageId: id });
} else {
showVisualAttachment={attachment => {
showLightbox({ attachment, messageId: id });
}
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/>
</div>
@ -1063,10 +1076,7 @@ export class Message extends React.PureComponent<Props, State> {
timestamp,
kickOffAttachmentDownload() {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
},
onCorrupted() {
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 isDangerous = isFileDangerous(fileName || '');
@ -1100,7 +1110,6 @@ export class Message extends React.PureComponent<Props, State> {
if (!isDownloaded(firstAttachment)) {
kickOffAttachmentDownload({
attachment: firstAttachment,
messageId: id,
});
} else {
@ -1143,7 +1152,7 @@ export class Message extends React.PureComponent<Props, State> {
`module-message__generic-attachment__file-size--${direction}`
)}
>
{fileSize}
{formatFileSize(size)}
</div>
</div>
</button>
@ -1158,6 +1167,7 @@ export class Message extends React.PureComponent<Props, State> {
i18n,
id,
kickOffAttachmentDownload,
cancelAttachmentDownload,
previews,
quote,
shouldCollapseAbove,
@ -1209,18 +1219,6 @@ export class Message extends React.PureComponent<Props, State> {
'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 = (
<>
{first.image && previewHasImage && isFullSizeImage ? (
@ -1233,7 +1231,15 @@ export class Message extends React.PureComponent<Props, State> {
onError={this.handleImageError}
i18n={i18n}
theme={theme}
onClick={onPreviewImageClick}
showVisualAttachment={() => {
openLinkInWebBrowser(first.url);
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/>
) : null}
<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}
onError={this.handleImageError}
i18n={i18n}
onClick={onPreviewImageClick}
showVisualAttachment={() => {
openLinkInWebBrowser(first.url);
}}
startDownload={() => {
kickOffAttachmentDownload({ messageId: id });
}}
cancelDownload={() => {
cancelAttachmentDownload({ messageId: id });
}}
/>
</div>
) : null}
@ -1970,7 +1984,6 @@ export class Message extends React.PureComponent<Props, State> {
return;
}
kickOffAttachmentDownload({
attachment: textAttachment,
messageId: id,
});
}}
@ -2574,10 +2587,7 @@ export class Message extends React.PureComponent<Props, State> {
}
if (attachments && !isDownloaded(attachments[0])) {
kickOffAttachmentDownload({
attachment: attachments[0],
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
return;
}
@ -2597,9 +2607,7 @@ export class Message extends React.PureComponent<Props, State> {
event.preventDefault();
event.stopPropagation();
const attachment = attachments[0];
kickOffAttachmentDownload({ attachment, messageId: id });
kickOffAttachmentDownload({ messageId: id });
return;
}
@ -2699,10 +2707,7 @@ export class Message extends React.PureComponent<Props, State> {
const attachment = attachments[0];
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
return;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -300,6 +300,7 @@ const createProps = (overrideProps: Partial<Props> = {}): Props => ({
isTapToView: overrideProps.isTapToView,
isTapToViewError: overrideProps.isTapToViewError,
isTapToViewExpired: overrideProps.isTapToViewExpired,
cancelAttachmentDownload: action('cancelAttachmentDownload'),
kickOffAttachmentDownload: action('kickOffAttachmentDownload'),
markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'),
messageExpanded: action('messageExpanded'),
@ -1400,6 +1401,22 @@ Gif.args = {
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({});
GifInAGroup.args = {
attachments: [
@ -1423,10 +1440,10 @@ NotDownloadedGif.args = {
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
fileSize: '188.61 KB',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
height: 332,
path: undefined,
}),
],
status: 'sent',
@ -1440,10 +1457,48 @@ PendingGif.args = {
contentType: VIDEO_MP4,
flags: SignalService.AttachmentPointer.Flags.GIF,
fileName: 'cat-gif.mp4',
fileSize: '188.61 KB',
size: 188610,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
width: 400,
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',
@ -1553,7 +1608,6 @@ OtherFileType.args = {
contentType: stringToMIMEType('text/plain'),
fileName: 'my-resume.txt',
url: 'my-resume.txt',
fileSize: '10MB',
}),
],
status: 'sent',
@ -1566,7 +1620,6 @@ OtherFileTypeWithCaption.args = {
contentType: stringToMIMEType('text/plain'),
fileName: 'my-resume.txt',
url: 'my-resume.txt',
fileSize: '10MB',
}),
],
status: 'sent',
@ -1581,7 +1634,6 @@ OtherFileTypeWithLongFilename.args = {
fileName:
'INSERT-APP-NAME_INSERT-APP-APPLE-ID_AppStore_AppsGamesWatch.psd.zip',
url: 'a2/a2334324darewer4234',
fileSize: '10MB',
}),
],
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
for (const attachment of attachments) {
if (!isDownloaded(attachment)) {
kickOffAttachmentDownload({
attachment,
messageId: id,
});
kickOffAttachmentDownload({ messageId: id });
attachmentsInProgress += 1;
}

View file

@ -3466,7 +3466,10 @@ async function appendChangeMessages(
let newMessages = 0;
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
if (existing) {

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as z from 'zod';
import { MINUTE } from '../util/durations';
import { MINUTE, SECOND } from '../util/durations';
import {
explodePromise,
type ExplodePromiseResultType,
@ -15,6 +15,7 @@ import {
exponentialBackoffSleepTime,
} from '../util/exponentialBackoff';
import * as Errors from '../types/errors';
import { sleep } from '../util/sleep';
export type JobManagerJobType = {
active: boolean;
@ -46,7 +47,10 @@ export type JobManagerParamsType<
removeJob: (job: JobType) => Promise<void>;
runJob: (
job: JobType,
isLastAttempt: boolean
options: {
abortSignal: AbortSignal;
isLastAttempt: boolean;
}
) => Promise<JobManagerJobResultType<CoreJobType>>;
shouldHoldOffOnStartingQueuedJobs?: () => boolean;
getJobId: (job: CoreJobType) => string;
@ -66,15 +70,15 @@ export type JobManagerJobResultType<CoreJobType> =
| { status: 'finished'; newJob?: CoreJobType }
| { status: 'rate-limited'; pauseDurationMs: number };
export type ActiveJobData<CoreJobType> = {
completionPromise: ExplodePromiseResultType<void>;
abortController: AbortController;
job: CoreJobType & JobManagerJobType;
};
export abstract class JobManager<CoreJobType> {
private enabled: boolean = false;
private activeJobs: Map<
string,
{
completionPromise: ExplodePromiseResultType<void>;
job: CoreJobType & JobManagerJobType;
}
> = new Map();
private activeJobs: Map<string, ActiveJobData<CoreJobType>> = new Map();
private jobStartPromises: Map<string, ExplodePromiseResultType<void>> =
new Map();
private jobCompletePromises: Map<string, ExplodePromiseResultType<void>> =
@ -108,7 +112,10 @@ export abstract class JobManager<CoreJobType> {
clearTimeoutIfNecessary(this.tickTimeout);
this.tickTimeout = null;
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;
try {
log.info(`${logId}: starting job`);
this.addRunningJob(job);
const { abortController } = this.addRunningJob(job);
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);
jobRunResult = await runJobPromise;
const { status } = jobRunResult;
@ -388,17 +398,71 @@ export abstract class JobManager<CoreJobType> {
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)) {
const jobIdForLogging = this.params.getJobIdForLogging(job);
log.warn(
`${this.logPrefix}/addRunningJob: job ${jobIdForLogging} is already running`
);
}
this.activeJobs.set(this.params.getJobId(job), {
const activeJob = {
completionPromise: explodePromise<void>(),
abortController: new AbortController(),
job,
});
};
this.activeJobs.set(this.params.getJobId(job), activeJob);
return activeJob;
}
private handleJobStartPromises(job: CoreJobType & JobManagerJobType) {

View file

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

View file

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

View file

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

View file

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

View file

@ -71,7 +71,8 @@ export async function sendStory(
}
const notFound = new Set(messageIds);
const messages = (await getMessagesById(messageIds)).filter(message => {
const messages = (await getMessagesById(messageIds, 'sendStory')).filter(
message => {
notFound.delete(message.id);
const distributionId = message.get('storyDistributionListId');
@ -102,7 +103,8 @@ export async function sendStory(
}
return true;
});
}
);
for (const messageId of notFound) {
log.info(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -29,7 +29,10 @@ export async function enqueueReactionForSend({
messageId: string;
remove: boolean;
}>): Promise<void> {
const message = await __DEPRECATED$getMessageById(messageId);
const message = await __DEPRECATED$getMessageById(
messageId,
'enqueueReactionForSend'
);
strictAssert(message, 'enqueueReactionForSend: no message found');
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) {
this.addMessageToCache(existing.attributes);
@ -447,13 +447,18 @@ export class MessageCache {
}
// 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);
if (!data) {
return undefined;
}
return this.toModel(data);
const model = this.toModel(data);
model.registerLocations.add(location);
return model;
}
public async upgradeSchema(
@ -513,9 +518,9 @@ export class MessageCache {
model.attributes = { ...messageAttributes };
if (getEnvironment() === Environment.Development) {
log.warn('MessageCache: stale model', {
log.warn('MessageCache: updating cached backbone model', {
cid: model.cid,
locations: Array.from(model.registerLocations).join('+'),
locations: Array.from(model.registerLocations).join(', '),
});
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -488,6 +488,7 @@ export const DataWriter: ServerWritableInterface = {
saveAttachmentDownloadJobs,
resetAttachmentDownloadActive,
removeAttachmentDownloadJob,
removeAttachmentDownloadJobsForMessage,
removeAllBackupAttachmentDownloadJobs,
getNextAttachmentBackupJobs,
@ -5129,6 +5130,18 @@ function removeAttachmentDownloadJob(
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
function clearAllAttachmentBackupJobs(db: WritableDB): void {

View file

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

View file

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

View file

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

View file

@ -382,7 +382,10 @@ function markStoryRead(
return;
}
const message = await __DEPRECATED$getMessageById(messageId);
const message = await __DEPRECATED$getMessageById(
messageId,
'markStoryRead'
);
if (!message) {
log.warn(`markStoryRead: no message found ${messageId}`);
@ -521,7 +524,10 @@ function queueStoryDownload(
return;
}
const message = await __DEPRECATED$getMessageById(storyId);
const message = await __DEPRECATED$getMessageById(
storyId,
'queueStoryDownload'
);
if (message) {
// We want to ensure that we re-hydrate the story reply context with the
@ -1396,7 +1402,10 @@ function removeAllContactStories(
const messages = (
await Promise.all(
messageIds.map(async messageId => {
const message = await __DEPRECATED$getMessageById(messageId);
const message = await __DEPRECATED$getMessageById(
messageId,
'removeAllContactStories'
);
if (!message) {
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 { LONG_MESSAGE } from '../../types/MIME';
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification';
import { formatFileSize } from '../../util/formatFileSize';
export { isIncoming, isOutgoing, isStory };
@ -1837,12 +1836,11 @@ export function getPropsForAttachment(
return undefined;
}
const { path, pending, size, screenshot, thumbnail, thumbnailFromBackup } =
const { path, pending, screenshot, thumbnail, thumbnailFromBackup } =
attachment;
return {
...attachment,
fileSize: size ? formatFileSize(size) : undefined,
isVoiceMessage: isVoiceMessage(attachment),
pending,
url: path ? getLocalAttachmentUrl(attachment) : undefined,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,11 +18,15 @@ export class AttachmentPermanentlyUndownloadableError extends Error {}
export async function downloadAttachment({
attachment,
variant = AttachmentVariant.Default,
options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal },
dependencies = { downloadAttachmentFromServer: doDownloadAttachment },
}: {
attachment: AttachmentType;
options: {
variant?: AttachmentVariant;
onSizeUpdate: (totalBytes: number) => void;
abortSignal: AbortSignal;
};
dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment };
}): Promise<ReencryptedAttachmentV2> {
const attachmentId = getAttachmentIdForLogging(attachment);
@ -54,9 +58,11 @@ export async function downloadAttachment({
server,
migratedAttachment,
{
variant,
mediaTier: MediaTier.BACKUP,
logPrefix: dataId,
mediaTier: MediaTier.BACKUP,
onSizeUpdate,
variant,
abortSignal,
}
);
} catch (error) {
@ -80,9 +86,11 @@ export async function downloadAttachment({
server,
migratedAttachment,
{
variant,
mediaTier: MediaTier.STANDARD,
logPrefix: dataId,
mediaTier: MediaTier.STANDARD,
onSizeUpdate,
variant,
abortSignal,
}
);
} 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
import filesize from 'filesize';
export function formatFileSize(size: number): string {
return filesize(size, { round: 0 });
export function formatFileSize(size: number, decimals = 0): string {
return filesize(size, { round: decimals });
}

View file

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

View file

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

View file

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

View file

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

View file

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