diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/formatBlockCommands.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/formatBlockCommands.test.tsx new file mode 100644 index 00000000000..24e1cf7ce61 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/formatBlockCommands.test.tsx @@ -0,0 +1,141 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$convertToMarkdownString} from '@lexical/markdown'; +import {$createParagraphNode, $createTextNode, $getRoot, LexicalEditor} from 'lexical'; +import {noop} from 'noop-esm'; +import {act, renderHook} from '@testing-library/react'; +import {type FunctionComponent, type ReactNode} from 'react'; + +import {editorConfig} from '../editorConfig'; +import {markdownTransformers} from '../utils/markdownTransformers'; +import {useBlockquoteState} from './useBlockquoteState/useBlockquoteState'; +import {useCodeBlockState} from './useCodeBlockState/useCodeBlockState'; + +type LexicalComposerTestWrapperProps = { + readonly children: ReactNode; +}; + +type FormatBlockCommands = { + readonly editor: LexicalEditor; + readonly formatBlockquote: () => void; + readonly formatCodeBlock: () => void; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +const LexicalComposerTestWrapper: FunctionComponent = props => { + const {children} = props; + + return {children}; +}; + +function useFormatBlockCommands(): FormatBlockCommands { + const [editor] = useLexicalComposerContext(); + const {formatBlockquote} = useBlockquoteState(); + const {formatCodeBlock} = useCodeBlockState(); + + return {editor, formatBlockquote, formatCodeBlock}; +} + +function setSelectedParagraph(editor: LexicalEditor, text: string): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + textNode.select(0, text.length); + }, + {discrete: true}, + ); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +function flushEditorUpdate(editor: LexicalEditor): void { + editor.update(noop, {discrete: true}); +} + +describe('FormatToolbar block commands', () => { + it('formats a selected paragraph as a blockquote and toggles it back to a paragraph', () => { + const {result} = renderHook(useFormatBlockCommands, {wrapper: LexicalComposerTestWrapper}); + const {editor, formatBlockquote} = result.current; + + act(() => { + setSelectedParagraph(editor, 'quoted text'); + }); + + act(() => { + formatBlockquote(); + }); + + act(() => { + flushEditorUpdate(editor); + }); + + expect(getMarkdown(editor)).toBe('> quoted text'); + + act(() => { + formatBlockquote(); + }); + + act(() => { + flushEditorUpdate(editor); + }); + + expect(getMarkdown(editor)).toBe('quoted text'); + }); + + it('formats a selected paragraph as a code block and toggles it back to a paragraph', () => { + const {result} = renderHook(useFormatBlockCommands, {wrapper: LexicalComposerTestWrapper}); + const {editor, formatCodeBlock} = result.current; + + act(() => { + setSelectedParagraph(editor, 'const value = 1;'); + }); + + act(() => { + formatCodeBlock(); + }); + + act(() => { + flushEditorUpdate(editor); + }); + + expect(getMarkdown(editor)).toBe('```\nconst value = 1;\n```'); + + act(() => { + formatCodeBlock(); + }); + + act(() => { + flushEditorUpdate(editor); + }); + + expect(getMarkdown(editor)).toBe('const value = 1;'); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/inlineFormatCommands.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/inlineFormatCommands.test.ts new file mode 100644 index 00000000000..b53f83f339d --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/inlineFormatCommands.test.ts @@ -0,0 +1,134 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {registerRichText} from '@lexical/rich-text'; +import { + FORMAT_TEXT_COMMAND, + $createParagraphNode, + $createTextNode, + $getRoot, + LexicalEditor, + TextFormatType, +} from 'lexical'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from '../testSupport/createWireLexicalEditorTestHarness'; + +type InlineFormat = Extract; + +type InlineFormatCharacterizationTestCase = { + readonly description: string; + readonly format: InlineFormat; + readonly expectedMarkdown: string; +}; + +type RegisteredRichTextTestFunction = (harness: WireLexicalEditorTestHarness) => void; +type InlineFormatTestFunction = () => void; + +const inlineFormatCharacterizationTestCases: readonly InlineFormatCharacterizationTestCase[] = [ + { + description: 'bold text', + format: 'bold', + expectedMarkdown: '**formatted text**', + }, + { + description: 'italic text', + format: 'italic', + expectedMarkdown: '*formatted text*', + }, + { + description: 'strikethrough text', + format: 'strikethrough', + expectedMarkdown: '~~formatted text~~', + }, + { + description: 'inline code text', + format: 'code', + expectedMarkdown: '`formatted text`', + }, +]; + +function setSelectedText(editor: LexicalEditor, text: string): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + textNode.select(0, text.length); + }, + {discrete: true}, + ); +} + +function dispatchFormatCommand(editor: LexicalEditor, format: InlineFormat): boolean { + let wasHandled = false; + + editor.update( + () => { + wasHandled = editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); + }, + {discrete: true}, + ); + + return wasHandled; +} + +function withRegisteredRichText(testFunction: RegisteredRichTextTestFunction): InlineFormatTestFunction { + return () => { + const harness = createWireLexicalEditorTestHarness(); + const unregisterRichText = registerRichText(harness.editor); + + try { + testFunction(harness); + } finally { + unregisterRichText(); + } + }; +} + +describe('Wire Lexical inline format commands', () => { + for (const characterizationTestCase of inlineFormatCharacterizationTestCases) { + it( + `formats and unformats ${characterizationTestCase.description}`, + withRegisteredRichText(harness => { + setSelectedText(harness.editor, 'formatted text'); + + expect(dispatchFormatCommand(harness.editor, characterizationTestCase.format)).toBe(true); + expect(harness.exportMarkdown()).toBe(characterizationTestCase.expectedMarkdown); + + expect(dispatchFormatCommand(harness.editor, characterizationTestCase.format)).toBe(true); + expect(harness.exportMarkdown()).toBe('formatted text'); + }), + ); + } + + it( + 'exports combined bold and italic formatting using the current Markdown representation', + withRegisteredRichText(harness => { + setSelectedText(harness.editor, 'formatted text'); + + expect(dispatchFormatCommand(harness.editor, 'bold')).toBe(true); + expect(dispatchFormatCommand(harness.editor, 'italic')).toBe(true); + + expect(harness.exportMarkdown()).toBe('***formatted text***'); + }), + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useHeadingState/headingCommand.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useHeadingState/headingCommand.test.ts new file mode 100644 index 00000000000..446294516fd --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useHeadingState/headingCommand.test.ts @@ -0,0 +1,70 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$convertToMarkdownString} from '@lexical/markdown'; +import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; + +import {createWireLexicalEditorTestHarness} from '../../testSupport/createWireLexicalEditorTestHarness'; +import {markdownTransformers} from '../../utils/markdownTransformers'; +import {headingCommand} from './headingCommand'; + +type HeadingCommandResult = { + readonly wasHandled: boolean; + readonly markdown: string; +}; + +function executeHeadingCommand(paragraphText: string, selectParagraphText: boolean): HeadingCommandResult { + const harness = createWireLexicalEditorTestHarness(); + let wasHandled = false; + + harness.editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(paragraphText); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + + if (selectParagraphText) { + textNode.select(0, paragraphText.length); + } + + wasHandled = headingCommand(); + }, + {discrete: true}, + ); + + const markdown = harness.editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); + + return {wasHandled, markdown}; +} + +describe('headingCommand', () => { + it('converts the selected paragraph to an H1 heading', () => { + const actualResult = executeHeadingCommand('Heading text', true); + + expect(actualResult).toEqual({wasHandled: true, markdown: '# Heading text'}); + }); + + it('returns handled without changing a document that has no range selection', () => { + const actualResult = executeHeadingCommand('Plain text', false); + + expect(actualResult).toEqual({wasHandled: true, markdown: 'Plain text'}); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/createNewLink/createNewLink.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/createNewLink/createNewLink.test.ts new file mode 100644 index 00000000000..a79c714a660 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/createNewLink/createNewLink.test.ts @@ -0,0 +1,95 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection} from 'lexical'; +import assert from 'node:assert'; + +import {createWireLexicalEditorTestHarness} from '../../../testSupport/createWireLexicalEditorTestHarness'; +import {createNewLink} from './createNewLink'; + +type CreateLinkTestCase = { + readonly description: string; + readonly selectedText: string; + readonly linkUrl: string; + readonly linkText: string | undefined; + readonly expectedMarkdown: string; +}; + +const createLinkTestCases: readonly CreateLinkTestCase[] = [ + { + description: 'uses the selected text when no replacement text is supplied', + selectedText: 'Wire', + linkUrl: 'example.com', + linkText: undefined, + expectedMarkdown: '[Wire](https://example.com)', + }, + { + description: 'uses replacement text instead of the selected text', + selectedText: 'Wire', + linkUrl: 'https://example.com', + linkText: 'Wire website', + expectedMarkdown: '[Wire website](https://example.com)', + }, + { + description: 'uses the URL as visible text when the selection is empty', + selectedText: '', + linkUrl: 'https://example.com/docs', + linkText: undefined, + expectedMarkdown: '[https://example.com/docs](https://example.com/docs)', + }, + { + description: 'keeps the visible URL text when an unsupported protocol is sanitized', + selectedText: '', + linkUrl: 'ftp://example.com', + linkText: undefined, + expectedMarkdown: '[ftp://example.com]()', + }, +]; + +function createLinkFromSelection(createLinkTestCase: CreateLinkTestCase): string { + const harness = createWireLexicalEditorTestHarness(); + + harness.editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const selectedTextNode = $createTextNode(createLinkTestCase.selectedText); + paragraphNode.append(selectedTextNode); + $getRoot().clear().append(paragraphNode); + selectedTextNode.select(0, createLinkTestCase.selectedText.length); + + const selection = $getSelection(); + assert($isRangeSelection(selection)); + createNewLink({ + selection, + url: createLinkTestCase.linkUrl, + text: createLinkTestCase.linkText, + }); + }, + {discrete: true}, + ); + + return harness.exportMarkdown(); +} + +describe('createNewLink', () => { + it.each(createLinkTestCases)('$description', createLinkTestCase => { + const actualMarkdown = createLinkFromSelection(createLinkTestCase); + + expect(actualMarkdown).toBe(createLinkTestCase.expectedMarkdown); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/getSelectedNode/getSelectedNode.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/getSelectedNode/getSelectedNode.test.ts new file mode 100644 index 00000000000..1e2fd16848c --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/getSelectedNode/getSelectedNode.test.ts @@ -0,0 +1,120 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$createParagraphNode, $createTextNode, $getRoot, $getSelection, $isRangeSelection, TextNode} from 'lexical'; +import assert from 'node:assert'; + +import {createWireLexicalEditorTestHarness} from '../../../testSupport/createWireLexicalEditorTestHarness'; +import {getSelectedNode} from './getSelectedNode'; + +type TextNodeName = 'first' | 'second'; + +type SelectionPoint = { + readonly textNodeName: TextNodeName; + readonly offset: number; +}; + +type SelectionCharacterizationTestCase = { + readonly description: string; + readonly anchor: SelectionPoint; + readonly focus: SelectionPoint; +}; + +const sameNodeSelectionCharacterizationTestCase: SelectionCharacterizationTestCase = { + description: 'returns the shared text node for a selection within one node', + anchor: {textNodeName: 'first', offset: 1}, + focus: {textNodeName: 'first', offset: 4}, +}; + +const crossNodeSelectionCharacterizationTestCases: readonly SelectionCharacterizationTestCase[] = [ + { + description: 'throws for a forward selection that starts inside the anchor node', + anchor: {textNodeName: 'first', offset: 1}, + focus: {textNodeName: 'second', offset: 2}, + }, + { + description: 'throws for a forward selection that starts at the anchor end', + anchor: {textNodeName: 'first', offset: 5}, + focus: {textNodeName: 'second', offset: 2}, + }, + { + description: 'throws for a backward selection whose focus is inside the focus node', + anchor: {textNodeName: 'second', offset: 2}, + focus: {textNodeName: 'first', offset: 1}, + }, + { + description: 'throws for a backward selection whose focus is at the focus node end', + anchor: {textNodeName: 'second', offset: 2}, + focus: {textNodeName: 'first', offset: 5}, + }, +]; + +function readSelectedNodeName(selectionTestCase: SelectionCharacterizationTestCase): TextNodeName { + const harness = createWireLexicalEditorTestHarness(); + let actualSelectedNodeName: TextNodeName | undefined; + + harness.editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const firstTextNode = $createTextNode('first'); + const secondTextNode = $createTextNode('second'); + secondTextNode.setFormat('bold'); + paragraphNode.append(firstTextNode, secondTextNode); + $getRoot().clear().append(paragraphNode); + + const textNodes: Record = { + first: firstTextNode, + second: secondTextNode, + }; + firstTextNode.select(0, 0); + const selection = $getSelection(); + assert($isRangeSelection(selection)); + selection.setTextNodeRange( + textNodes[selectionTestCase.anchor.textNodeName], + selectionTestCase.anchor.offset, + textNodes[selectionTestCase.focus.textNodeName], + selectionTestCase.focus.offset, + ); + const selectedNodeKey = getSelectedNode(selection).getKey(); + if (selectedNodeKey === firstTextNode.getKey()) { + actualSelectedNodeName = 'first'; + } else if (selectedNodeKey === secondTextNode.getKey()) { + actualSelectedNodeName = 'second'; + } + }, + {discrete: true}, + ); + + assert(actualSelectedNodeName !== undefined); + + return actualSelectedNodeName; +} + +describe('getSelectedNode', () => { + it('returns the shared text node for a selection within one node', () => { + const actualSelectedNodeName = readSelectedNodeName(sameNodeSelectionCharacterizationTestCase); + + expect(actualSelectedNodeName).toBe('first'); + }); + + it.each(crossNodeSelectionCharacterizationTestCases)('$description', selectionTestCase => { + expect(() => { + return readSelectedNodeName(selectionTestCase); + }).toThrow(TypeError); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/useLinkState.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/useLinkState.test.tsx new file mode 100644 index 00000000000..737ed9ba3d9 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useLinkState/useLinkState.test.tsx @@ -0,0 +1,204 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$convertToMarkdownString} from '@lexical/markdown'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createLinkNode} from '@lexical/link'; +import {$createParagraphNode, $createTextNode, $getRoot, LexicalEditor} from 'lexical'; +import {noop} from 'noop-esm'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {markdownTransformers} from '../../utils/markdownTransformers'; +import {useLinkState} from './useLinkState'; + +type LinkState = ReturnType; + +type LinkStateCapturePluginProps = { + readonly onReady: (linkState: LinkState) => void; +}; + +type LinkStateTestFixture = { + readonly editor: LexicalEditor; + readonly getLinkState: () => Result; +}; + +const LinkStateCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const linkState = useLinkState(); + + useEffect(() => { + onReady(linkState); + }, [linkState, onReady]); + + return null; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function renderLinkState(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + let capturedLinkState: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + function captureLinkState(linkState: LinkState): void { + capturedLinkState = Maybe.just(linkState); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return { + editor, + getLinkState(): Result { + return toolbelt.fromMaybe(new Error('The link state was not captured'), capturedLinkState); + }, + }; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function selectText(editor: LexicalEditor, text: string): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + textNode.select(0, text.length); + }, + {discrete: true}, + ); +} + +function selectExistingLink(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const linkNode = $createLinkNode('https://wire.com'); + const linkTextNode = $createTextNode('Wire'); + linkNode.append(linkTextNode); + paragraphNode.append(linkNode); + $getRoot().clear().append(paragraphNode); + linkTextNode.select(0, linkTextNode.getTextContentSize()); + }, + {discrete: true}, + ); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +function flushEditorUpdate(editor: LexicalEditor): void { + editor.update(noop, {discrete: true}); +} + +describe('useLinkState', () => { + it('opens a new-link state for selected text and inserts a sanitized link', () => { + const fixture = unwrap(renderLinkState()); + + act(() => { + selectText(fixture.editor, 'Wire'); + }); + + act(() => { + unwrap(fixture.getLinkState()).formatLink(); + }); + + const newLinkState = unwrap(fixture.getLinkState()); + + expect(newLinkState.isModalOpen).toBe(true); + expect(newLinkState.selectedText).toBe('Wire'); + expect(newLinkState.linkUrl).toBe(''); + + act(() => { + newLinkState.insertLink('example.com'); + }); + + act(() => { + flushEditorUpdate(fixture.editor); + }); + + expect(getMarkdown(fixture.editor)).toBe('[Wire](https://example.com)'); + expect(unwrap(fixture.getLinkState()).isModalOpen).toBe(false); + }); + + it('opens an existing-link state and replaces its URL and visible text', () => { + const fixture = unwrap(renderLinkState()); + + act(() => { + selectExistingLink(fixture.editor); + }); + + act(() => { + unwrap(fixture.getLinkState()).formatLink(); + }); + + const existingLinkState = unwrap(fixture.getLinkState()); + + expect(existingLinkState.isModalOpen).toBe(true); + expect(existingLinkState.selectedText).toBe('Wire'); + expect(existingLinkState.linkUrl).toBe('https://wire.com'); + + act(() => { + existingLinkState.insertLink('https://wire.com/new', 'New Wire'); + }); + + act(() => { + flushEditorUpdate(fixture.editor); + }); + + expect(getMarkdown(fixture.editor)).toBe('[New Wire](https://wire.com/new)'); + expect(unwrap(fixture.getLinkState()).isModalOpen).toBe(false); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useListState/listCommand.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useListState/listCommand.test.ts new file mode 100644 index 00000000000..f72bbf3ba1e --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useListState/listCommand.test.ts @@ -0,0 +1,224 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import { + INSERT_ORDERED_LIST_COMMAND, + INSERT_UNORDERED_LIST_COMMAND, + REMOVE_LIST_COMMAND, + registerList, +} from '@lexical/list'; +import {registerRichText} from '@lexical/rich-text'; +import { + $getRoot, + $isElementNode, + $isTextNode, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + KEY_ENTER_COMMAND, + LexicalEditor, +} from 'lexical'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from '../../testSupport/createWireLexicalEditorTestHarness'; + +type ListCommandCharacterizationTestCase = { + readonly description: string; + readonly command: typeof INSERT_ORDERED_LIST_COMMAND | typeof INSERT_UNORDERED_LIST_COMMAND; + readonly expectedMarkdown: string; +}; + +const listCommandCharacterizationTestCases: readonly ListCommandCharacterizationTestCase[] = [ + { + description: 'inserting an ordered list', + command: INSERT_ORDERED_LIST_COMMAND, + expectedMarkdown: '1. item', + }, + { + description: 'inserting an unordered list', + command: INSERT_UNORDERED_LIST_COMMAND, + expectedMarkdown: '- item', + }, +]; + +function selectEndOfFirstDocumentElement(editor: LexicalEditor): void { + editor.update( + () => { + const firstDocumentElement = $getRoot().getFirstChild(); + if (firstDocumentElement === null || !$isElementNode(firstDocumentElement)) { + throw new Error('The list command characterization requires a document element'); + } + const lastTextNode = firstDocumentElement.getLastDescendant(); + if (lastTextNode === null || !$isTextNode(lastTextNode)) { + throw new Error('The list command characterization requires a text node'); + } + lastTextNode.selectEnd(); + }, + {discrete: true}, + ); +} + +function selectEndOfFirstListItem(editor: LexicalEditor): void { + editor.update( + () => { + const firstDocumentElement = $getRoot().getFirstChild(); + if (firstDocumentElement === null || !$isElementNode(firstDocumentElement)) { + throw new Error('The list command characterization requires a document element'); + } + const firstListItem = firstDocumentElement.getFirstChild(); + if (firstListItem === null || !$isElementNode(firstListItem)) { + throw new Error('The list command characterization requires a list item'); + } + const lastTextNode = firstListItem.getLastDescendant(); + if (lastTextNode === null || !$isTextNode(lastTextNode)) { + throw new Error('The list command characterization requires a text node'); + } + lastTextNode.selectEnd(); + }, + {discrete: true}, + ); +} + +type RegisteredListTestFunction = (harness: WireLexicalEditorTestHarness) => void; + +function withRegisteredList( + inputMarkdown: string, + testFunction: RegisteredListTestFunction, +): RegisteredListTestFunction { + return () => { + const harness = createWireLexicalEditorTestHarness(); + harness.importMarkdown(inputMarkdown); + selectEndOfFirstDocumentElement(harness.editor); + + const unregisterList = registerList(harness.editor); + + try { + testFunction(harness); + } finally { + unregisterList(); + } + }; +} + +describe('Wire Lexical list commands', () => { + for (const characterizationTestCase of listCommandCharacterizationTestCases) { + it( + `preserves the current behavior for ${characterizationTestCase.description}`, + withRegisteredList('item', harness => { + let wasHandled = false; + harness.editor.update( + () => { + wasHandled = harness.editor.dispatchCommand(characterizationTestCase.command, undefined); + }, + {discrete: true}, + ); + + expect(wasHandled).toBe(true); + expect(harness.exportMarkdown()).toBe(characterizationTestCase.expectedMarkdown); + }), + ); + } + + it( + 'removes an existing unordered list', + withRegisteredList('- item', harness => { + let wasHandled = false; + harness.editor.update( + () => { + wasHandled = harness.editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined); + }, + {discrete: true}, + ); + + expect(wasHandled).toBe(true); + expect(harness.exportMarkdown()).toBe('item'); + }), + ); + + it( + 'creates a new list item when Enter is pressed at the end of an item', + withRegisteredList('- first', harness => { + registerRichText(harness.editor); + + const enterEvent = new KeyboardEvent('keydown', {cancelable: true}); + let wasHandled = false; + + harness.editor.update( + () => { + wasHandled = harness.editor.dispatchCommand(KEY_ENTER_COMMAND, enterEvent); + }, + {discrete: true}, + ); + + expect(wasHandled).toBe(true); + expect(enterEvent.defaultPrevented).toBe(true); + expect(harness.exportMarkdown()).toBe('- first\n- '); + }), + ); + + it( + 'removes an empty list item when Backspace follows Enter', + withRegisteredList('- first', harness => { + registerRichText(harness.editor); + + harness.editor.update( + () => { + harness.editor.dispatchCommand(KEY_ENTER_COMMAND, new KeyboardEvent('keydown', {cancelable: true})); + }, + {discrete: true}, + ); + + const backspaceEvent = new KeyboardEvent('keydown', {cancelable: true, key: 'Backspace'}); + let wasHandled = false; + + harness.editor.update( + () => { + wasHandled = harness.editor.dispatchCommand(KEY_BACKSPACE_COMMAND, backspaceEvent); + }, + {discrete: true}, + ); + + expect(wasHandled).toBe(true); + expect(backspaceEvent.defaultPrevented).toBe(true); + expect(harness.exportMarkdown()).toBe('- first'); + }), + ); + + it( + 'handles Delete at the end of the first item in a list', + withRegisteredList('- first\n- second', harness => { + selectEndOfFirstListItem(harness.editor); + registerRichText(harness.editor); + + const deleteEvent = new KeyboardEvent('keydown', {cancelable: true, key: 'Delete'}); + let wasHandled = false; + + harness.editor.update( + () => { + wasHandled = harness.editor.dispatchCommand(KEY_DELETE_COMMAND, deleteEvent); + }, + {discrete: true}, + ); + + expect(wasHandled).toBe(true); + expect(deleteEvent.defaultPrevented).toBe(true); + expect(harness.exportMarkdown()).toBe('- firstsecond'); + }), + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useListState/useListState.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useListState/useListState.test.tsx new file mode 100644 index 00000000000..18ce9e46d60 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useListState/useListState.test.tsx @@ -0,0 +1,136 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {ListPlugin} from '@lexical/react/LexicalListPlugin'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$convertToMarkdownString} from '@lexical/markdown'; +import {$createParagraphNode, $createTextNode, $getRoot, type LexicalEditor} from 'lexical'; +import {noop} from 'noop-esm'; +import {act, renderHook} from '@testing-library/react'; +import {type FunctionComponent, type ReactNode} from 'react'; + +import {editorConfig} from '../../editorConfig'; +import {markdownTransformers} from '../../utils/markdownTransformers'; + +import {useListState} from './useListState'; + +type LexicalComposerTestWrapperProps = { + readonly children: ReactNode; +}; + +type ListStateTestResult = { + readonly editor: LexicalEditor; + readonly formatList: (listType: ListType) => void; +}; + +type ListType = 'unordered' | 'ordered'; + +type ListStateCharacterizationTestCase = { + readonly description: string; + readonly listType: ListType; + readonly expectedListMarkdown: string; +}; + +const listStateCharacterizationTestCases: readonly ListStateCharacterizationTestCase[] = [ + { + description: 'an unordered list', + listType: 'unordered', + expectedListMarkdown: '- item', + }, + { + description: 'an ordered list', + listType: 'ordered', + expectedListMarkdown: '1. item', + }, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +const LexicalComposerTestWrapper: FunctionComponent = props => { + const {children} = props; + + return ( + + + {children} + + ); +}; + +function useListStateWithEditor(): ListStateTestResult { + const [editor] = useLexicalComposerContext(); + const {formatList} = useListState(); + + return {editor, formatList}; +} + +function setSelectedParagraph(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('item'); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + textNode.select(0, textNode.getTextContentSize()); + }, + {discrete: true}, + ); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +function flushEditorUpdate(editor: LexicalEditor): void { + editor.update(noop, {discrete: true}); +} + +describe('useListState', () => { + it.each(listStateCharacterizationTestCases)('toggles $description formatting', characterizationTestCase => { + const {result} = renderHook(useListStateWithEditor, {wrapper: LexicalComposerTestWrapper}); + + act(() => { + setSelectedParagraph(result.current.editor); + }); + + act(() => { + result.current.formatList(characterizationTestCase.listType); + }); + + act(() => { + flushEditorUpdate(result.current.editor); + }); + + expect(getMarkdown(result.current.editor)).toBe(characterizationTestCase.expectedListMarkdown); + + act(() => { + result.current.formatList(characterizationTestCase.listType); + }); + + act(() => { + flushEditorUpdate(result.current.editor); + }); + + expect(getMarkdown(result.current.editor)).toBe('item'); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useToolbarState/useToolbarState.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useToolbarState/useToolbarState.test.tsx new file mode 100644 index 00000000000..edf121616d2 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/FormatToolbar/useToolbarState/useToolbarState.test.tsx @@ -0,0 +1,229 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$createCodeNode} from '@lexical/code'; +import {$createLinkNode} from '@lexical/link'; +import {$createListItemNode, $createListNode} from '@lexical/list'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + ElementNode, + LexicalEditor, + TextFormatType, +} from 'lexical'; +import {noop} from 'noop-esm'; +import {match} from 'ts-pattern'; +import {act, renderHook} from '@testing-library/react'; +import {type FunctionComponent, type ReactNode} from 'react'; + +import {editorConfig} from '../../editorConfig'; +import {useToolbarState} from './useToolbarState'; + +type LexicalComposerTestWrapperProps = { + readonly children: ReactNode; +}; + +type ToolbarStateTestResult = { + readonly editor: LexicalEditor; + readonly activeFormats: readonly string[]; +}; + +type InlineFormat = Extract; + +type InlineFormatCharacterizationTestCase = { + readonly description: string; + readonly formats: readonly InlineFormat[]; + readonly expectedActiveFormats: readonly string[]; +}; + +type BlockFormat = 'unorderedList' | 'orderedList' | 'heading' | 'blockquote' | 'codeBlock' | 'link'; + +type BlockFormatCharacterizationTestCase = { + readonly description: string; + readonly format: BlockFormat; + readonly expectedActiveFormats: readonly string[]; +}; + +const inlineFormatCharacterizationTestCases: readonly InlineFormatCharacterizationTestCase[] = [ + {description: 'bold', formats: ['bold'], expectedActiveFormats: ['bold']}, + {description: 'italic', formats: ['italic'], expectedActiveFormats: ['italic']}, + {description: 'strikethrough', formats: ['strikethrough'], expectedActiveFormats: ['strikethrough']}, + {description: 'inline code', formats: ['code'], expectedActiveFormats: ['code']}, + { + description: 'bold and italic', + formats: ['bold', 'italic'], + expectedActiveFormats: ['bold', 'italic'], + }, +]; + +const blockFormatCharacterizationTestCases: readonly BlockFormatCharacterizationTestCase[] = [ + {description: 'an unordered list', format: 'unorderedList', expectedActiveFormats: ['unorderedList']}, + {description: 'an ordered list', format: 'orderedList', expectedActiveFormats: ['orderedList']}, + {description: 'a heading', format: 'heading', expectedActiveFormats: ['heading']}, + {description: 'a blockquote', format: 'blockquote', expectedActiveFormats: ['blockquote']}, + {description: 'a code block', format: 'codeBlock', expectedActiveFormats: ['codeBlock']}, + {description: 'a link', format: 'link', expectedActiveFormats: ['link']}, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +const LexicalComposerTestWrapper: FunctionComponent = props => { + const {children} = props; + + return {children}; +}; + +function useToolbarStateWithEditor(): ToolbarStateTestResult { + const [editor] = useLexicalComposerContext(); + const {activeFormats} = useToolbarState(); + + return {editor, activeFormats}; +} + +function setSelectedInlineText(editor: LexicalEditor, formats: readonly InlineFormat[]): void { + editor.update( + () => { + const textNode = $createTextNode('formatted text'); + const paragraphNode = $createParagraphNode(); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + textNode.select(0, textNode.getTextContentSize()); + + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + return; + } + + for (const format of formats) { + selection.formatText(format); + } + }, + {discrete: true}, + ); +} + +function setSelectedBlock(editor: LexicalEditor, format: BlockFormat): void { + editor.update( + () => { + const textNode = $createTextNode('formatted text'); + const blockNode = match(format) + .returnType() + .with('unorderedList', () => { + const listItemNode = $createListItemNode(); + listItemNode.append(textNode); + + return $createListNode('bullet').append(listItemNode); + }) + .with('orderedList', () => { + const listItemNode = $createListItemNode(); + listItemNode.append(textNode); + + return $createListNode('number').append(listItemNode); + }) + .with('heading', () => { + const headingNode = $createHeadingNode('h1'); + headingNode.append(textNode); + + return headingNode; + }) + .with('blockquote', () => { + const blockquoteNode = $createQuoteNode(); + blockquoteNode.append(textNode); + + return blockquoteNode; + }) + .with('codeBlock', () => { + const codeBlockNode = $createCodeNode(); + codeBlockNode.append(textNode); + + return codeBlockNode; + }) + .with('link', () => { + const linkNode = $createLinkNode('https://wire.com'); + linkNode.append(textNode); + + return linkNode; + }) + .exhaustive(); + + $getRoot().clear().append(blockNode); + textNode.select(0, textNode.getTextContentSize()); + }, + {discrete: true}, + ); +} + +function flushEditorUpdate(editor: LexicalEditor): void { + editor.update(noop, {discrete: true}); +} + +describe('useToolbarState', () => { + it('starts with no active formats before a range selection exists', () => { + const {result} = renderHook(useToolbarStateWithEditor, {wrapper: LexicalComposerTestWrapper}); + + expect(result.current.activeFormats).toEqual([]); + }); + + it.each(inlineFormatCharacterizationTestCases)('reports active $description formatting', testCase => { + const {result} = renderHook(useToolbarStateWithEditor, {wrapper: LexicalComposerTestWrapper}); + + act(() => { + setSelectedInlineText(result.current.editor, testCase.formats); + }); + + expect(result.current.activeFormats).toEqual(testCase.expectedActiveFormats); + }); + + it.each(blockFormatCharacterizationTestCases)('reports active formatting for $description', testCase => { + const {result} = renderHook(useToolbarStateWithEditor, {wrapper: LexicalComposerTestWrapper}); + + act(() => { + setSelectedBlock(result.current.editor, testCase.format); + }); + + expect(result.current.activeFormats).toEqual(testCase.expectedActiveFormats); + }); + + it('retains the previous active formatting after replacing selected formatted text with plain text', () => { + const {result} = renderHook(useToolbarStateWithEditor, {wrapper: LexicalComposerTestWrapper}); + + act(() => { + setSelectedInlineText(result.current.editor, ['bold']); + }); + + expect(result.current.activeFormats).toEqual(['bold']); + + act(() => { + setSelectedInlineText(result.current.editor, []); + }); + + act(() => { + flushEditorUpdate(result.current.editor); + }); + + expect(result.current.activeFormats).toEqual(['bold']); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/RichTextEditor.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/RichTextEditor.test.tsx new file mode 100644 index 00000000000..86c75afe4a7 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/RichTextEditor.test.tsx @@ -0,0 +1,322 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$convertFromMarkdownString} from '@lexical/markdown'; +import {$createMentionNode} from './nodes/MentionNode'; +import {LexicalEditor, $createParagraphNode, $createTextNode, $getRoot, KEY_ENTER_COMMAND} from 'lexical'; +import {noop} from 'noop-esm'; +import {Maybe, toolbelt, type Result} from 'true-myth'; + +import {act, render} from '@testing-library/react'; + +import {MessageContent} from 'Components/InputBar/common/messageContent/messageContent'; +import {User} from 'Repositories/entity/User'; +import {unwrap} from 'Util/test/resultTestSupport'; +import {translateForTest} from 'Util/test/translateForTest'; + +import {RichTextEditor} from './RichTextEditor'; + +import {markdownTransformers} from './utils/markdownTransformers'; + +type RichTextEditorTestOptions = { + readonly disableMessagePreprocessing: boolean; + readonly mentionCandidates: readonly User[]; + readonly replaceEmojis: boolean; + readonly showMarkdownPreview: boolean; +}; + +type RichTextEditorTestFixture = { + readonly editor: LexicalEditor; + readonly onUpdate: jest.Mock; + readonly onSend: jest.Mock; + readonly saveDraftState: jest.Mock; +}; + +type RichTextEditorTestFunction = () => void; +type AsyncRichTextEditorTestFunction = () => Promise; + +const defaultRichTextEditorTestOptions: RichTextEditorTestOptions = { + disableMessagePreprocessing: false, + mentionCandidates: [], + replaceEmojis: false, + showMarkdownPreview: true, +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function createTestUser(userName: string): User { + const user = new User(`${userName}-id`, '', translateForTest); + user.name(userName); + + return user; +} + +function withFakeTimers(testFunction: RichTextEditorTestFunction): RichTextEditorTestFunction { + return function (): void { + jest.useFakeTimers(); + + try { + testFunction(); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }; +} + +function withAsyncFakeTimers(testFunction: AsyncRichTextEditorTestFunction): AsyncRichTextEditorTestFunction { + return async function (): Promise { + jest.useFakeTimers(); + + try { + await testFunction(); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }; +} + +function renderRichTextEditor( + richTextEditorTestOptions: RichTextEditorTestOptions = defaultRichTextEditorTestOptions, +): Result { + const onUpdate = jest.fn(); + const onSend = jest.fn(); + const saveDraftState = jest.fn(); + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + { + return richTextEditorTestOptions.mentionCandidates.slice(); + }} + saveDraftState={saveDraftState} + loadDraftState={async () => { + return {editorState: null}; + }} + onUpdate={onUpdate} + onArrowUp={noop} + onEscape={noop} + onShiftTab={noop} + onBlur={noop} + onSend={onSend} + onSetup={captureEditor} + > + {null} + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor, onUpdate, onSend, saveDraftState}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function importMarkdown(editor: LexicalEditor, markdown: string): void { + editor.update( + () => { + $getRoot().clear(); + $convertFromMarkdownString(markdown, markdownTransformers, undefined, true); + }, + {discrete: true}, + ); +} + +function setRawParagraphs(editor: LexicalEditor, paragraphs: readonly string[]): void { + editor.update( + () => { + const paragraphNodes = paragraphs.map(paragraph => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode(paragraph)); + + return paragraphNode; + }); + + $getRoot() + .clear() + .append(...paragraphNodes); + }, + {discrete: true}, + ); +} + +function setMentionContent(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode('Hello '), $createMentionNode('@', 'Alice'), $createTextNode('!')); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +describe('RichTextEditor', () => { + it( + 'reports serialized Markdown and saves the same transformed message when preprocessing is enabled', + withFakeTimers(() => { + const fixture = unwrap(renderRichTextEditor()); + + act(() => { + importMarkdown(fixture.editor, '**bold**\n\n- item'); + }); + + expect(fixture.onUpdate).toHaveBeenLastCalledWith({ + text: '**bold**\n\n- item', + mentions: [], + }); + + act(() => { + jest.advanceTimersByTime(800); + }); + + expect(fixture.saveDraftState).toHaveBeenLastCalledWith( + JSON.stringify(fixture.editor.getEditorState().toJSON()), + '**bold**\n\n- item', + undefined, + ); + }), + ); + + it( + 'reports and saves emoji replacements when preprocessing and emoji replacement are enabled', + withFakeTimers(() => { + const fixture = unwrap( + renderRichTextEditor({ + ...defaultRichTextEditorTestOptions, + replaceEmojis: true, + }), + ); + + act(() => { + setRawParagraphs(fixture.editor, ['hello :)']); + }); + + expect(fixture.onUpdate).toHaveBeenLastCalledWith({ + text: 'hello πŸ™‚', + mentions: [], + }); + + act(() => { + jest.advanceTimersByTime(800); + }); + + expect(fixture.saveDraftState).toHaveBeenLastCalledWith( + JSON.stringify(fixture.editor.getEditorState().toJSON()), + 'hello πŸ™‚', + undefined, + ); + }), + ); + + it( + 'reports raw editor text and leaves Markdown-looking text unchanged when preprocessing is disabled', + withFakeTimers(() => { + const fixture = unwrap( + renderRichTextEditor({ + ...defaultRichTextEditorTestOptions, + disableMessagePreprocessing: true, + replaceEmojis: true, + showMarkdownPreview: false, + }), + ); + + act(() => { + setRawParagraphs(fixture.editor, ['**bold**', 'hello :)']); + }); + + expect(fixture.onUpdate).toHaveBeenLastCalledWith({ + text: '**bold**\nhello :)', + mentions: [], + }); + + act(() => { + jest.advanceTimersByTime(800); + }); + + expect(fixture.saveDraftState).toHaveBeenLastCalledWith( + JSON.stringify(fixture.editor.getEditorState().toJSON()), + '**bold**\nhello :)', + undefined, + ); + }), + ); + + it( + 'reports serialized mention text and extracts the matching mention entity', + withAsyncFakeTimers(async () => { + const fixture = unwrap( + renderRichTextEditor({ + ...defaultRichTextEditorTestOptions, + mentionCandidates: [createTestUser('Alice')], + }), + ); + + await act(async () => { + setMentionContent(fixture.editor); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + jest.advanceTimersByTime(800); + await Promise.resolve(); + }); + + expect(fixture.onUpdate).toHaveBeenLastCalledWith({ + text: 'Hello @Alice!', + mentions: [ + expect.objectContaining({ + startIndex: 6, + length: 6, + userId: 'Alice-id', + domain: '', + }), + ], + }); + }), + ); + + it('sends Enter when the mention and emoji menus are closed', () => { + const fixture = unwrap(renderRichTextEditor()); + const enterEvent = new KeyboardEvent('keydown', {cancelable: true}); + + let wasHandled = false; + act(() => { + wasHandled = fixture.editor.dispatchCommand(KEY_ENTER_COMMAND, enterEvent); + }); + + expect(wasHandled).toBe(true); + expect(enterEvent.defaultPrevented).toBe(true); + expect(fixture.onSend).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownCharacterization.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownCharacterization.test.ts new file mode 100644 index 00000000000..293df0d1fd6 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownCharacterization.test.ts @@ -0,0 +1,432 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from './testSupport/createWireLexicalEditorTestHarness'; + +type MarkdownCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly expectedMarkdown: string; + readonly expectedTextContent: string; +}; + +const markdownCharacterizationTestCases: MarkdownCharacterizationTestCase[] = [ + {description: 'an empty message', inputMarkdown: '', expectedMarkdown: '', expectedTextContent: ''}, + { + description: 'ordinary text with punctuation and Unicode', + inputMarkdown: 'Hello, δΈ–η•Œ 🌍! 42. β€” cafΓ©', + expectedMarkdown: 'Hello, δΈ–η•Œ 🌍! 42. β€” cafΓ©', + expectedTextContent: 'Hello, δΈ–η•Œ 🌍! 42. β€” cafΓ©', + }, + { + description: 'leading, trailing, and consecutive spaces', + inputMarkdown: ' leading and trailing ', + expectedMarkdown: ' leading and trailing ', + expectedTextContent: ' leading and trailing ', + }, + { + description: 'a backslash that is not Markdown syntax', + inputMarkdown: 'escaped\\backslash', + expectedMarkdown: 'escaped\\backslash', + expectedTextContent: 'escaped\\backslash', + }, + { + description: 'a date-like line beginning with a non-one ordered marker', + inputMarkdown: '14. - 25. september', + expectedMarkdown: '14. - 25. september', + expectedTextContent: '- 25. september', + }, + { + description: 'a date-like line beginning with a capitalized month', + inputMarkdown: '14. September', + expectedMarkdown: '14. September', + expectedTextContent: 'September', + }, + { + description: 'a year-like line followed by a word', + inputMarkdown: '2026. something', + expectedMarkdown: '2026. something', + expectedTextContent: 'something', + }, + { + description: 'a one-based ordered-list-looking line', + inputMarkdown: '1. something', + expectedMarkdown: '1. something', + expectedTextContent: 'something', + }, + { + description: 'a zero-padded ordered-list-looking line', + inputMarkdown: '01. something', + expectedMarkdown: '1. something', + expectedTextContent: 'something', + }, + { + description: 'a zero ordered-list-looking line', + inputMarkdown: '0. something', + expectedMarkdown: '0. something', + expectedTextContent: 'something', + }, + { + description: 'a number without a space after its period', + inputMarkdown: '1.something', + expectedMarkdown: '1.something', + expectedTextContent: '1.something', + }, + { + description: 'a number followed by a closing parenthesis', + inputMarkdown: '1) something', + expectedMarkdown: '1) something', + expectedTextContent: '1) something', + }, + { + description: 'a hyphen without a following space', + inputMarkdown: '-something', + expectedMarkdown: '-something', + expectedTextContent: '-something', + }, + { + description: 'an asterisk without a following space', + inputMarkdown: '*something', + expectedMarkdown: '*something', + expectedTextContent: '*something', + }, + { + description: 'a plus sign without a following space', + inputMarkdown: '+something', + expectedMarkdown: '+something', + expectedTextContent: '+something', + }, + { + description: 'a blockquote marker followed by text', + inputMarkdown: '> something', + expectedMarkdown: '> something', + expectedTextContent: 'something', + }, + { + description: 'a hash marker followed by text', + inputMarkdown: '# something', + expectedMarkdown: '# something', + expectedTextContent: 'something', + }, + { + description: 'plain text containing a period after a number', + inputMarkdown: 'The version is 1. something else.', + expectedMarkdown: 'The version is 1. something else.', + expectedTextContent: 'The version is 1. something else.', + }, + { + description: 'two ordinary lines', + inputMarkdown: 'first line\nsecond line', + expectedMarkdown: 'first line\nsecond line', + expectedTextContent: 'first line\nsecond line', + }, + { + description: 'an ordered list with consecutive items', + inputMarkdown: '1. first\n2. second', + expectedMarkdown: '1. first\n2. second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'an ordered list with a non-one starting number', + inputMarkdown: '14. first\n15. second', + expectedMarkdown: '14. first\n15. second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'an ordered list with a zero-padded starting number', + inputMarkdown: '01. first\n02. second', + expectedMarkdown: '1. first\n2. second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'an unordered list written with hyphens', + inputMarkdown: '- first\n- second', + expectedMarkdown: '- first\n- second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'an unordered list written with asterisks', + inputMarkdown: '* first\n* second', + expectedMarkdown: '- first\n- second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'an unordered list written with plus signs', + inputMarkdown: '+ first\n+ second', + expectedMarkdown: '- first\n- second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'all supported heading levels', + inputMarkdown: '# one\n## two\n### three\n#### four\n##### five\n###### six', + expectedMarkdown: '# one\n## two\n### three\n#### four\n##### five\n###### six', + expectedTextContent: 'one\n\ntwo\n\nthree\n\nfour\n\nfive\n\nsix', + }, + { + description: 'a heading without a separating space', + inputMarkdown: '#heading', + expectedMarkdown: '#heading', + expectedTextContent: '#heading', + }, + { + description: 'a multiline blockquote', + inputMarkdown: '> first\n> second', + expectedMarkdown: '> first\n> second', + expectedTextContent: 'first\nsecond', + }, + { + description: 'a fenced code block containing Markdown-looking text', + inputMarkdown: '```\n**not bold** https://example.com @name πŸ˜€\n```', + expectedMarkdown: '```\n**not bold** https://example.com @name πŸ˜€\n```', + expectedTextContent: '**not bold** https://example.com @name πŸ˜€', + }, + { + description: 'an empty fenced code block', + inputMarkdown: '```\n```', + expectedMarkdown: '```\n```', + expectedTextContent: '', + }, + { + description: 'a fenced code block with a language suffix', + inputMarkdown: '```typescript\nconst value = 1;\n```', + expectedMarkdown: '```typescript\nconst value = 1;\n```', + expectedTextContent: 'const value = 1;', + }, + { + description: 'inline formatting and inline code', + inputMarkdown: '**bold** *italic* ***both*** ~~strike~~ `code`', + expectedMarkdown: '**bold** *italic* ***both*** ~~strike~~ `code`', + expectedTextContent: 'bold italic both strike code', + }, + { + description: 'inline formatting around punctuation', + inputMarkdown: '**bold**, *italic*! ~~strike~~.', + expectedMarkdown: '**bold**, *italic*! ~~strike~~.', + expectedTextContent: 'bold, italic! strike.', + }, + { + description: 'Unicode and emoji inside inline formatting', + inputMarkdown: '**cafΓ© πŸ˜€**', + expectedMarkdown: '**cafΓ© πŸ˜€**', + expectedTextContent: 'cafΓ© πŸ˜€', + }, + { + description: 'escaped formatting markers', + inputMarkdown: '\\*not italic\\*', + expectedMarkdown: '\\*not italic\\*', + expectedTextContent: '\\not italic\\', + }, + { + description: 'an unmatched formatting marker', + inputMarkdown: '**unmatched', + expectedMarkdown: '**unmatched', + expectedTextContent: '**unmatched', + }, + { + description: 'empty formatting markers', + inputMarkdown: '****', + expectedMarkdown: '****', + expectedTextContent: '****', + }, + { + description: 'formatting markers separated by a line break', + inputMarkdown: '**first\nsecond**', + expectedMarkdown: '**first\nsecond**', + expectedTextContent: '**first\nsecond**', + }, + { + description: 'a Markdown link', + inputMarkdown: '[Wire](https://wire.com)', + expectedMarkdown: '[Wire](https://wire.com)', + expectedTextContent: 'Wire', + }, +]; + +type MarkdownRoundTripCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly expectedCanonicalMarkdown: string; + readonly expectedTextContent: string; +}; + +const markdownRoundTripCharacterizationTestCases: readonly MarkdownRoundTripCharacterizationTestCase[] = [ + { + description: 'a plain paragraph', + inputMarkdown: 'plain paragraph', + expectedCanonicalMarkdown: 'plain paragraph', + expectedTextContent: 'plain paragraph', + }, + { + description: 'a multiline paragraph', + inputMarkdown: 'first line\nsecond line', + expectedCanonicalMarkdown: 'first line\nsecond line', + expectedTextContent: 'first line\nsecond line', + }, + { + description: 'combined inline formatting', + inputMarkdown: '**bold** *italic* ***both*** ~~strike~~ `code`', + expectedCanonicalMarkdown: '**bold** *italic* ***both*** ~~strike~~ `code`', + expectedTextContent: 'bold italic both strike code', + }, + { + description: 'inline formatting around punctuation', + inputMarkdown: '**bold**, *italic*! ~~strike~~.', + expectedCanonicalMarkdown: '**bold**, *italic*! ~~strike~~.', + expectedTextContent: 'bold, italic! strike.', + }, + { + description: 'Unicode and emoji inside inline formatting', + inputMarkdown: '**cafΓ© πŸ˜€**', + expectedCanonicalMarkdown: '**cafΓ© πŸ˜€**', + expectedTextContent: 'cafΓ© πŸ˜€', + }, + { + description: 'escaped formatting markers', + inputMarkdown: '\\*not italic\\*', + expectedCanonicalMarkdown: '\\*not italic\\*', + expectedTextContent: '\\not italic\\', + }, + { + description: 'an unmatched formatting marker', + inputMarkdown: '**unmatched', + expectedCanonicalMarkdown: '**unmatched', + expectedTextContent: '**unmatched', + }, + { + description: 'empty formatting markers', + inputMarkdown: '****', + expectedCanonicalMarkdown: '****', + expectedTextContent: '****', + }, + { + description: 'formatting markers separated by a line break', + inputMarkdown: '**first\nsecond**', + expectedCanonicalMarkdown: '**first\nsecond**', + expectedTextContent: '**first\nsecond**', + }, + { + description: 'a fenced code block', + inputMarkdown: '```\n**not bold** https://example.com\n```', + expectedCanonicalMarkdown: '```\n**not bold** https://example.com\n```', + expectedTextContent: '**not bold** https://example.com', + }, + { + description: 'an empty fenced code block', + inputMarkdown: '```\n```', + expectedCanonicalMarkdown: '```\n```', + expectedTextContent: '', + }, + { + description: 'a fenced code block with a language suffix', + inputMarkdown: '```typescript\nconst value = 1;\n```', + expectedCanonicalMarkdown: '```typescript\nconst value = 1;\n```', + expectedTextContent: 'const value = 1;', + }, + { + description: 'a heading', + inputMarkdown: '### heading', + expectedCanonicalMarkdown: '### heading', + expectedTextContent: 'heading', + }, + { + description: 'a multiline blockquote', + inputMarkdown: '> first\n> second', + expectedCanonicalMarkdown: '> first\n> second', + expectedTextContent: 'first\nsecond', + }, + { + description: 'an ordered list with a non-one starting number', + inputMarkdown: '14. first\n15. second', + expectedCanonicalMarkdown: '14. first\n15. second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'an unordered list with a non-canonical marker', + inputMarkdown: '* first\n* second', + expectedCanonicalMarkdown: '- first\n- second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'an ordered list with zero-padded item numbers', + inputMarkdown: '01. first\n02. second', + expectedCanonicalMarkdown: '1. first\n2. second', + expectedTextContent: 'first\n\nsecond', + }, + { + description: 'a mixed nested list', + inputMarkdown: '1. one\n - nested', + expectedCanonicalMarkdown: '1. one\n- nested', + expectedTextContent: 'one\n\nnested', + }, + { + description: 'a Markdown link', + inputMarkdown: '[Wire](https://wire.com)', + expectedCanonicalMarkdown: '[Wire](https://wire.com)', + expectedTextContent: 'Wire', + }, + { + description: 'the ambiguous date-like list input', + inputMarkdown: '14. - 25. september', + expectedCanonicalMarkdown: '14. - 25. september', + expectedTextContent: '- 25. september', + }, +]; + +describe('Wire Lexical Markdown characterization', () => { + it.each(markdownCharacterizationTestCases)( + 'preserves the current behavior for $description', + characterizationTestCase => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + harness.importMarkdown(characterizationTestCase.inputMarkdown); + + const actualMarkdown = harness.exportMarkdown(); + const actualTextContent = harness.getTextContent(); + + expect(actualMarkdown).toBe(characterizationTestCase.expectedMarkdown); + expect(actualTextContent).toBe(characterizationTestCase.expectedTextContent); + }, + ); +}); + +describe('Wire Lexical Markdown round trips', () => { + it.each(markdownRoundTripCharacterizationTestCases)( + 'keeps the canonical representation stable for $description', + characterizationTestCase => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + harness.importMarkdown(characterizationTestCase.inputMarkdown); + + const actualFirstExport = harness.exportMarkdown(); + + harness.importMarkdown(actualFirstExport); + + const actualSecondExport = harness.exportMarkdown(); + const actualTextContent = harness.getTextContent(); + const expectedCanonicalMarkdown = characterizationTestCase.expectedCanonicalMarkdown; + const expectedTextContent = characterizationTestCase.expectedTextContent; + + expect(actualFirstExport).toBe(expectedCanonicalMarkdown); + expect(actualSecondExport).toBe(expectedCanonicalMarkdown); + expect(actualTextContent).toBe(expectedTextContent); + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownRendererCharacterization.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownRendererCharacterization.test.ts new file mode 100644 index 00000000000..ee9169222ee --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownRendererCharacterization.test.ts @@ -0,0 +1,106 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {renderMessage} from 'Util/messageRenderer'; + +import {createWireLexicalEditorTestHarness} from './testSupport/createWireLexicalEditorTestHarness'; + +type MarkdownRendererCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly expectedHtml: string; +}; + +const markdownRendererCharacterizationTestCases: readonly MarkdownRendererCharacterizationTestCase[] = [ + { + description: 'a plain paragraph', + inputMarkdown: 'plain paragraph', + expectedHtml: 'plain paragraph', + }, + { + description: 'a multiline paragraph', + inputMarkdown: 'first line\nsecond line', + expectedHtml: 'first line
second line', + }, + { + description: 'combined inline formatting', + inputMarkdown: '**bold** *italic* ~~strike~~ `code`', + expectedHtml: 'bold italic strike code', + }, + { + description: 'an empty fenced code block', + inputMarkdown: '```\n```', + expectedHtml: '
', + }, + { + description: 'a heading', + inputMarkdown: '### heading', + expectedHtml: '
heading
', + }, + { + description: 'a multiline quote', + inputMarkdown: '> first\n> second', + expectedHtml: '
first
second
', + }, + { + description: 'an ordered list', + inputMarkdown: '14. first\n15. second', + expectedHtml: '
    \n
  1. first
  2. \n
  3. second
  4. \n
', + }, + { + description: 'a mixed nested list', + inputMarkdown: '1. one\n - nested', + expectedHtml: '
    \n
  1. one
  2. \n
\n
    \n
  • nested
  • \n
', + }, + { + description: 'an unordered list', + inputMarkdown: '- first\n- second', + expectedHtml: '
    \n
  • first
  • \n
  • second
  • \n
', + }, + { + description: 'a Markdown link', + inputMarkdown: '[Wire](https://wire.com)', + expectedHtml: + 'Wire', + }, + { + description: 'the ambiguous date-like list input', + inputMarkdown: '14. - 25. september', + expectedHtml: + '
    \n
  1. \n
      \n
    • \n
        \n
      1. september
      2. \n
      \n
    • \n
    \n
  2. \n
', + }, +]; + +function renderExportedMarkdown(inputMarkdown: string): string { + const harness = createWireLexicalEditorTestHarness(); + harness.importMarkdown(inputMarkdown); + + return renderMessage(harness.exportMarkdown()); +} + +describe('Wire Lexical Markdown to message renderer', () => { + it.each(markdownRendererCharacterizationTestCases)( + 'preserves the current rendered result for $description', + characterizationTestCase => { + const actualHtml = renderExportedMarkdown(characterizationTestCase.inputMarkdown); + const expectedHtml = characterizationTestCase.expectedHtml; + + expect(actualHtml).toBe(expectedHtml); + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownShortcuts.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownShortcuts.test.ts new file mode 100644 index 00000000000..24cdf1b4ccb --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/markdownShortcuts.test.ts @@ -0,0 +1,202 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$createParagraphNode, $getRoot, $getSelection, $isRangeSelection, LexicalEditor} from 'lexical'; +import {registerMarkdownShortcuts} from '@lexical/markdown'; + +import {createWireLexicalEditorTestHarness} from './testSupport/createWireLexicalEditorTestHarness'; + +import {markdownTransformers} from './utils/markdownTransformers'; + +type MarkdownShortcutCharacterizationTestCase = { + readonly description: string; + readonly typedText: string; + readonly expectedMarkdown: string; + readonly expectedTextContent: string; +}; + +const markdownShortcutCharacterizationTestCases: readonly MarkdownShortcutCharacterizationTestCase[] = [ + { + description: 'an ordered-list shortcut', + typedText: '1. item', + expectedMarkdown: '1. item', + expectedTextContent: 'item', + }, + { + description: 'an ambiguous non-one ordered-list input', + typedText: '14. - 25. september', + expectedMarkdown: '14. - 25. september', + expectedTextContent: '- 25. september', + }, + { + description: 'a non-one ordered-list input with a month name', + typedText: '14. September', + expectedMarkdown: '14. September', + expectedTextContent: 'September', + }, + { + description: 'a year-like ordered-list input', + typedText: '2026. something', + expectedMarkdown: '2026. something', + expectedTextContent: 'something', + }, + { + description: 'a zero-padded ordered-list input', + typedText: '01. something', + expectedMarkdown: '1. something', + expectedTextContent: 'something', + }, + { + description: 'a zero ordered-list input', + typedText: '0. something', + expectedMarkdown: '0. something', + expectedTextContent: 'something', + }, + { + description: 'a number without a separating space', + typedText: '1.something', + expectedMarkdown: '1.something', + expectedTextContent: '1.something', + }, + { + description: 'a number followed by a closing parenthesis', + typedText: '1) something', + expectedMarkdown: '1) something', + expectedTextContent: '1) something', + }, + { + description: 'a dash unordered-list shortcut', + typedText: '- item', + expectedMarkdown: '- item', + expectedTextContent: 'item', + }, + { + description: 'a dash unordered-list input with a following space', + typedText: '- something', + expectedMarkdown: '- something', + expectedTextContent: 'something', + }, + { + description: 'a dash without a following space', + typedText: '-something', + expectedMarkdown: '-something', + expectedTextContent: '-something', + }, + { + description: 'an asterisk unordered-list shortcut', + typedText: '* item', + expectedMarkdown: '- item', + expectedTextContent: 'item', + }, + { + description: 'an asterisk unordered-list input with a following space', + typedText: '* something', + expectedMarkdown: '- something', + expectedTextContent: 'something', + }, + { + description: 'an asterisk without a following space', + typedText: '*something', + expectedMarkdown: '*something', + expectedTextContent: '*something', + }, + { + description: 'a plus unordered-list shortcut', + typedText: '+ item', + expectedMarkdown: '- item', + expectedTextContent: 'item', + }, + { + description: 'a plus unordered-list input with a following space', + typedText: '+ something', + expectedMarkdown: '- something', + expectedTextContent: 'something', + }, + { + description: 'a plus without a following space', + typedText: '+something', + expectedMarkdown: '+something', + expectedTextContent: '+something', + }, + { + description: 'a heading shortcut', + typedText: '## heading', + expectedMarkdown: '## heading', + expectedTextContent: 'heading', + }, + { + description: 'a blockquote shortcut', + typedText: '> quote', + expectedMarkdown: '> quote', + expectedTextContent: 'quote', + }, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +function prepareEditor(editor: LexicalEditor): void { + editor.update( + () => { + const paragraph = $createParagraphNode(); + $getRoot().clear(); + $getRoot().append(paragraph); + paragraph.select(); + }, + {discrete: true}, + ); +} + +function typeText(editor: LexicalEditor, typedText: string): void { + for (const character of typedText) { + editor.update( + () => { + const selection = $getSelection(); + if (!$isRangeSelection(selection)) { + throwEditorError(new Error('The typing characterization requires a range selection')); + } + selection.insertText(character); + }, + {discrete: true}, + ); + } +} + +describe('Wire Lexical Markdown shortcuts', () => { + it.each(markdownShortcutCharacterizationTestCases)( + 'preserves the current typed behavior for $description', + characterizationTestCase => { + const harness = createWireLexicalEditorTestHarness(); + const unregisterMarkdownShortcuts = registerMarkdownShortcuts(harness.editor, markdownTransformers); + + try { + prepareEditor(harness.editor); + typeText(harness.editor, characterizationTestCase.typedText); + + const actualMarkdown = harness.exportMarkdown(); + const actualTextContent = harness.getTextContent(); + + expect(actualMarkdown).toBe(characterizationTestCase.expectedMarkdown); + expect(actualTextContent).toBe(characterizationTestCase.expectedTextContent); + } finally { + unregisterMarkdownShortcuts(); + } + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/mentionMarkdownCharacterization.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/mentionMarkdownCharacterization.test.ts new file mode 100644 index 00000000000..7e448bf9fcf --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/mentionMarkdownCharacterization.test.ts @@ -0,0 +1,155 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$convertFromMarkdownString, $convertToMarkdownString, type Transformer} from '@lexical/markdown'; +import {$getRoot, $nodesOfType} from 'lexical'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from './testSupport/createWireLexicalEditorTestHarness'; +import {MentionNode} from './nodes/MentionNode'; +import {getMentionMarkdownTransformer} from './plugins/EditedMessagePlugin/getMentionMarkdownTransformer/getMentionMarkdownTransformer'; +import {markdownTransformers} from './utils/markdownTransformers'; + +type MentionMarkdownCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly allowedMentions: string[]; + readonly expectedMarkdown: string; + readonly expectedTextContent: string; + readonly expectedMentionValues: string[]; +}; + +const mentionMarkdownCharacterizationTestCases: readonly MentionMarkdownCharacterizationTestCase[] = [ + { + description: 'one allowed mention between ordinary text', + inputMarkdown: 'Hello @Alice!', + allowedMentions: ['@Alice'], + expectedMarkdown: 'Hello @Alice!', + expectedTextContent: 'Hello @Alice!', + expectedMentionValues: ['@Alice'], + }, + { + description: 'multiple mentions with punctuation and a line break', + inputMarkdown: '@Alice, meet @Bob.\n@Alice', + allowedMentions: ['@Alice', '@Bob'], + expectedMarkdown: '@Alice, meet @Bob.\n@Alice', + expectedTextContent: '@Alice, meet @Bob.\n@Alice', + expectedMentionValues: ['@Alice', '@Bob', '@Alice'], + }, + { + description: 'a mention inside bold formatting', + inputMarkdown: '**Hello @Alice**', + allowedMentions: ['@Alice'], + expectedMarkdown: '**Hello** @Alice', + expectedTextContent: 'Hello @Alice', + expectedMentionValues: ['@Alice'], + }, + { + description: 'a mention inside an unordered list item', + inputMarkdown: '- @Alice', + allowedMentions: ['@Alice'], + expectedMarkdown: '- @Alice', + expectedTextContent: '@Alice', + expectedMentionValues: ['@Alice'], + }, + { + description: 'a mention that is not allowed', + inputMarkdown: 'Hello @Unknown!', + allowedMentions: ['@Alice'], + expectedMarkdown: 'Hello @Unknown!', + expectedTextContent: 'Hello @Unknown!', + expectedMentionValues: [], + }, + { + description: 'mention markup with no allowed mentions', + inputMarkdown: '@Alice', + allowedMentions: [], + expectedMarkdown: '@Alice', + expectedTextContent: '@Alice', + expectedMentionValues: [], + }, +]; + +type ImportMentionMarkdownOptions = { + readonly harness: WireLexicalEditorTestHarness; + readonly inputMarkdown: string; + readonly allowedMentions: string[]; +}; + +function createMentionTransformers(allowedMentions: string[]): Transformer[] { + const mentionMarkdownTransformer = getMentionMarkdownTransformer(allowedMentions); + + return [mentionMarkdownTransformer, ...markdownTransformers]; +} + +function importMentionMarkdown(importMentionMarkdownOptions: ImportMentionMarkdownOptions): void { + const {harness, inputMarkdown, allowedMentions} = importMentionMarkdownOptions; + const transformers = createMentionTransformers(allowedMentions); + + harness.editor.update( + function (): void { + $getRoot().clear(); + $convertFromMarkdownString(inputMarkdown, transformers, undefined, true); + }, + {discrete: true}, + ); +} + +function exportMentionMarkdown(harness: WireLexicalEditorTestHarness, allowedMentions: string[]): string { + const transformers = createMentionTransformers(allowedMentions); + + return harness.editor.getEditorState().read(function (): string { + return $convertToMarkdownString(transformers, undefined, true); + }); +} + +function getMentionValues(harness: WireLexicalEditorTestHarness): string[] { + return harness.editor.getEditorState().read(function (): string[] { + return $nodesOfType(MentionNode).map(function (mentionNode: MentionNode): string { + return mentionNode.getTextContent(); + }); + }); +} + +describe('Wire Lexical mention Markdown characterization', () => { + it.each(mentionMarkdownCharacterizationTestCases)( + 'preserves the current behavior for $description', + characterizationTestCase => { + const harness = createWireLexicalEditorTestHarness(); + + importMentionMarkdown({ + harness, + inputMarkdown: characterizationTestCase.inputMarkdown, + allowedMentions: characterizationTestCase.allowedMentions, + }); + + const actualMarkdown = exportMentionMarkdown(harness, characterizationTestCase.allowedMentions); + const actualTextContent = harness.getTextContent(); + const actualMentionValues = getMentionValues(harness); + const expectedMarkdown = characterizationTestCase.expectedMarkdown; + const expectedTextContent = characterizationTestCase.expectedTextContent; + const expectedMentionValues = characterizationTestCase.expectedMentionValues; + + expect(actualMarkdown).toBe(expectedMarkdown); + expect(actualTextContent).toBe(expectedTextContent); + expect(actualMentionValues).toEqual(expectedMentionValues); + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/EmojiNode.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/EmojiNode.test.ts new file mode 100644 index 00000000000..66925af40e4 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/EmojiNode.test.ts @@ -0,0 +1,109 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {assertNotNull} from '@sindresorhus/is'; +import {$createParagraphNode, $getRoot, $nodesOfType, LexicalEditor} from 'lexical'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from '../testSupport/createWireLexicalEditorTestHarness'; + +import {EmojiNode} from './EmojiNode'; + +function appendEmojiNode(editor: LexicalEditor, emojiText: string): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append(new EmojiNode(emojiText)); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +function getEmojiNodeText(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + const [emojiNode] = $nodesOfType(EmojiNode); + assertNotNull(emojiNode); + + return emojiNode.getTextContent(); + }); +} + +function getEmojiNodeCount(editor: LexicalEditor): number { + return editor.getEditorState().read(() => { + return $nodesOfType(EmojiNode).length; + }); +} + +describe('EmojiNode', () => { + it('serializes its custom node type while preserving the emoji text', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + appendEmojiNode(harness.editor, 'πŸ˜€'); + + const serializedEditorState = harness.editor.getEditorState().toJSON(); + const expectedSerializedEmoji = expect.objectContaining({type: 'emoji', text: 'πŸ˜€'}); + + expect(serializedEditorState.root.children).toEqual([ + expect.objectContaining({children: [expectedSerializedEmoji]}), + ]); + }); + + it('restores its custom node type from serialized editor state', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + appendEmojiNode(harness.editor, 'πŸ§ͺ'); + + const serializedEditorState = harness.editor.getEditorState().toJSON(); + const restoredEditorState = harness.editor.parseEditorState(serializedEditorState); + harness.editor.setEditorState(restoredEditorState); + + expect(getEmojiNodeCount(harness.editor)).toBe(1); + expect(getEmojiNodeText(harness.editor)).toBe('πŸ§ͺ'); + }); + + it('exports an inserted custom emoji node as ordinary Markdown text', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + appendEmojiNode(harness.editor, '🌍'); + + expect(harness.exportMarkdown()).toBe('🌍'); + expect(harness.getTextContent()).toBe('🌍'); + }); + + it('renders the emoji text inside the custom DOM wrapper', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + const rootElement = document.createElement('div'); + + harness.editor.setRootElement(rootElement); + appendEmojiNode(harness.editor, 'πŸŽ‰'); + + const emojiInnerElement = rootElement.querySelector('.emoji-inner'); + assertNotNull(emojiInnerElement); + const emojiOuterElement = emojiInnerElement.parentElement; + assertNotNull(emojiOuterElement); + + expect(emojiInnerElement).toHaveTextContent('πŸŽ‰'); + expect(emojiOuterElement.tagName).toBe('SPAN'); + + harness.editor.setRootElement(null); + rootElement.remove(); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/Mention.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/Mention.test.tsx new file mode 100644 index 00000000000..2489e4011cd --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/Mention.test.tsx @@ -0,0 +1,237 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {assertNotNull} from '@sindresorhus/is'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {LexicalErrorBoundary} from '@lexical/react/LexicalErrorBoundary'; +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $getSelection, + $isRangeSelection, + KEY_ARROW_LEFT_COMMAND, + KEY_ARROW_RIGHT_COMMAND, + KEY_BACKSPACE_COMMAND, + KEY_DELETE_COMMAND, + LexicalCommand, + LexicalEditor, +} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render, type RenderResult} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../editorConfig'; + +import {$createMentionNode} from './MentionNode'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type MentionEditorTestFixture = { + readonly editor: LexicalEditor; + readonly editorElement: HTMLElement; +}; + +type KeyboardCommandResult = { + readonly wasHandled: boolean; + readonly defaultWasPrevented: boolean; +}; + +const EditorCapturePlugin: FunctionComponent = props => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + props.onReady(editor); + }, [editor, props.onReady]); + + return null; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function renderMentionEditor(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + const renderedMentionEditor: RenderResult = render( + + + } + ErrorBoundary={LexicalErrorBoundary} + /> + , + ); + + const editorElement = renderedMentionEditor.getByTestId('mention-editor'); + const fixture = capturedEditor.map(editor => { + return {editor, editorElement}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function setMentionContent(editor: LexicalEditor, cursorBeforeMention: boolean): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textBeforeMention = $createTextNode('before '); + const mentionNode = $createMentionNode('@', 'Alice'); + const textAfterMention = $createTextNode(' after'); + + paragraphNode.append(textBeforeMention, mentionNode, textAfterMention); + $getRoot().clear().append(paragraphNode); + + if (cursorBeforeMention) { + textBeforeMention.selectEnd(); + } else { + textAfterMention.select(0, 0); + } + }, + {discrete: true}, + ); +} + +function dispatchKeyboardCommand( + editor: LexicalEditor, + command: LexicalCommand, + key: string, +): KeyboardCommandResult { + const keyboardEvent = new KeyboardEvent('keydown', {cancelable: true, key}); + const wasHandled = editor.dispatchCommand(command, keyboardEvent); + + return {wasHandled, defaultWasPrevented: keyboardEvent.defaultPrevented}; +} + +async function selectMentionWithBackspace(fixture: MentionEditorTestFixture): Promise { + await act(async () => { + setMentionContent(fixture.editor, false); + await Promise.resolve(); + }); + + let commandResult: KeyboardCommandResult = {wasHandled: false, defaultWasPrevented: false}; + await act(async () => { + commandResult = dispatchKeyboardCommand(fixture.editor, KEY_BACKSPACE_COMMAND, 'Backspace'); + await Promise.resolve(); + }); + + return commandResult; +} + +function getFocusedMentionElement(editorElement: HTMLElement): HTMLElement | null { + return editorElement.querySelector('[data-uie-name="item-input-mention"].focused-mentions'); +} + +describe('Mention', () => { + it('selects a mention when Backspace is pressed immediately after it', async () => { + const fixture = unwrap(renderMentionEditor()); + + const commandResult = await selectMentionWithBackspace(fixture); + const focusedMentionElement = getFocusedMentionElement(fixture.editorElement); + + assertNotNull(focusedMentionElement); + expect(commandResult).toEqual({wasHandled: true, defaultWasPrevented: true}); + expect(focusedMentionElement).toHaveTextContent('@Alice'); + }); + + it('removes a selected mention when Backspace is pressed again', async () => { + const fixture = unwrap(renderMentionEditor()); + + await selectMentionWithBackspace(fixture); + + let commandResult: KeyboardCommandResult = {wasHandled: false, defaultWasPrevented: false}; + await act(async () => { + commandResult = dispatchKeyboardCommand(fixture.editor, KEY_BACKSPACE_COMMAND, 'Backspace'); + await Promise.resolve(); + }); + + expect(commandResult).toEqual({wasHandled: true, defaultWasPrevented: true}); + expect( + fixture.editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }), + ).toBe('before after'); + expect(getFocusedMentionElement(fixture.editorElement)).toBeNull(); + }); + + it('selects and then removes a mention when Delete is pressed immediately before it', async () => { + const fixture = unwrap(renderMentionEditor()); + + await act(async () => { + setMentionContent(fixture.editor, true); + await Promise.resolve(); + }); + + let selectionResult: KeyboardCommandResult = {wasHandled: false, defaultWasPrevented: false}; + await act(async () => { + selectionResult = dispatchKeyboardCommand(fixture.editor, KEY_DELETE_COMMAND, 'Delete'); + await Promise.resolve(); + }); + + let deletionResult: KeyboardCommandResult = {wasHandled: false, defaultWasPrevented: false}; + await act(async () => { + deletionResult = dispatchKeyboardCommand(fixture.editor, KEY_DELETE_COMMAND, 'Delete'); + await Promise.resolve(); + }); + + expect(selectionResult).toEqual({wasHandled: true, defaultWasPrevented: true}); + expect(deletionResult).toEqual({wasHandled: true, defaultWasPrevented: true}); + expect( + fixture.editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }), + ).toBe('before after'); + expect(getFocusedMentionElement(fixture.editorElement)).toBeNull(); + }); + + it.each([ + {description: 'ArrowLeft', command: KEY_ARROW_LEFT_COMMAND, key: 'ArrowLeft'}, + {description: 'ArrowRight', command: KEY_ARROW_RIGHT_COMMAND, key: 'ArrowRight'}, + ])('moves the selection away from a selected mention with $description', async testCase => { + const fixture = unwrap(renderMentionEditor()); + + await selectMentionWithBackspace(fixture); + + let commandResult: KeyboardCommandResult = {wasHandled: false, defaultWasPrevented: false}; + await act(async () => { + commandResult = dispatchKeyboardCommand(fixture.editor, testCase.command, testCase.key); + await Promise.resolve(); + }); + + const hasRangeSelection = fixture.editor.getEditorState().read(() => { + return $isRangeSelection($getSelection()); + }); + + expect(commandResult).toEqual({wasHandled: true, defaultWasPrevented: true}); + expect(hasRangeSelection).toBe(true); + expect(getFocusedMentionElement(fixture.editorElement)).toBeNull(); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/MentionNode.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/MentionNode.test.ts new file mode 100644 index 00000000000..05be3a6d3d0 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/nodes/MentionNode.test.ts @@ -0,0 +1,124 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {assertNotNull} from '@sindresorhus/is'; +import assert from 'node:assert'; +import {$createParagraphNode, $getRoot, $nodesOfType, LexicalEditor} from 'lexical'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from '../testSupport/createWireLexicalEditorTestHarness'; + +import {$createMentionNode, MentionNode} from './MentionNode'; + +type MentionNodeDetails = { + readonly trigger: string; + readonly value: string; + readonly textContent: string; +}; + +function appendMentionNode(editor: LexicalEditor, trigger: string, value: string): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createMentionNode(trigger, value)); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +function getMentionNodeDetails(editor: LexicalEditor): MentionNodeDetails { + return editor.getEditorState().read(() => { + const [mentionNode] = $nodesOfType(MentionNode); + assertNotNull(mentionNode); + + return { + trigger: mentionNode.getTrigger(), + value: mentionNode.getValue(), + textContent: mentionNode.getTextContent(), + }; + }); +} + +function getExportedMentionElement(editor: LexicalEditor): HTMLElement { + return editor.getEditorState().read(() => { + const [mentionNode] = $nodesOfType(MentionNode); + assertNotNull(mentionNode); + + const exportedDOM = mentionNode.exportDOM(); + assertNotNull(exportedDOM.element); + assert(exportedDOM.element instanceof HTMLElement); + + return exportedDOM.element; + }); +} + +describe('MentionNode', () => { + it('serializes its trigger and value separately from its display text', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + appendMentionNode(harness.editor, '@', 'Alice'); + + const serializedEditorState = harness.editor.getEditorState().toJSON(); + const expectedSerializedMention = expect.objectContaining({ + type: 'Mention', + version: 1, + trigger: '@', + value: 'Alice', + }); + + expect(serializedEditorState.root.children).toEqual([ + expect.objectContaining({children: [expectedSerializedMention]}), + ]); + expect(harness.getTextContent()).toBe('@Alice'); + }); + + it('restores its trigger, value, and display text from serialized editor state', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + appendMentionNode(harness.editor, '@', 'Alice'); + + const serializedEditorState = harness.editor.getEditorState().toJSON(); + const restoredEditorState = harness.editor.parseEditorState(serializedEditorState); + harness.editor.setEditorState(restoredEditorState); + + const actualMentionNodeDetails = getMentionNodeDetails(harness.editor); + const expectedMentionNodeDetails: MentionNodeDetails = { + trigger: '@', + value: 'Alice', + textContent: '@Alice', + }; + + expect(actualMentionNodeDetails).toEqual(expectedMentionNodeDetails); + }); + + it('exports the DOM attributes consumed when a mention is pasted', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + appendMentionNode(harness.editor, '@', 'Alice'); + + const exportedMentionElement = getExportedMentionElement(harness.editor); + + expect(exportedMentionElement).toHaveAttribute('data-lexical-mention', 'true'); + expect(exportedMentionElement).toHaveAttribute('data-lexical-mention-trigger', '@'); + expect(exportedMentionElement).toHaveAttribute('data-lexical-mention-value', 'Alice'); + expect(exportedMentionElement).toHaveTextContent('@Alice'); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/AutoLinkPlugin/AutoLinkPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/AutoLinkPlugin/AutoLinkPlugin.test.tsx new file mode 100644 index 00000000000..71e8937837c --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/AutoLinkPlugin/AutoLinkPlugin.test.tsx @@ -0,0 +1,151 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {AutoLinkNode} from '@lexical/link'; +import {$convertToMarkdownString} from '@lexical/markdown'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $createTextNode, $getRoot, $nodesOfType, LexicalEditor} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {markdownTransformers} from '../../utils/markdownTransformers'; +import {AutoLinkPlugin} from './AutoLinkPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type AutoLinkCharacterizationTestCase = { + readonly description: string; + readonly inputText: string; + readonly expectedLinkUrls: readonly string[]; + readonly expectedMarkdown: string; +}; + +type AutoLinkPluginTestFixture = { + readonly editor: LexicalEditor; +}; + +const autoLinkCharacterizationTestCases: readonly AutoLinkCharacterizationTestCase[] = [ + { + description: 'a supported HTTPS URL', + inputText: 'Visit https://wire.com', + expectedLinkUrls: ['https://wire.com'], + expectedMarkdown: 'Visit [https://wire.com](https://wire.com)', + }, + { + description: 'a URL followed by punctuation', + inputText: 'Visit https://wire.com, today', + expectedLinkUrls: ['https://wire.com,'], + expectedMarkdown: 'Visit [https://wire.com,](https://wire.com,) today', + }, + { + description: 'an unsupported FTP URL', + inputText: 'Visit ftp://wire.com', + expectedLinkUrls: [], + expectedMarkdown: 'Visit ftp://wire.com', + }, + { + description: 'an HTTPS URL embedded in another word', + inputText: 'prefixhttps://wire.com', + expectedLinkUrls: [], + expectedMarkdown: 'prefixhttps://wire.com', + }, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function renderAutoLinkPlugin(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function importText(editor: LexicalEditor, text: string): void { + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(text)); + $getRoot().clear(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + {discrete: true}, + ); +} + +function getAutoLinkUrls(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => { + return $nodesOfType(AutoLinkNode).map(autoLinkNode => { + return autoLinkNode.getURL(); + }); + }); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +describe('AutoLinkPlugin', () => { + it.each(autoLinkCharacterizationTestCases)('preserves current behavior for $description', testCase => { + const fixture = unwrap(renderAutoLinkPlugin()); + + act(() => { + importText(fixture.editor, testCase.inputText); + }); + + expect(getAutoLinkUrls(fixture.editor)).toEqual(testCase.expectedLinkUrls); + expect(getMarkdown(fixture.editor)).toBe(testCase.expectedMarkdown); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.test.tsx new file mode 100644 index 00000000000..8c120c3ed12 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/BlockquotePlugin/BlockquotePlugin.test.tsx @@ -0,0 +1,215 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$convertToMarkdownString} from '@lexical/markdown'; +import {$createQuoteNode} from '@lexical/rich-text'; +import { + $createLineBreakNode, + $createTextNode, + $getRoot, + KEY_BACKSPACE_COMMAND, + KEY_ENTER_COMMAND, + LexicalEditor, +} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {Config} from 'src/script/Config'; +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {SendPlugin} from '../SendPlugin/SendPlugin'; +import {markdownTransformers} from '../../utils/markdownTransformers'; +import {BlockquotePlugin} from './BlockquotePlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type BlockquotePluginTestFixture = { + readonly editor: LexicalEditor; + readonly onSend: jest.Mock; +}; + +type BlockquotePluginTestFunction = () => void; + +const defaultFeatureConfiguration = Config.getConfig().FEATURE; + +function throwEditorError(error: unknown): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function setMessageFormatButtonsEnabled(enabled: boolean): void { + Config._dangerouslySetConfigFeaturesForDebug({ + ...Config.getConfig().FEATURE, + ENABLE_MESSAGE_FORMAT_BUTTONS: enabled, + }); +} + +function withDefaultFeatureConfiguration(testFunction: BlockquotePluginTestFunction): BlockquotePluginTestFunction { + return () => { + try { + testFunction(); + } finally { + Config._dangerouslySetConfigFeaturesForDebug(defaultFeatureConfiguration); + } + }; +} + +function renderBlockquotePlugin(includeRichTextPlugin: boolean): Result { + const onSend = jest.fn(); + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + {includeRichTextPlugin && ( + } ErrorBoundary={throwEditorError} /> + )} + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor, onSend}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function prepareQuote(editor: LexicalEditor, includeTrailingLineBreak: boolean): void { + editor.update( + () => { + const quote = $createQuoteNode(); + quote.append($createTextNode('quoted')); + + if (includeTrailingLineBreak) { + quote.append($createLineBreakNode()); + } + + $getRoot().clear(); + $getRoot().append(quote); + quote.selectEnd(); + }, + {discrete: true}, + ); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +describe('BlockquotePlugin', () => { + it( + 'leaves a null Enter event unhandled', + withDefaultFeatureConfiguration(() => { + const fixture = unwrap(renderBlockquotePlugin(false)); + + const wasHandled = fixture.editor.dispatchCommand(KEY_ENTER_COMMAND, null); + + expect(wasHandled).toBe(false); + expect(fixture.onSend).not.toHaveBeenCalled(); + }), + ); + + it( + 'inserts a line break inside a quote for Shift+Enter', + withDefaultFeatureConfiguration(() => { + setMessageFormatButtonsEnabled(true); + const fixture = unwrap(renderBlockquotePlugin(true)); + act(() => { + prepareQuote(fixture.editor, false); + }); + const shiftEnterEvent = new KeyboardEvent('keydown', {cancelable: true, shiftKey: true}); + + let wasHandled = false; + act(() => { + wasHandled = fixture.editor.dispatchCommand(KEY_ENTER_COMMAND, shiftEnterEvent); + }); + + expect(wasHandled).toBe(true); + expect(shiftEnterEvent.defaultPrevented).toBe(true); + expect(getMarkdown(fixture.editor)).toBe('> quoted'); + expect(fixture.onSend).not.toHaveBeenCalled(); + }), + ); + + it( + 'keeps the quote and adds an empty quoted line when Backspace follows a trailing line break', + withDefaultFeatureConfiguration(() => { + const fixture = unwrap(renderBlockquotePlugin(true)); + act(() => { + prepareQuote(fixture.editor, true); + }); + const backspaceEvent = new KeyboardEvent('keydown', {cancelable: true, key: 'Backspace'}); + + let wasHandled = false; + act(() => { + wasHandled = fixture.editor.dispatchCommand(KEY_BACKSPACE_COMMAND, backspaceEvent); + }); + + expect(wasHandled).toBe(true); + expect(backspaceEvent.defaultPrevented).toBe(true); + expect(getMarkdown(fixture.editor)).toBe('> quoted\n> '); + }), + ); + + it( + 'prevents Backspace but leaves a quote unchanged without a trailing line break', + withDefaultFeatureConfiguration(() => { + const fixture = unwrap(renderBlockquotePlugin(false)); + act(() => { + prepareQuote(fixture.editor, false); + }); + const backspaceEvent = new KeyboardEvent('keydown', {cancelable: true, key: 'Backspace'}); + + let wasHandled = false; + act(() => { + wasHandled = fixture.editor.dispatchCommand(KEY_BACKSPACE_COMMAND, backspaceEvent); + }); + + expect(wasHandled).toBe(false); + expect(backspaceEvent.defaultPrevented).toBe(true); + expect(getMarkdown(fixture.editor)).toBe('> quoted'); + }), + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/CodeHighlightPlugin/CodeHighlightPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/CodeHighlightPlugin/CodeHighlightPlugin.test.tsx new file mode 100644 index 00000000000..a8b7ed82941 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/CodeHighlightPlugin/CodeHighlightPlugin.test.tsx @@ -0,0 +1,129 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {CodeHighlightNode, $createCodeNode, $isCodeHighlightNode} from '@lexical/code'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createTextNode, $getRoot, $nodesOfType, LexicalEditor} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {CodeHighlightPlugin} from './CodeHighlightPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type CodeHighlightPluginTestFixture = { + readonly editor: LexicalEditor; +}; + +type CodeHighlightToken = { + readonly text: string; + readonly highlightType: string | null | undefined; +}; + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function renderCodeHighlightPlugin(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function setJavaScriptCode(editor: LexicalEditor): void { + editor.update( + () => { + const codeNode = $createCodeNode('javascript'); + codeNode.append($createTextNode('const greeting = "hello";')); + $getRoot().clear().append(codeNode); + codeNode.selectEnd(); + }, + {discrete: true}, + ); +} + +function getCodeHighlightTokens(editor: LexicalEditor): CodeHighlightToken[] { + return editor.getEditorState().read(() => { + return $nodesOfType(CodeHighlightNode) + .filter($isCodeHighlightNode) + .map(codeHighlightNode => { + return { + text: codeHighlightNode.getTextContent(), + highlightType: codeHighlightNode.getHighlightType(), + }; + }); + }); +} + +describe('CodeHighlightPlugin', () => { + it('tokenizes JavaScript code while preserving its text content', () => { + const fixture = unwrap(renderCodeHighlightPlugin()); + + act(() => { + setJavaScriptCode(fixture.editor); + }); + + expect( + fixture.editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }), + ).toBe('const greeting = "hello";'); + expect(getCodeHighlightTokens(fixture.editor)).toEqual([ + {text: 'const', highlightType: 'keyword'}, + {text: ' greeting ', highlightType: undefined}, + {text: '=', highlightType: 'operator'}, + {text: ' ', highlightType: undefined}, + {text: '"hello"', highlightType: 'string'}, + {text: ';', highlightType: 'punctuation'}, + ]); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/DraftStatePlugin/DraftStatePlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/DraftStatePlugin/DraftStatePlugin.test.tsx new file mode 100644 index 00000000000..5781190a2ab --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/DraftStatePlugin/DraftStatePlugin.test.tsx @@ -0,0 +1,223 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$convertToMarkdownString} from '@lexical/markdown'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $getRoot, $nodesOfType, LexicalEditor} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {render, waitFor} from '@testing-library/react'; + +import {DraftState} from 'Components/InputBar/common/draftState/draftState'; +import {unwrap} from 'Util/test/resultTestSupport'; + +import {EmojiNode} from '../../nodes/EmojiNode'; +import {editorConfig} from '../../editorConfig'; +import {$createMentionNode, MentionNode} from '../../nodes/MentionNode'; +import {markdownTransformers} from '../../utils/markdownTransformers'; +import {createWireLexicalEditorTestHarness} from '../../testSupport/createWireLexicalEditorTestHarness'; +import {DraftStatePlugin} from './DraftStatePlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type DraftStateCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly expectedMarkdown: string; + readonly expectedTextContent: string; +}; + +type DraftStatePluginTestFixture = { + readonly editor: LexicalEditor; + readonly loadDraftState: jest.Mock, []>; +}; + +const draftStateCharacterizationTestCases: readonly DraftStateCharacterizationTestCase[] = [ + { + description: 'a plain paragraph', + inputMarkdown: 'draft message', + expectedMarkdown: 'draft message', + expectedTextContent: 'draft message', + }, + { + description: 'formatted and multiline content', + inputMarkdown: '**draft**\n\nsecond line', + expectedMarkdown: '**draft**\n\nsecond line', + expectedTextContent: 'draft\n\n\n\nsecond line', + }, + { + description: 'a list and a link', + inputMarkdown: '- first\n- [second](https://wire.com)', + expectedMarkdown: '- first\n- [second](https://wire.com)', + expectedTextContent: 'first\n\nsecond', + }, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function createSerializedEditorState(inputMarkdown: string): string { + const sourceHarness = createWireLexicalEditorTestHarness(); + sourceHarness.importMarkdown(inputMarkdown); + + return JSON.stringify(sourceHarness.editor.getEditorState().toJSON()); +} + +function createSerializedMentionEditorState(): string { + const sourceHarness = createWireLexicalEditorTestHarness(); + + sourceHarness.editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createMentionNode('@', 'Alice')); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); + + return JSON.stringify(sourceHarness.editor.getEditorState().toJSON()); +} + +function createSerializedEmojiEditorState(): string { + const sourceHarness = createWireLexicalEditorTestHarness(); + + sourceHarness.editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append(new EmojiNode('πŸ§ͺ')); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); + + return JSON.stringify(sourceHarness.editor.getEditorState().toJSON()); +} + +function renderDraftStatePlugin(draftState: DraftState): Result { + const loadDraftState = jest.fn, []>().mockResolvedValue(draftState); + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor, loadDraftState}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +function getTextContent(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }); +} + +function getMentionNodeCount(editor: LexicalEditor): number { + return editor.getEditorState().read(() => { + return $nodesOfType(MentionNode).length; + }); +} + +function getEmojiNodeCount(editor: LexicalEditor): number { + return editor.getEditorState().read(() => { + return $nodesOfType(EmojiNode).length; + }); +} + +describe('DraftStatePlugin', () => { + it.each(draftStateCharacterizationTestCases)('restores $description from serialized editor state', async testCase => { + const serializedEditorState = createSerializedEditorState(testCase.inputMarkdown); + const fixture = unwrap(renderDraftStatePlugin({editorState: serializedEditorState})); + + await waitFor(() => { + expect(fixture.loadDraftState).toHaveBeenCalledTimes(1); + expect(getMarkdown(fixture.editor)).toBe(testCase.expectedMarkdown); + }); + + expect(getTextContent(fixture.editor)).toBe(testCase.expectedTextContent); + }); + + it('restores a serialized custom mention node from a draft', async () => { + const fixture = unwrap(renderDraftStatePlugin({editorState: createSerializedMentionEditorState()})); + + await waitFor(() => { + expect(fixture.loadDraftState).toHaveBeenCalledTimes(1); + expect(getMarkdown(fixture.editor)).toBe('@Alice'); + }); + + expect(getTextContent(fixture.editor)).toBe('@Alice'); + expect(getMentionNodeCount(fixture.editor)).toBe(1); + }); + + it('restores a serialized custom emoji node from a draft', async () => { + const fixture = unwrap(renderDraftStatePlugin({editorState: createSerializedEmojiEditorState()})); + + await waitFor(() => { + expect(fixture.loadDraftState).toHaveBeenCalledTimes(1); + expect(getMarkdown(fixture.editor)).toBe('πŸ§ͺ'); + }); + + expect(getTextContent(fixture.editor)).toBe('πŸ§ͺ'); + expect(getEmojiNodeCount(fixture.editor)).toBe(1); + }); + + it.each([ + {description: 'a null editor state', editorState: null}, + {description: 'an empty editor state', editorState: ''}, + ])('keeps the editor empty for $description', async draftStateTestCase => { + const fixture = unwrap(renderDraftStatePlugin(draftStateTestCase)); + + await waitFor(() => { + expect(fixture.loadDraftState).toHaveBeenCalledTimes(1); + }); + + expect(getMarkdown(fixture.editor)).toBe(''); + expect(getTextContent(fixture.editor)).toBe(''); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EditedMessagePlugin/EditedMessagePlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EditedMessagePlugin/EditedMessagePlugin.test.tsx new file mode 100644 index 00000000000..939b4235309 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EditedMessagePlugin/EditedMessagePlugin.test.tsx @@ -0,0 +1,289 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$convertToMarkdownString} from '@lexical/markdown'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $createTextNode, $getRoot, $nodesOfType, LexicalEditor} from 'lexical'; +import {noop} from 'noop-esm'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render, waitFor, type RenderResult} from '@testing-library/react'; + +import {ContentMessage} from 'Repositories/entity/message/contentMessage'; +import {Text} from 'Repositories/entity/message/text'; +import {MentionEntity} from 'src/script/message/mentionEntity'; +import {translateForTest} from 'Util/test/translateForTest'; +import {unwrap} from 'Util/test/resultTestSupport'; + +import {MentionNode} from '../../nodes/MentionNode'; +import {editorConfig} from '../../editorConfig'; +import {getMentionMarkdownTransformer} from './getMentionMarkdownTransformer/getMentionMarkdownTransformer'; +import {EditedMessagePlugin} from './EditedMessagePlugin'; +import {markdownTransformers} from '../../utils/markdownTransformers'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type EditedMessagePluginTestFixture = { + readonly editor: LexicalEditor; + readonly rerender: RenderResult['rerender']; +}; + +type MentionCharacterizationTestCase = { + readonly description: string; + readonly messageText: string; + readonly mentionStartIndex: number; + readonly mentionLength: number; + readonly expectedTextContent: string; + readonly expectedMentionText: string; +}; + +type EditedMessageStructureTestCase = { + readonly description: string; + readonly messageText: string; + readonly expectedTextContent: string; +}; + +const mentionCharacterizationTestCases: readonly MentionCharacterizationTestCase[] = [ + { + description: 'a mention surrounded by ordinary text and punctuation', + messageText: 'Hello @Alice!', + mentionStartIndex: 6, + mentionLength: 6, + expectedTextContent: 'Hello @Alice!', + expectedMentionText: '@Alice', + }, +]; + +const editedMessageStructureTestCases: readonly EditedMessageStructureTestCase[] = [ + { + description: 'a multiline blockquote', + messageText: '> first\n> second', + expectedTextContent: 'first\nsecond', + }, + { + description: 'a fenced code block with a language suffix', + messageText: '```typescript\nconst value = 1;\n```', + expectedTextContent: 'const value = 1;', + }, +]; + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function createContentMessage(messageText: string, mentions: readonly MentionEntity[] = []): ContentMessage { + const message = new ContentMessage(undefined, translateForTest); + const textAsset = new Text(undefined, messageText); + textAsset.mentions(mentions.slice()); + message.addAsset(textAsset); + + return message; +} + +function renderEditedMessagePlugin( + message: ContentMessage, + showMarkdownPreview: boolean, +): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + const renderedEditor = render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor, rerender: renderedEditor.rerender}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +function getTextContent(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }); +} + +function getMentionMarkdown(editor: LexicalEditor, allowedMentions: string[]): string { + const mentionMarkdownTransformer = getMentionMarkdownTransformer(allowedMentions); + + return editor.getEditorState().read(() => { + return $convertToMarkdownString([mentionMarkdownTransformer, ...markdownTransformers], undefined, true); + }); +} + +function getMentionTexts(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => { + return $nodesOfType(MentionNode).map(mentionNode => { + return mentionNode.getTextContent(); + }); + }); +} + +describe('EditedMessagePlugin', () => { + it('loads an existing plain message into the editor', async () => { + const fixture = unwrap(renderEditedMessagePlugin(createContentMessage('existing message'), true)); + + await waitFor(() => { + expect(getMarkdown(fixture.editor)).toBe('existing message'); + }); + + expect(getTextContent(fixture.editor)).toBe('existing message'); + }); + + it('imports Markdown formatting, lists, and links when preview mode is enabled', async () => { + const messageText = '**bold**\n\n- item\n- [link](https://wire.com)'; + const fixture = unwrap(renderEditedMessagePlugin(createContentMessage(messageText), true)); + + await waitFor(() => { + expect(getMarkdown(fixture.editor)).toBe(messageText); + }); + + expect(getTextContent(fixture.editor)).toBe('bold\n\n\n\nitem\n\nlink'); + }); + + it('serializes content added after restoring an edited message', async () => { + const fixture = unwrap(renderEditedMessagePlugin(createContentMessage('**existing**'), true)); + + await waitFor(() => { + expect(getMarkdown(fixture.editor)).toBe('**existing**'); + }); + + act(() => { + fixture.editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode('added')); + $getRoot().append(paragraphNode); + }, + {discrete: true}, + ); + }); + + expect(getMarkdown(fixture.editor)).toBe('**existing**\nadded'); + }); + + it.each(editedMessageStructureTestCases)( + 'restores $description and preserves its Markdown representation', + async editedMessageStructureTestCase => { + const fixture = unwrap( + renderEditedMessagePlugin(createContentMessage(editedMessageStructureTestCase.messageText), true), + ); + + await waitFor(() => { + expect(getMarkdown(fixture.editor)).toBe(editedMessageStructureTestCase.messageText); + }); + + expect(getTextContent(fixture.editor)).toBe(editedMessageStructureTestCase.expectedTextContent); + }, + ); + + it('keeps Markdown-looking text as text when preview mode is disabled', async () => { + const messageText = '**bold**'; + const fixture = unwrap(renderEditedMessagePlugin(createContentMessage(messageText), false)); + + await waitFor(() => { + expect(getTextContent(fixture.editor)).toBe(messageText); + }); + + expect(getMarkdown(fixture.editor)).toBe(messageText); + }); + + it.each(mentionCharacterizationTestCases)( + 'restores $description as a custom mention node', + async mentionCharacterizationTestCase => { + const mention = new MentionEntity( + mentionCharacterizationTestCase.mentionStartIndex, + mentionCharacterizationTestCase.mentionLength, + '00000000-0000-0000-0000-000000000001', + ); + const message = createContentMessage(mentionCharacterizationTestCase.messageText, [mention]); + const fixture = unwrap(renderEditedMessagePlugin(message, true)); + + await waitFor(() => { + expect(getMentionTexts(fixture.editor)).toEqual([mentionCharacterizationTestCase.expectedMentionText]); + }); + + expect(getTextContent(fixture.editor)).toBe(mentionCharacterizationTestCase.expectedTextContent); + expect(getMentionMarkdown(fixture.editor, [mentionCharacterizationTestCase.expectedMentionText])).toBe( + 'Hello @Alice!', + ); + }, + ); + + it('restores mentions as custom nodes when preview mode is disabled', async () => { + const messageText = 'Hello @Alice!'; + const mention = new MentionEntity(6, 6, '00000000-0000-0000-0000-000000000001'); + const fixture = unwrap(renderEditedMessagePlugin(createContentMessage(messageText, [mention]), false)); + + await waitFor(() => { + expect(getMentionTexts(fixture.editor)).toEqual(['@Alice']); + }); + + expect(getTextContent(fixture.editor)).toBe(messageText); + expect(getMentionMarkdown(fixture.editor, ['@Alice'])).toBe('Hello @Alice!'); + }); + + it('replaces the previous editor contents when the edited message changes', async () => { + const firstMessage = createContentMessage('first message'); + const secondMessage = createContentMessage('second message'); + const fixture = unwrap(renderEditedMessagePlugin(firstMessage, true)); + + await waitFor(() => { + expect(getTextContent(fixture.editor)).toBe('first message'); + }); + + fixture.rerender( + + + + , + ); + + await waitFor(() => { + expect(getTextContent(fixture.editor)).toBe('second message'); + }); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EditedMessagePlugin/wrapMentionsWithTags/wrapMentionsWithTags.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EditedMessagePlugin/wrapMentionsWithTags/wrapMentionsWithTags.test.ts new file mode 100644 index 00000000000..c69da2e374a --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/EditedMessagePlugin/wrapMentionsWithTags/wrapMentionsWithTags.test.ts @@ -0,0 +1,86 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {wrapMentionsWithTags} from './wrapMentionsWithTags'; + +type MentionTagCharacterizationTestCase = { + readonly description: string; + readonly inputText: string; + readonly allowedMentions: string[]; + readonly expectedText: string; +}; + +const mentionTagCharacterizationTestCases: readonly MentionTagCharacterizationTestCase[] = [ + { + description: 'no allowed mentions', + inputText: 'hello @Alice', + allowedMentions: [], + expectedText: 'hello @Alice', + }, + { + description: 'one mention between ordinary text', + inputText: 'hello @Alice', + allowedMentions: ['@Alice'], + expectedText: 'hello @Alice', + }, + { + description: 'multiple different mentions', + inputText: '@Alice and @Bob', + allowedMentions: ['@Alice', '@Bob'], + expectedText: '@Alice and @Bob', + }, + { + description: 'repeated occurrences of one mention', + inputText: '@Alice, please ask @Alice', + allowedMentions: ['@Alice'], + expectedText: '@Alice, please ask @Alice', + }, + { + description: 'punctuation adjacent to a mention', + inputText: '(@Alice),', + allowedMentions: ['@Alice'], + expectedText: '(@Alice),', + }, + { + description: 'Markdown formatting adjacent to a mention', + inputText: '**@Alice**', + allowedMentions: ['@Alice'], + expectedText: '**@Alice**', + }, + { + description: 'a mention-like value that is not allowed', + inputText: 'hello @Unknown', + allowedMentions: ['@Alice'], + expectedText: 'hello @Unknown', + }, +]; + +describe('wrapMentionsWithTags', () => { + it.each(mentionTagCharacterizationTestCases)( + 'preserves the current behavior for $description', + characterizationTestCase => { + const actualText = wrapMentionsWithTags( + characterizationTestCase.inputText, + characterizationTestCase.allowedMentions, + ); + const expectedText = characterizationTestCase.expectedText; + + expect(actualText).toBe(expectedText); + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/GlobalEventsPlugin/GlobalEventsPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/GlobalEventsPlugin/GlobalEventsPlugin.test.tsx new file mode 100644 index 00000000000..26411f2efe3 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/GlobalEventsPlugin/GlobalEventsPlugin.test.tsx @@ -0,0 +1,158 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation: either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {BLUR_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ESCAPE_COMMAND, LexicalEditor} from 'lexical'; +import {assertNotNull} from '@sindresorhus/is'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {GlobalEventsPlugin} from './GlobalEventsPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type GlobalEventsPluginTestFixture = { + readonly editor: LexicalEditor; + readonly onShiftTab: jest.Mock; + readonly onEscape: jest.Mock; + readonly onArrowUp: jest.Mock; + readonly onBlur: jest.Mock; +}; + +type ShiftTabCharacterizationTestCase = { + readonly description: string; + readonly key: string; + readonly shiftKey: boolean; + readonly expectedCallbackCount: number; +}; + +const shiftTabCharacterizationTestCases: readonly ShiftTabCharacterizationTestCase[] = [ + { + description: 'Shift+Tab', + key: 'Tab', + shiftKey: true, + expectedCallbackCount: 1, + }, + { + description: 'Tab without Shift', + key: 'Tab', + shiftKey: false, + expectedCallbackCount: 0, + }, + { + description: 'Shift+Enter', + key: 'Enter', + shiftKey: true, + expectedCallbackCount: 0, + }, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function renderGlobalEventsPlugin(): Result { + const onShiftTab = jest.fn(); + const onEscape = jest.fn(); + const onArrowUp = jest.fn(); + const onBlur = jest.fn(); + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor, onShiftTab, onEscape, onArrowUp, onBlur}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +describe('GlobalEventsPlugin', () => { + it.each(shiftTabCharacterizationTestCases)('handles only $description on the editor root', testCase => { + const fixture = unwrap(renderGlobalEventsPlugin()); + const rootElement = fixture.editor.getRootElement(); + + assertNotNull(rootElement); + + rootElement.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + key: testCase.key, + shiftKey: testCase.shiftKey, + }), + ); + + expect(fixture.onShiftTab).toHaveBeenCalledTimes(testCase.expectedCallbackCount); + }); + + it('invokes the Escape callback and leaves the command unhandled', () => { + const fixture = unwrap(renderGlobalEventsPlugin()); + + const wasHandled = fixture.editor.dispatchCommand(KEY_ESCAPE_COMMAND, new KeyboardEvent('keydown')); + + expect(wasHandled).toBe(false); + expect(fixture.onEscape).toHaveBeenCalledTimes(1); + }); + + it('invokes the ArrowUp callback and leaves the command unhandled', () => { + const fixture = unwrap(renderGlobalEventsPlugin()); + + const wasHandled = fixture.editor.dispatchCommand(KEY_ARROW_UP_COMMAND, new KeyboardEvent('keydown')); + + expect(wasHandled).toBe(false); + expect(fixture.onArrowUp).toHaveBeenCalledTimes(1); + }); + + it('invokes the blur callback and leaves the command unhandled', () => { + const fixture = unwrap(renderGlobalEventsPlugin()); + + const wasHandled = fixture.editor.dispatchCommand(BLUR_COMMAND, new FocusEvent('blur')); + + expect(wasHandled).toBe(false); + expect(fixture.onBlur).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/HistoryPlugin/HistoryPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/HistoryPlugin/HistoryPlugin.test.tsx new file mode 100644 index 00000000000..248db9635b7 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/HistoryPlugin/HistoryPlugin.test.tsx @@ -0,0 +1,255 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import { + $createParagraphNode, + $createTextNode, + $getRoot, + $nodesOfType, + LexicalEditor, + REDO_COMMAND, + UNDO_COMMAND, +} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {EmojiNode} from '../../nodes/EmojiNode'; +import {$createMentionNode, MentionNode} from '../../nodes/MentionNode'; +import {HistoryPlugin} from './HistoryPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type HistoryPluginTestFixture = { + readonly editor: LexicalEditor; +}; + +type HistoryTestFunction = () => void; + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function withFakeTimers(testFunction: HistoryTestFunction): HistoryTestFunction { + return function (): void { + jest.useFakeTimers(); + + try { + testFunction(); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } + }; +} + +function renderHistoryPlugin(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function setEditorText(editor: LexicalEditor, text: string): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode(text)); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +function getTextContent(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }); +} + +function setEditorMention(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode('hello '), $createMentionNode('@', 'Alice')); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +function getMentionNodeCount(editor: LexicalEditor): number { + return editor.getEditorState().read(() => { + return $nodesOfType(MentionNode).length; + }); +} + +function setEditorEmoji(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode('hello '), new EmojiNode('πŸ§ͺ')); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +function getEmojiNodeCount(editor: LexicalEditor): number { + return editor.getEditorState().read(() => { + return $nodesOfType(EmojiNode).length; + }); +} + +function advancePastHistoryMergeWindow(): void { + act(() => { + jest.advanceTimersByTime(301); + }); +} + +function dispatchHistoryCommand(editor: LexicalEditor, command: typeof UNDO_COMMAND | typeof REDO_COMMAND): boolean { + let wasHandled = false; + + editor.update( + () => { + wasHandled = editor.dispatchCommand(command, undefined); + }, + {discrete: true}, + ); + + return wasHandled; +} + +describe('HistoryPlugin', () => { + it( + 'undoes and redoes editor changes after the history merge window', + withFakeTimers(() => { + const fixture = unwrap(renderHistoryPlugin()); + setEditorText(fixture.editor, 'first'); + advancePastHistoryMergeWindow(); + setEditorText(fixture.editor, 'second'); + advancePastHistoryMergeWindow(); + + expect(dispatchHistoryCommand(fixture.editor, UNDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('first'); + + expect(dispatchHistoryCommand(fixture.editor, REDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('second'); + }), + ); + + it( + 'returns the immediately preceding state after changes inside the history merge window', + withFakeTimers(() => { + const fixture = unwrap(renderHistoryPlugin()); + setEditorText(fixture.editor, 'first'); + jest.advanceTimersByTime(100); + setEditorText(fixture.editor, 'second'); + jest.advanceTimersByTime(100); + setEditorText(fixture.editor, 'third'); + advancePastHistoryMergeWindow(); + + expect(dispatchHistoryCommand(fixture.editor, UNDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('second'); + + expect(dispatchHistoryCommand(fixture.editor, REDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('third'); + }), + ); + + it( + 'handles undo when the history is empty without changing the editor', + withFakeTimers(() => { + const fixture = unwrap(renderHistoryPlugin()); + + expect(dispatchHistoryCommand(fixture.editor, UNDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe(''); + }), + ); + + it( + 'restores custom mention nodes through undo and redo', + withFakeTimers(() => { + const fixture = unwrap(renderHistoryPlugin()); + setEditorMention(fixture.editor); + advancePastHistoryMergeWindow(); + setEditorText(fixture.editor, 'plain text'); + advancePastHistoryMergeWindow(); + + expect(dispatchHistoryCommand(fixture.editor, UNDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('hello @Alice'); + expect(getMentionNodeCount(fixture.editor)).toBe(1); + + expect(dispatchHistoryCommand(fixture.editor, REDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('plain text'); + expect(getMentionNodeCount(fixture.editor)).toBe(0); + }), + ); + + it( + 'restores custom emoji nodes through undo and redo', + withFakeTimers(() => { + const fixture = unwrap(renderHistoryPlugin()); + setEditorEmoji(fixture.editor); + advancePastHistoryMergeWindow(); + setEditorText(fixture.editor, 'plain text'); + advancePastHistoryMergeWindow(); + + expect(dispatchHistoryCommand(fixture.editor, UNDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('hello πŸ§ͺ'); + expect(getEmojiNodeCount(fixture.editor)).toBe(1); + + expect(dispatchHistoryCommand(fixture.editor, REDO_COMMAND)).toBe(true); + expect(getTextContent(fixture.editor)).toBe('plain text'); + expect(getEmojiNodeCount(fixture.editor)).toBe(0); + }), + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.integration.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.integration.test.tsx new file mode 100644 index 00000000000..7ac35d993f2 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.integration.test.tsx @@ -0,0 +1,153 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $createTextNode, $getRoot, KEY_SPACE_COMMAND, LexicalEditor} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {ReplaceEmojiPlugin} from './InlineEmojiReplacementPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type InlineEmojiReplacementPluginTestFixture = { + readonly editor: LexicalEditor; +}; + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function renderReplaceEmojiPlugin(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function setParagraphsAndSelectEnd(editor: LexicalEditor, paragraphs: readonly string[]): void { + editor.update( + () => { + const paragraphNodes = paragraphs.map(paragraph => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode(paragraph)); + + return paragraphNode; + }); + + $getRoot() + .clear() + .append(...paragraphNodes); + paragraphNodes.at(-1)?.selectEnd(); + }, + {discrete: true}, + ); +} + +function dispatchSpaceCommand(editor: LexicalEditor): boolean { + let wasHandled = false; + + editor.update( + () => { + wasHandled = editor.dispatchCommand(KEY_SPACE_COMMAND, new KeyboardEvent('keydown', {key: ' '})); + }, + {discrete: true}, + ); + + return wasHandled; +} + +function getParagraphTexts(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => { + return $getRoot() + .getChildren() + .map(paragraphNode => { + return paragraphNode.getTextContent(); + }); + }); +} + +describe('ReplaceEmojiPlugin', () => { + it('keeps an emoticon unchanged until the space command is dispatched', () => { + const fixture = unwrap(renderReplaceEmojiPlugin()); + + act(() => { + setParagraphsAndSelectEnd(fixture.editor, ['hello :) ']); + }); + + expect(getParagraphTexts(fixture.editor)).toEqual(['hello :) ']); + }); + + it('replaces an emoticon in the selected text node after a space command', () => { + const fixture = unwrap(renderReplaceEmojiPlugin()); + + act(() => { + setParagraphsAndSelectEnd(fixture.editor, ['hello :) ']); + }); + + const wasHandled = dispatchSpaceCommand(fixture.editor); + + expect(wasHandled).toBe(false); + expect(getParagraphTexts(fixture.editor)).toEqual(['hello πŸ™‚ ']); + }); + + it('replaces an emoticon only in the selected text node', () => { + const fixture = unwrap(renderReplaceEmojiPlugin()); + + act(() => { + setParagraphsAndSelectEnd(fixture.editor, ['first :) ', 'second :D ']); + }); + + dispatchSpaceCommand(fixture.editor); + + expect(getParagraphTexts(fixture.editor)).toEqual(['first :) ', 'second πŸ˜„ ']); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.test.ts new file mode 100644 index 00000000000..8b3a2731596 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/InlineEmojiReplacementPlugin/InlineEmojiReplacementPlugin.test.ts @@ -0,0 +1,80 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {findAndTransformEmoji} from './InlineEmojiReplacementPlugin'; + +type EmojiReplacementCharacterizationTestCase = { + readonly description: string; + readonly inputText: string; + readonly expectedText: string; +}; + +const emojiReplacementCharacterizationTestCases: readonly EmojiReplacementCharacterizationTestCase[] = [ + { + description: 'an emoticon at the beginning of a message', + inputText: ':)', + expectedText: 'πŸ™‚', + }, + { + description: 'an emoticon between words', + inputText: 'hello :) world', + expectedText: 'hello πŸ™‚ world', + }, + { + description: 'a heart emoticon', + inputText: '<3', + expectedText: '❀️', + }, + { + description: 'a laughing emoticon', + inputText: ':D', + expectedText: 'πŸ˜„', + }, + { + description: 'the configured first matching replacement in a message with multiple candidates', + inputText: ':) :D', + expectedText: ':) πŸ˜„', + }, + { + description: 'an emoticon attached to a preceding word', + inputText: 'hello:)', + expectedText: 'hello:)', + }, + { + description: 'an emoticon followed immediately by punctuation', + inputText: ':)!', + expectedText: ':)!', + }, + { + description: 'an ordinary Unicode emoji', + inputText: 'already πŸ˜€', + expectedText: 'already πŸ˜€', + }, +]; + +describe('findAndTransformEmoji', () => { + it.each(emojiReplacementCharacterizationTestCases)( + 'preserves the current behavior for $description', + characterizationTestCase => { + const actualText = findAndTransformEmoji(characterizationTestCase.inputText); + const expectedText = characterizationTestCase.expectedText; + + expect(actualText).toBe(expectedText); + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/LinkPlugin/LinkPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/LinkPlugin/LinkPlugin.test.tsx new file mode 100644 index 00000000000..f023834b1a2 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/LinkPlugin/LinkPlugin.test.tsx @@ -0,0 +1,185 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {TOGGLE_LINK_COMMAND, $createLinkNode} from '@lexical/link'; +import {$convertToMarkdownString} from '@lexical/markdown'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $createTextNode, $getRoot, LexicalEditor} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {markdownTransformers} from '../../utils/markdownTransformers'; +import {LinkPlugin} from './LinkPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type LinkCharacterizationTestCase = { + readonly description: string; + readonly url: string; + readonly expectedWasHandled: boolean; + readonly expectedMarkdown: string; +}; + +type LinkPluginTestFixture = { + readonly editor: LexicalEditor; +}; + +const linkCharacterizationTestCases: readonly LinkCharacterizationTestCase[] = [ + { + description: 'a supported HTTPS URL', + url: 'https://wire.com', + expectedWasHandled: true, + expectedMarkdown: '[Wire](https://wire.com)', + }, + { + description: 'an unsupported FTP URL', + url: 'ftp://wire.com', + expectedWasHandled: false, + expectedMarkdown: 'Wire', + }, + { + description: 'a mailto URL despite the URL sanitizer supporting mailto links', + url: 'mailto:alice@wire.com', + expectedWasHandled: false, + expectedMarkdown: 'Wire', + }, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function renderLinkPlugin(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function selectText(editor: LexicalEditor, text: string): void { + editor.update( + () => { + const paragraph = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraph.append(textNode); + $getRoot().clear(); + $getRoot().append(paragraph); + textNode.select(0, text.length); + }, + {discrete: true}, + ); +} + +function createLink(editor: LexicalEditor): void { + editor.update( + () => { + const paragraph = $createParagraphNode(); + const linkNode = $createLinkNode('https://wire.com'); + const linkTextNode = $createTextNode('Wire'); + linkNode.append(linkTextNode); + paragraph.append(linkNode); + $getRoot().clear(); + $getRoot().append(paragraph); + linkTextNode.select(0, linkTextNode.getTextContentSize()); + }, + {discrete: true}, + ); +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +describe('LinkPlugin', () => { + it.each(linkCharacterizationTestCases)('preserves current behavior for $description', testCase => { + const fixture = unwrap(renderLinkPlugin()); + + act(() => { + selectText(fixture.editor, 'Wire'); + }); + + let wasHandled = false; + act(() => { + fixture.editor.update( + () => { + wasHandled = fixture.editor.dispatchCommand(TOGGLE_LINK_COMMAND, testCase.url); + }, + {discrete: true}, + ); + }); + + expect(wasHandled).toBe(testCase.expectedWasHandled); + expect(getMarkdown(fixture.editor)).toBe(testCase.expectedMarkdown); + }); + + it('removes a link when the toggle command receives a null URL', () => { + const fixture = unwrap(renderLinkPlugin()); + + act(() => { + createLink(fixture.editor); + }); + + let wasHandled = false; + act(() => { + fixture.editor.update( + () => { + wasHandled = fixture.editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + }, + {discrete: true}, + ); + }); + + expect(wasHandled).toBe(true); + expect(getMarkdown(fixture.editor)).toBe('Wire'); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ListIndentationPlugin/ListIndentationPlugin.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ListIndentationPlugin/ListIndentationPlugin.test.ts new file mode 100644 index 00000000000..e41ff62fadf --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ListIndentationPlugin/ListIndentationPlugin.test.ts @@ -0,0 +1,153 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$getRoot, KEY_TAB_COMMAND} from 'lexical'; +import {registerList} from '@lexical/list'; +import {registerRichText} from '@lexical/rich-text'; +import {$isElementNode, $isTextNode, type LexicalEditor} from 'lexical'; + +import {createWireLexicalEditorTestHarness} from '../../testSupport/createWireLexicalEditorTestHarness'; + +import {registerListItemTabIndentation} from './ListIndentationPlugin'; + +type ListIndentationCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly isShiftPressed: boolean; + readonly expectedWasHandled: boolean; + readonly expectedDefaultPrevented: boolean; + readonly expectedMarkdown: string; +}; + +const listIndentationCharacterizationTestCases: readonly ListIndentationCharacterizationTestCase[] = [ + { + description: 'indents the last item with Tab', + inputMarkdown: '- first\n- second', + isShiftPressed: false, + expectedWasHandled: false, + expectedDefaultPrevented: true, + expectedMarkdown: '- first\n- second', + }, + { + description: 'outdents the nested item with Shift+Tab', + inputMarkdown: '- first\n - second', + isShiftPressed: true, + expectedWasHandled: false, + expectedDefaultPrevented: true, + expectedMarkdown: '- first\n- second', + }, +]; + +function createTabEvent(isShiftPressed: boolean): KeyboardEvent { + return new KeyboardEvent('keydown', {cancelable: true, shiftKey: isShiftPressed}); +} + +function selectEndOfLastListItem(editor: LexicalEditor): void { + editor.update( + () => { + const firstDocumentElement = $getRoot().getFirstChild(); + if (firstDocumentElement === null || !$isElementNode(firstDocumentElement)) { + throw new Error('The list indentation characterization requires a document element'); + } + const lastTextNode = firstDocumentElement.getLastDescendant(); + if (lastTextNode === null || !$isTextNode(lastTextNode)) { + throw new Error('The list indentation characterization requires a text node'); + } + lastTextNode.selectEnd(); + }, + {discrete: true}, + ); +} + +describe('ListIndentationPlugin', () => { + it.each(listIndentationCharacterizationTestCases)( + 'preserves the current command behavior for $description', + characterizationTestCase => { + const harness = createWireLexicalEditorTestHarness(); + harness.importMarkdown(characterizationTestCase.inputMarkdown); + + harness.editor.update( + () => { + $getRoot().selectEnd(); + }, + {discrete: true}, + ); + + const unregisterList = registerList(harness.editor); + const unregisterListItemTabIndentation = registerListItemTabIndentation(harness.editor); + const tabEvent = createTabEvent(characterizationTestCase.isShiftPressed); + const actualWasHandled = harness.editor.dispatchCommand(KEY_TAB_COMMAND, tabEvent); + const actualMarkdown = harness.exportMarkdown(); + const expectedWasHandled = characterizationTestCase.expectedWasHandled; + const expectedDefaultPrevented = characterizationTestCase.expectedDefaultPrevented; + const expectedMarkdown = characterizationTestCase.expectedMarkdown; + + unregisterList(); + unregisterListItemTabIndentation(); + + expect(actualWasHandled).toBe(expectedWasHandled); + expect(tabEvent.defaultPrevented).toBe(expectedDefaultPrevented); + expect(actualMarkdown).toBe(expectedMarkdown); + }, + ); + + it('does not handle Tab when the selection is in an ordinary paragraph', () => { + const harness = createWireLexicalEditorTestHarness(); + harness.importMarkdown('ordinary text'); + harness.editor.update( + () => { + $getRoot().selectEnd(); + }, + {discrete: true}, + ); + + const unregisterList = registerList(harness.editor); + const unregisterListItemTabIndentation = registerListItemTabIndentation(harness.editor); + const tabEvent = createTabEvent(false); + const actualWasHandled = harness.editor.dispatchCommand(KEY_TAB_COMMAND, tabEvent); + const expectedWasHandled = false; + + unregisterList(); + unregisterListItemTabIndentation(); + + expect(actualWasHandled).toBe(expectedWasHandled); + expect(tabEvent.defaultPrevented).toBe(false); + expect(harness.exportMarkdown()).toBe('ordinary text'); + }); + + it('handles Tab for the selected last list item without changing Markdown', () => { + const harness = createWireLexicalEditorTestHarness(); + harness.importMarkdown('- first\n- second'); + selectEndOfLastListItem(harness.editor); + + const unregisterList = registerList(harness.editor); + const unregisterRichText = registerRichText(harness.editor); + const unregisterListItemTabIndentation = registerListItemTabIndentation(harness.editor); + const tabEvent = createTabEvent(false); + const actualWasHandled = harness.editor.dispatchCommand(KEY_TAB_COMMAND, tabEvent); + const actualMarkdown = harness.exportMarkdown(); + + unregisterList(); + unregisterRichText(); + unregisterListItemTabIndentation(); + + expect(actualWasHandled).toBe(true); + expect(tabEvent.defaultPrevented).toBe(true); + expect(actualMarkdown).toBe('- first\n- second'); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ListMaxIndentLevelPlugin/ListMaxIndentLevelPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ListMaxIndentLevelPlugin/ListMaxIndentLevelPlugin.test.tsx new file mode 100644 index 00000000000..520740caf1c --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ListMaxIndentLevelPlugin/ListMaxIndentLevelPlugin.test.tsx @@ -0,0 +1,166 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {ListPlugin} from '@lexical/react/LexicalListPlugin'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$convertFromMarkdownString, $convertToMarkdownString} from '@lexical/markdown'; +import {registerRichText} from '@lexical/rich-text'; +import {$getRoot, $isElementNode, $isTextNode, INDENT_CONTENT_COMMAND, type LexicalEditor} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {ListItemTabIndentationPlugin} from '../ListIndentationPlugin/ListIndentationPlugin'; +import {markdownTransformers} from '../../utils/markdownTransformers'; + +import {ListMaxIndentLevelPlugin} from './ListMaxIndentLevelPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type ListMaxIndentLevelCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly maxDepth: number; + readonly expectedWasHandled: boolean; + readonly expectedMarkdown: string; +}; + +const listMaxIndentLevelCharacterizationTestCases: readonly ListMaxIndentLevelCharacterizationTestCase[] = [ + { + description: 'a top-level list item below the configured maximum depth', + inputMarkdown: '- first\n- second', + maxDepth: 3, + expectedWasHandled: true, + expectedMarkdown: '- first\n- second', + }, + { + description: 'a top-level list item at a maximum depth of one', + inputMarkdown: '- first\n- second', + maxDepth: 1, + expectedWasHandled: true, + expectedMarkdown: '- first\n- second', + }, + { + description: 'a nested list item at a maximum depth of two', + inputMarkdown: '- one\n - two\n - three', + maxDepth: 2, + expectedWasHandled: true, + expectedMarkdown: '- one\n- two\n - three', + }, +]; + +function throwEditorError(error: unknown): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function renderListMaxIndentLevelEditor(maxDepth: number): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + + + , + ); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), capturedEditor); +} + +function importMarkdown(editor: LexicalEditor, markdown: string): void { + editor.update( + () => { + $getRoot().clear(); + $convertFromMarkdownString(markdown, markdownTransformers, undefined, true); + $getRoot().selectEnd(); + }, + {discrete: true}, + ); +} + +function selectEndOfLastListItem(editor: LexicalEditor): void { + editor.update( + () => { + const firstDocumentElement = $getRoot().getFirstChild(); + if (firstDocumentElement === null || !$isElementNode(firstDocumentElement)) { + throw new Error('The list max indentation characterization requires a document element'); + } + const lastTextNode = firstDocumentElement.getLastDescendant(); + if (lastTextNode === null || !$isTextNode(lastTextNode)) { + throw new Error('The list max indentation characterization requires a text node'); + } + lastTextNode.selectEnd(); + }, + {discrete: true}, + ); +} + +function exportMarkdown(editor: LexicalEditor): string { + let markdown = ''; + + editor.getEditorState().read(() => { + markdown = $convertToMarkdownString(markdownTransformers, undefined, true); + }); + + return markdown; +} + +describe('ListMaxIndentLevelPlugin', () => { + it.each(listMaxIndentLevelCharacterizationTestCases)( + 'preserves the current indent command behavior for $description', + characterizationTestCase => { + const editorResult = renderListMaxIndentLevelEditor(characterizationTestCase.maxDepth); + const editor = unwrap(editorResult); + + importMarkdown(editor, characterizationTestCase.inputMarkdown); + registerRichText(editor); + selectEndOfLastListItem(editor); + + const actualWasHandled = editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined); + const actualMarkdown = exportMarkdown(editor); + const expectedWasHandled = characterizationTestCase.expectedWasHandled; + const expectedMarkdown = characterizationTestCase.expectedMarkdown; + + expect(actualWasHandled).toBe(expectedWasHandled); + expect(actualMarkdown).toBe(expectedMarkdown); + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/PastePlugin/PastePlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/PastePlugin/PastePlugin.test.tsx new file mode 100644 index 00000000000..f92a31f023c --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/PastePlugin/PastePlugin.test.tsx @@ -0,0 +1,351 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$convertToMarkdownString} from '@lexical/markdown'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $getRoot, $nodesOfType, LexicalEditor, PASTE_COMMAND} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {render} from '@testing-library/react'; + +import {User} from 'Repositories/entity/User'; +import {translateForTest} from 'Util/test/translateForTest'; +import {unwrap} from 'Util/test/resultTestSupport'; + +import {MentionNode} from '../../nodes/MentionNode'; +import {editorConfig} from '../../editorConfig'; +import {PastePlugin} from './PastePlugin'; +import {markdownTransformers} from '../../utils/markdownTransformers'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type PastePluginTestFixture = { + readonly editor: LexicalEditor; +}; + +type RenderPastePluginOptions = { + readonly isPreviewMode: boolean; + readonly mentionCandidates: readonly User[]; +}; + +type PasteEventOptions = { + readonly htmlContent: string; + readonly plainText: string; +}; + +type PasteEventFixture = { + readonly event: ClipboardEvent; + readonly preventDefault: jest.Mock; +}; + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +function renderPastePlugin(renderPastePluginOptions: RenderPastePluginOptions): Result { + const getMentionCandidates = jest + .fn() + .mockReturnValue(renderPastePluginOptions.mentionCandidates.slice()); + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +function createPasteEvent(pasteEventOptions: PasteEventOptions): PasteEventFixture { + const preventDefault = jest.fn(); + const clipboardData = { + getData(format: string): string { + return format === 'text/html' ? pasteEventOptions.htmlContent : pasteEventOptions.plainText; + }, + }; + const event = {clipboardData, preventDefault} as unknown as ClipboardEvent; + + return {event, preventDefault}; +} + +function selectEndOfEmptyParagraph(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + $getRoot().clear().append(paragraphNode); + paragraphNode.selectEnd(); + }, + {discrete: true}, + ); +} + +function dispatchPaste(editor: LexicalEditor, event: ClipboardEvent): boolean { + let wasHandled = false; + + editor.update( + () => { + wasHandled = editor.dispatchCommand(PASTE_COMMAND, event); + }, + {discrete: true}, + ); + + return wasHandled; +} + +function getMarkdown(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); +} + +function getTextContent(editor: LexicalEditor): string { + return editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }); +} + +function getMentionTexts(editor: LexicalEditor): string[] { + return editor.getEditorState().read(() => { + return $nodesOfType(MentionNode).map(mentionNode => { + return mentionNode.getTextContent(); + }); + }); +} + +function createMentionCandidate(name: string): User { + const user = new User(`${name}-id`, '', translateForTest); + user.name(name); + + return user; +} + +describe('PastePlugin', () => { + it('inserts multiline plain text and consumes the paste command', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [], + }), + ); + const pasteEventFixture = createPasteEvent({htmlContent: '', plainText: 'first line\nsecond line'}); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(pasteEventFixture.preventDefault).toHaveBeenCalledTimes(1); + expect(getTextContent(fixture.editor)).toBe('first line\nsecond line'); + }); + + it('preserves date-like list syntax as pasted text', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [], + }), + ); + const pasteEventFixture = createPasteEvent({htmlContent: '', plainText: '14. - 25. september'}); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(pasteEventFixture.preventDefault).toHaveBeenCalledTimes(1); + expect(getTextContent(fixture.editor)).toBe('14. - 25. september'); + expect(getMarkdown(fixture.editor)).toBe('14. - 25. september'); + }); + + it('preserves formatting from HTML in preview mode', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [], + }), + ); + const pasteEventFixture = createPasteEvent({ + htmlContent: 'bold italic', + plainText: 'bold italic', + }); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(getMarkdown(fixture.editor)).toBe('**bold** *italic*'); + }); + + it('inserts a Markdown link as text when preview mode is disabled', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: false, + mentionCandidates: [], + }), + ); + const pasteEventFixture = createPasteEvent({ + htmlContent: 'Wire', + plainText: 'Wire', + }); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(getMarkdown(fixture.editor)).toBe('[Wire](https://wire.com)'); + expect(getTextContent(fixture.editor)).toBe('[Wire](https://wire.com)'); + }); + + it('preserves an HTML link as a link node in preview mode', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [], + }), + ); + const pasteEventFixture = createPasteEvent({ + htmlContent: 'Wire', + plainText: 'Wire', + }); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(getMarkdown(fixture.editor)).toBe('[Wire](https://wire.com)'); + expect(getTextContent(fixture.editor)).toBe('Wire'); + }); + + it('preserves a valid Lexical mention node from HTML', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [createMentionCandidate('Alice')], + }), + ); + const pasteEventFixture = createPasteEvent({ + htmlContent: + '@Alice', + plainText: '@Alice', + }); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(getMentionTexts(fixture.editor)).toEqual(['@Alice']); + expect(getTextContent(fixture.editor)).toBe('@Alice'); + }); + + it('preserves ordinary mention markup as plain text', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [createMentionCandidate('Alice')], + }), + ); + const pasteEventFixture = createPasteEvent({ + htmlContent: '@Alice', + plainText: '@Alice', + }); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(getMentionTexts(fixture.editor)).toEqual([]); + expect(getTextContent(fixture.editor)).toBe('@Alice'); + }); + + it('combines the mention trigger with an at-prefixed pasted mention value', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [createMentionCandidate('Alice')], + }), + ); + const pasteEventFixture = createPasteEvent({ + htmlContent: + '@Alice', + plainText: '@Alice', + }); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(getMentionTexts(fixture.editor)).toEqual(['@@Alice']); + expect(getTextContent(fixture.editor)).toBe('@@Alice'); + }); + + it('converts an unavailable Lexical mention to ordinary text', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [], + }), + ); + const pasteEventFixture = createPasteEvent({ + htmlContent: + '@Unknown', + plainText: '@Unknown', + }); + selectEndOfEmptyParagraph(fixture.editor); + + const wasHandled = dispatchPaste(fixture.editor, pasteEventFixture.event); + + expect(wasHandled).toBe(true); + expect(getMentionTexts(fixture.editor)).toEqual([]); + expect(getTextContent(fixture.editor)).toBe('@Unknown'); + }); + + it('does not consume a paste command without clipboard data', () => { + const fixture = unwrap( + renderPastePlugin({ + isPreviewMode: true, + mentionCandidates: [], + }), + ); + const wasHandled = dispatchPaste(fixture.editor, {} as ClipboardEvent); + + expect(wasHandled).toBe(false); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ReplaceCarriageReturnPlugin/ReplaceCarriageReturnPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ReplaceCarriageReturnPlugin/ReplaceCarriageReturnPlugin.test.tsx new file mode 100644 index 00000000000..bee7dc7a5b3 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/ReplaceCarriageReturnPlugin/ReplaceCarriageReturnPlugin.test.tsx @@ -0,0 +1,126 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {useEffect, type FunctionComponent} from 'react'; + +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $createTextNode, $getRoot, LexicalEditor, PASTE_COMMAND} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; + +import {render} from '@testing-library/react'; + +import {unwrap} from 'Util/test/resultTestSupport'; + +import {ReplaceCarriageReturnPlugin} from './ReplaceCarriageReturnPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type CarriageReturnTestCase = { + readonly description: string; + readonly inputText: string; + readonly expectedText: string; +}; + +const carriageReturnTestCases: readonly CarriageReturnTestCase[] = [ + { + description: 'CRLF line endings', + inputText: 'first\r\nsecond', + expectedText: 'first\nsecond', + }, + { + description: 'standalone carriage returns', + inputText: 'first\rsecond', + expectedText: 'first\nsecond', + }, + { + description: 'LF line endings', + inputText: 'first\nsecond', + expectedText: 'first\nsecond', + }, + { + description: 'mixed line endings', + inputText: 'first\r\nsecond\rthird\nfourth', + expectedText: 'first\nsecond\nthird\nfourth', + }, +]; + +function throwEditorError(error: Error): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect( + function (): void { + onReady(editor); + }, + [editor, onReady], + ); + + return null; +}; + +function renderReplaceCarriageReturnPlugin(): Result { + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + + , + ); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), capturedEditor); +} + +describe('ReplaceCarriageReturnPlugin', () => { + it.each(carriageReturnTestCases)( + 'normalizes $description before the editor reads the pasted text', + carriageReturnTestCase => { + const editor = unwrap(renderReplaceCarriageReturnPlugin()); + + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode(carriageReturnTestCase.inputText)); + $getRoot().clear(); + $getRoot().append(paragraph); + paragraph.selectEnd(); + editor.dispatchCommand(PASTE_COMMAND, new Event('paste') as ClipboardEvent); + }, + {discrete: true}, + ); + + const actualText = editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }); + const expectedText = carriageReturnTestCase.expectedText; + + expect(actualText).toBe(expectedText); + }, + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/SendPlugin/SendPlugin.test.tsx b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/SendPlugin/SendPlugin.test.tsx new file mode 100644 index 00000000000..84e18d1faee --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/SendPlugin/SendPlugin.test.tsx @@ -0,0 +1,181 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {RichTextPlugin} from '@lexical/react/LexicalRichTextPlugin'; +import {ContentEditable} from '@lexical/react/LexicalContentEditable'; +import {LexicalComposer} from '@lexical/react/LexicalComposer'; +import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; +import {$createParagraphNode, $createTextNode, $getRoot, KEY_ENTER_COMMAND, LexicalEditor} from 'lexical'; +import {Maybe, toolbelt, type Result} from 'true-myth'; +import {useEffect, type FunctionComponent} from 'react'; + +import {act, render} from '@testing-library/react'; + +import {Config} from 'src/script/Config'; +import {unwrap} from 'Util/test/resultTestSupport'; + +import {editorConfig} from '../../editorConfig'; +import {SendPlugin} from './SendPlugin'; + +type EditorCapturePluginProps = { + readonly onReady: (editor: LexicalEditor) => void; +}; + +type SendPluginTestFixture = { + readonly editor: LexicalEditor; + readonly onSend: jest.Mock; +}; + +type SendPluginTestFunction = () => void; + +const defaultFeatureConfiguration = Config.getConfig().FEATURE; + +function throwEditorError(error: unknown): never { + throw error; +} + +const EditorCapturePlugin: FunctionComponent = props => { + const {onReady} = props; + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + onReady(editor); + }, [editor, onReady]); + + return null; +}; + +function setMessageFormatButtonsEnabled(enabled: boolean): void { + Config._dangerouslySetConfigFeaturesForDebug({ + ...Config.getConfig().FEATURE, + ENABLE_MESSAGE_FORMAT_BUTTONS: enabled, + }); +} + +function selectTextAtEndOfParagraph(editor: LexicalEditor): void { + editor.update( + () => { + const paragraph = $createParagraphNode(); + paragraph.append($createTextNode('message')); + $getRoot().append(paragraph); + paragraph.selectEnd(); + }, + {discrete: true}, + ); +} + +function getRootChildCount(editor: LexicalEditor): number { + return editor.getEditorState().read(() => { + return $getRoot().getChildrenSize(); + }); +} + +function withDefaultFeatureConfiguration(testFunction: SendPluginTestFunction): SendPluginTestFunction { + return () => { + try { + testFunction(); + } finally { + Config._dangerouslySetConfigFeaturesForDebug(defaultFeatureConfiguration); + } + }; +} + +function renderSendPlugin(): Result { + const onSend = jest.fn(); + let capturedEditor: Maybe = Maybe.nothing(); + + function captureEditor(editor: LexicalEditor): void { + capturedEditor = Maybe.just(editor); + } + + render( + + + } ErrorBoundary={throwEditorError} /> + + , + ); + + const fixture = capturedEditor.map(editor => { + return {editor, onSend}; + }); + + return toolbelt.fromMaybe(new Error('The Lexical editor was not captured'), fixture); +} + +describe('SendPlugin', () => { + it( + 'leaves a null Enter event unhandled', + withDefaultFeatureConfiguration(() => { + const fixture = unwrap(renderSendPlugin()); + + const wasHandled = fixture.editor.dispatchCommand(KEY_ENTER_COMMAND, null); + + expect(wasHandled).toBe(false); + expect(fixture.onSend).not.toHaveBeenCalled(); + }), + ); + + it( + 'sends on plain Enter and prevents the browser default', + withDefaultFeatureConfiguration(() => { + const fixture = unwrap(renderSendPlugin()); + const enterEvent = new KeyboardEvent('keydown', {cancelable: true}); + + const wasHandled = fixture.editor.dispatchCommand(KEY_ENTER_COMMAND, enterEvent); + + expect(wasHandled).toBe(true); + expect(enterEvent.defaultPrevented).toBe(true); + expect(fixture.onSend).toHaveBeenCalledTimes(1); + }), + ); + + it( + 'handles Shift+Enter without preventing the browser default when formatting buttons are disabled', + withDefaultFeatureConfiguration(() => { + setMessageFormatButtonsEnabled(false); + const fixture = unwrap(renderSendPlugin()); + const shiftEnterEvent = new KeyboardEvent('keydown', {cancelable: true, shiftKey: true}); + + const wasHandled = fixture.editor.dispatchCommand(KEY_ENTER_COMMAND, shiftEnterEvent); + + expect(wasHandled).toBe(true); + expect(shiftEnterEvent.defaultPrevented).toBe(false); + expect(fixture.onSend).not.toHaveBeenCalled(); + }), + ); + + it( + 'delegates Shift+Enter to paragraph insertion when formatting buttons are enabled', + withDefaultFeatureConfiguration(() => { + setMessageFormatButtonsEnabled(true); + const fixture = unwrap(renderSendPlugin()); + act(() => { + selectTextAtEndOfParagraph(fixture.editor); + }); + const shiftEnterEvent = new KeyboardEvent('keydown', {cancelable: true, shiftKey: true}); + + const wasHandled = fixture.editor.dispatchCommand(KEY_ENTER_COMMAND, shiftEnterEvent); + + expect(wasHandled).toBe(true); + expect(shiftEnterEvent.defaultPrevented).toBe(true); + expect(fixture.onSend).not.toHaveBeenCalled(); + expect(getRootChildCount(fixture.editor)).toBe(2); + }), + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/TypeaheadMenuPlugin/getScrollParent.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/TypeaheadMenuPlugin/getScrollParent.test.ts new file mode 100644 index 00000000000..25377de7583 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/plugins/TypeaheadMenuPlugin/getScrollParent.test.ts @@ -0,0 +1,121 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {getScrollParent} from './TypeaheadMenuPlugin'; + +type ScrollParentTestFunction = () => void; +type ScrollParentTestCase = { + readonly includeHidden: boolean; + readonly expectedScrollParent: 'documentBody' | 'scrollContainer'; +}; +type ScrollContainerElements = { + readonly scrollContainer: HTMLDivElement; + readonly childElement: HTMLDivElement; +}; + +function withCleanDocumentBody(testFunction: ScrollParentTestFunction): ScrollParentTestFunction { + return () => { + try { + testFunction(); + } finally { + document.body.replaceChildren(); + } + }; +} + +function withCleanDocumentBodyForTestCase( + testFunction: (testCase: TestCase) => void, +): (testCase: TestCase) => void { + return (testCase: TestCase): void => { + try { + testFunction(testCase); + } finally { + document.body.replaceChildren(); + } + }; +} + +function appendScrollContainer(): ScrollContainerElements { + const scrollContainer = document.createElement('div'); + const childElement = document.createElement('div'); + + scrollContainer.append(childElement); + document.body.append(scrollContainer); + + return {scrollContainer, childElement}; +} + +describe('getScrollParent', () => { + it( + 'returns the document body for a fixed element', + withCleanDocumentBody(() => { + const {childElement} = appendScrollContainer(); + childElement.style.position = 'fixed'; + + expect(getScrollParent(childElement, false)).toBe(document.body); + }), + ); + + it( + 'returns the nearest parent with scroll overflow', + withCleanDocumentBody(() => { + const {scrollContainer, childElement} = appendScrollContainer(); + scrollContainer.style.overflow = 'auto'; + + expect(getScrollParent(childElement, false)).toBe(scrollContainer); + }), + ); + + const scrollParentTestCases: readonly ScrollParentTestCase[] = [ + {includeHidden: false, expectedScrollParent: 'documentBody'}, + {includeHidden: true, expectedScrollParent: 'scrollContainer'}, + ]; + + it.each(scrollParentTestCases)( + 'includes hidden overflow only when requested: $includeHidden', + withCleanDocumentBodyForTestCase(testCase => { + const {scrollContainer, childElement} = appendScrollContainer(); + scrollContainer.style.overflow = 'hidden'; + + const expectedScrollParent = + testCase.expectedScrollParent === 'scrollContainer' ? scrollContainer : document.body; + + expect(getScrollParent(childElement, testCase.includeHidden)).toBe(expectedScrollParent); + }), + ); + + it( + 'skips a static parent for an absolutely positioned element', + withCleanDocumentBody(() => { + const outerScrollContainer = document.createElement('div'); + const staticScrollContainer = document.createElement('div'); + const childElement = document.createElement('div'); + + outerScrollContainer.style.overflow = 'auto'; + staticScrollContainer.style.overflow = 'auto'; + staticScrollContainer.style.position = 'static'; + childElement.style.position = 'absolute'; + + staticScrollContainer.append(childElement); + outerScrollContainer.append(staticScrollContainer); + document.body.append(outerScrollContainer); + + expect(getScrollParent(childElement, false)).toBe(outerScrollContainer); + }), + ); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/testSupport/createWireLexicalEditorTestHarness.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/testSupport/createWireLexicalEditorTestHarness.ts new file mode 100644 index 00000000000..7ca622765fd --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/testSupport/createWireLexicalEditorTestHarness.ts @@ -0,0 +1,69 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +import {$convertFromMarkdownString, $convertToMarkdownString} from '@lexical/markdown'; +import {createEditor, $getRoot, LexicalEditor} from 'lexical'; + +import {editorConfig} from '../editorConfig'; +import {markdownTransformers} from '../utils/markdownTransformers'; + +export type WireLexicalEditorTestHarness = { + readonly editor: LexicalEditor; + readonly importMarkdown: (markdown: string) => void; + readonly exportMarkdown: () => string; + readonly getTextContent: () => string; +}; + +function throwEditorError(error: unknown): never { + throw error; +} + +export function createWireLexicalEditorTestHarness(): WireLexicalEditorTestHarness { + const {namespace, theme, nodes} = editorConfig; + const editor = createEditor({ + namespace, + theme, + nodes, + onError: throwEditorError, + }); + + function importMarkdown(markdown: string): void { + editor.update( + () => { + $getRoot().clear(); + $convertFromMarkdownString(markdown, markdownTransformers, undefined, true); + }, + {discrete: true}, + ); + } + + function exportMarkdown(): string { + return editor.getEditorState().read(() => { + return $convertToMarkdownString(markdownTransformers, undefined, true); + }); + } + + function getTextContent(): string { + return editor.getEditorState().read(() => { + return $getRoot().getTextContent(); + }); + } + + return {editor, importMarkdown, exportMarkdown, getTextContent}; +} diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/generateNodes.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/generateNodes.test.ts new file mode 100644 index 00000000000..0fc80a73b07 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/generateNodes.test.ts @@ -0,0 +1,94 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {MentionEntity} from '../../../../../message/mentionEntity'; + +import {createNodes} from './generateNodes'; + +type GenerateNodesTestCase = { + readonly description: string; + readonly messageText: string; + readonly mentions: MentionEntity[]; + readonly expectedNodes: readonly {readonly data: string; readonly type: string}[]; +}; + +const generateNodesTestCases: readonly GenerateNodesTestCase[] = [ + { + description: 'text without mentions', + messageText: 'ordinary text', + mentions: [], + expectedNodes: [{data: 'ordinary text', type: 'text'}], + }, + { + description: 'a mention at the beginning', + messageText: '@Alice says hello', + mentions: [new MentionEntity(0, 6, 'alice-id')], + expectedNodes: [ + {data: '@Alice', type: 'Mention'}, + {data: ' says hello', type: 'text'}, + ], + }, + { + description: 'a mention between ordinary text', + messageText: 'Hello @Alice!', + mentions: [new MentionEntity(6, 6, 'alice-id')], + expectedNodes: [ + {data: 'Hello ', type: 'text'}, + {data: '@Alice', type: 'Mention'}, + {data: '!', type: 'text'}, + ], + }, + { + description: 'multiple mentions supplied out of order', + messageText: '@Alice and @Bob', + mentions: [new MentionEntity(11, 4, 'bob-id'), new MentionEntity(0, 6, 'alice-id')], + expectedNodes: [ + {data: '@Alice', type: 'Mention'}, + {data: ' and ', type: 'text'}, + {data: '@Bob', type: 'Mention'}, + ], + }, + { + description: 'repeated mentions of the same user', + messageText: '@Alice and @Alice', + mentions: [new MentionEntity(0, 6, 'alice-id'), new MentionEntity(11, 6, 'alice-id')], + expectedNodes: [ + {data: '@Alice', type: 'Mention'}, + {data: ' and ', type: 'text'}, + {data: '@Alice', type: 'Mention'}, + ], + }, + { + description: 'a mention after Unicode text', + messageText: 'πŸ˜€ @Alice', + mentions: [new MentionEntity(3, 6, 'alice-id')], + expectedNodes: [ + {data: 'πŸ˜€ ', type: 'text'}, + {data: '@Alice', type: 'Mention'}, + ], + }, +]; + +describe('createNodes', () => { + it.each(generateNodesTestCases)('preserves the current behavior for $description', generateNodesTestCase => { + const actualNodes = createNodes(generateNodesTestCase.mentions, generateNodesTestCase.messageText); + const expectedNodes = generateNodesTestCase.expectedNodes; + + expect(actualNodes).toEqual(expectedNodes); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/getSelectionInfo.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/getSelectionInfo.test.ts new file mode 100644 index 00000000000..ee5000a469b --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/getSelectionInfo.test.ts @@ -0,0 +1,212 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {assertNotNull} from '@sindresorhus/is'; +import {$createParagraphNode, $createTextNode, $getRoot, LexicalEditor} from 'lexical'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from '../testSupport/createWireLexicalEditorTestHarness'; + +import {getSelectionInfo} from './getSelectionInfo'; + +type TextSelectionOptions = { + readonly text: string; + readonly startOffset: number; + readonly endOffset: number; +}; + +type SelectionInfoTestCase = { + readonly description: string; + readonly text: string; + readonly cursorOffset: number; + readonly expectedWordCharBeforeCursor: boolean; + readonly expectedWordCharAfterCursor: boolean; + readonly expectedCursorAtStartOfNode: boolean; + readonly expectedCursorAtEndOfNode: boolean; +}; + +type SelectionSiblingTextContents = { + readonly previousText: string | undefined; + readonly nextText: string | undefined; +}; + +const selectionInfoTestCases: readonly SelectionInfoTestCase[] = [ + { + description: 'a word after a mention trigger', + text: '@Alice', + cursorOffset: 6, + expectedWordCharBeforeCursor: true, + expectedWordCharAfterCursor: false, + expectedCursorAtStartOfNode: false, + expectedCursorAtEndOfNode: true, + }, + { + description: 'a mention trigger immediately before a space', + text: '@ Alice', + cursorOffset: 1, + expectedWordCharBeforeCursor: false, + expectedWordCharAfterCursor: false, + expectedCursorAtStartOfNode: false, + expectedCursorAtEndOfNode: false, + }, + { + description: 'a mention trigger embedded after ordinary text', + text: 'name@example', + cursorOffset: 5, + expectedWordCharBeforeCursor: false, + expectedWordCharAfterCursor: true, + expectedCursorAtStartOfNode: false, + expectedCursorAtEndOfNode: false, + }, + { + description: 'the start of ordinary text', + text: 'Alice', + cursorOffset: 0, + expectedWordCharBeforeCursor: false, + expectedWordCharAfterCursor: true, + expectedCursorAtStartOfNode: true, + expectedCursorAtEndOfNode: false, + }, + { + description: 'punctuation at the end of text', + text: 'Alice!', + cursorOffset: 6, + expectedWordCharBeforeCursor: false, + expectedWordCharAfterCursor: false, + expectedCursorAtStartOfNode: false, + expectedCursorAtEndOfNode: true, + }, +]; + +function setTextSelection(editor: LexicalEditor, textSelectionOptions: TextSelectionOptions): void { + const {text, startOffset, endOffset} = textSelectionOptions; + + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode(text); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + textNode.select(startOffset, endOffset); + }, + {discrete: true}, + ); +} + +function readSelectionInfo(editor: LexicalEditor): ReturnType { + return editor.getEditorState().read(() => { + return getSelectionInfo(['@']); + }); +} + +function readSelectionSiblingTextContents(editor: LexicalEditor): SelectionSiblingTextContents { + return editor.getEditorState().read(() => { + const selectionInfo = getSelectionInfo(['@']); + assertNotNull(selectionInfo); + + return { + previousText: selectionInfo.prevNode?.getTextContent(), + nextText: selectionInfo.nextNode?.getTextContent(), + }; + }); +} + +function setTextSelectionWithSiblings(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textBeforeSelection = $createTextNode('before'); + const selectedText = $createTextNode('@Alice'); + const textAfterSelection = $createTextNode('after'); + selectedText.setFormat('bold'); + paragraphNode.append(textBeforeSelection, selectedText, textAfterSelection); + $getRoot().clear().append(paragraphNode); + selectedText.select(3, 3); + }, + {discrete: true}, + ); +} + +describe('getSelectionInfo', () => { + it.each(selectionInfoTestCases)('characterizes $description', testCase => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + setTextSelection(harness.editor, { + text: testCase.text, + startOffset: testCase.cursorOffset, + endOffset: testCase.cursorOffset, + }); + + const actualSelectionInfo = readSelectionInfo(harness.editor); + assertNotNull(actualSelectionInfo); + + expect(actualSelectionInfo.textContent).toBe(testCase.text); + expect(actualSelectionInfo.offset).toBe(testCase.cursorOffset); + expect(actualSelectionInfo.isTextNode).toBe(true); + expect(actualSelectionInfo.wordCharBeforeCursor).toBe(testCase.expectedWordCharBeforeCursor); + expect(actualSelectionInfo.wordCharAfterCursor).toBe(testCase.expectedWordCharAfterCursor); + expect(actualSelectionInfo.cursorAtStartOfNode).toBe(testCase.expectedCursorAtStartOfNode); + expect(actualSelectionInfo.cursorAtEndOfNode).toBe(testCase.expectedCursorAtEndOfNode); + }); + + it('returns adjacent lexical siblings for a collapsed text selection', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + setTextSelectionWithSiblings(harness.editor); + + const actualSelectionSiblingTextContents = readSelectionSiblingTextContents(harness.editor); + + expect(actualSelectionSiblingTextContents.previousText).toBe('before'); + expect(actualSelectionSiblingTextContents.nextText).toBe('after'); + }); + + it('returns undefined for a non-collapsed text selection', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + setTextSelection(harness.editor, {text: 'Alice', startOffset: 0, endOffset: 3}); + + const actualSelectionInfo = readSelectionInfo(harness.editor); + + expect(actualSelectionInfo).toBeUndefined(); + }); + + it('projects a paragraph element selection onto its first text node', () => { + const harness: WireLexicalEditorTestHarness = createWireLexicalEditorTestHarness(); + + harness.editor.update( + () => { + const paragraphNode = $createParagraphNode(); + const textNode = $createTextNode('Alice'); + paragraphNode.append(textNode); + $getRoot().clear().append(paragraphNode); + paragraphNode.select(); + }, + {discrete: true}, + ); + + const actualSelectionInfo = readSelectionInfo(harness.editor); + assertNotNull(actualSelectionInfo); + + expect(actualSelectionInfo.textContent).toBe('Alice'); + expect(actualSelectionInfo.offset).toBe(0); + expect(actualSelectionInfo.isTextNode).toBe(true); + expect(actualSelectionInfo.selection.anchor.type).toBe('element'); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/parseMentions.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/parseMentions.test.ts new file mode 100644 index 00000000000..ec751d86b59 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/parseMentions.test.ts @@ -0,0 +1,189 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical'; + +import {User} from 'Repositories/entity/User'; +import {translateForTest} from 'Util/test/translateForTest'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from '../testSupport/createWireLexicalEditorTestHarness'; +import {$createMentionNode} from '../nodes/MentionNode'; + +import {parseMentions} from './parseMentions'; + +type CreateTestUserOptions = { + readonly userId: string; + readonly userName: string; + readonly userDomain?: string; +}; + +type MentionEditorContentOptions = { + readonly harness: WireLexicalEditorTestHarness; + readonly text: string; + readonly mentionValues: string[]; +}; + +type ParsedMentionValue = { + readonly startIndex: number; + readonly length: number; + readonly userId: string; + readonly domain: string | undefined; +}; + +function createTestUser(testUserOptions: CreateTestUserOptions): User { + const {userId, userName, userDomain} = testUserOptions; + const user = new User(userId, userDomain ?? '', translateForTest); + user.name(userName); + + return user; +} + +function setMentionEditorContent(mentionEditorContentOptions: MentionEditorContentOptions): void { + const {harness, text, mentionValues} = mentionEditorContentOptions; + harness.editor.update( + () => { + const paragraph = $createParagraphNode(); + let textStartIndex = 0; + + mentionValues.forEach(function (mentionValue: string): void { + const mentionText = `@${mentionValue}`; + const mentionStartIndex = text.indexOf(mentionText, textStartIndex); + + if (mentionStartIndex === -1) { + throw new Error(`Mention ${mentionText} was not found in ${text}`); + } + + const textBeforeMention = text.slice(textStartIndex, mentionStartIndex); + if (textBeforeMention.length > 0) { + paragraph.append($createTextNode(textBeforeMention)); + } + + paragraph.append($createMentionNode('@', mentionValue)); + textStartIndex = mentionStartIndex + mentionText.length; + }); + + const textAfterMentions = text.slice(textStartIndex); + if (textAfterMentions.length > 0) { + paragraph.append($createTextNode(textAfterMentions)); + } + + $getRoot().clear(); + $getRoot().append(paragraph); + }, + {discrete: true}, + ); +} + +function getParsedMentionValues(mentionEntities: ReturnType): ParsedMentionValue[] { + return mentionEntities.map(mentionEntity => { + return { + startIndex: mentionEntity.startIndex, + length: mentionEntity.length, + userId: mentionEntity.userId, + domain: mentionEntity.domain, + }; + }); +} + +describe('parseMentions', () => { + it('returns no entities when the editor contains no mention nodes', () => { + const harness = createWireLexicalEditorTestHarness(); + const user = createTestUser({userId: 'alice-id', userName: 'Alice'}); + + setMentionEditorContent({harness, text: 'ordinary text', mentionValues: []}); + + const actualMentions = getParsedMentionValues(parseMentions(harness.editor, 'ordinary text', [user])); + const expectedMentions: ParsedMentionValue[] = []; + + expect(actualMentions).toEqual(expectedMentions); + }); + + it('returns the entity for one allowed mention with its text position', () => { + const harness = createWireLexicalEditorTestHarness(); + const user = createTestUser({userId: 'alice-id', userName: 'Alice'}); + + setMentionEditorContent({harness, text: 'Hello @Alice!', mentionValues: ['Alice']}); + + const actualMentions = getParsedMentionValues(parseMentions(harness.editor, 'Hello @Alice!', [user])); + const expectedMentions: ParsedMentionValue[] = [{startIndex: 6, length: 6, userId: 'alice-id', domain: ''}]; + + expect(actualMentions).toEqual(expectedMentions); + }); + + it('returns multiple mentions in document order even when users are supplied in another order', () => { + const harness = createWireLexicalEditorTestHarness(); + const alice = createTestUser({userId: 'alice-id', userName: 'Alice'}); + const bob = createTestUser({userId: 'bob-id', userName: 'Bob'}); + const text = '@Bob and @Alice'; + + setMentionEditorContent({harness, text, mentionValues: ['Bob', 'Alice']}); + + const actualMentions = getParsedMentionValues(parseMentions(harness.editor, text, [alice, bob])); + const expectedMentions: ParsedMentionValue[] = [ + {startIndex: 0, length: 4, userId: 'bob-id', domain: ''}, + {startIndex: 9, length: 6, userId: 'alice-id', domain: ''}, + ]; + + expect(actualMentions).toEqual(expectedMentions); + }); + + it('returns repeated mentions for the same user at their individual positions', () => { + const harness = createWireLexicalEditorTestHarness(); + const user = createTestUser({userId: 'alice-id', userName: 'Alice'}); + const text = '@Alice and @Alice'; + + setMentionEditorContent({harness, text, mentionValues: ['Alice', 'Alice']}); + + const actualMentions = getParsedMentionValues(parseMentions(harness.editor, text, [user])); + const expectedMentions: ParsedMentionValue[] = [ + {startIndex: 0, length: 6, userId: 'alice-id', domain: ''}, + {startIndex: 11, length: 6, userId: 'alice-id', domain: ''}, + ]; + + expect(actualMentions).toEqual(expectedMentions); + }); + + it('omits a mention whose user is not among the candidates', () => { + const harness = createWireLexicalEditorTestHarness(); + const user = createTestUser({userId: 'alice-id', userName: 'Alice'}); + + setMentionEditorContent({harness, text: 'Hello @Unknown!', mentionValues: ['Unknown']}); + + const actualMentions = getParsedMentionValues(parseMentions(harness.editor, 'Hello @Unknown!', [user])); + const expectedMentions: ParsedMentionValue[] = []; + + expect(actualMentions).toEqual(expectedMentions); + }); + + it('preserves the user domain on a federated mention', () => { + const harness = createWireLexicalEditorTestHarness(); + const user = createTestUser({userId: 'alice-id', userName: 'Alice', userDomain: 'example.com'}); + + setMentionEditorContent({harness, text: '@Alice', mentionValues: ['Alice']}); + + const actualMentions = getParsedMentionValues(parseMentions(harness.editor, '@Alice', [user])); + const expectedMentions: ParsedMentionValue[] = [ + {startIndex: 0, length: 6, userId: 'alice-id', domain: 'example.com'}, + ]; + + expect(actualMentions).toEqual(expectedMentions); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/transformMessage.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/transformMessage.test.ts new file mode 100644 index 00000000000..c56466b36ab --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/transformMessage.test.ts @@ -0,0 +1,132 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {createWireLexicalEditorTestHarness} from '../testSupport/createWireLexicalEditorTestHarness'; +import {getRawMessageText, transformMessage} from './transformMessage'; + +type MessageTransformationTestCase = { + readonly description: string; + readonly replaceEmojis: boolean; + readonly inputMarkdown: string; + readonly expectedMessage: string; +}; + +const messageTransformationTestCases: readonly MessageTransformationTestCase[] = [ + { + description: 'does not replace emoticons when emoji replacement is disabled', + replaceEmojis: false, + inputMarkdown: 'hello :)', + expectedMessage: 'hello :)', + }, + { + description: 'replaces a supported emoticon when emoji replacement is enabled', + replaceEmojis: true, + inputMarkdown: 'hello :)', + expectedMessage: 'hello πŸ™‚', + }, + { + description: 'preserves text without a supported emoticon', + replaceEmojis: true, + inputMarkdown: 'hello there', + expectedMessage: 'hello there', + }, + { + description: 'uses the configured first matching replacement', + replaceEmojis: true, + inputMarkdown: ':) :D', + expectedMessage: ':) πŸ˜„', + }, + { + description: 'preserves ordinary Unicode emoji', + replaceEmojis: true, + inputMarkdown: 'already πŸ˜€', + expectedMessage: 'already πŸ˜€', + }, +]; + +type RawMessageTextTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly expectedText: string; +}; + +const rawMessageTextTestCases: readonly RawMessageTextTestCase[] = [ + { + description: 'an empty editor', + inputMarkdown: '', + expectedText: '', + }, + { + description: 'a paragraph with one line break', + inputMarkdown: 'first line\nsecond line', + expectedText: 'first line\nsecond line', + }, + { + description: 'two paragraphs separated by a blank line', + inputMarkdown: 'first paragraph\n\nsecond paragraph', + expectedText: 'first paragraph\n\nsecond paragraph', + }, + { + description: 'an ordered list', + inputMarkdown: '1. first\n2. second', + expectedText: 'first\n\nsecond', + }, + { + description: 'formatted text', + inputMarkdown: '**bold** *italic* `code`', + expectedText: 'bold italic code', + }, + { + description: 'a link', + inputMarkdown: '[Wire](https://wire.com)', + expectedText: 'Wire', + }, +]; + +function getRawMessageTextFromMarkdown(inputMarkdown: string): string { + const harness = createWireLexicalEditorTestHarness(); + harness.importMarkdown(inputMarkdown); + + return harness.editor.getEditorState().read(() => { + return getRawMessageText(); + }); +} + +describe('transformMessage', () => { + it.each(messageTransformationTestCases)( + 'preserves the current behavior when it $description', + transformationTestCase => { + const actualMessage = transformMessage({ + replaceEmojis: transformationTestCase.replaceEmojis, + markdown: transformationTestCase.inputMarkdown, + }); + const expectedMessage = transformationTestCase.expectedMessage; + + expect(actualMessage).toBe(expectedMessage); + }, + ); +}); + +describe('getRawMessageText', () => { + it.each(rawMessageTextTestCases)('preserves the current behavior for $description', rawTextTestCase => { + const actualText = getRawMessageTextFromMarkdown(rawTextTestCase.inputMarkdown); + const expectedText = rawTextTestCase.expectedText; + + expect(actualText).toBe(expectedText); + }); +}); diff --git a/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/useEditorDraftState.test.ts b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/useEditorDraftState.test.ts new file mode 100644 index 00000000000..5117a2ec996 --- /dev/null +++ b/apps/webapp/src/script/components/InputBar/InputBarEditor/RichTextEditor/utils/useEditorDraftState.test.ts @@ -0,0 +1,316 @@ +/* + * Wire + * Copyright (C) 2026 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + */ + +import {$createMentionNode} from '../nodes/MentionNode'; +import {EmojiNode} from '../nodes/EmojiNode'; +import {$createParagraphNode, $createTextNode, $getRoot, LexicalEditor} from 'lexical'; + +import {act, renderHook} from '@testing-library/react'; + +import { + createWireLexicalEditorTestHarness, + WireLexicalEditorTestHarness, +} from '../testSupport/createWireLexicalEditorTestHarness'; +import {useEditorDraftState} from './useEditorDraftState'; + +type SaveDraftState = (editorState: string, plainMessage: string, replyId?: string) => void; + +type DraftStateCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly replaceEmojis: boolean; + readonly expectedPlainMessage: string; +}; + +type DraftStateTestFunction = () => void; + +type DraftStateCharacterizationTestFunction = (testCase: DraftStateCharacterizationTestCase) => void; + +type DraftStateHookOptions = { + readonly editor: LexicalEditor | null; + readonly replaceEmojis: boolean; + readonly disableMessagePreprocessing: boolean; + readonly saveDraftState: SaveDraftState; +}; + +type DraftStateHookRenderResult = { + readonly result: { + readonly current: ReturnType; + }; + readonly unmount: () => void; +}; + +const draftStateCharacterizationTestCases: readonly DraftStateCharacterizationTestCase[] = [ + { + description: 'plain text', + inputMarkdown: 'draft message', + replaceEmojis: false, + expectedPlainMessage: 'draft message', + }, + { + description: 'formatted text in a list and a link', + inputMarkdown: '**bold**\n\n- item\n- [link](https://wire.com)', + replaceEmojis: false, + expectedPlainMessage: '**bold**\n\n- item\n- [link](https://wire.com)', + }, + { + description: 'an emoticon with emoji replacement enabled', + inputMarkdown: 'hello :)', + replaceEmojis: true, + expectedPlainMessage: 'hello πŸ™‚', + }, +]; + +function executeWithFakeTimers(testFunction: DraftStateTestFunction): void { + jest.useFakeTimers(); + + try { + testFunction(); + } finally { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + } +} + +function withFakeTimers(testFunction: DraftStateTestFunction): DraftStateTestFunction { + return () => { + executeWithFakeTimers(testFunction); + }; +} + +function withFakeTimersForCharacterizationTest( + testFunction: DraftStateCharacterizationTestFunction, +): DraftStateCharacterizationTestFunction { + return function (testCase: DraftStateCharacterizationTestCase): void { + executeWithFakeTimers(() => { + testFunction(testCase); + }); + }; +} + +function renderDraftStateHook(draftStateHookOptions: DraftStateHookOptions): DraftStateHookRenderResult { + const editorRef = {current: draftStateHookOptions.editor}; + const renderedHook = renderHook(() => { + return useEditorDraftState({ + editorRef, + saveDraftState: draftStateHookOptions.saveDraftState, + replaceEmojis: draftStateHookOptions.replaceEmojis, + disableMessagePreprocessing: draftStateHookOptions.disableMessagePreprocessing, + }); + }); + + return {result: renderedHook.result, unmount: renderedHook.unmount}; +} + +function importMarkdown(harness: WireLexicalEditorTestHarness, markdown: string): void { + harness.importMarkdown(markdown); +} + +function appendMentionContent(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append($createTextNode('Hello '), $createMentionNode('@', 'Alice'), $createTextNode('!')); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +function appendEmojiContent(editor: LexicalEditor): void { + editor.update( + () => { + const paragraphNode = $createParagraphNode(); + paragraphNode.append(new EmojiNode('πŸ§ͺ')); + $getRoot().clear().append(paragraphNode); + }, + {discrete: true}, + ); +} + +describe('useEditorDraftState', () => { + it.each(draftStateCharacterizationTestCases)( + 'saves the serialized editor and transformed plain message for $description', + withFakeTimersForCharacterizationTest(testCase => { + const harness = createWireLexicalEditorTestHarness(); + importMarkdown(harness, testCase.inputMarkdown); + const saveDraftState = jest.fn>(); + const renderedHook = renderDraftStateHook({ + editor: harness.editor, + replaceEmojis: testCase.replaceEmojis, + disableMessagePreprocessing: false, + saveDraftState, + }); + + act(() => { + renderedHook.result.current.saveDraft(); + }); + + expect(saveDraftState).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(799); + }); + + expect(saveDraftState).not.toHaveBeenCalled(); + + act(() => { + jest.advanceTimersByTime(1); + }); + + expect(saveDraftState).toHaveBeenCalledWith( + JSON.stringify(harness.editor.getEditorState().toJSON()), + testCase.expectedPlainMessage, + undefined, + ); + + renderedHook.unmount(); + }), + ); + + it( + 'saves raw editor text and an empty Markdown value when preprocessing is disabled', + withFakeTimers(() => { + const harness = createWireLexicalEditorTestHarness(); + importMarkdown(harness, 'first line\nsecond line'); + const saveDraftState = jest.fn>(); + const renderedHook = renderDraftStateHook({ + editor: harness.editor, + replaceEmojis: false, + disableMessagePreprocessing: true, + saveDraftState, + }); + + act(() => { + renderedHook.result.current.saveDraft(); + jest.advanceTimersByTime(800); + }); + + expect(saveDraftState).toHaveBeenCalledWith( + JSON.stringify(harness.editor.getEditorState().toJSON()), + 'first line\nsecond line', + undefined, + ); + + renderedHook.unmount(); + }), + ); + + it( + 'saves the display text of a custom mention node', + withFakeTimers(() => { + const harness = createWireLexicalEditorTestHarness(); + appendMentionContent(harness.editor); + const saveDraftState = jest.fn>(); + const renderedHook = renderDraftStateHook({ + editor: harness.editor, + replaceEmojis: false, + disableMessagePreprocessing: false, + saveDraftState, + }); + + act(() => { + renderedHook.result.current.saveDraft(); + jest.advanceTimersByTime(800); + }); + + expect(saveDraftState).toHaveBeenCalledWith( + JSON.stringify(harness.editor.getEditorState().toJSON()), + 'Hello @Alice!', + undefined, + ); + + renderedHook.unmount(); + }), + ); + + it( + 'saves the display text and serialized state of a custom emoji node', + withFakeTimers(() => { + const harness = createWireLexicalEditorTestHarness(); + appendEmojiContent(harness.editor); + const saveDraftState = jest.fn>(); + const renderedHook = renderDraftStateHook({ + editor: harness.editor, + replaceEmojis: false, + disableMessagePreprocessing: false, + saveDraftState, + }); + + act(() => { + renderedHook.result.current.saveDraft(); + jest.advanceTimersByTime(800); + }); + + expect(saveDraftState).toHaveBeenCalledWith(expect.stringContaining('"type":"emoji"'), 'πŸ§ͺ', undefined); + + renderedHook.unmount(); + }), + ); + + it( + 'flushes a pending save when the hook unmounts', + withFakeTimers(() => { + const harness = createWireLexicalEditorTestHarness(); + importMarkdown(harness, 'pending draft'); + const saveDraftState = jest.fn>(); + const renderedHook = renderDraftStateHook({ + editor: harness.editor, + replaceEmojis: false, + disableMessagePreprocessing: false, + saveDraftState, + }); + + act(() => { + renderedHook.result.current.saveDraft(); + }); + + expect(saveDraftState).not.toHaveBeenCalled(); + + renderedHook.unmount(); + + expect(saveDraftState).toHaveBeenCalledWith( + JSON.stringify(harness.editor.getEditorState().toJSON()), + 'pending draft', + undefined, + ); + }), + ); + + it( + 'does not save when the editor reference is empty', + withFakeTimers(() => { + const saveDraftState = jest.fn>(); + const renderedHook = renderDraftStateHook({ + editor: null, + replaceEmojis: false, + disableMessagePreprocessing: false, + saveDraftState, + }); + + act(() => { + renderedHook.result.current.saveDraft(); + jest.advanceTimersByTime(800); + }); + + expect(saveDraftState).not.toHaveBeenCalled(); + + renderedHook.unmount(); + }), + ); +}); diff --git a/apps/webapp/src/script/util/messageRenderer.test.ts b/apps/webapp/src/script/util/messageRenderer.test.ts index 11623944998..01f0f159de3 100644 --- a/apps/webapp/src/script/util/messageRenderer.test.ts +++ b/apps/webapp/src/script/util/messageRenderer.test.ts @@ -21,7 +21,80 @@ import {renderMessage, getRenderedTextContent} from './messageRenderer'; import {MentionEntity} from '../message/mentionEntity'; -const escapeLink = (link: string) => link.replace(/&/g, '&'); +function escapeLink(link: string): string { + return link.replace(/&/g, '&'); +} + +type MarkdownStructureCharacterizationTestCase = { + readonly description: string; + readonly inputMarkdown: string; + readonly expectedHtml: string; +}; + +const markdownStructureCharacterizationTestCases: readonly MarkdownStructureCharacterizationTestCase[] = [ + { + description: 'an ordered list', + inputMarkdown: '1. first\n2. second', + expectedHtml: '
    \n
  1. first
  2. \n
  3. second
  4. \n
', + }, + { + description: 'an ordered list with a non-default starting number', + inputMarkdown: '14. first\n15. second', + expectedHtml: '
    \n
  1. first
  2. \n
  3. second
  4. \n
', + }, + { + description: 'a dash unordered list', + inputMarkdown: '- first\n- second', + expectedHtml: '
    \n
  • first
  • \n
  • second
  • \n
', + }, + { + description: 'an asterisk unordered list', + inputMarkdown: '* first\n* second', + expectedHtml: '
    \n
  • first
  • \n
  • second
  • \n
', + }, + { + description: 'a plus unordered list', + inputMarkdown: '+ first\n+ second', + expectedHtml: '
    \n
  • first
  • \n
  • second
  • \n
', + }, + { + description: 'a mixed nested list', + inputMarkdown: '1. one\n - nested', + expectedHtml: '
    \n
  1. one\n
      \n
    • nested
    • \n
    \n
  2. \n
', + }, + { + description: 'an ordered list followed by ordinary text', + inputMarkdown: '1. item\n\ntext', + expectedHtml: '
    \n
  1. item
  2. \n
\n
text', + }, + { + description: 'ordinary text followed by an unordered list', + inputMarkdown: 'text\n\n- item', + expectedHtml: 'text
    \n
  • item
  • \n
', + }, + { + description: 'a multiline blockquote', + inputMarkdown: '> quote\n> next', + expectedHtml: '
quote
next
', + }, + { + description: 'a blockquote with inline formatting and a link', + inputMarkdown: '> **quoted** [link](https://wire.com)', + expectedHtml: + '
quoted link
', + }, + { + description: 'all nested list levels produced by the ambiguous date-like input', + inputMarkdown: '14. - 25. september', + expectedHtml: + '
    \n
  1. \n
      \n
    • \n
        \n
      1. september
      2. \n
      \n
    • \n
    \n
  2. \n
', + }, + { + description: 'an escaped date-like list input as plain text', + inputMarkdown: '14\\. - 25. september', + expectedHtml: '14. - 25. september', + }, +]; describe('renderMessage', () => { it('renders a normal link', () => { @@ -409,6 +482,7 @@ describe('renderMessage', () => { tests.forEach(({expected, mentions, testCase, text}) => { const mentionEntities = mentions.map(mention => { const mentionEntity = new MentionEntity(mention.startIndex, mention.length, mention.userId); + return mentionEntity; }); @@ -425,6 +499,7 @@ describe('renderMessage', () => { const mentions = [{length: 5, startIndex: 4, userId: 'pain-id'}]; const mentionEntities = mentions.map(mention => { const mentionEntity = new MentionEntity(mention.startIndex, mention.length, mention.userId); + return mentionEntity; }); const result = renderMessage('hey @user', undefined, mentionEntities); @@ -527,6 +602,14 @@ describe('Markdown for headings', () => { expect(renderMessage('## heading')).toBe('
heading
'); expect(renderMessage('### heading')).toBe('
heading
'); expect(renderMessage('#### heading')).toBe('
heading
'); + expect(renderMessage('##### heading')).toBe('
heading
'); + expect(renderMessage('###### heading')).toBe('
heading
'); + }); +}); + +describe('Markdown block structures', () => { + it.each(markdownStructureCharacterizationTestCases)('renders $description', characterizationTestCase => { + expect(renderMessage(characterizationTestCase.inputMarkdown)).toBe(characterizationTestCase.expectedHtml); }); }); diff --git a/apps/webapp/test/e2e_tests/specs/Conversations/conversations.spec.ts b/apps/webapp/test/e2e_tests/specs/Conversations/conversations.spec.ts index 629a954fd95..73655e2a7cd 100644 --- a/apps/webapp/test/e2e_tests/specs/Conversations/conversations.spec.ts +++ b/apps/webapp/test/e2e_tests/specs/Conversations/conversations.spec.ts @@ -468,6 +468,23 @@ test.describe('Conversations', () => { await expect(userAPages.conversation().getMessage({content: 'πŸ™‚'})).toBeVisible(); }); + test('Verify emoji picker selection replaces an emoji alias', {tag: ['@regression']}, async ({createPage}) => { + const userAPage = await createPage(withLogin(userA)); + await connectWithUser(userAPage, userB); + + const userAPages = PageManager.from(userAPage).webapp.pages; + await userAPages.conversationList().getConversation(userB.fullName).open(); + + await userAPages.conversation().messageInput.pressSequentially(':smile', {delay: 100}); + + const emojiMenu = userAPage.locator('#emoji-typeahead-menu'); + await expect(emojiMenu).toBeVisible(); + await emojiMenu.getByRole('button', {name: 'smile', exact: true}).click(); + + await expect(userAPages.conversation().messageInput).toContainText('πŸ˜„'); + await expect(userAPages.conversation().messageInput).not.toContainText(':smile'); + }); + test( 'I can see the system message "You renamed the conversation" after renaming conversation', {tag: ['@TC-496', '@regression']}, diff --git a/apps/webapp/test/e2e_tests/specs/Markdown/markdown.spec.ts b/apps/webapp/test/e2e_tests/specs/Markdown/markdown.spec.ts index dbc25889ebb..c583db64bcb 100644 --- a/apps/webapp/test/e2e_tests/specs/Markdown/markdown.spec.ts +++ b/apps/webapp/test/e2e_tests/specs/Markdown/markdown.spec.ts @@ -33,6 +33,69 @@ test.describe('Markdown', () => { userA = team.owner; }); + test( + 'normalizes CRLF in plain text pasted into the editor', + {tag: ['@rich-text-characterization', '@regression']}, + async ({createPage}) => { + const userAPage = await createPage(withLogin(userA)); + await connectWithUser(userAPage, userB); + + const userAPages = PageManager.from(userAPage).webapp.pages; + await userAPages.conversationList().getConversation(userB.fullName, {protocol: 'mls'}).open(); + + const messageInput = userAPages.conversation().messageInput; + await messageInput.click(); + await userAPage.evaluate(async pastedText => { + await navigator.clipboard.writeText(pastedText); + }, 'first line\r\nsecond line'); + await messageInput.press('ControlOrMeta+v'); + + await expect(messageInput).toContainText('first line'); + await expect(messageInput).toContainText('second line'); + + const actualEditorText = await messageInput.evaluate((element: HTMLElement): string => element.innerText); + const expectedEditorText = 'first line\nsecond line'; + + expect(actualEditorText).toBe(expectedEditorText); + }, + ); + + test( + 'preserves rich HTML formatting, links, and lists when pasted into the editor', + {tag: ['@rich-text-characterization', '@regression']}, + async ({createPage}) => { + const userAPage = await createPage(withLogin(userA)); + await connectWithUser(userAPage, userB); + + const userAPages = PageManager.from(userAPage).webapp.pages; + await userAPages.conversationList().getConversation(userB.fullName, {protocol: 'mls'}).open(); + + const messageInput = userAPages.conversation().messageInput; + await messageInput.click(); + await userAPage.evaluate( + async clipboardContents => { + const clipboardItem = new ClipboardItem({ + 'text/html': new Blob([clipboardContents.html], {type: 'text/html'}), + 'text/plain': new Blob([clipboardContents.plainText], {type: 'text/plain'}), + }); + + await navigator.clipboard.write([clipboardItem]); + }, + { + html: '

Pasted bold Wire

  • first
  • second
', + plainText: 'Pasted bold Wire\nfirst\nsecond', + }, + ); + await messageInput.press('ControlOrMeta+v'); + + await expect(messageInput.locator('strong')).toHaveText('Pasted bold'); + await expect(messageInput.locator('a')).toHaveAttribute('href', 'https://wire.com'); + await expect(messageInput.locator('a')).toHaveText('Wire'); + await expect(messageInput.locator('ul')).toHaveCount(1); + await expect(messageInput.locator('li')).toHaveText(['first', 'second']); + }, + ); + [ { description: 'I want to write a bold message', diff --git a/apps/webapp/test/e2e_tests/specs/Mention/mention.spec.ts b/apps/webapp/test/e2e_tests/specs/Mention/mention.spec.ts index e84db7a7439..b1988ac3099 100644 --- a/apps/webapp/test/e2e_tests/specs/Mention/mention.spec.ts +++ b/apps/webapp/test/e2e_tests/specs/Mention/mention.spec.ts @@ -478,6 +478,32 @@ test.describe('Mention', () => { }, ); + test('I should navigate mention suggestions with the keyboard', {tag: ['@regression']}, async ({createPage}) => { + const {pages} = PageManager.from(await createPage(withLogin(userA))).webapp; + + await createGroup(pages, 'Keyboard Mention Group', [userB, userC]); + await pages.conversationList().getConversation('Keyboard Mention Group').open(); + + const conversationPage = pages.conversation(); + const mentionSuggestions = conversationPage.mentionSuggestions; + + await conversationPage.messageInput.pressSequentially('@'); + await expect(mentionSuggestions).toHaveCount(2); + await expect(mentionSuggestions.nth(1)).toHaveAttribute('data-uie-selected', 'true'); + await expect(mentionSuggestions.nth(0)).toHaveAttribute('data-uie-selected', 'false'); + + await conversationPage.messageInput.press('ArrowUp'); + await expect(mentionSuggestions.nth(0)).toHaveAttribute('data-uie-selected', 'true'); + await expect(mentionSuggestions.nth(1)).toHaveAttribute('data-uie-selected', 'false'); + + await conversationPage.messageInput.press('ArrowDown'); + await expect(mentionSuggestions.nth(1)).toHaveAttribute('data-uie-selected', 'true'); + + await conversationPage.messageInput.press('Escape'); + await expect(mentionSuggestions).toHaveCount(0); + await expect(conversationPage.messageInput).toHaveText('@'); + }); + test( 'I want to mention a name with or without umlaut gives the same suggestion (query normalization)', {tag: ['@TC-3537', '@regression']},