Replace the composer textarea with a Tiptap markdown editor
The composer now writes markdown with bold, italic, strike, code, links, quotes and lists, from shortcuts, markdown typing or a bubble over the selection. @ mentions a bot and # a workspace, shown as chips and saved as [bot:ID] / [workspace:ID]. Text selected in a message can be quoted into the composer. User messages render as markdown, and replies tell the bot who and what the user mentioned. Adds an ESM stand-in for use-sync-external-store, since bunext keeps React external in page bundles and the CommonJS original requires it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { Bold, Code, Italic, Link2, List, ListOrdered, Quote, Strikethrough, type LucideIcon } from "lucide-react";
|
||||
|
||||
type ComposerFormat = {
|
||||
/**
|
||||
* The mark or node it toggles, for `editor.isActive`.
|
||||
*/
|
||||
name: string;
|
||||
title: string;
|
||||
Icon: LucideIcon;
|
||||
toggle: (params: { editor: Editor }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The formatting the composer's selection bubble offers, in order.
|
||||
*/
|
||||
export const ComposerFormats: ComposerFormat[] = [
|
||||
{ name: "bold", title: "Bold", Icon: Bold, toggle: ({ editor }) => editor.chain().focus().toggleBold().run() },
|
||||
{ name: "italic", title: "Italic", Icon: Italic, toggle: ({ editor }) => editor.chain().focus().toggleItalic().run() },
|
||||
{
|
||||
name: "strike",
|
||||
title: "Strikethrough",
|
||||
Icon: Strikethrough,
|
||||
toggle: ({ editor }) => editor.chain().focus().toggleStrike().run(),
|
||||
},
|
||||
{ name: "code", title: "Code", Icon: Code, toggle: ({ editor }) => editor.chain().focus().toggleCode().run() },
|
||||
{ name: "link", title: "Link", Icon: Link2, toggle: toggleLink },
|
||||
{
|
||||
name: "blockquote",
|
||||
title: "Quote",
|
||||
Icon: Quote,
|
||||
toggle: ({ editor }) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
name: "bulletList",
|
||||
title: "Bulleted list",
|
||||
Icon: List,
|
||||
toggle: ({ editor }) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
name: "orderedList",
|
||||
title: "Numbered list",
|
||||
Icon: ListOrdered,
|
||||
toggle: ({ editor }) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Removes the selection's link, or asks for one to set: web and mail
|
||||
* links only, `https://` added when there's no scheme.
|
||||
*/
|
||||
function toggleLink({ editor }: { editor: Editor }) {
|
||||
if (editor.isActive("link")) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
const href = window.prompt("Link address")?.trim();
|
||||
|
||||
if (!href) return;
|
||||
|
||||
const has_scheme = /^[a-z][a-z0-9+.-]*:/i.test(href);
|
||||
|
||||
if (has_scheme && !/^(https?|mailto):/i.test(href)) return;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href: has_scheme ? href : `https://${href}` })
|
||||
.run();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* What the composer can mention, by the character that starts it: `@` a
|
||||
* bot (`[bot:ID]`), `#` a workspace (`[workspace:ID]`).
|
||||
*/
|
||||
export const ComposerMentionKinds = {
|
||||
"@": "bot",
|
||||
"#": "workspace",
|
||||
} as const;
|
||||
|
||||
export type ComposerMentionChar = keyof typeof ComposerMentionKinds;
|
||||
export type ComposerMentionKind = (typeof ComposerMentionKinds)[ComposerMentionChar];
|
||||
|
||||
/**
|
||||
* The trigger character of a kind.
|
||||
*/
|
||||
export function composerMentionChar({ kind }: { kind: ComposerMentionKind }): ComposerMentionChar {
|
||||
return kind == "workspace" ? "#" : "@";
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import React from "react";
|
||||
import type { WorkspaceWithActivity } from "@/src/types";
|
||||
|
||||
/**
|
||||
* The live workspaces the user is in, so a workspace named in a message
|
||||
* links to it and the composer can mention it.
|
||||
*/
|
||||
export const CrewWorkspacesContext = React.createContext<WorkspaceWithActivity[]>([]);
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { Components } from "react-markdown";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import MarkdownList from "../(partials)/markdown-list";
|
||||
import UserMarkdownLink from "../(partials)/user-markdown-link";
|
||||
import { MessageMarkdownComponents } from "./message-markdown-components";
|
||||
|
||||
const Heading: Components["h1"] = ({ children }) => (
|
||||
<P noMargin className="text-sm leading-5 font-bold text-on-ink">
|
||||
{children}
|
||||
</P>
|
||||
);
|
||||
|
||||
/**
|
||||
* The user's own message markdown, on the ink bubble: the chat's
|
||||
* components (`MessageMarkdownComponents`) in the bubble's colours, and
|
||||
* headings no bigger than the text. Code blocks and ref chips keep their
|
||||
* own surface.
|
||||
*/
|
||||
export const UserMarkdownComponents: Components = {
|
||||
...MessageMarkdownComponents,
|
||||
p: ({ children }) => (
|
||||
<P noMargin className="text-sm leading-5 text-on-ink">
|
||||
{children}
|
||||
</P>
|
||||
),
|
||||
h1: Heading,
|
||||
h2: Heading,
|
||||
h3: Heading,
|
||||
h4: Heading,
|
||||
h5: Heading,
|
||||
h6: Heading,
|
||||
a: UserMarkdownLink,
|
||||
ul: ({ children }) => <MarkdownList className="text-on-ink">{children}</MarkdownList>,
|
||||
ol: ({ children, start }) => (
|
||||
<MarkdownList ordered start={start} className="text-on-ink">
|
||||
{children}
|
||||
</MarkdownList>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<Span className="font-mono text-[13px] px-1 py-px rounded-default-xs bg-on-ink/15 text-on-ink break-words">
|
||||
{children}
|
||||
</Span>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<Stack className="gap-2! items-stretch pl-3 border-0 border-l-2 border-solid border-on-ink/40 opacity-80">
|
||||
{children}
|
||||
</Stack>
|
||||
),
|
||||
};
|
||||
@@ -1,22 +0,0 @@
|
||||
import { useLayoutEffect, type RefObject } from "react";
|
||||
|
||||
type Params = {
|
||||
ref: RefObject<HTMLTextAreaElement | null>;
|
||||
value: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Grows a textarea with its text where `field-sizing: content` isn't
|
||||
* supported (Firefox). Its CSS `max-height` still caps it, then it scrolls.
|
||||
* Does nothing where the CSS handles it.
|
||||
*/
|
||||
export default function useAutoGrowTextarea({ ref, value }: Params) {
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el || CSS.supports("field-sizing", "content")) return;
|
||||
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}, [ref, value]);
|
||||
}
|
||||
@@ -7,13 +7,14 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's text, kept in `localStorage` per bot and workspace until
|
||||
* it's sent, so a reload or leaving the conversation doesn't lose it.
|
||||
* Read after mount, since the page renders on the server first.
|
||||
* The composer's text (markdown), kept in `localStorage` per bot and
|
||||
* workspace until it's sent, so a reload or leaving the conversation
|
||||
* doesn't lose it. Read after mount, since the page renders on the server
|
||||
* first; `loaded` turns true once it has been, for that `key`.
|
||||
*/
|
||||
export default function useComposerDraft({ bot_id, workspace_id }: Params) {
|
||||
const key = composerDraftKey({ bot_id, workspace_id, part: "text" });
|
||||
const [draft, setDraft] = useState({ key, text: "" });
|
||||
const [draft, setDraft] = useState({ key, text: "", loaded: false });
|
||||
|
||||
useEffect(() => {
|
||||
let text = "";
|
||||
@@ -22,11 +23,11 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
|
||||
text = localStorage.getItem(key) || "";
|
||||
} catch {}
|
||||
|
||||
setDraft({ key, text });
|
||||
setDraft({ key, text, loaded: true });
|
||||
}, [key]);
|
||||
|
||||
const setText = (text: string) => {
|
||||
setDraft({ key, text });
|
||||
setDraft({ key, text, loaded: true });
|
||||
|
||||
try {
|
||||
if (text) localStorage.setItem(key, text);
|
||||
@@ -34,8 +35,12 @@ export default function useComposerDraft({ bot_id, workspace_id }: Params) {
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const current = draft.key == key;
|
||||
|
||||
return {
|
||||
text: draft.key == key ? draft.text : "",
|
||||
key,
|
||||
text: current ? draft.text : "",
|
||||
loaded: current && draft.loaded,
|
||||
setText,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Extension, ReactNodeViewRenderer, useEditor, type Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { Markdown } from "@tiptap/markdown";
|
||||
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
|
||||
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
|
||||
import {
|
||||
ComposerMentionKinds,
|
||||
type ComposerMentionChar,
|
||||
} from "../(data)/composer-mention-kinds";
|
||||
import ComposerMentionChip from "../(partials)/composer-mention-chip";
|
||||
import { MentionMarkdown, mentionRef } from "../(utils)/mention-markdown";
|
||||
import searchMentionItems, { type ComposerMentionItem } from "../(utils)/search-mention-items";
|
||||
|
||||
/**
|
||||
* The open mention menu: what matches what's typed after `@` or `#`, the
|
||||
* highlighted one, and how to pick one.
|
||||
*/
|
||||
export type ComposerMentionMenu = {
|
||||
char: ComposerMentionChar;
|
||||
items: ComposerMentionItem[];
|
||||
index: number;
|
||||
select: (item: ComposerMentionItem) => void;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
placeholder: string;
|
||||
bots: BotWithLastMessage[];
|
||||
workspaces: WorkspaceWithActivity[];
|
||||
/**
|
||||
* The message as markdown, on every change.
|
||||
*/
|
||||
onChange: (params: { markdown: string }) => void;
|
||||
onSubmit: () => void;
|
||||
/**
|
||||
* Files pasted or dropped in.
|
||||
*/
|
||||
onFiles: (params: { files: File[] }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's editor (Tiptap): markdown in and out, with bold, italic,
|
||||
* strike, code, lists, quotes and links (no headings), and mentions of
|
||||
* bots (`@`) and workspaces (`#`) written as `[bot:ID]` and
|
||||
* `[workspace:ID]`. Enter sends and Shift+Enter breaks the line, except
|
||||
* in a code block or list, where Enter is the editor's and Mod+Enter
|
||||
* sends. Enter picks from an open mention menu. Pasted text comes in
|
||||
* plain, without another site's styling; pasted or dropped files go to
|
||||
* `onFiles`.
|
||||
*/
|
||||
export default function useComposerEditor({
|
||||
placeholder,
|
||||
bots,
|
||||
workspaces,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onFiles,
|
||||
}: Params) {
|
||||
const latest = useRef({ bots, workspaces, onChange, onSubmit, onFiles });
|
||||
latest.current = { bots, workspaces, onChange, onSubmit, onFiles };
|
||||
|
||||
const [menu, setMenu] = useState<ComposerMentionMenu | null>(null);
|
||||
const menu_ref = useRef<ComposerMentionMenu | null>(null);
|
||||
|
||||
const showMenu = (next: ComposerMentionMenu | null) => {
|
||||
menu_ref.current = next;
|
||||
setMenu(next);
|
||||
};
|
||||
|
||||
const suggestion = (char: ComposerMentionChar): Omit<SuggestionOptions, "editor"> => ({
|
||||
char,
|
||||
allowSpaces: false,
|
||||
items: ({ query }) =>
|
||||
searchMentionItems({
|
||||
kind: ComposerMentionKinds[char],
|
||||
query,
|
||||
bots: latest.current.bots,
|
||||
workspaces: latest.current.workspaces,
|
||||
}),
|
||||
render: () => {
|
||||
const open = (props: SuggestionProps<ComposerMentionItem>) => {
|
||||
const index = menu_ref.current?.char == char ? menu_ref.current.index : 0;
|
||||
|
||||
showMenu({
|
||||
char,
|
||||
items: props.items,
|
||||
index: Math.min(index, Math.max(props.items.length - 1, 0)),
|
||||
select: (item) => props.command({ id: String(item.id), label: item.label }),
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: open,
|
||||
onUpdate: open,
|
||||
onExit: () => showMenu(null),
|
||||
onKeyDown: ({ event }) => moveMenu({ event }),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const moveMenu = ({ event }: { event: KeyboardEvent }) => {
|
||||
const current = menu_ref.current;
|
||||
|
||||
if (!current?.items.length) return false;
|
||||
|
||||
const count = current.items.length;
|
||||
|
||||
if (event.key == "ArrowDown" || event.key == "ArrowUp") {
|
||||
const step = event.key == "ArrowDown" ? 1 : -1;
|
||||
|
||||
showMenu({ ...current, index: (current.index + step + count) % count });
|
||||
return true;
|
||||
}
|
||||
|
||||
const picked = current.items[current.index];
|
||||
|
||||
if ((event.key == "Enter" || event.key == "Tab") && picked) {
|
||||
current.select(picked);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
horizontalRule: false,
|
||||
underline: false,
|
||||
link: { openOnClick: false, autolink: true },
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
Markdown,
|
||||
Mention.extend({
|
||||
...MentionMarkdown,
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ComposerMentionChip, { as: "span" });
|
||||
},
|
||||
}).configure({
|
||||
renderText: ({ node }) =>
|
||||
node.attrs.label
|
||||
? `${node.attrs.mentionSuggestionChar || "@"}${node.attrs.label}`
|
||||
: mentionRef({ id: node.attrs.id, char: node.attrs.mentionSuggestionChar }),
|
||||
suggestions: [suggestion("@"), suggestion("#")],
|
||||
}),
|
||||
Extension.create({
|
||||
name: "composerKeys",
|
||||
addKeyboardShortcuts() {
|
||||
const editorOwnsEnter = () =>
|
||||
this.editor.isActive("codeBlock") || this.editor.isActive("listItem");
|
||||
|
||||
return {
|
||||
Enter: () => {
|
||||
if (menu_ref.current?.items.length || this.editor.view.composing) return false;
|
||||
if (editorOwnsEnter()) return false;
|
||||
|
||||
latest.current.onSubmit();
|
||||
return true;
|
||||
},
|
||||
"Mod-Enter": () => {
|
||||
latest.current.onSubmit();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
role: "textbox",
|
||||
"aria-multiline": "true",
|
||||
"aria-label": placeholder,
|
||||
class: ComposerEditorClassName,
|
||||
},
|
||||
handlePaste: (view, event) => {
|
||||
const files = [...(event.clipboardData?.files || [])];
|
||||
|
||||
if (files.length) {
|
||||
latest.current.onFiles({ files });
|
||||
return true;
|
||||
}
|
||||
|
||||
const html = event.clipboardData?.getData("text/html") || "";
|
||||
const text = event.clipboardData?.getData("text/plain") || "";
|
||||
|
||||
/** Our own copies keep their formatting; other sites' don't. */
|
||||
if (!html || html.includes("data-pm-slice") || !text) return false;
|
||||
|
||||
return view.pasteText(text, event);
|
||||
},
|
||||
handleDrop: (_view, event) => {
|
||||
const files = [...(event.dataTransfer?.files || [])];
|
||||
|
||||
if (!files.length) return false;
|
||||
|
||||
event.preventDefault();
|
||||
latest.current.onFiles({ files });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
latest.current.onChange({ markdown: editorMarkdown({ editor }) });
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Picking a mention with the pointer leaves the menu's state behind if
|
||||
* the editor goes before the suggestion exits.
|
||||
*/
|
||||
useEffect(() => () => showMenu(null), [editor]);
|
||||
|
||||
/**
|
||||
* Replaces the content with a draft, without reporting it as a change.
|
||||
*/
|
||||
const setMarkdown = ({ markdown }: { markdown: string }) => {
|
||||
editor?.commands.setContent(markdown, { contentType: "markdown", emitUpdate: false });
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
editor?.commands.clearContent(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Puts dictated text at the cursor, spaced from what's around it.
|
||||
*/
|
||||
const insertText = ({ text }: { text: string }) => {
|
||||
if (!editor) return;
|
||||
|
||||
const { from, to } = editor.state.selection;
|
||||
const before = editor.state.doc.textBetween(Math.max(0, from - 1), from, "\n", " ");
|
||||
const after = editor.state.doc.textBetween(to, Math.min(editor.state.doc.content.size, to + 1), "\n", " ");
|
||||
const piece =
|
||||
(before && !/\s$/.test(before) ? " " : "") + text + (after && !/^\s/.test(after) ? " " : "");
|
||||
|
||||
editor.chain().focus().insertContent(piece).run();
|
||||
};
|
||||
|
||||
/**
|
||||
* Quotes text (one blockquote, a paragraph per line) at the cursor,
|
||||
* after any text selected in the editor (never over it), and leaves the
|
||||
* cursor in a fresh line after it.
|
||||
*/
|
||||
const insertQuote = ({ text }: { text: string }) => {
|
||||
if (!editor) return;
|
||||
|
||||
const lines = text.replace(/\r\n?/g, "\n").trim().split("\n");
|
||||
|
||||
if (!lines.join("").trim()) return;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setTextSelection(editor.state.selection.to)
|
||||
.insertContent([
|
||||
{
|
||||
type: "blockquote",
|
||||
content: lines.map((line) => ({
|
||||
type: "paragraph",
|
||||
content: line.trim() ? [{ type: "text", text: line }] : [],
|
||||
})),
|
||||
},
|
||||
{ type: "paragraph" },
|
||||
])
|
||||
.run();
|
||||
};
|
||||
|
||||
return { editor, menu, setMarkdown, clear, insertText, insertQuote };
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor's markdown with Tiptap's empty-paragraph ` ` dropped and
|
||||
* blank lines at the ends trimmed.
|
||||
*/
|
||||
function editorMarkdown({ editor }: { editor: Editor }) {
|
||||
if (editor.isEmpty) return "";
|
||||
|
||||
return editor
|
||||
.getMarkdown()
|
||||
.replace(/^ $/gm, "")
|
||||
.replace(/^\s*\n|\n\s*$/g, "")
|
||||
.trimEnd();
|
||||
}
|
||||
|
||||
/**
|
||||
* The editable area: the textarea's old type and growth (to 160px, then
|
||||
* it scrolls), with the chat's markdown look for what's formatted.
|
||||
*/
|
||||
const ComposerEditorClassName = [
|
||||
"min-h-5 max-h-[160px] overflow-y-auto outline-none text-sm leading-5 text-ink break-words",
|
||||
"[&_p]:m-0 [&_strong]:font-bold [&_em]:italic [&_s]:line-through [&_a]:underline [&_a]:underline-offset-2",
|
||||
"[&_code]:font-mono [&_code]:text-[13px] [&_code]:px-1 [&_code]:rounded-default-xs [&_code]:bg-sunken",
|
||||
"[&_pre]:my-1 [&_pre]:p-3 [&_pre]:rounded-default-sm [&_pre]:bg-sunken [&_pre]:whitespace-pre-wrap [&_pre_code]:p-0 [&_pre_code]:bg-transparent",
|
||||
"[&_ul]:my-0 [&_ul]:pl-5 [&_ul]:list-disc [&_ol]:my-0 [&_ol]:pl-5 [&_ol]:list-decimal",
|
||||
"[&_blockquote]:my-1 [&_blockquote]:pl-3 [&_blockquote]:border-0 [&_blockquote]:border-l-2 [&_blockquote]:border-solid [&_blockquote]:border-hairline-strong [&_blockquote]:text-ink-secondary",
|
||||
"[&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:text-ink-secondary",
|
||||
].join(" ");
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useEffect, useState, type RefObject } from "react";
|
||||
|
||||
/**
|
||||
* Text selected inside one message, and where it sits on screen.
|
||||
*/
|
||||
export type MessageSelection = {
|
||||
text: string;
|
||||
rect: DOMRect;
|
||||
};
|
||||
|
||||
/**
|
||||
* Attribute on a message's text, marking what can be quoted.
|
||||
*/
|
||||
export const QuotableAttribute = "data-quotable";
|
||||
|
||||
type Params = {
|
||||
ref: RefObject<HTMLElement | null>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The text the user has selected in a message of the thread under `ref`,
|
||||
* or null. A selection that runs across messages, or out of the thread,
|
||||
* doesn't count.
|
||||
*/
|
||||
export default function useMessageSelection({ ref }: Params) {
|
||||
const [selection, setSelection] = useState<MessageSelection | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const read = () => {
|
||||
const picked = window.getSelection();
|
||||
const text = picked?.toString().trim() || "";
|
||||
|
||||
if (!picked || picked.isCollapsed || !picked.rangeCount || !text) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = picked.getRangeAt(0);
|
||||
const quotable = (node: Node | null) =>
|
||||
(node instanceof Element ? node : node?.parentElement)?.closest(`[${QuotableAttribute}]`);
|
||||
const start = quotable(range.startContainer);
|
||||
|
||||
if (!start || start != quotable(range.endContainer) || !ref.current?.contains(start)) {
|
||||
setSelection(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setSelection({ text, rect: range.getBoundingClientRect() });
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", read);
|
||||
ref.current?.addEventListener("scroll", read, { passive: true });
|
||||
|
||||
const scroller = ref.current;
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", read);
|
||||
scroller?.removeEventListener("scroll", read);
|
||||
};
|
||||
}, [ref]);
|
||||
|
||||
return {
|
||||
selection,
|
||||
clear: () => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
setSelection(null);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
import MessageActions from "./message-actions";
|
||||
import MessageReactions from "./message-reactions";
|
||||
@@ -29,7 +30,7 @@ type Props = {
|
||||
* rendered). Computers it names (`computerRef`) show as buttons that
|
||||
* switch the bot to them. A quoted reply sits above it, its reactions
|
||||
* below, and a row under it replies, reacts, reads it aloud and shows
|
||||
* why it needs you.
|
||||
* why it needs you. Its text can be selected and quoted.
|
||||
*/
|
||||
export default function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
const { onReact } = useContext(BotMessageActionsContext);
|
||||
@@ -43,7 +44,10 @@ export default function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
bot_name={bot_name}
|
||||
/>
|
||||
) : null}
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Stack
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className="w-full gap-2.5! items-start break-words"
|
||||
>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={MessageMarkdownComponents}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EditorContent, type Editor } from "@tiptap/react";
|
||||
import ComposerFormatBubble from "./composer-format-bubble";
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
invalid?: boolean;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The composer's input (`useComposerEditor`), with the formatting bubble
|
||||
* over a selection. It fills the row between the attach and voice
|
||||
* buttons.
|
||||
*/
|
||||
export default function ComposerEditor({ editor, invalid, title }: Props) {
|
||||
return (
|
||||
<>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
aria-invalid={invalid ? true : undefined}
|
||||
title={title}
|
||||
className="grow min-w-0 self-center"
|
||||
/>
|
||||
{editor ? <ComposerFormatBubble editor={editor} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import ComposerFormatButton from "./composer-format-button";
|
||||
import { ComposerFormats } from "../(data)/composer-formats";
|
||||
|
||||
type Props = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formatting for the selected text, floating over it: bold, italic,
|
||||
* strike, code, link, quote and lists. The same marks have their usual
|
||||
* shortcuts (Mod+B, Mod+I, ...) and markdown typing (`**`, `- `, `> `).
|
||||
*/
|
||||
export default function ComposerFormatBubble({ editor }: Props) {
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={({ editor, from, to }) =>
|
||||
from != to && !editor.isActive("codeBlock") && !editor.isActive("mention")
|
||||
}
|
||||
>
|
||||
<Row className="gap-0.5! flex-nowrap items-center p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
|
||||
{ComposerFormats.map((format) => (
|
||||
<ComposerFormatButton
|
||||
key={format.name}
|
||||
title={format.title}
|
||||
active={editor.isActive(format.name)}
|
||||
onClick={() => format.toggle({ editor })}
|
||||
>
|
||||
<format.Icon size={15} aria-hidden />
|
||||
</ComposerFormatButton>
|
||||
))}
|
||||
</Row>
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* One formatting toggle in the composer's selection bubble.
|
||||
*/
|
||||
export default function ComposerFormatButton({ title, active, onClick, children }: Props) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
title={title}
|
||||
aria-label={title}
|
||||
aria-pressed={active}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onClick}
|
||||
className={`shrink-0 p-1.5! rounded-default-xs ${active ? "bg-sunken text-ink" : "text-ink-secondary hover:text-ink"}`}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useContext } from "react";
|
||||
import { NodeViewWrapper, type ReactNodeViewProps } from "@tiptap/react";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
|
||||
/**
|
||||
* A bot or workspace mentioned in the composer, shown like the chips in
|
||||
* the thread (`BotRefChip`, `WorkspaceRefChip`) but not a link. One the
|
||||
* user can't see any more keeps its ref's words.
|
||||
*/
|
||||
export default function ComposerMentionChip({ node, selected }: ReactNodeViewProps) {
|
||||
const bots = useContext(CrewBotsContext);
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const id = Number(node.attrs.id);
|
||||
const is_workspace = node.attrs.mentionSuggestionChar == "#";
|
||||
const bot = is_workspace ? undefined : bots.find((b) => b.id == id);
|
||||
const workspace = is_workspace ? workspaces.find((w) => w.id == id) : undefined;
|
||||
const name = bot?.name || workspace?.name || node.attrs.label || `${is_workspace ? "workspace" : "bot"} ${id}`;
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
as="span"
|
||||
data-mention=""
|
||||
className={`inline-flex h-6 gap-1.5 items-center px-1.5 mx-px align-middle rounded-default-xs bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap ${selected ? "ring-2 ring-ink" : ""}`}
|
||||
>
|
||||
{bot ? (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={18}
|
||||
/>
|
||||
) : null}
|
||||
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
|
||||
<Span>{name}</Span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import type { ComposerMentionMenu } from "../(hooks)/use-composer-editor";
|
||||
import ComposerMentionOption from "./composer-mention-option";
|
||||
|
||||
type Props = {
|
||||
menu: ComposerMentionMenu;
|
||||
};
|
||||
|
||||
/**
|
||||
* The bots (`@`) or workspaces (`#`) matching what's typed, just above
|
||||
* the composer's input. Up and Down move, Enter or Tab picks, Escape
|
||||
* closes; a click picks too.
|
||||
*/
|
||||
export default function ComposerMentionList({ menu }: Props) {
|
||||
return (
|
||||
<Stack
|
||||
role="listbox"
|
||||
aria-label={menu.char == "#" ? "Workspaces" : "Bots"}
|
||||
className="w-full max-w-container mx-auto gap-0.5! items-stretch px-3 md:px-6 pt-3"
|
||||
>
|
||||
<Stack className="gap-0.5! items-stretch p-1 rounded-default border border-solid border-hairline bg-surface shadow-popover">
|
||||
{menu.items.length ? (
|
||||
menu.items.map((item, index) => (
|
||||
<ComposerMentionOption
|
||||
key={item.id}
|
||||
item={item}
|
||||
active={index == menu.index}
|
||||
onPick={menu.select}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<Span className="px-2 py-1.5 text-xs leading-4 text-ink-secondary">
|
||||
{menu.char == "#" ? "No workspace by that name" : "No bot by that name"}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { ComposerMentionItem } from "../(utils)/search-mention-items";
|
||||
|
||||
type Props = {
|
||||
item: ComposerMentionItem;
|
||||
active: boolean;
|
||||
onPick: (item: ComposerMentionItem) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One bot or workspace in the mention menu, with its role or description.
|
||||
*/
|
||||
export default function ComposerMentionOption({ item, active, onPick }: Props) {
|
||||
const { bot, workspace } = item;
|
||||
const detail = bot?.role || workspace?.description || "";
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
title={item.label}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
onPick(item);
|
||||
}}
|
||||
className={`w-full justify-start! gap-2.5! px-2! py-1.5! rounded-default-sm text-left text-ink ${active ? "bg-sunken" : "hover:bg-sunken"}`}
|
||||
>
|
||||
{bot ? (
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
label={bot.name || ""}
|
||||
size={22}
|
||||
/>
|
||||
) : null}
|
||||
{workspace ? <WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" /> : null}
|
||||
<Span className="shrink-0 text-sm font-bold leading-5">{item.label}</Span>
|
||||
{detail ? (
|
||||
<Span className="min-w-0 truncate text-xs leading-4 text-ink-secondary">{detail}</Span>
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import type {
|
||||
BotExtensionsPayload,
|
||||
@@ -9,20 +8,34 @@ import type {
|
||||
BotVoicePayload,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
import sendBotMessage from "../(functions)/send-bot-message";
|
||||
import transcribeVoice from "../(functions)/transcribe-voice";
|
||||
import useComposerAttachments from "../(hooks)/use-composer-attachments";
|
||||
import useComposerDraft from "../(hooks)/use-composer-draft";
|
||||
import useAutoGrowTextarea from "../(hooks)/use-auto-grow-textarea";
|
||||
import useComposerEditor from "../(hooks)/use-composer-editor";
|
||||
import useVoiceRecorder from "../(hooks)/use-voice-recorder";
|
||||
import useOnlineStatus from "@/src/hooks/use-online-status";
|
||||
import ComposerAttachButton from "./composer-attach-button";
|
||||
import ComposerAttachments from "./composer-attachments";
|
||||
import ComposerEditor from "./composer-editor";
|
||||
import ComposerMentionList from "./composer-mention-list";
|
||||
import ComposerReplyPreview from "./composer-reply-preview";
|
||||
import ComposerSendButton from "./composer-send-button";
|
||||
import ComposerTools from "./composer-tools";
|
||||
import ComposerVoiceButton from "./composer-voice-button";
|
||||
|
||||
/**
|
||||
* What the conversation can do to the composer from outside it.
|
||||
*/
|
||||
export type ComposerHandle = {
|
||||
/**
|
||||
* Quotes text from the thread at the cursor.
|
||||
*/
|
||||
insertQuote: (params: { text: string }) => void;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
@@ -43,10 +56,13 @@ type Props = {
|
||||
reply_to?: BotMessageWithAttachments | null;
|
||||
onCancelReply?: () => void;
|
||||
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
||||
handle_ref?: Ref<ComposerHandle>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Enter sends, Shift+Enter adds a line. Grows to eight lines, then scrolls.
|
||||
* A Tiptap editor (`useComposerEditor`) writing markdown: Enter sends,
|
||||
* Shift+Enter adds a line, a selection can be formatted, `@` mentions a
|
||||
* bot and `#` a workspace. Grows to eight lines, then scrolls.
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
* just above it. A click anywhere on the row focuses the input. Files can
|
||||
* also be pasted or dropped in. Unsent text is kept per conversation
|
||||
@@ -62,21 +78,53 @@ export default function Composer({
|
||||
reply_to,
|
||||
onCancelReply,
|
||||
onSent,
|
||||
handle_ref,
|
||||
}: Props) {
|
||||
const { text, setText } = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||
const draft = useComposerDraft({ bot_id: bot.id, workspace_id });
|
||||
const { text, setText } = draft;
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const files = useComposerAttachments({ bot_id: bot.id, workspace_id });
|
||||
const online = useOnlineStatus();
|
||||
const input_ref = useRef<HTMLTextAreaElement>(null);
|
||||
const bots = useContext(CrewBotsContext);
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const send_ref = useRef<() => void>(() => {});
|
||||
|
||||
useAutoGrowTextarea({ ref: input_ref, value: text });
|
||||
const input = useComposerEditor({
|
||||
placeholder: `Message ${bot.name}`,
|
||||
bots,
|
||||
workspaces,
|
||||
onChange: ({ markdown }) => setText(markdown),
|
||||
onSubmit: () => send_ref.current(),
|
||||
onFiles: files.add,
|
||||
});
|
||||
const editor = input.editor;
|
||||
|
||||
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
|
||||
|
||||
/**
|
||||
* The saved draft goes into the editor once per conversation, after
|
||||
* it's read; typing saves it back from then on.
|
||||
*/
|
||||
const applied_key = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !draft.loaded || applied_key.current == draft.key) return;
|
||||
|
||||
applied_key.current = draft.key;
|
||||
|
||||
/** Out of React's render, since mention chips mount as it loads. */
|
||||
queueMicrotask(() => {
|
||||
input.setMarkdown({ markdown: text });
|
||||
editor.commands.focus("end");
|
||||
});
|
||||
}, [editor, draft.loaded, draft.key]);
|
||||
|
||||
/**
|
||||
* Picking a message to reply to puts the cursor back in the input.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (reply_to) input_ref.current?.focus();
|
||||
if (reply_to) editor?.commands.focus();
|
||||
}, [reply_to]);
|
||||
|
||||
const recorder = useVoiceRecorder({
|
||||
@@ -90,7 +138,7 @@ export default function Composer({
|
||||
return;
|
||||
}
|
||||
|
||||
insertText({ insert: res.singleRes.text });
|
||||
input.insertText({ text: res.singleRes.text });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -101,30 +149,6 @@ export default function Composer({
|
||||
recorder.state == "idle" &&
|
||||
online;
|
||||
|
||||
/**
|
||||
* Puts dictated text at the cursor (or the end), spaced from what's
|
||||
* around it, and leaves the cursor after it.
|
||||
*/
|
||||
function insertText({ insert }: { insert: string }) {
|
||||
const el = input_ref.current;
|
||||
const start = el ? el.selectionStart : text.length;
|
||||
const end = el ? el.selectionEnd : text.length;
|
||||
const before = text.slice(0, start);
|
||||
const after = text.slice(end);
|
||||
const piece =
|
||||
(before && !/\s$/.test(before) ? " " : "") +
|
||||
insert +
|
||||
(after && !/^\s/.test(after) ? " " : "");
|
||||
const cursor = before.length + piece.length;
|
||||
|
||||
setText(before + piece + after);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
input_ref.current?.focus();
|
||||
input_ref.current?.setSelectionRange(cursor, cursor);
|
||||
});
|
||||
}
|
||||
|
||||
async function send() {
|
||||
if (!can_send || !bot.id) return;
|
||||
|
||||
@@ -147,18 +171,12 @@ export default function Composer({
|
||||
}
|
||||
|
||||
setText("");
|
||||
input.clear();
|
||||
files.clear();
|
||||
onSent({ message: res.singleRes });
|
||||
}
|
||||
|
||||
function handleKeyDown(
|
||||
e: KeyboardEvent<HTMLTextAreaElement | HTMLInputElement>,
|
||||
) {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
}
|
||||
send_ref.current = send;
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -167,7 +185,8 @@ export default function Composer({
|
||||
if (e.dataTransfer.types.includes("Files")) e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
if (!e.dataTransfer.files.length) return;
|
||||
/** Dropped on the editor, which took the files already. */
|
||||
if (e.defaultPrevented || !e.dataTransfer.files.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
files.add({ files: [...e.dataTransfer.files] });
|
||||
@@ -185,49 +204,22 @@ export default function Composer({
|
||||
onCancel={onCancelReply}
|
||||
/>
|
||||
) : null}
|
||||
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
|
||||
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
if (target.closest("button, textarea")) return;
|
||||
if (target.closest("button, [contenteditable]")) return;
|
||||
|
||||
e.preventDefault();
|
||||
input_ref.current?.focus();
|
||||
editor?.commands.focus();
|
||||
}}
|
||||
>
|
||||
<ComposerAttachButton
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<Textarea
|
||||
name="message"
|
||||
componentRef={input_ref}
|
||||
aria-label={`Message ${bot.name}`}
|
||||
placeholder={`Message ${bot.name}`}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={(e) => {
|
||||
const pasted = [...e.clipboardData.files];
|
||||
|
||||
if (!pasted.length) return;
|
||||
|
||||
e.preventDefault();
|
||||
files.add({ files: pasted });
|
||||
}}
|
||||
aria-invalid={error ? true : undefined}
|
||||
title={error}
|
||||
className="field-sizing-content min-h-5 max-h-[160px] resize-none overflow-y-auto text-sm leading-5 text-ink placeholder:text-ink-secondary"
|
||||
wrapperWrapperProps={{
|
||||
className: "grow min-w-0 self-center z-auto! hover:z-auto!",
|
||||
}}
|
||||
wrapperProps={{
|
||||
className:
|
||||
"p-0! outline-0! border-0! bg-transparent! dark:bg-transparent! rounded-none",
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
|
||||
{recorder.supported ? (
|
||||
<ComposerVoiceButton
|
||||
state={recorder.state}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import { ComputerViewModes, type ComputerViewMode } from "@/src/dict/computer-dict";
|
||||
import { BotRefHrefPrefix, ComputerRefHrefPrefix, TakeOverRefHrefPrefix } from "@/src/utils/link-computer-refs";
|
||||
import {
|
||||
BotRefHrefPrefix,
|
||||
ComputerRefHrefPrefix,
|
||||
TakeOverRefHrefPrefix,
|
||||
WorkspaceRefHrefPrefix,
|
||||
} from "@/src/utils/link-computer-refs";
|
||||
import BotRefChip from "./bot-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
import TakeOverChip from "./take-over-chip";
|
||||
import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
|
||||
/**
|
||||
* A link in a bot message. A computer the bot named (`linkComputerRefs`)
|
||||
* becomes its switch button, a take-over request a button that opens the
|
||||
* computer, a bot it named a link to that bot's conversation; anything else opens in a new tab.
|
||||
* computer, a bot it named a link to that bot's conversation, a workspace
|
||||
* a link to that workspace; anything else opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (href?.startsWith(ComputerRefHrefPrefix)) {
|
||||
@@ -39,6 +46,12 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (bot_id) return <BotRefChip bot_id={bot_id} />;
|
||||
}
|
||||
|
||||
if (href?.startsWith(WorkspaceRefHrefPrefix)) {
|
||||
const workspace_id = Number(href.slice(WorkspaceRefHrefPrefix.length));
|
||||
|
||||
if (workspace_id) return <WorkspaceRefChip workspace_id={workspace_id} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { Children, isValidElement, type ReactNode } from "react";
|
||||
import List from "@moduletrace/twui/layout/List";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
children?: ReactNode;
|
||||
ordered?: boolean;
|
||||
start?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A list in a bot message, through twui's `List` (which takes items, not
|
||||
* `li` children): each item's content is lifted out of its `li`.
|
||||
*/
|
||||
export default function MarkdownList({ children, ordered, start }: Props) {
|
||||
export default function MarkdownList({ children, ordered, start, className }: Props) {
|
||||
const items = Children.toArray(children)
|
||||
.filter(isValidElement)
|
||||
.map((item) => ({
|
||||
@@ -23,7 +25,10 @@ export default function MarkdownList({ children, ordered, start }: Props) {
|
||||
ordered={ordered}
|
||||
start={start}
|
||||
items={items}
|
||||
className={`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`}
|
||||
className={twMerge(
|
||||
`w-full gap-1! pl-5 my-0 text-sm leading-5 text-ink ${ordered ? "list-decimal" : "list-disc"}`,
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useRef, type RefObject } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type {
|
||||
BotMessageWithAttachments,
|
||||
BotWithLastMessage,
|
||||
} from "@/src/types";
|
||||
import useMessageSelection from "../(hooks)/use-message-selection";
|
||||
import groupMessages from "../(utils)/group-messages";
|
||||
import BotThinkingRow from "./bot-thinking-row";
|
||||
import MessageGroupBlock from "./message-group-block";
|
||||
import QuoteSelectionButton from "./quote-selection-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -22,19 +25,27 @@ type Props = {
|
||||
* This session has an active task.
|
||||
*/
|
||||
has_task?: boolean;
|
||||
/**
|
||||
* Quotes text selected in a message in the composer.
|
||||
*/
|
||||
onQuote?: (params: { text: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Bottom-anchored: the stack sits 24px above the composer rule and grows
|
||||
* upward. Empty space belongs at the top. New messages announce politely.
|
||||
* While the bot works on a reply or a task, a status row sits under the
|
||||
* last one.
|
||||
* last one. Text selected in a message can be quoted in the composer.
|
||||
*/
|
||||
export default function MessageThread({ bot, messages, progress, reply_progress, has_task }: Props) {
|
||||
export default function MessageThread({ bot, messages, progress, reply_progress, has_task, onQuote }: Props) {
|
||||
const groups = groupMessages({ messages });
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { selection, clear } = useMessageSelection({ ref });
|
||||
|
||||
return (
|
||||
<Stack className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
|
||||
<Stack
|
||||
componentRef={ref as RefObject<HTMLDivElement>}
|
||||
className="grow min-h-0 gap-0! items-stretch overflow-y-auto flex-col-reverse">
|
||||
<Stack
|
||||
role="log"
|
||||
aria-live="polite"
|
||||
@@ -56,6 +67,15 @@ export default function MessageThread({ bot, messages, progress, reply_progress,
|
||||
has_task={has_task}
|
||||
/>
|
||||
</Stack>
|
||||
{selection && onQuote ? (
|
||||
<QuoteSelectionButton
|
||||
selection={selection}
|
||||
onQuote={() => {
|
||||
onQuote({ text: selection.text });
|
||||
clear();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { computePosition, flip, offset, shift } from "@floating-ui/dom";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Quote } from "lucide-react";
|
||||
import type { MessageSelection } from "../(hooks)/use-message-selection";
|
||||
|
||||
type Props = {
|
||||
selection: MessageSelection;
|
||||
onQuote: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Quote", floating over text selected in a message: it puts the text in
|
||||
* the composer as a quote. Pressing it keeps the selection, so the text
|
||||
* it quotes is the one shown.
|
||||
*/
|
||||
export default function QuoteSelectionButton({ selection, onQuote }: Props) {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el) return;
|
||||
|
||||
computePosition({ getBoundingClientRect: () => selection.rect }, el, {
|
||||
strategy: "fixed",
|
||||
placement: "top",
|
||||
middleware: [offset(8), flip(), shift({ padding: 8 })],
|
||||
}).then(({ x, y }) => {
|
||||
el.style.left = `${x}px`;
|
||||
el.style.top = `${y}px`;
|
||||
});
|
||||
}, [selection.rect]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
type="button"
|
||||
color="secondary"
|
||||
title="Quote in your message"
|
||||
onPointerDown={(e) => e.preventDefault()}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={onQuote}
|
||||
className="fixed left-0 top-0 z-50 gap-1.5! px-2.5! py-1.5! rounded-default-sm border border-solid border-hairline bg-surface shadow-popover text-xs font-bold text-ink hover:bg-sunken"
|
||||
>
|
||||
<Quote size={13} aria-hidden />
|
||||
Quote
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { UserMarkdownComponents } from "../(data)/user-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
|
||||
/**
|
||||
* Ink bubble with paper text; inverts in dark mode.
|
||||
* Ink bubble with paper text; inverts in dark mode. The text is markdown
|
||||
* (GFM; raw HTML is never rendered) as the composer writes it, a single
|
||||
* line break kept as one, so older plain messages read as typed. Bots
|
||||
* and workspaces it mentions show as chips.
|
||||
*/
|
||||
export default function UserBubble({ text }: { text: string }) {
|
||||
return (
|
||||
<P noMargin className="max-w-[80%] rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 whitespace-pre-wrap break-words">
|
||||
{text}
|
||||
</P>
|
||||
<Stack
|
||||
{...{ [QuotableAttribute]: "" }}
|
||||
className="max-w-[80%] min-w-0 gap-2.5! items-stretch rounded-default-lg bg-ink text-on-ink px-4 py-3 text-sm leading-5 break-words"
|
||||
>
|
||||
<Markdown remarkPlugins={[remarkGfm, remarkBreaks]} components={UserMarkdownComponents}>
|
||||
{linkComputerRefs({ text })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import MarkdownLink from "./markdown-link";
|
||||
|
||||
/**
|
||||
* A link in the user's own message: refs (bots, workspaces) as their
|
||||
* chips (`MarkdownLink`), anything else underlined in the bubble's ink.
|
||||
*/
|
||||
export default function UserMarkdownLink(props: ComponentProps<"a">) {
|
||||
if (props.href?.startsWith("#")) return <MarkdownLink {...props} />;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={props.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-on-ink underline underline-offset-2 hover:opacity-80 break-words"
|
||||
>
|
||||
{props.children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useContext } from "react";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace named in a message (`workspaceRef`): a link to its
|
||||
* dashboard. One the user isn't in is shown by name only.
|
||||
*/
|
||||
export default function WorkspaceRefChip({ workspace_id }: Props) {
|
||||
const workspaces = useContext(CrewWorkspacesContext);
|
||||
const workspace = workspaces.find((w) => w.id == workspace_id);
|
||||
|
||||
if (!workspace) {
|
||||
return <Span className="font-bold">{`workspace ${workspace_id}`}</Span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/workspaces/${workspace.id}`}
|
||||
title={`Open ${workspace.name}`}
|
||||
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
>
|
||||
<WorkspaceAvatar image_media_id={workspace.image_media_id} size="xs" />
|
||||
{workspace.name}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { useContext, useEffect, useRef, useState } from "react";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageReactionEmoji } from "@/src/dict/bot-dict";
|
||||
import type {
|
||||
@@ -9,8 +9,10 @@ import { AppContext } from "@/src/pages/__root";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import useLiveBots from "@/src/hooks/use-live-bots";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import useLiveWorkspaces from "@/src/layouts/bots/(hooks)/use-live-workspaces";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import { CrewBotsContext } from "../(data)/crew-bots-context";
|
||||
import { CrewWorkspacesContext } from "../(data)/crew-workspaces-context";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { SpeakMessageContext } from "../(data)/speak-message-context";
|
||||
import reactToBotMessage from "../(functions)/react-to-bot-message";
|
||||
@@ -21,7 +23,7 @@ import useReplyCatchUp from "../(hooks)/use-reply-catch-up";
|
||||
import useSpeakMessage from "../(hooks)/use-speak-message";
|
||||
import ConversationHeader from "../(partials)/conversation-header";
|
||||
import MessageThread from "../(partials)/message-thread";
|
||||
import Composer from "../(partials)/composer";
|
||||
import Composer, { type ComposerHandle } from "../(partials)/composer";
|
||||
import ComputerViewerModal from "../(partials)/computer-viewer-modal";
|
||||
|
||||
/**
|
||||
@@ -31,7 +33,8 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
|
||||
* The bot's computers are shared with the header's modal and the computer
|
||||
* buttons in the thread, so a switch from either shows in both; either
|
||||
* can open one in the viewer. Bot messages can be read aloud, one at a
|
||||
* time.
|
||||
* time. Text selected in the thread can be quoted in the composer, which
|
||||
* mentions the crew and workspaces the user sees.
|
||||
*/
|
||||
export default function Conversation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -59,6 +62,11 @@ export default function Conversation() {
|
||||
workspace_id,
|
||||
});
|
||||
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
|
||||
const workspaces = useLiveWorkspaces({
|
||||
workspaces: pageProps?.workspaces || [],
|
||||
open_workspace_id: workspace_id || undefined,
|
||||
});
|
||||
const composer = useRef<ComposerHandle>(null);
|
||||
const bot_computers = useBotComputers({
|
||||
bot_id: bot?.id || 0,
|
||||
workspace_id,
|
||||
@@ -158,6 +166,7 @@ export default function Conversation() {
|
||||
|
||||
return (
|
||||
<CrewBotsContext.Provider value={bots}>
|
||||
<CrewWorkspacesContext.Provider value={workspaces}>
|
||||
<BotComputersContext.Provider value={bot_computers}>
|
||||
<SpeakMessageContext.Provider value={speak}>
|
||||
<BotMessageActionsContext.Provider
|
||||
@@ -179,8 +188,10 @@ export default function Conversation() {
|
||||
progress={progress.text}
|
||||
reply_progress={reply_progress}
|
||||
has_task={Boolean(progress.task_id)}
|
||||
onQuote={({ text }) => composer.current?.insertQuote({ text })}
|
||||
/>
|
||||
<Composer
|
||||
handle_ref={composer}
|
||||
bot={bot}
|
||||
workspace_id={workspace_id}
|
||||
extensions={pageProps?.bot_extensions}
|
||||
@@ -208,6 +219,7 @@ export default function Conversation() {
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
</SpeakMessageContext.Provider>
|
||||
</BotComputersContext.Provider>
|
||||
</CrewWorkspacesContext.Provider>
|
||||
</CrewBotsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import { MarkdownManager } from "@tiptap/markdown";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { MentionMarkdown, mentionRef } from "./mention-markdown";
|
||||
|
||||
const markdown = new MarkdownManager({
|
||||
extensions: [StarterKit.configure({ heading: false }), Mention.extend(MentionMarkdown)],
|
||||
});
|
||||
|
||||
const roundTrip = (text: string) => markdown.serialize(markdown.parse(text)).trim();
|
||||
|
||||
describe("mention markdown", () => {
|
||||
test("reads bot and workspace refs as mentions", () => {
|
||||
const doc = markdown.parse("Ask [bot:3] about [workspace:2]");
|
||||
const nodes = doc.content?.[0]?.content || [];
|
||||
const mentions = nodes.filter((node) => node.type == "mention");
|
||||
|
||||
expect(mentions.map((node) => [node.attrs?.id, node.attrs?.mentionSuggestionChar])).toEqual([
|
||||
["3", "@"],
|
||||
["2", "#"],
|
||||
]);
|
||||
});
|
||||
|
||||
test("writes them back as refs, marks staying on the text around them", () => {
|
||||
expect(roundTrip("Ask [bot:3] about **[workspace:2] now**")).toBe("Ask [bot:3] about [workspace:2] **now**");
|
||||
expect(roundTrip("_Ask_ [bot:3] `soon`")).toBe("*Ask* [bot:3] `soon`");
|
||||
expect(roundTrip("> quoted\n\nreply to [bot:4]")).toBe("> quoted\n\nreply to [bot:4]");
|
||||
});
|
||||
|
||||
test("leaves markdown links alone", () => {
|
||||
const doc = markdown.parse("[bot:3](https://example.com)");
|
||||
const nodes = doc.content?.[0]?.content || [];
|
||||
|
||||
expect(nodes.some((node) => node.type == "mention")).toBe(false);
|
||||
});
|
||||
|
||||
test("mentionRef defaults to a bot", () => {
|
||||
expect(mentionRef({ id: 5, char: "#" })).toBe("[workspace:5]");
|
||||
expect(mentionRef({ id: 5 })).toBe("[bot:5]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { JSONContent, MarkdownParseHelpers, MarkdownToken } from "@tiptap/core";
|
||||
import {
|
||||
composerMentionChar,
|
||||
ComposerMentionKinds,
|
||||
type ComposerMentionChar,
|
||||
} from "../(data)/composer-mention-kinds";
|
||||
|
||||
const RefPattern = /^\[(bot|workspace):(\d+)\](?!\()/;
|
||||
|
||||
/**
|
||||
* How the composer's mention node reads and writes markdown: a bot as
|
||||
* `[bot:3]` (`botRef`), a workspace as `[workspace:2]` (`workspaceRef`),
|
||||
* so a draft or a sent message holds the same refs bots write. A ref
|
||||
* followed by `(` is a markdown link, not a mention.
|
||||
*/
|
||||
export const MentionMarkdown = {
|
||||
markdownTokenizer: {
|
||||
name: "mention",
|
||||
level: "inline" as const,
|
||||
start(src: string) {
|
||||
const match = src.match(/\[(bot|workspace):\d+\](?!\()/);
|
||||
|
||||
return match?.index ?? -1;
|
||||
},
|
||||
tokenize(src: string) {
|
||||
const match = src.match(RefPattern);
|
||||
|
||||
if (!match) return undefined;
|
||||
|
||||
const kind = match[1] as "bot" | "workspace";
|
||||
|
||||
return {
|
||||
type: "mention",
|
||||
raw: match[0],
|
||||
attributes: { id: match[2], mentionSuggestionChar: composerMentionChar({ kind }) },
|
||||
};
|
||||
},
|
||||
},
|
||||
|
||||
parseMarkdown(token: MarkdownToken, h: MarkdownParseHelpers) {
|
||||
return h.createNode("mention", { ...token.attributes, label: null });
|
||||
},
|
||||
|
||||
renderMarkdown(node: JSONContent) {
|
||||
return mentionRef({
|
||||
id: node.attrs?.id,
|
||||
char: node.attrs?.mentionSuggestionChar,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The ref a mention writes, `[bot:3]` unless it's a workspace's.
|
||||
*/
|
||||
export function mentionRef({ id, char }: { id?: string | number | null; char?: string | null }) {
|
||||
const kind = ComposerMentionKinds[(char || "@") as ComposerMentionChar] || "bot";
|
||||
|
||||
return `[${kind}:${id}]`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
|
||||
import searchMentionItems from "./search-mention-items";
|
||||
|
||||
const bots = [
|
||||
{ id: 1, name: "Cobble" },
|
||||
{ id: 2, name: "Researcher" },
|
||||
{ id: 3, name: "Archive reader" },
|
||||
] as BotWithLastMessage[];
|
||||
|
||||
const workspaces = [
|
||||
{ id: 1, name: "Launch", unread: 0 },
|
||||
{ id: 2, name: "Old launch", archived: 1, unread: 0 },
|
||||
] as WorkspaceWithActivity[];
|
||||
|
||||
describe("searchMentionItems", () => {
|
||||
test("puts names starting with the query first", () => {
|
||||
const items = searchMentionItems({ kind: "bot", query: "r", bots, workspaces });
|
||||
|
||||
expect(items.map((item) => item.id)).toEqual([2, 3]);
|
||||
});
|
||||
|
||||
test("offers every bot for an empty query", () => {
|
||||
expect(searchMentionItems({ kind: "bot", query: "", bots, workspaces })).toHaveLength(3);
|
||||
});
|
||||
|
||||
test("leaves archived workspaces out", () => {
|
||||
const items = searchMentionItems({ kind: "workspace", query: "launch", bots, workspaces });
|
||||
|
||||
expect(items.map((item) => item.label)).toEqual(["Launch"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
|
||||
import type { ComposerMentionKind } from "../(data)/composer-mention-kinds";
|
||||
|
||||
/**
|
||||
* One thing the mention menu offers.
|
||||
*/
|
||||
export type ComposerMentionItem = {
|
||||
id: number;
|
||||
label: string;
|
||||
bot?: BotWithLastMessage;
|
||||
workspace?: WorkspaceWithActivity;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
kind: ComposerMentionKind;
|
||||
query: string;
|
||||
bots: BotWithLastMessage[];
|
||||
workspaces: WorkspaceWithActivity[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The bots or workspaces whose name has what was typed, names starting
|
||||
* with it first, at most `AppData.ComposerMentionMaxItems`.
|
||||
*/
|
||||
export default function searchMentionItems({ kind, query, bots, workspaces }: Params): ComposerMentionItem[] {
|
||||
const items: ComposerMentionItem[] =
|
||||
kind == "bot"
|
||||
? bots
|
||||
.filter((bot) => bot.id && bot.name)
|
||||
.map((bot) => ({ id: Number(bot.id), label: String(bot.name), bot }))
|
||||
: workspaces
|
||||
.filter((workspace) => workspace.id && workspace.name && !workspace.archived)
|
||||
.map((workspace) => ({ id: Number(workspace.id), label: String(workspace.name), workspace }));
|
||||
|
||||
const q = query.trim().toLowerCase();
|
||||
|
||||
return items
|
||||
.map((item) => ({ item, at: item.label.toLowerCase().indexOf(q) }))
|
||||
.filter(({ at }) => at >= 0)
|
||||
.sort((a, b) => Number(a.at != 0) - Number(b.at != 0) || a.item.label.localeCompare(b.item.label))
|
||||
.slice(0, AppData.ComposerMentionMaxItems)
|
||||
.map(({ item }) => item);
|
||||
}
|
||||
Reference in New Issue
Block a user