Unverified Commit 2ccb98a6 authored by memoclaw's avatar memoclaw Committed by GitHub

fix: render audio attachments as inline players (#5699)

Co-authored-by: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 45036791
...@@ -28,6 +28,10 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps) ...@@ -28,6 +28,10 @@ const AttachmentCard = ({ attachment, onClick, className }: AttachmentCardProps)
return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="metadata" />; return <video src={sourceUrl} className={cn("w-full h-full object-cover rounded-lg", className)} controls preload="metadata" />;
} }
if (attachmentType === "audio/*") {
return <audio src={sourceUrl} className={cn("w-full rounded-lg", className)} controls preload="metadata" />;
}
return null; return null;
}; };
......
import { FileIcon, PaperclipIcon } from "lucide-react"; import { FileAudioIcon, FileIcon, PaperclipIcon } from "lucide-react";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb"; import type { Attachment } from "@/types/proto/api/v1/attachment_service_pb";
import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment"; import { getAttachmentType, getAttachmentUrl } from "@/utils/attachment";
...@@ -11,25 +11,26 @@ interface AttachmentListProps { ...@@ -11,25 +11,26 @@ interface AttachmentListProps {
attachments: Attachment[]; attachments: Attachment[];
} }
// Type guards for attachment types
const isImageAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "image/*"; const isImageAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "image/*";
const isVideoAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "video/*"; const isVideoAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "video/*";
const isMediaAttachment = (attachment: Attachment): boolean => isImageAttachment(attachment) || isVideoAttachment(attachment); const isAudioAttachment = (attachment: Attachment): boolean => getAttachmentType(attachment) === "audio/*";
// Separate attachments into media (images/videos) and documents const separateAttachments = (attachments: Attachment[]) => {
const separateMediaAndDocs = (attachments: Attachment[]): { media: Attachment[]; docs: Attachment[] } => { const visual: Attachment[] = [];
const media: Attachment[] = []; const audio: Attachment[] = [];
const docs: Attachment[] = []; const docs: Attachment[] = [];
for (const attachment of attachments) { for (const attachment of attachments) {
if (isMediaAttachment(attachment)) { if (isImageAttachment(attachment) || isVideoAttachment(attachment)) {
media.push(attachment); visual.push(attachment);
} else if (isAudioAttachment(attachment)) {
audio.push(attachment);
} else { } else {
docs.push(attachment); docs.push(attachment);
} }
} }
return { media, docs }; return { visual, audio, docs };
}; };
const DocumentItem = ({ attachment }: { attachment: Attachment }) => { const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
...@@ -60,16 +61,30 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => { ...@@ -60,16 +61,30 @@ const DocumentItem = ({ attachment }: { attachment: Attachment }) => {
); );
}; };
interface MediaItemProps { const AudioItem = ({ attachment }: { attachment: Attachment }) => {
const sourceUrl = getAttachmentUrl(attachment);
return (
<div className="flex flex-col gap-1 px-1 py-1">
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<FileAudioIcon className="w-3 h-3 shrink-0" />
<span className="truncate" title={attachment.filename}>
{attachment.filename}
</span>
</div>
<audio src={sourceUrl} controls preload="metadata" className="w-full h-8" />
</div>
);
};
interface VisualItemProps {
attachment: Attachment; attachment: Attachment;
onImageClick: (url: string) => void; onImageClick: (url: string) => void;
} }
const MediaItem = ({ attachment, onImageClick }: MediaItemProps) => { const VisualItem = ({ attachment, onImageClick }: VisualItemProps) => {
const isImage = isImageAttachment(attachment);
const handleClick = () => { const handleClick = () => {
if (isImage) { if (isImageAttachment(attachment)) {
onImageClick(getAttachmentUrl(attachment)); onImageClick(getAttachmentUrl(attachment));
} }
}; };
...@@ -84,15 +99,18 @@ const MediaItem = ({ attachment, onImageClick }: MediaItemProps) => { ...@@ -84,15 +99,18 @@ const MediaItem = ({ attachment, onImageClick }: MediaItemProps) => {
); );
}; };
interface MediaGridProps { const VisualGrid = ({ attachments, onImageClick }: { attachments: Attachment[]; onImageClick: (url: string) => void }) => (
attachments: Attachment[];
onImageClick: (url: string) => void;
}
const MediaGrid = ({ attachments, onImageClick }: MediaGridProps) => (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2">
{attachments.map((attachment) => ( {attachments.map((attachment) => (
<MediaItem key={attachment.name} attachment={attachment} onImageClick={onImageClick} /> <VisualItem key={attachment.name} attachment={attachment} onImageClick={onImageClick} />
))}
</div>
);
const AudioList = ({ attachments }: { attachments: Attachment[] }) => (
<div className="flex flex-col gap-1">
{attachments.map((attachment) => (
<AudioItem key={attachment.name} attachment={attachment} />
))} ))}
</div> </div>
); );
...@@ -107,6 +125,8 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => ( ...@@ -107,6 +125,8 @@ const DocsList = ({ attachments }: { attachments: Attachment[] }) => (
</div> </div>
); );
const Divider = () => <div className="border-t mt-1 border-border opacity-60" />;
const AttachmentList = ({ attachments }: AttachmentListProps) => { const AttachmentList = ({ attachments }: AttachmentListProps) => {
const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({ const [previewImage, setPreviewImage] = useState<{ open: boolean; urls: string[]; index: number; mimeType?: string }>({
open: false, open: false,
...@@ -115,10 +135,9 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => { ...@@ -115,10 +135,9 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
mimeType: undefined, mimeType: undefined,
}); });
const { media: mediaItems, docs: docItems } = useMemo(() => separateMediaAndDocs(attachments), [attachments]); const { visual, audio, docs } = useMemo(() => separateAttachments(attachments), [attachments]);
// Pre-compute image URLs for preview dialog to avoid filtering on every click const imageAttachments = useMemo(() => visual.filter(isImageAttachment), [visual]);
const imageAttachments = useMemo(() => mediaItems.filter(isImageAttachment), [mediaItems]);
const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]); const imageUrls = useMemo(() => imageAttachments.map(getAttachmentUrl), [imageAttachments]);
if (attachments.length === 0) { if (attachments.length === 0) {
...@@ -131,17 +150,24 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => { ...@@ -131,17 +150,24 @@ const AttachmentList = ({ attachments }: AttachmentListProps) => {
setPreviewImage({ open: true, urls: imageUrls, index, mimeType }); setPreviewImage({ open: true, urls: imageUrls, index, mimeType });
}; };
const sections = [visual.length > 0, audio.length > 0, docs.length > 0];
const sectionCount = sections.filter(Boolean).length;
return ( return (
<> <>
<div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden"> <div className="w-full rounded-lg border border-border bg-muted/20 overflow-hidden">
<SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} /> <SectionHeader icon={PaperclipIcon} title="Attachments" count={attachments.length} />
<div className="p-1.5 flex flex-col gap-1"> <div className="p-1.5 flex flex-col gap-1">
{mediaItems.length > 0 && <MediaGrid attachments={mediaItems} onImageClick={handleImageClick} />} {visual.length > 0 && <VisualGrid attachments={visual} onImageClick={handleImageClick} />}
{visual.length > 0 && sectionCount > 1 && <Divider />}
{audio.length > 0 && <AudioList attachments={audio} />}
{mediaItems.length > 0 && docItems.length > 0 && <div className="border-t mt-1 border-border opacity-60" />} {audio.length > 0 && docs.length > 0 && <Divider />}
{docItems.length > 0 && <DocsList attachments={docItems} />} {docs.length > 0 && <DocsList attachments={docs} />}
</div> </div>
</div> </div>
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment