Skip to content
287 linesCodeBlameRaw
1import { Check, Copy } from "lucide-react";
2import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
3import { Link } from "react-router";
4
5import { MemberCard } from "./profile-card";
6import { Hint } from "../ui/hint";
7import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
8import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat";
9// The workspace's own emoji, drawn where `:name:` is written (components/emoji).
10import { useEmojiContext } from "../emoji/context";
11import { renderEmoji } from "../emoji/render";
12import { onlyEmojiOrCustom } from "../../lib/emoji";
13
14/** How the text's references resolve, from what the page knows. */
15export type TextContext = {
16 slug: string;
17 /** The viewer's username: a mention of them stands out more. */
18 me: string | null;
19 /** Handles of the workspace's agents: their mentions go to the agent. */
20 agents: ReadonlySet<string>;
21 /**
22 * Who each lowercased handle is (`mentionNames`): a mention shows as
23 * `@Ana Lima`, the name people know. The text keeps `@ana`.
24 */
25 names?: ReadonlyMap<string, string>;
26 /** Channels by name, for `#name`. */
27 channels: ReadonlySet<string>;
28 /** The project `#123` means when no repository is named, if any. */
29 project: string | null;
30 /** Where a link into Code goes instead, for a member without Code access. */
31 codeLink?: (href: string) => string;
32};
33
34const PILL = "rounded-[4px] px-1 py-px font-medium";
35
36function SpanView({ span, context }: { span: Span; context: TextContext }): ReactNode {
37 const emoji = useEmojiContext();
38 switch (span.t) {
39 case "text":
40 return renderEmoji(span.v, emoji.byName, emoji.usercontent);
41 case "code":
42 return <code className="rounded-[4px] border border-line bg-raised px-1 py-px font-mono text-[0.84em] text-fg-soft">{span.v}</code>;
43 case "strong":
44 return <strong className="font-semibold text-fg">{spans(span.c, context)}</strong>;
45 case "em":
46 return <em>{spans(span.c, context)}</em>;
47 case "del":
48 return <del className="text-muted">{spans(span.c, context)}</del>;
49 case "link": {
50 if (span.href.startsWith("/")) {
51 return (
52 <Link to={context.codeLink ? context.codeLink(span.href) : span.href} className="text-accent underline-offset-2 hover:underline">
53 {spans(span.c, context)}
54 </Link>
55 );
56 }
57 return (
58 <a href={span.href} target="_blank" rel="noopener noreferrer nofollow ugc" className="text-accent underline-offset-2 hover:underline">
59 {spans(span.c, context)}
60 </a>
61 );
62 }
63 case "mention": {
64 const name = span.name.toLowerCase();
65 const self = context.me != null && name === context.me.toLowerCase();
66 const agent = context.agents.has(name);
67 const to = agent ? `/${context.slug}/-/agents/${name}` : name.includes("/") ? `/${context.slug}/-/teams/${name.split("/")[1]}` : `/u/${name}`;
68 const pill = `${PILL} transition-colors ${self ? "bg-warn/20 text-warn hover:bg-warn/30" : "bg-accent/15 text-accent hover:bg-accent/25"}`;
69 // A person or an agent: their card. A team: its page.
70 if (name.includes("/")) {
71 return (
72 <Link to={to} className={pill}>
73 @{span.name}
74 </Link>
75 );
76 }
77 const shown = context.names?.get(name);
78 return (
79 <MemberCard member={{ kind: agent ? "agent" : "user", name, display_name: shown }} className={`inline ${pill}`}>
80 @{shown ?? span.name}
81 </MemberCard>
82 );
83 }
84 case "channel":
85 return context.channels.has(span.name) ? (
86 <Link to={`/${context.slug}/-/chat/${span.name}`} className={`${PILL} bg-accent/10 text-accent hover:bg-accent/20`}>
87 #{span.name}
88 </Link>
89 ) : (
90 `#${span.name}`
91 );
92 case "ref": {
93 const repo = span.repo ? (span.repo.includes("/") ? span.repo : `${context.slug}/${span.repo}`) : context.project ? `${context.slug}/${context.project}` : null;
94 const label = `${span.repo ? span.repo : ""}#${span.number}`;
95 if (!repo) return <span className="font-medium text-fg-soft">{label}</span>;
96 // Issues and pull requests share numbers; the issue page sends a pull on.
97 const href = `/${repo}/issues/${span.number}`;
98 return (
99 <Link to={context.codeLink ? context.codeLink(href) : href} className="font-medium text-accent hover:underline">
100 {label}
101 </Link>
102 );
103 }
104 }
105}
106
107function spans(list: Span[], context: TextContext): ReactNode {
108 return list.map((span, index) => <SpanView key={index} span={span} context={context} />);
109}
110
111function lines(list: Span[][], context: TextContext): ReactNode {
112 return list.map((line, index) => (
113 <Fragment key={index}>
114 {index > 0 && <br />}
115 {spans(line, context)}
116 </Fragment>
117 ));
118}
119
120/** A run of blocks, a little apart. */
121function BlocksView({ list, context, className = "space-y-1.5" }: { list: Block[]; context: TextContext; className?: string }) {
122 return (
123 <div className={className}>
124 {list.map((block, index) => (
125 <BlockView key={index} block={block} context={context} />
126 ))}
127 </div>
128 );
129}
130
131function ListItem({ item, context }: { item: Block[]; context: TextContext }) {
132 // An item of one paragraph is its lines; one with more holds its blocks.
133 if (item.length === 1 && item[0]!.t === "p") return <li className="pl-0.5">{lines(item[0]!.lines, context)}</li>;
134 return (
135 <li className="pl-0.5">
136 <BlocksView list={item} context={context} className="space-y-1" />
137 </li>
138 );
139}
140
141function BlockView({ block, context }: { block: Block; context: TextContext }) {
142 switch (block.t) {
143 case "p":
144 return <p>{lines(block.lines, context)}</p>;
145 case "heading":
146 // Chat, not a document: a heading is a bold line with room above it, the top two a touch larger.
147 return <p className={`pt-1.5 font-semibold tracking-[-0.005em] text-fg first:pt-0 ${block.level <= 2 ? "text-base" : ""}`}>{spans(block.c, context)}</p>;
148 case "table":
149 return <TableView block={block} context={context} />;
150 case "hr":
151 return <hr className="my-2 border-line" />;
152 case "code":
153 return <CodeBlock language={block.lang} code={block.v} />;
154 case "list": {
155 const Tag = block.ordered ? "ol" : "ul";
156 return (
157 <Tag start={block.ordered && block.start !== 1 ? block.start : undefined} className={`space-y-0.5 pl-5 ${block.ordered ? "list-decimal" : "list-disc"} marker:text-faint`}>
158 {block.items.map((item, index) => (
159 <ListItem key={index} item={item} context={context} />
160 ))}
161 </Tag>
162 );
163 }
164 case "quote":
165 return (
166 <blockquote className="border-l-2 border-line-strong pl-3 text-muted">
167 <BlocksView list={block.c} context={context} className="space-y-1" />
168 </blockquote>
169 );
170 }
171}
172
173/**
174 * A table, as the theme draws tables in rendered Markdown: a quiet header
175 * row, lines between rows, and its columns aligned as written. A wide one
176 * scrolls sideways inside the message, never the page, and takes focus so
177 * it can be scrolled from the keyboard.
178 */
179function TableView({ block, context }: { block: Extract<Block, { t: "table" }>; context: TextContext }) {
180 const align = (index: number) => block.align[index] ?? undefined;
181 return (
182 <Table frameProps={{ role: "region", "aria-label": "Table", tabIndex: 0, className: "my-1 rounded-lg border border-line" }} className="min-w-max text-[0.8125rem] leading-snug sm:min-w-full">
183 <TableHeader>
184 <TableRow>
185 {block.head.map((cell, index) => (
186 <TableHead key={index} scope="col" style={{ textAlign: align(index) }}>
187 {spans(cell, context)}
188 </TableHead>
189 ))}
190 </TableRow>
191 </TableHeader>
192 <TableBody>
193 {block.rows.map((row, at) => (
194 <TableRow key={at}>
195 {row.map((cell, index) => (
196 <TableCell key={index} style={{ textAlign: align(index) }} className={align(index) === "right" ? "whitespace-nowrap tabular-nums" : "max-w-lg"}>
197 {spans(cell, context)}
198 </TableCell>
199 ))}
200 </TableRow>
201 ))}
202 </TableBody>
203 </Table>
204 );
205}
206
207type Token = { content: string; color?: string };
208
209/**
210 * Fenced code: plain at once, coloured once the highlighter has loaded
211 * when its language is one g1t knows (lib/shiki.ts), with a copy button.
212 * The colours are tokens drawn as text, never HTML.
213 */
214function CodeBlock({ language, code }: { language: string | null; code: string }) {
215 const [rows, setRows] = useState<Token[][] | null>(null);
216 const [copied, setCopied] = useState(false);
217 useEffect(() => {
218 setRows(null);
219 if (!language || code.length > 20_000) return;
220 let cancelled = false;
221 void import("../../lib/shiki")
222 .then(async ({ getHighlighter, languageNamed, tokenRows }) => {
223 const lang = languageNamed(language);
224 if (!lang) return null;
225 return tokenRows(await getHighlighter(), code, lang);
226 })
227 .then((tokens) => {
228 if (!cancelled && tokens) setRows(tokens);
229 })
230 .catch(() => {});
231 return () => {
232 cancelled = true;
233 };
234 }, [language, code]);
235 return (
236 <div className="group/code relative my-1">
237 <pre className="overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 pointer-coarse:pr-10 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
238 <code>
239 {rows
240 ? rows.map((row, index) => (
241 <span key={index} className="block min-h-lh">
242 {row.map((token, at) => (
243 <span key={at} style={token.color ? { color: token.color } : undefined}>
244 {token.content}
245 </span>
246 ))}
247 </span>
248 ))
249 : code}
250 </code>
251 </pre>
252 <div className="absolute top-1.5 right-1.5 flex items-center gap-2 opacity-0 transition-opacity group-hover/code:opacity-100 focus-within:opacity-100 pointer-coarse:opacity-100">
253 {language && <span className="font-mono text-[0.6875rem] text-faint pointer-coarse:hidden">{language}</span>}
254 <Hint label={copied ? "Copied" : "Copy code"}>
255 <button
256 type="button"
257 aria-label="Copy code"
258 onClick={() => {
259 void navigator.clipboard?.writeText(code).then(() => {
260 setCopied(true);
261 setTimeout(() => setCopied(false), 1500);
262 });
263 }}
264 className="flex size-6 items-center justify-center rounded-md border border-line bg-raised text-muted hover:text-fg"
265 >
266 {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
267 </button>
268 </Hint>
269 </div>
270 </div>
271 );
272}
273
274/** A message's text, rendered from parsed blocks: never as HTML. */
275export function MessageText({ body, context }: { body: string; context: TextContext }) {
276 const parsed = useMemo(() => blocks(body), [body]);
277 const emoji = useEmojiContext();
278 if (onlyEmoji(body)) return <p className="text-3xl leading-tight">{body.trim()}</p>;
279 if (onlyEmojiOrCustom(body, emoji.byName)) return <p className="text-3xl leading-tight">{renderEmoji(body.trim(), emoji.byName, emoji.usercontent, 32)}</p>;
280 return (
281 <div className="space-y-1.5 text-[0.9375rem] leading-[1.55] break-words text-fg-soft">
282 {parsed.map((block, index) => (
283 <BlockView key={index} block={block} context={context} />
284 ))}
285 </div>
286 );
287}