Skip to content
87 linesCodeBlameRaw
1/**
2 * Chat's cards for artifact links (docs/ARTIFACTS_MODE.md section 4.3 rule
3 * 3): each viewer asks the site for their own, so someone who can open the
4 * artifact sees its kind, title, space and last edit, and anyone else sees
5 * only "An artifact you don't have access to", with no title. Looking is
6 * not opening: a card never makes a link-shared artifact readable.
7 */
8import type { FolioKind, Result } from "@g1t/contracts";
9import { Lock } from "lucide-react";
10import { useEffect, useMemo, useState } from "react";
11import { Link } from "react-router";
12
13import { linkedArtifacts } from "../../lib/folios";
14import { TimeAgo } from "../ui";
15import { KindIcon } from "./kinds";
16
17/** What the site answers for one link (routes/workspace/folios/api.ts `unfurl`); null: not for this viewer. */
18type Card = { id: string; kind: FolioKind; title: string; icon: string | null; path: string; space: string | null; private: boolean; excerpt: string; edited_at: string; edited_by: string | null } | null;
19
20/** The most cards one message shows. */
21const MAX_CARDS = 3;
22/** How long a card is kept in this tab before it is asked for again. */
23const KEEP_MS = 60_000;
24
25const cards = new Map<string, { at: number; card: Promise<Card | undefined> }>();
26
27function cardFor(slug: string, id: string): Promise<Card | undefined> {
28 const key = `${slug}/${id}`;
29 const kept = cards.get(key);
30 if (kept && Date.now() - kept.at < KEEP_MS) return kept.card;
31 const card = fetch(`/${slug}/-/artifacts/api?unfurl=${encodeURIComponent(id)}`, { headers: { accept: "application/json" } })
32 .then((r) => r.json() as Promise<Result<Card>>)
33 .then((r) => (r.ok ? r.value : undefined))
34 // Not answered: no card at all, rather than a wrong one.
35 .catch(() => undefined);
36 cards.set(key, { at: Date.now(), card });
37 return card;
38}
39
40export function FolioUnfurls({ slug, body }: { slug: string; body: string }) {
41 const ids = useMemo(() => linkedArtifacts(body, slug).slice(0, MAX_CARDS), [body, slug]);
42 if (!ids.length) return null;
43 return (
44 <div className="mt-1.5 flex max-w-md flex-col gap-1.5">
45 {ids.map((id) => (
46 <Unfurl key={id} slug={slug} id={id} />
47 ))}
48 </div>
49 );
50}
51
52function Unfurl({ slug, id }: { slug: string; id: string }) {
53 const [card, setCard] = useState<Card | undefined | "loading">("loading");
54 useEffect(() => {
55 let live = true;
56 void cardFor(slug, id).then((c) => live && setCard(c));
57 return () => {
58 live = false;
59 };
60 }, [slug, id]);
61 if (card === "loading" || card === undefined) return null;
62 if (card === null) {
63 return (
64 <div className="flex items-center gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 text-xs text-faint">
65 <Lock size={14} className="shrink-0" />
66 An artifact you don&apos;t have access to
67 </div>
68 );
69 }
70 return (
71 <Link to={card.path} className="group flex items-start gap-2.5 rounded-lg border border-line bg-surface px-3 py-2 transition-colors hover:border-line-strong">
72 <KindIcon kind={card.kind} size={14} box={26} />
73 <span className="min-w-0 grow">
74 <span className="block truncate text-sm font-medium text-fg group-hover:text-accent">
75 {card.icon && <span className="mr-1">{card.icon}</span>}
76 {card.title || "Untitled"}
77 </span>
78 {card.excerpt && <span className="mt-0.5 line-clamp-2 text-xs text-muted">{card.excerpt}</span>}
79 <span className="mt-0.5 flex items-center gap-1 text-[0.6875rem] text-faint">
80 {card.private && <Lock size={10} aria-label="Only you can see this" />}
81 {card.space ?? "Private"} · Edited <TimeAgo at={card.edited_at} />
82 {card.edited_by ? ` by ${card.edited_by}` : ""}
83 </span>
84 </span>
85 </Link>
86 );
87}