diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index 93994244a6..9cf63d8b5b 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -1366,6 +1366,10 @@ Signal Desktop makes use of the following open source projects. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +## growing-file + + License: MIT + ## heic-convert License: ISC diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 6859407487..cc97a677bb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1494,6 +1494,10 @@ "messageformat": "Image sent in chat", "description": "Used in the alt tag for the image shown in a full-screen lightbox view" }, + "icu:lightBoxDownloading": { + "messageformat": "Downloading {downloaded} of {total}", + "description": "When watching a video while it is downloaded, a toast will appear over the video along with the playback controls showing download progress." + }, "icu:imageCaptionIconAlt": { "messageformat": "Icon showing that this image has a caption", "description": "Used for the icon layered on top of an image in message bubbles" @@ -1512,15 +1516,15 @@ }, "icu:retryDownload": { "messageformat": "Retry download", - "description": "(Deleted 2024/12/12) Label for button shown on an existing download to restart a download that was partially completed" + "description": "Label for button shown on an existing download to restart a download that was partially completed" }, "icu:retryDownloadShort": { "messageformat": "Retry", - "description": "(Deleted 2024/12/12) Describes a button shown on an existing download to restart a download that was partially completed" + "description": "Describes a button shown on an existing download to restart a download that was partially completed" }, "icu:downloadNItems": { "messageformat": "{count, plural, one {# item} other {# items}}", - "description": "Describes a button shown on an existing download to restart a download that was partially completed" + "description": "Describes a button shown on a grid of attachments to start of them downloading" }, "icu:save": { "messageformat": "Save", diff --git a/app/attachment_channel.ts b/app/attachment_channel.ts index fd671df517..91fda68608 100644 --- a/app/attachment_channel.ts +++ b/app/attachment_channel.ts @@ -16,7 +16,11 @@ import { join, normalize } from 'node:path'; import { PassThrough, type Writable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import z from 'zod'; +import GrowingFile from 'growing-file'; +import { isNumber } from 'lodash'; + import { decryptAttachmentV2ToSink } from '../ts/AttachmentCrypto'; +import * as Bytes from '../ts/Bytes'; import type { MessageAttachmentsCursorType } from '../ts/sql/Interface'; import type { MainSQL } from '../ts/sql/main'; import { @@ -69,6 +73,10 @@ const CLEANUP_ORPHANED_ATTACHMENTS_KEY = 'cleanup-orphaned-attachments'; const INTERACTIVITY_DELAY = 50; +// Matches the value in WebAPI.ts +const GET_ATTACHMENT_CHUNK_TIMEOUT = 10 * SECOND; +const GROWING_FILE_TIMEOUT = GET_ATTACHMENT_CHUNK_TIMEOUT * 1.5; + type RangeFinderContextType = Readonly< ( | { @@ -76,6 +84,14 @@ type RangeFinderContextType = Readonly< keysBase64: string; size: number; } + | { + type: 'incremental'; + digest: Uint8Array; + incrementalMac: Uint8Array; + chunkSize: number; + keysBase64: string; + size: number; + } | { type: 'plaintext'; } @@ -90,7 +106,7 @@ type DigestLRUEntryType = Readonly<{ }>; const digestLRU = new LRUCache({ - // The size of each entry is roughgly 8kb per digest + 32 bytes per key. We + // The size of each entry is roughly 8kb per digest + 32 bytes per key. We // mostly need this cache for range requests, so keep it low. max: 100, }); @@ -99,17 +115,60 @@ async function safeDecryptToSink( ctx: RangeFinderContextType, sink: Writable ): Promise { - strictAssert(ctx.type === 'ciphertext', 'Cannot decrypt plaintext'); - - const options = { - ciphertextPath: ctx.path, - idForLogging: 'attachment_channel', - keysBase64: ctx.keysBase64, - type: 'local' as const, - size: ctx.size, - }; + strictAssert( + ctx.type === 'ciphertext' || ctx.type === 'incremental', + 'Cannot decrypt plaintext' + ); try { + if (ctx.type === 'incremental') { + const ciphertextStream = new PassThrough(); + const file = GrowingFile.open(ctx.path, { + timeout: GROWING_FILE_TIMEOUT, + }); + file.on('error', (error: Error) => { + console.warn( + 'safeDecryptToSync/incremental: growing-file emitted an error:', + Errors.toLogFormat(error) + ); + }); + file.pipe(ciphertextStream); + + const options = { + ciphertextStream, + idForLogging: 'attachment_channel/incremental', + keysBase64: ctx.keysBase64, + size: ctx.size, + theirChunkSize: ctx.chunkSize, + theirDigest: ctx.digest, + theirIncrementalMac: ctx.incrementalMac, + type: 'standard' as const, + }; + + const controller = new AbortController(); + + await Promise.race([ + // Just use a non-existing event name to wait for an 'error'. We want + // to handle errors on `sink` while generating digest in case the whole + // request gets cancelled early. + once(sink, 'non-error-event', { signal: controller.signal }), + decryptAttachmentV2ToSink(options, sink), + ]); + + // Stop handling errors on sink + controller.abort(); + + return; + } + + const options = { + ciphertextPath: ctx.path, + idForLogging: 'attachment_channel/ciphertext', + keysBase64: ctx.keysBase64, + size: ctx.size, + type: 'local' as const, + }; + const chunkSize = inferChunkSize(ctx.size); let entry = digestLRU.get(ctx.path); if (!entry) { @@ -122,9 +181,7 @@ async function safeDecryptToSink( const controller = new AbortController(); await Promise.race([ - // Just use a non-existing event name to wait for an 'error'. We want - // to handle errors on `sink` while generating digest in case whole - // request get cancelled early. + // Same as above usage of the once() pattern once(sink, 'non-error-event', { signal: controller.signal }), decryptAttachmentV2ToSink(options, digester), ]); @@ -171,7 +228,7 @@ const storage = new DefaultStorage( return createReadStream(ctx.path); } - if (ctx.type === 'ciphertext') { + if (ctx.type === 'ciphertext' || ctx.type === 'incremental') { const plaintext = new PassThrough(); drop(safeDecryptToSink(ctx, plaintext)); return plaintext; @@ -183,7 +240,7 @@ const storage = new DefaultStorage( maxSize: 10, ttl: SECOND, cacheKey: ctx => { - if (ctx.type === 'ciphertext') { + if (ctx.type === 'ciphertext' || ctx.type === 'incremental') { return `${ctx.type}:${ctx.path}:${ctx.size}:${ctx.keysBase64}`; } if (ctx.type === 'plaintext') { @@ -199,10 +256,11 @@ const rangeFinder = new RangeFinder(storage, { const dispositionSchema = z.enum([ 'attachment', - 'temporary', - 'draft', - 'sticker', 'avatarData', + 'download', + 'draft', + 'temporary', + 'sticker', ]); type DeleteOrphanedAttachmentsOptionsType = Readonly<{ @@ -479,6 +537,7 @@ export async function handleAttachmentRequest(req: Request): Promise { strictAssert(attachmentsDir != null, 'not initialized'); strictAssert(tempDir != null, 'not initialized'); + strictAssert(downloadsDir != null, 'not initialized'); strictAssert(draftDir != null, 'not initialized'); strictAssert(stickersDir != null, 'not initialized'); strictAssert(avatarDataDir != null, 'not initialized'); @@ -488,6 +547,9 @@ export async function handleAttachmentRequest(req: Request): Promise { case 'attachment': parentDir = attachmentsDir; break; + case 'download': + parentDir = downloadsDir; + break; case 'temporary': parentDir = tempDir; break; @@ -534,8 +596,8 @@ export async function handleAttachmentRequest(req: Request): Promise { // Encrypted attachments // Get AES+MAC key - const maybeKeysBase64 = url.searchParams.get('key'); - if (maybeKeysBase64 == null) { + const keysBase64 = url.searchParams.get('key'); + if (keysBase64 == null) { return new Response('Missing key', { status: 400 }); } @@ -544,12 +606,45 @@ export async function handleAttachmentRequest(req: Request): Promise { return new Response('Missing size', { status: 400 }); } - context = { - type: 'ciphertext', - path, - keysBase64: maybeKeysBase64, - size: maybeSize, - }; + if (disposition !== 'download') { + context = { + type: 'ciphertext', + keysBase64, + path, + size: maybeSize, + }; + } else { + // When trying to view in-progress downloads, we need more information + // to validate the file before returning data. + + const digestBase64 = url.searchParams.get('digest'); + if (digestBase64 == null) { + return new Response('Missing digest', { status: 400 }); + } + + const incrementalMacBase64 = url.searchParams.get('incrementalMac'); + if (incrementalMacBase64 == null) { + return new Response('Missing incrementalMac', { status: 400 }); + } + + const chunkSizeString = url.searchParams.get('chunkSize'); + const chunkSize = chunkSizeString + ? parseInt(chunkSizeString, 10) + : undefined; + if (!isNumber(chunkSize)) { + return new Response('Missing chunkSize', { status: 400 }); + } + + context = { + type: 'incremental', + chunkSize, + digest: Bytes.fromBase64(digestBase64), + incrementalMac: Bytes.fromBase64(incrementalMacBase64), + keysBase64, + path, + size: maybeSize, + }; + } } try { diff --git a/package-lock.json b/package-lock.json index dfd76839cf..9688bf3cd5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "fuse.js": "6.5.3", "google-libphonenumber": "3.2.39", "got": "11.8.5", + "growing-file": "0.1.3", "heic-convert": "2.1.0", "humanize-duration": "3.27.1", "intl-tel-input": "24.7.0", @@ -18351,6 +18352,17 @@ "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/growing-file": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/growing-file/-/growing-file-0.1.3.tgz", + "integrity": "sha512-5+YYjm3sKIxyHAhlgDOzs1mL7sT9tbT3Unt1xymjkAgXZ2KwpLzYaaaNp3z1KIOXaKTYdJiUqxZmRusOTrO0gg==", + "dependencies": { + "oop": "0.0.3" + }, + "engines": { + "node": "*" + } + }, "node_modules/handle-thing": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-2.0.0.tgz", @@ -24621,6 +24633,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/oop": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/oop/-/oop-0.0.3.tgz", + "integrity": "sha512-NCkLvw6ZyDnLCFNWIXtbrhNKEVBwHxv8n003Lum8Y5YF3dZtbSYSZZN/8gGJ1Ey52hCpsBQ6n5qutYAc4OOhFA==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/open": { "version": "8.4.2", "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", diff --git a/package.json b/package.json index b589155981..9f7e2d3508 100644 --- a/package.json +++ b/package.json @@ -142,6 +142,7 @@ "fuse.js": "6.5.3", "google-libphonenumber": "3.2.39", "got": "11.8.5", + "growing-file": "0.1.3", "heic-convert": "2.1.0", "humanize-duration": "3.27.1", "intl-tel-input": "24.7.0", diff --git a/patches/growing-file+0.1.3.patch b/patches/growing-file+0.1.3.patch new file mode 100644 index 0000000000..ee20b922b0 --- /dev/null +++ b/patches/growing-file+0.1.3.patch @@ -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)); diff --git a/scripts/generate-acknowledgments.js b/scripts/generate-acknowledgments.js index d84dbee568..9042398bf7 100644 --- a/scripts/generate-acknowledgments.js +++ b/scripts/generate-acknowledgments.js @@ -35,7 +35,7 @@ async function getMarkdownForDependency(dependencyName) { // fs-xattr is an optional dependency that may fail to install (on Windows, most // commonly), so we have a special case for it here. We may need to do something // similar for new optionalDependencies in the future. - if (dependencyName === 'fs-xattr') { + if (dependencyName === 'fs-xattr' || dependencyName === 'growing-file') { licenseBody = 'License: MIT'; } else { const dependencyRootPath = join(nodeModulesPath, dependencyName); diff --git a/stylesheets/components/Lightbox.scss b/stylesheets/components/Lightbox.scss index 0c766edd16..10376657df 100644 --- a/stylesheets/components/Lightbox.scss +++ b/stylesheets/components/Lightbox.scss @@ -213,6 +213,31 @@ text-align: center; } + &__toast-container { + opacity: 0; + transition: opacity 500ms; + + position: absolute; + bottom: 45px; + pointer-events: none; + + // We need this so our toast goes on top of the video + z-index: variables.$z-index-above-base; + + .Toast { + background-color: variables.$color-black-alpha-80; + } + .Toast__content { + padding-block: 7px; + padding-inline: 12px; + @include mixins.font-caption; + } + + &--visible { + opacity: 1; + } + } + &__nav-next, &__nav-prev { --height: 224px; diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index a1194427b8..db82f9ba10 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -3,6 +3,7 @@ import { createReadStream, createWriteStream } from 'fs'; import { open, unlink, stat } from 'fs/promises'; +import type { FileHandle } from 'fs/promises'; import { createCipheriv, createHash, createHmac, randomBytes } from 'crypto'; import type { Hash } from 'crypto'; import { PassThrough, Transform, type Writable, Readable } from 'stream'; @@ -301,7 +302,6 @@ export async function encryptAttachmentV2({ type DecryptAttachmentToSinkOptionsType = Readonly< { - ciphertextPath: string; idForLogging: string; size: number; outerEncryption?: { @@ -310,18 +310,26 @@ type DecryptAttachmentToSinkOptionsType = Readonly< }; } & ( | { - type: 'standard'; - theirDigest: Readonly; - theirIncrementalMac: Readonly | undefined; - theirChunkSize: number | undefined; + ciphertextPath: string; } | { - // No need to check integrity for locally reencrypted attachments, or for backup - // thumbnails (since we created it) - type: 'local' | 'backupThumbnail'; - theirDigest?: undefined; + ciphertextStream: Readable; } ) & + ( + | { + type: 'standard'; + theirDigest: Readonly; + theirIncrementalMac: Readonly | 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; @@ -383,7 +391,7 @@ export async function decryptAttachmentV2ToSink( options: DecryptAttachmentToSinkOptionsType, sink: Writable ): Promise> { - const { ciphertextPath, idForLogging, outerEncryption } = options; + const { idForLogging, outerEncryption } = options; let aesKey: Uint8Array; let macKey: Uint8Array; @@ -434,19 +442,27 @@ export async function decryptAttachmentV2ToSink( : undefined; let isPaddingAllZeros = false; - let readFd; + let readFd: FileHandle | undefined; let iv: Uint8Array | undefined; + let ciphertextStream: Readable; try { - try { - readFd = await open(ciphertextPath, 'r'); - } catch (cause) { - throw new Error(`${logId}: Read path doesn't exist`, { cause }); + if ('ciphertextPath' in options) { + try { + readFd = await open(options.ciphertextPath, 'r'); + ciphertextStream = readFd.createReadStream(); + } catch (cause) { + throw new Error(`${logId}: Read path doesn't exist`, { cause }); + } + } else if ('ciphertextStream' in options) { + ciphertextStream = options.ciphertextStream; + } else { + throw missingCaseError(options); } await pipeline( [ - readFd.createReadStream(), + ciphertextStream, maybeOuterEncryptionGetMacAndUpdateMac, maybeOuterEncryptionGetIvAndDecipher, peekAndUpdateHash(digest), diff --git a/ts/components/Lightbox.stories.tsx b/ts/components/Lightbox.stories.tsx index 8cc03e2bf4..cf89c6e43e 100644 --- a/ts/components/Lightbox.stories.tsx +++ b/ts/components/Lightbox.stories.tsx @@ -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 ( + + ); +} diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 6c7429fb36..6c1fbe045f 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -14,7 +14,7 @@ import type { SaveAttachmentActionCreatorType, } from '../state/ducks/conversations'; import type { LocalizerType } from '../types/Util'; -import type { MediaItemType, MediaItemMessageType } from '../types/MediaItem'; +import type { MediaItemType } from '../types/MediaItem'; import * as GoogleChrome from '../util/GoogleChrome'; import * as log from '../logging/log'; import * as Errors from '../types/errors'; @@ -22,7 +22,7 @@ import { Avatar, AvatarSize } from './Avatar'; import { IMAGE_PNG, isImage, isVideo } from '../types/MIME'; import { formatDateTimeForAttachment } from '../util/timestamp'; import { formatDuration } from '../util/formatDuration'; -import { isGIF } from '../types/Attachment'; +import { isGIF, isIncremental } from '../types/Attachment'; import { useRestoreFocus } from '../hooks/useRestoreFocus'; import { usePrevious } from '../hooks/usePrevious'; import { arrow } from '../util/keyboard'; @@ -31,6 +31,9 @@ import { isCmdOrCtrl } from '../hooks/useKeyboardShortcuts'; import type { ForwardMessagesPayload } from '../state/ducks/globalModals'; import { ForwardMessagesModalType } from './ForwardMessagesModal'; import { useReducedMotion } from '../hooks/useReducedMotion'; +import { formatFileSize } from '../util/formatFileSize'; +import { SECOND } from '../util/durations'; +import { Toast } from './Toast'; export type PropsType = { children?: ReactNode; @@ -53,6 +56,8 @@ export type PropsType = { const ZOOM_SCALE = 3; +const TWO_SECONDS = 2.5 * SECOND; + const INITIAL_IMAGE_TRANSFORM = { scale: 1, translateX: 0, @@ -103,6 +108,9 @@ export function Lightbox({ const [videoElement, setVideoElement] = useState( null ); + const [shouldShowDownloadToast, setShouldShowDownloadToast] = useState(false); + const downloadToastTimeout = useRef(); + const [videoTime, setVideoTime] = useState(); const [isZoomed, setIsZoomed] = useState(false); const containerRef = useRef(null); @@ -128,6 +136,55 @@ export function Lightbox({ | undefined >(); + const currentItem = media[selectedIndex]; + const { + attachment, + contentType, + loop = false, + objectURL, + incrementalObjectUrl, + } = currentItem || {}; + + const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined); + const isDownloading = + attachment && + isIncremental(attachment) && + attachment.pending && + !attachment.path; + + const onMouseLeaveVideo = useCallback(() => { + if (downloadToastTimeout.current) { + clearTimeout(downloadToastTimeout.current); + downloadToastTimeout.current = undefined; + } + if (!isDownloading) { + return; + } + + setShouldShowDownloadToast(false); + }, [isDownloading, setShouldShowDownloadToast]); + const onUserInteractionOnVideo = useCallback( + (event: React.MouseEvent) => { + if (downloadToastTimeout.current) { + clearTimeout(downloadToastTimeout.current); + downloadToastTimeout.current = undefined; + } + if (!isDownloading) { + return; + } + const elementRect = event.currentTarget.getBoundingClientRect(); + const bottomThreshold = elementRect.bottom - 75; + + setShouldShowDownloadToast(true); + + if (event.clientY >= bottomThreshold) { + return; + } + downloadToastTimeout.current = setTimeout(onMouseLeaveVideo, TWO_SECONDS); + }, + [isDownloading, onMouseLeaveVideo, setShouldShowDownloadToast] + ); + const onPrevious = useCallback( ( event: KeyboardEvent | React.MouseEvent @@ -179,9 +236,9 @@ export function Lightbox({ event.preventDefault(); const mediaItem = media[selectedIndex]; - const { attachment, message, index } = mediaItem; + const { attachment: attachmentToSave, message, index } = mediaItem; - saveAttachment(attachment, message.sentAt, index + 1); + saveAttachment(attachmentToSave, message.sentAt, index + 1); }, [isViewOnce, media, saveAttachment, selectedIndex] ); @@ -288,16 +345,6 @@ export function Lightbox({ }; }, [onKeyDown]); - const { - attachment, - contentType, - loop = false, - objectURL, - message, - } = media[selectedIndex] || {}; - - const isAttachmentGIF = isGIF(attachment ? [attachment] : undefined); - useEffect(() => { playVideo(); @@ -596,11 +643,13 @@ export function Lightbox({ ); } else if (isUnsupportedImageType || isUnsupportedVideoType) { @@ -671,7 +720,7 @@ export function Lightbox({ ) : (
@@ -713,6 +762,28 @@ export function Lightbox({ ), }} > + {isDownloading ? ( +
+ + {attachment.totalDownloaded && attachment.size + ? i18n('icu:lightBoxDownloading', { + downloaded: formatFileSize( + attachment.totalDownloaded, + 2 + ), + total: formatFileSize(attachment.size, 2), + }) + : undefined} + +
+ ) : null} {content} {hasPrevious && ( @@ -797,12 +868,13 @@ export function Lightbox({ function LightboxHeader({ getConversation, i18n, - message, + item, }: { getConversation: (id: string) => ConversationType; i18n: LocalizerType; - message: ReadonlyDeep; + item: ReadonlyDeep; }): JSX.Element { + const { message } = item; const conversation = getConversation(message.conversationId); const now = Date.now(); diff --git a/ts/components/conversation/AttachmentDetailPill.stories.tsx b/ts/components/conversation/AttachmentDetailPill.stories.tsx index 17af7c41ff..941875e694 100644 --- a/ts/components/conversation/AttachmentDetailPill.stories.tsx +++ b/ts/components/conversation/AttachmentDetailPill.stories.tsx @@ -89,3 +89,87 @@ export function OneNotPendingSomeDownloaded(args: PropsType): JSX.Element { /> ); } + +export function OneIncrementalDownloadedBlank(args: PropsType): JSX.Element { + return ( + + ); +} + +export function OneIncrementalNotPendingNotDownloaded( + args: PropsType +): JSX.Element { + return ( + + ); +} + +export function OneIncrementalPendingNotDownloading( + args: PropsType +): JSX.Element { + return ( + + ); +} + +export function OneIncrementalDownloading(args: PropsType): JSX.Element { + return ( + + ); +} + +export function OneIncrementalNotPendingSomeDownloaded( + args: PropsType +): JSX.Element { + return ( + + ); +} diff --git a/ts/components/conversation/AttachmentDetailPill.tsx b/ts/components/conversation/AttachmentDetailPill.tsx index 361197ef45..52ed0c4cf9 100644 --- a/ts/components/conversation/AttachmentDetailPill.tsx +++ b/ts/components/conversation/AttachmentDetailPill.tsx @@ -2,11 +2,15 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import classNames from 'classnames'; import { formatFileSize } from '../../util/formatFileSize'; +import { ProgressCircle } from '../ProgressCircle'; import type { AttachmentForUIType } from '../../types/Attachment'; import type { LocalizerType } from '../../types/I18N'; +import { Spinner } from '../Spinner'; +import { isKeyboardActivation } from '../../hooks/useKeyboardShortcuts'; export type PropsType = { attachments: ReadonlyArray; @@ -18,7 +22,10 @@ export type PropsType = { export function AttachmentDetailPill({ attachments, + cancelDownload, + i18n, isGif, + startDownload, }: PropsType): JSX.Element | null { const areAllDownloaded = attachments.every(attachment => attachment.path); const totalSize = attachments.reduce( @@ -28,10 +35,54 @@ export function AttachmentDetailPill({ 0 ); + const startDownloadClick = React.useCallback( + (event: React.MouseEvent) => { + if (startDownload) { + event.preventDefault(); + event.stopPropagation(); + startDownload(); + } + }, + [startDownload] + ); + const startDownloadKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + 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) => { + if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) { + event.preventDefault(); + event.stopPropagation(); + cancelDownload(); + } + }, + [cancelDownload] + ); + if (areAllDownloaded || totalSize === 0) { return null; } + const areAnyIncremental = attachments.some( + attachment => attachment.incrementalMac && attachment.chunkSize + ); const totalDownloadedSize = attachments.reduce( (total: number, attachment: AttachmentForUIType) => { return ( @@ -43,6 +94,99 @@ export function AttachmentDetailPill({ ); const areAnyPending = attachments.some(attachment => attachment.pending); + if (areAnyIncremental) { + let ariaLabel: string; + let onClick: (event: React.MouseEvent) => void; + let onKeyDown: (event: React.KeyboardEvent) => void; + let control: JSX.Element; + let text: JSX.Element; + + if (!areAnyPending && totalDownloadedSize > 0) { + ariaLabel = i18n('icu:retryDownload'); + onClick = startDownloadClick; + onKeyDown = startDownloadKeyDown; + control = ( +
+
+
+ ); + text = ( +
+ {i18n('icu:retryDownloadShort')} +
+ ); + } else if (!areAnyPending) { + ariaLabel = i18n('icu:startDownload'); + onClick = startDownloadClick; + onKeyDown = startDownloadKeyDown; + control = ( +
+
+
+ ); + text = ( +
+ {formatFileSize(totalSize, 2)} +
+ ); + } else if (totalDownloadedSize > 0) { + const downloadFraction = totalDownloadedSize / totalSize; + + ariaLabel = i18n('icu:cancelDownload'); + onClick = cancelDownloadClick; + onKeyDown = cancelDownloadKeyDown; + control = ( +
+ +
+
+ ); + text = ( +
+ {totalDownloadedSize > 0 && areAnyPending + ? `${formatFileSize(totalDownloadedSize, 2)} / ` + : undefined} + {formatFileSize(totalSize, 2)} +
+ ); + } else { + ariaLabel = i18n('icu:cancelDownload'); + onClick = cancelDownloadClick; + onKeyDown = cancelDownloadKeyDown; + control = ( +
+ +
+
+ ); + text = ( +
+ {formatFileSize(totalSize, 2)} +
+ ); + } + + return ( + + ); + } + return (
diff --git a/ts/components/conversation/Image.stories.tsx b/ts/components/conversation/Image.stories.tsx index 4b8a620a51..c65f446a65 100644 --- a/ts/components/conversation/Image.stories.tsx +++ b/ts/components/conversation/Image.stories.tsx @@ -173,6 +173,82 @@ export function NotPendingWDownloadProgress(): JSX.Element { return ; } +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 ; +} + +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 ; +} + +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 ; +} + +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 ; +} + export function CurvedCorners(): JSX.Element { const props = createProps({ curveBottomLeft: CurveType.Normal, diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index ec6a6ff92b..f595dd1ded 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -12,7 +12,11 @@ import type { AttachmentForUIType, AttachmentType, } from '../../types/Attachment'; -import { defaultBlurHash, isReadyToView } from '../../types/Attachment'; +import { + defaultBlurHash, + isIncremental, + isReadyToView, +} from '../../types/Attachment'; import { ProgressCircle } from '../ProgressCircle'; export enum CurveType { @@ -180,7 +184,10 @@ export function Image({ ); const startDownloadButton = - startDownload && !attachment.path && !attachment.pending ? ( + startDownload && + !attachment.path && + !attachment.pending && + !isIncremental(attachment) ? (