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.
|
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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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
20
package-lock.json
generated
|
@ -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",
|
||||||
|
|
|
@ -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",
|
||||||
|
|
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
|
// 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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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),
|
||||||
|
|
|
@ -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,
|
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();
|
||||||
|
|
|
@ -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
|
// 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">
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
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;
|
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 {
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
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 { 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());
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue