Show ready-to-download documents in media gallery

This commit is contained in:
Fedor Indutny 2025-09-23 11:53:41 -07:00 committed by GitHub
commit 9c97d3e73c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 481 additions and 314 deletions

View file

@ -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,

View file

@ -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';

View file

@ -26,16 +26,6 @@ export function Default(args: PropsType): JSX.Element {
);
}
export function NoAttachment(args: PropsType): JSX.Element {
return (
<div style={{ backgroundColor: 'gray' }}>
<AttachmentStatusIcon {...args} attachment={undefined}>
🔥🔥
</AttachmentStatusIcon>
</div>
);
}
export function NeedsDownload(args: PropsType): JSX.Element {
return (
<div style={{ backgroundColor: 'gray' }}>

View file

@ -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<boolean>(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<NodeJS.Timeout | undefined>();
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 (
<div className="AttachmentStatusIcon__container">
<div
@ -109,19 +48,8 @@ export function AttachmentStatusIcon({
);
}
if (
attachment &&
(state === IconState.Downloading ||
(state === IconState.Downloaded && isWaiting))
) {
const { size, totalDownloaded } = attachment;
let spinnerValue = (size && totalDownloaded) || undefined;
if (state === IconState.Downloading && isWaiting) {
spinnerValue = undefined;
}
if (state === IconState.Downloaded && isWaiting) {
spinnerValue = size;
}
if (status.state === 'Downloading') {
const { size, totalDownloaded: spinnerValue } = status;
return (
<div className="AttachmentStatusIcon__container">
@ -165,5 +93,9 @@ export function AttachmentStatusIcon({
);
}
return <div className="AttachmentStatusIcon__container">{children}</div>;
if (status.state === 'ReadyToShow') {
return <div className="AttachmentStatusIcon__container">{children}</div>;
}
throw missingCaseError(status);
}

View file

@ -2869,14 +2869,17 @@ export class Message extends React.PureComponent<Props, State> {
: null
)}
>
<AttachmentStatusIcon
key={id}
attachment={firstAttachment}
isExpired={isExpired}
isIncoming={isIncoming}
>
{this.renderTapToViewIcon()}
</AttachmentStatusIcon>
{isExpired || firstAttachment == null ? (
this.renderTapToViewIcon()
) : (
<AttachmentStatusIcon
key={id}
attachment={firstAttachment}
isIncoming={isIncoming}
>
{this.renderTapToViewIcon()}
</AttachmentStatusIcon>
)}
{content}
</div>
);

View file

@ -28,23 +28,31 @@ export function renderAvatar({
const avatarUrl = avatar && avatar.avatar && avatar.avatar.path;
const title = getName(contact) || '';
const fallback = (
<Avatar
avatarUrl={avatarUrl}
badge={undefined}
blur={AvatarBlur.NoBlur}
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
title={title}
sharedGroupNames={[]}
size={size}
/>
);
const attachment = avatar?.avatar;
if (attachment == null) {
return fallback;
}
return (
<AttachmentStatusIcon
attachment={avatar?.avatar}
attachment={attachment}
isIncoming={direction === 'incoming'}
>
<Avatar
avatarUrl={avatarUrl}
badge={undefined}
blur={AvatarBlur.NoBlur}
color={AvatarColors[0]}
conversationType="direct"
i18n={i18n}
title={title}
sharedGroupNames={[]}
size={size}
/>
{fallback}
</AttachmentStatusIcon>
);
}

View file

@ -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 (
<DocumentListItem
i18n={i18n}
key={`${message.id}-${index}`}
mediaItem={mediaItem}
onClick={onClick}

View file

@ -15,6 +15,8 @@ export default {
title: 'Components/Conversation/MediaGallery/DocumentListItem',
} satisfies Meta<Props>;
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 => (
<DocumentListItem
i18n={i18n}
key={mediaItem.attachment.fileName}
mediaItem={mediaItem}
onClick={action('onClick')}

View file

@ -1,34 +1,108 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useCallback } from 'react';
import moment from 'moment';
import { formatFileSize } from '../../../util/formatFileSize.js';
import { missingCaseError } from '../../../util/missingCaseError.js';
import type { MediaItemType } from '../../../types/MediaItem.js';
import type { LocalizerType } from '../../../types/Util.js';
import { SpinnerV2 } from '../../SpinnerV2.js';
import { tw } from '../../../axo/tw.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.js';
import { FileThumbnail } from '../../FileThumbnail.js';
import {
useAttachmentStatus,
type AttachmentStatusType,
} from '../../../hooks/useAttachmentStatus.js';
export type Props = {
i18n: LocalizerType;
// Required
mediaItem: MediaItemType;
// Optional
onClick?: (ev: React.MouseEvent) => 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 = (
<>
<AxoSymbol.InlineGlyph symbol="arrow-down" label={null} />
&nbsp;
</>
);
button = (
<div
className={tw(
'relative -ms-1 size-7 shrink-0 rounded-full bg-fill-secondary',
'flex items-center justify-center'
)}
>
{status.state === 'Downloading' && (
<SpinnerV2
variant="no-background-incoming"
size={28}
strokeWidth={1}
marginRatio={1}
min={0}
max={status.size}
value={status.totalDownloaded}
/>
)}
<div className={tw('absolute text-label-primary')}>
<AxoSymbol.Icon
symbol={status.state === 'Downloading' ? 'x' : 'arrow-down'}
size={16}
label={null}
/>
</div>
</div>
);
}
return (
<button
className={tw('flex w-full flex-row items-center gap-3 py-2')}
type="button"
onClick={onClick}
onClick={handleClick}
aria-label={label}
>
<div className={tw('shrink-0')}>
<FileThumbnail {...attachment} />
@ -36,12 +110,14 @@ export function DocumentListItem({ mediaItem, onClick }: Props): JSX.Element {
<div className={tw('grow overflow-hidden text-start')}>
<h3 className={tw('truncate')}>{fileName}</h3>
<div className={tw('type-body-small leading-4 text-label-secondary')}>
{glyph}
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
</div>
</div>
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
{moment(timestamp).format('MMM D')}
</div>
{button}
</button>
);
}

View file

@ -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;
}

View file

@ -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,
},
});

View file

@ -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<MediaItemType>;
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 (
<button
type="button"
@ -68,35 +85,28 @@ export function MediaGridItem(props: Props): JSX.Element {
'relative size-30 overflow-hidden rounded-md',
'flex items-center justify-center'
)}
onClick={onClick}
onClick={handleClick}
aria-label={label}
>
{imageOrBlurHash}
<MetadataOverlay i18n={i18n} attachment={attachment} />
<SpinnerOverlay attachment={attachment} />
<MetadataOverlay i18n={i18n} status={status} attachment={attachment} />
<SpinnerOverlay status={status} />
</button>
);
}
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 (
<div
className={tw(
@ -104,20 +114,20 @@ function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined {
'flex items-center justify-center'
)}
>
{attachment.pending && (
{status.state === 'Downloading' && (
<SpinnerV2
variant="no-background"
size={44}
strokeWidth={2}
marginRatio={1}
min={0}
max={attachment.size}
value={spinnerValue}
max={status.size}
value={status.totalDownloaded}
/>
)}
<div className={tw('absolute text-label-primary-on-color')}>
<AxoSymbol.Icon
symbol={attachment.pending ? 'x' : 'arrow-down'}
symbol={status.state === 'Downloading' ? 'x' : 'arrow-down'}
size={24}
label={null}
/>
@ -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);

View file

@ -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'];
};

View file

@ -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'
? {

View file

@ -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);
}

View file

@ -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<Value> = Readonly<
| {
type: 'transition';
from: Value;
to: Value;
}
| {
type: 'idle';
value: Value;
}
>;
export function useDelayedValue<Value>(newValue: Value, delay: number): Value {
const [state, setState] = useState<InternalState<Value>>({
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;
}

View file

@ -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,

View file

@ -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';

View file

@ -581,6 +581,7 @@ export type GetOlderMediaOptionsType = Readonly<{
messageId?: string;
receivedAt?: number;
sentAt?: number;
type: 'media' | 'files';
}>;
export type MediaItemDBType = Readonly<{

View file

@ -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<MediaItemDBType> {
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})

View file

@ -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<MediaItemType>;
@ -114,25 +103,7 @@ function _sortDocuments(
return orderBy(documents, ['message.receivedAt', 'message.sentAt']);
}
function _getMediaItemMessage(
message: ReadonlyDeep<MessageAttributesType>
): 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<MediaItemDBType>
): ReadonlyArray<MediaItemType> {
return rawMedia.map(({ message, index, attachment }) => {
@ -144,30 +115,6 @@ function _cleanVisualAttachments(
});
}
function _cleanFileAttachments(
rawDocuments: ReadonlyDeep<ReadonlyArray<MessageAttributesType>>
): ReadonlyArray<MediaItemType> {
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<MediaItemDBType> = (
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;

View file

@ -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';

View file

@ -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';

View file

@ -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,

View file

@ -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 {

View file

@ -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);
}
}

View file

@ -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<AbortController | null>(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<AbortController | null>(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<NodeJS.Timeout | undefined>();",
"reasonCategory": "usageTrusted",
"updated": "2025-02-21T04:17:59.239Z"
},
{
"rule": "React-useRef",
"path": "ts/components/conversation/CallingNotification.tsx",

9
ts/util/logPadSize.ts Normal file
View file

@ -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)))
);
}

View file

@ -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.

View file

@ -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');