Signal-Desktop/ts/components/conversation/AttachmentStatusIcon.tsx

170 lines
4.8 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useRef, useState } from 'react';
import classNames from 'classnames';
import { ProgressCircle } from '../ProgressCircle';
import { usePrevious } from '../../hooks/usePrevious';
import type { AttachmentForUIType } from '../../types/Attachment';
import { roundFractionForProgressBar } from '../../util/numbers';
const TRANSITION_DELAY = 200;
export type PropsType = {
attachment: AttachmentForUIType | undefined;
isAttachmentNotAvailable: boolean;
isIncoming: boolean;
renderAttachmentDownloaded: () => JSX.Element;
};
enum IconState {
NeedsDownload = 'NeedsDownload',
Downloading = 'Downloading',
Downloaded = 'Downloaded',
}
export function AttachmentStatusIcon({
attachment,
isAttachmentNotAvailable,
isIncoming,
renderAttachmentDownloaded,
}: PropsType): JSX.Element | null {
const [isWaiting, setIsWaiting] = useState<boolean>(false);
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 (
<div className="AttachmentStatusIcon__container">
<div
className={classNames(
'AttachmentStatusIcon__circle-icon-container',
isIncoming
? 'AttachmentStatusIcon__circle-icon-container--incoming'
: undefined
)}
>
<div
className={classNames(
'AttachmentStatusIcon__circle-icon',
isIncoming
? 'AttachmentStatusIcon__circle-icon--incoming'
: undefined,
'AttachmentStatusIcon__circle-icon--arrow-down'
)}
/>
</div>
</div>
);
}
if (
attachment &&
(state === IconState.Downloading ||
(state === IconState.Downloaded && isWaiting))
) {
const { size, totalDownloaded } = attachment;
let downloadFraction =
size && totalDownloaded
? roundFractionForProgressBar(totalDownloaded / size)
: undefined;
if (state === IconState.Downloading && isWaiting) {
downloadFraction = undefined;
}
if (state === IconState.Downloaded && isWaiting) {
downloadFraction = 1;
}
return (
<div className="AttachmentStatusIcon__container">
<div
className={classNames(
'AttachmentStatusIcon__circle-icon-container',
isIncoming
? 'AttachmentStatusIcon__circle-icon-container--incoming'
: undefined
)}
>
{downloadFraction ? (
<div
className={classNames(
'AttachmentStatusIcon__progress-container',
isIncoming
? 'AttachmentStatusIcon__progress-container--incoming'
: undefined
)}
>
<ProgressCircle
fractionComplete={downloadFraction}
width={36}
strokeWidth={2}
/>
</div>
) : undefined}
<div
className={classNames(
'AttachmentStatusIcon__circle-icon',
isIncoming
? 'AttachmentStatusIcon__circle-icon--incoming'
: undefined,
'AttachmentStatusIcon__circle-icon--x'
)}
/>
</div>
</div>
);
}
return (
<div className="AttachmentStatusIcon__container">
{renderAttachmentDownloaded()}
</div>
);
}