Show ready-to-download documents in media gallery

This commit is contained in:
Fedor Indutny
2025-09-23 11:53:41 -07:00
committed by GitHub
parent e9ea20bb73
commit 9c97d3e73c
30 changed files with 481 additions and 314 deletions

View File

@@ -0,0 +1,88 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { getUrl, type AttachmentForUIType } from '../types/Attachment.js';
import { MediaTier } from '../types/AttachmentDownload.js';
import { missingCaseError } from '../util/missingCaseError.js';
import { getAttachmentCiphertextSize } from '../util/AttachmentCrypto.js';
import { useDelayedValue } from './useDelayedValue.js';
const TRANSITION_DELAY = 200;
type InternalState = 'NeedsDownload' | 'Downloading' | 'ReadyToShow';
export type AttachmentStatusType = Readonly<
| {
state: 'NeedsDownload';
}
| {
state: 'Downloading';
totalDownloaded: number | undefined;
size: number;
}
| {
state: 'ReadyToShow';
}
>;
export function useAttachmentStatus(
attachment: AttachmentForUIType
): AttachmentStatusType {
const isAttachmentNotAvailable =
attachment.isPermanentlyUndownloadable && !attachment.wasTooBig;
const url = getUrl(attachment);
let nextState: InternalState = 'ReadyToShow';
if (attachment && isAttachmentNotAvailable) {
nextState = 'ReadyToShow';
} else if (attachment && url == null && !attachment.pending) {
nextState = 'NeedsDownload';
} else if (attachment && url == null && attachment.pending) {
nextState = 'Downloading';
}
const state = useDelayedValue(nextState, TRANSITION_DELAY);
// Idle
if (state === 'NeedsDownload' && nextState === state) {
return { state: 'NeedsDownload' };
}
const { size: unpaddedPlaintextSize, totalDownloaded } = attachment;
const size = getAttachmentCiphertextSize({
unpaddedPlaintextSize,
mediaTier: MediaTier.STANDARD,
});
// Transition
if (state !== nextState) {
if (nextState === 'NeedsDownload') {
return { state: 'NeedsDownload' };
}
if (nextState === 'Downloading') {
return { state: 'Downloading', size, totalDownloaded };
}
if (nextState === 'ReadyToShow') {
return { state: 'Downloading', size, totalDownloaded: size };
}
throw missingCaseError(nextState);
}
if (state === 'NeedsDownload') {
return { state: 'NeedsDownload' };
}
if (state === 'Downloading') {
return { state: 'Downloading', size, totalDownloaded };
}
if (state === 'ReadyToShow') {
return { state: 'ReadyToShow' };
}
throw missingCaseError(state);
}

View File

@@ -0,0 +1,63 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { useState, useEffect } from 'react';
import { noop } from 'lodash';
type InternalState<Value> = Readonly<
| {
type: 'transition';
from: Value;
to: Value;
}
| {
type: 'idle';
value: Value;
}
>;
export function useDelayedValue<Value>(newValue: Value, delay: number): Value {
const [state, setState] = useState<InternalState<Value>>({
type: 'idle',
value: newValue,
});
const currentValue = state.type === 'idle' ? state.value : state.from;
useEffect(() => {
if (state.type === 'idle') {
return noop;
}
const timer = setTimeout(() => {
setState({
type: 'idle',
value: state.to,
});
}, delay);
return () => {
clearTimeout(timer);
};
}, [state, delay]);
useEffect(() => {
setState(prevState => {
if (prevState.type === 'transition') {
return {
type: 'transition',
from: prevState.from,
to: newValue,
};
}
return {
type: 'transition',
from: prevState.value,
to: newValue,
};
});
}, [newValue]);
return currentValue;
}