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:
@@ -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" && (
|
||||
|
||||
Reference in New Issue
Block a user