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