Merge branch 'worktree-agent-a1c6f5039da0a4ac2' into integrate
16 files+2304−4460/16 viewed
| 235 | 235 | ||
| 236 | 236 | An address such as `me@example.com` is never read as a mention. | |
| 237 | 237 | ||
| 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 | + | ||
| 238 | 302 | ## Agents in channels | |
| 239 | 303 | ||
| 240 | 304 | Invite an agent to a channel the way you invite a person. Only agents of | |
| ⋯ | |||
| 343 | 407 | ||
| 344 | 408 | ## Edit and delete | |
| 345 | 409 | ||
| 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)). | |
| 348 | 419 | ||
| 349 | 420 | ## Reactions | |
| 350 | 421 | ||
| 793 | 793 | line-height: 1.65; | |
| 794 | 794 | } | |
| 795 | 795 | ||
| 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 | + |
| 167 | 167 | const [actions, setActions] = useState<ShownMessage | null>(null); | |
| 168 | 168 | // Someone's profile, opened from their card, beside the conversation. | |
| 169 | 169 | 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); | |
| 171 | 172 | const scroller = useRef<HTMLDivElement>(null); | |
| 172 | 173 | const stick = useRef(true); | |
| 173 | 174 | ||
| ⋯ | |||
| 555 | 556 | }; | |
| 556 | 557 | ||
| 557 | 558 | 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 | + | ); | |
| 558 | 581 | const typers = [...typing.values()].map((t) => t.member); | |
| 559 | 582 | const name = channel.name ?? ""; | |
| 560 | 583 | const archived = channel.kind === "channel" && !!channel.archived_at; | |
| ⋯ | |||
| 637 | 660 | onRetry={() => retry(row.message)} | |
| 638 | 661 | onLongPress={() => setActions(row.message)} | |
| 639 | 662 | 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} | |
| 640 | 665 | onWriteUp={joined && !archived ? () => setWriteUp(row.message.id) : undefined} | |
| 641 | 666 | threadOpen={row.message.id === threadId} | |
| 642 | 667 | /> | |
| ⋯ | |||
| 750 | 775 | link={writeUp && typeof window !== "undefined" ? linkToThread(writeUp) : ""} | |
| 751 | 776 | onSend={askForWriteUp} | |
| 752 | 777 | /> | |
| 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"> | |
| 770 | 781 | <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> | |
| 787 | 784 | )} | |
| 788 | 785 | </BottomSheet> | |
| 789 | 786 | </div> | |
| ⋯ | |||
| 1219 | 1216 | onWriteUp, | |
| 1220 | 1217 | threadOpen, | |
| 1221 | 1218 | inThread, | |
| 1219 | + | onEdit, | |
| 1220 | + | editor, | |
| 1222 | 1221 | }: { | |
| 1222 | + | /** Your own message: "Edit message" in the "⋯", and the composer in its place while editing. */ | |
| 1223 | + | onEdit?: () => void; | |
| 1224 | + | editor?: ReactNode; | |
| 1223 | 1225 | /** A phone: a long press opens the message's actions. */ | |
| 1224 | 1226 | onLongPress?: () => void; | |
| 1225 | 1227 | /** The hover toolbar's "⋯": copy the link to its thread, write it up in Docs. */ | |
| ⋯ | |||
| 1276 | 1278 | </div> | |
| 1277 | 1279 | )} | |
| 1278 | 1280 | <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} />} | |
| 1280 | 1282 | {message.card && ( | |
| 1281 | 1283 | <CardBox | |
| 1282 | 1284 | card={message.card} | |
| ⋯ | |||
| 1327 | 1329 | <MessageSquareText size={15} /> | |
| 1328 | 1330 | </button> | |
| 1329 | 1331 | </Hint> | |
| 1330 | − | {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} />} | |
| 1332 | + | {onCopyLink && <MoreActions onCopyLink={onCopyLink} onWriteUp={onWriteUp} onEdit={onEdit} />} | |
| 1331 | 1333 | </div> | |
| 1332 | 1334 | )} | |
| 1333 | 1335 | </article> | |
| ⋯ | |||
| 1335 | 1337 | } | |
| 1336 | 1338 | ||
| 1337 | 1339 | /** 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 }) { | |
| 1339 | 1341 | return ( | |
| 1340 | 1342 | <DropdownMenu> | |
| 1341 | 1343 | <Hint label="More actions"> | |
| ⋯ | |||
| 1347 | 1349 | </DropdownMenuTrigger> | |
| 1348 | 1350 | </Hint> | |
| 1349 | 1351 | <DropdownMenuContent align="end"> | |
| 1352 | + | {onEdit && ( | |
| 1353 | + | <DropdownMenuItem onSelect={onEdit}> | |
| 1354 | + | <Pencil /> | |
| 1355 | + | Edit message | |
| 1356 | + | </DropdownMenuItem> | |
| 1357 | + | )} | |
| 1350 | 1358 | <ThreadMenuItems onCopyLink={onCopyLink} onWriteUp={onWriteUp} /> | |
| 1351 | 1359 | </DropdownMenuContent> | |
| 1352 | 1360 | </DropdownMenu> | |
| 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"; | |
| 3 | 3 | ||
| 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"; | |
| 10 | 5 | ||
| 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")); | |
| 13 | 9 | ||
| 14 | 10 | /** | |
| 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. | |
| 19 | 13 | */ | |
| 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 | + | } | |
| 70 | 21 | ||
| 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 }) { | |
| 129 | 24 | 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> | |
| 258 | 32 | </div> | |
| 259 | 33 | </div> | |
| 260 | 34 | ); |
| 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 · > 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 | + | } |
| 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"; | |
| 2 | 3 | import { Link } from "react-router"; | |
| 3 | 4 | ||
| 4 | 5 | import { MemberCard } from "./profile-card"; | |
| 6 | + | import { Hint } from "../ui/hint"; | |
| 5 | 7 | import { type Block, type Span, blocks, onlyEmoji } from "../../lib/chat"; | |
| 6 | 8 | // The workspace's own emoji, drawn where `:name:` is written (components/emoji). | |
| 7 | 9 | import { useEmojiContext } from "../emoji/context"; | |
| ⋯ | |||
| 114 | 116 | )); | |
| 115 | 117 | } | |
| 116 | 118 | ||
| 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 | + | ||
| 117 | 140 | function BlockView({ block, context }: { block: Block; context: TextContext }) { | |
| 118 | 141 | switch (block.t) { | |
| 119 | 142 | case "p": | |
| 120 | 143 | 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" />; | |
| 121 | 149 | 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} />; | |
| 127 | 151 | case "list": { | |
| 128 | 152 | const Tag = block.ordered ? "ol" : "ul"; | |
| 129 | 153 | 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`}> | |
| 131 | 155 | {block.items.map((item, index) => ( | |
| 132 | − | <li key={index}>{spans(item, context)}</li> | |
| 156 | + | <ListItem key={index} item={item} context={context} /> | |
| 133 | 157 | ))} | |
| 134 | 158 | </Tag> | |
| 135 | 159 | ); | |
| 136 | 160 | } | |
| 137 | 161 | 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 | + | ); | |
| 139 | 167 | } | |
| 140 | 168 | } | |
| 141 | 169 | ||
| 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 | + | ||
| 142 | 238 | /** A message's text, rendered from parsed blocks: never as HTML. */ | |
| 143 | 239 | export function MessageText({ body, context }: { body: string; context: TextContext }) { | |
| 144 | 240 | const parsed = useMemo(() => blocks(body), [body]); | |
| 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 | + | }); |
| 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 | + | } |
| 134 | 134 | } | |
| 135 | 135 | ||
| 136 | 136 | // --------------------------------------------------------------------------- | |
| 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. | |
| 181 | 138 | ||
| 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"; | |
| 274 | 140 | ||
| 275 | 141 | /** Whether a message is only emoji, a few of them: shown larger, as people expect. */ | |
| 276 | 142 | export function onlyEmoji(text: string): boolean { |
| 20 | 20 | "@g1t/contracts": "*", | |
| 21 | 21 | "@g1t/theme": "*", | |
| 22 | 22 | "@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", | |
| 23 | 28 | "class-variance-authority": "^0.7.1", | |
| 24 | 29 | "clsx": "^2.1.1", | |
| 25 | 30 | "cmdk": "^1.1.1", |
| 80 | 80 | "@g1t/contracts": "*", | |
| 81 | 81 | "@g1t/theme": "*", | |
| 82 | 82 | "@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", | |
| 83 | 88 | "class-variance-authority": "^0.7.1", | |
| 84 | 89 | "clsx": "^2.1.1", | |
| 85 | 90 | "cmdk": "^1.1.1", | |
| ⋯ | |||
| 5232 | 5237 | "@tiptap/pm": "3.31.4" | |
| 5233 | 5238 | } | |
| 5234 | 5239 | }, | |
| 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 | + | }, | |
| 5235 | 5254 | "node_modules/@tiptap/extension-bold": { | |
| 5236 | 5255 | "version": "3.31.4", | |
| 5237 | 5256 | "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz", | |
| ⋯ | |||
| 5263 | 5282 | "@tiptap/pm": "3.31.4" | |
| 5264 | 5283 | } | |
| 5265 | 5284 | }, | |
| 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 | + | }, | |
| 5266 | 5298 | "node_modules/@tiptap/extension-code": { | |
| 5267 | 5299 | "version": "3.31.4", | |
| 5268 | 5300 | "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz", | |
| ⋯ | |||
| 5276 | 5308 | "@tiptap/core": "3.31.4" | |
| 5277 | 5309 | } | |
| 5278 | 5310 | }, | |
| 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 | + | }, | |
| 5279 | 5351 | "node_modules/@tiptap/extension-floating-menu": { | |
| 5280 | 5352 | "version": "3.31.4", | |
| 5281 | 5353 | "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.31.4.tgz", | |
| ⋯ | |||
| 5292 | 5364 | "@tiptap/pm": "3.31.4" | |
| 5293 | 5365 | } | |
| 5294 | 5366 | }, | |
| 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 | + | }, | |
| 5295 | 5420 | "node_modules/@tiptap/extension-italic": { | |
| 5296 | 5421 | "version": "3.31.4", | |
| 5297 | 5422 | "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz", | |
| ⋯ | |||
| 5305 | 5430 | "@tiptap/core": "3.31.4" | |
| 5306 | 5431 | } | |
| 5307 | 5432 | }, | |
| 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 | + | }, | |
| 5308 | 5516 | "node_modules/@tiptap/extension-strike": { | |
| 5309 | 5517 | "version": "3.31.4", | |
| 5310 | 5518 | "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz", | |
| ⋯ | |||
| 5416 | 5624 | "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", | |
| 5417 | 5625 | "license": "MIT" | |
| 5418 | 5626 | }, | |
| 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 | + | }, | |
| 5419 | 5663 | "node_modules/@tootallnate/quickjs-emscripten": { | |
| 5420 | 5664 | "version": "0.23.0", | |
| 5421 | 5665 | "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", | |
| ⋯ | |||
| 9113 | 9357 | "unicode-trie": "^2.0.0" | |
| 9114 | 9358 | } | |
| 9115 | 9359 | }, | |
| 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 | + | }, | |
| 9116 | 9366 | "node_modules/lodash": { | |
| 9117 | 9367 | "version": "4.18.1", | |
| 9118 | 9368 | "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", | |
| 4 | 4 | "private": true, | |
| 5 | 5 | "type": "module", | |
| 6 | 6 | "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" }, | |
| 8 | 8 | "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" } | |
| 9 | 9 | } |
| 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 | + | ")", | |
| 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(""), [{ 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~~ "), "*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 | + | }); |
| 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 | + | } |
| 81 | 81 | ||
| 82 | 82 | test("previews are one short line without markup", () => { | |
| 83 | 83 | 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>"); | |
| 85 | 87 | assert.equal(preview("x".repeat(300)).length, 140); | |
| 86 | 88 | }); | |
| 87 | 89 |
| 21 | 21 | */ | |
| 22 | 22 | import type { CardAction, FeedDelivery, FeedNotification, MemberProfile, NotificationCard, NotificationKind } from "@g1t/contracts"; | |
| 23 | 23 | ||
| 24 | + | import { plainText } from "@g1t/contracts/chat-markdown"; | |
| 25 | + | ||
| 24 | 26 | import { tally, type UnreadRow } from "./unread.ts"; | |
| 25 | 27 | ||
| 26 | 28 | export type Person = { key: string; user_id: string; username: string; muted: boolean }; | |
| ⋯ | |||
| 55 | 57 | return out; | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 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. */ | |
| 59 | 61 | 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); | |
| 69 | 63 | } | |
| 70 | 64 | ||
| 71 | 65 | /** At most this many of a card's actions ride on a notification. */ | |