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. 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 ## heic-convert
License: ISC License: ISC

View file

@ -1494,6 +1494,10 @@
"messageformat": "Image sent in chat", "messageformat": "Image sent in chat",
"description": "Used in the alt tag for the image shown in a full-screen lightbox view" "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": { "icu:imageCaptionIconAlt": {
"messageformat": "Icon showing that this image has a caption", "messageformat": "Icon showing that this image has a caption",
"description": "Used for the icon layered on top of an image in message bubbles" "description": "Used for the icon layered on top of an image in message bubbles"
@ -1512,15 +1516,15 @@
}, },
"icu:retryDownload": { "icu:retryDownload": {
"messageformat": "Retry download", "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": { "icu:retryDownloadShort": {
"messageformat": "Retry", "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": { "icu:downloadNItems": {
"messageformat": "{count, plural, one {# item} other {# items}}", "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": { "icu:save": {
"messageformat": "Save", "messageformat": "Save",

View file

@ -16,7 +16,11 @@ import { join, normalize } from 'node:path';
import { PassThrough, type Writable } from 'node:stream'; import { PassThrough, type Writable } from 'node:stream';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import z from 'zod'; import z from 'zod';
import GrowingFile from 'growing-file';
import { isNumber } from 'lodash';
import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto'; import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto';
import * as Bytes from '../ts/Bytes';
import type { MessageAttachmentsCursorType } from '../ts/sql/Interface'; import type { MessageAttachmentsCursorType } from '../ts/sql/Interface';
import type { MainSQL } from '../ts/sql/main'; import type { MainSQL } from '../ts/sql/main';
import { import {
@ -69,6 +73,10 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments';
const INTERACTIVITY_DELAY = 50; 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< type RangeFinderContextType = Readonly<
( (
| { | {
@ -76,6 +84,14 @@ type RangeFinderContextType = Readonly<
keysBase64: string; keysBase64: string;
size: number; size: number;
} }
| {
type: 'incremental';
digest: Uint8Array;
incrementalMac: Uint8Array;
chunkSize: number;
keysBase64: string;
size: number;
}
| { | {
type: 'plaintext'; type: 'plaintext';
} }
@ -90,7 +106,7 @@ type DigestLRUEntryType = Readonly<{
}>; }>;
const digestLRU = new LRUCache<string, DigestLRUEntryType>({ 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. // mostly need this cache for range requests, so keep it low.
max: 100, max: 100,
}); });
@ -99,17 +115,60 @@ async function safeDecryptToSink(
ctx: RangeFinderContextType, ctx: RangeFinderContextType,
sink: Writable sink: Writable
): Promise<void> { ): Promise<void> {
strictAssert(ctx.type === 'ciphertext', 'Cannot decrypt plaintext'); strictAssert(
ctx.type === 'ciphertext' || ctx.type === 'incremental',
const options = { 'Cannot decrypt plaintext'
ciphertextPath: ctx.path, );
idForLogging: 'attachment_channel',
keysBase64: ctx.keysBase64,
type: 'local' as const,
size: ctx.size,
};
try { 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); const chunkSize = inferChunkSize(ctx.size);
let entry = digestLRU.get(ctx.path); let entry = digestLRU.get(ctx.path);
if (!entry) { if (!entry) {
@ -122,9 +181,7 @@ async function safeDecryptToSink(
const controller = new AbortController(); const controller = new AbortController();
await Promise.race([ await Promise.race([
// Just use a non-existing event name to wait for an 'error'. We want // Same as above usage of the once() pattern
// to handle errors on `sink` while generating digest in case whole
// request get cancelled early.
once(sink, 'non-error-event', { signal: controller.signal }), once(sink, 'non-error-event', { signal: controller.signal }),
decryptAttachmentV2ToSink(options, digester), decryptAttachmentV2ToSink(options, digester),
]); ]);
@ -171,7 +228,7 @@ const storage = new DefaultStorage<RangeFinderContextType>(
return createReadStream(ctx.path); return createReadStream(ctx.path);
} }
if (ctx.type === 'ciphertext') { if (ctx.type === 'ciphertext' || ctx.type === 'incremental') {
const plaintext = new PassThrough(); const plaintext = new PassThrough();
drop(safeDecryptToSink(ctx, plaintext)); drop(safeDecryptToSink(ctx, plaintext));
return plaintext; return plaintext;
@ -183,7 +240,7 @@ const storage = new DefaultStorage<RangeFinderContextType>(
maxSize: 10, maxSize: 10,
ttl: SECOND, ttl: SECOND,
cacheKey: ctx => { cacheKey: ctx => {
if (ctx.type === 'ciphertext') { if (ctx.type === 'ciphertext' || ctx.type === 'incremental') {
return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`; return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`;
} }
if (ctx.type === 'plaintext') { if (ctx.type === 'plaintext') {
@ -199,10 +256,11 @@ const rangeFinder = new RangeFinder<RangeFinderContextType>(storage, {
const dispositionSchema = z.enum([ const dispositionSchema = z.enum([
'attachment', 'attachment',
'temporary',
'draft',
'sticker',
'avatarData', 'avatarData',
'download',
'draft',
'temporary',
'sticker',
]); ]);
type DeleteOrphanedAttachmentsOptionsType = Readonly<{ type DeleteOrphanedAttachmentsOptionsType = Readonly<{
@ -479,6 +537,7 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
strictAssert(attachmentsDir != null, 'not initialized'); strictAssert(attachmentsDir != null, 'not initialized');
strictAssert(tempDir != null, 'not initialized'); strictAssert(tempDir != null, 'not initialized');
strictAssert(downloadsDir != null, 'not initialized');
strictAssert(draftDir != null, 'not initialized'); strictAssert(draftDir != null, 'not initialized');
strictAssert(stickersDir != null, 'not initialized'); strictAssert(stickersDir != null, 'not initialized');
strictAssert(avatarDataDir != null, 'not initialized'); strictAssert(avatarDataDir != null, 'not initialized');
@ -488,6 +547,9 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
case 'attachment': case 'attachment':
parentDir = attachmentsDir; parentDir = attachmentsDir;
break; break;
case 'download':
parentDir = downloadsDir;
break;
case 'temporary': case 'temporary':
parentDir = tempDir; parentDir = tempDir;
break; break;
@ -534,8 +596,8 @@ export async function handleAttachmentRequest(req: Request): Promise<Response> {
// Encrypted attachments // Encrypted attachments
// Get AES+MAC key // Get AES+MAC key
const maybeKeysBase64 = url.searchParams.get('key'); const keysBase64 = url.searchParams.get('key');
if (maybeKeysBase64 == null) { if (keysBase64 == null) {
return new Response('Missing key', { status: 400 }); 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 }); return new Response('Missing size', { status: 400 });
} }
context = { if (disposition !== 'download') {
type: 'ciphertext', context = {
path, type: 'ciphertext',
keysBase64: maybeKeysBase64, keysBase64,
size: maybeSize, 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 { try {

20
package-lock.json generated
View file

@ -51,6 +51,7 @@
"fuse.js": "6.5.3", "fuse.js": "6.5.3",
"google-libphonenumber": "3.2.39", "google-libphonenumber": "3.2.39",
"got": "11.8.5", "got": "11.8.5",
"growing-file": "0.1.3",
"heic-convert": "2.1.0", "heic-convert": "2.1.0",
"humanize-duration": "3.27.1", "humanize-duration": "3.27.1",
"intl-tel-input": "24.7.0", "intl-tel-input": "24.7.0",
@ -18351,6 +18352,17 @@
"integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==",
"dev": true "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": { "node_modules/handle-thing": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz",
@ -24621,6 +24633,14 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/open": {
"version": "8.4.2", "version": "8.4.2",
"resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz",

View file

@ -142,6 +142,7 @@
"fuse.js": "6.5.3", "fuse.js": "6.5.3",
"google-libphonenumber": "3.2.39", "google-libphonenumber": "3.2.39",
"got": "11.8.5", "got": "11.8.5",
"growing-file": "0.1.3",
"heic-convert": "2.1.0", "heic-convert": "2.1.0",
"humanize-duration": "3.27.1", "humanize-duration": "3.27.1",
"intl-tel-input": "24.7.0", "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 // 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 // commonly), so we have a special case for it here. We may need to do something
// similar for new optionalDependencies in the future. // similar for new optionalDependencies in the future.
if (dependencyName === 'fs-xattr') { if (dependencyName === 'fs-xattr' || dependencyName === 'growing-file') {
licenseBody = 'License: MIT'; licenseBody = 'License: MIT';
} else { } else {
const dependencyRootPath = join(nodeModulesPath, dependencyName); const dependencyRootPath = join(nodeModulesPath, dependencyName);

View file

@ -213,6 +213,31 @@
text-align: center; 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-next,
&__nav-prev { &__nav-prev {
--height: 224px; --height: 224px;

View file

@ -3,6 +3,7 @@
import { createReadStream, createWriteStream } from 'fs'; import { createReadStream, createWriteStream } from 'fs';
import { open, unlink, stat } from 'fs/promises'; import { open, unlink, stat } from 'fs/promises';
import type { FileHandle } from 'fs/promises';
import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'; import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto';
import type { Hash } from 'crypto'; import type { Hash } from 'crypto';
import { PassThrough, Transform, type Writable, Readable } from 'stream'; import { PassThrough, Transform, type Writable, Readable } from 'stream';
@ -301,7 +302,6 @@ export async function encryptAttachmentV2({
type DecryptAttachmentToSinkOptionsType = Readonly< type DecryptAttachmentToSinkOptionsType = Readonly<
{ {
ciphertextPath: string;
idForLogging: string; idForLogging: string;
size: number; size: number;
outerEncryption?: { outerEncryption?: {
@ -310,18 +310,26 @@ type DecryptAttachmentToSinkOptionsType = Readonly<
}; };
} & ( } & (
| { | {
type: 'standard'; ciphertextPath: string;
theirDigest: Readonly<Uint8Array>;
theirIncrementalMac: Readonly<Uint8Array> | undefined;
theirChunkSize: number | undefined;
} }
| { | {
// No need to check integrity for locally reencrypted attachments, or for backup ciphertextStream: Readable;
// thumbnails (since we created it)
type: 'local' | 'backupThumbnail';
theirDigest?: undefined;
} }
) & ) &
(
| {
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>; aesKey: Readonly<Uint8Array>;
@ -383,7 +391,7 @@ export async function decryptAttachmentV2ToSink(
options: DecryptAttachmentToSinkOptionsType, options: DecryptAttachmentToSinkOptionsType,
sink: Writable sink: Writable
): Promise<Omit<DecryptedAttachmentV2, 'path'>> { ): Promise<Omit<DecryptedAttachmentV2, 'path'>> {
const { ciphertextPath, idForLogging, outerEncryption } = options; const { idForLogging, outerEncryption } = options;
let aesKey: Uint8Array; let aesKey: Uint8Array;
let macKey: Uint8Array; let macKey: Uint8Array;
@ -434,19 +442,27 @@ export async function decryptAttachmentV2ToSink(
: undefined; : undefined;
let isPaddingAllZeros = false; let isPaddingAllZeros = false;
let readFd; let readFd: FileHandle | undefined;
let iv: Uint8Array | undefined; let iv: Uint8Array | undefined;
let ciphertextStream: Readable;
try { try {
try { if ('ciphertextPath' in options) {
readFd = await open(ciphertextPath, 'r'); try {
} catch (cause) { readFd = await open(options.ciphertextPath, 'r');
throw new Error(`${logId}: Read path doesn't exist`, { cause }); 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( await pipeline(
[ [
readFd.createReadStream(), ciphertextStream,
maybeOuterEncryptionGetMacAndUpdateMac, maybeOuterEncryptionGetMacAndUpdateMac,
maybeOuterEncryptionGetIvAndDecipher, maybeOuterEncryptionGetIvAndDecipher,
peekAndUpdateHash(digest), 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, SaveAttachmentActionCreatorType,
} from '../state/ducks/conversations'; } from '../state/ducks/conversations';
import type { LocalizerType } from '../types/Util'; 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 GoogleChrome from '../util/GoogleChrome';
import * as log from '../logging/log'; import * as log from '../logging/log';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
@ -22,7 +22,7 @@ import { Avatar, AvatarSize } from './Avatar';
import { IMAGE_PNG, isImage, isVideo } from '../types/MIME'; import { IMAGE_PNG, isImage, isVideo } from '../types/MIME';
import { formatDateTimeForAttachment } from '../util/timestamp'; import { formatDateTimeForAttachment } from '../util/timestamp';
import { formatDuration } from '../util/formatDuration'; import { formatDuration } from '../util/formatDuration';
import { isGIF } from '../types/Attachment'; import { isGIF, isIncremental } from '../types/Attachment';
import { useRestoreFocus } from '../hooks/useRestoreFocus'; import { useRestoreFocus } from '../hooks/useRestoreFocus';
import { usePrevious } from '../hooks/usePrevious'; import { usePrevious } from '../hooks/usePrevious';
import { arrow } from '../util/keyboard'; import { arrow } from '../util/keyboard';
@ -31,6 +31,9 @@ import { isCmdOrCtrl } from '../hooks/useKeyboardShortcuts';
import type { ForwardMessagesPayload } from '../state/ducks/globalModals'; import type { ForwardMessagesPayload } from '../state/ducks/globalModals';
import { ForwardMessagesModalType } from './ForwardMessagesModal'; import { ForwardMessagesModalType } from './ForwardMessagesModal';
import { useReducedMotion } from '../hooks/useReducedMotion'; import { useReducedMotion } from '../hooks/useReducedMotion';
import { formatFileSize } from '../util/formatFileSize';
import { SECOND } from '../util/durations';
import { Toast } from './Toast';
export type PropsType = { export type PropsType = {
children?: ReactNode; children?: ReactNode;
@ -53,6 +56,8 @@ export type PropsType = {
const ZOOM_SCALE = 3; const ZOOM_SCALE = 3;
const TWO_SECONDS = 2.5 * SECOND;
const INITIAL_IMAGE_TRANSFORM = { const INITIAL_IMAGE_TRANSFORM = {
scale: 1, scale: 1,
translateX: 0, translateX: 0,
@ -103,6 +108,9 @@ export function Lightbox({
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>( const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(
null null
); );
const [shouldShowDownloadToast, setShouldShowDownloadToast] = useState(false);
const downloadToastTimeout = useRef<NodeJS.Timeout | number | undefined>();
const [videoTime, setVideoTime] = useState<number | undefined>(); const [videoTime, setVideoTime] = useState<number | undefined>();
const [isZoomed, setIsZoomed] = useState(false); const [isZoomed, setIsZoomed] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
@ -128,6 +136,55 @@ export function Lightbox({
| undefined | 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( const onPrevious = useCallback(
( (
event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent> event: KeyboardEvent | React.MouseEvent<HTMLButtonElement, MouseEvent>
@ -179,9 +236,9 @@ export function Lightbox({
event.preventDefault(); event.preventDefault();
const mediaItem = media[selectedIndex]; 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] [isViewOnce, media, saveAttachment, selectedIndex]
); );
@ -288,16 +345,6 @@ export function Lightbox({
}; };
}, [onKeyDown]); }, [onKeyDown]);
const {
attachment,
contentType,
loop = false,
objectURL,
message,
} = media[selectedIndex] || {};
const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined);
useEffect(() => { useEffect(() => {
playVideo(); playVideo();
@ -596,11 +643,13 @@ export function Lightbox({
<video <video
className="Lightbox__object Lightbox__object--video" className="Lightbox__object Lightbox__object--video"
controls={!shouldLoop} controls={!shouldLoop}
key={objectURL} key={objectURL || incrementalObjectUrl}
loop={shouldLoop} loop={shouldLoop}
ref={setVideoElement} ref={setVideoElement}
onMouseMove={onUserInteractionOnVideo}
onMouseLeave={onMouseLeaveVideo}
> >
<source src={objectURL} /> <source src={objectURL || incrementalObjectUrl} />
</video> </video>
); );
} else if (isUnsupportedImageType || isUnsupportedVideoType) { } else if (isUnsupportedImageType || isUnsupportedVideoType) {
@ -671,7 +720,7 @@ export function Lightbox({
<LightboxHeader <LightboxHeader
getConversation={getConversation} getConversation={getConversation}
i18n={i18n} i18n={i18n}
message={message} item={currentItem}
/> />
) : ( ) : (
<div /> <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} {content}
{hasPrevious && ( {hasPrevious && (
@ -797,12 +868,13 @@ export function Lightbox({
function LightboxHeader({ function LightboxHeader({
getConversation, getConversation,
i18n, i18n,
message, item,
}: { }: {
getConversation: (id: string) => ConversationType; getConversation: (id: string) => ConversationType;
i18n: LocalizerType; i18n: LocalizerType;
message: ReadonlyDeep<MediaItemMessageType>; item: ReadonlyDeep<MediaItemType>;
}): JSX.Element { }): JSX.Element {
const { message } = item;
const conversation = getConversation(message.conversationId); const conversation = getConversation(message.conversationId);
const now = Date.now(); 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 // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import classNames from 'classnames';
import { formatFileSize } from '../../util/formatFileSize'; import { formatFileSize } from '../../util/formatFileSize';
import { ProgressCircle } from '../ProgressCircle';
import type { AttachmentForUIType } from '../../types/Attachment'; import type { AttachmentForUIType } from '../../types/Attachment';
import type { LocalizerType } from '../../types/I18N'; import type { LocalizerType } from '../../types/I18N';
import { Spinner } from '../Spinner';
import { isKeyboardActivation } from '../../hooks/useKeyboardShortcuts';
export type PropsType = { export type PropsType = {
attachments: ReadonlyArray<AttachmentForUIType>; attachments: ReadonlyArray<AttachmentForUIType>;
@ -18,7 +22,10 @@ export type PropsType = {
export function AttachmentDetailPill({ export function AttachmentDetailPill({
attachments, attachments,
cancelDownload,
i18n,
isGif, isGif,
startDownload,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const areAllDownloaded = attachments.every(attachment => attachment.path); const areAllDownloaded = attachments.every(attachment => attachment.path);
const totalSize = attachments.reduce( const totalSize = attachments.reduce(
@ -28,10 +35,54 @@ export function AttachmentDetailPill({
0 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) { if (areAllDownloaded || totalSize === 0) {
return null; return null;
} }
const areAnyIncremental = attachments.some(
attachment => attachment.incrementalMac && attachment.chunkSize
);
const totalDownloadedSize = attachments.reduce( const totalDownloadedSize = attachments.reduce(
(total: number, attachment: AttachmentForUIType) => { (total: number, attachment: AttachmentForUIType) => {
return ( return (
@ -43,6 +94,99 @@ export function AttachmentDetailPill({
); );
const areAnyPending = attachments.some(attachment => attachment.pending); 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 ( return (
<div className="AttachmentDetailPill"> <div className="AttachmentDetailPill">
<div className="AttachmentDetailPill__text-wrapper"> <div className="AttachmentDetailPill__text-wrapper">

View file

@ -173,6 +173,82 @@ export function NotPendingWDownloadProgress(): JSX.Element {
return <Image {...props} />; 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 { export function CurvedCorners(): JSX.Element {
const props = createProps({ const props = createProps({
curveBottomLeft: CurveType.Normal, curveBottomLeft: CurveType.Normal,

View file

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

View file

@ -157,6 +157,98 @@ export function OneVideoDownloadProgressNotPending(args: Props): JSX.Element {
return <ImageGrid {...props} />; 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 { export function TwoImages(args: Props): JSX.Element {
return ( return (
<ImageGrid <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 { export function TwoImagesPendingWDownloadProgress(args: Props): JSX.Element {
const props = { const props = {
...args, ...args,
@ -716,6 +836,71 @@ export function _6ImagesPendingWDownloadProgress(args: Props): JSX.Element {
return <ImageGrid {...props} />; 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 { export function MixedContentTypes(args: Props): JSX.Element {
return ( return (
<ImageGrid <ImageGrid

View file

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

View file

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

View file

@ -51,6 +51,7 @@ import {
type ReencryptedAttachmentV2, type ReencryptedAttachmentV2,
} from '../AttachmentCrypto'; } from '../AttachmentCrypto';
import { safeParsePartial } from '../util/schemas'; import { safeParsePartial } from '../util/schemas';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
import { createBatcher } from '../util/batcher'; import { createBatcher } from '../util/batcher';
import { postSaveUpdates } from '../util/cleanup'; import { postSaveUpdates } from '../util/cleanup';
@ -520,6 +521,7 @@ export async function runDownloadAttachmentJobInner({
); );
try { try {
const { downloadPath } = attachment;
let totalDownloaded = 0; let totalDownloaded = 0;
let downloadedAttachment: ReencryptedAttachmentV2 | undefined; let downloadedAttachment: ReencryptedAttachmentV2 | undefined;
@ -550,13 +552,52 @@ export async function runDownloadAttachmentJobInner({
}); });
const upgradedAttachment = await dependencies.processNewAttachment({ const upgradedAttachment = await dependencies.processNewAttachment({
...omit(attachment, ['error', 'pending', 'downloadPath']), ...omit(attachment, ['error', 'pending']),
...downloadedAttachment, ...downloadedAttachment,
}); });
await addAttachmentToMessage(messageId, upgradedAttachment, logId, { const isShowingLightbox = (): boolean => {
type: attachmentType, 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 }; return { downloadedVariant: AttachmentVariant.Default };
} catch (error) { } catch (error) {
if ( if (

View file

@ -14,6 +14,8 @@ import { JobLogger } from './JobLogger';
import * as Errors from '../types/errors'; import * as Errors from '../types/errors';
import type { LoggerType } from '../types/Logging'; import type { LoggerType } from '../types/Logging';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { sleep } from '../util/sleep';
import { SECOND } from '../util/durations';
const noopOnCompleteCallbacks = { const noopOnCompleteCallbacks = {
resolve: noop, resolve: noop,
@ -62,6 +64,7 @@ export abstract class JobQueue<T> {
private readonly logPrefix: string; private readonly logPrefix: string;
private shuttingDown = false; private shuttingDown = false;
private paused = false;
private readonly onCompleteCallbacks = new Map< private readonly onCompleteCallbacks = new Map<
string, string,
@ -78,6 +81,9 @@ export abstract class JobQueue<T> {
get isShuttingDown(): boolean { get isShuttingDown(): boolean {
return this.shuttingDown; return this.shuttingDown;
} }
get isPaused(): boolean {
return this.paused;
}
constructor(options: Readonly<JobQueueOptions>) { constructor(options: Readonly<JobQueueOptions>) {
assertDev( assertDev(
@ -151,6 +157,14 @@ export abstract class JobQueue<T> {
log.info(`${this.logPrefix} is shutting down. Can't accept more work.`); log.info(`${this.logPrefix} is shutting down. Can't accept more work.`);
break; 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)); drop(this.enqueueStoredJob(storedJob));
} }
} }
@ -359,4 +373,12 @@ export abstract class JobQueue<T> {
await Promise.all([...queues].map(q => q.onIdle())); await Promise.all([...queues].map(q => q.onIdle()));
log.info(`${this.logPrefix} shutdown: complete`); 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 { callLinkRefreshJobQueue } from './callLinkRefreshJobQueue';
import { conversationJobQueue } from './conversationJobQueue'; import { conversationJobQueue } from './conversationJobQueue';
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
import { groupAvatarJobQueue } from './groupAvatarJobQueue'; import { groupAvatarJobQueue } from './groupAvatarJobQueue';
import { readSyncJobQueue } from './readSyncJobQueue'; import { readSyncJobQueue } from './readSyncJobQueue';
import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue'; import { removeStorageKeyJobQueue } from './removeStorageKeyJobQueue';
@ -40,6 +41,7 @@ export function initializeAllJobQueues({
drop(viewOnceOpenJobQueue.streamJobs()); drop(viewOnceOpenJobQueue.streamJobs());
// Other queues // Other queues
drop(deleteDownloadsJobQueue.streamJobs());
drop(removeStorageKeyJobQueue.streamJobs()); drop(removeStorageKeyJobQueue.streamJobs());
drop(reportSpamJobQueue.streamJobs()); drop(reportSpamJobQueue.streamJobs());
drop(callLinkRefreshJobQueue.streamJobs()); drop(callLinkRefreshJobQueue.streamJobs());

View file

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

View file

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

View file

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

View file

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

View file

@ -468,12 +468,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-12-01T01:31:12.757Z" "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", "rule": "DOM-innerHTML",
"path": "node_modules/intl-tel-input/build/js/intlTelInput.js", "path": "node_modules/intl-tel-input/build/js/intlTelInput.js",
@ -481,6 +475,12 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2024-11-16T00:33:41.092Z" "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", "rule": "DOM-innerHTML",
"path": "node_modules/intl-tel-input/build/js/intlTelInputWithUtils.js", "path": "node_modules/intl-tel-input/build/js/intlTelInputWithUtils.js",
@ -557,13 +557,6 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2024-11-16T00:33:41.092Z" "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", "rule": "eval",
"path": "node_modules/jest-runner/node_modules/source-map-support/source-map-support.js", "path": "node_modules/jest-runner/node_modules/source-map-support/source-map-support.js",
@ -2207,6 +2200,14 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-10-11T21:21:08.188Z" "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", "rule": "React-useRef",
"path": "ts/components/ListView.tsx", "path": "ts/components/ListView.tsx",
@ -2322,6 +2323,13 @@
"reasonCategory": "usageTrusted", "reasonCategory": "usageTrusted",
"updated": "2021-11-30T10:15:33.662Z" "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", "rule": "React-useRef",
"path": "ts/components/StoryImage.tsx", "path": "ts/components/StoryImage.tsx",

View file

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