Files
Desktop/ts/components/conversation/media-gallery/ListItem.dom.tsx
2025-11-18 14:40:01 -08:00

134 lines
3.5 KiB
TypeScript

// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback } from 'react';
import moment from 'moment';
import { missingCaseError } from '../../../util/missingCaseError.std.js';
import type { GenericMediaItemType } from '../../../types/MediaItem.std.js';
import type { AttachmentForUIType } from '../../../types/Attachment.std.js';
import type { LocalizerType } from '../../../types/Util.std.js';
import { SpinnerV2 } from '../../SpinnerV2.dom.js';
import { tw } from '../../../axo/tw.dom.js';
import { AxoSymbol } from '../../../axo/AxoSymbol.dom.js';
import {
useAttachmentStatus,
type AttachmentStatusType,
} from '../../../hooks/useAttachmentStatus.std.js';
export type Props = {
i18n: LocalizerType;
mediaItem: GenericMediaItemType;
thumbnail: React.ReactNode;
title: React.ReactNode;
subtitle: React.ReactNode;
readyLabel: string;
onClick: (status: AttachmentStatusType['state']) => void;
};
export function ListItem({
i18n,
mediaItem,
thumbnail,
title,
subtitle,
readyLabel,
onClick,
}: Props): JSX.Element {
const { message } = mediaItem;
let attachment: AttachmentForUIType | undefined;
if (mediaItem.type === 'link') {
attachment = mediaItem.preview.image;
} else {
({ attachment } = mediaItem);
}
const timestamp = message.receivedAtMs || message.receivedAt;
let label: string;
const status = useAttachmentStatus(attachment);
const handleClick = useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
onClick?.(status?.state || 'ReadyToShow');
},
[onClick, status?.state]
);
if (status == null || status.state === 'ReadyToShow') {
label = readyLabel;
} else if (status.state === 'NeedsDownload') {
label = i18n('icu:downloadAttachment');
} else if (status.state === 'Downloading') {
label = i18n('icu:cancelDownload');
} else {
throw missingCaseError(status);
}
let button: JSX.Element | undefined;
if (
status != null &&
status.state !== 'ReadyToShow' &&
mediaItem.type !== 'link'
) {
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 flex items-center justify-center 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 gap-3 py-2',
mediaItem.type === 'link' ? undefined : 'items-center'
)}
type="button"
onClick={handleClick}
aria-label={label}
>
<div className={tw('shrink-0')}>{thumbnail}</div>
<div className={tw('grow overflow-hidden text-start')}>
<h3 className={tw('truncate')}>{title}</h3>
<div className={tw('type-body-small leading-4 text-label-secondary')}>
{subtitle}
</div>
</div>
<div className={tw('shrink-0 type-body-small text-label-secondary')}>
{moment(timestamp).format('MMM D')}
</div>
{button}
</button>
);
}