From 9c97d3e73ceacb8f628df39bbc8f3c5adbbae70d Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Tue, 23 Sep 2025 11:53:41 -0700 Subject: [PATCH] Show ready-to-download documents in media gallery --- ts/AttachmentCrypto.ts | 38 +----- ts/Crypto.ts | 2 +- .../AttachmentStatusIcon.stories.tsx | 10 -- .../conversation/AttachmentStatusIcon.tsx | 94 ++------------ ts/components/conversation/Message.tsx | 19 +-- ts/components/conversation/contactUtil.tsx | 32 +++-- .../media-gallery/AttachmentSection.tsx | 12 +- .../DocumentListItem.stories.tsx | 3 + .../media-gallery/DocumentListItem.tsx | 84 ++++++++++++- .../media-gallery/MediaGallery.tsx | 18 ++- .../media-gallery/MediaGridItem.stories.tsx | 2 +- .../media-gallery/MediaGridItem.tsx | 67 ++++++---- .../media-gallery/types/ItemClickEvent.ts | 2 + .../conversation/media-gallery/utils/mocks.ts | 2 + ts/hooks/useAttachmentStatus.ts | 88 +++++++++++++ ts/hooks/useDelayedValue.ts | 63 ++++++++++ ts/jobs/AttachmentBackupManager.ts | 2 +- ts/jobs/AttachmentDownloadManager.ts | 6 +- ts/sql/Interface.ts | 1 + ts/sql/Server.ts | 29 ++++- ts/state/ducks/mediaGallery.ts | 119 ++++++------------ ts/test-electron/Crypto_test.ts | 6 +- .../AttachmentDownloadManager_test.ts | 6 +- ts/test-node/util/logPadding_test.ts | 3 +- ts/textsecure/downloadAttachment.ts | 2 +- ts/util/AttachmentCrypto.ts | 44 +++++++ ts/util/lint/exceptions.json | 21 ++-- ts/util/logPadSize.ts | 9 ++ ts/util/logPadding.ts | 9 +- ts/util/processAttachment.ts | 2 +- 30 files changed, 481 insertions(+), 314 deletions(-) create mode 100644 ts/hooks/useAttachmentStatus.ts create mode 100644 ts/hooks/useDelayedValue.ts create mode 100644 ts/util/AttachmentCrypto.ts create mode 100644 ts/util/logPadSize.ts diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index db28ad4fa83..263b16a21a2 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -39,7 +39,7 @@ import { } from './types/Crypto.js'; import { constantTimeEqual } from './Crypto.js'; import { createName, getRelativePath } from './util/attachmentPath.js'; -import { appendPaddingStream, logPadSize } from './util/logPadding.js'; +import { appendPaddingStream } from './util/logPadding.js'; import { prependStream } from './util/prependStream.js'; import { appendMacStream } from './util/appendMacStream.js'; import { finalStream } from './util/finalStream.js'; @@ -52,6 +52,7 @@ import { missingCaseError } from './util/missingCaseError.js'; import { getEnvironment, Environment } from './environment.js'; import { isNotEmpty, toBase64, toHex } from './Bytes.js'; import { decipherWithAesKey } from './util/decipherWithAesKey.js'; +import { getAttachmentCiphertextSize } from './util/AttachmentCrypto.js'; import { MediaTier } from './types/AttachmentDownload.js'; const { ensureFile } = fsExtra; @@ -670,41 +671,6 @@ export function measureSize({ return passthrough; } -export function getAttachmentCiphertextSize({ - unpaddedPlaintextSize, - mediaTier, -}: { - unpaddedPlaintextSize: number; - mediaTier: MediaTier; -}): number { - const paddedSize = logPadSize(unpaddedPlaintextSize); - - switch (mediaTier) { - case MediaTier.STANDARD: - return getCiphertextSize(paddedSize); - case MediaTier.BACKUP: - // objects on backup tier are doubly encrypted! - return getCiphertextSize(getCiphertextSize(paddedSize)); - default: - throw missingCaseError(mediaTier); - } -} - -export function getCiphertextSize(paddedPlaintextSize: number): number { - return ( - IV_LENGTH + - getAesCbcCiphertextSize(paddedPlaintextSize) + - ATTACHMENT_MAC_LENGTH - ); -} - -export function getAesCbcCiphertextSize(plaintextSize: number): number { - const AES_CBC_BLOCK_SIZE = 16; - return ( - (1 + Math.floor(plaintextSize / AES_CBC_BLOCK_SIZE)) * AES_CBC_BLOCK_SIZE - ); -} - function checkIntegrity({ locallyCalculatedDigest, locallyCalculatedPlaintextHash, diff --git a/ts/Crypto.ts b/ts/Crypto.ts index 2fccd940fe6..d6c6c317d0e 100644 --- a/ts/Crypto.ts +++ b/ts/Crypto.ts @@ -14,7 +14,7 @@ import { HashType, CipherType } from './types/Crypto.js'; import { AVATAR_COLOR_COUNT, AvatarColors } from './types/Colors.js'; import { ProfileDecryptError } from './types/errors.js'; import { getBytesSubarray } from './util/uuidToBytes.js'; -import { logPadSize } from './util/logPadding.js'; +import { logPadSize } from './util/logPadSize.js'; import { Environment, getEnvironment } from './environment.js'; import { toWebSafeBase64 } from './util/webSafeBase64.js'; diff --git a/ts/components/conversation/AttachmentStatusIcon.stories.tsx b/ts/components/conversation/AttachmentStatusIcon.stories.tsx index f4d5ad4005c..5a02e7d98d9 100644 --- a/ts/components/conversation/AttachmentStatusIcon.stories.tsx +++ b/ts/components/conversation/AttachmentStatusIcon.stories.tsx @@ -26,16 +26,6 @@ export function Default(args: PropsType): JSX.Element { ); } -export function NoAttachment(args: PropsType): JSX.Element { - return ( -
- - 🔥🔥 - -
- ); -} - export function NeedsDownload(args: PropsType): JSX.Element { return (
diff --git a/ts/components/conversation/AttachmentStatusIcon.tsx b/ts/components/conversation/AttachmentStatusIcon.tsx index fab9112d960..33f354a68bf 100644 --- a/ts/components/conversation/AttachmentStatusIcon.tsx +++ b/ts/components/conversation/AttachmentStatusIcon.tsx @@ -1,90 +1,29 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useRef, useState, type ReactNode } from 'react'; +import React, { type ReactNode } from 'react'; import classNames from 'classnames'; import { SpinnerV2 } from '../SpinnerV2.js'; -import { usePrevious } from '../../hooks/usePrevious.js'; import type { AttachmentForUIType } from '../../types/Attachment.js'; - -const TRANSITION_DELAY = 200; +import { missingCaseError } from '../../util/missingCaseError.js'; +import { useAttachmentStatus } from '../../hooks/useAttachmentStatus.js'; export type PropsType = { - attachment: AttachmentForUIType | undefined; - isExpired?: boolean; + attachment: AttachmentForUIType; isIncoming: boolean; children?: ReactNode; }; -enum IconState { - NeedsDownload = 'NeedsDownload', - Downloading = 'Downloading', - Downloaded = 'Downloaded', -} - export function AttachmentStatusIcon({ attachment, - isExpired, isIncoming, children, }: PropsType): JSX.Element | null { - const [isWaiting, setIsWaiting] = useState(false); + const status = useAttachmentStatus(attachment); - const isAttachmentNotAvailable = - isExpired || - (attachment != null && - attachment.isPermanentlyUndownloadable && - !attachment.wasTooBig); - - let state: IconState = IconState.Downloaded; - if (attachment && isAttachmentNotAvailable) { - state = IconState.Downloaded; - } else if (attachment && !attachment.path && !attachment.pending) { - state = IconState.NeedsDownload; - } else if (attachment && !attachment.path && attachment.pending) { - state = IconState.Downloading; - } - - const timerRef = useRef(); - const previousState = usePrevious(state, state); - - // We need useLayoutEffect; otherwise we might get a flash of the wrong visual state. - // We do calculations here which change the UI! - React.useLayoutEffect(() => { - if (state === previousState) { - return; - } - - if ( - previousState === IconState.NeedsDownload && - state === IconState.Downloading - ) { - setIsWaiting(true); - if (timerRef.current) { - clearTimeout(timerRef.current); - } - timerRef.current = setTimeout(() => { - timerRef.current = undefined; - setIsWaiting(false); - }, TRANSITION_DELAY); - } else if ( - previousState === IconState.Downloading && - state === IconState.Downloaded - ) { - setIsWaiting(true); - if (timerRef.current) { - clearTimeout(timerRef.current); - } - timerRef.current = setTimeout(() => { - timerRef.current = undefined; - setIsWaiting(false); - }, TRANSITION_DELAY); - } - }, [previousState, state]); - - if (attachment && state === IconState.NeedsDownload) { + if (status.state === 'NeedsDownload') { return (
@@ -165,5 +93,9 @@ export function AttachmentStatusIcon({ ); } - return
{children}
; + if (status.state === 'ReadyToShow') { + return
{children}
; + } + + throw missingCaseError(status); } diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 675bd084f8b..a60156b52d9 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -2869,14 +2869,17 @@ export class Message extends React.PureComponent { : null )} > - - {this.renderTapToViewIcon()} - + {isExpired || firstAttachment == null ? ( + this.renderTapToViewIcon() + ) : ( + + {this.renderTapToViewIcon()} + + )} {content}
); diff --git a/ts/components/conversation/contactUtil.tsx b/ts/components/conversation/contactUtil.tsx index 513c713f602..51146e96c98 100644 --- a/ts/components/conversation/contactUtil.tsx +++ b/ts/components/conversation/contactUtil.tsx @@ -28,23 +28,31 @@ export function renderAvatar({ const avatarUrl = avatar && avatar.avatar && avatar.avatar.path; const title = getName(contact) || ''; + const fallback = ( + + ); + + const attachment = avatar?.avatar; + if (attachment == null) { + return fallback; + } return ( - + {fallback} ); } diff --git a/ts/components/conversation/media-gallery/AttachmentSection.tsx b/ts/components/conversation/media-gallery/AttachmentSection.tsx index 3e5d0fa57a7..30a5e67370e 100644 --- a/ts/components/conversation/media-gallery/AttachmentSection.tsx +++ b/ts/components/conversation/media-gallery/AttachmentSection.tsx @@ -8,6 +8,7 @@ import type { LocalizerType, ThemeType } from '../../../types/Util.js'; import type { MediaItemType } from '../../../types/MediaItem.js'; import { DocumentListItem } from './DocumentListItem.js'; import { MediaGridItem } from './MediaGridItem.js'; +import type { AttachmentStatusType } from '../../../hooks/useAttachmentStatus.js'; import { missingCaseError } from '../../../util/missingCaseError.js'; import { tw } from '../../../axo/tw.js'; @@ -37,9 +38,8 @@ export function AttachmentSection({ {mediaItems.map(mediaItem => { const { message, index, attachment } = mediaItem; - const onClick = (ev: React.MouseEvent) => { - ev.preventDefault(); - onItemClick({ type, message, attachment }); + const onClick = (state: AttachmentStatusType['state']) => { + onItemClick({ type, message, attachment, state }); }; return ( @@ -69,13 +69,13 @@ export function AttachmentSection({ {mediaItems.map(mediaItem => { const { message, index, attachment } = mediaItem; - const onClick = (ev: React.MouseEvent) => { - ev.preventDefault(); - onItemClick({ type, message, attachment }); + const onClick = (state: AttachmentStatusType['state']) => { + onItemClick({ type, message, attachment, state }); }; return ( ; +const { i18n } = window.SignalContext; + export function Multiple(): JSX.Element { const items = createPreparedMediaItems(createRandomDocuments); @@ -22,6 +24,7 @@ export function Multiple(): JSX.Element { <> {items.map(mediaItem => ( void; + onClick?: (status: AttachmentStatusType['state']) => void; }; -export function DocumentListItem({ mediaItem, onClick }: Props): JSX.Element { +export function DocumentListItem({ + i18n, + mediaItem, + onClick, +}: Props): JSX.Element { const { attachment, message } = mediaItem; const { fileName, size: fileSize } = attachment; const timestamp = message.receivedAtMs || message.receivedAt; + let label: string; + + const status = useAttachmentStatus(attachment); + + const handleClick = useCallback( + (ev: React.MouseEvent) => { + ev.preventDefault(); + onClick?.(status.state); + }, + [onClick, status.state] + ); + + if (status.state === 'NeedsDownload') { + label = i18n('icu:downloadAttachment'); + } else if (status.state === 'Downloading') { + label = i18n('icu:cancelDownload'); + } else if (status.state === 'ReadyToShow') { + label = i18n('icu:startDownload'); + } else { + throw missingCaseError(status); + } + + let glyph: JSX.Element | undefined; + let button: JSX.Element | undefined; + if (status.state !== 'ReadyToShow') { + glyph = ( + <> + +   + + ); + button = ( +
+ {status.state === 'Downloading' && ( + + )} +
+ +
+
+ ); + } + return ( ); } diff --git a/ts/components/conversation/media-gallery/MediaGallery.tsx b/ts/components/conversation/media-gallery/MediaGallery.tsx index 89ed35c6ba5..8d39463a929 100644 --- a/ts/components/conversation/media-gallery/MediaGallery.tsx +++ b/ts/components/conversation/media-gallery/MediaGallery.tsx @@ -127,20 +127,30 @@ function MediaSection({ onItemClick={(event: ItemClickEvent) => { switch (event.type) { case 'documents': { - saveAttachment(event.attachment, event.message.sentAt); + if (event.state === 'ReadyToShow') { + saveAttachment(event.attachment, event.message.sentAt); + } else if (event.state === 'Downloading') { + cancelAttachmentDownload({ messageId: event.message.id }); + } else if (event.state === 'NeedsDownload') { + kickOffAttachmentDownload({ messageId: event.message.id }); + } else { + throw missingCaseError(event.state); + } break; } case 'media': { - if (event.attachment.url || event.attachment.incrementalUrl) { + if (event.state === 'ReadyToShow') { showLightbox({ attachment: event.attachment, messageId: event.message.id, }); - } else if (event.attachment.pending) { + } else if (event.state === 'Downloading') { cancelAttachmentDownload({ messageId: event.message.id }); - } else { + } else if (event.state === 'NeedsDownload') { kickOffAttachmentDownload({ messageId: event.message.id }); + } else { + throw missingCaseError(event.state); } break; } diff --git a/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx b/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx index 4c9e3df6c54..a6b91c57de0 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.stories.tsx @@ -175,7 +175,7 @@ export function PendingImage(): JSX.Element { size: 123000, blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', isPermanentlyUndownloadable: false, - totalDownloaded: 0, + totalDownloaded: undefined, pending: true, }, }); diff --git a/ts/components/conversation/media-gallery/MediaGridItem.tsx b/ts/components/conversation/media-gallery/MediaGridItem.tsx index f5b8c327db4..c2fc83c10b9 100644 --- a/ts/components/conversation/media-gallery/MediaGridItem.tsx +++ b/ts/components/conversation/media-gallery/MediaGridItem.tsx @@ -1,11 +1,12 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useCallback } from 'react'; import type { ReadonlyDeep } from 'type-fest'; import { formatFileSize } from '../../../util/formatFileSize.js'; import { formatDuration } from '../../../util/formatDuration.js'; +import { missingCaseError } from '../../../util/missingCaseError.js'; import type { LocalizerType, ThemeType } from '../../../types/Util.js'; import type { MediaItemType } from '../../../types/MediaItem.js'; import type { AttachmentForUIType } from '../../../types/Attachment.js'; @@ -20,10 +21,14 @@ import { ImageOrBlurhash } from '../../ImageOrBlurhash.js'; import { SpinnerV2 } from '../../SpinnerV2.js'; import { tw } from '../../../axo/tw.js'; import { AxoSymbol } from '../../../axo/AxoSymbol.js'; +import { + useAttachmentStatus, + type AttachmentStatusType, +} from '../../../hooks/useAttachmentStatus.js'; export type Props = Readonly<{ mediaItem: ReadonlyDeep; - onClick?: (ev: React.MouseEvent) => void; + onClick?: (attachmentState: AttachmentStatusType['state']) => void; i18n: LocalizerType; theme?: ThemeType; }>; @@ -38,6 +43,7 @@ export function MediaGridItem(props: Props): JSX.Element { const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme); const url = getUrl(attachment); + const status = useAttachmentStatus(attachment); const { width, height } = attachment; @@ -53,14 +59,25 @@ export function MediaGridItem(props: Props): JSX.Element { ); let label: string; - if (url != null) { + if (status.state === 'ReadyToShow') { label = i18n('icu:imageOpenAlt'); - } else if (attachment.pending) { + } else if (status.state === 'Downloading') { label = i18n('icu:cancelDownload'); - } else { + } else if (status.state === 'NeedsDownload') { label = i18n('icu:startDownload'); + } else { + throw missingCaseError(status); } + const handleClick = useCallback( + (ev: React.MouseEvent) => { + ev.preventDefault(); + + onClick?.(status.state); + }, + [onClick, status.state] + ); + return ( ); } type SpinnerOverlayProps = Readonly<{ - attachment: AttachmentForUIType; + status: AttachmentStatusType; }>; function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined { - const { attachment } = props; + const { status } = props; - const url = getUrl(attachment); - if (url != null) { + if (status.state === 'ReadyToShow') { return undefined; } - const spinnerValue = - (!attachment.incrementalUrl && - attachment.size && - attachment.totalDownloaded) || - undefined; - return (
- {attachment.pending && ( + {status.state === 'Downloading' && ( )}
@@ -128,20 +138,23 @@ function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined { type MetadataOverlayProps = Readonly<{ i18n: LocalizerType; + status: AttachmentStatusType; attachment: AttachmentForUIType; }>; function MetadataOverlay(props: MetadataOverlayProps): JSX.Element | undefined { - const { i18n, attachment } = props; + const { i18n, status, attachment } = props; - const url = getUrl(attachment); - const canBeShown = url != null; - if (canBeShown && !isGIF([attachment]) && !isVideoAttachment(attachment)) { + if ( + status.state === 'ReadyToShow' && + !isGIF([attachment]) && + !isVideoAttachment(attachment) + ) { return undefined; } let text: string; - if (isGIF([attachment]) && canBeShown) { + if (isGIF([attachment]) && status.state === 'ReadyToShow') { text = i18n('icu:message--getNotificationText--gif'); } else if (isVideoAttachment(attachment) && attachment.duration != null) { text = formatDuration(attachment.duration); diff --git a/ts/components/conversation/media-gallery/types/ItemClickEvent.ts b/ts/components/conversation/media-gallery/types/ItemClickEvent.ts index b22f849d6a7..811f392f3a3 100644 --- a/ts/components/conversation/media-gallery/types/ItemClickEvent.ts +++ b/ts/components/conversation/media-gallery/types/ItemClickEvent.ts @@ -2,9 +2,11 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { AttachmentType } from '../../../../types/Attachment.js'; +import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.js'; export type ItemClickEvent = { message: { id: string; sentAt: number }; attachment: AttachmentType; type: 'media' | 'documents'; + state: AttachmentStatusType['state']; }; diff --git a/ts/components/conversation/media-gallery/utils/mocks.ts b/ts/components/conversation/media-gallery/utils/mocks.ts index 6d4f2724bb9..d16a8a61f9f 100644 --- a/ts/components/conversation/media-gallery/utils/mocks.ts +++ b/ts/components/conversation/media-gallery/utils/mocks.ts @@ -33,6 +33,7 @@ function createRandomFile( const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`; const isDownloaded = Math.random() > 0.4; + const isPending = !isDownloaded && Math.random() > 0.5; return { message: { @@ -46,6 +47,7 @@ function createRandomFile( attachment: { url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined, path: isDownloaded ? 'abc' : undefined, + pending: isPending, screenshot: fileExtension === 'mp4' ? { diff --git a/ts/hooks/useAttachmentStatus.ts b/ts/hooks/useAttachmentStatus.ts new file mode 100644 index 00000000000..6056493c6e1 --- /dev/null +++ b/ts/hooks/useAttachmentStatus.ts @@ -0,0 +1,88 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { getUrl, type AttachmentForUIType } from '../types/Attachment.js'; +import { MediaTier } from '../types/AttachmentDownload.js'; +import { missingCaseError } from '../util/missingCaseError.js'; +import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js'; +import { useDelayedValue } from './useDelayedValue.js'; + +const TRANSITION_DELAY = 200; + +type InternalState = 'NeedsDownload' | 'Downloading' | 'ReadyToShow'; + +export type AttachmentStatusType = Readonly< + | { + state: 'NeedsDownload'; + } + | { + state: 'Downloading'; + totalDownloaded: number | undefined; + size: number; + } + | { + state: 'ReadyToShow'; + } +>; + +export function useAttachmentStatus( + attachment: AttachmentForUIType +): AttachmentStatusType { + const isAttachmentNotAvailable = + attachment.isPermanentlyUndownloadable && !attachment.wasTooBig; + + const url = getUrl(attachment); + + let nextState: InternalState = 'ReadyToShow'; + if (attachment && isAttachmentNotAvailable) { + nextState = 'ReadyToShow'; + } else if (attachment && url == null && !attachment.pending) { + nextState = 'NeedsDownload'; + } else if (attachment && url == null && attachment.pending) { + nextState = 'Downloading'; + } + + const state = useDelayedValue(nextState, TRANSITION_DELAY); + + // Idle + if (state === 'NeedsDownload' && nextState === state) { + return { state: 'NeedsDownload' }; + } + + const { size: unpaddedPlaintextSize, totalDownloaded } = attachment; + const size = getAttachmentCiphertextSize({ + unpaddedPlaintextSize, + mediaTier: MediaTier.STANDARD, + }); + + // Transition + if (state !== nextState) { + if (nextState === 'NeedsDownload') { + return { state: 'NeedsDownload' }; + } + + if (nextState === 'Downloading') { + return { state: 'Downloading', size, totalDownloaded }; + } + + if (nextState === 'ReadyToShow') { + return { state: 'Downloading', size, totalDownloaded: size }; + } + + throw missingCaseError(nextState); + } + + if (state === 'NeedsDownload') { + return { state: 'NeedsDownload' }; + } + + if (state === 'Downloading') { + return { state: 'Downloading', size, totalDownloaded }; + } + + if (state === 'ReadyToShow') { + return { state: 'ReadyToShow' }; + } + + throw missingCaseError(state); +} diff --git a/ts/hooks/useDelayedValue.ts b/ts/hooks/useDelayedValue.ts new file mode 100644 index 00000000000..b5609f91b2e --- /dev/null +++ b/ts/hooks/useDelayedValue.ts @@ -0,0 +1,63 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { useState, useEffect } from 'react'; +import { noop } from 'lodash'; + +type InternalState = Readonly< + | { + type: 'transition'; + from: Value; + to: Value; + } + | { + type: 'idle'; + value: Value; + } +>; + +export function useDelayedValue(newValue: Value, delay: number): Value { + const [state, setState] = useState>({ + type: 'idle', + value: newValue, + }); + + const currentValue = state.type === 'idle' ? state.value : state.from; + + useEffect(() => { + if (state.type === 'idle') { + return noop; + } + + const timer = setTimeout(() => { + setState({ + type: 'idle', + value: state.to, + }); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [state, delay]); + + useEffect(() => { + setState(prevState => { + if (prevState.type === 'transition') { + return { + type: 'transition', + from: prevState.from, + to: newValue, + }; + } + + return { + type: 'transition', + from: prevState.value, + to: newValue, + }; + }); + }, [newValue]); + + return currentValue; +} diff --git a/ts/jobs/AttachmentBackupManager.ts b/ts/jobs/AttachmentBackupManager.ts index d7b489c4b24..8736a4c44ec 100644 --- a/ts/jobs/AttachmentBackupManager.ts +++ b/ts/jobs/AttachmentBackupManager.ts @@ -24,7 +24,6 @@ import { import { type EncryptedAttachmentV2, decryptAttachmentV2ToSink, - getAttachmentCiphertextSize, } from '../AttachmentCrypto.js'; import { getBackupMediaRootKey, @@ -39,6 +38,7 @@ import { } from '../types/AttachmentBackup.js'; import { isInCall as isInCallSelector } from '../state/selectors/calling.js'; import { encryptAndUploadAttachment } from '../util/uploadAttachment.js'; +import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js'; import { getMediaIdFromMediaName, getMediaNameForAttachmentThumbnail, diff --git a/ts/jobs/AttachmentDownloadManager.ts b/ts/jobs/AttachmentDownloadManager.ts index 5cdeeb88a4b..a24987cedaa 100644 --- a/ts/jobs/AttachmentDownloadManager.ts +++ b/ts/jobs/AttachmentDownloadManager.ts @@ -52,10 +52,7 @@ import { import { IMAGE_WEBP } from '../types/MIME.js'; import { AttachmentDownloadSource } from '../sql/Interface.js'; import { drop } from '../util/drop.js'; -import { - getAttachmentCiphertextSize, - type ReencryptedAttachmentV2, -} from '../AttachmentCrypto.js'; +import { type ReencryptedAttachmentV2 } from '../AttachmentCrypto.js'; import { safeParsePartial } from '../util/schemas.js'; import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue.js'; import { createBatcher } from '../util/batcher.js'; @@ -68,6 +65,7 @@ import { } from './helpers/attachmentBackfill.js'; import { formatCountForLogging } from '../logging/formatCountForLogging.js'; import { strictAssert } from '../util/assert.js'; +import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js'; import { updateBackupMediaDownloadProgress } from '../util/updateBackupMediaDownloadProgress.js'; import { HTTPError } from '../textsecure/Errors.js'; import { isOlderThan } from '../util/timestamp.js'; diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 56a0bc9bccd..2b0bd26ee58 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -581,6 +581,7 @@ export type GetOlderMediaOptionsType = Readonly<{ messageId?: string; receivedAt?: number; sentAt?: number; + type: 'media' | 'files'; }>; export type MediaItemDBType = Readonly<{ diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index b38475754e4..604aa39e0a1 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -36,6 +36,7 @@ import { STORAGE_UI_KEYS } from '../types/StorageUIKeys.js'; import type { StoryDistributionIdString } from '../types/StoryDistributionId.js'; import * as Errors from '../types/errors.js'; import { assertDev, strictAssert } from '../util/assert.js'; +import { missingCaseError } from '../util/missingCaseError.js'; import { combineNames } from '../util/combineNames.js'; import { consoleLogger } from '../util/consoleLogger.js'; import { @@ -5097,6 +5098,7 @@ function getOlderMedia( messageId, receivedAt: maxReceivedAt = Number.MAX_VALUE, sentAt: maxSentAt = Number.MAX_VALUE, + type, }: GetOlderMediaOptionsType ): Array { const timeFilters = { @@ -5104,6 +5106,27 @@ function getOlderMedia( second: sqlFragment`receivedAt < ${maxReceivedAt}`, }; + let contentFilter: QueryFragment; + if (type === 'media') { + // see 'isVisualMedia' in ts/types/Attachment.ts + contentFilter = sqlFragment` + contentType LIKE 'image/%' OR + contentType LIKE 'video/%' + `; + } else if (type === 'files') { + // see 'isFile' in ts/types/Attachment.ts + contentFilter = sqlFragment` + contentType IS NOT NULL AND + contentType IS NOT '' AND + contentType IS NOT 'text/x-signal-plain' AND + contentType NOT LIKE 'audio/%' AND + contentType NOT LIKE 'image/%' AND + contentType NOT LIKE 'video/%' + `; + } else { + throw missingCaseError(type); + } + const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment` SELECT * @@ -5116,11 +5139,7 @@ function getOlderMedia( ( ${timeFilter} ) AND - ( - -- see 'isVisualMedia' in ts/types/Attachment.ts - contentType LIKE 'image/%' OR - contentType LIKE 'video/%' - ) AND + (${contentFilter}) AND isViewOnce IS NOT 1 AND messageType IN ('incoming', 'outgoing') AND (${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null}) diff --git a/ts/state/ducks/mediaGallery.ts b/ts/state/ducks/mediaGallery.ts index 84d6e72b69e..7f46b7fde10 100644 --- a/ts/state/ducks/mediaGallery.ts +++ b/ts/state/ducks/mediaGallery.ts @@ -14,7 +14,6 @@ import { MESSAGE_DELETED, MESSAGE_EXPIRED, } from './conversations.js'; -import { isNotNil } from '../../util/isNotNil.js'; import { useBoundActions } from '../../hooks/useBoundActions.js'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.js'; @@ -25,24 +24,14 @@ import type { MessageExpiredActionType, } from './conversations.js'; import type { MediaItemType } from '../../types/MediaItem.js'; +import { isFile, isVisualMedia } from '../../types/Attachment.js'; import type { StateType as RootStateType } from '../reducer.js'; -import type { MessageAttributesType, MessageType } from '../../model-types.js'; -import { isTapToView, getPropsForAttachment } from '../selectors/message.js'; +import { getPropsForAttachment } from '../selectors/message.js'; const { orderBy } = lodash; const log = createLogger('mediaGallery'); -type MediaItemMessage = ReadonlyDeep<{ - // Note that this reflects the sender, and not the parent conversation - conversationId: string; - type: MessageType; - id: string; - receivedAt: number; - receivedAtMs: number; - sentAt: number; -}>; - export type MediaGalleryStateType = ReadonlyDeep<{ conversationId: string | undefined; documents: ReadonlyArray; @@ -114,25 +103,7 @@ function _sortDocuments( return orderBy(documents, ['message.receivedAt', 'message.sentAt']); } -function _getMediaItemMessage( - message: ReadonlyDeep -): MediaItemMessage { - return { - conversationId: - window.ConversationController.lookupOrCreate({ - serviceId: message.sourceServiceId, - e164: message.source, - reason: 'conversation_view.showAllMedia', - })?.id || message.conversationId, - type: message.type, - id: message.id, - receivedAt: message.received_at, - receivedAtMs: Number(message.received_at_ms), - sentAt: message.sent_at, - }; -} - -function _cleanVisualAttachments( +function _cleanAttachments( rawMedia: ReadonlyArray ): ReadonlyArray { return rawMedia.map(({ message, index, attachment }) => { @@ -144,30 +115,6 @@ function _cleanVisualAttachments( }); } -function _cleanFileAttachments( - rawDocuments: ReadonlyDeep> -): ReadonlyArray { - return rawDocuments - .map(message => { - if (isTapToView(message)) { - return; - } - - const attachments = message.attachments || []; - const attachment = attachments[0]; - if (!attachment) { - return; - } - - return { - index: 0, - attachment: getPropsForAttachment(attachment, 'attachment', message), - message: _getMediaItemMessage(message), - }; - }) - .filter(isNotNil); -} - function initialLoad( conversationId: string ): ThunkAction< @@ -185,17 +132,16 @@ function initialLoad( const rawMedia = await DataReader.getOlderMedia({ conversationId, limit: FETCH_CHUNK_COUNT, + type: 'media', }); - const rawDocuments = await DataReader.getOlderMessagesByConversation({ + const rawDocuments = await DataReader.getOlderMedia({ conversationId, - includeStoryReplies: false, limit: FETCH_CHUNK_COUNT, - requireFileAttachments: true, - storyId: undefined, + type: 'files', }); - const media = _cleanVisualAttachments(rawMedia); - const documents = _cleanFileAttachments(rawDocuments); + const media = _cleanAttachments(rawMedia); + const documents = _cleanAttachments(rawDocuments); dispatch({ type: INITIAL_LOAD, @@ -246,9 +192,10 @@ function loadMoreMedia( messageId, receivedAt, sentAt, + type: 'media', }); - const media = _cleanVisualAttachments(rawMedia); + const media = _cleanAttachments(rawMedia); dispatch({ type: LOAD_MORE_MEDIA, @@ -298,18 +245,16 @@ function loadMoreDocuments( const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message; - const rawDocuments = await DataReader.getOlderMessagesByConversation({ + const rawDocuments = await DataReader.getOlderMedia({ conversationId, - includeStoryReplies: false, limit: FETCH_CHUNK_COUNT, messageId, receivedAt, - requireFileAttachments: true, sentAt, - storyId: undefined, + type: 'files', }); - const documents = _cleanFileAttachments(rawDocuments); + const documents = _cleanAttachments(rawDocuments); dispatch({ type: LOAD_MORE_DOCUMENTS, @@ -431,23 +376,29 @@ export function reducer( const oldestLoadedMedia = state.media[0]; const oldestLoadedDocument = state.documents[0]; - const newMedia = _cleanVisualAttachments( - (message.attachments ?? []).map((attachment, index) => { - return { - index, - attachment, - message: { - id: message.id, - type: message.type, - conversationId: message.conversationId, - receivedAt: message.received_at, - receivedAtMs: message.received_at_ms, - sentAt: message.sent_at, - }, - }; - }) + const messageMediaItems: Array = ( + message.attachments ?? [] + ).map((attachment, index) => { + return { + index, + attachment, + message: { + id: message.id, + type: message.type, + conversationId: message.conversationId, + receivedAt: message.received_at, + receivedAtMs: message.received_at_ms, + sentAt: message.sent_at, + }, + }; + }); + + const newMedia = _cleanAttachments( + messageMediaItems.filter(({ attachment }) => isVisualMedia(attachment)) + ); + const newDocuments = _cleanAttachments( + messageMediaItems.filter(({ attachment }) => isFile(attachment)) ); - const newDocuments = _cleanFileAttachments([message]); let { documents, haveOldestDocument, haveOldestMedia, media } = state; diff --git a/ts/test-electron/Crypto_test.ts b/ts/test-electron/Crypto_test.ts index b22bec770c0..25a069a527c 100644 --- a/ts/test-electron/Crypto_test.ts +++ b/ts/test-electron/Crypto_test.ts @@ -44,8 +44,6 @@ import { _generateAttachmentIv, decryptAttachmentV2, encryptAttachmentV2ToDisk, - getAesCbcCiphertextSize, - getAttachmentCiphertextSize, splitKeys, generateAttachmentKeys, type DecryptedAttachmentV2, @@ -54,6 +52,10 @@ import { import type { AciString, PniString } from '../types/ServiceId.js'; import { createTempDir, deleteTempDir } from '../updater/common.js'; import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js'; +import { + getAesCbcCiphertextSize, + getAttachmentCiphertextSize, +} from '../util/AttachmentCrypto.js'; import { getPath } from '../windows/main/attachments.js'; import { MediaTier } from '../types/AttachmentDownload.js'; diff --git a/ts/test-electron/services/AttachmentDownloadManager_test.ts b/ts/test-electron/services/AttachmentDownloadManager_test.ts index f36fe494cd2..3a4a50cff3b 100644 --- a/ts/test-electron/services/AttachmentDownloadManager_test.ts +++ b/ts/test-electron/services/AttachmentDownloadManager_test.ts @@ -29,10 +29,8 @@ import { import { strictAssert } from '../../util/assert.js'; import type { downloadAttachment as downloadAttachmentUtil } from '../../util/downloadAttachment.js'; import { AttachmentDownloadSource } from '../../sql/Interface.js'; -import { - generateAttachmentKeys, - getAttachmentCiphertextSize, -} from '../../AttachmentCrypto.js'; +import { generateAttachmentKeys } from '../../AttachmentCrypto.js'; +import { getAttachmentCiphertextSize } from '../../util/AttachmentCrypto.js'; import { MEBIBYTE } from '../../types/AttachmentSize.js'; import { generateAci } from '../../types/ServiceId.js'; import { toBase64, toHex } from '../../Bytes.js'; diff --git a/ts/test-node/util/logPadding_test.ts b/ts/test-node/util/logPadding_test.ts index 6f7c567a03d..a3f37fc2203 100644 --- a/ts/test-node/util/logPadding_test.ts +++ b/ts/test-node/util/logPadding_test.ts @@ -4,7 +4,8 @@ import { assert } from 'chai'; import { Readable } from 'node:stream'; -import { logPadSize, appendPaddingStream } from '../../util/logPadding.js'; +import { appendPaddingStream } from '../../util/logPadding.js'; +import { logPadSize } from '../../util/logPadSize.js'; const BUCKET_SIZES = [ 541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071, diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts index 27590986b6c..d33fc98d25e 100644 --- a/ts/textsecure/downloadAttachment.ts +++ b/ts/textsecure/downloadAttachment.ts @@ -22,7 +22,6 @@ import { } from '../types/Attachment.js'; import * as Bytes from '../Bytes.js'; import { - getAttachmentCiphertextSize, safeUnlink, splitKeys, type ReencryptedAttachmentV2, @@ -32,6 +31,7 @@ import { } from '../AttachmentCrypto.js'; import type { ProcessedAttachment } from './Types.d.ts'; import type { WebAPIType } from './WebAPI.js'; +import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js'; import { createName, getRelativePath } from '../util/attachmentPath.js'; import { MediaTier } from '../types/AttachmentDownload.js'; import { diff --git a/ts/util/AttachmentCrypto.ts b/ts/util/AttachmentCrypto.ts new file mode 100644 index 00000000000..eda7d9a1119 --- /dev/null +++ b/ts/util/AttachmentCrypto.ts @@ -0,0 +1,44 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { IV_LENGTH, ATTACHMENT_MAC_LENGTH } from '../types/Crypto.js'; +import { MediaTier } from '../types/AttachmentDownload.js'; +import { logPadSize } from './logPadSize.js'; +import { missingCaseError } from './missingCaseError.js'; + +export function getCiphertextSize(plaintextLength: number): number { + const paddedPlaintextSize = logPadSize(plaintextLength); + + return ( + IV_LENGTH + + getAesCbcCiphertextSize(paddedPlaintextSize) + + ATTACHMENT_MAC_LENGTH + ); +} + +export function getAesCbcCiphertextSize(plaintextLength: number): number { + const AES_CBC_BLOCK_SIZE = 16; + return ( + (1 + Math.floor(plaintextLength / AES_CBC_BLOCK_SIZE)) * AES_CBC_BLOCK_SIZE + ); +} + +export function getAttachmentCiphertextSize({ + unpaddedPlaintextSize, + mediaTier, +}: { + unpaddedPlaintextSize: number; + mediaTier: MediaTier; +}): number { + const paddedSize = logPadSize(unpaddedPlaintextSize); + + switch (mediaTier) { + case MediaTier.STANDARD: + return getCiphertextSize(paddedSize); + case MediaTier.BACKUP: + // objects on backup tier are doubly encrypted! + return getCiphertextSize(getCiphertextSize(paddedSize)); + default: + throw missingCaseError(mediaTier); + } +} diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 259a93531fe..3e1863d5c34 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -1507,6 +1507,13 @@ "updated": "2025-06-26T23:23:57.292Z", "reasonDetail": "Holding on to a close function" }, + { + "rule": "React-useRef", + "path": "ts/components/PreferencesInternal.tsx", + "line": " const prevAbortControlerRef = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2025-08-20T18:18:34.081Z" + }, { "rule": "React-useRef", "path": "ts/components/PreferencesLocalBackups.tsx", @@ -1515,13 +1522,6 @@ "updated": "2025-05-30T22:48:14.420Z", "reasonDetail": "For focusing the settings backup key viewer textarea" }, - { - "rule": "React-useRef", - "path": "ts/components/PreferencesInternal.tsx", - "line": " const prevAbortControlerRef = useRef(null);", - "reasonCategory": "usageTrusted", - "updated": "2025-08-20T18:18:34.081Z" - }, { "rule": "React-useRef", "path": "ts/components/ProfileEditor.tsx", @@ -1711,13 +1711,6 @@ "updated": "2025-05-28T00:57:39.376Z", "reasonDetail": "Holding on to a close function" }, - { - "rule": "React-useRef", - "path": "ts/components/conversation/AttachmentStatusIcon.tsx", - "line": " const timerRef = useRef();", - "reasonCategory": "usageTrusted", - "updated": "2025-02-21T04:17:59.239Z" - }, { "rule": "React-useRef", "path": "ts/components/conversation/CallingNotification.tsx", diff --git a/ts/util/logPadSize.ts b/ts/util/logPadSize.ts new file mode 100644 index 00000000000..8172b526f47 --- /dev/null +++ b/ts/util/logPadSize.ts @@ -0,0 +1,9 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function logPadSize(size: number): number { + return Math.max( + 541, + Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05))) + ); +} diff --git a/ts/util/logPadding.ts b/ts/util/logPadding.ts index e7444f1e50c..19b406ec5ba 100644 --- a/ts/util/logPadding.ts +++ b/ts/util/logPadding.ts @@ -4,14 +4,9 @@ import { Transform } from 'node:stream'; import type { Duplex, Readable } from 'node:stream'; -const PADDING_CHUNK_SIZE = 64 * 1024; +import { logPadSize } from './logPadSize.js'; -export function logPadSize(size: number): number { - return Math.max( - 541, - Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05))) - ); -} +const PADDING_CHUNK_SIZE = 64 * 1024; /** * Creates iterator that yields zero-filled padding chunks. diff --git a/ts/util/processAttachment.ts b/ts/util/processAttachment.ts index 9d599face68..8ec276a3f64 100644 --- a/ts/util/processAttachment.ts +++ b/ts/util/processAttachment.ts @@ -21,7 +21,7 @@ import { handleVideoAttachment } from './handleVideoAttachment.js'; import { isHeic, stringToMIMEType } from '../types/MIME.js'; import { ToastType } from '../types/Toast.js'; import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome.js'; -import { getAttachmentCiphertextSize } from '../AttachmentCrypto.js'; +import { getAttachmentCiphertextSize } from './AttachmentCrypto.js'; import { MediaTier } from '../types/AttachmentDownload.js'; const log = createLogger('processAttachment');