163 lines
4.3 KiB
TypeScript
163 lines
4.3 KiB
TypeScript
import { useConvexAccessToken } from "@code/auth/web";
|
|
import {
|
|
Attachment,
|
|
AttachmentAction,
|
|
AttachmentActions,
|
|
AttachmentContent,
|
|
AttachmentDescription,
|
|
AttachmentGroup,
|
|
AttachmentMedia,
|
|
AttachmentTitle,
|
|
} from "@code/ui/components/attachment";
|
|
import { ImageIcon, X } from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
import type { PendingChatImage } from "@/lib/chat/attachments";
|
|
import type { ConversationPart } from "@/lib/chat/types";
|
|
|
|
type FilePart = Extract<ConversationPart, { type: "file" }>;
|
|
|
|
const isDirectlyRenderableUrl = (url: string): boolean =>
|
|
url.startsWith("blob:") || url.startsWith("data:");
|
|
|
|
const useAuthenticatedImageSource = (url?: string): string | undefined => {
|
|
const resolveAccessToken = useConvexAccessToken();
|
|
const [loaded, setLoaded] = useState<{
|
|
readonly source: string;
|
|
readonly url: string;
|
|
}>();
|
|
|
|
useEffect(() => {
|
|
if (!url || isDirectlyRenderableUrl(url)) {
|
|
return;
|
|
}
|
|
let active = true;
|
|
let objectUrl: string | undefined;
|
|
const load = async (): Promise<void> => {
|
|
try {
|
|
const accessToken = await resolveAccessToken();
|
|
const response = await fetch(url, {
|
|
headers: accessToken
|
|
? { authorization: `Bearer ${accessToken}` }
|
|
: undefined,
|
|
});
|
|
if (!response.ok) {
|
|
return;
|
|
}
|
|
objectUrl = URL.createObjectURL(await response.blob());
|
|
if (active) {
|
|
setLoaded({ source: objectUrl, url });
|
|
}
|
|
} catch {
|
|
// The attachment remains represented by its filename and media type.
|
|
}
|
|
};
|
|
void load();
|
|
return () => {
|
|
active = false;
|
|
if (objectUrl) {
|
|
URL.revokeObjectURL(objectUrl);
|
|
}
|
|
};
|
|
}, [resolveAccessToken, url]);
|
|
|
|
if (url && isDirectlyRenderableUrl(url)) {
|
|
return url;
|
|
}
|
|
return loaded && loaded.url === url ? loaded.source : undefined;
|
|
};
|
|
|
|
const ImagePreview = ({
|
|
alt,
|
|
mediaType,
|
|
url,
|
|
}: {
|
|
readonly alt: string;
|
|
readonly mediaType: string;
|
|
readonly url?: string;
|
|
}) => {
|
|
const source = useAuthenticatedImageSource(url);
|
|
return (
|
|
<AttachmentMedia
|
|
variant={mediaType.startsWith("image/") ? "image" : "icon"}
|
|
>
|
|
{source && mediaType.startsWith("image/") ? (
|
|
<img alt={alt} className="size-full object-cover" src={source} />
|
|
) : (
|
|
<ImageIcon className="size-4" />
|
|
)}
|
|
</AttachmentMedia>
|
|
);
|
|
};
|
|
|
|
export const PendingChatAttachments = ({
|
|
images,
|
|
onRemove,
|
|
}: {
|
|
readonly images: readonly PendingChatImage[];
|
|
readonly onRemove: (id: string) => void;
|
|
}) => (
|
|
<AttachmentGroup className="max-w-full gap-2 pb-2">
|
|
{images.map((image) => (
|
|
<Attachment
|
|
className="w-24 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
|
|
key={image.id}
|
|
orientation="vertical"
|
|
size="sm"
|
|
>
|
|
<ImagePreview
|
|
alt={image.file.name}
|
|
mediaType={image.file.type}
|
|
url={image.previewUrl}
|
|
/>
|
|
<AttachmentContent>
|
|
<AttachmentTitle>{image.file.name}</AttachmentTitle>
|
|
</AttachmentContent>
|
|
<AttachmentActions>
|
|
<AttachmentAction
|
|
aria-label={`Remove ${image.file.name}`}
|
|
className="bg-white/90 hover:bg-white"
|
|
onClick={() => onRemove(image.id)}
|
|
>
|
|
<X className="size-3.5" />
|
|
</AttachmentAction>
|
|
</AttachmentActions>
|
|
</Attachment>
|
|
))}
|
|
</AttachmentGroup>
|
|
);
|
|
|
|
export const MessageAttachments = ({
|
|
parts,
|
|
}: {
|
|
readonly parts: readonly FilePart[];
|
|
}) => {
|
|
if (parts.length === 0) {
|
|
return null;
|
|
}
|
|
return (
|
|
<AttachmentGroup className="mb-2 max-w-full gap-2">
|
|
{parts.map((part, index) => {
|
|
const title = part.filename ?? `Image ${index + 1}`;
|
|
return (
|
|
<Attachment
|
|
className="min-w-0 max-w-48 border-[#d7d3c7] bg-[#fffefa] text-[#20201d]"
|
|
key={part.id ?? `${part.mediaType}:${index}`}
|
|
size="sm"
|
|
>
|
|
<ImagePreview
|
|
alt={title}
|
|
mediaType={part.mediaType}
|
|
url={part.url}
|
|
/>
|
|
<AttachmentContent>
|
|
<AttachmentTitle>{title}</AttachmentTitle>
|
|
<AttachmentDescription>{part.mediaType}</AttachmentDescription>
|
|
</AttachmentContent>
|
|
</Attachment>
|
|
);
|
|
})}
|
|
</AttachmentGroup>
|
|
);
|
|
};
|