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';
|
||||
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,
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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' }}>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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')}
|
||||
|
|
|
@ -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} />
|
||||
|
||||
</>
|
||||
);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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'];
|
||||
};
|
||||
|
|
|
@ -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'
|
||||
? {
|
||||
|
|
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 {
|
||||
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,
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -581,6 +581,7 @@ export type GetOlderMediaOptionsType = Readonly<{
|
|||
messageId?: string;
|
||||
receivedAt?: number;
|
||||
sentAt?: number;
|
||||
type: 'media' | 'files';
|
||||
}>;
|
||||
|
||||
export type MediaItemDBType = Readonly<{
|
||||
|
|
|
@ -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})
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
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",
|
||||
"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
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 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.
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue