From d6280439d08905cf266f7940e676a7bc146742b2 Mon Sep 17 00:00:00 2001 From: Arjun Patel Date: Mon, 30 Mar 2026 10:31:24 -0700 Subject: [PATCH] feat: support file and image attachments (#98) * upload & view attachments to particles * allow download of attachments --- js/src/features/compose/attachment-strip.tsx | 165 ++++++++++++++++++ js/src/features/compose/compose-overlay.tsx | 147 +++++++++++++++- js/src/features/compose/recording-overlay.tsx | 80 +++++++-- js/src/features/compose/text-compose-step.tsx | 88 +++++++--- .../particles/media-particle-view.tsx | 16 ++ .../particles/particle-attachments.tsx | 138 +++++++++++++++ js/src/features/particles/stream-view.tsx | 2 + .../features/particles/text-particle-view.tsx | 88 +++++++--- js/src/hooks/use-file-input.ts | 109 ++++++++++++ js/src/hooks/use-link-metadata.ts | 28 ++- js/src/hooks/use-particle-attachments.ts | 29 +++ js/src/hooks/use-particle.ts | 14 ++ js/src/lib/constants.ts | 6 + js/src/lib/firestore-particles.ts | 14 ++ js/src/lib/image-thumbnail.ts | 24 +++ 15 files changed, 874 insertions(+), 74 deletions(-) create mode 100644 js/src/features/compose/attachment-strip.tsx create mode 100644 js/src/features/particles/particle-attachments.tsx create mode 100644 js/src/hooks/use-file-input.ts create mode 100644 js/src/hooks/use-particle-attachments.ts create mode 100644 js/src/lib/image-thumbnail.ts diff --git a/js/src/features/compose/attachment-strip.tsx b/js/src/features/compose/attachment-strip.tsx new file mode 100644 index 0000000..33c6ac5 --- /dev/null +++ b/js/src/features/compose/attachment-strip.tsx @@ -0,0 +1,165 @@ +import { FileIcon, Globe, Loader2, Plus, X } from "lucide-react"; +import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; +import { Skeleton } from "@/components/ui/skeleton"; +import { cn } from "@/lib/utils"; +import type { LinkPreviewEntry } from "@/hooks/use-link-metadata"; + +export interface PendingAttachment { + id: string; + file: File; + thumbnailUrl?: string; + status: "pending" | "uploading" | "uploaded" | "error"; +} + +interface AttachmentStripProps { + attachments: PendingAttachment[]; + onRemove: (id: string) => void; + onAddClick: () => void; + linkPreviews?: LinkPreviewEntry[]; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function AttachmentThumbnail({ + attachment, + onRemove, +}: { + attachment: PendingAttachment; + onRemove: () => void; +}) { + const isImage = attachment.file.type.startsWith("image/"); + const isUploading = attachment.status === "uploading"; + const isError = attachment.status === "error"; + + return ( +
+ {isImage && attachment.thumbnailUrl ? ( + {attachment.file.name} + ) : ( +
+ + + {attachment.file.name} + + + {formatFileSize(attachment.file.size)} + +
+ )} + + {isUploading && ( +
+ +
+ )} + + +
+ ); +} + +function LinkPreviewThumbnail({ entry }: { entry: LinkPreviewEntry }) { + if (entry.isLoading) { + return ( +
+ + +
+ ); + } + + if (!entry.metadata) return null; + + const { metadata } = entry; + + return ( + + ); +} + +export function AttachmentStrip({ + attachments, + onRemove, + onAddClick, + linkPreviews, +}: AttachmentStripProps) { + const hasLinks = linkPreviews && linkPreviews.length > 0; + if (attachments.length === 0 && !hasLinks) return null; + + return ( + +
+ {attachments.map((a) => ( + onRemove(a.id)} + /> + ))} + + {linkPreviews?.map((entry) => ( + + ))} + + +
+ +
+ ); +} diff --git a/js/src/features/compose/compose-overlay.tsx b/js/src/features/compose/compose-overlay.tsx index 869ca9d..26ff7b9 100644 --- a/js/src/features/compose/compose-overlay.tsx +++ b/js/src/features/compose/compose-overlay.tsx @@ -1,14 +1,19 @@ import { useCallback, useEffect, useEffectEvent, useRef, useState } from "react"; +import { toast } from "sonner"; import { useAuthStore } from "@/stores/auth-store"; import { useCreateParticle, useCreateStreamParticle } from "@/hooks/use-create-particle"; import { useRecorder } from "@/features/compose/use-recorder"; -import { particlePath } from "@/lib/particle-path"; +import { particlePath, parseParticlePath } from "@/lib/particle-path"; import type { ParticlePath } from "@/lib/particle-path"; import { RecordingOverlay } from "@/features/compose/recording-overlay"; import { TextComposeStep } from "@/features/compose/text-compose-step"; import { ConfigureStreamStep } from "@/features/compose/configure-stream-step"; import { apiClient } from "@/api/client"; import { useMediaSettingsStore } from "@/stores/media-settings-store"; +import { useFileInput } from "@/hooks/use-file-input"; +import { createImageThumbnail } from "@/lib/image-thumbnail"; +import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from "@/lib/constants"; +import type { PendingAttachment } from "@/features/compose/attachment-strip"; type ComposeStep = "idle" | "recording" | "reviewing" | "typing" | "configuring" | "submitting"; @@ -41,6 +46,8 @@ export function ComposeOverlay({ const [reviewDurationMs, setReviewDurationMs] = useState(0); const [reviewMimeType, setReviewMimeType] = useState(null); + const [attachments, setAttachments] = useState([]); + const recordingMode = useMediaSettingsStore((s) => s.recordingMode); const userId = useAuthStore((s) => s.user?.id); const createParticle = useCreateParticle(); @@ -60,6 +67,12 @@ export function ComposeOverlay({ onActiveChange?.(step !== "idle"); }, [step, onActiveChange]); + const revokeAttachmentThumbnails = useCallback((items: PendingAttachment[]) => { + for (const a of items) { + if (a.thumbnailUrl) URL.revokeObjectURL(a.thumbnailUrl); + } + }, []); + const cancel = useCallback(() => { setStepSync("idle"); setError(null); @@ -68,7 +81,57 @@ export function ComposeOverlay({ setReviewBlob(null); setReviewDurationMs(0); setReviewMimeType(null); - }, [setStepSync]); + setAttachments((prev) => { + revokeAttachmentThumbnails(prev); + return []; + }); + }, [setStepSync, revokeAttachmentThumbnails]); + + const addAttachments = useCallback(async (files: File[]) => { + const currentCount = attachments.length; + const available = MAX_ATTACHMENTS - currentCount; + if (available <= 0) { + toast.error(`Maximum ${MAX_ATTACHMENTS} attachments`); + return; + } + + const accepted = files.slice(0, available); + if (accepted.length < files.length) { + toast.error(`Maximum ${MAX_ATTACHMENTS} attachments — ${files.length - accepted.length} skipped`); + } + + const newAttachments: PendingAttachment[] = []; + for (const file of accepted) { + if (file.size > MAX_ATTACHMENT_SIZE_BYTES) { + toast.error(`${file.name} is too large (max 25 MB)`); + continue; + } + const thumbnailUrl = await createImageThumbnail(file); + newAttachments.push({ + id: crypto.randomUUID(), + file, + thumbnailUrl, + status: "pending", + }); + } + + if (newAttachments.length > 0) { + setAttachments((prev) => [...prev, ...newAttachments]); + } + }, [attachments.length]); + + const removeAttachment = useCallback((id: string) => { + setAttachments((prev) => { + const removed = prev.find((a) => a.id === id); + if (removed?.thumbnailUrl) URL.revokeObjectURL(removed.thumbnailUrl); + return prev.filter((a) => a.id !== id); + }); + }, []); + + const { openFilePicker, isDragging, dropZoneProps } = useFileInput({ + onFilesSelected: addAttachments, + enabled: step === "typing" || step === "reviewing", + }); const { startRecording, stopRecording, cancelRecording } = useRecorder({ mode: recordingMode, @@ -111,11 +174,73 @@ export function ComposeOverlay({ [networkId], ); + const uploadFile = useCallback( + async (file: File) => { + const { object_id, upload_url, upload_headers } = + await apiClient.prepareUpload({ + network_id: networkId, + name: file.name, + content_type: file.type || "application/octet-stream", + content_length: file.size, + }); + + await fetch(upload_url, { + method: "PUT", + headers: upload_headers, + body: file, + }); + + await apiClient.confirmUpload(object_id); + + return { object_id, size_bytes: file.size }; + }, + [networkId], + ); + + const uploadAttachments = useCallback( + async (parentPath: ParticlePath, parentId: string) => { + if (attachments.length === 0 || !userId) return; + + const { networkId: netId, segments } = parseParticlePath(parentPath); + const childrenPath = particlePath(netId, [...segments, parentId]); + + const results = await Promise.allSettled( + attachments.map(async (attachment) => { + setAttachments((prev) => + prev.map((a) => + a.id === attachment.id ? { ...a, status: "uploading" as const } : a, + ), + ); + + const { object_id } = await uploadFile(attachment.file); + + await createParticle.mutateAsync({ + path: childrenPath, + type: "file", + properties: { + object_id, + filename: attachment.file.name, + mime_type: attachment.file.type || "application/octet-stream", + size_bytes: attachment.file.size, + }, + createdByHumanId: userId, + }); + }), + ); + + const failed = results.filter((r) => r.status === "rejected"); + if (failed.length > 0) { + toast.error(`${failed.length} attachment${failed.length > 1 ? "s" : ""} failed to upload`); + } + }, + [attachments, userId, uploadFile, createParticle], + ); + const createChildParticle = useCallback( async (path: ParticlePath) => { if (!userId) return; - let particleId = ''; + let particleId: undefined | string; if (textContent.trim()) { particleId = await createParticle.mutateAsync({ path, @@ -142,7 +267,10 @@ export function ComposeOverlay({ }); } - onParticleCreated?.(particleId); + if (particleId) { + await uploadAttachments(path, particleId); + onParticleCreated?.(particleId); + } }, [ userId, @@ -152,6 +280,7 @@ export function ComposeOverlay({ reviewDurationMs, createParticle, uploadMedia, + uploadAttachments, onParticleCreated ], ); @@ -293,6 +422,11 @@ export function ComposeOverlay({ reviewBlob={reviewBlob} error={error} onClose={cancel} + attachments={attachments} + onRemoveAttachment={removeAttachment} + onAddFiles={openFilePicker} + isDragging={isDragging} + dropZoneProps={dropZoneProps} /> )} {step === "typing" && ( @@ -301,6 +435,11 @@ export function ComposeOverlay({ onTextChange={setTextContent} onAdvance={handleTextAdvance} onCancel={cancel} + attachments={attachments} + onRemoveAttachment={removeAttachment} + onAddFiles={openFilePicker} + isDragging={isDragging} + dropZoneProps={dropZoneProps} /> )} {!targetPath && step === "configuring" && ( diff --git a/js/src/features/compose/recording-overlay.tsx b/js/src/features/compose/recording-overlay.tsx index 9779ae3..8615622 100644 --- a/js/src/features/compose/recording-overlay.tsx +++ b/js/src/features/compose/recording-overlay.tsx @@ -1,7 +1,12 @@ import { useEffect, useRef, useState } from "react"; +import { Paperclip } from "lucide-react"; import type { RecordingMode } from "@/hooks/use-recording-mode"; import { AudioLevelBars } from "@/components/audio/audio-level-bars"; import { useAudioSource } from "@/components/audio/use-audio-source"; +import { AttachmentStrip } from "@/features/compose/attachment-strip"; +import type { PendingAttachment } from "@/features/compose/attachment-strip"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; interface RecordingOverlayProps { step: "recording" | "reviewing"; @@ -10,6 +15,16 @@ interface RecordingOverlayProps { reviewBlob: Blob | null; error: string | null; onClose: () => void; + attachments: PendingAttachment[]; + onRemoveAttachment: (id: string) => void; + onAddFiles: () => void; + isDragging: boolean; + dropZoneProps: { + onDragOver: (e: React.DragEvent) => void; + onDragEnter: (e: React.DragEvent) => void; + onDragLeave: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + }; } function RecordingTimer() { @@ -99,6 +114,11 @@ export function RecordingOverlay({ reviewBlob, error, onClose, + attachments, + onRemoveAttachment, + onAddFiles, + isDragging, + dropZoneProps, }: RecordingOverlayProps) { const videoRef = useRef(null); const recordingAudioSource = useAudioSource(mediaStream ?? null); @@ -122,7 +142,13 @@ export function RecordingOverlay({ const isLoading = isRecording && !mediaStream; return ( -
+
{/* Loading state */} {isLoading && (
@@ -191,19 +217,45 @@ export function RecordingOverlay({ )} {isReviewing && ( -
- - - Enter - {" "} - next - - - - Q - {" "} - cancel - +
+ {attachments.length > 0 && ( +
+ +
+ )} +
+ + + Enter + {" "} + next + + + + Q + {" "} + cancel + + + + + +
)} diff --git a/js/src/features/compose/text-compose-step.tsx b/js/src/features/compose/text-compose-step.tsx index a950216..b4f1be8 100644 --- a/js/src/features/compose/text-compose-step.tsx +++ b/js/src/features/compose/text-compose-step.tsx @@ -1,16 +1,26 @@ import { useEffect, useRef, useCallback, useState } from "react"; +import { Paperclip } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useFirstLinkMetadata } from "@/hooks/use-link-metadata"; -import { - LinkPreviewCard, - LinkPreviewCardSkeleton, -} from "@/components/link-preview-card"; +import { useAllLinkMetadata } from "@/hooks/use-link-metadata"; +import { AttachmentStrip } from "@/features/compose/attachment-strip"; +import type { PendingAttachment } from "@/features/compose/attachment-strip"; +import { Button } from "@/components/ui/button"; interface TextComposeStepProps { textContent: string; onTextChange: (text: string) => void; onAdvance: () => void; onCancel: () => void; + attachments: PendingAttachment[]; + onRemoveAttachment: (id: string) => void; + onAddFiles: () => void; + isDragging: boolean; + dropZoneProps: { + onDragOver: (e: React.DragEvent) => void; + onDragEnter: (e: React.DragEvent) => void; + onDragLeave: (e: React.DragEvent) => void; + onDrop: (e: React.DragEvent) => void; + }; } const IMMERSIVE_CHAR_LIMIT = 120; @@ -26,6 +36,11 @@ export function TextComposeStep({ onTextChange, onAdvance, onCancel, + attachments, + onRemoveAttachment, + onAddFiles, + isDragging, + dropZoneProps, }: TextComposeStepProps) { const textareaRef = useRef(null); @@ -35,7 +50,7 @@ export function TextComposeStep({ const t = setTimeout(() => setDebouncedText(textContent), 500); return () => clearTimeout(t); }, [textContent]); - const { data: metadata, isLoading, url: firstUrl } = useFirstLinkMetadata(debouncedText); + const linkPreviews = useAllLinkMetadata(debouncedText); useEffect(() => { textareaRef.current?.focus(); @@ -54,14 +69,16 @@ export function TextComposeStep({ [onCancel, onAdvance, textContent], ); - const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT; - const showPreview = firstUrl && (isLoading || metadata); + const hasEnrichments = attachments.length > 0 || linkPreviews.length > 0; + const immersive = textContent.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments; - const linkPreview = ( - <> - {firstUrl && isLoading && } - {firstUrl && metadata && } - + const strip = ( + ); const keyboardHints = ( @@ -78,19 +95,34 @@ export function TextComposeStep({ {" "} next + + +
); if (immersive) { const style = getImmersiveTextStyle(textContent.length); return ( -
-
+
+