mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 20:10:56 +00:00
123 lines
3.4 KiB
TypeScript
123 lines
3.4 KiB
TypeScript
import {
|
|
isSupportAttachmentImage,
|
|
type SupportAttachmentDto,
|
|
} from "@edr/types";
|
|
import { Box, Group, Image, Loader, Paper, Stack, Text } from "@mantine/core";
|
|
import { FileText, ImageOff } from "lucide-react";
|
|
|
|
import { useAttachmentObjectUrl } from "./useAttachmentObjectUrl";
|
|
|
|
/** Human-readable size — kept coarse; nobody needs bytes in a chat bubble. */
|
|
export function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
/** Cap the bubble: a tall screenshot would push the conversation off-screen. */
|
|
const THUMB = { maxHeight: 220, maxWidth: 260 } as const;
|
|
|
|
/**
|
|
* One image attachment. Its own component because the bytes are fetched through
|
|
* the authenticated client (see {@link useAttachmentObjectUrl}) and a hook can't
|
|
* be called from inside a `.map()`.
|
|
*/
|
|
function ImageAttachment({
|
|
a,
|
|
onView,
|
|
}: {
|
|
a: SupportAttachmentDto;
|
|
onView: (a: SupportAttachmentDto, src: string) => void;
|
|
}) {
|
|
const { src, failed } = useAttachmentObjectUrl(a.url);
|
|
|
|
if (failed) {
|
|
return (
|
|
<Group gap={6} c="dimmed">
|
|
<ImageOff size={14} />
|
|
<Text size="xs">Couldn't load {a.name}</Text>
|
|
</Group>
|
|
);
|
|
}
|
|
|
|
if (!src) {
|
|
return (
|
|
<Box
|
|
style={{
|
|
width: THUMB.maxWidth,
|
|
height: 140,
|
|
display: "grid",
|
|
placeItems: "center",
|
|
background: "var(--mantine-color-gray-1)",
|
|
borderRadius: 8,
|
|
}}
|
|
>
|
|
<Loader size="xs" color="edr-green" />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<Box
|
|
onClick={() => onView(a, src)}
|
|
style={{ cursor: "zoom-in", borderRadius: 8, overflow: "hidden" }}
|
|
>
|
|
<Image src={src} alt={a.name} radius="md" fit="cover" style={THUMB} />
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Attachments inside a message bubble: images as thumbnails, everything else as
|
|
* a labelled file row. Non-images are not fetched until opened — pulling every
|
|
* document in a thread just to draw a filename would be wasteful.
|
|
*/
|
|
export function MessageAttachments({
|
|
attachments,
|
|
mine,
|
|
onView,
|
|
onOpenFile,
|
|
}: {
|
|
attachments: SupportAttachmentDto[];
|
|
mine: boolean;
|
|
onView: (a: SupportAttachmentDto, src: string) => void;
|
|
onOpenFile: (a: SupportAttachmentDto) => void;
|
|
}) {
|
|
if (attachments.length === 0) return null;
|
|
|
|
return (
|
|
<Stack gap={6} mt={6}>
|
|
{attachments.map((a) =>
|
|
isSupportAttachmentImage(a.mimeType) ? (
|
|
<ImageAttachment key={a.id} a={a} onView={onView} />
|
|
) : (
|
|
<Paper
|
|
key={a.id}
|
|
onClick={() => onOpenFile(a)}
|
|
px="sm"
|
|
py={6}
|
|
radius="md"
|
|
style={{
|
|
cursor: "pointer",
|
|
background: mine ? "rgba(255,255,255,0.16)" : "white",
|
|
border: mine ? "none" : "1px solid var(--mantine-color-gray-3)",
|
|
}}
|
|
>
|
|
<Group gap={8} wrap="nowrap">
|
|
<FileText size={16} style={{ flexShrink: 0 }} />
|
|
<Box style={{ minWidth: 0 }}>
|
|
<Text size="xs" fw={600} truncate>
|
|
{a.name}
|
|
</Text>
|
|
<Text size="10px" opacity={0.75}>
|
|
{formatBytes(a.size)}
|
|
</Text>
|
|
</Box>
|
|
</Group>
|
|
</Paper>
|
|
),
|
|
)}
|
|
</Stack>
|
|
);
|
|
}
|