Skip to content

Commit

Merge branch 'worktree-agent-a1c6f5039da0a4ac2' into integrate

syntaqxcommitted Parents25224d3602b913Browse files
16 files+2304−4460/16 viewed
+73−2
235235
236236 An address such as `me@example.com` is never read as a mention.
237237
238+## Format a message
239+
240+Messages can have bold, italic and struck-through words, links, inline
241+code, code blocks, lists and quotes. Format as you write, three ways:
242+
243+- **The formatting bar** above the text. **T** at the bottom left of the
244+ composer shows or hides it, and your choice is kept on that device. On a
245+ phone it starts hidden; when it is shown, swipe it sideways for the rest.
246+- **Shortcuts.** Select words, or start typing after one. The keyboard
247+ button at the end of the bar lists them too.
248+- **Markdown, typed inline.** It turns into its formatting as you type:
249+ `*bold*` becomes **bold** when you type the closing `*`.
250+
251+| Formatting | Shortcut (⌘ on a Mac) | Or type |
252+| --- | --- | --- |
253+| Bold | Ctrl+B | `*bold*` or `**bold**` |
254+| Italic | Ctrl+I | `_italic_` |
255+| Strikethrough | Ctrl+Shift+X | `~struck~` or `~~struck~~` |
256+| Link | Ctrl+K | Select words and paste an address over them |
257+| Inline code | Ctrl+Shift+C or Ctrl+E | `` `code` `` |
258+| Code block | Ctrl+Alt+Shift+C | ` ``` ` then Enter, or ` ```ts ` to name the language |
259+| Bulleted list | Ctrl+Shift+8 | `- ` at the start of a line |
260+| Numbered list | Ctrl+Shift+7 | `1. ` at the start of a line |
261+| Quote | Ctrl+Shift+9 | `> ` at the start of a line |
262+
263+**Ctrl+K** asks where the link goes, and for its text when nothing is
264+selected. On a link already, it changes the address or removes the link.
265+A link goes to a web address, an email address or a page on g1t; anything
266+else is refused. Undo (**Ctrl+Z**) takes formatting back off.
267+
268+### Enter and new lines
269+
270+| Key | In a line of text | In a list | In a code block |
271+| --- | --- | --- | --- |
272+| **Enter** | Sends | Adds an item; on an empty item, leaves the list | Adds a line; three in a row leave the block |
273+| **Shift+Enter** | Starts a new line | Adds an item | Adds a line |
274+| **Ctrl+Enter** | Sends | Sends | Sends |
275+
276+In a quote, Enter sends and Shift+Enter starts the next line of the quote.
277+
278+### What a message shows
279+
280+Every message, a person's or an agent's, is shown with its formatting.
281+Code blocks with a language g1t knows (`ts`, `rust`, `python`, `sh`,
282+`sql`, `json` and others) are coloured, and have a copy button. A web
283+address on its own becomes a link, and links open in a new tab. Headings
284+an agent writes show as a bold line, and a rule as a line across.
285+
286+Chat never shows HTML: `<b>` reads as typed. An image written in Markdown
287+shows as a link to it, not the image.
288+
289+### Messages are Markdown
290+
291+What you send is kept as Markdown, the formatting written as `**bold**`,
292+`_italic_`, `~~struck~~` and so on. That is what agents read and write,
293+and what you edit when you change a message, so formatting survives the
294+round trip. Text you paste without formatting, such as from a terminal, is
295+read as Markdown too; paste with **Ctrl+Shift+V** to keep it exactly as it
296+is. Notifications, pop-ups and browser notifications show a message's
297+words without the marks: `**Release** is _done_` reads *Release is done*.
298+
299+To show a character that would format, put a backslash before it: `\*`
300+shows `*`.
301+
238302 ## Agents in channels
239303
240304 Invite an agent to a channel the way you invite a person. Only agents of
343407
344408 ## Edit and delete
345409
346−You can edit or delete your own messages. An edited message says so. A deleted message is removed for everyone; replies under it stay.
347−A message can be up to 40,000 characters, and is written in Markdown.
410+You can edit or delete your own messages. On a computer, hover your message,
411+open its **⋯** and choose **Edit message**: it opens in the composer, in
412+place, with its formatting. Enter or **Save** keeps the change, Esc or
413+**Cancel** leaves it as it was. On a phone, press and hold the message and
414+choose **Edit**. An edited message says so. A deleted message is removed
415+for everyone; replies under it stay.
416+
417+A message can be up to 40,000 characters, and is written in Markdown (see
418+[format a message](#format-a-message)).
348419
349420 ## Reactions
350421
+44−0
793793 line-height: 1.65;
794794 }
795795
796+/* The chat composer (components/chat/editor.tsx): formatting as messages show it. */
797+.chat-editor {
798+ white-space: pre-wrap;
799+ word-break: break-word;
800+ min-height: 1.5rem;
801+}
802+.chat-editor p.is-editor-empty:first-child::before {
803+ content: attr(data-placeholder);
804+ float: left;
805+ height: 0;
806+ pointer-events: none;
807+ color: var(--color-faint);
808+}
809+.chat-editor strong {
810+ @apply font-semibold text-fg;
811+}
812+.chat-editor s {
813+ @apply text-muted;
814+}
815+.chat-editor a {
816+ @apply text-accent underline underline-offset-2;
817+}
818+.chat-editor code {
819+ @apply rounded-[4px] border border-line bg-raised px-1 py-px font-mono text-[0.84em] text-fg-soft;
820+}
821+.chat-editor pre {
822+ @apply my-1 rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft;
823+}
824+.chat-editor pre code {
825+ @apply border-0 bg-transparent p-0 text-[1em];
826+}
827+.chat-editor ul {
828+ @apply list-disc pl-5 marker:text-faint;
829+}
830+.chat-editor ol {
831+ @apply list-decimal pl-5 marker:text-faint;
832+}
833+.chat-editor li > p {
834+ @apply pl-0.5;
835+}
836+.chat-editor blockquote {
837+ @apply my-0.5 border-l-2 border-line-strong pl-3 text-muted;
838+}
839+
+45−37
167167 const [actions, setActions] = useState<ShownMessage | null>(null);
168168 // Someone's profile, opened from their card, beside the conversation.
169169 const [profile, setProfile] = useState<string | null>(null);
170− const [editing, setEditing] = useState<{ id: string; body: string } | null>(null);
170+ // A message being edited: in its place on a computer (`inline`), in a sheet on a phone.
171+ const [editing, setEditing] = useState<{ id: string; body: string; inline?: boolean } | null>(null);
171172 const scroller = useRef<HTMLDivElement>(null);
172173 const stick = useRef(true);
173174
555556 };
556557
557558 const rows = useMemo(() => timeline(messages, new Date(), zone), [messages, zone]);
559+ const mine = (message: ShownMessage) => message.author.kind === "user" && message.author.id === me?.id && !message.pending && !message.deleted_at;
560+ // Editing uses the composer, started from the message's Markdown.
561+ const editor = (target: { id: string; body: string }) => (
562+ <Composer
563+ initial={target.body}
564+ placeholder="Edit message"
565+ people={people}
566+ autoFocus
567+ compact
568+ onCancel={() => setEditing(null)}
569+ onSend={async (body) => {
570+ setEditing(null);
571+ if (body === target.body) return;
572+ // Shown edited at once; put back if the service refuses.
573+ const before = messages.find((m) => m.id === target.id);
574+ setMessages((now) => now.map((m) => (m.id === target.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
575+ const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: target.id, body });
576+ if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
577+ else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
578+ }}
579+ />
580+ );
558581 const typers = [...typing.values()].map((t) => t.member);
559582 const name = channel.name ?? "";
560583 const archived = channel.kind === "channel" && !!channel.archived_at;
637660 onRetry={() => retry(row.message)}
638661 onLongPress={() => setActions(row.message)}
639662 onCopyLink={() => copyThreadLink(row.message.id)}
663+ onEdit={mine(row.message) ? () => setEditing({ id: row.message.id, body: row.message.body, inline: true }) : undefined}
664+ editor={editing?.inline && editing.id === row.message.id ? editor(editing) : undefined}
640665 onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined}
641666 threadOpen={row.message.id === threadId}
642667 />
750775 link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""}
751776 onSend={askForWriteUp}
752777 />
753− <BottomSheet open={editing != null} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
754− {editing && (
755− <form
756− className="space-y-3 px-1 pt-1"
757− onSubmit={async (event) => {
758− event.preventDefault();
759− const body = editing.body.trim();
760− if (!body) return;
761− // Shown edited at once; put back if the service refuses.
762− const before = messages.find((m) => m.id === editing.id);
763− setMessages((now) => now.map((m) => (m.id === editing.id ? { ...m, body, edited_at: new Date().toISOString() } : m)));
764− setEditing(null);
765− const saved = await send<ChatMessage>({ intent: "edit", channel_id: data.channel.id, id: editing.id, body });
766− if (saved.ok) setMessages((now) => mergeMessages(now, [saved.value]));
767− else if (before) setMessages((now) => now.map((m) => (m.id === before.id ? before : m)));
768− }}
769− >
778+ <BottomSheet open={editing != null && !editing.inline} onOpenChange={(open) => !open && setEditing(null)} title="Edit message">
779+ {editing && !editing.inline && (
780+ <div className="space-y-3 px-1 pt-1">
770781 <h2 className="px-2 text-base font-semibold">Edit message</h2>
771− <textarea
772− autoFocus
773− value={editing.body}
774− onChange={(event) => setEditing({ ...editing, body: event.target.value })}
775− rows={4}
776− className="block w-full resize-none rounded-xl border border-line-strong bg-bg px-3.5 py-3 text-base text-fg outline-none focus:border-accent/40"
777− />
778− <div className="flex gap-2">
779− <button type="button" onClick={() => setEditing(null)} className="h-11 grow rounded-xl border border-line text-[0.9375rem] font-medium active:bg-raised">
780− Cancel
781− </button>
782− <button type="submit" className="h-11 grow rounded-xl bg-accent text-[0.9375rem] font-medium text-bg active:bg-accent-hover">
783− Save
784− </button>
785− </div>
786− </form>
782+ {editor(editing)}
783+ </div>
787784 )}
788785 </BottomSheet>
789786 </div>
12191216 onWriteUp,
12201217 threadOpen,
12211218 inThread,
1219+ onEdit,
1220+ editor,
12221221 }: {
1222+ /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */
1223+ onEdit?: () => void;
1224+ editor?: ReactNode;
12231225 /** A phone: a long press opens the message's actions. */
12241226 onLongPress?: () => void;
12251227 /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */
12761278 </div>
12771279 )}
12781280 <div className={message.pending && !message.failed ? "opacity-60" : undefined}>
1279− {message.body && <MessageText body={message.body} context={context} />}
1281+ {editor ? <div className="py-1">{editor}</div> : message.body && <MessageText body={message.body} context={context} />}
12801282 {message.card && (
12811283 <CardBox
12821284 card={message.card}
13271329 <MessageSquareText size={15} />
13281330 </button>
13291331 </Hint>
1330− {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} />}
1332+ {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />}
13311333 </div>
13321334 )}
13331335 </article>
13351337 }
13361338
13371339 /** A message's "⋯" in its hover toolbar. */
1338−function MoreActions({ onCopyLink, onWriteUp }: { onCopyLink: () => void; onWriteUp?: () => void }) {
1340+function MoreActions({ onCopyLink, onWriteUp, onEdit }: { onCopyLink: () => void; onWriteUp?: () => void; onEdit?: () => void }) {
13391341 return (
13401342 <DropdownMenu>
13411343 <Hint label="More actions">
13471349 </DropdownMenuTrigger>
13481350 </Hint>
13491351 <DropdownMenuContent align="end">
1352+ {onEdit && (
1353+ <DropdownMenuItem onSelect={onEdit}>
1354+ <Pencil />
1355+ Edit message
1356+ </DropdownMenuItem>
1357+ )}
13501358 <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} />
13511359 </DropdownMenuContent>
13521360 </DropdownMenu>
+24−250
1−import { ArrowUp, AtSign, Smile } from "lucide-react";
2−import { type KeyboardEvent, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
1+import { ArrowUp } from "lucide-react";
2+import { Suspense, lazy } from "react";
33
4−import { AgentPill, MemberAvatar } from "./marks";
5−import { Hint } from "../ui/hint";
6−// `:shortcode` completion and the emoji picker (components/emoji).
7−import { useEmojiAutocomplete } from "../emoji/autocomplete";
8−import { EmojiPickerPopover } from "../emoji/picker";
9−import { type MentionQuery, type Mentionable, mentionQuery, shownHandle } from "../../lib/chat";
4+import type { ComposerProps } from "./editor";
105
11−/** How tall the box grows before it scrolls. */
12−const MAX_HEIGHT = 240;
6+// The editor (components/chat/editor.tsx) is loaded with the first composer,
7+// so pages without one never fetch it.
8+const ChatEditor = lazy(() => import("./editor"));
139
1410 /**
15− * Where a message is written. Enter sends, Shift+Enter starts a new line;
16− * `@` offers the workspace's people and agents (Up and Down to move, Tab or
17− * Enter to take one, Escape to dismiss). What is typed is kept per
18− * conversation while the tab is open, so moving away loses nothing.
11+ * Where a message is written: the formatting editor, with what it looks
12+ * like for the moment it takes to load. See components/chat/editor.tsx.
1913 */
20−export function Composer({
21− draftKey,
22− placeholder,
23− people,
24− onSend,
25− onTyping,
26− disabled,
27− autoFocus,
28− compact,
29−}: {
30− draftKey: string;
31− placeholder: string;
32− people: Mentionable[];
33− onSend: (body: string) => void;
34− onTyping?: () => void;
35− disabled?: boolean;
36− autoFocus?: boolean;
37− compact?: boolean;
38−}) {
39− const box = useRef<HTMLTextAreaElement>(null);
40− const [text, setText] = useState("");
41− const [match, setMatch] = useState<MentionQuery | null>(null);
42− const [active, setActive] = useState(0);
43− const list = useId();
44− const emoji = useEmojiAutocomplete({ box, setText });
45−
46− // The draft for this conversation, if one was left.
47− useEffect(() => {
48− try {
49− setText(sessionStorage.getItem(`chat-draft:${draftKey}`) ?? "");
50− } catch {
51− setText("");
52− }
53− }, [draftKey]);
54− useEffect(() => {
55− try {
56− if (text) sessionStorage.setItem(`chat-draft:${draftKey}`, text);
57− else sessionStorage.removeItem(`chat-draft:${draftKey}`);
58− } catch {
59− // No storage: the draft lives only while the page does.
60− }
61− }, [draftKey, text]);
62−
63− // Grows with what is written, up to a point.
64− useLayoutEffect(() => {
65− const element = box.current;
66− if (!element) return;
67− element.style.height = "auto";
68− element.style.height = `${Math.min(MAX_HEIGHT, element.scrollHeight)}px`;
69− }, [text]);
14+export function Composer(props: ComposerProps) {
15+ return (
16+ <Suspense fallback={<Loading placeholder={props.placeholder} compact={props.compact} />}>
17+ <ChatEditor {...props} />
18+ </Suspense>
19+ );
20+}
7021
71− const look = () => {
72− const element = box.current;
73− if (!element) return;
74− emoji.look();
75− setMatch(element.selectionStart === element.selectionEnd ? mentionQuery(element.value, element.selectionStart, people) : null);
76− setActive(0);
77− };
78−
79− const complete = (person: Mentionable) => {
80− const element = box.current;
81− if (!element || !match) return;
82− const caret = element.selectionStart;
83− const next = `${element.value.slice(0, match.start)}@${person.name} ${element.value.slice(caret)}`;
84− setText(next);
85− setMatch(null);
86− requestAnimationFrame(() => {
87− const at = match.start + person.name.length + 2;
88− element.focus();
89− element.setSelectionRange(at, at);
90− });
91− };
92−
93− const send = () => {
94− const body = text.trim();
95− if (!body || disabled) return;
96− onSend(body);
97− setText("");
98− setMatch(null);
99− };
100−
101− const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
102− if (emoji.onKeyDown(event)) return;
103− if (match) {
104− const count = match.options.length;
105− if (event.key === "ArrowDown" || event.key === "ArrowUp") {
106− event.preventDefault();
107− setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
108− return;
109− }
110− if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
111− event.preventDefault();
112− complete(match.options[active] ?? match.options[0]!);
113− return;
114− }
115− if (event.key === "Escape") {
116− event.preventDefault();
117− setMatch(null);
118− return;
119− }
120− }
121− // Enter sends; Shift+Enter, and Enter while composing an IME word, does not.
122− if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
123− event.preventDefault();
124− send();
125− }
126− };
127−
128− const open = match != null;
22+/** The composer's frame, still, while the editor loads. */
23+function Loading({ placeholder, compact }: { placeholder: string; compact?: boolean }) {
12924 return (
130− <div className="relative">
131− {emoji.menu}
132− {match && (
133− <ul
134− id={list}
135− role="listbox"
136− aria-label="Mention someone"
137− className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm overflow-hidden rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40"
138− >
139− {match.options.map((person, index) => (
140− <li
141− key={`${person.kind}:${person.name}`}
142− id={`${list}-${index}`}
143− role="option"
144− aria-selected={index === active}
145− onMouseDown={(event) => event.preventDefault()}
146− onMouseEnter={() => setActive(index)}
147− onClick={() => complete(person)}
148− className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
149− >
150− <MemberAvatar member={person} size={22} />
151− <span className="min-w-0 grow truncate text-sm">
152− <span className="font-medium text-fg">{person.display_name}</span>
153− <span className="ml-1.5 text-faint">@{shownHandle(person)}</span>
154− </span>
155− {person.kind === "agent" && <AgentPill />}
156− </li>
157− ))}
158− </ul>
159− )}
160− <div
161− className={`rounded-xl border border-line-strong bg-surface transition-colors focus-within:border-accent/35 focus-within:ring-4 focus-within:ring-accent/[0.06] ${disabled ? "opacity-60" : ""}`}
162− >
163− <textarea
164− ref={box}
165− value={text}
166− rows={1}
167− disabled={disabled}
168− autoFocus={autoFocus}
169− placeholder={placeholder}
170− aria-label={placeholder}
171− role={open ? "combobox" : undefined}
172− aria-autocomplete="list"
173− aria-expanded={open}
174− aria-controls={open ? list : undefined}
175− aria-activedescendant={open ? `${list}-${active}` : undefined}
176− autoComplete="off"
177− data-1p-ignore
178− data-lpignore="true"
179− onChange={(event) => {
180− setText(event.target.value);
181− onTyping?.();
182− }}
183− onInput={look}
184− onClick={look}
185− onKeyDown={onKeyDown}
186− onBlur={() =>
187− setTimeout(() => {
188− setMatch(null);
189− emoji.dismiss();
190− }, 150)
191− }
192− className={`block w-full resize-none bg-transparent px-3.5 text-[0.9375rem] leading-6 text-fg outline-none placeholder:text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}
193− />
194− <div className="flex items-center gap-1 px-2 pb-2">
195− <Hint label="Mention someone">
196− <button
197− type="button"
198− aria-label="Mention someone"
199− disabled={disabled}
200− onClick={() => {
201− const element = box.current;
202− if (!element) return;
203− const at = element.selectionStart;
204− const before = element.value.slice(0, at);
205− const insert = before && !/\s$/.test(before) ? " @" : "@";
206− const next = `${before}${insert}${element.value.slice(at)}`;
207− setText(next);
208− requestAnimationFrame(() => {
209− element.focus();
210− element.setSelectionRange(at + insert.length, at + insert.length);
211− setMatch(mentionQuery(next, at + insert.length, people));
212− });
213− }}
214− className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
215− >
216− <AtSign size={15} />
217− </button>
218− </Hint>
219− <EmojiPickerPopover
220− side="top"
221− align="start"
222− onPick={(picked) => {
223− const element = box.current;
224− const at = element?.selectionStart ?? text.length;
225− const end = element?.selectionEnd ?? at;
226− const next = `${text.slice(0, at)}${picked}${text.slice(end)}`;
227− setText(next);
228− requestAnimationFrame(() => {
229− element?.focus();
230− element?.setSelectionRange(at + picked.length, at + picked.length);
231− });
232− }}
233− >
234− <Hint label="Add an emoji">
235− <button
236− type="button"
237− aria-label="Add an emoji"
238− disabled={disabled}
239− className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
240− >
241− <Smile size={15} />
242− </button>
243− </Hint>
244− </EmojiPickerPopover>
245− <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
246− <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
247− </span>
248− <button
249− type="button"
250− aria-label="Send"
251− disabled={disabled || !text.trim()}
252− onClick={send}
253− className="ml-auto flex size-8 items-center justify-center rounded-lg bg-accent text-bg transition-[background-color,opacity] hover:bg-accent-hover disabled:bg-line disabled:text-faint"
254− >
255− <ArrowUp size={16} strokeWidth={2.4} />
256− </button>
257− </div>
25+ <div aria-busy="true" className="rounded-xl border border-line-strong bg-surface">
26+ <p className={`px-3.5 text-[0.9375rem] leading-6 text-faint ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}>{placeholder}</p>
27+ <div className="flex items-center gap-1 px-2 pb-2">
28+ <span className="h-7" />
29+ <span aria-hidden="true" className="ml-auto flex size-8 items-center justify-center rounded-lg bg-line text-faint">
30+ <ArrowUp size={16} strokeWidth={2.4} />
31+ </span>
25832 </div>
25933 </div>
26034 );
+840−0
1+import { Extension, markInputRule, type Editor } from "@tiptap/core";
2+import { Slice } from "@tiptap/pm/model";
3+import { Placeholder } from "@tiptap/extensions";
4+import { EditorContent, useEditor, useEditorState } from "@tiptap/react";
5+import StarterKit from "@tiptap/starter-kit";
6+import {
7+ ArrowUp,
8+ AtSign,
9+ Bold,
10+ Code,
11+ Italic,
12+ Keyboard,
13+ Link as LinkIcon,
14+ List,
15+ ListOrdered,
16+ Smile,
17+ SquareCode,
18+ Strikethrough,
19+ TextQuote,
20+ Type,
21+ Unlink,
22+} from "lucide-react";
23+import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type RefObject, useEffect, useId, useMemo, useRef, useState } from "react";
24+
25+import { AgentPill, MemberAvatar } from "./marks";
26+import { Hint } from "../ui/hint";
27+import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
28+// `:shortcode` completion and the emoji picker (components/emoji).
29+import { useEmojiAutocomplete } from "../emoji/autocomplete";
30+import { EmojiPickerPopover } from "../emoji/picker";
31+import { type MentionQuery, type Mentionable, mentionQuery, safeHref, shownHandle } from "../../lib/chat";
32+import { docToMarkdown, markdownToDoc } from "../../lib/chat-compose";
33+
34+/** How tall the box grows before it scrolls. */
35+const MAX_HEIGHT = 240;
36+
37+export type ComposerProps = {
38+ /** Where the draft is kept while the tab is open; none when editing a message. */
39+ draftKey?: string;
40+ placeholder: string;
41+ people: Mentionable[];
42+ onSend: (body: string) => void;
43+ onTyping?: () => void;
44+ disabled?: boolean;
45+ autoFocus?: boolean;
46+ compact?: boolean;
47+ /** Editing a message: its Markdown to start from, and what leaving does. */
48+ initial?: string;
49+ onCancel?: () => void;
50+};
51+
52+// ---------------------------------------------------------------------------
53+// The editor's rules: Markdown typed as you go, and the shortcuts.
54+
55+/** `*bold*` (one star, as chat writes it) and `**bold**`; `_italic_`; `~strike~` and `~~strike~~`. */
56+const ChatMarks = Extension.create({
57+ name: "chatMarks",
58+ // Before the marks' own rules, so `*x*` is bold here, not italic.
59+ priority: 1000,
60+ addInputRules() {
61+ const { bold, italic, strike } = this.editor.schema.marks;
62+ return [
63+ markInputRule({ find: /(?:^|\s)(\*\*(?!\s+\*\*)((?:[^*]+))\*\*(?!\s+\*\*))$/, type: bold! }),
64+ markInputRule({ find: /(?:^|\s)(\*(?!\s+\*)((?:[^*]+))\*(?!\s+\*))$/, type: bold! }),
65+ markInputRule({ find: /(?:^|\s)(_(?!\s+_)((?:[^_]+))_(?!\s+_))$/, type: italic! }),
66+ markInputRule({ find: /(?:^|\s)(~(?!\s+~)((?:[^~]+))~(?!\s+~))$/, type: strike! }),
67+ ];
68+ },
69+});
70+
71+/** The shortcuts beyond the editor's own (Mod+B, Mod+I, Mod+E, Mod+Shift+7 and 8). */
72+function chatKeys(openLink: RefObject<() => void>) {
73+ return Extension.create({
74+ name: "chatKeys",
75+ addKeyboardShortcuts() {
76+ return {
77+ "Mod-Shift-x": () => this.editor.commands.toggleStrike(),
78+ "Mod-Shift-c": () => this.editor.commands.toggleCode(),
79+ "Mod-Alt-Shift-c": () => this.editor.commands.toggleCodeBlock(),
80+ "Mod-Shift-9": () => this.editor.commands.toggleBlockquote(),
81+ "Mod-k": () => {
82+ openLink.current();
83+ return true;
84+ },
85+ };
86+ },
87+ });
88+}
89+
90+/** Whether the caret is inside a node of this type. */
91+function inside(editor: Editor, type: string): boolean {
92+ const { $from } = editor.state.selection;
93+ for (let depth = $from.depth; depth > 0; depth--) if ($from.node(depth).type.name === type) return true;
94+ return false;
95+}
96+
97+/**
98+ * The block the caret is in, as a text box: what the `@` and `:` menus
99+ * read and write, as they would a textarea. Each character of the block's
100+ * text is one position in the document.
101+ */
102+type TextBox = { value: string; selectionStart: number; selectionEnd: number; focus: () => void; setSelectionRange: (from: number, to: number) => void };
103+
104+function textBox(editor: Editor): TextBox {
105+ return {
106+ get value() {
107+ const parent = editor.state.selection.$from.parent;
108+ return parent.textBetween(0, parent.content.size, undefined, "");
109+ },
110+ get selectionStart() {
111+ return editor.state.selection.$from.parentOffset;
112+ },
113+ get selectionEnd() {
114+ const { $from, $to } = editor.state.selection;
115+ return $from.sameParent($to) ? $to.parentOffset : $from.parentOffset;
116+ },
117+ focus: () => editor.commands.focus(),
118+ setSelectionRange: (from, to) => {
119+ const start = editor.state.selection.$from.start();
120+ editor.commands.setTextSelection({ from: start + from, to: start + to });
121+ },
122+ };
123+}
124+
125+/** The caret's block made to read `next`: only what changed is replaced, so its formatting stays. */
126+function replaceBlockText(editor: Editor, next: string) {
127+ const box = textBox(editor);
128+ const now = box.value;
129+ let head = 0;
130+ while (head < now.length && head < next.length && now[head] === next[head]) head++;
131+ let tail = 0;
132+ while (tail < now.length - head && tail < next.length - head && now[now.length - 1 - tail] === next[next.length - 1 - tail]) tail++;
133+ const start = editor.state.selection.$from.start();
134+ const inserted = next.slice(head, next.length - tail);
135+ const tr = editor.state.tr;
136+ if (inserted) tr.insertText(inserted, start + head, start + now.length - tail);
137+ else tr.delete(start + head, start + now.length - tail);
138+ editor.view.dispatch(tr);
139+}
140+
141+/** What a link field holds: an address as typed, made into one a link may go to. */
142+function linkAddress(typed: string): string | null {
143+ const trimmed = typed.trim();
144+ if (!trimmed) return null;
145+ if (/^[\w.+-]+@[\w-]+\.[\w.-]+$/.test(trimmed)) return safeHref(`mailto:${trimmed}`);
146+ if (!/^[a-z][a-z0-9+.-]*:/i.test(trimmed) && !trimmed.startsWith("/")) return safeHref(`https://${trimmed}`);
147+ return safeHref(trimmed);
148+}
149+
150+const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent) ? "⌘" : "Ctrl";
151+
152+/** Every formatting shortcut, for the hints and the list. */
153+const SHORTCUTS: { label: string; keys: string[] }[] = [
154+ { label: "Bold", keys: [MOD, "B"] },
155+ { label: "Italic", keys: [MOD, "I"] },
156+ { label: "Strikethrough", keys: [MOD, "Shift", "X"] },
157+ { label: "Link", keys: [MOD, "K"] },
158+ { label: "Code", keys: [MOD, "Shift", "C"] },
159+ { label: "Code block", keys: [MOD, "Alt", "Shift", "C"] },
160+ { label: "Bulleted list", keys: [MOD, "Shift", "8"] },
161+ { label: "Numbered list", keys: [MOD, "Shift", "7"] },
162+ { label: "Quote", keys: [MOD, "Shift", "9"] },
163+];
164+
165+const keysOf = (label: string) => SHORTCUTS.find((s) => s.label === label)!.keys.join("+");
166+
167+/** Whether the formatting bar is shown: as last chosen on this device, else on wider screens. */
168+function useToolbarShown(): [boolean, (shown: boolean) => void] {
169+ const [shown, setShown] = useState(true);
170+ useEffect(() => {
171+ try {
172+ const saved = localStorage.getItem("chat-formatting");
173+ if (saved) return setShown(saved === "shown");
174+ } catch {
175+ // No storage: the screen decides.
176+ }
177+ setShown(window.matchMedia("(min-width: 768px)").matches);
178+ }, []);
179+ return [
180+ shown,
181+ (next: boolean) => {
182+ setShown(next);
183+ try {
184+ localStorage.setItem("chat-formatting", next ? "shown" : "hidden");
185+ } catch {
186+ // Kept only while the page is open.
187+ }
188+ },
189+ ];
190+}
191+
192+// ---------------------------------------------------------------------------
193+// The composer.
194+
195+/**
196+ * Where a message is written, formatted as it is typed. Enter sends and
197+ * Shift+Enter starts a new line; in a list Enter adds an item (twice to
198+ * leave it), in a code block a line (three times to leave it), and
199+ * Ctrl+Enter sends from anywhere. Markdown typed inline becomes its
200+ * formatting; the bar above the text does the same with a click. `@`
201+ * offers the workspace's people and agents and `:` its emoji (Up and Down
202+ * to move, Tab or Enter to take one, Escape to dismiss). What is sent is
203+ * Markdown. A draft is kept per conversation while the tab is open.
204+ */
205+export default function ChatEditor({ draftKey, placeholder, people, onSend, onTyping, disabled, autoFocus, compact, initial, onCancel }: ComposerProps) {
206+ const editing = initial != null;
207+ const [match, setMatch] = useState<MentionQuery | null>(null);
208+ const [active, setActive] = useState(0);
209+ const [link, setLink] = useState<{ text: string; href: string; existing: boolean } | null>(null);
210+ const [toolbar, setToolbar] = useToolbarShown();
211+ const list = useId();
212+
213+ // What the editor's handlers read: the latest of each, not the first render's.
214+ const keys = useRef<(event: KeyboardEvent) => boolean>(() => false);
215+ const openLink = useRef<() => void>(() => {});
216+ const placeholderRef = useRef(placeholder);
217+ placeholderRef.current = placeholder;
218+ const typing = useRef(onTyping);
219+ typing.current = onTyping;
220+ const loading = useRef(false);
221+
222+ const extensions = useMemo(
223+ () => [
224+ StarterKit.configure({
225+ heading: false,
226+ horizontalRule: false,
227+ underline: false,
228+ dropcursor: false,
229+ gapcursor: false,
230+ trailingNode: false,
231+ link: {
232+ openOnClick: false,
233+ autolink: false,
234+ linkOnPaste: true,
235+ protocols: ["mailto"],
236+ isAllowedUri: (url) => safeHref(url) != null,
237+ HTMLAttributes: { rel: "noopener noreferrer nofollow ugc", target: null },
238+ },
239+ codeBlock: { HTMLAttributes: { spellcheck: "false" } },
240+ }),
241+ ChatMarks,
242+ chatKeys(openLink),
243+ Placeholder.configure({ placeholder: () => placeholderRef.current }),
244+ ],
245+ [],
246+ );
247+
248+ const draftOf = (key: string | undefined): string => {
249+ if (editing) return initial ?? "";
250+ if (!key) return "";
251+ try {
252+ return sessionStorage.getItem(`chat-draft:${key}`) ?? "";
253+ } catch {
254+ return "";
255+ }
256+ };
257+
258+ const editor = useEditor({
259+ extensions,
260+ immediatelyRender: false,
261+ autofocus: autoFocus ? "end" : false,
262+ editable: !disabled,
263+ content: markdownToDoc(draftOf(draftKey)),
264+ editorProps: {
265+ attributes: {
266+ role: "textbox",
267+ "aria-multiline": "true",
268+ "aria-label": placeholder,
269+ "data-1p-ignore": "",
270+ "data-lpignore": "true",
271+ class: "chat-editor outline-none",
272+ },
273+ handleKeyDown: (_view, event) => keys.current(event),
274+ // Text pasted without formatting is read as Markdown, the way it would be sent.
275+ clipboardTextParser: (text, $context, plain, view) => {
276+ if (plain || $context.parent.type.spec.code) return null as unknown as Slice;
277+ const doc = view.state.schema.nodeFromJSON(markdownToDoc(text));
278+ return Slice.maxOpen(doc.content);
279+ },
280+ },
281+ onUpdate: ({ editor: now }) => {
282+ if (loading.current) return;
283+ look(now);
284+ if (!editing && draftKey) {
285+ const markdown = docToMarkdown(now.getJSON());
286+ try {
287+ if (markdown) sessionStorage.setItem(`chat-draft:${draftKey}`, markdown);
288+ else sessionStorage.removeItem(`chat-draft:${draftKey}`);
289+ } catch {
290+ // No storage: the draft lives only while the page does.
291+ }
292+ }
293+ typing.current?.();
294+ },
295+ onSelectionUpdate: ({ editor: now }) => look(now),
296+ onBlur: () =>
297+ setTimeout(() => {
298+ setMatch(null);
299+ emoji.dismiss();
300+ }, 150),
301+ });
302+
303+ // The emoji menu works on the caret's block as on a text box.
304+ const box = useRef<TextBox | null>(null);
305+ box.current = editor ? textBox(editor) : null;
306+ const emoji = useEmojiAutocomplete({
307+ box: box as unknown as RefObject<HTMLTextAreaElement | null>,
308+ setText: (next) => editor && replaceBlockText(editor, next),
309+ });
310+
311+ // Another conversation's draft when the conversation changes.
312+ const shownKey = useRef(draftKey);
313+ useEffect(() => {
314+ if (!editor || shownKey.current === draftKey) return;
315+ shownKey.current = draftKey;
316+ loading.current = true;
317+ editor.commands.setContent(markdownToDoc(draftOf(draftKey)), { emitUpdate: false });
318+ loading.current = false;
319+ setMatch(null);
320+ setLink(null);
321+ // eslint-disable-next-line react-hooks/exhaustive-deps
322+ }, [editor, draftKey]);
323+
324+ useEffect(() => {
325+ editor?.setEditable(!disabled);
326+ }, [editor, disabled]);
327+
328+ const state = useEditorState({
329+ editor,
330+ selector: ({ editor: now }) =>
331+ now
332+ ? {
333+ bold: now.isActive("bold"),
334+ italic: now.isActive("italic"),
335+ strike: now.isActive("strike"),
336+ code: now.isActive("code"),
337+ link: now.isActive("link"),
338+ codeBlock: now.isActive("codeBlock"),
339+ bulletList: now.isActive("bulletList"),
340+ orderedList: now.isActive("orderedList"),
341+ blockquote: now.isActive("blockquote"),
342+ empty: now.isEmpty || docToMarkdown(now.getJSON()).trim() === "",
343+ }
344+ : null,
345+ });
346+
347+ function look(now: Editor) {
348+ emoji.look();
349+ const { selection } = now.state;
350+ if (!selection.empty || inside(now, "codeBlock") || now.isActive("code")) {
351+ setMatch(null);
352+ return;
353+ }
354+ const text = textBox(now);
355+ setMatch(mentionQuery(text.value, text.selectionStart, people));
356+ setActive(0);
357+ }
358+
359+ const complete = (person: Mentionable) => {
360+ if (!editor || !match) return;
361+ const start = editor.state.selection.$from.start();
362+ const caret = editor.state.selection.$from.parentOffset;
363+ editor
364+ .chain()
365+ .focus()
366+ .insertContentAt({ from: start + match.start, to: start + caret }, { type: "text", text: `@${person.name} ` })
367+ .run();
368+ setMatch(null);
369+ };
370+
371+ const send = () => {
372+ if (!editor || disabled) return;
373+ const body = docToMarkdown(editor.getJSON()).trim();
374+ if (!body) return;
375+ onSend(body);
376+ if (editing) return;
377+ editor.commands.clearContent(true);
378+ setMatch(null);
379+ setLink(null);
380+ };
381+
382+ openLink.current = () => {
383+ if (!editor) return;
384+ // On a link already: its whole text, to change or remove.
385+ if (editor.isActive("link")) editor.chain().extendMarkRange("link").run();
386+ const { from, to } = editor.state.selection;
387+ setLink({ text: editor.state.doc.textBetween(from, to, " "), href: editor.getAttributes("link").href ?? "", existing: editor.isActive("link") });
388+ };
389+
390+ keys.current = (event) => {
391+ if (!editor) return false;
392+ if (emoji.onKeyDown(event as unknown as ReactKeyboardEvent<HTMLElement>)) return true;
393+ if (match) {
394+ const count = match.options.length;
395+ if (event.key === "ArrowDown" || event.key === "ArrowUp") {
396+ event.preventDefault();
397+ setActive((index) => (index + (event.key === "ArrowDown" ? 1 : count - 1)) % count);
398+ return true;
399+ }
400+ if ((event.key === "Tab" || event.key === "Enter") && !event.shiftKey) {
401+ event.preventDefault();
402+ complete(match.options[active] ?? match.options[0]!);
403+ return true;
404+ }
405+ if (event.key === "Escape") {
406+ event.preventDefault();
407+ setMatch(null);
408+ return true;
409+ }
410+ }
411+ if (event.key === "Escape" && editing) {
412+ event.preventDefault();
413+ onCancel?.();
414+ return true;
415+ }
416+ if (event.key !== "Enter" || event.isComposing) return false;
417+ // Ctrl+Enter (⌘+Enter) sends from anywhere, a list or code too.
418+ if (event.ctrlKey || event.metaKey) {
419+ event.preventDefault();
420+ send();
421+ return true;
422+ }
423+ const inCode = inside(editor, "codeBlock");
424+ const inList = inside(editor, "listItem");
425+ if (inCode) {
426+ // A new line in the block; three Enters in a row leave it.
427+ if (event.shiftKey) {
428+ editor
429+ .chain()
430+ .command(({ tr }) => {
431+ tr.insertText("\n");
432+ return true;
433+ })
434+ .run();
435+ return true;
436+ }
437+ return false;
438+ }
439+ if (inList) {
440+ // A new item, or out of the list from an empty one.
441+ return event.shiftKey ? editor.commands.splitListItem("listItem") || false : false;
442+ }
443+ if (event.shiftKey) {
444+ // A new line: the next paragraph, so lists and quotes can start on it.
445+ return editor.commands.splitBlock();
446+ }
447+ // ``` and a language, then Enter: a code block.
448+ const fence = /^```([\w+#.-]*)$/.exec(textBox(editor).value);
449+ if (fence) {
450+ const start = editor.state.selection.$from.start();
451+ editor
452+ .chain()
453+ .deleteRange({ from: start, to: start + fence[0].length })
454+ .setCodeBlock(fence[1] ? { language: fence[1] } : undefined)
455+ .run();
456+ return true;
457+ }
458+ event.preventDefault();
459+ send();
460+ return true;
461+ };
462+
463+ // The `@` list is the text box's: say so to a screen reader.
464+ useEffect(() => {
465+ const dom = editor?.view.dom;
466+ if (!dom) return;
467+ if (match) {
468+ dom.setAttribute("aria-expanded", "true");
469+ dom.setAttribute("aria-controls", list);
470+ dom.setAttribute("aria-activedescendant", `${list}-${active}`);
471+ } else {
472+ dom.removeAttribute("aria-expanded");
473+ dom.removeAttribute("aria-controls");
474+ dom.removeAttribute("aria-activedescendant");
475+ }
476+ }, [editor, match, active, list]);
477+
478+ const applyLink = (text: string, href: string) => {
479+ if (!editor) return false;
480+ const address = linkAddress(href);
481+ if (!address) return false;
482+ const { from, to } = editor.state.selection;
483+ if (from === to) {
484+ const words = text.trim() || address;
485+ editor
486+ .chain()
487+ .focus()
488+ .insertContent({ type: "text", text: words, marks: [{ type: "link", attrs: { href: address } }] })
489+ .unsetMark("link")
490+ .insertContent({ type: "text", text: " " })
491+ .run();
492+ } else {
493+ editor.chain().focus().extendMarkRange("link").setLink({ href: address }).run();
494+ }
495+ return true;
496+ };
497+
498+ const tool = (label: string, icon: ReactNode, on: boolean | undefined, run: () => void) => (
499+ <Hint label={`${label} (${keysOf(label)})`}>
500+ <button
501+ type="button"
502+ aria-label={label}
503+ aria-pressed={!!on}
504+ disabled={disabled || !editor}
505+ onMouseDown={(event) => event.preventDefault()}
506+ onClick={run}
507+ className={`flex size-8 shrink-0 items-center justify-center rounded-md transition-colors sm:size-7 ${
508+ on ? "bg-accent/15 text-accent" : "text-faint hover:bg-raised hover:text-fg"
509+ }`}
510+ >
511+ {icon}
512+ </button>
513+ </Hint>
514+ );
515+
516+ const chain = () => editor!.chain().focus();
517+
518+ return (
519+ <div className="relative">
520+ {emoji.menu}
521+ {match && (
522+ <ul
523+ id={list}
524+ role="listbox"
525+ aria-label="Mention someone"
526+ className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm overflow-hidden rounded-lg border border-line-strong bg-raised p-1 shadow-xl shadow-black/40"
527+ >
528+ {match.options.map((person, index) => (
529+ <li
530+ key={`${person.kind}:${person.name}`}
531+ id={`${list}-${index}`}
532+ role="option"
533+ aria-selected={index === active}
534+ onMouseDown={(event) => event.preventDefault()}
535+ onMouseEnter={() => setActive(index)}
536+ onClick={() => complete(person)}
537+ className={`flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 ${index === active ? "bg-line" : ""}`}
538+ >
539+ <MemberAvatar member={person} size={22} />
540+ <span className="min-w-0 grow truncate text-sm">
541+ <span className="font-medium text-fg">{person.display_name}</span>
542+ <span className="ml-1.5 text-faint">@{shownHandle(person)}</span>
543+ </span>
544+ {person.kind === "agent" && <AgentPill />}
545+ </li>
546+ ))}
547+ </ul>
548+ )}
549+ {link && (
550+ <LinkForm
551+ initial={link}
552+ onApply={(text, href) => {
553+ if (!applyLink(text, href)) return false;
554+ setLink(null);
555+ return true;
556+ }}
557+ onRemove={
558+ link.existing
559+ ? () => {
560+ editor?.chain().focus().extendMarkRange("link").unsetLink().run();
561+ setLink(null);
562+ }
563+ : undefined
564+ }
565+ onClose={() => {
566+ setLink(null);
567+ editor?.commands.focus();
568+ }}
569+ />
570+ )}
571+ <div
572+ className={`rounded-xl border border-line-strong bg-surface transition-colors focus-within:border-accent/35 focus-within:ring-4 focus-within:ring-accent/[0.06] ${disabled ? "opacity-60" : ""}`}
573+ >
574+ {toolbar && (
575+ <div
576+ role="toolbar"
577+ aria-label="Formatting"
578+ className="flex items-center gap-0.5 overflow-x-auto border-b border-line px-1.5 py-1 [scrollbar-width:none]"
579+ >
580+ {tool("Bold", <Bold size={15} strokeWidth={2.4} />, state?.bold, () => chain().toggleBold().run())}
581+ {tool("Italic", <Italic size={15} />, state?.italic, () => chain().toggleItalic().run())}
582+ {tool("Strikethrough", <Strikethrough size={15} />, state?.strike, () => chain().toggleStrike().run())}
583+ <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
584+ {tool("Link", <LinkIcon size={15} />, state?.link, () => openLink.current())}
585+ <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
586+ {tool("Bulleted list", <List size={15} />, state?.bulletList, () => chain().toggleBulletList().run())}
587+ {tool("Numbered list", <ListOrdered size={15} />, state?.orderedList, () => chain().toggleOrderedList().run())}
588+ {tool("Quote", <TextQuote size={15} />, state?.blockquote, () => chain().toggleBlockquote().run())}
589+ <span aria-hidden="true" className="mx-1 h-4 w-px shrink-0 bg-line" />
590+ {tool("Code", <Code size={15} />, state?.code, () => chain().toggleCode().run())}
591+ {tool("Code block", <SquareCode size={15} />, state?.codeBlock, () => chain().toggleCodeBlock().run())}
592+ <ShortcutList />
593+ </div>
594+ )}
595+ <div
596+ className={`overflow-y-auto px-3.5 text-[0.9375rem] leading-6 text-fg [scrollbar-width:thin] ${compact ? "pt-2.5 pb-1" : "pt-3 pb-1.5"}`}
597+ style={{ maxHeight: MAX_HEIGHT }}
598+ onClick={(event) => {
599+ // The whole box takes focus, not only its first line.
600+ if (event.target === event.currentTarget) editor?.commands.focus("end");
601+ }}
602+ >
603+ {editor ? (
604+ <EditorContent editor={editor} />
605+ ) : (
606+ <p aria-hidden="true" className="text-faint">
607+ {placeholder}
608+ </p>
609+ )}
610+ </div>
611+ <div className="flex items-center gap-1 px-2 pb-2">
612+ <Hint label={toolbar ? "Hide formatting" : "Show formatting"}>
613+ <button
614+ type="button"
615+ aria-label={toolbar ? "Hide formatting" : "Show formatting"}
616+ aria-pressed={toolbar}
617+ onMouseDown={(event) => event.preventDefault()}
618+ onClick={() => setToolbar(!toolbar)}
619+ className={`flex size-7 items-center justify-center rounded-md transition-colors ${toolbar ? "bg-raised text-fg" : "text-faint hover:bg-raised hover:text-fg"}`}
620+ >
621+ <Type size={15} />
622+ </button>
623+ </Hint>
624+ <Hint label="Mention someone">
625+ <button
626+ type="button"
627+ aria-label="Mention someone"
628+ disabled={disabled || !editor}
629+ onMouseDown={(event) => event.preventDefault()}
630+ onClick={() => {
631+ if (!editor) return;
632+ const text = textBox(editor);
633+ const before = text.value.slice(0, text.selectionStart);
634+ editor
635+ .chain()
636+ .focus()
637+ .insertContent({ type: "text", text: before && !/\s$/.test(before) ? " @" : "@" })
638+ .run();
639+ look(editor);
640+ }}
641+ className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
642+ >
643+ <AtSign size={15} />
644+ </button>
645+ </Hint>
646+ <EmojiPickerPopover side="top" align="start" onPick={(picked) => editor?.chain().focus().insertContent({ type: "text", text: picked }).run()}>
647+ <Hint label="Add an emoji">
648+ <button
649+ type="button"
650+ aria-label="Add an emoji"
651+ disabled={disabled || !editor}
652+ className="flex size-7 items-center justify-center rounded-md text-faint transition-colors hover:bg-raised hover:text-fg"
653+ >
654+ <Smile size={15} />
655+ </button>
656+ </Hint>
657+ </EmojiPickerPopover>
658+ {editing ? (
659+ <>
660+ <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
661+ <kbd className="font-sans">Esc</kbd> to cancel · <kbd className="font-sans">Enter</kbd> to save
662+ </span>
663+ <span className="ml-auto flex items-center gap-1.5">
664+ <button
665+ type="button"
666+ onClick={onCancel}
667+ className="h-8 rounded-lg border border-line px-3 text-[0.8125rem] font-medium text-fg-soft transition-colors hover:border-line-strong hover:bg-raised"
668+ >
669+ Cancel
670+ </button>
671+ <button
672+ type="button"
673+ disabled={disabled || !editor || state?.empty}
674+ onClick={send}
675+ className="h-8 rounded-lg bg-accent px-3 text-[0.8125rem] font-medium text-bg transition-colors hover:bg-accent-hover disabled:bg-line disabled:text-faint"
676+ >
677+ Save
678+ </button>
679+ </span>
680+ </>
681+ ) : (
682+ <>
683+ <span className="ml-1 hidden text-[0.6875rem] text-faint sm:inline">
684+ <kbd className="font-sans">Enter</kbd> to send · <kbd className="font-sans">Shift+Enter</kbd> for a new line
685+ </span>
686+ <button
687+ type="button"
688+ aria-label="Send"
689+ disabled={disabled || !editor || state?.empty !== false}
690+ onMouseDown={(event) => event.preventDefault()}
691+ onClick={send}
692+ className="ml-auto flex size-8 items-center justify-center rounded-lg bg-accent text-bg transition-[background-color,opacity] hover:bg-accent-hover disabled:bg-line disabled:text-faint"
693+ >
694+ <ArrowUp size={16} strokeWidth={2.4} />
695+ </button>
696+ </>
697+ )}
698+ </div>
699+ </div>
700+ </div>
701+ );
702+}
703+
704+/** The bar's last button: every shortcut, and what Markdown typed becomes. */
705+function ShortcutList() {
706+ return (
707+ <Popover>
708+ <Hint label="Formatting shortcuts">
709+ <PopoverTrigger
710+ aria-label="Formatting shortcuts"
711+ onMouseDown={(event) => event.preventDefault()}
712+ className="ml-auto flex size-8 shrink-0 items-center justify-center rounded-md text-faint transition-colors outline-none hover:bg-raised hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/50 sm:size-7"
713+ >
714+ <Keyboard size={15} />
715+ </PopoverTrigger>
716+ </Hint>
717+ <PopoverContent side="top" align="end" className="w-80 p-3" onOpenAutoFocus={(event) => event.preventDefault()}>
718+ <p className="mb-2 text-xs font-semibold text-fg">Formatting shortcuts</p>
719+ <dl className="space-y-1 text-[0.8125rem]">
720+ {SHORTCUTS.map((shortcut) => (
721+ <div key={shortcut.label} className="flex items-center justify-between gap-3">
722+ <dt className="text-fg-soft">{shortcut.label}</dt>
723+ <dd className="flex gap-1">
724+ {shortcut.keys.map((key) => (
725+ <kbd key={key} className="rounded border border-line-strong bg-surface px-1.5 font-sans text-[0.6875rem] text-muted">
726+ {key}
727+ </kbd>
728+ ))}
729+ </dd>
730+ </div>
731+ ))}
732+ </dl>
733+ <p className="mt-3 mb-1.5 text-xs font-semibold text-fg">Or type Markdown</p>
734+ <p className="font-mono text-[0.75rem] leading-relaxed text-muted">
735+ *bold* _italic_ ~strike~ `code`
736+ <br />
737+ - list · 1. list · &gt; quote
738+ <br />
739+ ``` code block
740+ </p>
741+ <p className="mt-3 text-[0.75rem] text-faint">
742+ <kbd className="font-sans">{MOD}+Enter</kbd> sends from a list or code block.
743+ </p>
744+ </PopoverContent>
745+ </Popover>
746+ );
747+}
748+
749+/** Adding or changing a link: its text when nothing is selected, and where it goes. */
750+function LinkForm({
751+ initial,
752+ onApply,
753+ onRemove,
754+ onClose,
755+}: {
756+ initial: { text: string; href: string; existing: boolean };
757+ onApply: (text: string, href: string) => boolean;
758+ onRemove?: () => void;
759+ onClose: () => void;
760+}) {
761+ const [text, setText] = useState(initial.text);
762+ const [href, setHref] = useState(initial.href);
763+ const [wrong, setWrong] = useState(false);
764+ const askText = !initial.text;
765+ const id = useId();
766+ const keys = (event: ReactKeyboardEvent) => {
767+ if (event.key === "Escape") {
768+ event.preventDefault();
769+ event.stopPropagation();
770+ onClose();
771+ }
772+ };
773+ return (
774+ <form
775+ aria-label={initial.existing ? "Edit link" : "Add a link"}
776+ onSubmit={(event) => {
777+ event.preventDefault();
778+ setWrong(!onApply(text, href));
779+ }}
780+ onKeyDown={keys}
781+ className="absolute bottom-full left-0 z-20 mb-2 w-full max-w-sm space-y-2 rounded-lg border border-line-strong bg-raised p-3 shadow-xl shadow-black/40"
782+ >
783+ {askText && (
784+ <div className="space-y-1">
785+ <label htmlFor={`${id}-text`} className="text-xs font-medium text-muted">
786+ Text
787+ </label>
788+ <input
789+ id={`${id}-text`}
790+ autoFocus
791+ value={text}
792+ onChange={(event) => setText(event.target.value)}
793+ placeholder="What the link says"
794+ autoComplete="off"
795+ className="h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/40"
796+ />
797+ </div>
798+ )}
799+ <div className="space-y-1">
800+ <label htmlFor={`${id}-href`} className="text-xs font-medium text-muted">
801+ Link
802+ </label>
803+ <input
804+ id={`${id}-href`}
805+ autoFocus={!askText}
806+ value={href}
807+ onChange={(event) => {
808+ setHref(event.target.value);
809+ setWrong(false);
810+ }}
811+ placeholder="https://"
812+ inputMode="url"
813+ autoComplete="off"
814+ aria-invalid={wrong}
815+ aria-describedby={wrong ? `${id}-wrong` : undefined}
816+ className="h-8 w-full rounded-md border border-line bg-bg px-2.5 text-sm text-fg outline-none placeholder:text-faint focus:border-accent/40 aria-invalid:border-danger/60"
817+ />
818+ {wrong && (
819+ <p id={`${id}-wrong`} className="text-xs text-danger">
820+ A link goes to a web address (https://…), an email address or a page on g1t.
821+ </p>
822+ )}
823+ </div>
824+ <div className="flex items-center gap-1.5 pt-0.5">
825+ {onRemove && (
826+ <button type="button" onClick={onRemove} className="flex h-8 items-center gap-1.5 rounded-md px-2 text-[0.8125rem] text-muted hover:bg-line hover:text-fg">
827+ <Unlink size={14} />
828+ Remove link
829+ </button>
830+ )}
831+ <button type="button" onClick={onClose} className="ml-auto h-8 rounded-md px-3 text-[0.8125rem] text-muted hover:bg-line hover:text-fg">
832+ Cancel
833+ </button>
834+ <button type="submit" disabled={!href.trim()} className="h-8 rounded-md bg-accent px-3 text-[0.8125rem] font-medium text-bg hover:bg-accent-hover disabled:bg-line disabled:text-faint">
835+ {initial.existing ? "Save" : "Add link"}
836+ </button>
837+ </div>
838+ </form>
839+ );
840+}
+105−9
1−import { Fragment, type ReactNode, useMemo } from "react";
1+import { Check, Copy } from "lucide-react";
2+import { Fragment, type ReactNode, useEffect, useMemo, useState } from "react";
23 import { Link } from "react-router";
34
45 import { MemberCard } from "./profile-card";
6+import { Hint } from "../ui/hint";
57 import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat";
68 // The workspace's own emoji, drawn where `:name:` is written (components/emoji).
79 import { useEmojiContext } from "../emoji/context";
114116 ));
115117 }
116118
119+/** A run of blocks, a little apart. */
120+function BlocksView({ list, context, className = "space-y-1.5" }: { list: Block[]; context: TextContext; className?: string }) {
121+ return (
122+ <div className={className}>
123+ {list.map((block, index) => (
124+ <BlockView key={index} block={block} context={context} />
125+ ))}
126+ </div>
127+ );
128+}
129+
130+function ListItem({ item, context }: { item: Block[]; context: TextContext }) {
131+ // An item of one paragraph is its lines; one with more holds its blocks.
132+ if (item.length === 1 && item[0]!.t === "p") return <li className="pl-0.5">{lines(item[0]!.lines, context)}</li>;
133+ return (
134+ <li className="pl-0.5">
135+ <BlocksView list={item} context={context} className="space-y-1" />
136+ </li>
137+ );
138+}
139+
117140 function BlockView({ block, context }: { block: Block; context: TextContext }) {
118141 switch (block.t) {
119142 case "p":
120143 return <p>{lines(block.lines, context)}</p>;
144+ case "heading":
145+ // Chat, not a document: a heading is a bold line, the top two a touch larger.
146+ return <p className={`font-semibold text-fg ${block.level <= 2 ? "text-base" : ""}`}>{spans(block.c, context)}</p>;
147+ case "hr":
148+ return <hr className="my-2 border-line" />;
121149 case "code":
122− return (
123− <pre className="my-1 overflow-x-auto rounded-md border border-line bg-bg px-3 py-2 font-mono text-[0.8125rem] leading-relaxed text-fg-soft [scrollbar-width:thin]">
124− <code>{block.v}</code>
125− </pre>
126− );
150+ return <CodeBlock language={block.lang} code={block.v} />;
127151 case "list": {
128152 const Tag = block.ordered ? "ol" : "ul";
129153 return (
130− <Tag className={`my-0.5 space-y-0.5 pl-5 ${block.ordered ? "list-decimal" : "list-disc"} marker:text-faint`}>
154+ <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`}>
131155 {block.items.map((item, index) => (
132− <li key={index}>{spans(item, context)}</li>
156+ <ListItem key={index} item={item} context={context} />
133157 ))}
134158 </Tag>
135159 );
136160 }
137161 case "quote":
138− return <blockquote className="border-l-2 border-line-strong pl-3 text-muted">{lines(block.lines, context)}</blockquote>;
162+ return (
163+ <blockquote className="border-l-2 border-line-strong pl-3 text-muted">
164+ <BlocksView list={block.c} context={context} className="space-y-1" />
165+ </blockquote>
166+ );
139167 }
140168 }
141169
170+type Token = { content: string; color?: string };
171+
172+/**
173+ * Fenced code: plain at once, coloured once the highlighter has loaded
174+ * when its language is one g1t knows (lib/shiki.ts), with a copy button.
175+ * The colours are tokens drawn as text, never HTML.
176+ */
177+function CodeBlock({ language, code }: { language: string | null; code: string }) {
178+ const [rows, setRows] = useState<Token[][] | null>(null);
179+ const [copied, setCopied] = useState(false);
180+ useEffect(() => {
181+ setRows(null);
182+ if (!language || code.length > 20_000) return;
183+ let cancelled = false;
184+ void import("../../lib/shiki")
185+ .then(async ({ THEME, getHighlighter, languageNamed }) => {
186+ const lang = languageNamed(language);
187+ if (!lang) return null;
188+ const core = await getHighlighter();
189+ return core.codeToTokens(code, { lang, theme: THEME }).tokens.map((row) => row.map((token) => ({ content: token.content, color: token.color })));
190+ })
191+ .then((tokens) => {
192+ if (!cancelled && tokens) setRows(tokens);
193+ })
194+ .catch(() => {});
195+ return () => {
196+ cancelled = true;
197+ };
198+ }, [language, code]);
199+ return (
200+ <div className="group/code relative my-1">
201+ <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]">
202+ <code>
203+ {rows
204+ ? rows.map((row, index) => (
205+ <span key={index} className="block min-h-lh">
206+ {row.map((token, at) => (
207+ <span key={at} style={token.color ? { color: token.color } : undefined}>
208+ {token.content}
209+ </span>
210+ ))}
211+ </span>
212+ ))
213+ : code}
214+ </code>
215+ </pre>
216+ <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">
217+ {language && <span className="font-mono text-[0.6875rem] text-faint pointer-coarse:hidden">{language}</span>}
218+ <Hint label={copied ? "Copied" : "Copy code"}>
219+ <button
220+ type="button"
221+ aria-label="Copy code"
222+ onClick={() => {
223+ void navigator.clipboard?.writeText(code).then(() => {
224+ setCopied(true);
225+ setTimeout(() => setCopied(false), 1500);
226+ });
227+ }}
228+ className="flex size-6 items-center justify-center rounded-md border border-line bg-raised text-muted hover:text-fg"
229+ >
230+ {copied ? <Check size={12} className="text-success" /> : <Copy size={12} />}
231+ </button>
232+ </Hint>
233+ </div>
234+ </div>
235+ );
236+}
237+
142238 /** A message's text, rendered from parsed blocks: never as HTML. */
143239 export function MessageText({ body, context }: { body: string; context: TextContext }) {
144240 const parsed = useMemo(() => blocks(body), [body]);
+83−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type DocNode, docToMarkdown, escapeText, markdownToDoc } from "./chat-compose.ts";
5+
6+const text = (value: string, ...marks: (string | { type: string; attrs?: Record<string, unknown> })[]): DocNode => ({
7+ type: "text",
8+ text: value,
9+ ...(marks.length ? { marks: marks.map((mark) => (typeof mark === "string" ? { type: mark } : mark)) } : {}),
10+});
11+const p = (...content: DocNode[]): DocNode => (content.length ? { type: "paragraph", content } : { type: "paragraph" });
12+const doc = (...content: DocNode[]): DocNode => ({ type: "doc", content });
13+
14+test("formatting is written as Markdown", () => {
15+ assert.equal(
16+ docToMarkdown(doc(p(text("bold", "bold"), text(" "), text("it", "italic"), text(" "), text("gone", "strike"), text(" "), text("a*b", "code")))),
17+ "**bold** _it_ ~~gone~~ `a*b`",
18+ );
19+ // Marks that overlap open and close once.
20+ assert.equal(docToMarkdown(doc(p(text("a", "bold"), text("b", "bold", "italic"), text("c", "bold")))), "**a_b_c**");
21+ // Spaces at a mark's edges sit outside it.
22+ assert.equal(docToMarkdown(doc(p(text("say "), text(" hi ", "bold"), text("there")))), "say **hi** there");
23+ assert.equal(docToMarkdown(doc(p(text("tick ` in", "code")))), "`` tick ` in ``");
24+});
25+
26+test("links: with their text, or bare when the text is the address", () => {
27+ const link = (href: string) => ({ type: "link", attrs: { href } });
28+ assert.equal(docToMarkdown(doc(p(text("the docs", link("https://g1t.sh/docs"))))), "[the docs](https://g1t.sh/docs)");
29+ assert.equal(docToMarkdown(doc(p(text("https://g1t.sh", link("https://g1t.sh"))))), "https://g1t.sh");
30+ assert.equal(docToMarkdown(doc(p(text("x", link("https://a.example/a b"))))), "[x](<https://a.example/a%20b>)");
31+});
32+
33+test("each paragraph is a line; an empty one is a blank line", () => {
34+ assert.equal(docToMarkdown(doc(p(text("one")), p(text("two")), p(), p(text("three")))), "one\ntwo\n\nthree");
35+ assert.equal(docToMarkdown(doc(p(), p(text("x")), p())), "x");
36+});
37+
38+test("text that only looks like Markdown is escaped, and mentions stay mentions", () => {
39+ assert.equal(escapeText("2*3 and snake_case and _edge_ and [x] and `t` and ~5"), "2\\*3 and snake_case and \\_edge\\_ and \\[x\\] and \\`t\\` and \\~5");
40+ assert.equal(docToMarkdown(doc(p(text("- not a list")), p(text("1. not either")), p(text("# nor this")), p(text("> or this")))), "\\- not a list\n1\\. not either\n\\# nor this\n\\> or this");
41+ assert.equal(docToMarkdown(doc(p(text("ask @ana in #web about #12")))), "ask @ana in #web about #12");
42+});
43+
44+test("lists, quotes and code blocks", () => {
45+ const item = (...content: DocNode[]): DocNode => ({ type: "listItem", content });
46+ assert.equal(
47+ docToMarkdown(
48+ doc(
49+ p(text("Plan:")),
50+ { type: "bulletList", content: [item(p(text("one"))), item(p(text("two")), { type: "orderedList", attrs: { start: 1 }, content: [item(p(text("deep")))] })] },
51+ p(text("after")),
52+ ),
53+ ),
54+ "Plan:\n\n- one\n- two\n 1. deep\n\nafter",
55+ );
56+ assert.equal(docToMarkdown(doc({ type: "orderedList", attrs: { start: 3 }, content: [item(p(text("c"))), item(p(text("d")))] })), "3. c\n4. d");
57+ assert.equal(docToMarkdown(doc({ type: "blockquote", content: [p(text("quoted")), p(), p(text("more"))] })), "> quoted\n>\n> more");
58+ assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: "ts" }, content: [{ type: "text", text: "const a = 1;\n\n\nconst b = `x`;" }] })), "```ts\nconst a = 1;\n\n\nconst b = `x`;\n```");
59+ assert.equal(docToMarkdown(doc({ type: "codeBlock", attrs: { language: null }, content: [{ type: "text", text: "```\nfence\n```" }] })), "````\n```\nfence\n```\n````");
60+});
61+
62+test("Markdown comes back into the composer as it was written", () => {
63+ const samples = [
64+ "**bold** _it_ ~~gone~~ `code`",
65+ "one\ntwo\n\nthree",
66+ "Plan:\n\n- one\n- two\n 1. deep\n\nafter",
67+ "3. c\n4. d",
68+ "> quoted\n>\n> more",
69+ "```ts\nconst a = 1;\n\nconst b = 2;\n```",
70+ "[the docs](https://g1t.sh/docs) and https://g1t.sh",
71+ "ask @ana in #web about g1t#12",
72+ "\\- not a list and 2\\*3 and snake_case",
73+ "**a _b_ c**",
74+ ];
75+ for (const sample of samples) assert.equal(docToMarkdown(markdownToDoc(sample)), sample, sample);
76+});
77+
78+test("Markdown the composer cannot write still comes in", () => {
79+ // A heading is a bold line; a rule is left out; an image is a link.
80+ assert.equal(docToMarkdown(markdownToDoc("## Notes\n---\ntext")), "**Notes**\ntext");
81+ assert.equal(docToMarkdown(markdownToDoc("*star em* and __strong__ and ~one~")), "_star em_ and **strong** and ~~one~~");
82+ assert.deepEqual(markdownToDoc(""), { type: "doc", content: [{ type: "paragraph" }] });
83+});
+292−0
1+/**
2+ * The chat composer's document and the Markdown a message is stored as,
3+ * each turned into the other. The composer (components/chat/editor.tsx)
4+ * edits a small rich-text document; what it sends, keeps as a draft and
5+ * edits again is Markdown, the same text the API, agents and notifications
6+ * read. Pure, on the editor's JSON, so it is tested without a browser
7+ * (chat-compose.test.ts).
8+ *
9+ * In the document each paragraph is one line of the message: Shift+Enter
10+ * starts a new one, an empty one is a blank line.
11+ */
12+import { type Block, type Span, blocks } from "@g1t/contracts/chat-markdown";
13+
14+/** The editor's JSON, as much of it as is read here. */
15+export type DocNode = {
16+ type: string;
17+ attrs?: Record<string, unknown>;
18+ content?: DocNode[];
19+ text?: string;
20+ marks?: { type: string; attrs?: Record<string, unknown> }[];
21+};
22+
23+type Mark = { type: string; attrs?: Record<string, unknown> };
24+
25+// ---------------------------------------------------------------------------
26+// Markdown to the document.
27+
28+function textNode(text: string, marks: Mark[]): DocNode | null {
29+ if (!text) return null;
30+ return marks.length ? { type: "text", text, marks } : { type: "text", text };
31+}
32+
33+function spanNodes(list: Span[], marks: Mark[] = []): DocNode[] {
34+ const out: DocNode[] = [];
35+ const add = (node: DocNode | null) => node && out.push(node);
36+ for (const span of list) {
37+ switch (span.t) {
38+ case "text":
39+ add(textNode(span.v, marks));
40+ break;
41+ case "code":
42+ add(textNode(span.v, [...marks, { type: "code" }]));
43+ break;
44+ case "strong":
45+ out.push(...spanNodes(span.c, [...marks, { type: "bold" }]));
46+ break;
47+ case "em":
48+ out.push(...spanNodes(span.c, [...marks, { type: "italic" }]));
49+ break;
50+ case "del":
51+ out.push(...spanNodes(span.c, [...marks, { type: "strike" }]));
52+ break;
53+ case "link":
54+ out.push(...spanNodes(span.c, [{ type: "link", attrs: { href: span.href } }, ...marks]));
55+ break;
56+ case "mention":
57+ add(textNode(`@${span.name}`, marks));
58+ break;
59+ case "channel":
60+ add(textNode(`#${span.name}`, marks));
61+ break;
62+ case "ref":
63+ add(textNode(`${span.repo ?? ""}#${span.number}`, marks));
64+ break;
65+ }
66+ }
67+ return out;
68+}
69+
70+function paragraph(spans: Span[]): DocNode {
71+ const content = spanNodes(spans);
72+ return content.length ? { type: "paragraph", content } : { type: "paragraph" };
73+}
74+
75+function blockNodes(list: Block[]): DocNode[] {
76+ const out: DocNode[] = [];
77+ list.forEach((block, index) => {
78+ // Paragraphs one after another were apart by a blank line: an empty line between them.
79+ if (block.t === "p" && index > 0 && list[index - 1]!.t === "p") out.push({ type: "paragraph" });
80+ switch (block.t) {
81+ case "p":
82+ for (const line of block.lines) out.push(paragraph(line));
83+ break;
84+ case "heading":
85+ // Chat has no headings to write; one comes back as a bold line.
86+ out.push(paragraph([{ t: "strong", c: block.c }]));
87+ break;
88+ case "hr":
89+ break;
90+ case "code":
91+ out.push({ type: "codeBlock", attrs: { language: block.lang }, ...(block.v ? { content: [{ type: "text", text: block.v }] } : {}) });
92+ break;
93+ case "quote": {
94+ const content = blockNodes(block.c);
95+ out.push({ type: "blockquote", content: content.length ? content : [{ type: "paragraph" }] });
96+ break;
97+ }
98+ case "list":
99+ out.push({
100+ type: block.ordered ? "orderedList" : "bulletList",
101+ ...(block.ordered ? { attrs: { start: block.start } } : {}),
102+ content: block.items.map((item) => {
103+ const content = blockNodes(item);
104+ // An item starts with a line of text.
105+ if (content[0]?.type !== "paragraph") content.unshift({ type: "paragraph" });
106+ return { type: "listItem", content };
107+ }),
108+ });
109+ break;
110+ }
111+ });
112+ return out;
113+}
114+
115+/** A message's Markdown as the composer's document. */
116+export function markdownToDoc(markdown: string): DocNode {
117+ const content = blockNodes(blocks(markdown));
118+ return { type: "doc", content: content.length ? content : [{ type: "paragraph" }] };
119+}
120+
121+// ---------------------------------------------------------------------------
122+// The document to Markdown.
123+
124+/** Marks from the outside in: a link holds bold, bold holds code. */
125+const ORDER = ["link", "bold", "italic", "strike", "code"];
126+const OPEN: Record<string, string> = { bold: "**", italic: "_", strike: "~~" };
127+
128+function sameMark(a: Mark, b: Mark): boolean {
129+ return a.type === b.type && (a.type !== "link" || a.attrs?.href === b.attrs?.href);
130+}
131+
132+/** Text as Markdown shows it as typed: its marks escaped. */
133+export function escapeText(text: string): string {
134+ return text
135+ .replace(/\\(?=[\\`*_{}[\]()#+\-.!~>|<=:@])/g, "\\\\")
136+ .replace(/[`*~[\]]/g, (char) => `\\${char}`)
137+ .replace(/_/g, (char, at: number, all: string) => {
138+ // `snake_case` stays as typed: only an `_` at a word's edge could start or end italics.
139+ const before = all[at - 1] ?? "";
140+ const after = all[at + 1] ?? "";
141+ return /\w/.test(before) && /\w/.test(after) ? char : `\\${char}`;
142+ });
143+}
144+
145+/** A line that would read as a block's start (`# `, `- `, `1. `, `> `, `---`) when it is only text. */
146+function escapeLineStart(line: string): string {
147+ return line
148+ .replace(/^(\s*)(#{1,6}(?:\s|$))/, "$1\\$2")
149+ .replace(/^(\s*)([-+](?:\s|$))/, "$1\\$2")
150+ .replace(/^(\s*)(\d{1,9})([.)])(\s|$)/, "$1$2\\$3$4")
151+ .replace(/^(\s*)(>)/, "$1\\$2")
152+ .replace(/^(\s*)([-_=]{3,}\s*)$/, "$1\\$2")
153+ .replace(/^(\s*)•/, "$1\\•");
154+}
155+
156+function codeSpan(text: string): string {
157+ if (!text.includes("`")) return `\`${text}\``;
158+ return `\`\` ${text} \`\``;
159+}
160+
161+function linkTarget(href: string): string {
162+ return /[\s()<>]/.test(href) ? `<${href.replace(/[<>\s]/g, encodeURIComponent)}>` : href;
163+}
164+
165+/** A line of inline content (text with marks) as Markdown. */
166+function inlineMarkdown(nodes: DocNode[]): string {
167+ let out = "";
168+ const open: { mark: Mark; text: string }[] = [];
169+ // A link's text is gathered so a bare URL can be written bare.
170+ const close = (count: number) => {
171+ for (let n = 0; n < count; n++) {
172+ const top = open.pop()!;
173+ // Spaces at a mark's end go after its closing mark: `**a **` would not be bold.
174+ const body = top.text;
175+ const trailing = /\s*$/.exec(body)![0];
176+ const inner = body.slice(0, body.length - trailing.length);
177+ let written: string;
178+ if (top.mark.type === "link") {
179+ const href = String(top.mark.attrs?.href ?? "");
180+ written = inner === escapeText(href) && /^https?:\/\//i.test(href) && !/[\s()<>]/.test(href) ? href : `[${inner}](${linkTarget(href)})`;
181+ } else if (!inner) {
182+ written = "";
183+ } else {
184+ const delimiter = OPEN[top.mark.type] ?? "";
185+ written = `${delimiter}${inner}${delimiter}`;
186+ }
187+ append(written + trailing);
188+ }
189+ };
190+ const append = (text: string) => {
191+ if (open.length) open[open.length - 1]!.text += text;
192+ else out += text;
193+ };
194+ for (const node of nodes) {
195+ if (node.type === "hardBreak") {
196+ close(open.length);
197+ out += "\n";
198+ continue;
199+ }
200+ if (node.type !== "text" || !node.text) continue;
201+ const marks = [...(node.marks ?? [])]
202+ .filter((mark) => ORDER.includes(mark.type))
203+ .sort((a, b) => ORDER.indexOf(a.type) - ORDER.indexOf(b.type));
204+ // Close what this text no longer has, and everything opened after it.
205+ let keep = 0;
206+ while (keep < open.length && keep < marks.length && sameMark(open[keep]!.mark, marks[keep]!)) keep++;
207+ close(open.length - keep);
208+ const code = marks.some((mark) => mark.type === "code");
209+ let text = node.text;
210+ // Spaces before a mark's start go before it: `** a**` would not be bold.
211+ const opening = marks.slice(keep).filter((mark) => mark.type !== "code");
212+ if (opening.length) {
213+ const leading = /^\s*/.exec(text)![0];
214+ append(leading);
215+ text = text.slice(leading.length);
216+ }
217+ for (const mark of marks.slice(keep)) if (mark.type !== "code") open.push({ mark, text: "" });
218+ append(code ? codeSpan(text) : escapeText(text));
219+ }
220+ close(open.length);
221+ return out;
222+}
223+
224+function prefixLines(text: string, first: string, rest: string): string {
225+ return text
226+ .split("\n")
227+ .map((line, index) => (index === 0 ? first : line ? rest : rest.trimEnd()) + line)
228+ .join("\n");
229+}
230+
231+function blockMarkdown(node: DocNode): string | null {
232+ switch (node.type) {
233+ case "paragraph": {
234+ const line = inlineMarkdown(node.content ?? []);
235+ return line
236+ .split("\n")
237+ .map((part) => escapeLineStart(part))
238+ .join("\n");
239+ }
240+ case "codeBlock": {
241+ const text = (node.content ?? []).map((child) => child.text ?? "").join("");
242+ const longest = Math.max(2, ...(text.match(/`{3,}/g) ?? []).map((run) => run.length));
243+ const fence = "`".repeat(longest + 1);
244+ const language = typeof node.attrs?.language === "string" ? node.attrs.language : "";
245+ return `${fence}${language}\n${text}\n${fence}`;
246+ }
247+ case "blockquote": {
248+ const inner = blocksMarkdown(node.content ?? []);
249+ return inner
250+ .split("\n")
251+ .map((line) => (line ? `> ${line}` : ">"))
252+ .join("\n");
253+ }
254+ case "bulletList":
255+ case "orderedList": {
256+ const ordered = node.type === "orderedList";
257+ const start = ordered ? Number(node.attrs?.start ?? 1) || 1 : 1;
258+ return (node.content ?? [])
259+ .map((item, index) => {
260+ const marker = ordered ? `${start + index}. ` : "- ";
261+ const inner = blocksMarkdown(item.content ?? [], true);
262+ return prefixLines(inner, marker, " ".repeat(marker.length));
263+ })
264+ .join("\n");
265+ }
266+ default:
267+ return null;
268+ }
269+}
270+
271+function blocksMarkdown(nodes: DocNode[], tight = false): string {
272+ let out = "";
273+ let previous: string | null = null;
274+ for (const node of nodes) {
275+ const text = blockMarkdown(node);
276+ if (text == null) continue;
277+ if (previous != null) {
278+ // A line follows a line, and in a list item everything does; anything else stands apart by a blank line.
279+ out += tight || (previous === "paragraph" && node.type === "paragraph") ? "\n" : "\n\n";
280+ }
281+ out += text;
282+ previous = node.type;
283+ }
284+ return out;
285+}
286+
287+/** The composer's document as the Markdown a message is sent as. */
288+export function docToMarkdown(doc: DocNode): string {
289+ return blocksMarkdown(doc.content ?? [])
290+ .replace(/^(?:[ \t]*\n)+/, "")
291+ .replace(/(?:\n[ \t]*)+$/, "");
292+}
+2−136
134134 }
135135
136136 // ---------------------------------------------------------------------------
137−// A message's text: a little Markdown, parsed into blocks and spans.
138−
139−export type Span =
140− | { t: "text"; v: string }
141− | { t: "code"; v: string }
142− | { t: "strong"; c: Span[] }
143− | { t: "em"; c: Span[] }
144− | { t: "del"; c: Span[] }
145− | { t: "link"; href: string; c: Span[] }
146− | { t: "mention"; name: string }
147− | { t: "channel"; name: string }
148− | { t: "ref"; repo: string | null; number: number };
149−
150−export type Block =
151− | { t: "p"; lines: Span[][] }
152− | { t: "code"; lang: string | null; v: string }
153− | { t: "list"; ordered: boolean; items: Span[][] }
154− | { t: "quote"; lines: Span[][] };
155−
156−/** Where a link may go: the web, mail, or a page on this site. */
157−export function safeHref(href: string): string | null {
158− const trimmed = href.trim();
159− if (/^https?:\/\/[^\s]+$/i.test(trimmed)) return trimmed;
160− if (/^mailto:[^\s]+$/i.test(trimmed)) return trimmed;
161− if (/^\/(?!\/)[^\s]*$/.test(trimmed)) return trimmed;
162− return null;
163−}
164−
165−const INLINE = new RegExp(
166− [
167− "`([^`\\n]+)`", // 1 code
168− "\\*\\*([^*\\n](?:[^\\n]*?[^*\\n])?)\\*\\*", // 2 strong
169− "~~([^~\\n]+)~~", // 3 del
170− "(?<![\\w*])\\*([^*\\s](?:[^*\\n]*[^*\\s])?)\\*(?![\\w*])", // 4 em with *
171− "(?<![\\w])_([^_\\s](?:[^_\\n]*[^_\\s])?)_(?![\\w])", // 5 em with _
172− "\\[([^\\]\\n]+)\\]\\(([^)\\s]+)\\)", // 6 text, 7 href
173− "(https?:\\/\\/[^\\s<>()]+[^\\s<>().,;:!?'\"])", // 8 bare link
174− "(?<![\\w/@])@([A-Za-z0-9][\\w.-]*[A-Za-z0-9_]|[A-Za-z0-9])(?:\\/([A-Za-z0-9][\\w.-]*))?", // 9 mention, 10 team
175− "(?<![\\w/])([A-Za-z0-9][\\w.-]*(?:\\/[A-Za-z0-9][\\w.-]*)?)#(\\d+)\\b", // 11 repo, 12 number
176− "(?<![\\w&#/])#(\\d+)\\b", // 13 number alone
177− "(?<![\\w&#/])#([a-z0-9][a-z0-9_-]*)", // 14 channel
178− ].join("|"),
179− "g",
180−);
137+// A message's text: its Markdown, parsed by the rule every part of g1t reads it with.
181138
182−/** One line of text as spans. */
183−export function inline(text: string): Span[] {
184− const spans: Span[] = [];
185− let at = 0;
186− const push = (span: Span) => {
187− const last = spans[spans.length - 1];
188− if (span.t === "text" && last?.t === "text") last.v += span.v;
189− else spans.push(span);
190− };
191− // Its own copy: the spans inside bold and the like are parsed on the way.
192− const pattern = new RegExp(INLINE.source, "g");
193− for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
194− if (m.index > at) push({ t: "text", v: text.slice(at, m.index) });
195− at = m.index + m[0].length;
196− if (m[1] != null) push({ t: "code", v: m[1] });
197− else if (m[2] != null) push({ t: "strong", c: inline(m[2]) });
198− else if (m[3] != null) push({ t: "del", c: inline(m[3]) });
199− else if (m[4] != null) push({ t: "em", c: inline(m[4]) });
200− else if (m[5] != null) push({ t: "em", c: inline(m[5]) });
201− else if (m[6] != null) {
202− const href = safeHref(m[7]!);
203− if (href) push({ t: "link", href, c: inline(m[6]) });
204− else push({ t: "text", v: m[0] });
205− } else if (m[8] != null) push({ t: "link", href: m[8], c: [{ t: "text", v: m[8] }] });
206− else if (m[9] != null) push({ t: "mention", name: m[10] ? `${m[9]}/${m[10]}` : m[9] });
207− else if (m[11] != null) push({ t: "ref", repo: m[11], number: Number(m[12]) });
208− else if (m[13] != null) push({ t: "ref", repo: null, number: Number(m[13]) });
209− else if (m[14] != null) push({ t: "channel", name: m[14] });
210− }
211− if (at < text.length) push({ t: "text", v: text.slice(at) });
212− return spans;
213−}
214−
215−/** A message's whole text as blocks: paragraphs, fenced code, lists and quotes. */
216−export function blocks(text: string): Block[] {
217− const out: Block[] = [];
218− const lines = text.replace(/\r\n?/g, "\n").split("\n");
219− let i = 0;
220− let paragraph: Span[][] = [];
221− const flush = () => {
222− // Blank lines at either end of a paragraph say nothing.
223− while (paragraph.length && paragraph[0]!.length === 0) paragraph.shift();
224− while (paragraph.length && paragraph[paragraph.length - 1]!.length === 0) paragraph.pop();
225− if (paragraph.length) out.push({ t: "p", lines: paragraph });
226− paragraph = [];
227− };
228− while (i < lines.length) {
229− const line = lines[i]!;
230− const fence = /^\s*```\s*([\w+#.-]*)\s*$/.exec(line);
231− if (fence) {
232− flush();
233− const body: string[] = [];
234− i++;
235− while (i < lines.length && !/^\s*```\s*$/.test(lines[i]!)) body.push(lines[i++]!);
236− i++; // the closing fence, or the end
237− out.push({ t: "code", lang: fence[1] || null, v: body.join("\n") });
238− continue;
239− }
240− const bullet = /^\s*[-*•]\s+(.*)$/.exec(line);
241− const numbered = /^\s*\d+[.)]\s+(.*)$/.exec(line);
242− if (bullet || numbered) {
243− flush();
244− const ordered = !bullet;
245− const items: Span[][] = [];
246− while (i < lines.length) {
247− const item = ordered ? /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]!) : /^\s*[-*•]\s+(.*)$/.exec(lines[i]!);
248− if (!item) break;
249− items.push(inline(item[1]!));
250− i++;
251− }
252− out.push({ t: "list", ordered, items });
253− continue;
254− }
255− if (/^\s*>\s?/.test(line)) {
256− flush();
257− const quoted: Span[][] = [];
258− while (i < lines.length && /^\s*>\s?/.test(lines[i]!)) quoted.push(inline(lines[i++]!.replace(/^\s*>\s?/, "")));
259− out.push({ t: "quote", lines: quoted });
260− continue;
261− }
262− if (line.trim() === "") {
263− // A blank line ends a paragraph.
264− flush();
265− i++;
266− continue;
267− }
268− paragraph.push(inline(line));
269− i++;
270− }
271− flush();
272− return out;
273−}
139+export { type Block, type Span, blocks, inline, plainText, safeHref, spansText } from "@g1t/contracts/chat-markdown";
274140
275141 /** Whether a message is only emoji, a few of them: shown larger, as people expect. */
276142 export function onlyEmoji(text: string): boolean {
+5−0
2020 "@g1t/contracts": "*",
2121 "@g1t/theme": "*",
2222 "@heycatch/sdk": "0.8.3",
23+ "@tiptap/core": "^3.31.4",
24+ "@tiptap/extensions": "^3.31.4",
25+ "@tiptap/pm": "^3.31.4",
26+ "@tiptap/react": "^3.31.4",
27+ "@tiptap/starter-kit": "^3.31.4",
2328 "class-variance-authority": "^0.7.1",
2429 "clsx": "^2.1.1",
2530 "cmdk": "^1.1.1",
+250−0
8080 "@g1t/contracts": "*",
8181 "@g1t/theme": "*",
8282 "@heycatch/sdk": "0.8.3",
83+ "@tiptap/core": "^3.31.4",
84+ "@tiptap/extensions": "^3.31.4",
85+ "@tiptap/pm": "^3.31.4",
86+ "@tiptap/react": "^3.31.4",
87+ "@tiptap/starter-kit": "^3.31.4",
8388 "class-variance-authority": "^0.7.1",
8489 "clsx": "^2.1.1",
8590 "cmdk": "^1.1.1",
52325237 "@tiptap/pm": "3.31.4"
52335238 }
52345239 },
5240+ "node_modules/@tiptap/extension-blockquote": {
5241+ "version": "3.31.4",
5242+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz",
5243+ "integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==",
5244+ "license": "MIT",
5245+ "funding": {
5246+ "type": "github",
5247+ "url": "https://github.com/sponsors/ueberdosis"
5248+ },
5249+ "peerDependencies": {
5250+ "@tiptap/core": "3.31.4",
5251+ "@tiptap/pm": "3.31.4"
5252+ }
5253+ },
52355254 "node_modules/@tiptap/extension-bold": {
52365255 "version": "3.31.4",
52375256 "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz",
52635282 "@tiptap/pm": "3.31.4"
52645283 }
52655284 },
5285+ "node_modules/@tiptap/extension-bullet-list": {
5286+ "version": "3.31.4",
5287+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz",
5288+ "integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==",
5289+ "license": "MIT",
5290+ "funding": {
5291+ "type": "github",
5292+ "url": "https://github.com/sponsors/ueberdosis"
5293+ },
5294+ "peerDependencies": {
5295+ "@tiptap/extension-list": "3.31.4"
5296+ }
5297+ },
52665298 "node_modules/@tiptap/extension-code": {
52675299 "version": "3.31.4",
52685300 "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz",
52765308 "@tiptap/core": "3.31.4"
52775309 }
52785310 },
5311+ "node_modules/@tiptap/extension-code-block": {
5312+ "version": "3.31.4",
5313+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz",
5314+ "integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==",
5315+ "license": "MIT",
5316+ "funding": {
5317+ "type": "github",
5318+ "url": "https://github.com/sponsors/ueberdosis"
5319+ },
5320+ "peerDependencies": {
5321+ "@tiptap/core": "3.31.4",
5322+ "@tiptap/pm": "3.31.4"
5323+ }
5324+ },
5325+ "node_modules/@tiptap/extension-document": {
5326+ "version": "3.31.4",
5327+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz",
5328+ "integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==",
5329+ "license": "MIT",
5330+ "funding": {
5331+ "type": "github",
5332+ "url": "https://github.com/sponsors/ueberdosis"
5333+ },
5334+ "peerDependencies": {
5335+ "@tiptap/core": "3.31.4"
5336+ }
5337+ },
5338+ "node_modules/@tiptap/extension-dropcursor": {
5339+ "version": "3.31.4",
5340+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz",
5341+ "integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==",
5342+ "license": "MIT",
5343+ "funding": {
5344+ "type": "github",
5345+ "url": "https://github.com/sponsors/ueberdosis"
5346+ },
5347+ "peerDependencies": {
5348+ "@tiptap/extensions": "3.31.4"
5349+ }
5350+ },
52795351 "node_modules/@tiptap/extension-floating-menu": {
52805352 "version": "3.31.4",
52815353 "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz",
52925364 "@tiptap/pm": "3.31.4"
52935365 }
52945366 },
5367+ "node_modules/@tiptap/extension-gapcursor": {
5368+ "version": "3.31.4",
5369+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz",
5370+ "integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==",
5371+ "license": "MIT",
5372+ "funding": {
5373+ "type": "github",
5374+ "url": "https://github.com/sponsors/ueberdosis"
5375+ },
5376+ "peerDependencies": {
5377+ "@tiptap/extensions": "3.31.4"
5378+ }
5379+ },
5380+ "node_modules/@tiptap/extension-hard-break": {
5381+ "version": "3.31.4",
5382+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz",
5383+ "integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==",
5384+ "license": "MIT",
5385+ "funding": {
5386+ "type": "github",
5387+ "url": "https://github.com/sponsors/ueberdosis"
5388+ },
5389+ "peerDependencies": {
5390+ "@tiptap/core": "3.31.4"
5391+ }
5392+ },
5393+ "node_modules/@tiptap/extension-heading": {
5394+ "version": "3.31.4",
5395+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz",
5396+ "integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==",
5397+ "license": "MIT",
5398+ "funding": {
5399+ "type": "github",
5400+ "url": "https://github.com/sponsors/ueberdosis"
5401+ },
5402+ "peerDependencies": {
5403+ "@tiptap/core": "3.31.4"
5404+ }
5405+ },
5406+ "node_modules/@tiptap/extension-horizontal-rule": {
5407+ "version": "3.31.4",
5408+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz",
5409+ "integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==",
5410+ "license": "MIT",
5411+ "funding": {
5412+ "type": "github",
5413+ "url": "https://github.com/sponsors/ueberdosis"
5414+ },
5415+ "peerDependencies": {
5416+ "@tiptap/core": "3.31.4",
5417+ "@tiptap/pm": "3.31.4"
5418+ }
5419+ },
52955420 "node_modules/@tiptap/extension-italic": {
52965421 "version": "3.31.4",
52975422 "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz",
53055430 "@tiptap/core": "3.31.4"
53065431 }
53075432 },
5433+ "node_modules/@tiptap/extension-link": {
5434+ "version": "3.31.4",
5435+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz",
5436+ "integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==",
5437+ "license": "MIT",
5438+ "dependencies": {
5439+ "linkifyjs": "^4.3.3"
5440+ },
5441+ "funding": {
5442+ "type": "github",
5443+ "url": "https://github.com/sponsors/ueberdosis"
5444+ },
5445+ "peerDependencies": {
5446+ "@tiptap/core": "3.31.4",
5447+ "@tiptap/pm": "3.31.4"
5448+ }
5449+ },
5450+ "node_modules/@tiptap/extension-list": {
5451+ "version": "3.31.4",
5452+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz",
5453+ "integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==",
5454+ "license": "MIT",
5455+ "funding": {
5456+ "type": "github",
5457+ "url": "https://github.com/sponsors/ueberdosis"
5458+ },
5459+ "peerDependencies": {
5460+ "@tiptap/core": "3.31.4",
5461+ "@tiptap/pm": "3.31.4"
5462+ }
5463+ },
5464+ "node_modules/@tiptap/extension-list-item": {
5465+ "version": "3.31.4",
5466+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz",
5467+ "integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==",
5468+ "license": "MIT",
5469+ "funding": {
5470+ "type": "github",
5471+ "url": "https://github.com/sponsors/ueberdosis"
5472+ },
5473+ "peerDependencies": {
5474+ "@tiptap/extension-list": "3.31.4"
5475+ }
5476+ },
5477+ "node_modules/@tiptap/extension-list-keymap": {
5478+ "version": "3.31.4",
5479+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz",
5480+ "integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==",
5481+ "license": "MIT",
5482+ "funding": {
5483+ "type": "github",
5484+ "url": "https://github.com/sponsors/ueberdosis"
5485+ },
5486+ "peerDependencies": {
5487+ "@tiptap/extension-list": "3.31.4"
5488+ }
5489+ },
5490+ "node_modules/@tiptap/extension-ordered-list": {
5491+ "version": "3.31.4",
5492+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz",
5493+ "integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==",
5494+ "license": "MIT",
5495+ "funding": {
5496+ "type": "github",
5497+ "url": "https://github.com/sponsors/ueberdosis"
5498+ },
5499+ "peerDependencies": {
5500+ "@tiptap/extension-list": "3.31.4"
5501+ }
5502+ },
5503+ "node_modules/@tiptap/extension-paragraph": {
5504+ "version": "3.31.4",
5505+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz",
5506+ "integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==",
5507+ "license": "MIT",
5508+ "funding": {
5509+ "type": "github",
5510+ "url": "https://github.com/sponsors/ueberdosis"
5511+ },
5512+ "peerDependencies": {
5513+ "@tiptap/core": "3.31.4"
5514+ }
5515+ },
53085516 "node_modules/@tiptap/extension-strike": {
53095517 "version": "3.31.4",
53105518 "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz",
54165624 "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
54175625 "license": "MIT"
54185626 },
5627+ "node_modules/@tiptap/starter-kit": {
5628+ "version": "3.31.4",
5629+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz",
5630+ "integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==",
5631+ "license": "MIT",
5632+ "dependencies": {
5633+ "@tiptap/core": "3.31.4",
5634+ "@tiptap/extension-blockquote": "3.31.4",
5635+ "@tiptap/extension-bold": "3.31.4",
5636+ "@tiptap/extension-bullet-list": "3.31.4",
5637+ "@tiptap/extension-code": "3.31.4",
5638+ "@tiptap/extension-code-block": "3.31.4",
5639+ "@tiptap/extension-document": "3.31.4",
5640+ "@tiptap/extension-dropcursor": "3.31.4",
5641+ "@tiptap/extension-gapcursor": "3.31.4",
5642+ "@tiptap/extension-hard-break": "3.31.4",
5643+ "@tiptap/extension-heading": "3.31.4",
5644+ "@tiptap/extension-horizontal-rule": "3.31.4",
5645+ "@tiptap/extension-italic": "3.31.4",
5646+ "@tiptap/extension-link": "3.31.4",
5647+ "@tiptap/extension-list": "3.31.4",
5648+ "@tiptap/extension-list-item": "3.31.4",
5649+ "@tiptap/extension-list-keymap": "3.31.4",
5650+ "@tiptap/extension-ordered-list": "3.31.4",
5651+ "@tiptap/extension-paragraph": "3.31.4",
5652+ "@tiptap/extension-strike": "3.31.4",
5653+ "@tiptap/extension-text": "3.31.4",
5654+ "@tiptap/extension-underline": "3.31.4",
5655+ "@tiptap/extensions": "3.31.4",
5656+ "@tiptap/pm": "3.31.4"
5657+ },
5658+ "funding": {
5659+ "type": "github",
5660+ "url": "https://github.com/sponsors/ueberdosis"
5661+ }
5662+ },
54195663 "node_modules/@tootallnate/quickjs-emscripten": {
54205664 "version": "0.23.0",
54215665 "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz",
91139357 "unicode-trie": "^2.0.0"
91149358 }
91159359 },
9360+ "node_modules/linkifyjs": {
9361+ "version": "4.3.3",
9362+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
9363+ "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
9364+ "license": "MIT"
9365+ },
91169366 "node_modules/lodash": {
91179367 "version": "4.18.1",
91189368 "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./chat-markdown": "./src/chat-markdown.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+175−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { type Block, type Span, blocks, inline, plainText, safeHref } from "./chat-markdown.ts";
5+
6+/** Every link anywhere in parsed blocks. */
7+function links(list: Block[]): string[] {
8+ const out: string[] = [];
9+ const walk = (spans: Span[]) => {
10+ for (const span of spans) {
11+ if (span.t === "link") out.push(span.href);
12+ if ("c" in span) walk(span.c);
13+ }
14+ };
15+ const each = (block: Block) => {
16+ if (block.t === "p") block.lines.forEach(walk);
17+ else if (block.t === "heading") walk(block.c);
18+ else if (block.t === "quote") block.c.forEach(each);
19+ else if (block.t === "list") block.items.forEach((item) => item.forEach(each));
20+ };
21+ list.forEach(each);
22+ return out;
23+}
24+
25+test("formatting: bold, italic, strikethrough and code, as people and agents write them", () => {
26+ assert.deepEqual(inline("**bold** __also__ *it* _it_ ~~gone~~ ~gone~ `x`"), [
27+ { t: "strong", c: [{ t: "text", v: "bold" }] },
28+ { t: "text", v: " " },
29+ { t: "strong", c: [{ t: "text", v: "also" }] },
30+ { t: "text", v: " " },
31+ { t: "em", c: [{ t: "text", v: "it" }] },
32+ { t: "text", v: " " },
33+ { t: "em", c: [{ t: "text", v: "it" }] },
34+ { t: "text", v: " " },
35+ { t: "del", c: [{ t: "text", v: "gone" }] },
36+ { t: "text", v: " " },
37+ { t: "del", c: [{ t: "text", v: "gone" }] },
38+ { t: "text", v: " " },
39+ { t: "code", v: "x" },
40+ ]);
41+ assert.deepEqual(inline("**_both_**"), [{ t: "strong", c: [{ t: "em", c: [{ t: "text", v: "both" }] }] }]);
42+ assert.deepEqual(inline("``a ` b``"), [{ t: "code", v: "a ` b" }]);
43+});
44+
45+test("words that only look like formatting stay words", () => {
46+ assert.deepEqual(inline("snake_case_name and 2*3*4"), [{ t: "text", v: "snake_case_name and 2*3*4" }]);
47+ assert.deepEqual(inline("about ~5 min, ~/src"), [{ t: "text", v: "about ~5 min, ~/src" }]);
48+ assert.deepEqual(inline("\\*not\\* \\_em\\_ \\`code\\` \\@nobody"), [{ t: "text", v: "*not* _em_ `code` @nobody" }]);
49+ assert.deepEqual(inline("C:\\Users\\me"), [{ t: "text", v: "C:\\Users\\me" }]);
50+});
51+
52+test("mentions, channels and references; never inside code or a link's text", () => {
53+ assert.deepEqual(inline("ask @reviewer about #12, g1t#3 in #web"), [
54+ { t: "text", v: "ask " },
55+ { t: "mention", name: "reviewer" },
56+ { t: "text", v: " about " },
57+ { t: "ref", repo: null, number: 12 },
58+ { t: "text", v: ", " },
59+ { t: "ref", repo: "g1t", number: 3 },
60+ { t: "text", v: " in " },
61+ { t: "channel", name: "web" },
62+ ]);
63+ assert.deepEqual(inline("`@reviewer #12`"), [{ t: "code", v: "@reviewer #12" }]);
64+ assert.deepEqual(inline("[ping @ana](https://x.example)"), [{ t: "link", href: "https://x.example", c: [{ t: "text", v: "ping @ana" }] }]);
65+ assert.deepEqual(inline("me@example.com"), [{ t: "text", v: "me@example.com" }]);
66+});
67+
68+test("links go only to the web, mail or this site", () => {
69+ assert.deepEqual(inline("[docs](https://g1t.sh/docs) and https://g1t.sh/a."), [
70+ { t: "link", href: "https://g1t.sh/docs", c: [{ t: "text", v: "docs" }] },
71+ { t: "text", v: " and " },
72+ { t: "link", href: "https://g1t.sh/a", c: [{ t: "text", v: "https://g1t.sh/a" }] },
73+ { t: "text", v: "." },
74+ ]);
75+ assert.deepEqual(inline("<https://g1t.sh>"), [{ t: "link", href: "https://g1t.sh", c: [{ t: "text", v: "https://g1t.sh" }] }]);
76+ assert.deepEqual(inline("https://en.wikipedia.org/wiki/Foo_(bar)"), [
77+ { t: "link", href: "https://en.wikipedia.org/wiki/Foo_(bar)", c: [{ t: "text", v: "https://en.wikipedia.org/wiki/Foo_(bar)" }] },
78+ ]);
79+ assert.equal(safeHref("/acme/web/pull/3"), "/acme/web/pull/3");
80+ assert.equal(safeHref("mailto:me@example.com"), "mailto:me@example.com");
81+});
82+
83+test("XSS: script links, raw HTML and nested tricks are text", () => {
84+ const evil = [
85+ "[x](javascript:alert(1))",
86+ "[x](JaVaScRiPt:alert(1))",
87+ "[x]( javascript:alert(1))",
88+ "[x](java\tscript:alert(1))",
89+ "[x](data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==)",
90+ "[x](vbscript:msgbox(1))",
91+ "[x](//evil.example)",
92+ "[x](/\\evil.example)",
93+ "[x](<javascript:alert(1)>)",
94+ "![x](javascript:alert(1))",
95+ "<javascript:alert(1)>",
96+ "[**[x](javascript:alert(1))**](javascript:alert(2))",
97+ "[a](https://ok.example)[b](javascript:alert(1))",
98+ "javascript:alert(1)",
99+ ];
100+ for (const text of evil) {
101+ for (const href of links(blocks(text))) assert.ok(/^(https?:\/\/|mailto:|\/(?![/\\]))/i.test(href), `${text} linked to ${href}`);
102+ }
103+ assert.equal(safeHref("javascript:alert(1)"), null);
104+ assert.equal(safeHref("//evil.example"), null);
105+ assert.equal(safeHref("/\\evil.example"), null);
106+ assert.equal(safeHref("https://ok.example/\u0000x"), null);
107+ assert.equal(safeHref("https://ok.example\\@evil.example"), null);
108+
109+ // HTML is never markup: it comes through as the text it is.
110+ const html = blocks('<img src=x onerror="alert(1)"><script>alert(1)</script>\n<a href="javascript:alert(1)">x</a>');
111+ assert.deepEqual(html, [
112+ {
113+ t: "p",
114+ lines: [[{ t: "text", v: '<img src=x onerror="alert(1)"><script>alert(1)</script>' }], [{ t: "text", v: '<a href="javascript:alert(1)">x</a>' }]],
115+ },
116+ ]);
117+ // An image is a link to it, never an image.
118+ assert.deepEqual(inline("![logo](https://x.example/a.png)"), [{ t: "link", href: "https://x.example/a.png", c: [{ t: "text", v: "logo" }] }]);
119+});
120+
121+test("blocks: paragraphs keep their line breaks; code, lists, quotes, headings and rules", () => {
122+ const parsed = blocks("Plan:\n- one\n- two\n\n```ts\nconst a = 1;\n```\n> quoted\nlast");
123+ assert.deepEqual(
124+ parsed.map((block) => block.t),
125+ ["p", "list", "code", "quote", "p"],
126+ );
127+ assert.deepEqual(parsed[2], { t: "code", lang: "ts", v: "const a = 1;" });
128+ assert.deepEqual(blocks("a\nb"), [{ t: "p", lines: [[{ t: "text", v: "a" }], [{ t: "text", v: "b" }]] }]);
129+ assert.deepEqual(blocks("## Summary\n---\n#general"), [
130+ { t: "heading", level: 2, c: [{ t: "text", v: "Summary" }] },
131+ { t: "hr" },
132+ { t: "p", lines: [[{ t: "channel", name: "general" }]] },
133+ ]);
134+ // A fence inside a fence, with a longer fence around it.
135+ assert.deepEqual(blocks("````md\n```\ninner\n```\n````"), [{ t: "code", lang: "md", v: "```\ninner\n```" }]);
136+ // An unclosed fence runs to the end.
137+ assert.deepEqual(blocks("```\nopen"), [{ t: "code", lang: null, v: "open" }]);
138+});
139+
140+test("lists: numbered from where they start, nested by indenting, items with more than a line", () => {
141+ const [list] = blocks("3. three\n4. four\n - deeper\n more\n5. five");
142+ assert.equal(list!.t, "list");
143+ if (list!.t !== "list") return;
144+ assert.equal(list.ordered, true);
145+ assert.equal(list.start, 3);
146+ assert.equal(list.items.length, 3);
147+ const nested = list.items[1]![1]!;
148+ assert.equal(nested.t, "list");
149+ if (nested.t === "list") assert.deepEqual(nested.items[0], [{ t: "p", lines: [[{ t: "text", v: "deeper" }], [{ t: "text", v: "more" }]] }]);
150+ // A year at the start of a line is not a list.
151+ assert.deepEqual(blocks("We grew in\n2019. Then again.").map((b) => b.t), ["p"]);
152+ // Blank lines between items keep one list.
153+ assert.equal(blocks("- a\n\n- b").length, 1);
154+ // A quote holds blocks of its own.
155+ const [quote] = blocks("> - a\n> - b");
156+ assert.ok(quote!.t === "quote" && quote.c[0]!.t === "list");
157+});
158+
159+test("deep nesting is bounded", () => {
160+ const deep = blocks(">".repeat(200) + " x");
161+ assert.equal(deep.length, 1);
162+ const list = blocks(Array.from({ length: 50 }, (_, i) => `${" ".repeat(i)}- ${i}`).join("\n"));
163+ assert.equal(list.length, 1);
164+});
165+
166+test("plain text: the words without the marks, for previews", () => {
167+ assert.equal(plainText("**bold** and `code`\n\nnext [link](https://x.example)"), "bold and code next link");
168+ assert.equal(plainText("## Release\n- one\n- **two**\n\n> said\n---\n1. first"), "Release one two said 1. first");
169+ assert.equal(plainText("ping @ana in #web about g1t#3, snake_case_name"), "ping @ana in #web about g1t#3, snake_case_name");
170+ assert.equal(plainText("\\*literal\\* ~~gone~~ ![logo](https://x.example/a.png)"), "*literal* gone logo");
171+ assert.equal(plainText("```ts\nconst a = 1;\n```"), "const a = 1;");
172+ assert.equal(plainText("<b>hi</b>"), "<b>hi</b>");
173+ assert.equal(plainText("x".repeat(300), 140).length, 140);
174+ assert.ok(plainText("x".repeat(300), 140).endsWith("…"));
175+});
+358−0
1+/**
2+ * A chat message's Markdown, parsed into a small tree that React renders
3+ * as text (components/chat/text.tsx). Nothing in a message is ever HTML:
4+ * `<b>` is shown as typed, a link goes only to the web, mail or a page on
5+ * this site, and an image is shown as a link to it. Pure, so it is tested
6+ * on its own (chat-markdown.test.ts).
7+ *
8+ * The dialect is what people and agents write in chat: bold, italic,
9+ * strikethrough (`~~x~~` or `~x~`), inline and fenced code, links and bare
10+ * URLs, lists (nested by indenting), quotes, headings and rules, plus what
11+ * g1t adds: `@mentions`, `#channels` and `#123` references. A line break
12+ * is kept where it was typed.
13+ */
14+
15+export type Span =
16+ | { t: "text"; v: string }
17+ | { t: "code"; v: string }
18+ | { t: "strong"; c: Span[] }
19+ | { t: "em"; c: Span[] }
20+ | { t: "del"; c: Span[] }
21+ | { t: "link"; href: string; c: Span[] }
22+ | { t: "mention"; name: string }
23+ | { t: "channel"; name: string }
24+ | { t: "ref"; repo: string | null; number: number };
25+
26+export type Block =
27+ | { t: "p"; lines: Span[][] }
28+ | { t: "heading"; level: number; c: Span[] }
29+ | { t: "code"; lang: string | null; v: string }
30+ | { t: "list"; ordered: boolean; start: number; items: Block[][] }
31+ | { t: "quote"; c: Block[] }
32+ | { t: "hr" };
33+
34+/** Where a link may go: the web, mail, or a page on this site. */
35+export function safeHref(href: string): string | null {
36+ const trimmed = href.trim();
37+ // Control characters and spaces never belong in a link someone can follow.
38+ // eslint-disable-next-line no-control-regex
39+ if (/[\u0000-\u0020\u007f]/.test(trimmed)) return null;
40+ if (/^https?:\/\/[^\s\\]+$/i.test(trimmed)) return trimmed;
41+ if (/^mailto:[^\s/\\]+$/i.test(trimmed)) return trimmed;
42+ // A path on this site; `//host` and `/\host` are other sites to a browser.
43+ if (/^\/(?![/\\])[^\s\\]*$/.test(trimmed)) return trimmed;
44+ return null;
45+}
46+
47+/** The ASCII punctuation a backslash can escape. */
48+const ESCAPABLE = "\\\\`*_{}\\[\\]()#+\\-.!~>|<=:@";
49+
50+const INLINE = new RegExp(
51+ [
52+ `\\\\([${ESCAPABLE}])`, // 1 an escaped character
53+ "``\\s?([^\\n]+?)\\s?``(?!`)", // 2 code with two backticks
54+ "`([^`\\n]+)`", // 3 code
55+ "\\*\\*([^*\\s](?:[^\\n]*?[^*\\s])?)\\*\\*", // 4 strong
56+ "(?<![\\w_])__([^_\\s](?:[^\\n]*?[^_\\s])?)__(?![\\w_])", // 5 strong with _
57+ "~~([^~\\n]+)~~", // 6 del
58+ "(?<![\\w~])~([^~\\s](?:[^~\\n]*[^~\\s])?)~(?![\\w~])", // 7 del with one ~
59+ "(?<![\\w*])\\*([^*\\s](?:[^*\\n]*[^*\\s])?)\\*(?![\\w*])", // 8 em with *
60+ "(?<![\\w_])_([^_\\s](?:[^_\\n]*[^_\\s])?)_(?![\\w_])", // 9 em with _
61+ "!\\[([^\\]\\n]{0,1000})\\]\\(<?([^)\\s>]{1,2048})>?(?:\\s+\"[^\"\\n]{0,1000}\")?\\)", // 10 alt, 11 src: an image, shown as a link
62+ "\\[([^\\]\\n]{1,1000})\\]\\(<?([^)\\s>]{1,2048})>?(?:\\s+\"[^\"\\n]{0,1000}\")?\\)", // 12 text, 13 href
63+ "<(https?:\\/\\/[^\\s<>]+|mailto:[^\\s<>]+)>", // 14 <autolink>
64+ "(https?:\\/\\/(?:[^\\s<>()]|\\([^\\s<>()]*\\))*(?:[^\\s<>().,;:!?'\"*_~]|\\([^\\s<>()]*\\)))", // 15 bare link
65+ "(?<![\\w/@])@([A-Za-z0-9][\\w.-]*[A-Za-z0-9_]|[A-Za-z0-9])(?:\\/([A-Za-z0-9][\\w.-]*))?", // 16 mention, 17 team
66+ "(?<![\\w/])([A-Za-z0-9][\\w.-]*(?:\\/[A-Za-z0-9][\\w.-]*)?)#(\\d+)\\b", // 18 repo, 19 number
67+ "(?<![\\w&#/])#(\\d+)\\b", // 20 number alone
68+ "(?<![\\w&#/])#([a-z0-9][a-z0-9_-]*)", // 21 channel
69+ ].join("|"),
70+ "g",
71+);
72+
73+/** One line of text as spans. */
74+export function inline(text: string): Span[] {
75+ const spans: Span[] = [];
76+ let at = 0;
77+ const push = (span: Span) => {
78+ const last = spans[spans.length - 1];
79+ if (span.t === "text" && last?.t === "text") last.v += span.v;
80+ else spans.push(span);
81+ };
82+ const link = (href: string, inner: Span[], raw: string) => {
83+ const safe = safeHref(href);
84+ if (safe) push({ t: "link", href: safe, c: inner });
85+ else push({ t: "text", v: raw });
86+ };
87+ // Its own copy: the spans inside bold and the like are parsed on the way.
88+ const pattern = new RegExp(INLINE.source, "g");
89+ for (let m = pattern.exec(text); m; m = pattern.exec(text)) {
90+ if (m.index > at) push({ t: "text", v: text.slice(at, m.index) });
91+ at = m.index + m[0].length;
92+ if (m[1] != null) push({ t: "text", v: m[1] });
93+ else if (m[2] != null) push({ t: "code", v: m[2] });
94+ else if (m[3] != null) push({ t: "code", v: m[3] });
95+ else if (m[4] != null) push({ t: "strong", c: inline(m[4]) });
96+ else if (m[5] != null) push({ t: "strong", c: inline(m[5]) });
97+ else if (m[6] != null) push({ t: "del", c: inline(m[6]) });
98+ else if (m[7] != null) push({ t: "del", c: inline(m[7]) });
99+ else if (m[8] != null) push({ t: "em", c: inline(m[8]) });
100+ else if (m[9] != null) push({ t: "em", c: inline(m[9]) });
101+ else if (m[11] != null) link(m[11], [{ t: "text", v: m[10] || m[11] }], m[0]);
102+ else if (m[13] != null) link(m[13], plainSpans(inline(m[12]!)), m[0]);
103+ else if (m[14] != null) link(m[14], [{ t: "text", v: m[14] }], m[0]);
104+ else if (m[15] != null) push({ t: "link", href: m[15], c: [{ t: "text", v: m[15] }] });
105+ else if (m[16] != null) push({ t: "mention", name: m[17] ? `${m[16]}/${m[17]}` : m[16] });
106+ else if (m[18] != null) push({ t: "ref", repo: m[18], number: Number(m[19]) });
107+ else if (m[20] != null) push({ t: "ref", repo: null, number: Number(m[20]) });
108+ else if (m[21] != null) push({ t: "channel", name: m[21] });
109+ }
110+ if (at < text.length) push({ t: "text", v: text.slice(at) });
111+ return spans;
112+}
113+
114+/** A link's text keeps its formatting, but never a link, mention or reference inside a link. */
115+function plainSpans(list: Span[]): Span[] {
116+ const out: Span[] = [];
117+ for (const span of list.map(plainSpan)) {
118+ const last = out[out.length - 1];
119+ if (span.t === "text" && last?.t === "text") last.v += span.v;
120+ else out.push(span);
121+ }
122+ return out;
123+}
124+
125+function plainSpan(span: Span): Span {
126+ switch (span.t) {
127+ case "link":
128+ return { t: "text", v: spansText(span.c) };
129+ case "mention":
130+ return { t: "text", v: `@${span.name}` };
131+ case "channel":
132+ return { t: "text", v: `#${span.name}` };
133+ case "ref":
134+ return { t: "text", v: `${span.repo ?? ""}#${span.number}` };
135+ case "strong":
136+ case "em":
137+ case "del":
138+ return { ...span, c: plainSpans(span.c) };
139+ default:
140+ return { ...span };
141+ }
142+}
143+
144+// ---------------------------------------------------------------------------
145+// Blocks.
146+
147+const FENCE = /^( {0,3})(`{3,}|~{3,})\s*([\w+#.-]*)[^`]*$/;
148+const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*?))?(?:[ \t]+#+)?[ \t]*$/;
149+const RULE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
150+const QUOTE = /^ {0,3}>[ \t]?/;
151+/** A heading has words after its `#`s: `#general` is a channel. */
152+const HEADED = /^ {0,3}#{1,6}[ \t]+\S/;
153+const ITEM = /^([ \t]*)([-*+•]|\d{1,9}[.)])(?:([ \t]+)(.*)|[ \t]*$)/;
154+
155+function indentOf(line: string): number {
156+ let width = 0;
157+ for (const char of line) {
158+ if (char === " ") width++;
159+ else if (char === "\t") width += 4 - (width % 4);
160+ else break;
161+ }
162+ return width;
163+}
164+
165+/** A line with up to `width` columns of its indent taken off. */
166+function dedent(line: string, width: number): string {
167+ let taken = 0;
168+ let i = 0;
169+ while (i < line.length && taken < width) {
170+ if (line[i] === " ") taken++;
171+ else if (line[i] === "\t") taken += 4 - (taken % 4);
172+ else break;
173+ i++;
174+ }
175+ return line.slice(i);
176+}
177+
178+/** Whether a line starts a block of its own, ending a paragraph. */
179+function startsBlock(line: string): boolean {
180+ if (FENCE.test(line) || HEADED.test(line) || RULE.test(line) || QUOTE.test(line)) return true;
181+ // A numbered list breaks into a paragraph only when it counts from 1.
182+ const item = ITEM.exec(line);
183+ return item != null && (!/\d/.test(item[2]!) || Number.parseInt(item[2]!, 10) === 1);
184+}
185+
186+/** A paragraph's line without the marks of a hard break at its end. */
187+function lineText(line: string): string {
188+ return line.replace(/(?: {2,}|\\)$/, "").trim();
189+}
190+
191+function parse(lines: string[], depth: number): Block[] {
192+ const out: Block[] = [];
193+ let paragraph: Span[][] = [];
194+ const flush = () => {
195+ if (paragraph.length) out.push({ t: "p", lines: paragraph });
196+ paragraph = [];
197+ };
198+ let i = 0;
199+ while (i < lines.length) {
200+ const line = lines[i]!;
201+ if (line.trim() === "") {
202+ // A blank line ends a paragraph.
203+ flush();
204+ i++;
205+ continue;
206+ }
207+ const fence = FENCE.exec(line);
208+ if (fence) {
209+ flush();
210+ const [, indent, marks] = fence;
211+ const close = new RegExp(`^ {0,3}${marks![0] === "`" ? "`" : "~"}{${marks!.length},}\\s*$`);
212+ const body: string[] = [];
213+ i++;
214+ while (i < lines.length && !close.test(lines[i]!)) body.push(dedent(lines[i++]!, indent!.length));
215+ i++; // the closing fence, or the end
216+ out.push({ t: "code", lang: fence[3] || null, v: body.join("\n") });
217+ continue;
218+ }
219+ const heading = HEADED.test(line) ? HEADING.exec(line) : null;
220+ if (heading) {
221+ flush();
222+ out.push({ t: "heading", level: heading[1]!.length, c: inline((heading[2] ?? "").trim()) });
223+ i++;
224+ continue;
225+ }
226+ if (RULE.test(line)) {
227+ flush();
228+ out.push({ t: "hr" });
229+ i++;
230+ continue;
231+ }
232+ if (QUOTE.test(line)) {
233+ flush();
234+ const quoted: string[] = [];
235+ while (i < lines.length && QUOTE.test(lines[i]!)) quoted.push(lines[i++]!.replace(QUOTE, ""));
236+ out.push({ t: "quote", c: depth < 8 ? parse(quoted, depth + 1) : [{ t: "p", lines: quoted.map((q) => inline(q)) }] });
237+ continue;
238+ }
239+ const item = ITEM.exec(line);
240+ if (item && depth < 8 && (paragraph.length === 0 || startsBlock(line))) {
241+ flush();
242+ const ordered = /\d/.test(item[2]!);
243+ const base = indentOf(item[1]!);
244+ const items: Block[][] = [];
245+ const start = ordered ? Number.parseInt(item[2]!, 10) : 1;
246+ while (i < lines.length) {
247+ const head = ITEM.exec(lines[i]!);
248+ if (!head || /\d/.test(head[2]!) !== ordered || indentOf(head[1]!) > base + 1) break;
249+ // Where the item's text starts: what is indented that far belongs to it.
250+ const gap = head[3] ? Math.min(indentOf(head[3]), 4) : 1;
251+ const content = base + head[2]!.length + gap;
252+ const body = [head[4] ?? ""];
253+ i++;
254+ while (i < lines.length) {
255+ const next = lines[i]!;
256+ if (next.trim() === "") {
257+ // A blank line inside an item, if what follows is still indented under it.
258+ const after = lines.slice(i + 1).find((l) => l.trim() !== "");
259+ if (after != null && indentOf(after) > base && !(ITEM.test(after) && indentOf(after) <= base + 1)) {
260+ body.push("");
261+ i++;
262+ continue;
263+ }
264+ break;
265+ }
266+ if (indentOf(next) <= base) break;
267+ if (ITEM.test(next) && indentOf(next) <= base + 1) break;
268+ body.push(dedent(next, Math.min(indentOf(next), content)));
269+ i++;
270+ }
271+ items.push(parse(body, depth + 1));
272+ // Blank lines between items keep one list.
273+ let j = i;
274+ while (j < lines.length && lines[j]!.trim() === "") j++;
275+ const following = j < lines.length ? ITEM.exec(lines[j]!) : null;
276+ if (j > i && following && /\d/.test(following[2]!) === ordered && indentOf(following[1]!) <= base + 1) i = j;
277+ }
278+ out.push({ t: "list", ordered, start, items });
279+ continue;
280+ }
281+ if (paragraph.length && startsBlock(line)) flush();
282+ paragraph.push(inline(lineText(line)));
283+ i++;
284+ }
285+ flush();
286+ return out;
287+}
288+
289+/** A message's whole text as blocks: paragraphs, headings, code, lists, quotes and rules. */
290+export function blocks(text: string): Block[] {
291+ return parse(text.replace(/\r\n?/g, "\n").split("\n"), 0);
292+}
293+
294+// ---------------------------------------------------------------------------
295+// Plain text.
296+
297+/** Spans as the words they show. */
298+export function spansText(list: Span[]): string {
299+ return list
300+ .map((span) => {
301+ switch (span.t) {
302+ case "text":
303+ case "code":
304+ return span.v;
305+ case "strong":
306+ case "em":
307+ case "del":
308+ case "link":
309+ return spansText(span.c);
310+ case "mention":
311+ return `@${span.name}`;
312+ case "channel":
313+ return `#${span.name}`;
314+ case "ref":
315+ return `${span.repo ?? ""}#${span.number}`;
316+ }
317+ })
318+ .join("");
319+}
320+
321+function blocksText(list: Block[]): string[] {
322+ const out: string[] = [];
323+ for (const block of list) {
324+ switch (block.t) {
325+ case "p":
326+ out.push(block.lines.map(spansText).join(" "));
327+ break;
328+ case "heading":
329+ out.push(spansText(block.c));
330+ break;
331+ case "code":
332+ out.push(block.v);
333+ break;
334+ case "list":
335+ block.items.forEach((item, index) => {
336+ const text = blocksText(item).join(" ");
337+ out.push(block.ordered ? `${block.start + index}. ${text}` : text);
338+ });
339+ break;
340+ case "quote":
341+ out.push(...blocksText(block.c));
342+ break;
343+ case "hr":
344+ break;
345+ }
346+ }
347+ return out.filter((line) => line.trim() !== "");
348+}
349+
350+/**
351+ * A message as plain words on one line, for a preview: the Markdown's
352+ * marks gone, a link as its text, a mention as `@name`. Cut to `max`
353+ * characters with an ellipsis.
354+ */
355+export function plainText(markdown: string, max = Infinity): string {
356+ const text = blocksText(blocks(markdown)).join(" ").replace(/\s+/g, " ").trim();
357+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
358+}
+3−1
8181
8282 test("previews are one short line without markup", () => {
8383 assert.equal(preview("**bold** and `code`\n\nnext [link](https://x)"), "bold and code next link");
84− assert.equal(preview("```\nlong code\n```"), "[code]");
84+ assert.equal(preview("```\nlong code\n```"), "long code");
85+ assert.equal(preview("## Done\n- shipped **#412** in #web\n- see \\*notes\\*"), "Done shipped #412 in #web see *notes*");
86+ assert.equal(preview("snake_case and ~/src stay, <b>as typed</b>"), "snake_case and ~/src stay, <b>as typed</b>");
8587 assert.equal(preview("x".repeat(300)).length, 140);
8688 });
8789
+4−10
2121 */
2222 import type { CardAction, FeedDelivery, FeedNotification, MemberProfile, NotificationCard, NotificationKind } from "@g1t/contracts";
2323
24+import { plainText } from "@g1t/contracts/chat-markdown";
25+
2426 import { tally, type UnreadRow } from "./unread.ts";
2527
2628 export type Person = { key: string; user_id: string; username: string; muted: boolean };
5557 return out;
5658 }
5759
58−/** A message's text as a notification shows it: one line, no markup, short. */
60+/** A message's text as a notification shows it: one line, the Markdown's marks gone, short. */
5961 export function preview(body: string, max = 140): string {
60− const text = String(body ?? "")
61− .replace(/```[\s\S]*?```/g, " [code] ")
62− .replace(/`([^`]*)`/g, "$1")
63− .replace(/!\[[^\]]*\]\([^)]*\)/g, "[image]")
64− .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
65− .replace(/[*_~>#]+/g, "")
66− .replace(/\s+/g, " ")
67− .trim();
68− return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
62+ return plainText(String(body ?? ""), max);
6963 }
7064
7165 /** At most this many of a card's actions ride on a notification. */