Skip to content
Source
Sign inSign up

GuestMessageAttachments.tsx

103 lines3.9 KB

1import type { ChatAttachment } from "@t3tools/contracts";
2import { formatAttachmentSize } from "@t3tools/client-runtime/state/attachments";
3​
4import { useTheme } from "~/hooks/useTheme";
5import { cn } from "~/lib/utils";
6import { type ChatFileAttachment, isFileAttachment, isImageAttachment } from "../../types";
7import { AttachmentImage } from "../media/AttachmentImage";
8import { PierreEntryIcon } from "./PierreEntryIcon";
9​
10const IMAGE_FRAME =
11 "aspect-[4/3] overflow-hidden rounded-lg border border-border/80 bg-background/70";
12const FILE_ROW = "flex min-w-0 items-center gap-2 py-1 text-sm";
13​
14/**
15 * A guest message's files, drawn like a sent message's: image thumbnails, then file rows. The
16 * owner opens them in the app; the guest loads them from the guest route.
17 */
18export function GuestMessageAttachments(props: {
19 readonly attachments: ReadonlyArray<ChatAttachment>;
20 /** Where an image loads from; undefined while its URL is not ready. */
21 readonly imageUrl: (attachment: ChatAttachment) => string | undefined;
22 readonly onOpenImage: (attachment: ChatAttachment) => void;
23 /** A file row as a link, or a button that calls `onOpenFile`. */
24 readonly fileHref?: (attachment: ChatFileAttachment) => string;
25 readonly onOpenFile?: (attachment: ChatFileAttachment) => void;
26}) {
27 const { resolvedTheme } = useTheme();
28 const images = props.attachments.filter(isImageAttachment);
29 const files = props.attachments.filter(isFileAttachment);
30 if (images.length === 0 && files.length === 0) return null;
31 return (
32 <div className="flex flex-col items-end gap-2">
33 {images.length > 0 ? (
34 <div className="grid w-[210px] grid-cols-2 gap-2">
35 {images.map((image) => {
36 const src = props.imageUrl(image);
37 return (
38 <div key={image.id} className={IMAGE_FRAME}>
39 {src ? (
40 <button
41 type="button"
42 className="block size-full cursor-zoom-in"
43 aria-label={`Preview ${image.name}`}
44 onClick={() => props.onOpenImage(image)}
45 >
46 {/* Isolate the thumbnail raster from nearby activity repaints in Chromium. */}
47 <AttachmentImage
48 name={image.name}
49 mimeType={image.mimeType}
50 src={src}
51 alt={image.name}
52 decoding="async"
53 className="block size-full transform-gpu object-cover"
54 />
55 </button>
56 ) : (
57 <div className="flex size-full items-center justify-center px-2 text-center text-2xs text-muted-foreground/70">
58 {image.name}
59 </div>
60 )}
61 </div>
62 );
63 })}
64 </div>
65 ) : null}
66 {files.map((file) => {
67 const identity = (
68 <>
69 <PierreEntryIcon pathValue={file.name} kind="file" theme={resolvedTheme} />
70 <span className="min-w-0 truncate">{file.name}</span>
71 <span className="shrink-0 text-xs text-muted-foreground">
72 {formatAttachmentSize(file.sizeBytes)}
73 </span>
74 </>
75 );
76 return props.fileHref ? (
77 <a
78 key={file.id}
79 href={props.fileHref(file)}
80 download={file.name}
81 className={cn(FILE_ROW, "hover:underline")}
82 >
83 {identity}
84 </a>
85 ) : props.onOpenFile ? (
86 <button
87 key={file.id}
88 type="button"
89 aria-label={`Preview ${file.name}`}
90 onClick={() => props.onOpenFile?.(file)}
91 className={cn(FILE_ROW, "cursor-pointer hover:underline")}
92 >
93 {identity}
94 </button>
95 ) : (
96 <div key={file.id} className={FILE_ROW}>
97 {identity}
98 </div>
99 );
100 })}
101 </div>
102 );