// @vitest-environment jsdom /** * The composer triggers (CON-27, moved from Peek's `mention.tsx` and * `slashCommands.tsx`): each opens its list at the caret, and a pick inserts * the chip the body is written from. */ import { act } from 'react' import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest' import { render, screen, cleanup, fireEvent, within } from '@testing-library/react' import type { Editor } from '@tiptap/core' import { EditorContent, useEditor } from '@tiptap/react' import StarterKit from '@tiptap/starter-kit' import { CaptionedReference, PersonMention, ReferenceTrigger, SlashCommands, UrgentPersonMention, filterPeople, filterReferences, filterSlashSections, formatSection, typeTrigger, urgentPubkeys, type CaptionedItem, type MentionPerson, type SlashSection, } from './ComposerTriggers' import { isSuggestionActive, isSuggestionOpen } from './suggestionPopup' import { Callout, Checklist } from './BlockNodes' const KEY = 'b'.repeat(64) const PEOPLE: MentionPerson[] = [ { id: 'ada', name: 'Ada Lovelace', description: 'Engineering', pubkey: KEY }, { id: 'bea', name: 'Bea', pubkey: null }, ...['Cy', 'Di', 'Ed', 'Flo', 'Gus'].map((name) => ({ id: name.toLowerCase(), name })), ] const ITEMS: CaptionedItem[] = Array.from({ length: 8 }, (_, i) => ({ id: `${i}`.repeat(64), label: i === 7 ? 'Bea' : 'Ada', snippet: `line ${i}`, uri: `nostr:nevent1example${i}`, search: `**line** ${i}`, })) const APP_SECTION: SlashSection = { label: 'Shortcuts', commands: [{ key: 'shortcut-@', row: { label: 'Mention', shortcut: '@' }, keywords: ['@', 'Mention a person'], run: typeTrigger('@') }], } // An app's own name for the chip, as Peek's `messageMention`. const Chip = CaptionedReference.extend({ name: 'messageMention' }) let editor: Editor | null = null function Composer() { const made = useEditor({ extensions: [ StarterKit, PersonMention.configure({ people: () => PEOPLE }), UrgentPersonMention.configure({ people: () => PEOPLE }), Chip, ReferenceTrigger.configure({ items: () => ITEMS, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things' }), SlashCommands.configure({ sections: [formatSection(), APP_SECTION] }), ], }) editor = made return } async function type(text: string) { await act(async () => { editor!.chain().focus().insertContent(text).run() }) } async function press(key: string) { await act(async () => { editor!.view.someProp('handleKeyDown', (handle) => handle(editor!.view, new KeyboardEvent('keydown', { key }))) }) } // The highlighted row also draws its Enter hint; the label is what is compared. const options = () => within(screen.getByRole('listbox')).getAllByRole('option').map((one) => one.textContent?.replace('↩ Enter', '')) const nodes = () => { const found: { type: string; attrs: Record }[] = [] editor!.state.doc.descendants((node) => { if (node.isInline && !node.isText) found.push({ type: node.type.name, attrs: node.attrs }) }) return found } // jsdom lays nothing out, so it has no `scrollIntoView`; the highlighted row calls it. // Nor `getClientRects`, which the editor asks for when it scrolls to the caret, after the call has returned. beforeAll(() => { Element.prototype.scrollIntoView = vi.fn() Element.prototype.getClientRects = () => [] as unknown as DOMRectList // …and a caret in text is measured through a Range. Range.prototype.getClientRects = () => [] as unknown as DOMRectList Range.prototype.getBoundingClientRect = () => new DOMRect() ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true }) afterEach(() => { editor?.destroy() editor = null cleanup() }) describe('@ and !@', () => { it('lists the first six people, narrowed by name, and a pick inserts a chip with their key', async () => { render() await type('@') expect(screen.getByRole('listbox', { name: 'People' })).toBeTruthy() expect(options()).toHaveLength(6) await type('ada') expect(options()).toHaveLength(1) await press('Enter') expect(nodes()).toEqual([{ type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } }]) expect(editor!.getHTML()).toContain('data-mention="true"') expect(screen.queryByRole('listbox')).toBeNull() }) it('opens the urgent list on !@, and the document reports whose key it names', async () => { render() await type('!@') expect(screen.getByRole('listbox', { name: 'Urgent mention' })).toBeTruthy() await press('Enter') expect(nodes()[0]).toEqual({ type: 'urgentMention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } }) expect(urgentPubkeys(editor!)).toEqual([KEY]) }) it('keeps a person with no key, who has nothing to carry urgently', () => { expect(filterPeople(PEOPLE, 'BE')).toEqual([PEOPLE[1]]) expect(filterPeople(PEOPLE, '')).toHaveLength(6) }) }) describe('[', () => { it('lists the last six references and inserts the app-named chip with its uri', async () => { render() await type('[') expect(screen.getByRole('listbox', { name: 'Things' })).toBeTruthy() expect(options()).toEqual(['Ada: line 2', 'Ada: line 3', 'Ada: line 4', 'Ada: line 5', 'Ada: line 6', 'Bea: line 7']) await press('Enter') expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[2].id, label: 'Ada', snippet: 'line 2', uri: ITEMS[2].uri } }]) expect(editor!.getHTML()).toContain('data-message-mention="true"') }) it('gives each row the icon `iconFor` answers for its uri; a row’s own icon wins, and no answer keeps the list’s', async () => { const rows = [ { ...ITEMS[0], uri: 'nostr:a' }, { ...ITEMS[1], uri: 'nostr:b', icon: }, { ...ITEMS[2], uri: 'nostr:c' }, ] function Iconed() { const made = useEditor({ extensions: [ StarterKit, Chip, ReferenceTrigger.configure({ items: () => rows, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things', icon: , iconFor: (uri) => (uri === 'nostr:a' ? : undefined), }), ], }) editor = made return } render() await type('[') const icons = within(screen.getByRole('listbox')).getAllByRole('option').map((row) => row.querySelector('[data-icon]')?.getAttribute('data-icon')) expect(icons).toEqual(['kind-a', 'own', 'list']) }) it('draws a summary as the second line, with where the thing sits beside the label; without one, the row is as before (SUM-7)', async () => { const rows = [ { id: 's', label: 'Ship launch', snippet: '', uri: 'nostr:s', description: 'SHI-12 · Ship 1', summary: 'What we show on Friday' }, { id: 'n', label: 'Old plan', snippet: '', uri: 'nostr:n', description: 'SHI-3 · Ship 1' }, ] function Summarised() { const made = useEditor({ extensions: [StarterKit, Chip, ReferenceTrigger.configure({ items: () => rows, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things' })], }) editor = made return } render() await type('[') const [withSummary, without] = within(screen.getByRole('listbox')).getAllByRole('option') // The line above a row's second line, as a person reads it. const lineAbove = (row: HTMLElement, second: string) => within(row).getByText(second).previousElementSibling?.textContent expect(lineAbove(withSummary, 'What we show on Friday')).toBe('Ship launchSHI-12 · Ship 1') expect(lineAbove(without, 'SHI-3 · Ship 1')).toBe('Old plan') }) it('draws the app’s sections in order, skips an empty one, and redraws the open list when told', async () => { const listeners = new Set<() => void>() let late: CaptionedItem[] = [] const sections = (query: string) => [ { label: 'Recent', items: ITEMS.slice(0, 2).filter((item) => item.snippet.includes(query)) }, { label: 'Empty', items: [] }, { label: 'Found', items: late }, ] function Sectioned() { const made = useEditor({ extensions: [ StarterKit, Chip, ReferenceTrigger.configure({ items: () => [], sections, subscribe: (listener) => { listeners.add(listener) return () => listeners.delete(listener) }, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things', }), ], }) editor = made return } render() await type('[') expect(options()).toEqual(['Ada: line 0', 'Ada: line 1']) expect(screen.getByText('Recent')).toBeTruthy() expect(screen.queryByText('Empty')).toBeNull() expect(listeners.size).toBe(1) // The person arrows to a row, then a late answer lands: their row stays highlighted. await press('ArrowDown') late = [{ id: 'f'.repeat(64), label: 'Plan', snippet: 'Q3 roadmap', uri: 'nostr:naddr1example', description: 'Planning' }] await act(async () => listeners.forEach((listener) => listener())) expect(options()).toEqual(['Ada: line 0', 'Ada: line 1', 'Plan: Q3 roadmapPlanning']) expect(screen.getByText('Found')).toBeTruthy() await press('Enter') expect(nodes().map((node) => node.attrs.id)).toEqual([ITEMS[1].id]) expect(listeners.size).toBe(0) // A row that arrived late is picked like any other. await type('[') await press('ArrowDown') await press('ArrowDown') await press('Enter') expect(nodes()[1]).toEqual({ type: 'messageMention', attrs: { id: 'f'.repeat(64), label: 'Plan', snippet: 'Q3 roadmap', uri: 'nostr:naddr1example' } }) await type('[') expect(listeners.size).toBe(1) await press('Escape') expect(screen.queryByRole('listbox')).toBeNull() // Escape closes the list and stops listening. expect(listeners.size).toBe(0) }) it('draws a row’s own icon in place of the list’s, and the chip its `iconFor` answer (7 October)', async () => { const own = [ { id: 'a'.repeat(64), label: 'Plan', snippet: '', uri: 'nostr:naddr1plan', icon: }, { id: 'b'.repeat(64), label: 'Note', snippet: '', uri: 'nostr:naddr1note' }, ] function Kinds() { const made = useEditor({ extensions: [ StarterKit, CaptionedReference.extend({ name: 'messageMention' }).configure({ icon: , iconFor: (uri) => (uri === 'nostr:naddr1plan' ? : undefined), }), ReferenceTrigger.configure({ items: () => own, nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things', icon: }), ], }) editor = made return } render() await type('[') const rows = within(screen.getByRole('listbox')).getAllByRole('option') expect(rows[0].querySelector('[data-icon="row-own"]')).not.toBeNull() expect(rows[1].querySelector('[data-icon="list"]')).not.toBeNull() await press('Enter') await type('[') await press('ArrowDown') await press('Enter') const chips = document.querySelectorAll('[data-node-view-wrapper]') expect(chips[0].querySelector('[data-icon="chip-own"]')).not.toBeNull() expect(chips[1].querySelector('[data-icon="chip-default"]')).not.toBeNull() }) it('matches who said it, or the text it searches', () => { expect(filterReferences(ITEMS, 'bea').map((item) => item.id)).toEqual([ITEMS[7].id]) expect(filterReferences(ITEMS, '**line** 3').map((item) => item.id)).toEqual([ITEMS[3].id]) }) }) // f586437a (Miky, 2026-10-06): a space keeps the list listening; `]`, Escape, a pick or a new line ends it. describe('more than one word', () => { // The start of a title finds it, and so does a sentence holding it — so only the stop can close the list. function Loose() { const made = useEditor({ extensions: [ StarterKit, Chip, ReferenceTrigger.configure({ sections: (query) => [{ label: 'Things', items: ITEMS.filter((item) => item.snippet.startsWith(query) || query.includes(item.snippet)) }], nodeName: 'messageMention', ariaLabel: 'Things', sectionLabel: 'Things', }), ], }) editor = made return } it('narrows [ across a space, and the pick replaces every word typed', async () => { render() await type('see [line') expect(options()).toHaveLength(8) await type(' 3') expect(options()).toEqual(['Ada: line 3']) await press('Enter') expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[3].id, label: 'Ada', snippet: 'line 3', uri: ITEMS[3].uri } }]) // Nothing typed after `[` is left behind the chip. expect(editor!.getText()).not.toMatch(/\[|line/) }) it('hides the list while nothing matches and draws it again when the words do', async () => { render() await type('[line 9') expect(screen.queryByRole('listbox')).toBeNull() await act(async () => { editor!.commands.deleteRange({ from: editor!.state.selection.from - 1, to: editor!.state.selection.from }) }) await type('3') expect(options()).toEqual(['Ada: line 3']) }) it('leaves Enter a new line while no row shows', async () => { render() await type('[line 9') expect(screen.queryByRole('listbox')).toBeNull() await press('Enter') // No trigger took the key, so the editor's own Enter split the line. expect(editor!.state.doc.childCount).toBe(2) expect(nodes()).toEqual([]) }) it('stops at ], so a bracket in a sentence stays text', async () => { render() await type('see [line 3 above') expect(options()).toEqual(['Ada: line 3']) await type('] and more') expect(screen.queryByRole('listbox')).toBeNull() await press('Enter') expect(nodes()).toEqual([]) }) it('stays closed after Escape for the rest of that bracket', async () => { render() await type('[line 3') expect(options()).toEqual(['Ada: line 3']) await press('Escape') await type(' still line 3') expect(screen.queryByRole('listbox')).toBeNull() }) it('stops at a new line', async () => { render() await type('[line 3') expect(options()).toEqual(['Ada: line 3']) // A Shift+Enter line, inserted without the scroll jsdom cannot measure. await act(async () => { editor!.commands.insertContent([{ type: 'hardBreak' }, { type: 'text', text: 'line 3' }]) }) expect(screen.queryByRole('listbox')).toBeNull() }) it('counts a list as open only while it shows rows, so an Enter with none still sends', async () => { // The grace after the last test's list closed is module-wide: let it pass. await new Promise((resolve) => setTimeout(resolve, 120)) render() await type('meet @ad') expect(isSuggestionOpen()).toBe(true) await type(' at noon') expect(screen.queryByRole('listbox')).toBeNull() // A keystroke that left no rows closed nothing, so no grace either. expect(isSuggestionOpen()).toBe(false) expect(isSuggestionActive()).toBe(false) // Words that match again count again. await act(async () => { editor!.commands.deleteRange({ from: editor!.state.selection.from - 8, to: editor!.state.selection.from }) }) await type('a lo') expect(isSuggestionOpen()).toBe(true) }) it('takes a space straight after the key for prose, not a query', async () => { render() // Every two-word name holds a space, so "@ " would list them all. await type('meet @ ') expect(screen.queryByRole('listbox')).toBeNull() await type('lovelace') expect(screen.queryByRole('listbox')).toBeNull() expect(isSuggestionOpen()).toBe(false) }) it('opens nothing for a task box, "[ ]"', async () => { render() await type('[ ] line') expect(screen.queryByRole('listbox')).toBeNull() }) it('finds a person by first and last name, and a second @ starts over', async () => { render() // A row's text starts with its avatar's initials. await type('@ada lo') expect(options()).toEqual([expect.stringContaining('Ada Lovelace')]) await type('ve @be') expect(options()).toEqual([expect.stringContaining('Bea')]) await press('Enter') expect(nodes()).toEqual([{ type: 'mention', attrs: { id: 'bea', label: 'Bea', pubkey: null } }]) expect(editor!.getText()).toBe('@ada love @Bea ') }) }) describe('leaving the box', () => { // A page with a second box, as Ship's issue page: the message box and the description. function Page() { return ( <> ) } const other = () => screen.getByRole('textbox', { name: 'Other box' }) it('ends the list when focus moves to another box, and coming back finds none', async () => { // The grace after the last test's list closed is module-wide: let it pass. await new Promise((resolve) => setTimeout(resolve, 120)) render() await type('see [') // The caret is in the text before it leaves (the focus command waits a frame). await act(async () => editor!.view.focus()) expect(document.activeElement).toBe(editor!.view.dom) expect(options()).toHaveLength(6) expect(isSuggestionOpen()).toBe(true) await act(async () => other().focus()) expect(screen.queryByRole('listbox')).toBeNull() // The closing list does not pull focus back into the box that was left. await act(async () => new Promise((resolve) => setTimeout(resolve, 50))) expect(document.activeElement).toBe(other()) // So the other box's own Escape and Enter are its own — no grace either. expect(isSuggestionOpen()).toBe(false) expect(isSuggestionActive()).toBe(false) // A click back in, as the view takes it (the focus command waits a frame). await act(async () => editor!.view.focus()) expect(screen.queryByRole('listbox')).toBeNull() // As after Escape: the rest of that bracket is text. await type('line') expect(screen.queryByRole('listbox')).toBeNull() await press('Enter') expect(nodes()).toEqual([]) }) it('keeps focus in the text when the list’s own panel is pressed (its scrollbar)', async () => { render() await type('see [') const panel = screen.getByRole('listbox').closest('[role="dialog"]')! const pressed = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) panel.dispatchEvent(pressed) expect(pressed.defaultPrevented).toBe(true) // A press anywhere else on the page is left alone. const elsewhere = new MouseEvent('mousedown', { bubbles: true, cancelable: true }) other().dispatchEvent(elsewhere) expect(elsewhere.defaultPrevented).toBe(false) }) it('keeps the list when the window loses focus, not the page', async () => { render() await type('see [') // What a browser sends when another window takes focus: a blur, with the editor still the active element. await act(async () => { editor!.view.dom.dispatchEvent(new FocusEvent('blur')) }) expect(document.activeElement).toBe(editor!.view.dom) expect(options()).toHaveLength(6) expect(isSuggestionOpen()).toBe(true) }) it('still picks a row by mouse, which never takes focus from the text', async () => { render() await type('see [') const row = within(screen.getByRole('listbox')).getAllByRole('option')[2] await act(async () => { fireEvent.mouseDown(row) }) expect(nodes()).toEqual([{ type: 'messageMention', attrs: { id: ITEMS[4].id, label: 'Ada', snippet: 'line 4', uri: ITEMS[4].uri } }]) expect(isSuggestionOpen()).toBe(false) }) }) describe('a chip through HTML (copy and paste)', () => { it('keeps who it names and what it points at, and takes no key or uri from a crafted attribute', async () => { render() await act(async () => { editor!.commands.setContent([ { type: 'paragraph', content: [ { type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } }, { type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } }, ], }, ]) }) const html = editor!.getHTML() await act(async () => { editor!.commands.setContent(html) }) expect(nodes()).toEqual([ { type: 'mention', attrs: { id: 'ada', label: 'Ada Lovelace', pubkey: KEY } }, { type: 'messageMention', attrs: { id: ITEMS[0].id, label: 'Ada', snippet: 'line 0', uri: ITEMS[0].uri } }, ]) await act(async () => { editor!.commands.setContent( '

@Ada' + 'Ada

', ) }) const [person, reference] = nodes() expect([person.type, person.attrs.label, person.attrs.pubkey]).toEqual(['urgentMention', 'Ada', null]) expect([reference.type, reference.attrs.uri]).toEqual(['messageMention', null]) }) }) describe('/', () => { it('opens Format first, then the app’s sections, and runs the chosen row', async () => { render() await type('/') expect(screen.getByRole('listbox', { name: 'Commands' })).toBeTruthy() expect(options()).toEqual(['Text', 'Heading#', 'Subheading##', 'Quote>', 'Code`', 'Bulleted list-', 'Numbered list1.', 'Mention@']) await type('head') await press('Enter') expect(editor!.getJSON().content?.[0]).toEqual({ type: 'heading', attrs: { level: 1 } }) }) it('narrows on a label, an id or a keyword, and keeps an emptied section', () => { const sections = [formatSection(), APP_SECTION] const labels = (q: string) => filterSlashSections(sections, q).map((s) => s.commands.map((c) => c.row.label)) expect(labels('list')).toEqual([['Bulleted list', 'Numbered list'], []]) expect(labels('mention a person')).toEqual([[], ['Mention']]) expect(labels('HEAD')).toEqual([['Heading', 'Subheading'], []]) }) it('makes code a block when the app asks for one', async () => { render() const code = formatSection('block').commands.find((c) => c.key === 'format-code')! await act(async () => code.run(editor!)) expect(editor!.getJSON().content?.[0].type).toBe('codeBlock') }) it("hints the fence beside Code when it makes a block, and the backtick when it doesn't", () => { const hint = (section: ReturnType) => section.commands.find((c) => c.key === 'format-code')!.row.shortcut expect(hint(formatSection('block'))).toBe('```') expect(hint(formatSection())).toBe('`') }) }) describe('/ in a block editor — RIC-19', () => { function BlockComposer({ blocks }: { blocks: boolean }) { const made = useEditor({ extensions: [StarterKit, ...(blocks ? [Checklist, Callout] : []), SlashCommands.configure({ sections: [formatSection('block')] })], }) editor = made return } it('offers Checklist, Note and Warning where the editor has Checklist and Callout', async () => { render() await type('/') expect(options()?.slice(-3)).toEqual(['Checklist[]', 'Note', 'Warning']) }) it('offers none of them where it has not: they would make a plain bullet and a plain quote (Katerina, review of #170)', async () => { render() await type('/') expect(options()).toEqual(['Text', 'Heading#', 'Subheading##', 'Quote>', 'Code```', 'Bulleted list-', 'Numbered list1.']) }) })