Introduce ability to play mp4 files as they download

This commit is contained in:
Scott Nonnenberg 2025-01-14 15:22:40 +10:00 committed by GitHub
parent bab1ceb831
commit 16bbcc2c50
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1304 additions and 141 deletions

View file

@ -1366,6 +1366,10 @@ Signal Desktop makes use of the following open source projects.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## growing-file
License: MIT
## heic-convert
License: ISC

View file

@ -1494,6 +1494,10 @@
"messageformat": "Image sent in chat",
"description": "Used in the alt tag for the image shown in a full-screen lightbox view"
},
"icu:lightBoxDownloading": {
"messageformat": "Downloading {downloaded} of {total}",
"description": "When watching a video while it is downloaded, a toast will appear over the video along with the playback controls showing download progress."
},
"icu:imageCaptionIconAlt": {
"messageformat": "Icon showing that this image has a caption",
"description": "Used for the icon layered on top of an image in message bubbles"
@ -1512,15 +1516,15 @@
},
"icu:retryDownload": {
"messageformat": "Retry download",
"description": "(Deleted 2024/12/12) Label for button shown on an existing download to restart a download that was partially completed"
"description": "Label for button shown on an existing download to restart a download that was partially completed"
},
"icu:retryDownloadShort": {
"messageformat": "Retry",
"description": "(Deleted 2024/12/12) Describes a button shown on an existing download to restart a download that was partially completed"
"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"
"description": "Describes a button shown on a grid of attachments to start of them downloading"
},
"icu:save": {
"messageformat": "Save",

View file

@ -16,7 +16,11 @@ import { join, normalize } from 'node:path';
import { PassThrough, type Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import z from 'zod';
import GrowingFile from 'growing-file';
import { isNumber } from 'lodash';
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
import * as Bytes from '../ts/Bytes';
import type { MessageAttachmentsCursorType } from '../ts/sql/Interface';
import type { MainSQL } from '../ts/sql/main';
import {
@ -69,6 +73,10 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
const INTERACTIVITY_DELAY = 50;
// Matches the value in WebAPI.ts
const GET_ATTACHMENT_CHUNK_TIMEOUT = 10 * SECOND;
const GROWING_FILE_TIMEOUT = GET_ATTACHMENT_CHUNK_TIMEOUT * 1.5;
type RangeFinderContextType = Readonly<
(
| {
@ -76,6 +84,14 @@ type RangeFinderContextType = Readonly<
keysBase64: string;
size: number;
}
| {
type: 'incremental';
digest: Uint8Array;
incrementalMac: Uint8Array;
chunkSize: number;
keysBase64: string;
size: number;
}
| {
type: 'plaintext';
}
@ -90,7 +106,7 @@ type DigestLRUEntryType = Readonly<{
}>;
const digestLRU = new LRUCache<string, DigestLRUEntryType>({
// The size of each entry is roughgly 8kb per digest + 32 bytes per key. We
// The size of each entry is roughly 8kb per digest + 32 bytes per key. We
// mostly need this cache for range requests, so keep it low.
max: 100,
});
@ -99,17 +115,60 @@ async function safeDecryptToSink(
ctx: RangeFinderContextType,
sink: Writable
): Promise<void> {
strictAssert(ctx.type === 'ciphertext', 'Cannot decrypt plaintext');
const options = {
ciphertextPath: ctx.path,
idForLogging: 'attachment_channel',
keysBase64: ctx.keysBase64,
type: 'local' as const,
size: ctx.size,
};
strictAssert(
ctx.type === 'ciphertext' || ctx.type === 'incremental',
'Cannot decrypt plaintext'
);
try {
if (ctx.type === 'incremental') {
const ciphertextStream = new PassThrough();
const file = GrowingFile.open(ctx.path, {
timeout: GROWING_FILE_TIMEOUT,
});
file.on('error', (error: Error) => {
console.warn(
'safeDecryptToSync/incremental: growing-file emitted an error:',
Errors.toLogFormat(error)
);
});
file.pipe(ciphertextStream);
const options = {
ciphertextStream,
idForLogging: 'attachment_channel/incremental',
keysBase64: ctx.keysBase64,
size: ctx.size,
theirChunkSize: ctx.chunkSize,
theirDigest: ctx.digest,
theirIncrementalMac: ctx.incrementalMac,
type: 'standard' as const,
};
const controller = new AbortController();
await Promise.race([
// Just use a non-existing event name to wait for an 'error'. We want
// to handle errors on `sink` while generating digest in case the whole
// request gets cancelled early.
once(sink, 'non-error-event', { signal: controller.signal }),
decryptAttachmentV2ToSink(options, sink),
]);
// Stop handling errors on sink
controller.abort();
return;
}
const options = {
ciphertextPath: ctx.path,
idForLogging: 'attachment_channel/ciphertext',
keysBase64: ctx.keysBase64,
size: ctx.size,
type: 'local' as const,
};
const chunkSize = inferChunkSize(ctx.size);
let entry = digestLRU.get(ctx.path);
if (!entry) {
@ -122,9 +181,7 @@ async function safeDecryptToSink(
const controller = new AbortController();
await Promise.race([
// Just use a non-existing event name to wait for an 'error'. We want
// to handle errors on `sink` while generating digest in case whole
// request get cancelled early.
// Same as above usage of the once() pattern
once(sink, 'non-error-event', { signal: controller.signal }),
decryptAttachmentV2ToSink(options, digester),
]);
@ -171,7 +228,7 @@ const storage = new DefaultStorage<RangeFinderContextType>(
return createReadStream(ctx.path);
}
if (ctx.type === 'ciphertext') {
if (ctx.type === 'ciphertext' || ctx.type === 'incremental') {
const plaintext = new PassThrough();
drop(safeDecryptToSink(ctx, plaintext));
return plaintext;
@ -183,7 +240,7 @@ const storage = new DefaultStorage<RangeFinderContextType>(
maxSize: 10,
ttl: SECOND,
cacheKey: ctx => {
if (ctx.type === 'ciphertext') {
if (ctx.type === 'ciphertext' || ctx.type === 'incremental') {
return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`;
}
if (ctx.type === 'plaintext') {
@ -199,10 +256,11 @@ const rangeFinder = new RangeFinder<RangeFinderContextType>(storage, {
const dispositionSchema = z.enum([
'attachment',
'temporary',
'draft',
'sticker',
'avatarData',
'download',
'draft',
'temporary',
'sticker',
]);
type DeleteOrphanedAttachmentsOptionsType = Readonly<{
@ -479,6 +537,7 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
strictAssert(attachmentsDir != null, 'not initialized');
strictAssert(tempDir != null, 'not initialized');
strictAssert(downloadsDir != null, 'not initialized');
strictAssert(draftDir != null, 'not initialized');
strictAssert(stickersDir != null, 'not initialized');
strictAssert(avatarDataDir != null, 'not initialized');
@ -488,6 +547,9 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
case 'attachment':
parentDir = attachmentsDir;
break;
case 'download':
parentDir = downloadsDir;
break;
case 'temporary':
parentDir = tempDir;
break;
@ -534,8 +596,8 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
// Encrypted attachments
// Get AES+MAC key
const maybeKeysBase64 = url.searchParams.get('key');
if (maybeKeysBase64 == null) {
const keysBase64 = url.searchParams.get('key');
if (keysBase64 == null) {
return new Response('Missing key', { status: 400 });
}
@ -544,12 +606,45 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
return new Response('Missing size', { status: 400 });
}
context = {
type: 'ciphertext',
path,
keysBase64: maybeKeysBase64,
size: maybeSize,
};
if (disposition !== 'download') {
context = {
type: 'ciphertext',
keysBase64,
path,
size: maybeSize,
};
} else {
// When trying to view in-progress downloads, we need more information
// to validate the file before returning data.
const digestBase64 = url.searchParams.get('digest');
if (digestBase64 == null) {
return new Response('Missing digest', { status: 400 });
}
const incrementalMacBase64 = url.searchParams.get('incrementalMac');
if (incrementalMacBase64 == null) {
return new Response('Missing incrementalMac', { status: 400 });
}
const chunkSizeString = url.searchParams.get('chunkSize');
const chunkSize = chunkSizeString
? parseInt(chunkSizeString, 10)
: undefined;
if (!isNumber(chunkSize)) {
return new Response('Missing chunkSize', { status: 400 });
}
context = {
type: 'incremental',
chunkSize,
digest: Bytes.fromBase64(digestBase64),
incrementalMac: Bytes.fromBase64(incrementalMacBase64),
keysBase64,
path,
size: maybeSize,
};
}
}
try {

20
package-lock.json generated
View file

@ -51,6 +51,7 @@
"fuse.js": "6.5.3",
"google-libphonenumber": "3.2.39",
"got": "11.8.5",
"growing-file": "0.1.3",
"heic-convert": "2.1.0",
"humanize-duration": "3.27.1",
"intl-tel-input": "24.7.0",
@ -18351,6 +18352,17 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true
},
"node_modules/growing-file": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/growing-file/-/growing-file-0.1.3.tgz",
"integrity": "sha512-5+YYjm3sKIxyHAhlgDOzs1mL7sT9tbT3Unt1xymjkAgXZ2KwpLzYaaaNp3z1KIOXaKTYdJiUqxZmRusOTrO0gg==",
"dependencies": {
"oop": "0.0.3"
},
"engines": {
"node": "*"
}
},
"node_modules/handle-thing": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz",
@ -24621,6 +24633,14 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/oop": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/oop/-/oop-0.0.3.tgz",
"integrity": "sha512-NCkLvw6ZyDnLCFNWIXtbrhNKEVBwHxv8n003Lum8Y5YF3dZtbSYSZZN/8gGJ1Ey52hCpsBQ6n5qutYAc4OOhFA==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/open": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",

View file

@ -142,6 +142,7 @@
"fuse.js": "6.5.3",
"google-libphonenumber": "3.2.39",
"got": "11.8.5",
"growing-file": "0.1.3",
"heic-convert": "2.1.0",
"humanize-duration": "3.27.1",
"intl-tel-input": "24.7.0",

View file

@ -0,0 +1,25 @@
diff --git a/node_modules/growing-file/lib/growing_file.js b/node_modules/growing-file/lib/growing_file.js
index a25d618..0ff7634 100644
--- a/node_modules/growing-file/lib/growing_file.js
+++ b/node_modules/growing-file/lib/growing_file.js
@@ -69,11 +69,15 @@ GrowingFile.prototype._readUntilEof = function() {
this._reading = true;
- this._stream = fs.createReadStream(this._path, {
- start: this._offset,
- // @todo: Remove if this gets merged: https://github.com/joyent/node/pull/881
- end: Infinity
- });
+ try {
+ this._stream = fs.createReadStream(this._path, {
+ start: this._offset,
+ // @todo: Remove if this gets merged: https://github.com/joyent/node/pull/881
+ end: Infinity
+ });
+ } catch (error) {
+ this._handleError(error);
+ }
this._stream.on('error', this._handleError.bind(this));
this._stream.on('data', this._handleData.bind(this));

View file

@ -35,7 +35,7 @@ async function getMarkdownForDependency(dependencyName) {
// fs-xattr is an optional dependency that may fail to install (on Windows, most
// commonly), so we have a special case for it here. We may need to do something
// similar for new optionalDependencies in the future.
if (dependencyName === 'fs-xattr') {
if (dependencyName === 'fs-xattr' || dependencyName === 'growing-file') {
licenseBody = 'License: MIT';
} else {
const dependencyRootPath = join(nodeModulesPath, dependencyName);

View file

@ -213,6 +213,31 @@
text-align: center;
}
&__toast-container {
opacity: 0;
transition: opacity 500ms;
position: absolute;
bottom: 45px;
pointer-events: none;
// We need this so our toast goes on top of the video
z-index: variables.$z-index-above-base;
.Toast {
background-color: variables.$color-black-alpha-80;
}
.Toast__content {
padding-block: 7px;
padding-inline: 12px;
@include mixins.font-caption;
}
&--visible {
opacity: 1;
}
}
&__nav-next,
&__nav-prev {
--height: 224px;

View file

@ -3,6 +3,7 @@
import { createReadStream, createWriteStream } from 'fs';
import { open, unlink, stat } from 'fs/promises';
import type { FileHandle } from 'fs/promises';
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto';
import type { Hash } from 'crypto';
import { PassThrough, Transform, type Writable, Readable } from 'stream';
@ -301,7 +302,6 @@ export async function encryptAttachmentV2({
type DecryptAttachmentToSinkOptionsType = Readonly<
{
ciphertextPath: string;
idForLogging: string;
size: number;
outerEncryption?: {
@ -310,18 +310,26 @@ type DecryptAttachmentToSinkOptionsType = Readonly<
};
} & (
| {
type: 'standard';
theirDigest: Readonly<Uint8Array>;
theirIncrementalMac: Readonly<Uint8Array> | undefined;
theirChunkSize: number | undefined;
ciphertextPath: string;
}
| {
// No need to check integrity for locally reencrypted attachments, or for backup
// thumbnails (since we created it)
type: 'local' | 'backupThumbnail';
theirDigest?: undefined;
ciphertextStream: Readable;
}
) &
(
| {
type: 'standard';
theirDigest: Readonly<Uint8Array>;
theirIncrementalMac: Readonly<Uint8Array> | undefined;
theirChunkSize: number | undefined;
}
| {
// No need to check integrity for locally reencrypted attachments, or for backup
// thumbnails (since we created it)
type: 'local' | 'backupThumbnail';
theirDigest?: undefined;
}
) &
(
| {
aesKey: Readonly<Uint8Array>;
@ -383,7 +391,7 @@ export async function decryptAttachmentV2ToSink(
options: DecryptAttachmentToSinkOptionsType,
sink: Writable
): Promise<Omit<DecryptedAttachmentV2, 'path'>> {
const { ciphertextPath, idForLogging, outerEncryption } = options;
const { idForLogging, outerEncryption } = options;
let aesKey: Uint8Array;
let macKey: Uint8Array;
@ -434,19 +442,27 @@ export async function decryptAttachmentV2ToSink(
: undefined;
let isPaddingAllZeros = false;
let readFd;
let readFd: FileHandle | undefined;
let iv: Uint8Array | undefined;
let ciphertextStream: Readable;
try {
try {
readFd = await open(ciphertextPath, 'r');
} catch (cause) {
throw new Error(`${logId}: Read path doesn't exist`, { cause });
if ('ciphertextPath' in options) {
try {
readFd = await open(options.ciphertextPath, 'r');
ciphertextStream = readFd.createReadStream();
} catch (cause) {
throw new Error(`${logId}: Read path doesn't exist`, { cause });
}
} else if ('ciphertextStream' in options) {
ciphertextStream = options.ciphertextStream;
} else {
throw missingCaseError(options);
}
await pipeline(
[
readFd.createReadStream(),
ciphertextStream,
maybeOuterEncryptionGetMacAndUpdateMac,
maybeOuterEncryptionGetIvAndDecipher,
peekAndUpdateHash(digest),

View file

@ -347,3 +347,30 @@ export function ViewOnceVideo(): JSX.Element {
/>
);
}
export function IncrementalVideo(): JSX.Element {
const item = createMediaItem({
contentType: VIDEO_MP4,
objectURL: '/fixtures/pixabay-Soap-Bubble-7141.mp4',
});
return (
<Lightbox
{...createProps({
media: [
{
...item,
attachment: {
...item.attachment,
incrementalMac: 'something',
chunkSize: 42,
pending: true,
totalDownloaded: 50000,
size: 100000,
},
},
],
})}
/>
);
}

View file

@ -14,7 +14,7 @@ import type {
SaveAttachmentActionCreatorType,
} from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util';
import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem';
import type { MediaItemType } from '../types/MediaItem';
import * as GoogleChrome from '../util/GoogleChrome';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
@ -22,7 +22,7 @@ import { Avatar, AvatarSize } from './Avatar';
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
import { formatDateTimeForAttachment } from '../util/timestamp';
import { formatDuration } from '../util/formatDuration';
import { isGIF } from '../types/Attachment';
import { isGIF, isIncremental } from '../types/Attachment';
import { useRestoreFocus } from '../hooks/useRestoreFocus';
import { usePrevious } from '../hooks/usePrevious';
import { arrow } from '../util/keyboard';
@ -31,6 +31,9 @@ import { isCmdOrCtrl } from '../hooks/useKeyboardShortcuts';
import type { ForwardMessagesPayload } from '../state/ducks/globalModals';
import { ForwardMessagesModalType } from './ForwardMessagesModal';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { formatFileSize } from '../util/formatFileSize';
import { SECOND } from '../util/durations';
import { Toast } from './Toast';
export type PropsType = {
children?: ReactNode;
@ -53,6 +56,8 @@ export type PropsType = {
const ZOOM_SCALE = 3;
const TWO_SECONDS = 2.5 * SECOND;
const INITIAL_IMAGE_TRANSFORM = {
scale: 1,
translateX: 0,
@ -103,6 +108,9 @@ export function Lightbox({
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null
);
const [shouldShowDownloadToast, setShouldShowDownloadToast] = useState(false);
const downloadToastTimeout = useRef<NodeJS.Timeout | number | undefined>();
const [videoTime, setVideoTime] = useState<number | undefined>();
const [isZoomed, setIsZoomed] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
@ -128,6 +136,55 @@ export function Lightbox({
| undefined
>();
const currentItem = media[selectedIndex];
const {
attachment,
contentType,
loop = false,
objectURL,
incrementalObjectUrl,
} = currentItem || {};
const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined);
const isDownloading =
attachment &&
isIncremental(attachment) &&
attachment.pending &&
!attachment.path;
const onMouseLeaveVideo = useCallback(() => {
if (downloadToastTimeout.current) {
clearTimeout(downloadToastTimeout.current);
downloadToastTimeout.current = undefined;
}
if (!isDownloading) {
return;
}
setShouldShowDownloadToast(false);
}, [isDownloading, setShouldShowDownloadToast]);
const onUserInteractionOnVideo = useCallback(
(event: React.MouseEvent<HTMLVideoElement, MouseEvent>) => {
if (downloadToastTimeout.current) {
clearTimeout(downloadToastTimeout.current);
downloadToastTimeout.current = undefined;
}
if (!isDownloading) {
return;
}
const elementRect = event.currentTarget.getBoundingClientRect();
const bottomThreshold = elementRect.bottom - 75;
setShouldShowDownloadToast(true);
if (event.clientY >= bottomThreshold) {
return;
}
downloadToastTimeout.current = setTimeout(onMouseLeaveVideo, TWO_SECONDS);
},
[isDownloading, onMouseLeaveVideo, setShouldShowDownloadToast]
);
const onPrevious = useCallback(
(
event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
@ -179,9 +236,9 @@ export function Lightbox({
event.preventDefault();
const mediaItem = media[selectedIndex];
const { attachment, message, index } = mediaItem;
const { attachment: attachmentToSave, message, index } = mediaItem;
saveAttachment(attachment, message.sentAt, index + 1);
saveAttachment(attachmentToSave, message.sentAt, index + 1);
},
[isViewOnce, media, saveAttachment, selectedIndex]
);
@ -288,16 +345,6 @@ export function Lightbox({
};
}, [onKeyDown]);
const {
attachment,
contentType,
loop = false,
objectURL,
message,
} = media[selectedIndex] || {};
const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined);
useEffect(() => {
playVideo();
@ -596,11 +643,13 @@ export function Lightbox({
<video
className="Lightbox__object Lightbox__object--video"
controls={!shouldLoop}
key={objectURL}
key={objectURL || incrementalObjectUrl}
loop={shouldLoop}
ref={setVideoElement}
onMouseMove={onUserInteractionOnVideo}
onMouseLeave={onMouseLeaveVideo}
>
<source src={objectURL} />
<source src={objectURL || incrementalObjectUrl} />
</video>
);
} else if (isUnsupportedImageType || isUnsupportedVideoType) {
@ -671,7 +720,7 @@ export function Lightbox({
<LightboxHeader
getConversation={getConversation}
i18n={i18n}
message={message}
item={currentItem}
/>
) : (
<div />
@ -713,6 +762,28 @@ export function Lightbox({
),
}}
>
{isDownloading ? (
<div
className={classNames(
'Lightbox__toast-container',
shouldShowDownloadToast
? 'Lightbox__toast-container--visible'
: null
)}
>
<Toast onClose={noop}>
{attachment.totalDownloaded && attachment.size
? i18n('icu:lightBoxDownloading', {
downloaded: formatFileSize(
attachment.totalDownloaded,
2
),
total: formatFileSize(attachment.size, 2),
})
: undefined}
</Toast>
</div>
) : null}
{content}
{hasPrevious && (
@ -797,12 +868,13 @@ export function Lightbox({
function LightboxHeader({
getConversation,
i18n,
message,
item,
}: {
getConversation: (id: string) => ConversationType;
i18n: LocalizerType;
message: ReadonlyDeep<MediaItemMessageType>;
item: ReadonlyDeep<MediaItemType>;
}): JSX.Element {
const { message } = item;
const conversation = getConversation(message.conversationId);
const now = Date.now();

View file

@ -89,3 +89,87 @@ export function OneNotPendingSomeDownloaded(args: PropsType): JSX.Element {
/>
);
}
export function OneIncrementalDownloadedBlank(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
incrementalMac: 'something',
chunkSize: 10,
}),
]}
/>
);
}
export function OneIncrementalNotPendingNotDownloaded(
args: PropsType
): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
incrementalMac: 'something',
chunkSize: 10,
path: undefined,
}),
]}
/>
);
}
export function OneIncrementalPendingNotDownloading(
args: PropsType
): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
incrementalMac: 'something',
chunkSize: 10,
pending: true,
path: undefined,
}),
]}
/>
);
}
export function OneIncrementalDownloading(args: PropsType): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
incrementalMac: 'something',
chunkSize: 10,
pending: true,
path: undefined,
totalDownloaded: 5000,
}),
]}
/>
);
}
export function OneIncrementalNotPendingSomeDownloaded(
args: PropsType
): JSX.Element {
return (
<AttachmentDetailPill
{...args}
attachments={[
fakeAttachment({
incrementalMac: 'something',
chunkSize: 10,
path: undefined,
totalDownloaded: 5000,
}),
]}
/>
);
}

View file

@ -2,11 +2,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import classNames from 'classnames';
import { formatFileSize } from '../../util/formatFileSize';
import { ProgressCircle } from '../ProgressCircle';
import type { AttachmentForUIType } from '../../types/Attachment';
import type { LocalizerType } from '../../types/I18N';
import { Spinner } from '../Spinner';
import { isKeyboardActivation } from '../../hooks/useKeyboardShortcuts';
export type PropsType = {
attachments: ReadonlyArray<AttachmentForUIType>;
@ -18,7 +22,10 @@ export type PropsType = {
export function AttachmentDetailPill({
attachments,
cancelDownload,
i18n,
isGif,
startDownload,
}: PropsType): JSX.Element | null {
const areAllDownloaded = attachments.every(attachment => attachment.path);
const totalSize = attachments.reduce(
@ -28,10 +35,54 @@ export function AttachmentDetailPill({
0
);
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 && isKeyboardActivation(event.nativeEvent)) {
event.preventDefault();
event.stopPropagation();
startDownload();
}
},
[startDownload]
);
const cancelDownloadClick = React.useCallback(
(event: React.MouseEvent) => {
if (cancelDownload) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
const cancelDownloadKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) {
event.preventDefault();
event.stopPropagation();
cancelDownload();
}
},
[cancelDownload]
);
if (areAllDownloaded || totalSize === 0) {
return null;
}
const areAnyIncremental = attachments.some(
attachment => attachment.incrementalMac && attachment.chunkSize
);
const totalDownloadedSize = attachments.reduce(
(total: number, attachment: AttachmentForUIType) => {
return (
@ -43,6 +94,99 @@ export function AttachmentDetailPill({
);
const areAnyPending = attachments.some(attachment => attachment.pending);
if (areAnyIncremental) {
let ariaLabel: string;
let onClick: (event: React.MouseEvent) => void;
let onKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
let control: JSX.Element;
let text: JSX.Element;
if (!areAnyPending && totalDownloadedSize > 0) {
ariaLabel = i18n('icu:retryDownload');
onClick = startDownloadClick;
onKeyDown = startDownloadKeyDown;
control = (
<div className="AttachmentDetailPill__icon-wrapper">
<div className="AttachmentDetailPill__download-icon" />
</div>
);
text = (
<div className="AttachmentDetailPill__text-wrapper">
{i18n('icu:retryDownloadShort')}
</div>
);
} else if (!areAnyPending) {
ariaLabel = i18n('icu:startDownload');
onClick = startDownloadClick;
onKeyDown = startDownloadKeyDown;
control = (
<div className="AttachmentDetailPill__icon-wrapper">
<div className="AttachmentDetailPill__download-icon" />
</div>
);
text = (
<div className="AttachmentDetailPill__text-wrapper">
{formatFileSize(totalSize, 2)}
</div>
);
} else if (totalDownloadedSize > 0) {
const downloadFraction = totalDownloadedSize / totalSize;
ariaLabel = i18n('icu:cancelDownload');
onClick = cancelDownloadClick;
onKeyDown = cancelDownloadKeyDown;
control = (
<div className="AttachmentDetailPill__spinner-wrapper">
<ProgressCircle
fractionComplete={downloadFraction}
width={24}
strokeWidth={2}
/>
<div className="AttachmentDetailPill__stop-icon" />
</div>
);
text = (
<div className="AttachmentDetailPill__text-wrapper">
{totalDownloadedSize > 0 && areAnyPending
? `${formatFileSize(totalDownloadedSize, 2)} / `
: undefined}
{formatFileSize(totalSize, 2)}
</div>
);
} else {
ariaLabel = i18n('icu:cancelDownload');
onClick = cancelDownloadClick;
onKeyDown = cancelDownloadKeyDown;
control = (
<div className="AttachmentDetailPill__spinner-wrapper">
<Spinner svgSize="small" size="24px" />
<div className="AttachmentDetailPill__stop-icon" />
</div>
);
text = (
<div className="AttachmentDetailPill__text-wrapper">
{formatFileSize(totalSize, 2)}
</div>
);
}
return (
<button
type="button"
className={classNames(
'AttachmentDetailPill',
'AttachmentDetailPill--interactive'
)}
aria-label={ariaLabel}
onClick={onClick}
onKeyDown={onKeyDown}
>
{control}
{text}
</button>
);
}
return (
<div className="AttachmentDetailPill">
<div className="AttachmentDetailPill__text-wrapper">

View file

@ -173,6 +173,82 @@ export function NotPendingWDownloadProgress(): JSX.Element {
return <Image {...props} />;
}
export function PendingIncrementalNoProgress(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 5300000,
incrementalMac: 'something',
chunkSize: 100,
}),
playIconOverlay: true,
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
}
export function PendingIncrementalDownloadProgress(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 5300000,
totalDownloaded: 1230000,
incrementalMac: 'something',
chunkSize: 100,
}),
playIconOverlay: true,
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
}
export function NotPendingIncrementalNoProgress(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
size: 5300000,
incrementalMac: 'something',
chunkSize: 100,
}),
playIconOverlay: true,
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
}
export function NotPendingIncrementalWProgress(): JSX.Element {
const props = createProps({
attachment: fakeAttachment({
contentType: IMAGE_PNG,
fileName: 'sax.png',
path: undefined,
size: 5300000,
totalDownloaded: 1230000,
incrementalMac: 'something',
chunkSize: 100,
}),
playIconOverlay: true,
blurHash: 'thisisafakeblurhashthatwasmadeup',
url: undefined,
});
return <Image {...props} />;
}
export function CurvedCorners(): JSX.Element {
const props = createProps({
curveBottomLeft: CurveType.Normal,

View file

@ -12,7 +12,11 @@ import type {
AttachmentForUIType,
AttachmentType,
} from '../../types/Attachment';
import { defaultBlurHash, isReadyToView } from '../../types/Attachment';
import {
defaultBlurHash,
isIncremental,
isReadyToView,
} from '../../types/Attachment';
import { ProgressCircle } from '../ProgressCircle';
export enum CurveType {
@ -180,7 +184,10 @@ export function Image({
);
const startDownloadButton =
startDownload && !attachment.path && !attachment.pending ? (
startDownload &&
!attachment.path &&
!attachment.pending &&
!isIncremental(attachment) ? (
<button
type="button"
className="module-image__overlay-circle"
@ -193,15 +200,16 @@ export function Image({
</button>
) : undefined;
const spinner = !cancelDownload
? undefined
: getSpinner({
attachment,
i18n,
cancelDownloadClick,
cancelDownloadKeyDown,
tabIndex,
});
const spinner =
isIncremental(attachment) || !cancelDownload
? undefined
: getSpinner({
attachment,
i18n,
cancelDownloadClick,
cancelDownloadKeyDown,
tabIndex,
});
return (
<div
@ -237,7 +245,7 @@ export function Image({
}}
/>
) : null}
{attachment.path && playIconOverlay ? (
{(attachment.path || isIncremental(attachment)) && playIconOverlay ? (
<div className="module-image__overlay-circle">
<div className="module-image__play-icon" />
</div>
@ -308,7 +316,10 @@ export function getSpinner({
tabIndex: number | undefined;
}): JSX.Element | undefined {
const downloadFraction =
attachment.pending && attachment.size && attachment.totalDownloaded
attachment.pending &&
!isIncremental(attachment) &&
attachment.size &&
attachment.totalDownloaded
? attachment.totalDownloaded / attachment.size
: undefined;

View file

@ -157,6 +157,98 @@ export function OneVideoDownloadProgressNotPending(args: Props): JSX.Element {
return <ImageGrid {...props} />;
}
export function OneVideoIncrementalNotDownloadedNotPending(
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,
incrementalMac: 'something',
chunkSize: 100,
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoIncrementalPendingWDownloadQueued(
args: Props
): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
incrementalMac: 'something',
chunkSize: 100,
fileName: 'sax.png',
path: undefined,
pending: true,
size: 1000000,
url: undefined,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
}),
],
};
return <ImageGrid {...props} />;
}
export function OneVideoIncrementalPendingWDownloadProgress(
args: Props
): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
incrementalMac: 'something',
chunkSize: 100,
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 OneVideoIncrementalDownloadProgressNotPending(
args: Props
): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
incrementalMac: 'something',
chunkSize: 100,
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
@ -207,6 +299,34 @@ export function TwoImagesNotDownloaded(args: Props): JSX.Element {
);
}
export function TwoImagesIncrementalNotDownloaded(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]',
incrementalMac: 'something',
chunkSize: 100,
}),
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,
@ -716,6 +836,71 @@ export function _6ImagesPendingWDownloadProgress(args: Props): JSX.Element {
return <ImageGrid {...props} />;
}
export function _6ImagesOneIncrementalNeedDownload(args: Props): JSX.Element {
const props = {
...args,
attachments: [
fakeAttachment({
contentType: VIDEO_MP4,
fileName: 'sax.png',
path: undefined,
incrementalMac: 'something',
chunkSize: 100,
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
url: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
height: 1680,
url: undefined,
width: 3000,
path: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
height: 1680,
url: undefined,
width: 3000,
path: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
height: 1680,
url: undefined,
width: 3000,
path: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
height: 1680,
url: undefined,
width: 3000,
path: undefined,
}),
fakeAttachment({
contentType: IMAGE_JPEG,
fileName: 'tina-rolf-269345-unsplash.jpg',
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
height: 1680,
url: undefined,
width: 3000,
path: undefined,
}),
],
};
return <ImageGrid {...props} />;
}
export function MixedContentTypes(args: Props): JSX.Element {
return (
<ImageGrid

View file

@ -14,6 +14,7 @@ import {
getImageDimensions,
getThumbnailUrl,
getUrl,
isIncremental,
isVideoAttachment,
} from '../../types/Attachment';
@ -539,10 +540,11 @@ function renderDownloadPill({
startDownloadClick: (event: React.MouseEvent) => void;
startDownloadKeyDown: (event: React.KeyboardEvent<HTMLButtonElement>) => void;
}): JSX.Element | null {
const downloadedOrPending = attachments.some(
attachment => attachment.path || attachment.pending
const downloadedOrPendingOrIncremental = attachments.some(
attachment =>
attachment.path || attachment.pending || isIncremental(attachment)
);
if (downloadedOrPending) {
if (downloadedOrPendingOrIncremental) {
return null;
}

View file

@ -1010,13 +1010,7 @@ export class Message extends React.PureComponent<Props, State> {
);
}
if (
isImage(attachments) ||
(isVideo(attachments) &&
(!isDownloaded(attachments[0]) ||
!attachments?.[0].pending ||
hasVideoScreenshot(attachments)))
) {
if (isImage(attachments) || isVideo(attachments)) {
const bottomOverlay = !isSticker && !collapseMetadata;
// We only want users to tab into this if there's more than one
const tabIndex = attachments.length > 1 ? 0 : -1;

15
ts/growing-file.d.ts vendored Normal file
View file

@ -0,0 +1,15 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
declare module 'growing-file' {
type GrowingFileOptions = {
timeout?: number;
interval?: number;
};
class GrowingFile {
static open(path: string, options: GrowingFileOptions): Readable;
}
export default GrowingFile;
}

View file

@ -99,6 +99,17 @@ function useHasAnyOverlay(): boolean {
return panels || globalModal || calling;
}
export function isKeyboardActivation(event: KeyboardEvent): boolean {
if (
hasExactModifiers(event, 'none') &&
(event.key === 'Enter' || event.key === 'Space')
) {
return true;
}
return false;
}
export function useActiveCallShortcuts(
hangUp: (reason: string) => unknown
): KeyboardShortcutHandlerType {

View file

@ -51,6 +51,7 @@ import {
type ReencryptedAttachmentV2,
} from '../AttachmentCrypto';
import { safeParsePartial } from '../util/schemas';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
import { createBatcher } from '../util/batcher';
import { postSaveUpdates } from '../util/cleanup';
@ -520,6 +521,7 @@ export async function runDownloadAttachmentJobInner({
);
try {
const { downloadPath } = attachment;
let totalDownloaded = 0;
let downloadedAttachment: ReencryptedAttachmentV2 | undefined;
@ -550,13 +552,52 @@ export async function runDownloadAttachmentJobInner({
});
const upgradedAttachment = await dependencies.processNewAttachment({
...omit(attachment, ['error', 'pending', 'downloadPath']),
...omit(attachment, ['error', 'pending']),
...downloadedAttachment,
});
await addAttachmentToMessage(messageId, upgradedAttachment, logId, {
type: attachmentType,
});
const isShowingLightbox = (): boolean => {
const lightboxState = window.reduxStore.getState().lightbox;
if (!lightboxState.isShowingLightbox) {
return false;
}
if (lightboxState.selectedIndex == null) {
return false;
}
const selectedMedia = lightboxState.media[lightboxState.selectedIndex];
if (selectedMedia?.message.id !== messageId) {
return false;
}
return selectedMedia.attachment.digest === attachment.digest;
};
const shouldDeleteDownload = downloadPath && !isShowingLightbox();
if (downloadPath) {
if (shouldDeleteDownload) {
await dependencies.deleteDownloadData(downloadPath);
} else {
deleteDownloadsJobQueue.pause();
await deleteDownloadsJobQueue.add({
digest: attachment.digest,
downloadPath,
messageId,
plaintextHash: attachment.plaintextHash,
});
}
}
await addAttachmentToMessage(
messageId,
shouldDeleteDownload
? omit(upgradedAttachment, ['downloadPath', 'totalDownloaded'])
: omit(upgradedAttachment, ['totalDownloaded']),
logId,
{
type: attachmentType,
}
);
return { downloadedVariant: AttachmentVariant.Default };
} catch (error) {
if (

View file

@ -14,6 +14,8 @@ import { JobLogger } from './JobLogger';
import * as Errors from '../types/errors';
import type { LoggerType } from '../types/Logging';
import { drop } from '../util/drop';
import { sleep } from '../util/sleep';
import { SECOND } from '../util/durations';
const noopOnCompleteCallbacks = {
resolve: noop,
@ -62,6 +64,7 @@ export abstract class JobQueue<T> {
private readonly logPrefix: string;
private shuttingDown = false;
private paused = false;
private readonly onCompleteCallbacks = new Map<
string,
@ -78,6 +81,9 @@ export abstract class JobQueue<T> {
get isShuttingDown(): boolean {
return this.shuttingDown;
}
get isPaused(): boolean {
return this.paused;
}
constructor(options: Readonly<JobQueueOptions>) {
assertDev(
@ -151,6 +157,14 @@ export abstract class JobQueue<T> {
log.info(`${this.logPrefix} is shutting down. Can't accept more work.`);
break;
}
if (this.paused) {
log.info(`${this.logPrefix} is paused. Waiting until resume.`);
while (this.paused) {
// eslint-disable-next-line no-await-in-loop
await sleep(SECOND);
}
log.info(`${this.logPrefix} has been resumed. Queuing job.`);
}
drop(this.enqueueStoredJob(storedJob));
}
}
@ -359,4 +373,12 @@ export abstract class JobQueue<T> {
await Promise.all([...queues].map(q => q.onIdle()));
log.info(`${this.logPrefix} shutdown: complete`);
}
pause(): void {
log.info(`${this.logPrefix}: pausing queue`);
this.paused = true;
}
resume(): void {
log.info(`${this.logPrefix}: resuming queue`);
this.paused = false;
}
}

View file

@ -0,0 +1,119 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import { omit } from 'lodash';
import { JobQueue } from './JobQueue';
import { jobQueueDatabaseStore } from './JobQueueDatabaseStore';
import { parseUnknown } from '../util/schemas';
import { DataReader } from '../sql/Client';
import type { JOB_STATUS } from './JobQueue';
import type { LoggerType } from '../types/Logging';
import { commonShouldJobContinue } from './helpers/commonShouldJobContinue';
import { DAY } from '../util/durations';
import { exponentialBackoffMaxAttempts } from '../util/exponentialBackoff';
const deleteDownloadsJobDataSchema = z.object({
digest: z.string().optional(),
downloadPath: z.string(),
messageId: z.string(),
plaintextHash: z.string().optional(),
});
type DeleteDownloadsJobData = z.infer<typeof deleteDownloadsJobDataSchema>;
const MAX_RETRY_TIME = DAY;
const MAX_ATTEMPTS = exponentialBackoffMaxAttempts(MAX_RETRY_TIME);
export class DeleteDownloadsJobQueue extends JobQueue<DeleteDownloadsJobData> {
protected parseData(data: unknown): DeleteDownloadsJobData {
return parseUnknown(deleteDownloadsJobDataSchema, data);
}
protected async run(
{
timestamp,
data,
}: Readonly<{ data: DeleteDownloadsJobData; timestamp: number }>,
{ attempt, log }: Readonly<{ attempt: number; log: LoggerType }>
): Promise<typeof JOB_STATUS.NEEDS_RETRY | undefined> {
await new Promise<void>(resolve => {
window.storage.onready(resolve);
});
const timeRemaining = timestamp + MAX_RETRY_TIME - Date.now();
const shouldContinue = await commonShouldJobContinue({
attempt,
log,
timeRemaining,
skipWait: false,
});
if (!shouldContinue) {
return undefined;
}
const { digest, downloadPath, messageId, plaintextHash } = data;
const message = await DataReader.getMessageById(messageId);
if (!message) {
log?.warn('Message not found; attempting to delete download path.');
await window.Signal.Migrations.deleteDownloadData(downloadPath);
return undefined;
}
const { attachments } = message;
const target = (attachments || []).find(attachment => {
if (plaintextHash && attachment.plaintextHash === plaintextHash) {
return true;
}
if (digest && attachment.digest === digest) {
return true;
}
if (attachment.downloadPath === downloadPath) {
return true;
}
return false;
});
if (!target) {
log?.warn(
'Target attachment not found; attempting to delete download path.'
);
await window.Signal.Migrations.deleteDownloadData(downloadPath);
return undefined;
}
if (!target.path || target.pending) {
log?.warn(
'Target attachment is still downloading; Failing this job to try again later'
);
throw new Error('Attachment still downloading');
}
await window.Signal.Migrations.deleteDownloadData(downloadPath);
const updatedMessage = {
...message,
attachments: (attachments || []).map(attachment => {
if (attachment !== target) {
return attachment;
}
return omit(attachment, ['downloadPath', 'totalDownloaded']);
}),
};
await window.MessageCache.saveMessage(updatedMessage);
return undefined;
}
}
export const deleteDownloadsJobQueue = new DeleteDownloadsJobQueue({
store: jobQueueDatabaseStore,
queueType: 'delete downloads',
maxAttempts: MAX_ATTEMPTS,
});

View file

@ -7,6 +7,7 @@ import { CallLinkFinalizeDeleteManager } from './CallLinkFinalizeDeleteManager';
import { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue';
import { conversationJobQueue } from './conversationJobQueue';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
import { groupAvatarJobQueue } from './groupAvatarJobQueue';
import { readSyncJobQueue } from './readSyncJobQueue';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
@ -40,6 +41,7 @@ export function initializeAllJobQueues({
drop(viewOnceOpenJobQueue.streamJobs());
// Other queues
drop(deleteDownloadsJobQueue.streamJobs());
drop(removeStorageKeyJobQueue.streamJobs());
drop(reportSpamJobQueue.streamJobs());
drop(callLinkRefreshJobQueue.streamJobs());

View file

@ -19,7 +19,7 @@ import type { StateType as RootStateType } from '../reducer';
import * as log from '../../logging/log';
import { getMessageById } from '../../messages/getMessageById';
import type { ReadonlyMessageAttributesType } from '../../model-types.d';
import { isGIF } from '../../types/Attachment';
import { isGIF, isIncremental } from '../../types/Attachment';
import {
isImageTypeSupported,
isVideoTypeSupported,
@ -40,6 +40,9 @@ import {
import { showStickerPackPreview } from './globalModals';
import { useBoundActions } from '../../hooks/useBoundActions';
import { DataReader } from '../../sql/Client';
import { deleteDownloadsJobQueue } from '../../jobs/deleteDownloadsJobQueue';
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
import { getMessageIdForLogging } from '../../util/idForLogging';
import { markViewOnceMessageViewed } from '../../services/MessageUpdater';
@ -113,6 +116,8 @@ function closeLightbox(): ThunkAction<
return;
}
deleteDownloadsJobQueue.resume();
const { isViewOnce, media } = lightbox;
if (isViewOnce) {
@ -234,7 +239,7 @@ function filterValidAttachments(
attributes: ReadonlyMessageAttributesType
): Array<AttachmentType> {
return (attributes.attachments ?? []).filter(
item => !item.pending && !item.error
item => (!item.pending || isIncremental(item)) && !item.error
);
}
@ -277,6 +282,18 @@ function showLightbox(opts: {
return;
}
if (isIncremental(attachment)) {
// Queue all attachments, but this target attachment should be IMMEDIATE
const updatedFields = await queueAttachmentDownloads(message.attributes, {
urgency: AttachmentDownloadUrgency.STANDARD,
attachmentDigestForImmediate: attachment.digest,
});
if (updatedFields) {
message.set(updatedFields);
await window.MessageCache.saveMessage(message.attributes);
}
}
const attachments = filterValidAttachments(message.attributes);
const loop = isGIF(attachments);
@ -289,39 +306,51 @@ function showLightbox(opts: {
const receivedAt = message.get('received_at');
const sentAt = message.get('sent_at');
const media = attachments.map((item, index) => ({
objectURL: getLocalAttachmentUrl(item),
path: item.path,
contentType: item.contentType,
loop,
index,
message: {
attachments: message.get('attachments') || [],
id: messageId,
conversationId: authorId,
receivedAt,
receivedAtMs: Number(message.get('received_at_ms')),
sentAt,
},
attachment: item,
thumbnailObjectUrl:
item.thumbnail?.objectUrl || item.thumbnail
? getLocalAttachmentUrl(item.thumbnail)
: undefined,
}));
const media = attachments
.map((item, index) => ({
objectURL: item.path ? getLocalAttachmentUrl(item) : undefined,
incrementalObjectUrl:
isIncremental(item) && item.downloadPath
? getLocalAttachmentUrl(item, {
disposition: AttachmentDisposition.Download,
})
: undefined,
path: item.path,
contentType: item.contentType,
loop,
index,
message: {
attachments: message.get('attachments') || [],
id: messageId,
conversationId: authorId,
receivedAt,
receivedAtMs: Number(message.get('received_at_ms')),
sentAt,
},
attachment: item,
thumbnailObjectUrl:
item.thumbnail?.objectUrl || item.thumbnail?.path
? getLocalAttachmentUrl(item.thumbnail)
: undefined,
size: item.size,
totalDownloaded: item.totalDownloaded,
}))
.filter(item => item.objectURL || item.incrementalObjectUrl);
if (!media.length) {
log.error(
'showLightbox: unable to load attachment',
sentAt,
message.get('attachments')?.map(x => ({
thumbnail: !!x.thumbnail,
contentType: x.contentType,
pending: x.pending,
downloadPath: x.downloadPath,
error: x.error,
flags: x.flags,
isIncremental: isIncremental(x),
path: x.path,
pending: x.pending,
size: x.size,
thumbnail: !!x.thumbnail,
}))
);
@ -349,12 +378,13 @@ function showLightbox(opts: {
requireVisualMediaAttachments: true,
});
const index = media.findIndex(({ path }) => path === attachment.path);
dispatch({
type: SHOW_LIGHTBOX,
payload: {
isViewOnce: false,
media,
selectedIndex: media.findIndex(({ path }) => path === attachment.path),
selectedIndex: index === -1 ? 0 : index,
hasPrevMessage:
older.length > 0 && filterValidAttachments(older[0]).length > 0,
hasNextMessage:
@ -567,6 +597,64 @@ export function reducer(
action.type === MESSAGE_CHANGED &&
!action.payload.data.deletedForEveryone
) {
const message = action.payload.data;
const attachmentsByDigest = new Map<string, AttachmentType>();
if (!message.attachments || !message.attachments.length) {
return state;
}
message.attachments.forEach(attachment => {
const { digest } = attachment;
if (!digest) {
return;
}
attachmentsByDigest.set(digest, attachment);
});
let changed = false;
const media = state.media.map(item => {
if (item.message.id !== message.id) {
return item;
}
const { digest } = item.attachment;
if (!digest) {
return item;
}
const attachment = attachmentsByDigest.get(digest);
if (
!attachment ||
!isIncremental(attachment) ||
(!item.attachment.pending && !attachment.pending)
) {
return item;
}
const { totalDownloaded, pending } = attachment;
if (totalDownloaded !== item.attachment.totalDownloaded) {
changed = true;
return {
...item,
attachment: {
...item.attachment,
totalDownloaded,
pending,
},
};
}
return item;
});
if (changed) {
return {
...state,
media,
};
}
return state;
}

View file

@ -772,6 +772,12 @@ function resolveNestedAttachment<
return attachment;
}
export function isIncremental(
attachment: Pick<AttachmentForUIType, 'incrementalMac' | 'chunkSize'>
): boolean {
return Boolean(attachment.incrementalMac && attachment.chunkSize);
}
export function isDownloaded(
attachment?: Pick<AttachmentType, 'path' | 'textAttachment'>
): boolean {
@ -791,7 +797,10 @@ export function isReadyToView(
}
const resolved = resolveNestedAttachment(attachment);
return Boolean(resolved && (resolved.path || resolved.textAttachment));
return Boolean(
resolved &&
(resolved.path || resolved.textAttachment || isIncremental(resolved))
);
}
export function hasNotResolved(attachment?: AttachmentType): boolean {

View file

@ -21,5 +21,7 @@ export type MediaItemType = {
loop?: boolean;
message: MediaItemMessageType;
objectURL?: string;
incrementalObjectUrl?: string;
thumbnailObjectUrl?: string;
size?: number;
};

View file

@ -1,15 +1,18 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AttachmentType } from '../types/Attachment';
import { isNumber } from 'lodash';
import { strictAssert } from './assert';
import type { AttachmentType } from '../types/Attachment';
export enum AttachmentDisposition {
Attachment = 'attachment',
Temporary = 'temporary',
Draft = 'draft',
Sticker = 'sticker',
AvatarData = 'avatarData',
Draft = 'draft',
Download = 'download',
Sticker = 'sticker',
Temporary = 'temporary',
}
export type GetLocalAttachmentUrlOptionsType = Readonly<{
@ -20,20 +23,41 @@ export function getLocalAttachmentUrl(
attachment: Partial<
Pick<
AttachmentType,
'version' | 'path' | 'localKey' | 'size' | 'contentType'
| 'contentType'
| 'digest'
| 'downloadPath'
| 'incrementalMac'
| 'chunkSize'
| 'key'
| 'localKey'
| 'path'
| 'size'
| 'version'
>
>,
{
disposition = AttachmentDisposition.Attachment,
}: GetLocalAttachmentUrlOptionsType = {}
): string {
strictAssert(attachment.path != null, 'Attachment must be downloaded first');
let { path } = attachment;
if (disposition === AttachmentDisposition.Download) {
strictAssert(
attachment.incrementalMac && attachment.chunkSize,
'To view downloads, must have incrementalMac/chunkSize'
);
path = attachment.downloadPath;
}
strictAssert(path != null, `${disposition} attachment was missing path`);
// Fix Windows paths
const path = attachment.path.replace(/\\/g, '/');
path = path.replace(/\\/g, '/');
let url: URL;
if (attachment.version !== 2) {
if (disposition === AttachmentDisposition.Download) {
url = new URL(`attachment://v2/${path}`);
} else if (attachment.version !== 2) {
url = new URL(`attachment://v1/${path}`);
} else {
url = new URL(`attachment://v${attachment.version}/${path}`);
@ -53,5 +77,32 @@ export function getLocalAttachmentUrl(
if (disposition !== AttachmentDisposition.Attachment) {
url.searchParams.set('disposition', disposition);
}
if (disposition === AttachmentDisposition.Download) {
if (!attachment.key) {
throw new Error('getLocalAttachmentUrl: Missing attachment key!');
}
url.searchParams.set('key', attachment.key);
if (!attachment.digest) {
throw new Error('getLocalAttachmentUrl: Missing attachment digest!');
}
url.searchParams.set('digest', attachment.digest);
if (!attachment.incrementalMac) {
throw new Error(
'getLocalAttachmentUrl: Missing attachment incrementalMac!'
);
}
url.searchParams.set('incrementalMac', attachment.incrementalMac);
if (!isNumber(attachment.chunkSize)) {
throw new Error(
'getLocalAttachmentUrl: Missing attachment incrementalMac!'
);
}
url.searchParams.set('chunkSize', attachment.chunkSize.toString());
}
return url.toString();
}

View file

@ -468,12 +468,6 @@
"reasonCategory": "usageTrusted",
"updated": "2021-12-01T01:31:12.757Z"
},
{
"rule": "DOM-innerHTML",
"path": "node_modules/intl-tel-input/build/js/intlTelInput.min.js",
"reasonCategory": "usageTrusted",
"updated": "2021-12-01T01:31:12.757Z"
},
{
"rule": "DOM-innerHTML",
"path": "node_modules/intl-tel-input/build/js/intlTelInput.js",
@ -481,6 +475,12 @@
"reasonCategory": "usageTrusted",
"updated": "2024-11-16T00:33:41.092Z"
},
{
"rule": "DOM-innerHTML",
"path": "node_modules/intl-tel-input/build/js/intlTelInput.min.js",
"reasonCategory": "usageTrusted",
"updated": "2021-12-01T01:31:12.757Z"
},
{
"rule": "DOM-innerHTML",
"path": "node_modules/intl-tel-input/build/js/intlTelInputWithUtils.js",
@ -557,13 +557,6 @@
"reasonCategory": "usageTrusted",
"updated": "2024-11-16T00:33:41.092Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StandaloneRegistration.tsx",
"line": " const pluginRef = useRef<Iti | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2024-11-16T00:33:41.092Z"
},
{
"rule": "eval",
"path": "node_modules/jest-runner/node_modules/source-map-support/source-map-support.js",
@ -2207,6 +2200,14 @@
"reasonCategory": "usageTrusted",
"updated": "2021-10-11T21:21:08.188Z"
},
{
"rule": "React-useRef",
"path": "ts/components/Lightbox.tsx",
"line": " const downloadToastTimeout = useRef<NodeJS.Timeout | number | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2025-01-06T03:53:58.093Z",
"reasonDetail": "usageTrusted"
},
{
"rule": "React-useRef",
"path": "ts/components/ListView.tsx",
@ -2322,6 +2323,13 @@
"reasonCategory": "usageTrusted",
"updated": "2021-11-30T10:15:33.662Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StandaloneRegistration.tsx",
"line": " const pluginRef = useRef<Iti | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2024-11-16T00:33:41.092Z"
},
{
"rule": "React-useRef",
"path": "ts/components/StoryImage.tsx",

View file

@ -111,9 +111,11 @@ export async function queueAttachmentDownloads(
{
urgency = AttachmentDownloadUrgency.STANDARD,
source = AttachmentDownloadSource.STANDARD,
attachmentDigestForImmediate,
}: {
urgency?: AttachmentDownloadUrgency;
source?: AttachmentDownloadSource;
attachmentDigestForImmediate?: string;
} = {}
): Promise<MessageAttachmentsDownloadedType | undefined> {
const attachmentsToQueue = message.attachments || [];
@ -187,6 +189,7 @@ export async function queueAttachmentDownloads(
sentAt: message.sent_at,
urgency,
source,
attachmentDigestForImmediate,
}
);
count += attachmentsCount;
@ -387,7 +390,7 @@ export async function queueAttachmentDownloads(
};
}
async function queueNormalAttachments({
export async function queueNormalAttachments({
idLog,
messageId,
attachments = [],
@ -396,6 +399,7 @@ async function queueNormalAttachments({
sentAt,
urgency,
source,
attachmentDigestForImmediate,
}: {
idLog: string;
messageId: string;
@ -405,6 +409,7 @@ async function queueNormalAttachments({
sentAt: number;
urgency: AttachmentDownloadUrgency;
source: AttachmentDownloadSource;
attachmentDigestForImmediate?: string;
}): Promise<{
attachments: Array<AttachmentType>;
count: number;
@ -456,13 +461,18 @@ async function queueNormalAttachments({
count += 1;
const urgencyForAttachment =
attachmentDigestForImmediate &&
attachmentDigestForImmediate === attachment.digest
? AttachmentDownloadUrgency.IMMEDIATE
: urgency;
return AttachmentDownloadManager.addJob({
attachment,
messageId,
attachmentType: 'attachment',
receivedAt,
sentAt,
urgency,
urgency: urgencyForAttachment,
source,
});
})