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
@@ -1,16 +1,21 @@
import { useEffect, useRef } from "react";
import type { Particle } from "@/api/types";
import type { ParticlePath } from "@/lib/particle-path";
import { cn } from "@/lib/utils";
import { useFirstLinkMetadata } from "@/hooks/use-link-metadata";
import { useAllLinkMetadata, type LinkPreviewEntry } from "@/hooks/use-link-metadata";
import { extractUrls } from "@/lib/link-metadata";
import {
LinkPreviewCard,
LinkPreviewCardSkeleton,
} from "@/components/link-preview-card";
import { useParticleAttachments } from "@/hooks/use-particle-attachments";
import { ParticleAttachments } from "@/features/particles/particle-attachments";
type TextParticle = Extract<Particle, { type: "text" }>;
interface TextParticleViewProps {
particle: TextParticle;
streamPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
@@ -21,16 +26,20 @@ const CHARS_PER_MINUTE = 1000;
const MIN_DURATION_S = 3;
const MAX_DURATION_S = 15;
const TICK_MS = 100;
const LINK_EXTRA_DURATION_S = 3;
const EXTRA_S_PER_LINK = 2;
const EXTRA_S_PER_ATTACHMENT = 2;
// Below this threshold: immersive centered display
// Above: contained left-aligned card
const IMMERSIVE_CHAR_LIMIT = 120;
function computeReadDuration(text: string, hasLink: boolean): number {
function computeReadDuration(
text: string,
linkCount: number,
attachmentCount: number,
): number {
const base = (text.length / CHARS_PER_MINUTE) * 60;
const seconds = hasLink ? base + LINK_EXTRA_DURATION_S : base;
return Math.min(Math.max(seconds, MIN_DURATION_S), MAX_DURATION_S);
const extra = linkCount * EXTRA_S_PER_LINK + attachmentCount * EXTRA_S_PER_ATTACHMENT;
return Math.min(Math.max(base + extra, MIN_DURATION_S), MAX_DURATION_S);
}
function getImmersiveTextStyle(length: number) {
@@ -39,17 +48,39 @@ function getImmersiveTextStyle(length: number) {
return { size: "text-2xl", weight: "font-normal" };
}
function LinkPreviews({ entries }: { entries: LinkPreviewEntry[] }) {
return (
<div className="flex flex-wrap gap-3">
{entries.map((entry) => (
<div key={entry.url} className="shrink-0">
{entry.isLoading ? (
<LinkPreviewCardSkeleton />
) : entry.metadata ? (
<LinkPreviewCard metadata={entry.metadata} />
) : null}
</div>
))}
</div>
);
}
export function TextParticleView({
particle,
streamPath,
paused,
onEnded,
onProgress,
}: TextParticleViewProps) {
const content = particle.properties.content;
const { data: metadata, isLoading, url: firstUrl } = useFirstLinkMetadata(content);
const hasLink = !!firstUrl;
const immersive = content.length < IMMERSIVE_CHAR_LIMIT;
const durationS = computeReadDuration(content, hasLink);
const linkPreviews = useAllLinkMetadata(content);
const { attachments } = useParticleAttachments(streamPath, particle.id);
const urls = extractUrls(content);
const hasLinks = urls.length > 0;
const hasAttachments = attachments.length > 0;
const hasEnrichments = hasLinks || hasAttachments;
const durationS = computeReadDuration(content, urls.length, attachments.length);
const elapsedRef = useRef(0);
// Reset elapsed when particle changes
@@ -74,25 +105,22 @@ export function TextParticleView({
return () => clearInterval(interval);
}, [paused, durationS, onEnded, onProgress, particle.id]);
const linkPreview = (
<>
{isLoading && <LinkPreviewCardSkeleton />}
{metadata && <LinkPreviewCard metadata={metadata} />}
</>
);
// Content is just bare URLs with no surrounding text
const contentTrimmed = content.trim();
const linksOnly = hasLinks && urls.every((url) => contentTrimmed.includes(url)) &&
contentTrimmed.replace(/https?:\/\/[^\s<>"')\]]+/g, "").trim() === "";
// Content is just a bare URL with no surrounding text
const linkOnly = hasLink && content.trim() === firstUrl;
if (linkOnly) {
// Mode 1: bare URLs only — show link cards centered
if (linksOnly && !hasAttachments) {
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
{linkPreview}
<LinkPreviews entries={linkPreviews} />
</div>
);
}
if (immersive && !hasLink) {
// Mode 2: short text, no enrichments — immersive centered display
if (content.length < IMMERSIVE_CHAR_LIMIT && !hasEnrichments) {
const style = getImmersiveTextStyle(content.length);
return (
<div className="flex h-full w-full flex-col items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
@@ -109,15 +137,17 @@ export function TextParticleView({
);
}
// Mode 3: card layout with enrichments
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 p-8">
<div className="flex max-h-full w-full items-center gap-6">
<div className="flex-1 overflow-y-auto rounded-2xl max-w-lg mx-auto bg-white/10 p-5 backdrop-blur-md">
<p className="break-words text-base leading-relaxed text-white select-text cursor-text">
{content}
</p>
</div>
{hasLink && <div className="shrink-0">{linkPreview}</div>}
<div className="flex max-h-full w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-2xl bg-white/10 p-5 backdrop-blur-md">
<p className="break-words text-base leading-relaxed text-white select-text cursor-text">
{content}
</p>
{hasLinks && <LinkPreviews entries={linkPreviews} />}
{hasAttachments && <ParticleAttachments attachments={attachments} />}
</div>
</div>
);