Introduce ability to play mp4 files as they download
This commit is contained in:
parent
bab1ceb831
commit
16bbcc2c50
30 changed files with 1304 additions and 141 deletions
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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
20
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
25
patches/growing-file+0.1.3.patch
Normal file
25
patches/growing-file+0.1.3.patch
Normal 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));
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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,
|
||||
}),
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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
15
ts/growing-file.d.ts
vendored
Normal 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;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
119
ts/jobs/deleteDownloadsJobQueue.ts
Normal file
119
ts/jobs/deleteDownloadsJobQueue.ts
Normal 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,
|
||||
});
|
|
@ -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());
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -21,5 +21,7 @@ export type MediaItemType = {
|
|||
loop?: boolean;
|
||||
message: MediaItemMessageType;
|
||||
objectURL?: string;
|
||||
incrementalObjectUrl?: string;
|
||||
thumbnailObjectUrl?: string;
|
||||
size?: number;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue