Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
295 changes: 187 additions & 108 deletions packages/ui/src/features/canvas/components/MentionComposer.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,19 @@
import {
Avatar,
AvatarFallback,
InputGroup,
InputGroupTextarea,
} from "@posthog/quill";
import { Avatar, AvatarFallback, InputGroup } from "@posthog/quill";
import type { UserBasic } from "@posthog/shared/domain-types";
import { getUserInitials } from "@posthog/ui/features/auth/userInitials";
import {
type ActiveMentionQuery,
applyMention,
contentToDoc,
docToContent,
filterMentionCandidates,
findMentionQuery,
} 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 { EditorContent, 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;
Expand All @@ -26,15 +25,29 @@ 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]";

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,
Expand All @@ -44,106 +57,179 @@ export function MentionComposer({
onMentionInsert,
placeholder,
rows,
textareaClassName,
inputClassName,
children,
}: MentionComposerProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [active, setActive] = useState<ActiveMentionQuery | null>(null);
const [session, setSession] = useState<SuggestionSession | null>(null);
const [selectedIndex, setSelectedIndex] = useState(0);
// Esc hides the popup for the current trigger; typing a new `@` re-arms it.
const [dismissedStart, setDismissedStart] = useState<number | null>(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<HTMLTextAreaElement>) => {
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 (
<div className="relative">
{open && (
{open && session && (
<div className="absolute inset-x-0 bottom-full z-50 mb-1 flex flex-col overflow-hidden rounded-md border border-[var(--gray-a6)] bg-[var(--color-panel-solid)] text-[13px] shadow-lg">
<div
role="listbox"
aria-label="Mention a teammate"
className="max-h-56 overflow-y-auto py-1"
>
{suggestions.map((member, index) => (
{session.items.map((member, index) => (
<button
type="button"
role="option"
Expand All @@ -152,9 +238,9 @@ export function MentionComposer({
ref={(el) => {
itemRefs.current[index] = el;
}}
// Keep focus in the textarea so insertion lands at the caret.
// Keep focus in the editor so insertion lands at the caret.
onMouseDown={(event) => event.preventDefault()}
onClick={() => insert(member)}
onClick={() => session.command(member)}
onMouseEnter={() => setSelectedIndex(index)}
className={`flex w-full items-center gap-2 border-none px-2 py-1 text-left ${
index === highlightedIndex ? "bg-[var(--accent-a4)]" : ""
Expand All @@ -178,19 +264,12 @@ export function MentionComposer({
</div>
)}
<InputGroup className="h-auto cursor-text bg-card">
<InputGroupTextarea
ref={textareaRef}
value={value}
onChange={(event) => {
onValueChange(event.target.value);
syncActive(event.target);
}}
onSelect={(event) => syncActive(event.currentTarget)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={rows}
className={textareaClassName}
/>
<div
data-slot="input-group-control"
className={`quill-input-group__control mention-composer w-full overflow-y-auto p-0 ${inputClassName ?? ""}`}
>
<EditorContent editor={editor} />
</div>
{children}
</InputGroup>
</div>
Expand Down
Loading
Loading