feat: support file and image attachments (#98)

* upload & view attachments to particles

* allow download of attachments
This commit was merged in pull request #98.
This commit is contained in:
Arjun Patel
2026-03-30 10:31:24 -07:00
committed by GitHub
parent 3564055c29
commit d6280439d0
15 changed files with 874 additions and 74 deletions
+143 -4
View File
@@ -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<string | null>(null);
const [attachments, setAttachments] = useState<PendingAttachment[]>([]);
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" && (