Show ready-to-download documents in media gallery
This commit is contained in:
parent
e9ea20bb73
commit
9c97d3e73c
30 changed files with 481 additions and 314 deletions
|
@ -39,7 +39,7 @@ import {
|
||||||
} from './types/Crypto.js';
|
} from './types/Crypto.js';
|
||||||
import { constantTimeEqual } from './Crypto.js';
|
import { constantTimeEqual } from './Crypto.js';
|
||||||
import { createName, getRelativePath } from './util/attachmentPath.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 { prependStream } from './util/prependStream.js';
|
||||||
import { appendMacStream } from './util/appendMacStream.js';
|
import { appendMacStream } from './util/appendMacStream.js';
|
||||||
import { finalStream } from './util/finalStream.js';
|
import { finalStream } from './util/finalStream.js';
|
||||||
|
@ -52,6 +52,7 @@ import { missingCaseError } from './util/missingCaseError.js';
|
||||||
import { getEnvironment, Environment } from './environment.js';
|
import { getEnvironment, Environment } from './environment.js';
|
||||||
import { isNotEmpty, toBase64, toHex } from './Bytes.js';
|
import { isNotEmpty, toBase64, toHex } from './Bytes.js';
|
||||||
import { decipherWithAesKey } from './util/decipherWithAesKey.js';
|
import { decipherWithAesKey } from './util/decipherWithAesKey.js';
|
||||||
|
import { getAttachmentCiphertextSize } from './util/AttachmentCrypto.js';
|
||||||
import { MediaTier } from './types/AttachmentDownload.js';
|
import { MediaTier } from './types/AttachmentDownload.js';
|
||||||
|
|
||||||
const { ensureFile } = fsExtra;
|
const { ensureFile } = fsExtra;
|
||||||
|
@ -670,41 +671,6 @@ export function measureSize({
|
||||||
return passthrough;
|
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({
|
function checkIntegrity({
|
||||||
locallyCalculatedDigest,
|
locallyCalculatedDigest,
|
||||||
locallyCalculatedPlaintextHash,
|
locallyCalculatedPlaintextHash,
|
||||||
|
|
|
@ -14,7 +14,7 @@ import { HashType, CipherType } from './types/Crypto.js';
|
||||||
import { AVATAR_COLOR_COUNT, AvatarColors } from './types/Colors.js';
|
import { AVATAR_COLOR_COUNT, AvatarColors } from './types/Colors.js';
|
||||||
import { ProfileDecryptError } from './types/errors.js';
|
import { ProfileDecryptError } from './types/errors.js';
|
||||||
import { getBytesSubarray } from './util/uuidToBytes.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 { Environment, getEnvironment } from './environment.js';
|
||||||
import { toWebSafeBase64 } from './util/webSafeBase64.js';
|
import { toWebSafeBase64 } from './util/webSafeBase64.js';
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
export function NeedsDownload(args: PropsType): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div style={{ backgroundColor: 'gray' }}>
|
<div style={{ backgroundColor: 'gray' }}>
|
||||||
|
|
|
@ -1,90 +1,29 @@
|
||||||
// Copyright 2025 Signal Messenger, LLC
|
// Copyright 2025 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// 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 classNames from 'classnames';
|
||||||
|
|
||||||
import { SpinnerV2 } from '../SpinnerV2.js';
|
import { SpinnerV2 } from '../SpinnerV2.js';
|
||||||
import { usePrevious } from '../../hooks/usePrevious.js';
|
|
||||||
|
|
||||||
import type { AttachmentForUIType } from '../../types/Attachment.js';
|
import type { AttachmentForUIType } from '../../types/Attachment.js';
|
||||||
|
import { missingCaseError } from '../../util/missingCaseError.js';
|
||||||
const TRANSITION_DELAY = 200;
|
import { useAttachmentStatus } from '../../hooks/useAttachmentStatus.js';
|
||||||
|
|
||||||
export type PropsType = {
|
export type PropsType = {
|
||||||
attachment: AttachmentForUIType | undefined;
|
attachment: AttachmentForUIType;
|
||||||
isExpired?: boolean;
|
|
||||||
isIncoming: boolean;
|
isIncoming: boolean;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
enum IconState {
|
|
||||||
NeedsDownload = 'NeedsDownload',
|
|
||||||
Downloading = 'Downloading',
|
|
||||||
Downloaded = 'Downloaded',
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AttachmentStatusIcon({
|
export function AttachmentStatusIcon({
|
||||||
attachment,
|
attachment,
|
||||||
isExpired,
|
|
||||||
isIncoming,
|
isIncoming,
|
||||||
children,
|
children,
|
||||||
}: PropsType): JSX.Element | null {
|
}: PropsType): JSX.Element | null {
|
||||||
const [isWaiting, setIsWaiting] = useState<boolean>(false);
|
const status = useAttachmentStatus(attachment);
|
||||||
|
|
||||||
const isAttachmentNotAvailable =
|
if (status.state === 'NeedsDownload') {
|
||||||
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) {
|
|
||||||
return (
|
return (
|
||||||
<div className="AttachmentStatusIcon__container">
|
<div className="AttachmentStatusIcon__container">
|
||||||
<div
|
<div
|
||||||
|
@ -109,19 +48,8 @@ export function AttachmentStatusIcon({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (status.state === 'Downloading') {
|
||||||
attachment &&
|
const { size, totalDownloaded: spinnerValue } = status;
|
||||||
(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="AttachmentStatusIcon__container">
|
<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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -2869,14 +2869,17 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
: null
|
: null
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<AttachmentStatusIcon
|
{isExpired || firstAttachment == null ? (
|
||||||
key={id}
|
this.renderTapToViewIcon()
|
||||||
attachment={firstAttachment}
|
) : (
|
||||||
isExpired={isExpired}
|
<AttachmentStatusIcon
|
||||||
isIncoming={isIncoming}
|
key={id}
|
||||||
>
|
attachment={firstAttachment}
|
||||||
{this.renderTapToViewIcon()}
|
isIncoming={isIncoming}
|
||||||
</AttachmentStatusIcon>
|
>
|
||||||
|
{this.renderTapToViewIcon()}
|
||||||
|
</AttachmentStatusIcon>
|
||||||
|
)}
|
||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
@ -28,23 +28,31 @@ export function renderAvatar({
|
||||||
|
|
||||||
const avatarUrl = avatar && avatar.avatar && avatar.avatar.path;
|
const avatarUrl = avatar && avatar.avatar && avatar.avatar.path;
|
||||||
const title = getName(contact) || '';
|
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 (
|
return (
|
||||||
<AttachmentStatusIcon
|
<AttachmentStatusIcon
|
||||||
attachment={avatar?.avatar}
|
attachment={attachment}
|
||||||
isIncoming={direction === 'incoming'}
|
isIncoming={direction === 'incoming'}
|
||||||
>
|
>
|
||||||
<Avatar
|
{fallback}
|
||||||
avatarUrl={avatarUrl}
|
|
||||||
badge={undefined}
|
|
||||||
blur={AvatarBlur.NoBlur}
|
|
||||||
color={AvatarColors[0]}
|
|
||||||
conversationType="direct"
|
|
||||||
i18n={i18n}
|
|
||||||
title={title}
|
|
||||||
sharedGroupNames={[]}
|
|
||||||
size={size}
|
|
||||||
/>
|
|
||||||
</AttachmentStatusIcon>
|
</AttachmentStatusIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@ import type { LocalizerType, ThemeType } from '../../../types/Util.js';
|
||||||
import type { MediaItemType } from '../../../types/MediaItem.js';
|
import type { MediaItemType } from '../../../types/MediaItem.js';
|
||||||
import { DocumentListItem } from './DocumentListItem.js';
|
import { DocumentListItem } from './DocumentListItem.js';
|
||||||
import { MediaGridItem } from './MediaGridItem.js';
|
import { MediaGridItem } from './MediaGridItem.js';
|
||||||
|
import type { AttachmentStatusType } from '../../../hooks/useAttachmentStatus.js';
|
||||||
import { missingCaseError } from '../../../util/missingCaseError.js';
|
import { missingCaseError } from '../../../util/missingCaseError.js';
|
||||||
import { tw } from '../../../axo/tw.js';
|
import { tw } from '../../../axo/tw.js';
|
||||||
|
|
||||||
|
@ -37,9 +38,8 @@ export function AttachmentSection({
|
||||||
{mediaItems.map(mediaItem => {
|
{mediaItems.map(mediaItem => {
|
||||||
const { message, index, attachment } = mediaItem;
|
const { message, index, attachment } = mediaItem;
|
||||||
|
|
||||||
const onClick = (ev: React.MouseEvent) => {
|
const onClick = (state: AttachmentStatusType['state']) => {
|
||||||
ev.preventDefault();
|
onItemClick({ type, message, attachment, state });
|
||||||
onItemClick({ type, message, attachment });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -69,13 +69,13 @@ export function AttachmentSection({
|
||||||
{mediaItems.map(mediaItem => {
|
{mediaItems.map(mediaItem => {
|
||||||
const { message, index, attachment } = mediaItem;
|
const { message, index, attachment } = mediaItem;
|
||||||
|
|
||||||
const onClick = (ev: React.MouseEvent) => {
|
const onClick = (state: AttachmentStatusType['state']) => {
|
||||||
ev.preventDefault();
|
onItemClick({ type, message, attachment, state });
|
||||||
onItemClick({ type, message, attachment });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DocumentListItem
|
<DocumentListItem
|
||||||
|
i18n={i18n}
|
||||||
key={`${message.id}-${index}`}
|
key={`${message.id}-${index}`}
|
||||||
mediaItem={mediaItem}
|
mediaItem={mediaItem}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
|
|
@ -15,6 +15,8 @@ export default {
|
||||||
title: 'Components/Conversation/MediaGallery/DocumentListItem',
|
title: 'Components/Conversation/MediaGallery/DocumentListItem',
|
||||||
} satisfies Meta<Props>;
|
} satisfies Meta<Props>;
|
||||||
|
|
||||||
|
const { i18n } = window.SignalContext;
|
||||||
|
|
||||||
export function Multiple(): JSX.Element {
|
export function Multiple(): JSX.Element {
|
||||||
const items = createPreparedMediaItems(createRandomDocuments);
|
const items = createPreparedMediaItems(createRandomDocuments);
|
||||||
|
|
||||||
|
@ -22,6 +24,7 @@ export function Multiple(): JSX.Element {
|
||||||
<>
|
<>
|
||||||
{items.map(mediaItem => (
|
{items.map(mediaItem => (
|
||||||
<DocumentListItem
|
<DocumentListItem
|
||||||
|
i18n={i18n}
|
||||||
key={mediaItem.attachment.fileName}
|
key={mediaItem.attachment.fileName}
|
||||||
mediaItem={mediaItem}
|
mediaItem={mediaItem}
|
||||||
onClick={action('onClick')}
|
onClick={action('onClick')}
|
||||||
|
|
|
@ -1,34 +1,108 @@
|
||||||
// Copyright 2018 Signal Messenger, LLC
|
// Copyright 2018 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { formatFileSize } from '../../../util/formatFileSize.js';
|
import { formatFileSize } from '../../../util/formatFileSize.js';
|
||||||
|
import { missingCaseError } from '../../../util/missingCaseError.js';
|
||||||
import type { MediaItemType } from '../../../types/MediaItem.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 { tw } from '../../../axo/tw.js';
|
||||||
|
import { AxoSymbol } from '../../../axo/AxoSymbol.js';
|
||||||
import { FileThumbnail } from '../../FileThumbnail.js';
|
import { FileThumbnail } from '../../FileThumbnail.js';
|
||||||
|
import {
|
||||||
|
useAttachmentStatus,
|
||||||
|
type AttachmentStatusType,
|
||||||
|
} from '../../../hooks/useAttachmentStatus.js';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
|
i18n: LocalizerType;
|
||||||
// Required
|
// Required
|
||||||
mediaItem: MediaItemType;
|
mediaItem: MediaItemType;
|
||||||
|
|
||||||
// Optional
|
// 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 { attachment, message } = mediaItem;
|
||||||
|
|
||||||
const { fileName, size: fileSize } = attachment;
|
const { fileName, size: fileSize } = attachment;
|
||||||
|
|
||||||
const timestamp = message.receivedAtMs || message.receivedAt;
|
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} />
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
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 (
|
return (
|
||||||
<button
|
<button
|
||||||
className={tw('flex w-full flex-row items-center gap-3 py-2')}
|
className={tw('flex w-full flex-row items-center gap-3 py-2')}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
|
aria-label={label}
|
||||||
>
|
>
|
||||||
<div className={tw('shrink-0')}>
|
<div className={tw('shrink-0')}>
|
||||||
<FileThumbnail {...attachment} />
|
<FileThumbnail {...attachment} />
|
||||||
|
@ -36,12 +110,14 @@ export function DocumentListItem({ mediaItem, onClick }: Props): JSX.Element {
|
||||||
<div className={tw('grow overflow-hidden text-start')}>
|
<div className={tw('grow overflow-hidden text-start')}>
|
||||||
<h3 className={tw('truncate')}>{fileName}</h3>
|
<h3 className={tw('truncate')}>{fileName}</h3>
|
||||||
<div className={tw('type-body-small leading-4 text-label-secondary')}>
|
<div className={tw('type-body-small leading-4 text-label-secondary')}>
|
||||||
|
{glyph}
|
||||||
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
|
{typeof fileSize === 'number' ? formatFileSize(fileSize) : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
|
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
|
||||||
{moment(timestamp).format('MMM D')}
|
{moment(timestamp).format('MMM D')}
|
||||||
</div>
|
</div>
|
||||||
|
{button}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -127,20 +127,30 @@ function MediaSection({
|
||||||
onItemClick={(event: ItemClickEvent) => {
|
onItemClick={(event: ItemClickEvent) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'documents': {
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'media': {
|
case 'media': {
|
||||||
if (event.attachment.url || event.attachment.incrementalUrl) {
|
if (event.state === 'ReadyToShow') {
|
||||||
showLightbox({
|
showLightbox({
|
||||||
attachment: event.attachment,
|
attachment: event.attachment,
|
||||||
messageId: event.message.id,
|
messageId: event.message.id,
|
||||||
});
|
});
|
||||||
} else if (event.attachment.pending) {
|
} else if (event.state === 'Downloading') {
|
||||||
cancelAttachmentDownload({ messageId: event.message.id });
|
cancelAttachmentDownload({ messageId: event.message.id });
|
||||||
} else {
|
} else if (event.state === 'NeedsDownload') {
|
||||||
kickOffAttachmentDownload({ messageId: event.message.id });
|
kickOffAttachmentDownload({ messageId: event.message.id });
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(event.state);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
|
@ -175,7 +175,7 @@ export function PendingImage(): JSX.Element {
|
||||||
size: 123000,
|
size: 123000,
|
||||||
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]',
|
||||||
isPermanentlyUndownloadable: false,
|
isPermanentlyUndownloadable: false,
|
||||||
totalDownloaded: 0,
|
totalDownloaded: undefined,
|
||||||
pending: true,
|
pending: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
// Copyright 2018 Signal Messenger, LLC
|
// Copyright 2018 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React from 'react';
|
import React, { useCallback } from 'react';
|
||||||
|
|
||||||
import type { ReadonlyDeep } from 'type-fest';
|
import type { ReadonlyDeep } from 'type-fest';
|
||||||
import { formatFileSize } from '../../../util/formatFileSize.js';
|
import { formatFileSize } from '../../../util/formatFileSize.js';
|
||||||
import { formatDuration } from '../../../util/formatDuration.js';
|
import { formatDuration } from '../../../util/formatDuration.js';
|
||||||
|
import { missingCaseError } from '../../../util/missingCaseError.js';
|
||||||
import type { LocalizerType, ThemeType } from '../../../types/Util.js';
|
import type { LocalizerType, ThemeType } from '../../../types/Util.js';
|
||||||
import type { MediaItemType } from '../../../types/MediaItem.js';
|
import type { MediaItemType } from '../../../types/MediaItem.js';
|
||||||
import type { AttachmentForUIType } from '../../../types/Attachment.js';
|
import type { AttachmentForUIType } from '../../../types/Attachment.js';
|
||||||
|
@ -20,10 +21,14 @@ import { ImageOrBlurhash } from '../../ImageOrBlurhash.js';
|
||||||
import { SpinnerV2 } from '../../SpinnerV2.js';
|
import { SpinnerV2 } from '../../SpinnerV2.js';
|
||||||
import { tw } from '../../../axo/tw.js';
|
import { tw } from '../../../axo/tw.js';
|
||||||
import { AxoSymbol } from '../../../axo/AxoSymbol.js';
|
import { AxoSymbol } from '../../../axo/AxoSymbol.js';
|
||||||
|
import {
|
||||||
|
useAttachmentStatus,
|
||||||
|
type AttachmentStatusType,
|
||||||
|
} from '../../../hooks/useAttachmentStatus.js';
|
||||||
|
|
||||||
export type Props = Readonly<{
|
export type Props = Readonly<{
|
||||||
mediaItem: ReadonlyDeep<MediaItemType>;
|
mediaItem: ReadonlyDeep<MediaItemType>;
|
||||||
onClick?: (ev: React.MouseEvent) => void;
|
onClick?: (attachmentState: AttachmentStatusType['state']) => void;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
theme?: ThemeType;
|
theme?: ThemeType;
|
||||||
}>;
|
}>;
|
||||||
|
@ -38,6 +43,7 @@ export function MediaGridItem(props: Props): JSX.Element {
|
||||||
|
|
||||||
const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme);
|
const resolvedBlurHash = attachment.blurHash || defaultBlurHash(theme);
|
||||||
const url = getUrl(attachment);
|
const url = getUrl(attachment);
|
||||||
|
const status = useAttachmentStatus(attachment);
|
||||||
|
|
||||||
const { width, height } = attachment;
|
const { width, height } = attachment;
|
||||||
|
|
||||||
|
@ -53,14 +59,25 @@ export function MediaGridItem(props: Props): JSX.Element {
|
||||||
);
|
);
|
||||||
|
|
||||||
let label: string;
|
let label: string;
|
||||||
if (url != null) {
|
if (status.state === 'ReadyToShow') {
|
||||||
label = i18n('icu:imageOpenAlt');
|
label = i18n('icu:imageOpenAlt');
|
||||||
} else if (attachment.pending) {
|
} else if (status.state === 'Downloading') {
|
||||||
label = i18n('icu:cancelDownload');
|
label = i18n('icu:cancelDownload');
|
||||||
} else {
|
} else if (status.state === 'NeedsDownload') {
|
||||||
label = i18n('icu:startDownload');
|
label = i18n('icu:startDownload');
|
||||||
|
} else {
|
||||||
|
throw missingCaseError(status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClick = useCallback(
|
||||||
|
(ev: React.MouseEvent) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
|
||||||
|
onClick?.(status.state);
|
||||||
|
},
|
||||||
|
[onClick, status.state]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
@ -68,35 +85,28 @@ export function MediaGridItem(props: Props): JSX.Element {
|
||||||
'relative size-30 overflow-hidden rounded-md',
|
'relative size-30 overflow-hidden rounded-md',
|
||||||
'flex items-center justify-center'
|
'flex items-center justify-center'
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={handleClick}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
>
|
>
|
||||||
{imageOrBlurHash}
|
{imageOrBlurHash}
|
||||||
|
|
||||||
<MetadataOverlay i18n={i18n} attachment={attachment} />
|
<MetadataOverlay i18n={i18n} status={status} attachment={attachment} />
|
||||||
<SpinnerOverlay attachment={attachment} />
|
<SpinnerOverlay status={status} />
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpinnerOverlayProps = Readonly<{
|
type SpinnerOverlayProps = Readonly<{
|
||||||
attachment: AttachmentForUIType;
|
status: AttachmentStatusType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined {
|
function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined {
|
||||||
const { attachment } = props;
|
const { status } = props;
|
||||||
|
|
||||||
const url = getUrl(attachment);
|
if (status.state === 'ReadyToShow') {
|
||||||
if (url != null) {
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const spinnerValue =
|
|
||||||
(!attachment.incrementalUrl &&
|
|
||||||
attachment.size &&
|
|
||||||
attachment.totalDownloaded) ||
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={tw(
|
className={tw(
|
||||||
|
@ -104,20 +114,20 @@ function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined {
|
||||||
'flex items-center justify-center'
|
'flex items-center justify-center'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{attachment.pending && (
|
{status.state === 'Downloading' && (
|
||||||
<SpinnerV2
|
<SpinnerV2
|
||||||
variant="no-background"
|
variant="no-background"
|
||||||
size={44}
|
size={44}
|
||||||
strokeWidth={2}
|
strokeWidth={2}
|
||||||
marginRatio={1}
|
marginRatio={1}
|
||||||
min={0}
|
min={0}
|
||||||
max={attachment.size}
|
max={status.size}
|
||||||
value={spinnerValue}
|
value={status.totalDownloaded}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={tw('absolute text-label-primary-on-color')}>
|
<div className={tw('absolute text-label-primary-on-color')}>
|
||||||
<AxoSymbol.Icon
|
<AxoSymbol.Icon
|
||||||
symbol={attachment.pending ? 'x' : 'arrow-down'}
|
symbol={status.state === 'Downloading' ? 'x' : 'arrow-down'}
|
||||||
size={24}
|
size={24}
|
||||||
label={null}
|
label={null}
|
||||||
/>
|
/>
|
||||||
|
@ -128,20 +138,23 @@ function SpinnerOverlay(props: SpinnerOverlayProps): JSX.Element | undefined {
|
||||||
|
|
||||||
type MetadataOverlayProps = Readonly<{
|
type MetadataOverlayProps = Readonly<{
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
status: AttachmentStatusType;
|
||||||
attachment: AttachmentForUIType;
|
attachment: AttachmentForUIType;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
function MetadataOverlay(props: MetadataOverlayProps): JSX.Element | undefined {
|
function MetadataOverlay(props: MetadataOverlayProps): JSX.Element | undefined {
|
||||||
const { i18n, attachment } = props;
|
const { i18n, status, attachment } = props;
|
||||||
|
|
||||||
const url = getUrl(attachment);
|
if (
|
||||||
const canBeShown = url != null;
|
status.state === 'ReadyToShow' &&
|
||||||
if (canBeShown && !isGIF([attachment]) && !isVideoAttachment(attachment)) {
|
!isGIF([attachment]) &&
|
||||||
|
!isVideoAttachment(attachment)
|
||||||
|
) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
let text: string;
|
let text: string;
|
||||||
if (isGIF([attachment]) && canBeShown) {
|
if (isGIF([attachment]) && status.state === 'ReadyToShow') {
|
||||||
text = i18n('icu:message--getNotificationText--gif');
|
text = i18n('icu:message--getNotificationText--gif');
|
||||||
} else if (isVideoAttachment(attachment) && attachment.duration != null) {
|
} else if (isVideoAttachment(attachment) && attachment.duration != null) {
|
||||||
text = formatDuration(attachment.duration);
|
text = formatDuration(attachment.duration);
|
||||||
|
|
|
@ -2,9 +2,11 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { AttachmentType } from '../../../../types/Attachment.js';
|
import type { AttachmentType } from '../../../../types/Attachment.js';
|
||||||
|
import type { AttachmentStatusType } from '../../../../hooks/useAttachmentStatus.js';
|
||||||
|
|
||||||
export type ItemClickEvent = {
|
export type ItemClickEvent = {
|
||||||
message: { id: string; sentAt: number };
|
message: { id: string; sentAt: number };
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
type: 'media' | 'documents';
|
type: 'media' | 'documents';
|
||||||
|
state: AttachmentStatusType['state'];
|
||||||
};
|
};
|
||||||
|
|
|
@ -33,6 +33,7 @@ function createRandomFile(
|
||||||
const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`;
|
const fileName = `${sample(tokens)}${sample(tokens)}.${fileExtension}`;
|
||||||
|
|
||||||
const isDownloaded = Math.random() > 0.4;
|
const isDownloaded = Math.random() > 0.4;
|
||||||
|
const isPending = !isDownloaded && Math.random() > 0.5;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: {
|
message: {
|
||||||
|
@ -46,6 +47,7 @@ function createRandomFile(
|
||||||
attachment: {
|
attachment: {
|
||||||
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
|
url: isDownloaded ? '/fixtures/cat-screenshot-3x4.png' : undefined,
|
||||||
path: isDownloaded ? 'abc' : undefined,
|
path: isDownloaded ? 'abc' : undefined,
|
||||||
|
pending: isPending,
|
||||||
screenshot:
|
screenshot:
|
||||||
fileExtension === 'mp4'
|
fileExtension === 'mp4'
|
||||||
? {
|
? {
|
||||||
|
|
88
ts/hooks/useAttachmentStatus.ts
Normal file
88
ts/hooks/useAttachmentStatus.ts
Normal 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);
|
||||||
|
}
|
63
ts/hooks/useDelayedValue.ts
Normal file
63
ts/hooks/useDelayedValue.ts
Normal 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;
|
||||||
|
}
|
|
@ -24,7 +24,6 @@ import {
|
||||||
import {
|
import {
|
||||||
type EncryptedAttachmentV2,
|
type EncryptedAttachmentV2,
|
||||||
decryptAttachmentV2ToSink,
|
decryptAttachmentV2ToSink,
|
||||||
getAttachmentCiphertextSize,
|
|
||||||
} from '../AttachmentCrypto.js';
|
} from '../AttachmentCrypto.js';
|
||||||
import {
|
import {
|
||||||
getBackupMediaRootKey,
|
getBackupMediaRootKey,
|
||||||
|
@ -39,6 +38,7 @@ import {
|
||||||
} from '../types/AttachmentBackup.js';
|
} from '../types/AttachmentBackup.js';
|
||||||
import { isInCall as isInCallSelector } from '../state/selectors/calling.js';
|
import { isInCall as isInCallSelector } from '../state/selectors/calling.js';
|
||||||
import { encryptAndUploadAttachment } from '../util/uploadAttachment.js';
|
import { encryptAndUploadAttachment } from '../util/uploadAttachment.js';
|
||||||
|
import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js';
|
||||||
import {
|
import {
|
||||||
getMediaIdFromMediaName,
|
getMediaIdFromMediaName,
|
||||||
getMediaNameForAttachmentThumbnail,
|
getMediaNameForAttachmentThumbnail,
|
||||||
|
|
|
@ -52,10 +52,7 @@ import {
|
||||||
import { IMAGE_WEBP } from '../types/MIME.js';
|
import { IMAGE_WEBP } from '../types/MIME.js';
|
||||||
import { AttachmentDownloadSource } from '../sql/Interface.js';
|
import { AttachmentDownloadSource } from '../sql/Interface.js';
|
||||||
import { drop } from '../util/drop.js';
|
import { drop } from '../util/drop.js';
|
||||||
import {
|
import { type ReencryptedAttachmentV2 } from '../AttachmentCrypto.js';
|
||||||
getAttachmentCiphertextSize,
|
|
||||||
type ReencryptedAttachmentV2,
|
|
||||||
} from '../AttachmentCrypto.js';
|
|
||||||
import { safeParsePartial } from '../util/schemas.js';
|
import { safeParsePartial } from '../util/schemas.js';
|
||||||
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue.js';
|
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue.js';
|
||||||
import { createBatcher } from '../util/batcher.js';
|
import { createBatcher } from '../util/batcher.js';
|
||||||
|
@ -68,6 +65,7 @@ import {
|
||||||
} from './helpers/attachmentBackfill.js';
|
} from './helpers/attachmentBackfill.js';
|
||||||
import { formatCountForLogging } from '../logging/formatCountForLogging.js';
|
import { formatCountForLogging } from '../logging/formatCountForLogging.js';
|
||||||
import { strictAssert } from '../util/assert.js';
|
import { strictAssert } from '../util/assert.js';
|
||||||
|
import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js';
|
||||||
import { updateBackupMediaDownloadProgress } from '../util/updateBackupMediaDownloadProgress.js';
|
import { updateBackupMediaDownloadProgress } from '../util/updateBackupMediaDownloadProgress.js';
|
||||||
import { HTTPError } from '../textsecure/Errors.js';
|
import { HTTPError } from '../textsecure/Errors.js';
|
||||||
import { isOlderThan } from '../util/timestamp.js';
|
import { isOlderThan } from '../util/timestamp.js';
|
||||||
|
|
|
@ -581,6 +581,7 @@ export type GetOlderMediaOptionsType = Readonly<{
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
receivedAt?: number;
|
receivedAt?: number;
|
||||||
sentAt?: number;
|
sentAt?: number;
|
||||||
|
type: 'media' | 'files';
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
export type MediaItemDBType = Readonly<{
|
export type MediaItemDBType = Readonly<{
|
||||||
|
|
|
@ -36,6 +36,7 @@ import { STORAGE_UI_KEYS } from '../types/StorageUIKeys.js';
|
||||||
import type { StoryDistributionIdString } from '../types/StoryDistributionId.js';
|
import type { StoryDistributionIdString } from '../types/StoryDistributionId.js';
|
||||||
import * as Errors from '../types/errors.js';
|
import * as Errors from '../types/errors.js';
|
||||||
import { assertDev, strictAssert } from '../util/assert.js';
|
import { assertDev, strictAssert } from '../util/assert.js';
|
||||||
|
import { missingCaseError } from '../util/missingCaseError.js';
|
||||||
import { combineNames } from '../util/combineNames.js';
|
import { combineNames } from '../util/combineNames.js';
|
||||||
import { consoleLogger } from '../util/consoleLogger.js';
|
import { consoleLogger } from '../util/consoleLogger.js';
|
||||||
import {
|
import {
|
||||||
|
@ -5097,6 +5098,7 @@ function getOlderMedia(
|
||||||
messageId,
|
messageId,
|
||||||
receivedAt: maxReceivedAt = Number.MAX_VALUE,
|
receivedAt: maxReceivedAt = Number.MAX_VALUE,
|
||||||
sentAt: maxSentAt = Number.MAX_VALUE,
|
sentAt: maxSentAt = Number.MAX_VALUE,
|
||||||
|
type,
|
||||||
}: GetOlderMediaOptionsType
|
}: GetOlderMediaOptionsType
|
||||||
): Array<MediaItemDBType> {
|
): Array<MediaItemDBType> {
|
||||||
const timeFilters = {
|
const timeFilters = {
|
||||||
|
@ -5104,6 +5106,27 @@ function getOlderMedia(
|
||||||
second: sqlFragment`receivedAt < ${maxReceivedAt}`,
|
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`
|
const createQuery = (timeFilter: QueryFragment): QueryFragment => sqlFragment`
|
||||||
SELECT
|
SELECT
|
||||||
*
|
*
|
||||||
|
@ -5116,11 +5139,7 @@ function getOlderMedia(
|
||||||
(
|
(
|
||||||
${timeFilter}
|
${timeFilter}
|
||||||
) AND
|
) AND
|
||||||
(
|
(${contentFilter}) AND
|
||||||
-- see 'isVisualMedia' in ts/types/Attachment.ts
|
|
||||||
contentType LIKE 'image/%' OR
|
|
||||||
contentType LIKE 'video/%'
|
|
||||||
) AND
|
|
||||||
isViewOnce IS NOT 1 AND
|
isViewOnce IS NOT 1 AND
|
||||||
messageType IN ('incoming', 'outgoing') AND
|
messageType IN ('incoming', 'outgoing') AND
|
||||||
(${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null})
|
(${messageId ?? null} IS NULL OR messageId IS NOT ${messageId ?? null})
|
||||||
|
|
|
@ -14,7 +14,6 @@ import {
|
||||||
MESSAGE_DELETED,
|
MESSAGE_DELETED,
|
||||||
MESSAGE_EXPIRED,
|
MESSAGE_EXPIRED,
|
||||||
} from './conversations.js';
|
} from './conversations.js';
|
||||||
import { isNotNil } from '../../util/isNotNil.js';
|
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions.js';
|
import { useBoundActions } from '../../hooks/useBoundActions.js';
|
||||||
|
|
||||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.js';
|
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions.js';
|
||||||
|
@ -25,24 +24,14 @@ import type {
|
||||||
MessageExpiredActionType,
|
MessageExpiredActionType,
|
||||||
} from './conversations.js';
|
} from './conversations.js';
|
||||||
import type { MediaItemType } from '../../types/MediaItem.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 { StateType as RootStateType } from '../reducer.js';
|
||||||
import type { MessageAttributesType, MessageType } from '../../model-types.js';
|
import { getPropsForAttachment } from '../selectors/message.js';
|
||||||
import { isTapToView, getPropsForAttachment } from '../selectors/message.js';
|
|
||||||
|
|
||||||
const { orderBy } = lodash;
|
const { orderBy } = lodash;
|
||||||
|
|
||||||
const log = createLogger('mediaGallery');
|
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<{
|
export type MediaGalleryStateType = ReadonlyDeep<{
|
||||||
conversationId: string | undefined;
|
conversationId: string | undefined;
|
||||||
documents: ReadonlyArray<MediaItemType>;
|
documents: ReadonlyArray<MediaItemType>;
|
||||||
|
@ -114,25 +103,7 @@ function _sortDocuments(
|
||||||
return orderBy(documents, ['message.receivedAt', 'message.sentAt']);
|
return orderBy(documents, ['message.receivedAt', 'message.sentAt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function _getMediaItemMessage(
|
function _cleanAttachments(
|
||||||
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(
|
|
||||||
rawMedia: ReadonlyArray<MediaItemDBType>
|
rawMedia: ReadonlyArray<MediaItemDBType>
|
||||||
): ReadonlyArray<MediaItemType> {
|
): ReadonlyArray<MediaItemType> {
|
||||||
return rawMedia.map(({ message, index, attachment }) => {
|
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(
|
function initialLoad(
|
||||||
conversationId: string
|
conversationId: string
|
||||||
): ThunkAction<
|
): ThunkAction<
|
||||||
|
@ -185,17 +132,16 @@ function initialLoad(
|
||||||
const rawMedia = await DataReader.getOlderMedia({
|
const rawMedia = await DataReader.getOlderMedia({
|
||||||
conversationId,
|
conversationId,
|
||||||
limit: FETCH_CHUNK_COUNT,
|
limit: FETCH_CHUNK_COUNT,
|
||||||
|
type: 'media',
|
||||||
});
|
});
|
||||||
const rawDocuments = await DataReader.getOlderMessagesByConversation({
|
const rawDocuments = await DataReader.getOlderMedia({
|
||||||
conversationId,
|
conversationId,
|
||||||
includeStoryReplies: false,
|
|
||||||
limit: FETCH_CHUNK_COUNT,
|
limit: FETCH_CHUNK_COUNT,
|
||||||
requireFileAttachments: true,
|
type: 'files',
|
||||||
storyId: undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = _cleanVisualAttachments(rawMedia);
|
const media = _cleanAttachments(rawMedia);
|
||||||
const documents = _cleanFileAttachments(rawDocuments);
|
const documents = _cleanAttachments(rawDocuments);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: INITIAL_LOAD,
|
type: INITIAL_LOAD,
|
||||||
|
@ -246,9 +192,10 @@ function loadMoreMedia(
|
||||||
messageId,
|
messageId,
|
||||||
receivedAt,
|
receivedAt,
|
||||||
sentAt,
|
sentAt,
|
||||||
|
type: 'media',
|
||||||
});
|
});
|
||||||
|
|
||||||
const media = _cleanVisualAttachments(rawMedia);
|
const media = _cleanAttachments(rawMedia);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: LOAD_MORE_MEDIA,
|
type: LOAD_MORE_MEDIA,
|
||||||
|
@ -298,18 +245,16 @@ function loadMoreDocuments(
|
||||||
|
|
||||||
const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message;
|
const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message;
|
||||||
|
|
||||||
const rawDocuments = await DataReader.getOlderMessagesByConversation({
|
const rawDocuments = await DataReader.getOlderMedia({
|
||||||
conversationId,
|
conversationId,
|
||||||
includeStoryReplies: false,
|
|
||||||
limit: FETCH_CHUNK_COUNT,
|
limit: FETCH_CHUNK_COUNT,
|
||||||
messageId,
|
messageId,
|
||||||
receivedAt,
|
receivedAt,
|
||||||
requireFileAttachments: true,
|
|
||||||
sentAt,
|
sentAt,
|
||||||
storyId: undefined,
|
type: 'files',
|
||||||
});
|
});
|
||||||
|
|
||||||
const documents = _cleanFileAttachments(rawDocuments);
|
const documents = _cleanAttachments(rawDocuments);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: LOAD_MORE_DOCUMENTS,
|
type: LOAD_MORE_DOCUMENTS,
|
||||||
|
@ -431,23 +376,29 @@ export function reducer(
|
||||||
const oldestLoadedMedia = state.media[0];
|
const oldestLoadedMedia = state.media[0];
|
||||||
const oldestLoadedDocument = state.documents[0];
|
const oldestLoadedDocument = state.documents[0];
|
||||||
|
|
||||||
const newMedia = _cleanVisualAttachments(
|
const messageMediaItems: Array<MediaItemDBType> = (
|
||||||
(message.attachments ?? []).map((attachment, index) => {
|
message.attachments ?? []
|
||||||
return {
|
).map((attachment, index) => {
|
||||||
index,
|
return {
|
||||||
attachment,
|
index,
|
||||||
message: {
|
attachment,
|
||||||
id: message.id,
|
message: {
|
||||||
type: message.type,
|
id: message.id,
|
||||||
conversationId: message.conversationId,
|
type: message.type,
|
||||||
receivedAt: message.received_at,
|
conversationId: message.conversationId,
|
||||||
receivedAtMs: message.received_at_ms,
|
receivedAt: message.received_at,
|
||||||
sentAt: message.sent_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;
|
let { documents, haveOldestDocument, haveOldestMedia, media } = state;
|
||||||
|
|
||||||
|
|
|
@ -44,8 +44,6 @@ import {
|
||||||
_generateAttachmentIv,
|
_generateAttachmentIv,
|
||||||
decryptAttachmentV2,
|
decryptAttachmentV2,
|
||||||
encryptAttachmentV2ToDisk,
|
encryptAttachmentV2ToDisk,
|
||||||
getAesCbcCiphertextSize,
|
|
||||||
getAttachmentCiphertextSize,
|
|
||||||
splitKeys,
|
splitKeys,
|
||||||
generateAttachmentKeys,
|
generateAttachmentKeys,
|
||||||
type DecryptedAttachmentV2,
|
type DecryptedAttachmentV2,
|
||||||
|
@ -54,6 +52,10 @@ import {
|
||||||
import type { AciString, PniString } from '../types/ServiceId.js';
|
import type { AciString, PniString } from '../types/ServiceId.js';
|
||||||
import { createTempDir, deleteTempDir } from '../updater/common.js';
|
import { createTempDir, deleteTempDir } from '../updater/common.js';
|
||||||
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js';
|
import { uuidToBytes, bytesToUuid } from '../util/uuidToBytes.js';
|
||||||
|
import {
|
||||||
|
getAesCbcCiphertextSize,
|
||||||
|
getAttachmentCiphertextSize,
|
||||||
|
} from '../util/AttachmentCrypto.js';
|
||||||
import { getPath } from '../windows/main/attachments.js';
|
import { getPath } from '../windows/main/attachments.js';
|
||||||
import { MediaTier } from '../types/AttachmentDownload.js';
|
import { MediaTier } from '../types/AttachmentDownload.js';
|
||||||
|
|
||||||
|
|
|
@ -29,10 +29,8 @@ import {
|
||||||
import { strictAssert } from '../../util/assert.js';
|
import { strictAssert } from '../../util/assert.js';
|
||||||
import type { downloadAttachment as downloadAttachmentUtil } from '../../util/downloadAttachment.js';
|
import type { downloadAttachment as downloadAttachmentUtil } from '../../util/downloadAttachment.js';
|
||||||
import { AttachmentDownloadSource } from '../../sql/Interface.js';
|
import { AttachmentDownloadSource } from '../../sql/Interface.js';
|
||||||
import {
|
import { generateAttachmentKeys } from '../../AttachmentCrypto.js';
|
||||||
generateAttachmentKeys,
|
import { getAttachmentCiphertextSize } from '../../util/AttachmentCrypto.js';
|
||||||
getAttachmentCiphertextSize,
|
|
||||||
} from '../../AttachmentCrypto.js';
|
|
||||||
import { MEBIBYTE } from '../../types/AttachmentSize.js';
|
import { MEBIBYTE } from '../../types/AttachmentSize.js';
|
||||||
import { generateAci } from '../../types/ServiceId.js';
|
import { generateAci } from '../../types/ServiceId.js';
|
||||||
import { toBase64, toHex } from '../../Bytes.js';
|
import { toBase64, toHex } from '../../Bytes.js';
|
||||||
|
|
|
@ -4,7 +4,8 @@
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { Readable } from 'node:stream';
|
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 = [
|
const BUCKET_SIZES = [
|
||||||
541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071,
|
541, 568, 596, 626, 657, 690, 725, 761, 799, 839, 881, 925, 972, 1020, 1071,
|
||||||
|
|
|
@ -22,7 +22,6 @@ import {
|
||||||
} from '../types/Attachment.js';
|
} from '../types/Attachment.js';
|
||||||
import * as Bytes from '../Bytes.js';
|
import * as Bytes from '../Bytes.js';
|
||||||
import {
|
import {
|
||||||
getAttachmentCiphertextSize,
|
|
||||||
safeUnlink,
|
safeUnlink,
|
||||||
splitKeys,
|
splitKeys,
|
||||||
type ReencryptedAttachmentV2,
|
type ReencryptedAttachmentV2,
|
||||||
|
@ -32,6 +31,7 @@ import {
|
||||||
} from '../AttachmentCrypto.js';
|
} from '../AttachmentCrypto.js';
|
||||||
import type { ProcessedAttachment } from './Types.d.ts';
|
import type { ProcessedAttachment } from './Types.d.ts';
|
||||||
import type { WebAPIType } from './WebAPI.js';
|
import type { WebAPIType } from './WebAPI.js';
|
||||||
|
import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js';
|
||||||
import { createName, getRelativePath } from '../util/attachmentPath.js';
|
import { createName, getRelativePath } from '../util/attachmentPath.js';
|
||||||
import { MediaTier } from '../types/AttachmentDownload.js';
|
import { MediaTier } from '../types/AttachmentDownload.js';
|
||||||
import {
|
import {
|
||||||
|
|
44
ts/util/AttachmentCrypto.ts
Normal file
44
ts/util/AttachmentCrypto.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1507,6 +1507,13 @@
|
||||||
"updated": "2025-06-26T23:23:57.292Z",
|
"updated": "2025-06-26T23:23:57.292Z",
|
||||||
"reasonDetail": "Holding on to a close function"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/PreferencesLocalBackups.tsx",
|
"path": "ts/components/PreferencesLocalBackups.tsx",
|
||||||
|
@ -1515,13 +1522,6 @@
|
||||||
"updated": "2025-05-30T22:48:14.420Z",
|
"updated": "2025-05-30T22:48:14.420Z",
|
||||||
"reasonDetail": "For focusing the settings backup key viewer textarea"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/ProfileEditor.tsx",
|
"path": "ts/components/ProfileEditor.tsx",
|
||||||
|
@ -1711,13 +1711,6 @@
|
||||||
"updated": "2025-05-28T00:57:39.376Z",
|
"updated": "2025-05-28T00:57:39.376Z",
|
||||||
"reasonDetail": "Holding on to a close function"
|
"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",
|
"rule": "React-useRef",
|
||||||
"path": "ts/components/conversation/CallingNotification.tsx",
|
"path": "ts/components/conversation/CallingNotification.tsx",
|
||||||
|
|
9
ts/util/logPadSize.ts
Normal file
9
ts/util/logPadSize.ts
Normal 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)))
|
||||||
|
);
|
||||||
|
}
|
|
@ -4,14 +4,9 @@
|
||||||
import { Transform } from 'node:stream';
|
import { Transform } from 'node:stream';
|
||||||
import type { Duplex, Readable } 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 {
|
const PADDING_CHUNK_SIZE = 64 * 1024;
|
||||||
return Math.max(
|
|
||||||
541,
|
|
||||||
Math.floor(1.05 ** Math.ceil(Math.log(size) / Math.log(1.05)))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates iterator that yields zero-filled padding chunks.
|
* Creates iterator that yields zero-filled padding chunks.
|
||||||
|
|
|
@ -21,7 +21,7 @@ import { handleVideoAttachment } from './handleVideoAttachment.js';
|
||||||
import { isHeic, stringToMIMEType } from '../types/MIME.js';
|
import { isHeic, stringToMIMEType } from '../types/MIME.js';
|
||||||
import { ToastType } from '../types/Toast.js';
|
import { ToastType } from '../types/Toast.js';
|
||||||
import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome.js';
|
import { isImageTypeSupported, isVideoTypeSupported } from './GoogleChrome.js';
|
||||||
import { getAttachmentCiphertextSize } from '../AttachmentCrypto.js';
|
import { getAttachmentCiphertextSize } from './AttachmentCrypto.js';
|
||||||
import { MediaTier } from '../types/AttachmentDownload.js';
|
import { MediaTier } from '../types/AttachmentDownload.js';
|
||||||
|
|
||||||
const log = createLogger('processAttachment');
|
const log = createLogger('processAttachment');
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue