From 5899a76afad5e55423fde8c08f5e865a1afe78bd Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 10 Jul 2026 16:34:06 +0100 Subject: [PATCH 1/2] Render links and live mention chips in channel threads Thread messages now render bare URLs as clickable links, and the thread composer shows @-mentions as styled chips while typing (via a Tiptap editor that serializes back to the shared mention token format) instead of the raw markdown token. Generated-By: PostHog Code Task-Id: 0c3b84d6-4b62-449f-9154-917103a1109a --- .../canvas/components/MentionComposer.test.ts | 59 +++ .../canvas/components/MentionComposer.tsx | 342 ++++++++++++------ .../canvas/components/MentionText.tsx | 80 ++-- .../canvas/components/ThreadPanel.tsx | 2 +- .../canvas/components/mention-composer.css | 13 + .../src/features/canvas/utils/linkify.test.ts | 60 +++ .../ui/src/features/canvas/utils/linkify.ts | 61 ++++ .../canvas/utils/mentionComposer.test.ts | 74 +--- .../features/canvas/utils/mentionComposer.ts | 52 --- 9 files changed, 483 insertions(+), 260 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/MentionComposer.test.ts create mode 100644 packages/ui/src/features/canvas/components/mention-composer.css create mode 100644 packages/ui/src/features/canvas/utils/linkify.test.ts create mode 100644 packages/ui/src/features/canvas/utils/linkify.ts diff --git a/packages/ui/src/features/canvas/components/MentionComposer.test.ts b/packages/ui/src/features/canvas/components/MentionComposer.test.ts new file mode 100644 index 0000000000..a1391d197b --- /dev/null +++ b/packages/ui/src/features/canvas/components/MentionComposer.test.ts @@ -0,0 +1,59 @@ +import { getSchema } from "@tiptap/core"; +import Mention from "@tiptap/extension-mention"; +import { Node as PmNode } from "@tiptap/pm/model"; +import StarterKit from "@tiptap/starter-kit"; +import { describe, expect, it } from "vitest"; +import { contentToDoc, docToContent } from "./MentionComposer"; + +const schema = getSchema([StarterKit, Mention]); + +function roundTrip(content: string): string { + return docToContent(PmNode.fromJSON(schema, contentToDoc(content))); +} + +describe("contentToDoc / docToContent", () => { + it.each([ + ["plain text", "hello there"], + ["empty content", ""], + ["mention token", "hey @[Raquel Smith](raquel@posthog.com) hi"], + [ + "multiple mentions", + "@[Ann Lee](ann@posthog.com) @[Bob Stone](bob@posthog.com)", + ], + ["multi-line", "first\nsecond\n\nfourth"], + [ + "mention across lines", + "cc @[Ann Lee](ann@posthog.com)\nthanks", + ], + ])("round-trips %s", (_label, content) => { + expect(roundTrip(content)).toBe(content); + }); + + it("maps mention tokens to mention nodes with email as id", () => { + const doc = contentToDoc("hi @[Ann Lee](ann@posthog.com)"); + expect(doc.content?.[0]?.content).toEqual([ + { type: "text", text: "hi " }, + { + type: "mention", + attrs: { id: "ann@posthog.com", label: "Ann Lee" }, + }, + ]); + }); + + it("serializes hard breaks as newlines", () => { + const doc = PmNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "one" }, + { type: "hardBreak" }, + { type: "text", text: "two" }, + ], + }, + ], + }); + expect(docToContent(doc)).toBe("one\ntwo"); + }); +}); diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index 40877cf728..b233a7edd6 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -1,20 +1,17 @@ -import { - Avatar, - AvatarFallback, - InputGroup, - InputGroupTextarea, -} from "@posthog/quill"; +import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill"; +import { formatMention, splitMentionSegments } from "@posthog/shared"; import type { UserBasic } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; -import { - type ActiveMentionQuery, - applyMention, - filterMentionCandidates, - findMentionQuery, -} from "@posthog/ui/features/canvas/utils/mentionComposer"; +import { filterMentionCandidates } from "@posthog/ui/features/canvas/utils/mentionComposer"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { Text } from "@radix-ui/themes"; -import { type ReactNode, useCallback, useMemo, useRef, useState } from "react"; +import Mention, { type MentionNodeAttrs } from "@tiptap/extension-mention"; +import Placeholder from "@tiptap/extension-placeholder"; +import type { Node as PmNode } from "@tiptap/pm/model"; +import { EditorContent, type JSONContent, useEditor } from "@tiptap/react"; +import StarterKit from "@tiptap/starter-kit"; +import { type ReactNode, useEffect, useRef, useState } from "react"; +import "./mention-composer.css"; interface MentionComposerProps { value: string; @@ -26,15 +23,72 @@ interface MentionComposerProps { onMentionInsert?: (member: UserBasic) => void; placeholder?: string; rows?: number; - textareaClassName?: string; - /** Rendered inside the input group after the textarea (send button etc.). */ + inputClassName?: string; + /** Rendered inside the input group after the editor (send button etc.). */ children?: ReactNode; } +/** Styled like the chips MentionText renders on sent messages. */ +const MENTION_CHIP_CLASS = "rounded px-0.5 font-medium text-[var(--accent-11)]"; + +// Padding lives on the editable element (not the input-group control) so a +// click anywhere in the box focuses the editor. +const EDITOR_CLASS = + "w-full px-2.5 py-2 outline-none break-words [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]"; + +/** Serialize the editor doc back to content with inline mention tokens. */ +export function docToContent(doc: PmNode): string { + const lines: string[] = []; + doc.forEach((block) => { + let line = ""; + block.forEach((child) => { + if (child.type.name === "mention") { + line += formatMention(child.attrs.label, child.attrs.id); + } else if (child.type.name === "hardBreak") { + line += "\n"; + } else { + line += child.text ?? ""; + } + }); + lines.push(line); + }); + return lines.join("\n"); +} + +export function contentToDoc(content: string): JSONContent { + return { + type: "doc", + content: content.split("\n").map((line) => { + const children = splitMentionSegments(line).flatMap( + (segment) => + segment.type === "mention" + ? [ + { + type: "mention", + attrs: { id: segment.email, label: segment.name }, + }, + ] + : segment.text + ? [{ type: "text", text: segment.text }] + : [], + ); + return children.length + ? { type: "paragraph", content: children } + : { type: "paragraph" }; + }), + }; +} + +interface SuggestionSession { + items: UserBasic[]; + command: (member: UserBasic) => void; +} + /** - * The thread composer: a textarea that opens an @-mention typeahead over the - * org's members. Selecting a member inserts an inline mention token (see - * `@posthog/shared` mentions) that notifies them in the Activity page. + * The thread composer: a rich text area that opens an @-mention typeahead over + * the org's members. Selecting a member inserts an inline chip — rendered the + * same way as in sent messages — that serializes to the `@posthog/shared` + * mention token and notifies them in the Activity page. */ export function MentionComposer({ value, @@ -44,106 +98,179 @@ export function MentionComposer({ onMentionInsert, placeholder, rows, - textareaClassName, + inputClassName, children, }: MentionComposerProps) { - const textareaRef = useRef(null); const itemRefs = useRef<(HTMLButtonElement | null)[]>([]); - const [active, setActive] = useState(null); + const [session, setSession] = useState(null); const [selectedIndex, setSelectedIndex] = useState(0); - // Esc hides the popup for the current trigger; typing a new `@` re-arms it. - const [dismissedStart, setDismissedStart] = useState(null); + // Esc hides the popup until the current trigger exits; a new `@` re-arms it. + const [dismissed, setDismissed] = useState(false); - const syncActive = useCallback((el: HTMLTextAreaElement) => { - const caret = el.selectionStart ?? el.value.length; - setActive(findMentionQuery(el.value, caret)); - }, []); - - const suggestions = useMemo( - () => (active ? filterMentionCandidates(members, active.query) : []), - [active, members], - ); - const open = - !!active && active.start !== dismissedStart && suggestions.length > 0; - - // Render-time adjustments (ref-guarded, same idiom as SuggestionList): when - // the parent clears the draft the mention context goes with it, and a new - // query restarts keyboard selection at the top. - const prevValueRef = useRef(value); - if (prevValueRef.current !== value) { - prevValueRef.current = value; - if (!value) { - setActive(null); - setDismissedStart(null); - } - } - const activeKey = active ? `${active.start}:${active.query}` : ""; - const prevActiveKeyRef = useRef(activeKey); - if (prevActiveKeyRef.current !== activeKey) { - prevActiveKeyRef.current = activeKey; - if (selectedIndex !== 0) setSelectedIndex(0); - } + const open = !!session && !dismissed && session.items.length > 0; // The list can shrink while a lower row is selected (members filter down). const highlightedIndex = Math.min( selectedIndex, - Math.max(0, suggestions.length - 1), + Math.max(0, (session?.items.length ?? 0) - 1), ); - const insert = useCallback( - (member: UserBasic) => { - const el = textareaRef.current; - if (!el || !active) return; - const caret = el.selectionStart ?? value.length; - const result = applyMention(value, active, caret, member); - onValueChange(result.text); - setActive(null); - onMentionInsert?.(member); - requestAnimationFrame(() => { - el.focus(); - el.setSelectionRange(result.caret, result.caret); - }); + // The suggestion plugin's callbacks close over refs so they always see the + // latest props and popup state. + const membersRef = useRef(members); + membersRef.current = members; + const onValueChangeRef = useRef(onValueChange); + onValueChangeRef.current = onValueChange; + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; + const onMentionInsertRef = useRef(onMentionInsert); + onMentionInsertRef.current = onMentionInsert; + const openRef = useRef(open); + openRef.current = open; + const sessionRef = useRef(session); + sessionRef.current = session; + const highlightedRef = useRef(highlightedIndex); + highlightedRef.current = highlightedIndex; + const lastValueRef = useRef(value); + + const editor = useEditor( + { + extensions: [ + StarterKit.configure({ + heading: false, + blockquote: false, + codeBlock: false, + bulletList: false, + orderedList: false, + listItem: false, + horizontalRule: false, + bold: false, + italic: false, + strike: false, + code: false, + link: false, + }), + Placeholder.configure({ placeholder: placeholder ?? "" }), + Mention.configure({ + renderHTML: ({ node }) => [ + "span", + { class: MENTION_CHIP_CLASS, title: node.attrs.id }, + `@${node.attrs.label}`, + ], + renderText: ({ node }) => `@${node.attrs.label}`, + suggestion: { + char: "@", + allowSpaces: true, + items: ({ query }) => + filterMentionCandidates(membersRef.current, query), + // The suggestion pipeline carries whole members, not node attrs, + // so member data survives into `command`. + command: ({ editor: e, range, props }) => { + const member = props as unknown as UserBasic; + e.chain() + .focus() + .insertContentAt(range, [ + { + type: "mention", + attrs: { id: member.email, label: userDisplayName(member) }, + }, + { type: "text", text: " " }, + ]) + .run(); + onMentionInsertRef.current?.(member); + }, + render: () => ({ + onStart: (props) => { + setDismissed(false); + setSelectedIndex(0); + setSession({ + items: props.items as unknown as UserBasic[], + command: (member) => + props.command(member as unknown as MentionNodeAttrs), + }); + }, + onUpdate: (props) => { + setSelectedIndex(0); + setSession({ + items: props.items as unknown as UserBasic[], + command: (member) => + props.command(member as unknown as MentionNodeAttrs), + }); + }, + onKeyDown: ({ event }) => { + if (event.key === "Escape" && sessionRef.current) { + setDismissed(true); + return true; + } + if (!openRef.current) return false; + const items = sessionRef.current?.items ?? []; + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + const delta = + event.key === "ArrowDown" ? 1 : items.length - 1; + const next = (highlightedRef.current + delta) % items.length; + setSelectedIndex(next); + itemRefs.current[next]?.scrollIntoView({ block: "nearest" }); + return true; + } + if (event.key === "Enter" || event.key === "Tab") { + const member = items[highlightedRef.current]; + if (member) sessionRef.current?.command(member); + return true; + } + return false; + }, + onExit: () => { + setSession(null); + setDismissed(false); + }, + }), + }, + }), + ], + content: contentToDoc(value), + editorProps: { + attributes: { + class: EDITOR_CLASS, + ...(rows ? { style: `min-height:${rows * 1.25}rem` } : {}), + }, + // Runs before the suggestion plugin's handler, so defer to it while + // the popup is open. + handleKeyDown: (_view, event) => { + if (openRef.current) return false; + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + onSubmitRef.current(); + return true; + } + return false; + }, + }, + onUpdate: ({ editor: e }) => { + const text = docToContent(e.state.doc); + lastValueRef.current = text; + onValueChangeRef.current(text); + }, }, - [active, value, onValueChange, onMentionInsert], + [placeholder, rows], ); - const handleKeyDown = (event: React.KeyboardEvent) => { - if (open) { - if (event.key === "ArrowDown" || event.key === "ArrowUp") { - event.preventDefault(); - const delta = event.key === "ArrowDown" ? 1 : suggestions.length - 1; - const next = (highlightedIndex + delta) % suggestions.length; - setSelectedIndex(next); - itemRefs.current[next]?.scrollIntoView({ block: "nearest" }); - return; - } - if (event.key === "Enter" || event.key === "Tab") { - event.preventDefault(); - const member = suggestions[highlightedIndex]; - if (member) insert(member); - return; - } - if (event.key === "Escape") { - event.preventDefault(); - setDismissedStart(active.start); - return; - } - } - if (event.key === "Enter" && !event.shiftKey) { - event.preventDefault(); - onSubmit(); - } - }; + // Adopt external value changes (submit clears the draft; a failed post + // restores it) without echoing our own edits back into the editor. + useEffect(() => { + if (!editor || value === lastValueRef.current) return; + lastValueRef.current = value; + editor.commands.setContent(contentToDoc(value)); + }, [editor, value]); return (
- {open && ( + {open && session && (
- {suggestions.map((member, index) => ( + {session.items.map((member, index) => (
)} - { - onValueChange(event.target.value); - syncActive(event.target); - }} - onSelect={(event) => syncActive(event.currentTarget)} - onKeyDown={handleKeyDown} - placeholder={placeholder} - rows={rows} - className={textareaClassName} - /> +
+ +
{children}
diff --git a/packages/ui/src/features/canvas/components/MentionText.tsx b/packages/ui/src/features/canvas/components/MentionText.tsx index 4e85d61798..ffdb7834ec 100644 --- a/packages/ui/src/features/canvas/components/MentionText.tsx +++ b/packages/ui/src/features/canvas/components/MentionText.tsx @@ -1,10 +1,17 @@ import { splitMentionSegments } from "@posthog/shared"; +import { splitLinkSegments } from "@posthog/ui/features/canvas/utils/linkify"; import { Text } from "@radix-ui/themes"; import { Fragment, useMemo } from "react"; +type RenderSegment = + | { type: "text"; text: string } + | { type: "link"; text: string; href: string } + | { type: "mention"; name: string; email: string }; + /** * Thread message content with inline mention tokens rendered as highlighted - * `@Name` chips; a mention of the viewer gets the stronger treatment. + * `@Name` chips (a mention of the viewer gets the stronger treatment) and + * bare URLs rendered as links. */ export function MentionText({ content, @@ -18,32 +25,59 @@ export function MentionText({ // Key each segment by its character offset — stable for a given content. const segments = useMemo(() => { let offset = 0; - return splitMentionSegments(content).map((segment) => { - const entry = { segment, key: `${offset}` }; - offset += segment.text.length; - return entry; - }); + const entries: Array<{ segment: RenderSegment; key: string }> = []; + const push = (segment: RenderSegment, length: number) => { + entries.push({ segment, key: `${offset}` }); + offset += length; + }; + for (const segment of splitMentionSegments(content)) { + if (segment.type === "mention") { + push( + { type: "mention", name: segment.name, email: segment.email }, + segment.text.length, + ); + } else { + for (const part of splitLinkSegments(segment.text)) { + push(part, part.text.length); + } + } + } + return entries; }, [content]); const selfEmail = currentUserEmail?.toLowerCase(); return ( - {segments.map(({ segment, key }) => - segment.type === "mention" ? ( - - @{segment.name} - - ) : ( - {segment.text} - ), - )} + {segments.map(({ segment, key }) => { + if (segment.type === "mention") { + return ( + + @{segment.name} + + ); + } + if (segment.type === "link") { + return ( + + {segment.text} + + ); + } + return {segment.text}; + })} ); } diff --git a/packages/ui/src/features/canvas/components/ThreadPanel.tsx b/packages/ui/src/features/canvas/components/ThreadPanel.tsx index 78a3760d85..47e5ef82ea 100644 --- a/packages/ui/src/features/canvas/components/ThreadPanel.tsx +++ b/packages/ui/src/features/canvas/components/ThreadPanel.tsx @@ -316,7 +316,7 @@ export function ThreadPanel({ onMentionInsert={handleMentionInsert} placeholder="Reply in thread… @ to tag a teammate" rows={2} - textareaClassName="max-h-40 text-[13px]" + inputClassName="max-h-40 text-[13px]" > diff --git a/packages/ui/src/features/canvas/components/mention-composer.css b/packages/ui/src/features/canvas/components/mention-composer.css new file mode 100644 index 0000000000..656d9a393b --- /dev/null +++ b/packages/ui/src/features/canvas/components/mention-composer.css @@ -0,0 +1,13 @@ +/* Tiptap placeholder - targets the empty first paragraph */ +.mention-composer p.is-editor-empty:first-child::before { + content: attr(data-placeholder); + color: var(--muted-foreground); + pointer-events: none; + float: left; + height: 0; +} + +.mention-composer .ProseMirror-selectednode { + background: var(--accent-a4); + border-radius: var(--radius-1); +} diff --git a/packages/ui/src/features/canvas/utils/linkify.test.ts b/packages/ui/src/features/canvas/utils/linkify.test.ts new file mode 100644 index 0000000000..6a4a67f6f7 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/linkify.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { type LinkSegment, splitLinkSegments } from "./linkify"; + +const text = (t: string): LinkSegment => ({ type: "text", text: t }); +const link = (url: string): LinkSegment => ({ + type: "link", + text: url, + href: url, +}); + +describe("splitLinkSegments", () => { + it.each<[string, string, LinkSegment[]]>([ + ["plain text", "no links here", [text("no links here")]], + ["bare url", "https://posthog.com", [link("https://posthog.com")]], + [ + "url mid-sentence", + "see https://posthog.com for docs", + [text("see "), link("https://posthog.com"), text(" for docs")], + ], + [ + "http url", + "http://example.com/a?b=c#d", + [link("http://example.com/a?b=c#d")], + ], + [ + "multiple urls", + "https://a.com and https://b.com", + [link("https://a.com"), text(" and "), link("https://b.com")], + ], + [ + "trailing period stays prose", + "read https://posthog.com/docs.", + [text("read "), link("https://posthog.com/docs"), text(".")], + ], + [ + "trailing comma and question mark", + "https://a.com, or https://b.com?", + [link("https://a.com"), text(", or "), link("https://b.com"), text("?")], + ], + [ + "wrapping parens stay prose", + "(https://posthog.com)", + [text("("), link("https://posthog.com"), text(")")], + ], + [ + "paren inside url is kept", + "https://en.wikipedia.org/wiki/A_(B)", + [link("https://en.wikipedia.org/wiki/A_(B)")], + ], + [ + "url split across whitespace", + "https://a.com\nhttps://b.com", + [link("https://a.com"), text("\n"), link("https://b.com")], + ], + ["not a url scheme", "ftp://example.com", [text("ftp://example.com")]], + ["empty string", "", []], + ])("%s", (_label, input, expected) => { + expect(splitLinkSegments(input)).toEqual(expected); + }); +}); diff --git a/packages/ui/src/features/canvas/utils/linkify.ts b/packages/ui/src/features/canvas/utils/linkify.ts new file mode 100644 index 0000000000..476d0917d9 --- /dev/null +++ b/packages/ui/src/features/canvas/utils/linkify.ts @@ -0,0 +1,61 @@ +const URL_PATTERN = /https?:\/\/[^\s<>]+/gi; + +export interface LinkTextSegment { + type: "text"; + text: string; +} + +export interface LinkUrlSegment { + type: "link"; + text: string; + href: string; +} + +export type LinkSegment = LinkTextSegment | LinkUrlSegment; + +/** + * Trailing punctuation reads as prose, not part of the URL; a `)` is kept only + * when it closes a paren opened inside the URL (e.g. Wikipedia paths). + */ +function trimTrailingPunctuation(url: string): string { + let end = url.length; + while (end > 0) { + const char = url[end - 1] as string; + if (".,;:!?'\"]}".includes(char)) { + end--; + continue; + } + if (char === ")") { + const body = url.slice(0, end); + const opens = body.split("(").length - 1; + const closes = body.split(")").length - 1; + if (closes > opens) { + end--; + continue; + } + } + break; + } + return url.slice(0, end); +} + +/** Split plain text into text and http(s) link segments, in document order. */ +export function splitLinkSegments(text: string): LinkSegment[] { + const segments: LinkSegment[] = []; + let lastIndex = 0; + URL_PATTERN.lastIndex = 0; + for (const match of text.matchAll(URL_PATTERN)) { + const url = trimTrailingPunctuation(match[0]); + if (!url) continue; + const index = match.index ?? 0; + if (index > lastIndex) { + segments.push({ type: "text", text: text.slice(lastIndex, index) }); + } + segments.push({ type: "link", text: url, href: url }); + lastIndex = index + url.length; + } + if (lastIndex < text.length) { + segments.push({ type: "text", text: text.slice(lastIndex) }); + } + return segments; +} diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts index 6d3d52a757..9f492341f0 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts @@ -1,10 +1,6 @@ import type { UserBasic } from "@posthog/shared/domain-types"; import { describe, expect, it } from "vitest"; -import { - applyMention, - filterMentionCandidates, - findMentionQuery, -} from "./mentionComposer"; +import { filterMentionCandidates } from "./mentionComposer"; function member(overrides: Partial & { email: string }): UserBasic { return { @@ -33,31 +29,6 @@ const raquel = member({ }); const members = [ann, bob, raquel]; -describe("findMentionQuery", () => { - it.each([ - ["at text start", "@ra", 3, { start: 0, query: "ra" }], - ["after whitespace", "hey @ra", 7, { start: 4, query: "ra" }], - ["empty query right after @", "hey @", 5, { start: 4, query: "" }], - [ - "query with a space", - "hey @raquel sm", - 14, - { start: 4, query: "raquel sm" }, - ], - ["mid-word @ (emails)", "mail me@work", 12, null], - ["query opening with [ (inserted token)", "@[Ann](a) x", 9, null], - ["query starting with a space", "hey @ home", 10, null], - ["query spanning a newline", "hey @ra\nnew", 11, null], - ["no @ before caret", "hello", 5, null], - ])("%s", (_label, text, caret, expected) => { - expect(findMentionQuery(text, caret)).toEqual(expected); - }); - - it("only considers text before the caret", () => { - expect(findMentionQuery("@raquel", 3)).toEqual({ start: 0, query: "ra" }); - }); -}); - describe("filterMentionCandidates", () => { it("returns everyone for an empty query", () => { expect(filterMentionCandidates(members, "")).toEqual([ann, bob, raquel]); @@ -88,46 +59,3 @@ describe("filterMentionCandidates", () => { expect(filterMentionCandidates(members, "zzz")).toEqual([]); }); }); - -describe("applyMention", () => { - it("replaces the active query, reusing the existing following space", () => { - const text = "hey @raq can you look"; - const active = { start: 4, query: "raq" }; - const result = applyMention(text, active, 8, raquel); - expect(result.text).toBe( - "hey @[Raquel Smith](raquel@posthog.com) can you look", - ); - expect(result.caret).toBe( - "hey @[Raquel Smith](raquel@posthog.com) ".length, - ); - }); - - it("consumes the rest of the @word when the caret moved backward", () => { - // "hey @raq" with the caret between "ra" and "q": the trailing "q" is - // still query text and must not leak into the message. - const result = applyMention( - "hey @raq", - { start: 4, query: "ra" }, - 7, - raquel, - ); - expect(result.text).toBe("hey @[Raquel Smith](raquel@posthog.com) "); - expect(result.caret).toBe(result.text.length); - }); - - it("does not consume text beyond the @word", () => { - const result = applyMention( - "@ra world", - { start: 0, query: "ra" }, - 3, - raquel, - ); - expect(result.text).toBe("@[Raquel Smith](raquel@posthog.com) world"); - }); - - it("works at the end of the text", () => { - const result = applyMention("@", { start: 0, query: "" }, 1, ann); - expect(result.text).toBe("@[Ann Lee](ann@posthog.com) "); - expect(result.caret).toBe(result.text.length); - }); -}); diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.ts b/packages/ui/src/features/canvas/utils/mentionComposer.ts index fd6a552f16..a953f9d781 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.ts @@ -1,33 +1,6 @@ -import { formatMention } from "@posthog/shared"; import type { UserBasic } from "@posthog/shared/domain-types"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; -/** The in-progress `@query` between the trigger and the caret. */ -export interface ActiveMentionQuery { - /** Index of the `@` in the full text. */ - start: number; - query: string; -} - -/** - * The mention query the caret is inside, or null when the caret isn't in one. - * The `@` must open a word (start of text or after whitespace); a query - * starting with `[` is an already-inserted token, not a fresh trigger. - */ -export function findMentionQuery( - text: string, - caret: number, -): ActiveMentionQuery | null { - const upToCaret = text.slice(0, caret); - const start = upToCaret.lastIndexOf("@"); - if (start === -1) return null; - if (start > 0 && !/\s/.test(upToCaret[start - 1] ?? "")) return null; - const query = upToCaret.slice(start + 1); - if (query.startsWith("[") || query.startsWith(" ")) return null; - if (query.includes("\n") || query.length > 60) return null; - return { start, query }; -} - /** Members matching the query, best-first: name prefix, word prefix, email, substring. */ export function filterMentionCandidates( members: UserBasic[], @@ -55,28 +28,3 @@ export function filterMentionCandidates( .slice(0, limit) .map((entry) => entry.member); } - -/** - * Replace the active `@query` with the member's mention token, leaving the - * caret right after it (past any space that already follows). - */ -export function applyMention( - text: string, - active: ActiveMentionQuery, - caret: number, - member: UserBasic, -): { text: string; caret: number } { - // The replacement spans the whole @word: when the caret moved back inside - // the query, the characters typed after it are still mention text. - let end = caret; - while (end < text.length && !/\s/.test(text[end] ?? "")) end++; - const tail = text.slice(end); - const token = formatMention(userDisplayName(member), member.email); - const before = text.slice(0, active.start); - // Reuse an existing following space rather than doubling it up. - const inserted = tail.startsWith(" ") ? token : `${token} `; - return { - text: before + inserted + tail, - caret: before.length + inserted.length + (tail.startsWith(" ") ? 1 : 0), - }; -} From e83752861f1a965844b41d4cd4007da6d0d95823 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Fri, 10 Jul 2026 16:56:43 +0100 Subject: [PATCH 2/2] Fix Biome formatting; move composer serialization into utils The quality check failed on formatting in the new test file. Also moves docToContent/contentToDoc out of the component file into the mentionComposer utils (with their tests) to address React Doctor's only-export-components warnings. Generated-By: PostHog Code Task-Id: 0c3b84d6-4b62-449f-9154-917103a1109a --- .../canvas/components/MentionComposer.test.ts | 59 ------------------ .../canvas/components/MentionComposer.tsx | 53 ++-------------- .../canvas/utils/mentionComposer.test.ts | 60 ++++++++++++++++++- .../features/canvas/utils/mentionComposer.ts | 46 ++++++++++++++ 4 files changed, 111 insertions(+), 107 deletions(-) delete mode 100644 packages/ui/src/features/canvas/components/MentionComposer.test.ts diff --git a/packages/ui/src/features/canvas/components/MentionComposer.test.ts b/packages/ui/src/features/canvas/components/MentionComposer.test.ts deleted file mode 100644 index a1391d197b..0000000000 --- a/packages/ui/src/features/canvas/components/MentionComposer.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { getSchema } from "@tiptap/core"; -import Mention from "@tiptap/extension-mention"; -import { Node as PmNode } from "@tiptap/pm/model"; -import StarterKit from "@tiptap/starter-kit"; -import { describe, expect, it } from "vitest"; -import { contentToDoc, docToContent } from "./MentionComposer"; - -const schema = getSchema([StarterKit, Mention]); - -function roundTrip(content: string): string { - return docToContent(PmNode.fromJSON(schema, contentToDoc(content))); -} - -describe("contentToDoc / docToContent", () => { - it.each([ - ["plain text", "hello there"], - ["empty content", ""], - ["mention token", "hey @[Raquel Smith](raquel@posthog.com) hi"], - [ - "multiple mentions", - "@[Ann Lee](ann@posthog.com) @[Bob Stone](bob@posthog.com)", - ], - ["multi-line", "first\nsecond\n\nfourth"], - [ - "mention across lines", - "cc @[Ann Lee](ann@posthog.com)\nthanks", - ], - ])("round-trips %s", (_label, content) => { - expect(roundTrip(content)).toBe(content); - }); - - it("maps mention tokens to mention nodes with email as id", () => { - const doc = contentToDoc("hi @[Ann Lee](ann@posthog.com)"); - expect(doc.content?.[0]?.content).toEqual([ - { type: "text", text: "hi " }, - { - type: "mention", - attrs: { id: "ann@posthog.com", label: "Ann Lee" }, - }, - ]); - }); - - it("serializes hard breaks as newlines", () => { - const doc = PmNode.fromJSON(schema, { - type: "doc", - content: [ - { - type: "paragraph", - content: [ - { type: "text", text: "one" }, - { type: "hardBreak" }, - { type: "text", text: "two" }, - ], - }, - ], - }); - expect(docToContent(doc)).toBe("one\ntwo"); - }); -}); diff --git a/packages/ui/src/features/canvas/components/MentionComposer.tsx b/packages/ui/src/features/canvas/components/MentionComposer.tsx index b233a7edd6..aaee6d89aa 100644 --- a/packages/ui/src/features/canvas/components/MentionComposer.tsx +++ b/packages/ui/src/features/canvas/components/MentionComposer.tsx @@ -1,14 +1,16 @@ import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill"; -import { formatMention, splitMentionSegments } from "@posthog/shared"; import type { UserBasic } from "@posthog/shared/domain-types"; import { getUserInitials } from "@posthog/ui/features/auth/userInitials"; -import { filterMentionCandidates } from "@posthog/ui/features/canvas/utils/mentionComposer"; +import { + contentToDoc, + docToContent, + filterMentionCandidates, +} from "@posthog/ui/features/canvas/utils/mentionComposer"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { Text } from "@radix-ui/themes"; import Mention, { type MentionNodeAttrs } from "@tiptap/extension-mention"; import Placeholder from "@tiptap/extension-placeholder"; -import type { Node as PmNode } from "@tiptap/pm/model"; -import { EditorContent, type JSONContent, useEditor } from "@tiptap/react"; +import { EditorContent, useEditor } from "@tiptap/react"; import StarterKit from "@tiptap/starter-kit"; import { type ReactNode, useEffect, useRef, useState } from "react"; import "./mention-composer.css"; @@ -36,49 +38,6 @@ const MENTION_CHIP_CLASS = "rounded px-0.5 font-medium text-[var(--accent-11)]"; const EDITOR_CLASS = "w-full px-2.5 py-2 outline-none break-words [overflow-wrap:break-word] [white-space:pre-wrap] [word-break:break-word]"; -/** Serialize the editor doc back to content with inline mention tokens. */ -export function docToContent(doc: PmNode): string { - const lines: string[] = []; - doc.forEach((block) => { - let line = ""; - block.forEach((child) => { - if (child.type.name === "mention") { - line += formatMention(child.attrs.label, child.attrs.id); - } else if (child.type.name === "hardBreak") { - line += "\n"; - } else { - line += child.text ?? ""; - } - }); - lines.push(line); - }); - return lines.join("\n"); -} - -export function contentToDoc(content: string): JSONContent { - return { - type: "doc", - content: content.split("\n").map((line) => { - const children = splitMentionSegments(line).flatMap( - (segment) => - segment.type === "mention" - ? [ - { - type: "mention", - attrs: { id: segment.email, label: segment.name }, - }, - ] - : segment.text - ? [{ type: "text", text: segment.text }] - : [], - ); - return children.length - ? { type: "paragraph", content: children } - : { type: "paragraph" }; - }), - }; -} - interface SuggestionSession { items: UserBasic[]; command: (member: UserBasic) => void; diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts index 9f492341f0..e6a4f52658 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.test.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.test.ts @@ -1,6 +1,14 @@ import type { UserBasic } from "@posthog/shared/domain-types"; +import { getSchema } from "@tiptap/core"; +import Mention from "@tiptap/extension-mention"; +import { Node as PmNode } from "@tiptap/pm/model"; +import StarterKit from "@tiptap/starter-kit"; import { describe, expect, it } from "vitest"; -import { filterMentionCandidates } from "./mentionComposer"; +import { + contentToDoc, + docToContent, + filterMentionCandidates, +} from "./mentionComposer"; function member(overrides: Partial & { email: string }): UserBasic { return { @@ -59,3 +67,53 @@ describe("filterMentionCandidates", () => { expect(filterMentionCandidates(members, "zzz")).toEqual([]); }); }); + +describe("contentToDoc / docToContent", () => { + const schema = getSchema([StarterKit, Mention]); + + function roundTrip(content: string): string { + return docToContent(PmNode.fromJSON(schema, contentToDoc(content))); + } + + it.each([ + ["plain text", "hello there"], + ["empty content", ""], + ["mention token", "hey @[Raquel Smith](raquel@posthog.com) hi"], + [ + "multiple mentions", + "@[Ann Lee](ann@posthog.com) @[Bob Stone](bob@posthog.com)", + ], + ["multi-line", "first\nsecond\n\nfourth"], + ["mention across lines", "cc @[Ann Lee](ann@posthog.com)\nthanks"], + ])("round-trips %s", (_label, content) => { + expect(roundTrip(content)).toBe(content); + }); + + it("maps mention tokens to mention nodes with email as id", () => { + const doc = contentToDoc("hi @[Ann Lee](ann@posthog.com)"); + expect(doc.content?.[0]?.content).toEqual([ + { type: "text", text: "hi " }, + { + type: "mention", + attrs: { id: "ann@posthog.com", label: "Ann Lee" }, + }, + ]); + }); + + it("serializes hard breaks as newlines", () => { + const doc = PmNode.fromJSON(schema, { + type: "doc", + content: [ + { + type: "paragraph", + content: [ + { type: "text", text: "one" }, + { type: "hardBreak" }, + { type: "text", text: "two" }, + ], + }, + ], + }); + expect(docToContent(doc)).toBe("one\ntwo"); + }); +}); diff --git a/packages/ui/src/features/canvas/utils/mentionComposer.ts b/packages/ui/src/features/canvas/utils/mentionComposer.ts index a953f9d781..4a4d923fde 100644 --- a/packages/ui/src/features/canvas/utils/mentionComposer.ts +++ b/packages/ui/src/features/canvas/utils/mentionComposer.ts @@ -1,5 +1,8 @@ +import { formatMention, splitMentionSegments } from "@posthog/shared"; import type { UserBasic } from "@posthog/shared/domain-types"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; +import type { Node as PmNode } from "@tiptap/pm/model"; +import type { JSONContent } from "@tiptap/react"; /** Members matching the query, best-first: name prefix, word prefix, email, substring. */ export function filterMentionCandidates( @@ -28,3 +31,46 @@ export function filterMentionCandidates( .slice(0, limit) .map((entry) => entry.member); } + +/** Serialize the composer's editor doc back to content with inline mention tokens. */ +export function docToContent(doc: PmNode): string { + const lines: string[] = []; + doc.forEach((block) => { + let line = ""; + block.forEach((child) => { + if (child.type.name === "mention") { + line += formatMention(child.attrs.label, child.attrs.id); + } else if (child.type.name === "hardBreak") { + line += "\n"; + } else { + line += child.text ?? ""; + } + }); + lines.push(line); + }); + return lines.join("\n"); +} + +export function contentToDoc(content: string): JSONContent { + return { + type: "doc", + content: content.split("\n").map((line) => { + const children = splitMentionSegments(line).flatMap( + (segment) => + segment.type === "mention" + ? [ + { + type: "mention", + attrs: { id: segment.email, label: segment.name }, + }, + ] + : segment.text + ? [{ type: "text", text: segment.text }] + : [], + ); + return children.length + ? { type: "paragraph", content: children } + : { type: "paragraph" }; + }), + }; +}