diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 85d1477116c..1013885b3d7 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -15,9 +15,11 @@ limitations under the License. */ /// +import { EventType } from "matrix-js-sdk/src/@types/event"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { MatrixClient } from "../../global"; describe("Composer", () => { let homeserver: HomeserverInstance; @@ -181,6 +183,81 @@ describe("Composer", () => { }); }); + describe("Mentions", () => { + // TODO add tests for rich text mode + + describe("Plain text mode", () => { + it("autocomplete behaviour tests", () => { + // Setup a private room so we have another user to mention + const otherUserName = "Bob"; + let bobClient: MatrixClient; + cy.getBot(homeserver, { + displayName: otherUserName, + }).then((bob) => { + bobClient = bob; + }); + // create DM with bob + cy.getClient().then(async (cli) => { + const bobRoom = await cli.createRoom({ is_direct: true }); + await cli.invite(bobRoom.room_id, bobClient.getUserId()); + await cli.setAccountData("m.direct" as EventType, { + [bobClient.getUserId()]: [bobRoom.room_id], + }); + }); + + cy.viewRoomByName("Bob"); + + // Select plain text mode after composer is ready + cy.get("div[contenteditable=true]").should("exist"); + cy.findByRole("button", { name: "Hide formatting" }).click(); + + // Typing a single @ does not display the autocomplete menu and contents + cy.findByRole("textbox").type("@"); + cy.findByTestId("autocomplete-wrapper").should("be.empty"); + + // Entering the first letter of the other user's name opens the autocomplete... + cy.findByRole("textbox").type(otherUserName.slice(0, 1)); + cy.findByTestId("autocomplete-wrapper") + .should("not.be.empty") + .within(() => { + // ...with the other user name visible, and clicking that username... + cy.findByText(otherUserName).should("exist").click(); + }); + // ...inserts the username into the composer + cy.findByRole("textbox").within(() => { + // TODO update this test when the mentions are inserted as pills, instead + // of as text + cy.findByText(otherUserName, { exact: false }).should("exist"); + }); + + // Send the message to clear the composer + cy.findByRole("button", { name: "Send message" }).click(); + + // Typing an @, then other user's name, then trailing space closes the autocomplete + cy.findByRole("textbox").type(`@${otherUserName} `); + cy.findByTestId("autocomplete-wrapper").should("be.empty"); + + // Send the message to clear the composer + cy.findByRole("button", { name: "Send message" }).click(); + + // Moving the cursor back to an "incomplete" mention opens the autocomplete + cy.findByRole("textbox").type(`initial text @${otherUserName.slice(0, 1)} abc`); + cy.findByTestId("autocomplete-wrapper").should("be.empty"); + // Move the cursor left by 4 to put it to: `@B| abc`, check autocomplete displays + cy.findByRole("textbox").type(`${"{leftArrow}".repeat(4)}`); + cy.findByTestId("autocomplete-wrapper").should("not.be.empty"); + + // Selecting the autocomplete option using Enter inserts it into the composer + cy.findByRole("textbox").type(`{Enter}`); + cy.findByRole("textbox").within(() => { + // TODO update this test when the mentions are inserted as pills, instead + // of as text + cy.findByText(otherUserName, { exact: false }).should("exist"); + }); + }); + }); + }); + it("sends a message when you click send or press Enter", () => { // Type a message cy.get("div[contenteditable=true]").type("my message 0"); diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index e1db110847f..e8d1833cc89 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -20,13 +20,13 @@ import { SyntheticEvent, useState } from "react"; /** * Information about the current state of the `useSuggestion` hook. */ -export type Suggestion = MappedSuggestion & { - /** - * The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete - * component but more information is required to allow manipulation of the correct part of the DOM - * when selecting an option from the autocomplete. These three pieces of information allow us to - * do that. - */ +export type Suggestion = { + mappedSuggestion: MappedSuggestion; + /* The information in a `MappedSuggestion` is sufficient to generate a query for the autocomplete + component but more information is required to allow manipulation of the correct part of the DOM + when selecting an option from the autocomplete. These three pieces of information allow us to + do that. + */ node: Node; startOffset: number; endOffset: number; @@ -39,38 +39,37 @@ type SuggestionState = Suggestion | null; * @param editorRef - a ref to the div that is the composer textbox * @param setText - setter function to set the content of the composer * @returns - * - `handleMention`: TODO a function that will insert @ or # mentions which are selected from - * the autocomplete into the composer + * - `handleMention`: a function that will insert @ or # mentions which are selected from + * the autocomplete into the composer, given an href, the text to display, and any additional attributes * - `handleCommand`: a function that will replace the content of the composer with the given replacement text. * Can be used to process autocomplete of slash commands * - `onSelect`: a selection change listener to be attached to the plain text composer * - `suggestion`: if the cursor is inside something that could be interpreted as a command or a mention, * this will be an object representing that command or mention, otherwise it is null */ - export function useSuggestion( editorRef: React.RefObject, setText: (text: string) => void, ): { - handleMention: (link: string, text: string, attributes: Attributes) => void; + handleMention: (href: string, displayName: string, attributes: Attributes) => void; handleCommand: (text: string) => void; onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { - const [suggestion, setSuggestion] = useState(null); + const [suggestionData, setSuggestionData] = useState(null); - // TODO handle the mentions (@user, #room etc) - const handleMention = (): void => {}; - - // We create a `seletionchange` handler here because we need to know when the user has moved the cursor, + // We create a `selectionchange` handler here because we need to know when the user has moved the cursor, // we can not depend on input events only - const onSelect = (): void => processSelectionChange(editorRef, suggestion, setSuggestion); + const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData); + + const handleMention = (href: string, displayName: string, attributes: Attributes): void => + processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText); const handleCommand = (replacementText: string): void => - processCommand(replacementText, suggestion, setSuggestion, setText); + processCommand(replacementText, suggestionData, setSuggestionData, setText); return { - suggestion: mapSuggestion(suggestion), + suggestion: suggestionData?.mappedSuggestion ?? null, handleCommand, handleMention, onSelect, @@ -78,41 +77,118 @@ export function useSuggestion( } /** - * Convert a PlainTextSuggestionPattern (or null) to a MappedSuggestion (or null) + * When the selection changes inside the current editor, check to see if the cursor is inside + * something that could be a command or a mention and update the suggestion state if so * - * @param suggestion - the suggestion that is the JS equivalent of the rust model's representation - * @returns - null if the input is null, a MappedSuggestion if the input is non-null + * @param editorRef - ref to the composer + * @param setSuggestionData - the setter for the suggestion state */ -export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => { - if (suggestion === null) { - return null; - } else { - const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion; - return mappedSuggestion; +export function processSelectionChange( + editorRef: React.RefObject, + setSuggestionData: React.Dispatch>, +): void { + const selection = document.getSelection(); + + // return early if we do not have a current editor ref with a cursor selection inside a text node + if ( + editorRef.current === null || + selection === null || + !selection.isCollapsed || + selection.anchorNode?.nodeName !== "#text" + ) { + setSuggestionData(null); + return; + } + + // from here onwards we have a cursor inside a text node + const { anchorNode: currentNode, anchorOffset: currentOffset } = selection; + + // if we have no text content, return, clearing the suggestion state + if (currentNode.textContent === null) { + setSuggestionData(null); + return; + } + + const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); + const isFirstTextNode = currentNode === firstTextNode; + const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset, isFirstTextNode); + + // if we have not found a suggestion, return, clearing the suggestion state + if (foundSuggestion === null) { + setSuggestionData(null); + return; } -}; + + setSuggestionData({ + mappedSuggestion: foundSuggestion.mappedSuggestion, + node: currentNode, + startOffset: foundSuggestion.startOffset, + endOffset: foundSuggestion.endOffset, + }); +} + +/** + * Replaces the relevant part of the editor text with a link representing a mention after it + * is selected from the autocomplete. + * + * @param href - the href that the inserted link will use + * @param displayName - the text content of the link + * @param attributes - additional attributes to add to the link, can include data-* attributes + * @param suggestionData - representation of the part of the DOM that will be replaced + * @param setSuggestionData - setter function to set the suggestion state + * @param setText - setter function to set the content of the composer + */ +export function processMention( + href: string, + displayName: string, + attributes: Attributes, // these will be used when formatting the link as a pill + suggestionData: SuggestionState, + setSuggestionData: React.Dispatch>, + setText: (text: string) => void, +): void { + // if we do not have a suggestion, return early + if (suggestionData === null) { + return; + } + + const { node } = suggestionData; + + const textBeforeReplacement = node.textContent?.slice(0, suggestionData.startOffset) ?? ""; + const textAfterReplacement = node.textContent?.slice(suggestionData.endOffset) ?? ""; + + // TODO replace this markdown style text insertion with a pill representation + const newText = `[${displayName}](<${href}>) `; + const newCursorOffset = textBeforeReplacement.length + newText.length; + const newContent = textBeforeReplacement + newText + textAfterReplacement; + + // insert the new text, move the cursor, set the text state, clear the suggestion state + node.textContent = newContent; + document.getSelection()?.setBaseAndExtent(node, newCursorOffset, node, newCursorOffset); + setText(newContent); + setSuggestionData(null); +} /** * Replaces the relevant part of the editor text with the replacement text after a command is selected * from the autocomplete. * * @param replacementText - the text that we will insert into the DOM - * @param suggestion - representation of the part of the DOM that will be replaced - * @param setSuggestion - setter function to set the suggestion state + * @param suggestionData - representation of the part of the DOM that will be replaced + * @param setSuggestionData - setter function to set the suggestion state * @param setText - setter function to set the content of the composer */ -export const processCommand = ( +export function processCommand( replacementText: string, - suggestion: SuggestionState, - setSuggestion: React.Dispatch>, + suggestionData: SuggestionState, + setSuggestionData: React.Dispatch>, setText: (text: string) => void, -): void => { +): void { // if we do not have a suggestion, return early - if (suggestion === null) { + if (suggestionData === null) { return; } - const { node } = suggestion; + const { node } = suggestionData; // for a command, we know we start at the beginning of the text node, so build the replacement // string (note trailing space) and manually adjust the node's textcontent @@ -123,70 +199,120 @@ export const processCommand = ( // hook and clear the suggestion from state document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length); setText(newContent); - setSuggestion(null); -}; + setSuggestionData(null); +} /** - * When the selection changes inside the current editor, check to see if the cursor is inside - * something that could require the autocomplete to be opened and update the suggestion state - * if so - * TODO expand this to handle mentions + * Given some text content from a node and the cursor position, find the word that the cursor is currently inside + * and then test that word to see if it is a suggestion. Return the `MappedSuggestion` with start and end offsets if + * the cursor is inside a valid suggestion, null otherwise. * - * @param editorRef - ref to the composer - * @param suggestion - the current suggestion state - * @param setSuggestion - the setter for the suggestion state + * @param text - the text content of a node + * @param offset - the current cursor offset position within the node + * @param isFirstTextNode - whether or not the node is the first text node in the editor. Used to determine + * if a command suggestion is found or not + * @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null */ -export const processSelectionChange = ( - editorRef: React.RefObject, - suggestion: SuggestionState, - setSuggestion: React.Dispatch>, -): void => { - const selection = document.getSelection(); +export function findSuggestionInText( + text: string, + offset: number, + isFirstTextNode: boolean, +): { mappedSuggestion: MappedSuggestion; startOffset: number; endOffset: number } | null { + // Return null early if the offset is outside the content + if (offset < 0 || offset > text.length) { + return null; + } - // return early if we do not have a current editor ref with a cursor selection inside a text node + // Variables to keep track of the indices we will be slicing from and to in order to create + // a substring of the word that the cursor is currently inside + let startSliceIndex = offset; + let endSliceIndex = offset; + + // Search backwards from the current cursor position to find the start index of the word + // containing the cursor + while (shouldDecrementStartIndex(text, startSliceIndex)) { + startSliceIndex--; + } + + // Search forwards from the current cursor position to find the end index of the word + // containing the cursor + while (shouldIncrementEndIndex(text, endSliceIndex)) { + endSliceIndex++; + } + + // Get the word at the cursor then check if it contains a suggestion or not + const wordAtCursor = text.slice(startSliceIndex, endSliceIndex); + const mappedSuggestion = getMappedSuggestion(wordAtCursor); + + /** + * If we have a word that could be a command, it is not a valid command if: + * - the node we're looking at isn't the first text node in the editor (adding paragraphs can + * result in nested

tags inside the editor

) + * - the starting index is anything other than 0 (they can only appear at the start of a message) + * - there is more text following the command (eg `/spo asdf|` should not be interpreted as + * something requiring autocomplete) + */ if ( - editorRef.current === null || - selection === null || - !selection.isCollapsed || - selection.anchorNode?.nodeName !== "#text" + mappedSuggestion === null || + (mappedSuggestion.type === "command" && + (!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length)) ) { - return; + return null; } - // here we have established that both anchor and focus nodes in the selection are - // the same node, so rename to `currentNode` for later use - const { anchorNode: currentNode } = selection; + return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + wordAtCursor.length }; +} - // first check is that the text node is the first text node of the editor, as adding paragraphs can result - // in nested

tags inside the editor

- const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); +/** + * Associated function for findSuggestionInText. Checks the character at the preceding index + * to determine if the search loop should continue. + * + * @param text - text content to check for mentions or commands + * @param index - the current index to check + * @returns true if check should keep moving backwards, false otherwise + */ +function shouldDecrementStartIndex(text: string, index: number): boolean { + // If the index is at or outside the beginning of the string, return false + if (index <= 0) return false; - // if we're not in the first text node or we have no text content, return - if (currentNode !== firstTextNode || currentNode.textContent === null) { - return; - } + // We are inside the string so can guarantee that there is a preceding character + // Keep searching backwards if the preceding character is not a space + return !/\s/.test(text[index - 1]); +} - // it's a command if: - // it is the first textnode AND - // it starts with /, not // AND - // then has letters all the way up to the end of the textcontent - const commandRegex = /^\/(\w*)$/; - const commandMatches = currentNode.textContent.match(commandRegex); - - // if we don't have any matches, return, clearing the suggeston state if it is non-null - if (commandMatches === null) { - if (suggestion !== null) { - setSuggestion(null); - } - return; - } else { - setSuggestion({ - keyChar: "/", - type: "command", - text: commandMatches[1], - node: selection.anchorNode, - startOffset: 0, - endOffset: currentNode.textContent.length, - }); +/** + * Associated function for findSuggestionInText. Checks the character at the current index + * to determine if the search loop should continue. + * + * @param text - text content to check for mentions or commands + * @param index - the current index to check + * @returns true if check should keep moving forwards, false otherwise + */ +function shouldIncrementEndIndex(text: string, index: number): boolean { + // If the index is at or outside the end of the string, return false + if (index >= text.length) return false; + + // Keep searching forwards if the current character is not a space + return !/\s/.test(text[index]); +} + +/** + * Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null. + * + * @param text - string to check for a suggestion + * @returns a `MappedSuggestion` if a suggestion is present, null otherwise + */ +export function getMappedSuggestion(text: string): MappedSuggestion | null { + const firstChar = text.charAt(0); + const restOfString = text.slice(1); + + switch (firstChar) { + case "/": + return { keyChar: firstChar, text: restOfString, type: "command" }; + case "#": + case "@": + return { keyChar: firstChar, text: restOfString, type: "mention" }; + default: + return null; } -}; +} diff --git a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx index c25a869a09f..568bc613466 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -17,16 +17,16 @@ import React from "react"; import { Suggestion, - mapSuggestion, + findSuggestionInText, + getMappedSuggestion, processCommand, + processMention, processSelectionChange, } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion"; function createMockPlainTextSuggestionPattern(props: Partial = {}): Suggestion { return { - keyChar: "/", - type: "command", - text: "some text", + mappedSuggestion: { keyChar: "/", type: "command", text: "some text", ...props.mappedSuggestion }, node: document.createTextNode(""), startOffset: 0, endOffset: 0, @@ -34,24 +34,6 @@ function createMockPlainTextSuggestionPattern(props: Partial = {}): }; } -describe("mapSuggestion", () => { - it("returns null if called with a null argument", () => { - expect(mapSuggestion(null)).toBeNull(); - }); - - it("returns a mapped suggestion when passed a suggestion", () => { - const inputFields = { - keyChar: "/" as const, - type: "command" as const, - text: "some text", - }; - const input = createMockPlainTextSuggestionPattern(inputFields); - const output = mapSuggestion(input); - - expect(output).toEqual(inputFields); - }); -}); - describe("processCommand", () => { it("does not change parent hook state if suggestion is null", () => { // create a mockSuggestion using the text node above @@ -85,6 +67,48 @@ describe("processCommand", () => { }); }); +describe("processMention", () => { + // TODO refactor and expand tests when mentions become tags + it("returns early when suggestion is null", () => { + const mockSetSuggestion = jest.fn(); + const mockSetText = jest.fn(); + processMention("href", "displayName", {}, null, mockSetSuggestion, mockSetText); + + expect(mockSetSuggestion).not.toHaveBeenCalled(); + expect(mockSetText).not.toHaveBeenCalled(); + }); + + it("can insert a mention into an empty text node", () => { + // make an empty text node, set the cursor inside it and then append to the document + const textNode = document.createTextNode(""); + document.body.appendChild(textNode); + document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0); + + // call the util function + const href = "href"; + const displayName = "displayName"; + const mockSetSuggestion = jest.fn(); + const mockSetText = jest.fn(); + processMention( + href, + displayName, + {}, + { node: textNode, startOffset: 0, endOffset: 0 } as unknown as Suggestion, + mockSetSuggestion, + mockSetText, + ); + + // placeholder testing for the changed content - these tests will all be changed + // when the mention is inserted as an tagfs + const { textContent } = textNode; + expect(textContent!.includes(href)).toBe(true); + expect(textContent!.includes(displayName)).toBe(true); + + expect(mockSetText).toHaveBeenCalledWith(expect.stringContaining(displayName)); + expect(mockSetSuggestion).toHaveBeenCalledWith(null); + }); +}); + describe("processSelectionChange", () => { function createMockEditorRef(element: HTMLDivElement | null = null): React.RefObject { return { current: element } as React.RefObject; @@ -112,14 +136,14 @@ describe("processSelectionChange", () => { // we monitor for the call to document.createNodeIterator to indicate an early return const nodeIteratorSpy = jest.spyOn(document, "createNodeIterator"); - processSelectionChange(mockEditorRef, null, jest.fn()); + processSelectionChange(mockEditorRef, jest.fn()); expect(nodeIteratorSpy).not.toHaveBeenCalled(); // tidy up to avoid potential impacts on other tests nodeIteratorSpy.mockRestore(); }); - it("does not call setSuggestion if selection is not a cursor", () => { + it("calls setSuggestion with null if selection is not a cursor", () => { const [mockEditor, textNode] = appendEditorWithTextNodeContaining("content"); const mockEditorRef = createMockEditorRef(mockEditor); @@ -128,11 +152,11 @@ describe("processSelectionChange", () => { document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 4); // process the selection and check that we do not attempt to set the suggestion - processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion); - expect(mockSetSuggestion).not.toHaveBeenCalled(); + processSelectionChange(mockEditorRef, mockSetSuggestion); + expect(mockSetSuggestion).toHaveBeenCalledWith(null); }); - it("does not call setSuggestion if selection cursor is not inside a text node", () => { + it("calls setSuggestion with null if selection cursor is not inside a text node", () => { const [mockEditor] = appendEditorWithTextNodeContaining("content"); const mockEditorRef = createMockEditorRef(mockEditor); @@ -140,8 +164,8 @@ describe("processSelectionChange", () => { document.getSelection()?.setBaseAndExtent(mockEditor, 0, mockEditor, 0); // process the selection and check that we do not attempt to set the suggestion - processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion); - expect(mockSetSuggestion).not.toHaveBeenCalled(); + processSelectionChange(mockEditorRef, mockSetSuggestion); + expect(mockSetSuggestion).toHaveBeenCalledWith(null); }); it("calls setSuggestion with null if we have an existing suggestion but no command match", () => { @@ -153,7 +177,7 @@ describe("processSelectionChange", () => { // the call to process the selection will have an existing suggestion in state due to the second // argument being non-null, expect that we clear this suggestion now that the text is not a command - processSelectionChange(mockEditorRef, createMockPlainTextSuggestionPattern(), mockSetSuggestion); + processSelectionChange(mockEditorRef, mockSetSuggestion); expect(mockSetSuggestion).toHaveBeenCalledWith(null); }); @@ -166,14 +190,167 @@ describe("processSelectionChange", () => { document.getSelection()?.setBaseAndExtent(textNode, 3, textNode, 3); // process the change and check the suggestion that is set looks as we expect it to - processSelectionChange(mockEditorRef, null, mockSetSuggestion); + processSelectionChange(mockEditorRef, mockSetSuggestion); expect(mockSetSuggestion).toHaveBeenCalledWith({ - keyChar: "/", - type: "command", - text: "potentialCommand", + mappedSuggestion: { + keyChar: "/", + type: "command", + text: "potentialCommand", + }, node: textNode, startOffset: 0, endOffset: commandText.length, }); }); + + it("does not treat a command outside the first text node to be a suggestion", () => { + const [mockEditor] = appendEditorWithTextNodeContaining("some text in first node"); + const [, commandTextNode] = appendEditorWithTextNodeContaining("/potentialCommand"); + + const mockEditorRef = createMockEditorRef(mockEditor); + + // create a selection in the text node that has identical start and end locations, ie it is a cursor + document.getSelection()?.setBaseAndExtent(commandTextNode, 3, commandTextNode, 3); + + // process the change and check the suggestion that is set looks as we expect it to + processSelectionChange(mockEditorRef, mockSetSuggestion); + expect(mockSetSuggestion).toHaveBeenCalledWith(null); + }); +}); + +describe("findSuggestionInText", () => { + const command = "/someCommand"; + const userMention = "@userMention"; + const roomMention = "#roomMention"; + + const mentionTestCases = [userMention, roomMention]; + const allTestCases = [command, userMention, roomMention]; + + it("returns null if content does not contain any mention or command characters", () => { + expect(findSuggestionInText("hello", 1, true)).toBeNull(); + }); + + it("returns null if content contains a command but is not the first text node", () => { + expect(findSuggestionInText(command, 1, false)).toBeNull(); + }); + + it("returns null if the offset is outside the content length", () => { + expect(findSuggestionInText("hi", 30, true)).toBeNull(); + expect(findSuggestionInText("hi", -10, true)).toBeNull(); + }); + + it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => { + const expected = { + mappedSuggestion: getMappedSuggestion(text), + startOffset: 0, + endOffset: text.length, + }; + // test for cursor immediately before and after special character, before end, at end + expect(findSuggestionInText(text, 0, true)).toEqual(expected); + expect(findSuggestionInText(text, 1, true)).toEqual(expected); + expect(findSuggestionInText(text, text.length - 2, true)).toEqual(expected); + expect(findSuggestionInText(text, text.length, true)).toEqual(expected); + }); + + it("returns null when a command is followed by other text", () => { + const followingText = " followed by something"; + + // check for cursor inside and outside the command + expect(findSuggestionInText(command + followingText, command.length - 2, true)).toBeNull(); + expect(findSuggestionInText(command + followingText, command.length + 2, true)).toBeNull(); + }); + + it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => { + const followingText = " followed by something else"; + expect(findSuggestionInText(mention + followingText, mention.length - 2, true)).toEqual({ + mappedSuggestion: getMappedSuggestion(mention), + startOffset: 0, + endOffset: mention.length, + }); + }); + + it("returns null if there is a command surrounded by text", () => { + const precedingText = "text before the command "; + const followingText = " text after the command"; + expect( + findSuggestionInText(precedingText + command + followingText, precedingText.length + 4, true), + ).toBeNull(); + }); + + it.each(mentionTestCases)("returns an object if %s is surrounded by text", (mention) => { + const precedingText = "I want to mention "; + const followingText = " in my message"; + + const textInput = precedingText + mention + followingText; + const expected = { + mappedSuggestion: getMappedSuggestion(mention), + startOffset: precedingText.length, + endOffset: precedingText.length + mention.length, + }; + + // when the cursor is immediately before the special character + expect(findSuggestionInText(textInput, precedingText.length, true)).toEqual(expected); + // when the cursor is inside the mention + expect(findSuggestionInText(textInput, precedingText.length + 3, true)).toEqual(expected); + // when the cursor is right at the end of the mention + expect(findSuggestionInText(textInput, precedingText.length + mention.length, true)).toEqual(expected); + }); + + it("returns null for text content with an email address", () => { + const emailInput = "send to user@test.com"; + expect(findSuggestionInText(emailInput, 15, true)).toBeNull(); + }); + + it("returns null for double slashed command", () => { + const doubleSlashCommand = "//not a command"; + expect(findSuggestionInText(doubleSlashCommand, 4, true)).toBeNull(); + }); + + it("returns null for slash separated text", () => { + const slashSeparatedInput = "please to this/that/the other"; + expect(findSuggestionInText(slashSeparatedInput, 21, true)).toBeNull(); + }); + + it("returns an object for a mention that contains punctuation", () => { + const mentionWithPunctuation = "@userX14#5a_-"; + const precedingText = "mention "; + const mentionInput = precedingText + mentionWithPunctuation; + expect(findSuggestionInText(mentionInput, 12, true)).toEqual({ + mappedSuggestion: getMappedSuggestion(mentionWithPunctuation), + startOffset: precedingText.length, + endOffset: precedingText.length + mentionWithPunctuation.length, + }); + }); + + it("returns null when user inputs any whitespace after the special character", () => { + const mentionWithSpaceAfter = "@ somebody"; + expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull(); + }); +}); + +describe("getMappedSuggestion", () => { + it("returns null when the first character is not / # @", () => { + expect(getMappedSuggestion("Zzz")).toBe(null); + }); + + it("returns the expected mapped suggestion when first character is # or @", () => { + expect(getMappedSuggestion("@user-mention")).toEqual({ + type: "mention", + keyChar: "@", + text: "user-mention", + }); + expect(getMappedSuggestion("#room-mention")).toEqual({ + type: "mention", + keyChar: "#", + text: "room-mention", + }); + }); + + it("returns the expected mapped suggestion when first character is /", () => { + expect(getMappedSuggestion("/command")).toEqual({ + type: "command", + keyChar: "/", + text: "command", + }); + }); });