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
+109
View File
@@ -0,0 +1,109 @@
import { useCallback, useEffect, useRef, useState } from "react";
interface UseFileInputOptions {
onFilesSelected: (files: File[]) => void;
enabled: boolean;
}
export function useFileInput({ onFilesSelected, enabled }: UseFileInputOptions) {
const [isDragging, setIsDragging] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const dragCountRef = useRef(0);
// Stable ref for the callback to avoid re-registering effects
const onFilesRef = useRef(onFilesSelected);
onFilesRef.current = onFilesSelected;
// Hidden file input element
useEffect(() => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
input.style.display = "none";
input.addEventListener("change", () => {
if (input.files?.length) {
onFilesRef.current(Array.from(input.files));
input.value = "";
}
});
document.body.appendChild(input);
inputRef.current = input;
return () => {
document.body.removeChild(input);
inputRef.current = null;
};
}, []);
const openFilePicker = useCallback(() => {
inputRef.current?.click();
}, []);
// Clipboard paste
useEffect(() => {
if (!enabled) return;
const handlePaste = (e: ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length > 0) {
e.preventDefault();
onFilesRef.current(files);
}
};
window.addEventListener("paste", handlePaste);
return () => window.removeEventListener("paste", handlePaste);
}, [enabled]);
// Drag and drop handlers
const onDragOver = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
},
[enabled],
);
const onDragEnter = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current++;
if (dragCountRef.current === 1) setIsDragging(true);
},
[enabled],
);
const onDragLeave = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current--;
if (dragCountRef.current === 0) setIsDragging(false);
},
[enabled],
);
const onDrop = useCallback(
(e: React.DragEvent) => {
if (!enabled) return;
e.preventDefault();
e.stopPropagation();
dragCountRef.current = 0;
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
onFilesRef.current(files);
}
},
[enabled],
);
return {
openFilePicker,
isDragging,
dropZoneProps: { onDragOver, onDragEnter, onDragLeave, onDrop },
};
}
+27 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useQueries, useQuery } from "@tanstack/react-query";
import { extractUrls, type LinkMetadata } from "@/lib/link-metadata";
export function useLinkMetadata(url: string | null) {
@@ -17,3 +17,29 @@ export function useFirstLinkMetadata(text: string) {
const firstUrl = urls[0] ?? null;
return { ...useLinkMetadata(firstUrl), url: firstUrl };
}
export interface LinkPreviewEntry {
url: string;
metadata: LinkMetadata | null | undefined;
isLoading: boolean;
}
export function useAllLinkMetadata(text: string): LinkPreviewEntry[] {
const urls = extractUrls(text);
const results = useQueries({
queries: urls.map((url) => ({
queryKey: ["link-metadata", url],
queryFn: () => window.electronLink.fetchMetadata(url),
staleTime: Infinity,
gcTime: 30 * 60 * 1000,
retry: 1,
})),
});
return urls.map((url, i) => ({
url,
metadata: results[i].data,
isLoading: results[i].isLoading,
}));
}
+29
View File
@@ -0,0 +1,29 @@
import { useMemo } from "react";
import type { Particle } from "@/api/types";
import { useParticleChildren } from "@/hooks/use-particle";
import { particlePath, type ParticlePath, parseParticlePath } from "@/lib/particle-path";
type FileParticle = Extract<Particle, { type: "file" }>;
/**
* Fetches file children (attachments) of a particle in a stream.
* Uses a one-shot query since attachments are immutable after creation.
*/
export function useParticleAttachments(
streamPath: ParticlePath,
particleId: string,
) {
const childrenPath = useMemo(() => {
const { networkId, segments } = parseParticlePath(streamPath);
return particlePath(networkId, [...segments, particleId]);
}, [streamPath, particleId]);
const { data: children, isLoading } = useParticleChildren(childrenPath);
const attachments = useMemo(
() => (children ?? []).filter((c): c is FileParticle => c.type === "file"),
[children],
);
return { attachments, isLoading };
}
+14
View File
@@ -4,6 +4,7 @@ import {
subscribeToParticleChildren,
subscribeToLatestChild,
getParticle,
getParticleChildren,
} from "@/lib/firestore-particles";
import type { Particle } from "@/api/types";
import {
@@ -143,3 +144,16 @@ export function useParticle(path?: ParticlePath) {
enabled: !!path,
});
}
export function useParticleChildren(path?: ParticlePath) {
return useQuery({
queryKey: ["particle-children", path],
queryFn: async () => {
if (!path) return [];
const collectionPath = toFirestoreChildrenPath(path);
return getParticleChildren(collectionPath);
},
enabled: !!path,
staleTime: 1000 * 60 * 5, // 5 min — attachments don't change
});
}