2025-03-04 10:09:43 +10:00
|
|
|
// Copyright 2025 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
2025-09-10 13:25:21 -07:00
|
|
|
import React, { useRef, useState, type ReactNode } from 'react';
|
2025-03-04 10:09:43 +10:00
|
|
|
import classNames from 'classnames';
|
|
|
|
|
2025-08-29 11:55:52 -07:00
|
|
|
import { SpinnerV2 } from '../SpinnerV2';
|
2025-03-04 10:09:43 +10:00
|
|
|
import { usePrevious } from '../../hooks/usePrevious';
|
|
|
|
|
|
|
|
import type { AttachmentForUIType } from '../../types/Attachment';
|
|
|
|
|
|
|
|
const TRANSITION_DELAY = 200;
|
|
|
|
|
|
|
|
export type PropsType = {
|
|
|
|
attachment: AttachmentForUIType | undefined;
|
2025-09-10 13:25:21 -07:00
|
|
|
isExpired?: boolean;
|
2025-03-04 10:09:43 +10:00
|
|
|
isIncoming: boolean;
|
2025-09-10 13:25:21 -07:00
|
|
|
children?: ReactNode;
|
2025-03-04 10:09:43 +10:00
|
|
|
};
|
|
|
|
|
|
|
|
enum IconState {
|
|
|
|
NeedsDownload = 'NeedsDownload',
|
|
|
|
Downloading = 'Downloading',
|
|
|
|
Downloaded = 'Downloaded',
|
|
|
|
}
|
|
|
|
|
|
|
|
export function AttachmentStatusIcon({
|
|
|
|
attachment,
|
2025-09-10 13:25:21 -07:00
|
|
|
isExpired,
|
2025-03-04 10:09:43 +10:00
|
|
|
isIncoming,
|
2025-09-10 13:25:21 -07:00
|
|
|
children,
|
2025-03-04 10:09:43 +10:00
|
|
|
}: PropsType): JSX.Element | null {
|
|
|
|
const [isWaiting, setIsWaiting] = useState<boolean>(false);
|
|
|
|
|
2025-09-10 13:25:21 -07:00
|
|
|
const isAttachmentNotAvailable =
|
|
|
|
isExpired ||
|
|
|
|
(attachment != null &&
|
|
|
|
attachment.isPermanentlyUndownloadable &&
|
|
|
|
!attachment.wasTooBig);
|
|
|
|
|
2025-03-04 10:09:43 +10:00
|
|
|
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;
|
2025-08-29 11:55:52 -07:00
|
|
|
let spinnerValue = (size && totalDownloaded) || undefined;
|
2025-03-04 10:09:43 +10:00
|
|
|
if (state === IconState.Downloading && isWaiting) {
|
2025-08-29 11:55:52 -07:00
|
|
|
spinnerValue = undefined;
|
2025-03-04 10:09:43 +10:00
|
|
|
}
|
|
|
|
if (state === IconState.Downloaded && isWaiting) {
|
2025-08-29 11:55:52 -07:00
|
|
|
spinnerValue = size;
|
2025-03-04 10:09:43 +10:00
|
|
|
}
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="AttachmentStatusIcon__container">
|
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
'AttachmentStatusIcon__circle-icon-container',
|
|
|
|
isIncoming
|
|
|
|
? 'AttachmentStatusIcon__circle-icon-container--incoming'
|
|
|
|
: undefined
|
|
|
|
)}
|
|
|
|
>
|
2025-08-29 11:55:52 -07:00
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
'AttachmentStatusIcon__progress-container',
|
|
|
|
isIncoming
|
|
|
|
? 'AttachmentStatusIcon__progress-container--incoming'
|
|
|
|
: undefined
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
<SpinnerV2
|
|
|
|
min={0}
|
|
|
|
max={size}
|
|
|
|
value={spinnerValue}
|
|
|
|
variant={isIncoming ? 'no-background-incoming' : 'no-background'}
|
|
|
|
size={36}
|
|
|
|
strokeWidth={2}
|
|
|
|
marginRatio={1}
|
|
|
|
/>
|
|
|
|
</div>
|
2025-03-04 10:09:43 +10:00
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
'AttachmentStatusIcon__circle-icon',
|
|
|
|
isIncoming
|
|
|
|
? 'AttachmentStatusIcon__circle-icon--incoming'
|
|
|
|
: undefined,
|
|
|
|
'AttachmentStatusIcon__circle-icon--x'
|
|
|
|
)}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-09-10 13:25:21 -07:00
|
|
|
return <div className="AttachmentStatusIcon__container">{children}</div>;
|
2025-03-04 10:09:43 +10:00
|
|
|
}
|