From 2cfa7785957819fb69d19e047b31a7eb4fb0260c Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 27 Apr 2023 11:27:37 +0100 Subject: [PATCH 01/34] update useSuggestion --- .../wysiwyg_composer/hooks/useSuggestion.ts | 213 ++++++++++++++---- 1 file changed, 163 insertions(+), 50 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index e1db110847f..446daccc487 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg"; -import { SyntheticEvent, useState } from "react"; +import { SyntheticEvent, useMemo, useState } from "react"; /** * Information about the current state of the `useSuggestion` hook. @@ -47,7 +47,6 @@ type SuggestionState = Suggestion | null; * - `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, @@ -59,18 +58,24 @@ export function useSuggestion( } { const [suggestion, setSuggestion] = 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, setSuggestion); + + const handleMention = (href: string, displayName: string, attributes: Attributes): void => + processMention(href, displayName, attributes, suggestion, setSuggestion, setText); const handleCommand = (replacementText: string): void => processCommand(replacementText, suggestion, setSuggestion, setText); + const memoizedMappedSuggestion: MappedSuggestion | null = useMemo(() => { + return suggestion !== null + ? { keyChar: suggestion.keyChar, type: suggestion.type, text: suggestion.text } + : null; + }, [suggestion]); + return { - suggestion: mapSuggestion(suggestion), + suggestion: memoizedMappedSuggestion, handleCommand, handleMention, onSelect, @@ -78,19 +83,45 @@ export function useSuggestion( } /** - * Convert a PlainTextSuggestionPattern (or null) to a MappedSuggestion (or null) + * Replaces the relevant part of the editor text with a link representing a mention after it + * is selected from the autocomplete. * - * @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 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 suggestion - representation of the part of the DOM that will be replaced + * @param setSuggestion - setter function to set the suggestion state + * @param setText - setter function to set the content of the composer */ -export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | null => { +export function processMention( + href: string, + displayName: string, + attributes: Attributes, // these will be used when formatting the link as a pill + suggestion: SuggestionState, + setSuggestion: React.Dispatch>, + setText: (text: string) => void, +): void { + // if we do not have any of the values we need to do the work, do nothing if (suggestion === null) { - return null; - } else { - const { node, startOffset, endOffset, ...mappedSuggestion } = suggestion; - return mappedSuggestion; + return; } -}; + + const { node } = suggestion; + + const textBeforeReplacement = node.textContent?.slice(0, suggestion.startOffset) ?? ""; + const textAfterReplacement = node.textContent?.slice(suggestion.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); + setSuggestion(null); +} /** * Replaces the relevant part of the editor text with the replacement text after a command is selected @@ -101,12 +132,12 @@ export const mapSuggestion = (suggestion: SuggestionState): MappedSuggestion | n * @param setSuggestion - 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>, setText: (text: string) => void, -): void => { +): void { // if we do not have a suggestion, return early if (suggestion === null) { return; @@ -124,23 +155,83 @@ export const processCommand = ( document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length); setText(newContent); setSuggestion(null); -}; +} + +/** + * Given some text content from a node and the cursor position, search through the content + * to find a mention or a command. If there is one present, return a slice of the content + * from the special character to the end of the input + * + * @param text - the text content of a node + * @param offset - the current cursor offset position + * @returns an empty string if no mention or command is found, otherwise the mention/command substring + */ +export function findMentionOrCommand(text: string, offset: number): { text: string; startOffset: number } | null { + // return early if the offset is outside the content + if (offset < 0 || offset > text.length) { + return null; + } + + let startCharIndex = offset - 1; // due to us searching left from here + let endCharIndex = offset; // due to us searching right from here + + const keepMovingLeft = (): boolean => { + const specialStartChars = /[@#/]/; + const currentChar = text[startCharIndex]; + const precedingChar = text[startCharIndex - 1] ?? ""; + + const currentCharIsSpecial = specialStartChars.test(currentChar); + + const shouldIgnoreMentionChar = (currentChar === "@" || currentChar === "#") && /\S/.test(precedingChar); + + return startCharIndex >= 0 && (!currentCharIsSpecial || shouldIgnoreMentionChar); + }; + + const keepMovingRight = (): boolean => { + const specialEndChars = /\s/; + return !specialEndChars.test(text[endCharIndex]) && endCharIndex < text.length; + }; + + while (keepMovingLeft()) { + // special case - if we hit some whitespace, just return null, this is to catch cases + // where user types a special character then whitespace + if (/\s/.test(text[startCharIndex])) { + return null; + } + startCharIndex--; + } + + while (keepMovingRight()) { + endCharIndex++; + } + + // we have looped through the first part of the string => if we get a minus one + // as the index, we haven't found a start point, so return + // alternatively if we have found command (/) and the startCharIndex isn't 0, return "" + // or also if we hav found a command and the end index isn't the end of the content + const startCharIsCommand = text[startCharIndex] === "/"; + if ( + startCharIndex < 0 || + (startCharIsCommand && startCharIndex !== 0) || + (startCharIsCommand && endCharIndex !== text.length) + ) { + return null; + } else { + return { text: text.slice(startCharIndex, endCharIndex), startOffset: startCharIndex }; + } +} /** * 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 + * something that could be a command or a mention and update the suggestion state if so * * @param editorRef - ref to the composer - * @param suggestion - the current suggestion state * @param setSuggestion - the setter for the suggestion state */ -export const processSelectionChange = ( +export function processSelectionChange( editorRef: React.RefObject, - suggestion: SuggestionState, setSuggestion: React.Dispatch>, -): void => { +): void { const selection = document.getSelection(); // return early if we do not have a current editor ref with a cursor selection inside a text node @@ -150,43 +241,65 @@ export const processSelectionChange = ( !selection.isCollapsed || selection.anchorNode?.nodeName !== "#text" ) { + setSuggestion(null); return; } - // 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; + // here we have established that we have a cursor and both anchor and focus nodes in the selection + // are the same node, so rename to `currentNode` and `currentOffset` for subsequent use + const { anchorNode: currentNode, anchorOffset: currentOffset } = selection; // 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(); - // if we're not in the first text node or we have no text content, return - if (currentNode !== firstTextNode || currentNode.textContent === null) { + // if we have no text content, return + if (currentNode.textContent === null) return; + + const mentionOrCommand = findMentionOrCommand(currentNode.textContent, currentOffset); + + // if we don't have any mentionsOrCommands, return, clearing the suggestion state + if (mentionOrCommand === null) { + setSuggestion(null); return; } - // 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); - } + // else we do have something, so get the constituent parts + const suggestionParts = getMentionOrCommandParts(mentionOrCommand.text); + + // if we have a command at the beginning of a node, but that node isn't the first text node, return + if (suggestionParts.type === "command" && currentNode !== firstTextNode) { + setSuggestion(null); return; } else { + // else, we have found a mention or a command setSuggestion({ - keyChar: "/", - type: "command", - text: commandMatches[1], + ...suggestionParts, node: selection.anchorNode, - startOffset: 0, - endOffset: currentNode.textContent.length, + startOffset: mentionOrCommand.startOffset, + endOffset: mentionOrCommand.startOffset + mentionOrCommand.text.length, }); } -}; +} + +/** + * Given a string that represents a suggestion in the composer, return an object that represents + * that text as a `MappedSuggestion`. + * + * @param suggestionText - string that could be a mention of a command type suggestion + * @returns an object representing the `MappedSuggestion` from that string + */ +export function getMentionOrCommandParts(suggestionText: string): MappedSuggestion { + const firstChar = suggestionText.charAt(0); + const restOfString = suggestionText.slice(1); + + switch (firstChar) { + case "/": + return { keyChar: firstChar, text: restOfString, type: "command" }; + case "#": + case "@": + return { keyChar: firstChar, text: restOfString, type: "mention" }; + default: + return { keyChar: "", text: "", type: "unknown" }; + } +} From b5c1f596c4d93d5c9f22186f072dc984efb6c30e Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 27 Apr 2023 11:28:11 +0100 Subject: [PATCH 02/34] update useSuggestion-tests --- .../hooks/useSuggestion-test.tsx | 124 ++++++++++++++---- 1 file changed, 96 insertions(+), 28 deletions(-) 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..7aa1c583669 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -17,7 +17,7 @@ import React from "react"; import { Suggestion, - mapSuggestion, + findMentionOrCommand, processCommand, processSelectionChange, } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion"; @@ -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 @@ -112,14 +94,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 +110,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 +122,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 +135,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,7 +148,7 @@ 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", @@ -177,3 +159,89 @@ describe("processSelectionChange", () => { }); }); }); + +describe("findMentionOrCommand", () => { + 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(findMentionOrCommand("hello", 1)).toBeNull(); + }); + + it("returns null if the offset is outside the content length", () => { + expect(findMentionOrCommand("hi", 30)).toBeNull(); + expect(findMentionOrCommand("hi", -10)).toBeNull(); + }); + + it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => { + // test for cursor at after special character, before end, end + expect(findMentionOrCommand(text, 1)).toEqual({ text, startOffset: 0 }); + expect(findMentionOrCommand(text, text.length - 2)).toEqual({ text, startOffset: 0 }); + expect(findMentionOrCommand(text, text.length)).toEqual({ text, startOffset: 0 }); + }); + + 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(findMentionOrCommand(command + followingText, command.length - 2)).toBeNull(); + expect(findMentionOrCommand(command + followingText, command.length + 2)).toBeNull(); + }); + + it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => { + const followingText = " followed by something else"; + expect(findMentionOrCommand(mention + followingText, mention.length - 2)).toEqual({ + text: mention, + startOffset: 0, + }); + }); + + it("returns null if there is a command surrounded by text", () => { + const precedingText = "text before the command "; + const followingText = " text after the command"; + expect(findMentionOrCommand(precedingText + command + followingText, precedingText.length + 4)).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"; + expect(findMentionOrCommand(precedingText + mention + followingText, precedingText.length + 3)).toEqual({ + text: mention, + startOffset: precedingText.length, + }); + }); + + it("returns null for text content with an email address", () => { + const emailInput = "send to user@test.com"; + expect(findMentionOrCommand(emailInput, 15)).toBeNull(); + }); + + it("returns null for double slashed command", () => { + const doubleSlashCommand = "//not a command"; + expect(findMentionOrCommand(doubleSlashCommand, 4)).toBeNull(); + }); + + it("returns null for slash separated text", () => { + const slashSeparatedInput = "please to this/that/the other"; + expect(findMentionOrCommand(slashSeparatedInput, 21)).toBeNull(); + }); + + it("returns an object for a mention that contains punctuation", () => { + const mentionWithPunctuation = "@userX14#5a_-"; + const precedingText = "mention "; + const mentionInput = precedingText + mentionWithPunctuation; + expect(findMentionOrCommand(mentionInput, 12)).toEqual({ + text: mentionWithPunctuation, + startOffset: precedingText.length, + }); + }); + + it("returns null when user inputs any whitespace after the special character", () => { + const mentionWithSpaceAfter = "@ somebody"; + expect(findMentionOrCommand(mentionWithSpaceAfter, 2)).toBeNull(); + }); +}); From 7e846c0e164af3c98c713d0e7362938305608a0b Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 27 Apr 2023 14:18:58 +0100 Subject: [PATCH 03/34] add processMention tests --- .../hooks/useSuggestion-test.tsx | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) 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 7aa1c583669..803c8b8afac 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -19,6 +19,7 @@ import { Suggestion, findMentionOrCommand, processCommand, + processMention, processSelectionChange, } from "../../../../../../src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion"; @@ -67,6 +68,48 @@ describe("processCommand", () => { }); }); +describe.only("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 = "www.test.com"; + 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; From e4c65d2ada2825df1b6db8fde08d8f828b9e4aea Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 27 Apr 2023 14:44:15 +0100 Subject: [PATCH 04/34] add test --- .../hooks/useSuggestion-test.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) 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 803c8b8afac..1b54997ab71 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -68,7 +68,7 @@ describe("processCommand", () => { }); }); -describe.only("processMention", () => { +describe("processMention", () => { // TODO refactor and expand tests when mentions become tags it("returns early when suggestion is null", () => { const mockSetSuggestion = jest.fn(); @@ -201,6 +201,20 @@ describe("processSelectionChange", () => { 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("findMentionOrCommand", () => { From 897f9f02b2435022ee321173dd48712663f45344 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 27 Apr 2023 14:51:05 +0100 Subject: [PATCH 05/34] add getMentionOrCommand tests --- .../hooks/useSuggestion-test.tsx | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) 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 1b54997ab71..7a56b99f456 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -18,6 +18,7 @@ import React from "react"; import { Suggestion, findMentionOrCommand, + getMentionOrCommandParts, processCommand, processMention, processSelectionChange, @@ -302,3 +303,30 @@ describe("findMentionOrCommand", () => { expect(findMentionOrCommand(mentionWithSpaceAfter, 2)).toBeNull(); }); }); + +describe("getMentionOrCommandParts", () => { + it("returns an empty mapped suggestion when first character is not / # @", () => { + expect(getMentionOrCommandParts("Zzz")).toEqual({ type: "unknown", keyChar: "", text: "" }); + }); + + it("returns the expected mapped suggestion when first character is # or @", () => { + expect(getMentionOrCommandParts("@user-mention")).toEqual({ + type: "mention", + keyChar: "@", + text: "user-mention", + }); + expect(getMentionOrCommandParts("#room-mention")).toEqual({ + type: "mention", + keyChar: "#", + text: "room-mention", + }); + }); + + it("returns the expected mapped suggestion when first character is /", () => { + expect(getMentionOrCommandParts("/command")).toEqual({ + type: "command", + keyChar: "/", + text: "command", + }); + }); +}); From e184a41c1ba627e0adf603ef96cea4fdb5300287 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 27 Apr 2023 14:57:45 +0100 Subject: [PATCH 06/34] change mock href for codeQL reasons --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 7a56b99f456..69e337a5093 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -87,7 +87,7 @@ describe("processMention", () => { document.getSelection()?.setBaseAndExtent(textNode, 0, textNode, 0); // call the util function - const href = "www.test.com"; + const href = "href"; const displayName = "displayName"; const mockSetSuggestion = jest.fn(); const mockSetText = jest.fn(); From 8c1b3e6adef857ced7ff4e169338e22118a290fc Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 27 Apr 2023 14:59:17 +0100 Subject: [PATCH 07/34] fix TS issue in test --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 69e337a5093..a7d72ad9a34 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -103,8 +103,8 @@ describe("processMention", () => { // 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(textContent!.includes(href)).toBe(true); + expect(textContent!.includes(displayName)).toBe(true); expect(mockSetText).toHaveBeenCalledWith(expect.stringContaining(displayName)); expect(mockSetSuggestion).toHaveBeenCalledWith(null); From c0d0517d8e21692550f3788fcfb4cc2e6dbed1e2 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 28 Apr 2023 11:54:14 +0100 Subject: [PATCH 08/34] add a big old cypress test --- cypress/e2e/composer/composer.spec.ts | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 85d1477116c..b9d8975a38f 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -18,6 +18,8 @@ limitations under the License. import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { SettingLevel } from "../../../src/settings/SettingLevel"; +import { MatrixClient } from "../../global"; +import { EventType } from "matrix-js-sdk/src/@types/event"; describe("Composer", () => { let homeserver: HomeserverInstance; @@ -181,6 +183,82 @@ 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 dms 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.wait(250); // let the room list settle + 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"); From e6059773e4d01aebcaca95a2fd0af45aec5efa39 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 28 Apr 2023 12:07:18 +0100 Subject: [PATCH 09/34] fix lint error --- cypress/e2e/composer/composer.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index b9d8975a38f..1e0665fd217 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -15,11 +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"; -import { EventType } from "matrix-js-sdk/src/@types/event"; describe("Composer", () => { let homeserver: HomeserverInstance; From f3a7c6f0a5e8737eb1a9c5183c2051a2fea59598 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 28 Apr 2023 16:54:11 +0100 Subject: [PATCH 10/34] update comments --- .../wysiwyg_composer/hooks/useSuggestion.ts | 72 ++++++++++++------- 1 file changed, 46 insertions(+), 26 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 446daccc487..52e141c7fc6 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -172,28 +172,44 @@ export function findMentionOrCommand(text: string, offset: number): { text: stri return null; } - let startCharIndex = offset - 1; // due to us searching left from here - let endCharIndex = offset; // due to us searching right from here - - const keepMovingLeft = (): boolean => { - const specialStartChars = /[@#/]/; + // A cursor offset lies between two characters, so make a cursor offset correspond to + // an index in the text + let startCharIndex = offset - 1; + let endCharIndex = offset; + + function keepMovingLeft(): boolean { + // If the index is outside the string, return false + if (startCharIndex === -1) return false; + + // We are inside the string so can guarantee that there is a character at the index. + // The preceding character could be undefined if index === 1 + const mentionOrCommandChar = /[@#/]/; + const mentionChar = /[@#]/; const currentChar = text[startCharIndex]; const precedingChar = text[startCharIndex - 1] ?? ""; - const currentCharIsSpecial = specialStartChars.test(currentChar); + const currentCharIsMentionOrCommand = mentionOrCommandChar.test(currentChar); - const shouldIgnoreMentionChar = (currentChar === "@" || currentChar === "#") && /\S/.test(precedingChar); + // We want to ignore @ or # if they are preceded by anything that is not a whitespace + // to allow us to handle cases like email addesses + const shouldIgnoreCurrentMentionChar = mentionChar.test(currentChar) && /\S/.test(precedingChar); - return startCharIndex >= 0 && (!currentCharIsSpecial || shouldIgnoreMentionChar); - }; + // Keep moving left if the current character is not a relevant character, or if + // we have a relevant character preceded by something other than whitespace + return !currentCharIsMentionOrCommand || shouldIgnoreCurrentMentionChar; + } - const keepMovingRight = (): boolean => { - const specialEndChars = /\s/; - return !specialEndChars.test(text[endCharIndex]) && endCharIndex < text.length; - }; + function keepMovingRight(): boolean { + // If the index is outside the string, return false + if (endCharIndex === text.length) return false; + + // We are inside the string so can guarantee that there is a character at the index. + // Keep moving right if the current character is not a space + return /\S/.test(text[endCharIndex]); + } while (keepMovingLeft()) { - // special case - if we hit some whitespace, just return null, this is to catch cases + // Special case - if we hit some whitespace, return null. This is to catch cases // where user types a special character then whitespace if (/\s/.test(text[startCharIndex])) { return null; @@ -205,20 +221,24 @@ export function findMentionOrCommand(text: string, offset: number): { text: stri endCharIndex++; } - // we have looped through the first part of the string => if we get a minus one - // as the index, we haven't found a start point, so return - // alternatively if we have found command (/) and the startCharIndex isn't 0, return "" - // or also if we hav found a command and the end index isn't the end of the content - const startCharIsCommand = text[startCharIndex] === "/"; - if ( - startCharIndex < 0 || - (startCharIsCommand && startCharIndex !== 0) || - (startCharIsCommand && endCharIndex !== text.length) - ) { + // We have looped throught the text in both directions from the current cursor position + // whilst looking for special characters. + // We do not have a command or mention if: + // - the start or ending indices are outside the string + if (startCharIndex < 0 || endCharIndex > text.length) return null; + + const mentionOrCommandSlice = text.slice(startCharIndex, endCharIndex); + const mentionOrCommandParts = getMentionOrCommandParts(mentionOrCommandSlice); + + // We do not have a command if: + // - 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 (mentionOrCommandParts.type === "command" && (startCharIndex !== 0 || endCharIndex !== text.length)) { return null; - } else { - return { text: text.slice(startCharIndex, endCharIndex), startOffset: startCharIndex }; } + + return { text: mentionOrCommandSlice, startOffset: startCharIndex }; } /** From 2b281d857f3b14dc98cc8731e271e1c89aff9841 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 28 Apr 2023 17:02:01 +0100 Subject: [PATCH 11/34] reorganise functions in order of importance --- .../wysiwyg_composer/hooks/useSuggestion.ts | 190 ++++++++++-------- 1 file changed, 103 insertions(+), 87 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 52e141c7fc6..74bb8d6a1fb 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -82,6 +82,67 @@ export function useSuggestion( }; } +/** + * 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 editorRef - ref to the composer + * @param setSuggestion - the setter for the suggestion state + */ +export function processSelectionChange( + editorRef: React.RefObject, + setSuggestion: 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" + ) { + setSuggestion(null); + return; + } + + // here we have established that we have a cursor and both anchor and focus nodes in the selection + // are the same node, so rename to `currentNode` and `currentOffset` for subsequent use + const { anchorNode: currentNode, anchorOffset: currentOffset } = selection; + + // 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(); + + // if we have no text content, return + if (currentNode.textContent === null) return; + + const mentionOrCommand = findMentionOrCommand(currentNode.textContent, currentOffset); + + // if we don't have any mentionsOrCommands, return, clearing the suggestion state + if (mentionOrCommand === null) { + setSuggestion(null); + return; + } + + // else we do have something, so get the constituent parts + const suggestionParts = getMentionOrCommandParts(mentionOrCommand.text); + + // if we have a command at the beginning of a node, but that node isn't the first text node, return + if (suggestionParts.type === "command" && currentNode !== firstTextNode) { + setSuggestion(null); + return; + } else { + // else, we have found a mention or a command + setSuggestion({ + ...suggestionParts, + node: selection.anchorNode, + startOffset: mentionOrCommand.startOffset, + endOffset: mentionOrCommand.startOffset + mentionOrCommand.text.length, + }); + } +} + /** * Replaces the relevant part of the editor text with a link representing a mention after it * is selected from the autocomplete. @@ -177,38 +238,7 @@ export function findMentionOrCommand(text: string, offset: number): { text: stri let startCharIndex = offset - 1; let endCharIndex = offset; - function keepMovingLeft(): boolean { - // If the index is outside the string, return false - if (startCharIndex === -1) return false; - - // We are inside the string so can guarantee that there is a character at the index. - // The preceding character could be undefined if index === 1 - const mentionOrCommandChar = /[@#/]/; - const mentionChar = /[@#]/; - const currentChar = text[startCharIndex]; - const precedingChar = text[startCharIndex - 1] ?? ""; - - const currentCharIsMentionOrCommand = mentionOrCommandChar.test(currentChar); - - // We want to ignore @ or # if they are preceded by anything that is not a whitespace - // to allow us to handle cases like email addesses - const shouldIgnoreCurrentMentionChar = mentionChar.test(currentChar) && /\S/.test(precedingChar); - - // Keep moving left if the current character is not a relevant character, or if - // we have a relevant character preceded by something other than whitespace - return !currentCharIsMentionOrCommand || shouldIgnoreCurrentMentionChar; - } - - function keepMovingRight(): boolean { - // If the index is outside the string, return false - if (endCharIndex === text.length) return false; - - // We are inside the string so can guarantee that there is a character at the index. - // Keep moving right if the current character is not a space - return /\S/.test(text[endCharIndex]); - } - - while (keepMovingLeft()) { + while (keepMovingLeft(text, startCharIndex)) { // Special case - if we hit some whitespace, return null. This is to catch cases // where user types a special character then whitespace if (/\s/.test(text[startCharIndex])) { @@ -217,7 +247,7 @@ export function findMentionOrCommand(text: string, offset: number): { text: stri startCharIndex--; } - while (keepMovingRight()) { + while (keepMovingRight(text, endCharIndex)) { endCharIndex++; } @@ -242,64 +272,50 @@ export function findMentionOrCommand(text: string, offset: number): { text: stri } /** - * 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 + * Associated function for findMentionOrCommand. Checks the character at the current location + * to determine if search should continue. * - * @param editorRef - ref to the composer - * @param setSuggestion - the setter for the suggestion state + * @param text - text content to check for mentions or commands + * @param index - the current index to check + * @returns true if check should keep moving left, false otherwise */ -export function processSelectionChange( - editorRef: React.RefObject, - setSuggestion: 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" - ) { - setSuggestion(null); - return; - } - - // here we have established that we have a cursor and both anchor and focus nodes in the selection - // are the same node, so rename to `currentNode` and `currentOffset` for subsequent use - const { anchorNode: currentNode, anchorOffset: currentOffset } = selection; - - // 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(); - - // if we have no text content, return - if (currentNode.textContent === null) return; - - const mentionOrCommand = findMentionOrCommand(currentNode.textContent, currentOffset); - - // if we don't have any mentionsOrCommands, return, clearing the suggestion state - if (mentionOrCommand === null) { - setSuggestion(null); - return; - } +function keepMovingLeft(text: string, index: number): boolean { + // If the index is outside the string, return false + if (index === -1) return false; + + // We are inside the string so can guarantee that there is a character at the index. + // The preceding character could be undefined if index === 1 + const mentionOrCommandChar = /[@#/]/; + const mentionChar = /[@#]/; + const currentChar = text[index]; + const precedingChar = text[index - 1] ?? ""; + + const currentCharIsMentionOrCommand = mentionOrCommandChar.test(currentChar); + + // We want to ignore @ or # if they are preceded by anything that is not a whitespace + // to allow us to handle cases like email addesses + const shouldIgnoreCurrentMentionChar = mentionChar.test(currentChar) && /\S/.test(precedingChar); + + // Keep moving left if the current character is not a relevant character, or if + // we have a relevant character preceded by something other than whitespace + return !currentCharIsMentionOrCommand || shouldIgnoreCurrentMentionChar; +} - // else we do have something, so get the constituent parts - const suggestionParts = getMentionOrCommandParts(mentionOrCommand.text); +/** + * Associated function for findMentionOrCommand. Checks the character at the current location + * to determine if search 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 right, false otherwise + */ +function keepMovingRight(text: string, index: number): boolean { + // If the index is outside the string, return false + if (index === text.length) return false; - // if we have a command at the beginning of a node, but that node isn't the first text node, return - if (suggestionParts.type === "command" && currentNode !== firstTextNode) { - setSuggestion(null); - return; - } else { - // else, we have found a mention or a command - setSuggestion({ - ...suggestionParts, - node: selection.anchorNode, - startOffset: mentionOrCommand.startOffset, - endOffset: mentionOrCommand.startOffset + mentionOrCommand.text.length, - }); - } + // We are inside the string so can guarantee that there is a character at the index. + // Keep moving right if the current character is not a space + return /\S/.test(text[index]); } /** From c2ab234675407a15801380d058fb950c6ca151ab Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 28 Apr 2023 17:20:43 +0100 Subject: [PATCH 12/34] rename functions and variables --- .../wysiwyg_composer/hooks/useSuggestion.ts | 16 ++++++++-------- .../hooks/useSuggestion-test.tsx | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 74bb8d6a1fb..ffc085f60a1 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -126,16 +126,16 @@ export function processSelectionChange( } // else we do have something, so get the constituent parts - const suggestionParts = getMentionOrCommandParts(mentionOrCommand.text); + const mappedSuggestionParts = getMappedSuggestion(mentionOrCommand.text); // if we have a command at the beginning of a node, but that node isn't the first text node, return - if (suggestionParts.type === "command" && currentNode !== firstTextNode) { + if (mappedSuggestionParts.type === "command" && currentNode !== firstTextNode) { setSuggestion(null); return; } else { // else, we have found a mention or a command setSuggestion({ - ...suggestionParts, + ...mappedSuggestionParts, node: selection.anchorNode, startOffset: mentionOrCommand.startOffset, endOffset: mentionOrCommand.startOffset + mentionOrCommand.text.length, @@ -257,18 +257,18 @@ export function findMentionOrCommand(text: string, offset: number): { text: stri // - the start or ending indices are outside the string if (startCharIndex < 0 || endCharIndex > text.length) return null; - const mentionOrCommandSlice = text.slice(startCharIndex, endCharIndex); - const mentionOrCommandParts = getMentionOrCommandParts(mentionOrCommandSlice); + const suggestionText = text.slice(startCharIndex, endCharIndex); + const mappedSuggestion = getMappedSuggestion(suggestionText); // We do not have a command if: // - 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 (mentionOrCommandParts.type === "command" && (startCharIndex !== 0 || endCharIndex !== text.length)) { + if (mappedSuggestion.type === "command" && (startCharIndex !== 0 || endCharIndex !== text.length)) { return null; } - return { text: mentionOrCommandSlice, startOffset: startCharIndex }; + return { text: suggestionText, startOffset: startCharIndex }; } /** @@ -325,7 +325,7 @@ function keepMovingRight(text: string, index: number): boolean { * @param suggestionText - string that could be a mention of a command type suggestion * @returns an object representing the `MappedSuggestion` from that string */ -export function getMentionOrCommandParts(suggestionText: string): MappedSuggestion { +export function getMappedSuggestion(suggestionText: string): MappedSuggestion { const firstChar = suggestionText.charAt(0); const restOfString = suggestionText.slice(1); 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 a7d72ad9a34..f6268434400 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -18,7 +18,7 @@ import React from "react"; import { Suggestion, findMentionOrCommand, - getMentionOrCommandParts, + getMappedSuggestion, processCommand, processMention, processSelectionChange, @@ -306,16 +306,16 @@ describe("findMentionOrCommand", () => { describe("getMentionOrCommandParts", () => { it("returns an empty mapped suggestion when first character is not / # @", () => { - expect(getMentionOrCommandParts("Zzz")).toEqual({ type: "unknown", keyChar: "", text: "" }); + expect(getMappedSuggestion("Zzz")).toEqual({ type: "unknown", keyChar: "", text: "" }); }); it("returns the expected mapped suggestion when first character is # or @", () => { - expect(getMentionOrCommandParts("@user-mention")).toEqual({ + expect(getMappedSuggestion("@user-mention")).toEqual({ type: "mention", keyChar: "@", text: "user-mention", }); - expect(getMentionOrCommandParts("#room-mention")).toEqual({ + expect(getMappedSuggestion("#room-mention")).toEqual({ type: "mention", keyChar: "#", text: "room-mention", @@ -323,7 +323,7 @@ describe("getMentionOrCommandParts", () => { }); it("returns the expected mapped suggestion when first character is /", () => { - expect(getMentionOrCommandParts("/command")).toEqual({ + expect(getMappedSuggestion("/command")).toEqual({ type: "command", keyChar: "/", text: "command", From 4b8af0b75ae83b93a573f26c32afa11d7aab0da9 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 5 May 2023 11:07:41 +0100 Subject: [PATCH 13/34] add endOffset to return object --- .../rooms/wysiwyg_composer/hooks/useSuggestion.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index ffc085f60a1..da7150560d1 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -138,7 +138,7 @@ export function processSelectionChange( ...mappedSuggestionParts, node: selection.anchorNode, startOffset: mentionOrCommand.startOffset, - endOffset: mentionOrCommand.startOffset + mentionOrCommand.text.length, + endOffset: mentionOrCommand.endOffset, }); } } @@ -225,9 +225,12 @@ export function processCommand( * * @param text - the text content of a node * @param offset - the current cursor offset position - * @returns an empty string if no mention or command is found, otherwise the mention/command substring + * @returns an empty string if no mention or command is found, otherwise the mention/command substring with it's start offset */ -export function findMentionOrCommand(text: string, offset: number): { text: string; startOffset: number } | null { +export function findMentionOrCommand( + text: string, + offset: number, +): { text: string; startOffset: number; endOffset: number } | null { // return early if the offset is outside the content if (offset < 0 || offset > text.length) { return null; @@ -268,7 +271,7 @@ export function findMentionOrCommand(text: string, offset: number): { text: stri return null; } - return { text: suggestionText, startOffset: startCharIndex }; + return { text: suggestionText, startOffset: startCharIndex, endOffset: startCharIndex + suggestionText.length }; } /** From 89c2c78d7f9808d7acd1fc60554e3b460d623bf5 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 5 May 2023 11:10:58 +0100 Subject: [PATCH 14/34] fix failing tests --- .../wysiwyg_composer/hooks/useSuggestion-test.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) 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 f6268434400..2f4bfa72b52 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -236,10 +236,15 @@ describe("findMentionOrCommand", () => { }); it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => { + const expected = { + text, + startOffset: 0, + endOffset: text.length, + }; // test for cursor at after special character, before end, end - expect(findMentionOrCommand(text, 1)).toEqual({ text, startOffset: 0 }); - expect(findMentionOrCommand(text, text.length - 2)).toEqual({ text, startOffset: 0 }); - expect(findMentionOrCommand(text, text.length)).toEqual({ text, startOffset: 0 }); + expect(findMentionOrCommand(text, 1)).toEqual(expected); + expect(findMentionOrCommand(text, text.length - 2)).toEqual(expected); + expect(findMentionOrCommand(text, text.length)).toEqual(expected); }); it("returns null when a command is followed by other text", () => { @@ -255,6 +260,7 @@ describe("findMentionOrCommand", () => { expect(findMentionOrCommand(mention + followingText, mention.length - 2)).toEqual({ text: mention, startOffset: 0, + endOffset: mention.length, }); }); @@ -270,6 +276,7 @@ describe("findMentionOrCommand", () => { expect(findMentionOrCommand(precedingText + mention + followingText, precedingText.length + 3)).toEqual({ text: mention, startOffset: precedingText.length, + endOffset: precedingText.length + mention.length, }); }); @@ -295,6 +302,7 @@ describe("findMentionOrCommand", () => { expect(findMentionOrCommand(mentionInput, 12)).toEqual({ text: mentionWithPunctuation, startOffset: precedingText.length, + endOffset: precedingText.length + mentionWithPunctuation.length, }); }); From d1e5f5272009955a6679daf802b51706a6c5dd1e Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Fri, 5 May 2023 12:34:47 +0100 Subject: [PATCH 15/34] update function names and comments --- .../wysiwyg_composer/hooks/useSuggestion.ts | 49 +++++++++---------- .../hooks/useSuggestion-test.tsx | 36 +++++++------- 2 files changed, 41 insertions(+), 44 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index da7150560d1..2f36ca6f6cc 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -110,25 +110,22 @@ export function processSelectionChange( // are the same node, so rename to `currentNode` and `currentOffset` for subsequent use const { anchorNode: currentNode, anchorOffset: currentOffset } = selection; - // 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(); - // if we have no text content, return if (currentNode.textContent === null) return; - const mentionOrCommand = findMentionOrCommand(currentNode.textContent, currentOffset); + const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset); - // if we don't have any mentionsOrCommands, return, clearing the suggestion state - if (mentionOrCommand === null) { + // if we have not found a suggestion, return, clearing the suggestion state + if (foundSuggestion === null) { setSuggestion(null); return; } // else we do have something, so get the constituent parts - const mappedSuggestionParts = getMappedSuggestion(mentionOrCommand.text); + const mappedSuggestionParts = getMappedSuggestion(foundSuggestion.text); // if we have a command at the beginning of a node, but that node isn't the first text node, return + const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); if (mappedSuggestionParts.type === "command" && currentNode !== firstTextNode) { setSuggestion(null); return; @@ -136,9 +133,9 @@ export function processSelectionChange( // else, we have found a mention or a command setSuggestion({ ...mappedSuggestionParts, - node: selection.anchorNode, - startOffset: mentionOrCommand.startOffset, - endOffset: mentionOrCommand.endOffset, + node: currentNode, + startOffset: foundSuggestion.startOffset, + endOffset: foundSuggestion.endOffset, }); } } @@ -162,7 +159,7 @@ export function processMention( setSuggestion: React.Dispatch>, setText: (text: string) => void, ): void { - // if we do not have any of the values we need to do the work, do nothing + // if we do not have a suggestion, return early if (suggestion === null) { return; } @@ -227,21 +224,21 @@ export function processCommand( * @param offset - the current cursor offset position * @returns an empty string if no mention or command is found, otherwise the mention/command substring with it's start offset */ -export function findMentionOrCommand( +export function findSuggestionInText( text: string, offset: number, ): { text: string; startOffset: number; endOffset: number } | null { - // return early if the offset is outside the content + // Return null early if the offset is outside the content if (offset < 0 || offset > text.length) { return null; } // A cursor offset lies between two characters, so make a cursor offset correspond to - // an index in the text + // a character index in the text string let startCharIndex = offset - 1; let endCharIndex = offset; - while (keepMovingLeft(text, startCharIndex)) { + while (shouldDecrementStartIndex(text, startCharIndex)) { // Special case - if we hit some whitespace, return null. This is to catch cases // where user types a special character then whitespace if (/\s/.test(text[startCharIndex])) { @@ -250,7 +247,7 @@ export function findMentionOrCommand( startCharIndex--; } - while (keepMovingRight(text, endCharIndex)) { + while (shouldIncrementEndIndex(text, endCharIndex)) { endCharIndex++; } @@ -275,19 +272,19 @@ export function findMentionOrCommand( } /** - * Associated function for findMentionOrCommand. Checks the character at the current location - * to determine if search should continue. + * Associated function for findSuggestionInText. Checks the character at the current location + * to determine if the current index should be changed. * * @param text - text content to check for mentions or commands * @param index - the current index to check * @returns true if check should keep moving left, false otherwise */ -function keepMovingLeft(text: string, index: number): boolean { +function shouldDecrementStartIndex(text: string, index: number): boolean { // If the index is outside the string, return false if (index === -1) return false; - // We are inside the string so can guarantee that there is a character at the index. - // The preceding character could be undefined if index === 1 + // We are inside the string so can guarantee that there is a character at the index + // The preceding character could be undefined if index === 0 const mentionOrCommandChar = /[@#/]/; const mentionChar = /[@#]/; const currentChar = text[index]; @@ -305,18 +302,18 @@ function keepMovingLeft(text: string, index: number): boolean { } /** - * Associated function for findMentionOrCommand. Checks the character at the current location - * to determine if search should continue. + * Associated function for findSuggestionInText. Checks the character at the current location + * to determine if the current index should be changed. * * @param text - text content to check for mentions or commands * @param index - the current index to check * @returns true if check should keep moving right, false otherwise */ -function keepMovingRight(text: string, index: number): boolean { +function shouldIncrementEndIndex(text: string, index: number): boolean { // If the index is outside the string, return false if (index === text.length) return false; - // We are inside the string so can guarantee that there is a character at the index. + // We are inside the string so can guarantee that there is a character at the index // Keep moving right if the current character is not a space return /\S/.test(text[index]); } 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 2f4bfa72b52..a39774b0355 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -17,7 +17,7 @@ import React from "react"; import { Suggestion, - findMentionOrCommand, + findSuggestionInText, getMappedSuggestion, processCommand, processMention, @@ -218,7 +218,7 @@ describe("processSelectionChange", () => { }); }); -describe("findMentionOrCommand", () => { +describe("findSuggestionInText", () => { const command = "/someCommand"; const userMention = "@userMention"; const roomMention = "#roomMention"; @@ -227,12 +227,12 @@ describe("findMentionOrCommand", () => { const allTestCases = [command, userMention, roomMention]; it("returns null if content does not contain any mention or command characters", () => { - expect(findMentionOrCommand("hello", 1)).toBeNull(); + expect(findSuggestionInText("hello", 1)).toBeNull(); }); it("returns null if the offset is outside the content length", () => { - expect(findMentionOrCommand("hi", 30)).toBeNull(); - expect(findMentionOrCommand("hi", -10)).toBeNull(); + expect(findSuggestionInText("hi", 30)).toBeNull(); + expect(findSuggestionInText("hi", -10)).toBeNull(); }); it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => { @@ -242,22 +242,22 @@ describe("findMentionOrCommand", () => { endOffset: text.length, }; // test for cursor at after special character, before end, end - expect(findMentionOrCommand(text, 1)).toEqual(expected); - expect(findMentionOrCommand(text, text.length - 2)).toEqual(expected); - expect(findMentionOrCommand(text, text.length)).toEqual(expected); + expect(findSuggestionInText(text, 1)).toEqual(expected); + expect(findSuggestionInText(text, text.length - 2)).toEqual(expected); + expect(findSuggestionInText(text, text.length)).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(findMentionOrCommand(command + followingText, command.length - 2)).toBeNull(); - expect(findMentionOrCommand(command + followingText, command.length + 2)).toBeNull(); + expect(findSuggestionInText(command + followingText, command.length - 2)).toBeNull(); + expect(findSuggestionInText(command + followingText, command.length + 2)).toBeNull(); }); it.each(mentionTestCases)("returns an object when a %s is followed by other text", (mention) => { const followingText = " followed by something else"; - expect(findMentionOrCommand(mention + followingText, mention.length - 2)).toEqual({ + expect(findSuggestionInText(mention + followingText, mention.length - 2)).toEqual({ text: mention, startOffset: 0, endOffset: mention.length, @@ -267,13 +267,13 @@ describe("findMentionOrCommand", () => { it("returns null if there is a command surrounded by text", () => { const precedingText = "text before the command "; const followingText = " text after the command"; - expect(findMentionOrCommand(precedingText + command + followingText, precedingText.length + 4)).toBeNull(); + expect(findSuggestionInText(precedingText + command + followingText, precedingText.length + 4)).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"; - expect(findMentionOrCommand(precedingText + mention + followingText, precedingText.length + 3)).toEqual({ + expect(findSuggestionInText(precedingText + mention + followingText, precedingText.length + 3)).toEqual({ text: mention, startOffset: precedingText.length, endOffset: precedingText.length + mention.length, @@ -282,24 +282,24 @@ describe("findMentionOrCommand", () => { it("returns null for text content with an email address", () => { const emailInput = "send to user@test.com"; - expect(findMentionOrCommand(emailInput, 15)).toBeNull(); + expect(findSuggestionInText(emailInput, 15)).toBeNull(); }); it("returns null for double slashed command", () => { const doubleSlashCommand = "//not a command"; - expect(findMentionOrCommand(doubleSlashCommand, 4)).toBeNull(); + expect(findSuggestionInText(doubleSlashCommand, 4)).toBeNull(); }); it("returns null for slash separated text", () => { const slashSeparatedInput = "please to this/that/the other"; - expect(findMentionOrCommand(slashSeparatedInput, 21)).toBeNull(); + expect(findSuggestionInText(slashSeparatedInput, 21)).toBeNull(); }); it("returns an object for a mention that contains punctuation", () => { const mentionWithPunctuation = "@userX14#5a_-"; const precedingText = "mention "; const mentionInput = precedingText + mentionWithPunctuation; - expect(findMentionOrCommand(mentionInput, 12)).toEqual({ + expect(findSuggestionInText(mentionInput, 12)).toEqual({ text: mentionWithPunctuation, startOffset: precedingText.length, endOffset: precedingText.length + mentionWithPunctuation.length, @@ -308,7 +308,7 @@ describe("findMentionOrCommand", () => { it("returns null when user inputs any whitespace after the special character", () => { const mentionWithSpaceAfter = "@ somebody"; - expect(findMentionOrCommand(mentionWithSpaceAfter, 2)).toBeNull(); + expect(findSuggestionInText(mentionWithSpaceAfter, 2)).toBeNull(); }); }); From 79b5560adccf9572dea53f4c72a9f9bd52d84812 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 09:03:49 +0100 Subject: [PATCH 16/34] update comment, remove delay --- cypress/e2e/composer/composer.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 1e0665fd217..1013885b3d7 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -196,7 +196,7 @@ describe("Composer", () => { }).then((bob) => { bobClient = bob; }); - // create dms with 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()); @@ -205,7 +205,6 @@ describe("Composer", () => { }); }); - cy.wait(250); // let the room list settle cy.viewRoomByName("Bob"); // Select plain text mode after composer is ready From 276140b3330c9cbd05718bfc5d5c97484afa1ada Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 09:22:06 +0100 Subject: [PATCH 17/34] update comments and early return --- .../wysiwyg_composer/hooks/useSuggestion.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 2f36ca6f6cc..0790e401608 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -39,8 +39,8 @@ 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 @@ -51,7 +51,7 @@ 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; @@ -106,12 +106,14 @@ export function processSelectionChange( return; } - // here we have established that we have a cursor and both anchor and focus nodes in the selection - // are the same node, so rename to `currentNode` and `currentOffset` for subsequent use + // from here onwards we have a cursor inside a text node const { anchorNode: currentNode, anchorOffset: currentOffset } = selection; - // if we have no text content, return - if (currentNode.textContent === null) return; + // if we have no text content, return, clearing the suggestion state + if (currentNode.textContent === null) { + setSuggestion(null); + return; + } const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset); From b1b4e01a9fe64c06a9e6d6478d404658887a9f7e Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 09:35:49 +0100 Subject: [PATCH 18/34] nest mappedSuggestion inside Suggestion state and update test --- .../wysiwyg_composer/hooks/useSuggestion.ts | 19 +++++++------------ .../hooks/useSuggestion-test.tsx | 8 +++++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 0790e401608..8370fd14461 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -15,18 +15,19 @@ limitations under the License. */ import { Attributes, MappedSuggestion } from "@matrix-org/matrix-wysiwyg"; -import { SyntheticEvent, useMemo, useState } from "react"; +import { SyntheticEvent, useState } from "react"; /** * Information about the current state of the `useSuggestion` hook. */ -export type Suggestion = MappedSuggestion & { +export type Suggestion = { /** * 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. */ + mappedSuggestion: MappedSuggestion; node: Node; startOffset: number; endOffset: number; @@ -68,14 +69,8 @@ export function useSuggestion( const handleCommand = (replacementText: string): void => processCommand(replacementText, suggestion, setSuggestion, setText); - const memoizedMappedSuggestion: MappedSuggestion | null = useMemo(() => { - return suggestion !== null - ? { keyChar: suggestion.keyChar, type: suggestion.type, text: suggestion.text } - : null; - }, [suggestion]); - return { - suggestion: memoizedMappedSuggestion, + suggestion: suggestion?.mappedSuggestion ?? null, handleCommand, handleMention, onSelect, @@ -124,17 +119,17 @@ export function processSelectionChange( } // else we do have something, so get the constituent parts - const mappedSuggestionParts = getMappedSuggestion(foundSuggestion.text); + const mappedSuggestion = getMappedSuggestion(foundSuggestion.text); // if we have a command at the beginning of a node, but that node isn't the first text node, return const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); - if (mappedSuggestionParts.type === "command" && currentNode !== firstTextNode) { + if (mappedSuggestion.type === "command" && currentNode !== firstTextNode) { setSuggestion(null); return; } else { // else, we have found a mention or a command setSuggestion({ - ...mappedSuggestionParts, + mappedSuggestion, node: currentNode, startOffset: foundSuggestion.startOffset, endOffset: foundSuggestion.endOffset, 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 a39774b0355..4342723bd92 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -194,9 +194,11 @@ describe("processSelectionChange", () => { // process the change and check the suggestion that is set looks as we expect it to processSelectionChange(mockEditorRef, mockSetSuggestion); expect(mockSetSuggestion).toHaveBeenCalledWith({ - keyChar: "/", - type: "command", - text: "potentialCommand", + mappedSuggestion: { + keyChar: "/", + type: "command", + text: "potentialCommand", + }, node: textNode, startOffset: 0, endOffset: commandText.length, From 92e7836e383935214ed00110f0e3c0a267ba8a39 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 09:43:21 +0100 Subject: [PATCH 19/34] rename suggestion => suggestionData --- .../wysiwyg_composer/hooks/useSuggestion.ts | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 8370fd14461..39fa9c6d10c 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -57,20 +57,20 @@ export function useSuggestion( onSelect: (event: SyntheticEvent) => void; suggestion: MappedSuggestion | null; } { - const [suggestion, setSuggestion] = useState(null); + const [suggestionData, setSuggestionData] = useState(null); // 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, setSuggestion); + const onSelect = (): void => processSelectionChange(editorRef, setSuggestionData); const handleMention = (href: string, displayName: string, attributes: Attributes): void => - processMention(href, displayName, attributes, suggestion, setSuggestion, setText); + processMention(href, displayName, attributes, suggestionData, setSuggestionData, setText); const handleCommand = (replacementText: string): void => - processCommand(replacementText, suggestion, setSuggestion, setText); + processCommand(replacementText, suggestionData, setSuggestionData, setText); return { - suggestion: suggestion?.mappedSuggestion ?? null, + suggestion: suggestionData?.mappedSuggestion ?? null, handleCommand, handleMention, onSelect, @@ -82,11 +82,11 @@ export function useSuggestion( * something that could be a command or a mention and update the suggestion state if so * * @param editorRef - ref to the composer - * @param setSuggestion - the setter for the suggestion state + * @param setSuggestionData - the setter for the suggestion state */ export function processSelectionChange( editorRef: React.RefObject, - setSuggestion: React.Dispatch>, + setSuggestionData: React.Dispatch>, ): void { const selection = document.getSelection(); @@ -97,7 +97,7 @@ export function processSelectionChange( !selection.isCollapsed || selection.anchorNode?.nodeName !== "#text" ) { - setSuggestion(null); + setSuggestionData(null); return; } @@ -106,7 +106,7 @@ export function processSelectionChange( // if we have no text content, return, clearing the suggestion state if (currentNode.textContent === null) { - setSuggestion(null); + setSuggestionData(null); return; } @@ -114,7 +114,7 @@ export function processSelectionChange( // if we have not found a suggestion, return, clearing the suggestion state if (foundSuggestion === null) { - setSuggestion(null); + setSuggestionData(null); return; } @@ -124,11 +124,11 @@ export function processSelectionChange( // if we have a command at the beginning of a node, but that node isn't the first text node, return const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); if (mappedSuggestion.type === "command" && currentNode !== firstTextNode) { - setSuggestion(null); + setSuggestionData(null); return; } else { // else, we have found a mention or a command - setSuggestion({ + setSuggestionData({ mappedSuggestion, node: currentNode, startOffset: foundSuggestion.startOffset, @@ -144,27 +144,27 @@ export function processSelectionChange( * @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 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 function processMention( href: string, displayName: string, attributes: Attributes, // these will be used when formatting the link as a pill - suggestion: SuggestionState, - setSuggestion: React.Dispatch>, + suggestionData: SuggestionState, + setSuggestionData: React.Dispatch>, setText: (text: string) => void, ): void { // if we do not have a suggestion, return early - if (suggestion === null) { + if (suggestionData === null) { return; } - const { node } = suggestion; + const { node } = suggestionData; - const textBeforeReplacement = node.textContent?.slice(0, suggestion.startOffset) ?? ""; - const textAfterReplacement = node.textContent?.slice(suggestion.endOffset) ?? ""; + 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}>) `; @@ -175,7 +175,7 @@ export function processMention( node.textContent = newContent; document.getSelection()?.setBaseAndExtent(node, newCursorOffset, node, newCursorOffset); setText(newContent); - setSuggestion(null); + setSuggestionData(null); } /** @@ -183,22 +183,22 @@ export function processMention( * 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 function processCommand( replacementText: string, - suggestion: SuggestionState, - setSuggestion: React.Dispatch>, + suggestionData: SuggestionState, + setSuggestionData: React.Dispatch>, setText: (text: string) => 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 @@ -209,7 +209,7 @@ export function processCommand( // hook and clear the suggestion from state document.getSelection()?.setBaseAndExtent(node, newContent.length, node, newContent.length); setText(newContent); - setSuggestion(null); + setSuggestionData(null); } /** From 41d095abcc9083f5f033a6edb090f3bfd139d8e2 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 09:48:42 +0100 Subject: [PATCH 20/34] update comment --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 39fa9c6d10c..f52e0392380 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -118,7 +118,7 @@ export function processSelectionChange( return; } - // else we do have something, so get the constituent parts + // else we do have something, so get the mapped suggestion from the text const mappedSuggestion = getMappedSuggestion(foundSuggestion.text); // if we have a command at the beginning of a node, but that node isn't the first text node, return From 24b6b7d4e161a39ebbfbbae293b609f10d0c01b8 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 12:14:31 +0100 Subject: [PATCH 21/34] add argument to findSuggestionInText --- .../wysiwyg_composer/hooks/useSuggestion.ts | 35 ++++++++--------- .../hooks/useSuggestion-test.tsx | 38 +++++++++++-------- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index f52e0392380..d474ce51609 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -109,8 +109,9 @@ export function processSelectionChange( setSuggestionData(null); return; } - - const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset); + 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) { @@ -118,23 +119,13 @@ export function processSelectionChange( return; } - // else we do have something, so get the mapped suggestion from the text const mappedSuggestion = getMappedSuggestion(foundSuggestion.text); - - // if we have a command at the beginning of a node, but that node isn't the first text node, return - const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); - if (mappedSuggestion.type === "command" && currentNode !== firstTextNode) { - setSuggestionData(null); - return; - } else { - // else, we have found a mention or a command - setSuggestionData({ - mappedSuggestion, - node: currentNode, - startOffset: foundSuggestion.startOffset, - endOffset: foundSuggestion.endOffset, - }); - } + setSuggestionData({ + mappedSuggestion, + node: currentNode, + startOffset: foundSuggestion.startOffset, + endOffset: foundSuggestion.endOffset, + }); } /** @@ -219,11 +210,14 @@ export function processCommand( * * @param text - the text content of a node * @param offset - the current cursor offset position + * @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 an empty string if no mention or command is found, otherwise the mention/command substring with it's start offset */ export function findSuggestionInText( text: string, offset: number, + isFirstTextNode: boolean, ): { text: string; startOffset: number; endOffset: number } | null { // Return null early if the offset is outside the content if (offset < 0 || offset > text.length) { @@ -261,7 +255,10 @@ export function findSuggestionInText( // - 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 (mappedSuggestion.type === "command" && (startCharIndex !== 0 || endCharIndex !== text.length)) { + if ( + mappedSuggestion.type === "command" && + (!isFirstTextNode || startCharIndex !== 0 || endCharIndex !== text.length) + ) { 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 4342723bd92..c2884b3233e 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -229,12 +229,16 @@ describe("findSuggestionInText", () => { const allTestCases = [command, userMention, roomMention]; it("returns null if content does not contain any mention or command characters", () => { - expect(findSuggestionInText("hello", 1)).toBeNull(); + 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)).toBeNull(); - expect(findSuggestionInText("hi", -10)).toBeNull(); + 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) => { @@ -244,22 +248,22 @@ describe("findSuggestionInText", () => { endOffset: text.length, }; // test for cursor at after special character, before end, end - expect(findSuggestionInText(text, 1)).toEqual(expected); - expect(findSuggestionInText(text, text.length - 2)).toEqual(expected); - expect(findSuggestionInText(text, text.length)).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)).toBeNull(); - expect(findSuggestionInText(command + followingText, command.length + 2)).toBeNull(); + 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)).toEqual({ + expect(findSuggestionInText(mention + followingText, mention.length - 2, true)).toEqual({ text: mention, startOffset: 0, endOffset: mention.length, @@ -269,13 +273,15 @@ describe("findSuggestionInText", () => { 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)).toBeNull(); + 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"; - expect(findSuggestionInText(precedingText + mention + followingText, precedingText.length + 3)).toEqual({ + expect(findSuggestionInText(precedingText + mention + followingText, precedingText.length + 3, true)).toEqual({ text: mention, startOffset: precedingText.length, endOffset: precedingText.length + mention.length, @@ -284,24 +290,24 @@ describe("findSuggestionInText", () => { it("returns null for text content with an email address", () => { const emailInput = "send to user@test.com"; - expect(findSuggestionInText(emailInput, 15)).toBeNull(); + expect(findSuggestionInText(emailInput, 15, true)).toBeNull(); }); it("returns null for double slashed command", () => { const doubleSlashCommand = "//not a command"; - expect(findSuggestionInText(doubleSlashCommand, 4)).toBeNull(); + 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)).toBeNull(); + 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)).toEqual({ + expect(findSuggestionInText(mentionInput, 12, true)).toEqual({ text: mentionWithPunctuation, startOffset: precedingText.length, endOffset: precedingText.length + mentionWithPunctuation.length, @@ -310,7 +316,7 @@ describe("findSuggestionInText", () => { it("returns null when user inputs any whitespace after the special character", () => { const mentionWithSpaceAfter = "@ somebody"; - expect(findSuggestionInText(mentionWithSpaceAfter, 2)).toBeNull(); + expect(findSuggestionInText(mentionWithSpaceAfter, 2, true)).toBeNull(); }); }); From 1c785ea49f9ddd2756681191ded52464deb4dd8d Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 12:26:19 +0100 Subject: [PATCH 22/34] make findSuggestionInText return mappedSuggestion --- .../rooms/wysiwyg_composer/hooks/useSuggestion.ts | 10 +++++----- .../wysiwyg_composer/hooks/useSuggestion-test.tsx | 10 +++++----- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index d474ce51609..a2707e68e43 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -109,6 +109,7 @@ export function processSelectionChange( setSuggestionData(null); return; } + const firstTextNode = document.createNodeIterator(editorRef.current, NodeFilter.SHOW_TEXT).nextNode(); const isFirstTextNode = currentNode === firstTextNode; const foundSuggestion = findSuggestionInText(currentNode.textContent, currentOffset, isFirstTextNode); @@ -119,9 +120,8 @@ export function processSelectionChange( return; } - const mappedSuggestion = getMappedSuggestion(foundSuggestion.text); setSuggestionData({ - mappedSuggestion, + mappedSuggestion: foundSuggestion.mappedSuggestion, node: currentNode, startOffset: foundSuggestion.startOffset, endOffset: foundSuggestion.endOffset, @@ -212,13 +212,13 @@ export function processCommand( * @param offset - the current cursor offset position * @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 an empty string if no mention or command is found, otherwise the mention/command substring with it's start offset + * @returns null if no mention or command is found, otherwise the `MappedSuggestion` along with it's start and end offsets */ export function findSuggestionInText( text: string, offset: number, isFirstTextNode: boolean, -): { text: string; startOffset: number; endOffset: number } | null { +): { 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; @@ -262,7 +262,7 @@ export function findSuggestionInText( return null; } - return { text: suggestionText, startOffset: startCharIndex, endOffset: startCharIndex + suggestionText.length }; + return { mappedSuggestion, startOffset: startCharIndex, endOffset: startCharIndex + suggestionText.length }; } /** 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 c2884b3233e..70efc2894e7 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -243,7 +243,7 @@ describe("findSuggestionInText", () => { it.each(allTestCases)("returns an object when the whole input is special case: %s", (text) => { const expected = { - text, + mappedSuggestion: getMappedSuggestion(text), startOffset: 0, endOffset: text.length, }; @@ -264,7 +264,7 @@ describe("findSuggestionInText", () => { 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({ - text: mention, + mappedSuggestion: getMappedSuggestion(mention), startOffset: 0, endOffset: mention.length, }); @@ -282,7 +282,7 @@ describe("findSuggestionInText", () => { const precedingText = "I want to mention "; const followingText = " in my message"; expect(findSuggestionInText(precedingText + mention + followingText, precedingText.length + 3, true)).toEqual({ - text: mention, + mappedSuggestion: getMappedSuggestion(mention), startOffset: precedingText.length, endOffset: precedingText.length + mention.length, }); @@ -308,7 +308,7 @@ describe("findSuggestionInText", () => { const precedingText = "mention "; const mentionInput = precedingText + mentionWithPunctuation; expect(findSuggestionInText(mentionInput, 12, true)).toEqual({ - text: mentionWithPunctuation, + mappedSuggestion: getMappedSuggestion(mentionWithPunctuation), startOffset: precedingText.length, endOffset: precedingText.length + mentionWithPunctuation.length, }); @@ -320,7 +320,7 @@ describe("findSuggestionInText", () => { }); }); -describe("getMentionOrCommandParts", () => { +describe("getMappedSuggestion", () => { it("returns an empty mapped suggestion when first character is not / # @", () => { expect(getMappedSuggestion("Zzz")).toEqual({ type: "unknown", keyChar: "", text: "" }); }); From 2368e25168876c5bdbaa0a08ba3cd1f068be0a6a Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 12:32:04 +0100 Subject: [PATCH 23/34] fix TS error --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 70efc2894e7..4d8a1f9cca4 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -26,9 +26,7 @@ import { 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, From 12a75834d4aedf4435cb2eb845748b9151fb7139 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 12:45:05 +0100 Subject: [PATCH 24/34] update comments and index check from === -1 to < 0 --- .../rooms/wysiwyg_composer/hooks/useSuggestion.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index a2707e68e43..e3556d7448b 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -209,7 +209,7 @@ export function processCommand( * from the special character to the end of the input * * @param text - the text content of a node - * @param offset - the current cursor offset position + * @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 null if no mention or command is found, otherwise the `MappedSuggestion` along with it's start and end offsets @@ -224,8 +224,8 @@ export function findSuggestionInText( return null; } - // A cursor offset lies between two characters, so make a cursor offset correspond to - // a character index in the text string + // As we will be searching in both directions from the cursor, set the starting indices + // based on the current cursor offset let startCharIndex = offset - 1; let endCharIndex = offset; @@ -267,7 +267,7 @@ export function findSuggestionInText( /** * Associated function for findSuggestionInText. Checks the character at the current location - * to determine if the current index should be changed. + * 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 @@ -275,7 +275,7 @@ export function findSuggestionInText( */ function shouldDecrementStartIndex(text: string, index: number): boolean { // If the index is outside the string, return false - if (index === -1) return false; + if (index < 0) return false; // We are inside the string so can guarantee that there is a character at the index // The preceding character could be undefined if index === 0 @@ -297,7 +297,7 @@ function shouldDecrementStartIndex(text: string, index: number): boolean { /** * Associated function for findSuggestionInText. Checks the character at the current location - * to determine if the current index should be changed. + * 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 From 3d747106bf5fff00c3059c68d0b3125a60e9470c Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 13:10:54 +0100 Subject: [PATCH 25/34] tidy logic in increment functions --- .../rooms/wysiwyg_composer/hooks/useSuggestion.ts | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index e3556d7448b..bd004e7570e 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -280,19 +280,17 @@ function shouldDecrementStartIndex(text: string, index: number): boolean { // We are inside the string so can guarantee that there is a character at the index // The preceding character could be undefined if index === 0 const mentionOrCommandChar = /[@#/]/; - const mentionChar = /[@#]/; const currentChar = text[index]; const precedingChar = text[index - 1] ?? ""; const currentCharIsMentionOrCommand = mentionOrCommandChar.test(currentChar); - // We want to ignore @ or # if they are preceded by anything that is not a whitespace - // to allow us to handle cases like email addesses - const shouldIgnoreCurrentMentionChar = mentionChar.test(currentChar) && /\S/.test(precedingChar); + // If we have not found a special character, continue searching + if (!currentCharIsMentionOrCommand) return true; - // Keep moving left if the current character is not a relevant character, or if - // we have a relevant character preceded by something other than whitespace - return !currentCharIsMentionOrCommand || shouldIgnoreCurrentMentionChar; + // If we have found a special character, continue searching if the preceding character is not whitespace + // This enables us to handle any strings containing the special characters, such as email addresses + return /\S/.test(precedingChar); } /** @@ -305,7 +303,7 @@ function shouldDecrementStartIndex(text: string, index: number): boolean { */ function shouldIncrementEndIndex(text: string, index: number): boolean { // If the index is outside the string, return false - if (index === text.length) return false; + if (index >= text.length) return false; // We are inside the string so can guarantee that there is a character at the index // Keep moving right if the current character is not a space From c0d240a6dd7c67a87e59cc845b4d8ef237e78934 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 13:36:10 +0100 Subject: [PATCH 26/34] rename variable --- .../wysiwyg_composer/hooks/useSuggestion.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index bd004e7570e..8394027e7b7 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -226,29 +226,29 @@ export function findSuggestionInText( // As we will be searching in both directions from the cursor, set the starting indices // based on the current cursor offset - let startCharIndex = offset - 1; - let endCharIndex = offset; + let startSliceIndex = offset - 1; + let endSliceIndex = offset; - while (shouldDecrementStartIndex(text, startCharIndex)) { + while (shouldDecrementStartIndex(text, startSliceIndex)) { // Special case - if we hit some whitespace, return null. This is to catch cases // where user types a special character then whitespace - if (/\s/.test(text[startCharIndex])) { + if (/\s/.test(text[startSliceIndex])) { return null; } - startCharIndex--; + startSliceIndex--; } - while (shouldIncrementEndIndex(text, endCharIndex)) { - endCharIndex++; + while (shouldIncrementEndIndex(text, endSliceIndex)) { + endSliceIndex++; } // We have looped throught the text in both directions from the current cursor position // whilst looking for special characters. // We do not have a command or mention if: // - the start or ending indices are outside the string - if (startCharIndex < 0 || endCharIndex > text.length) return null; + if (startSliceIndex < 0 || endSliceIndex > text.length) return null; - const suggestionText = text.slice(startCharIndex, endCharIndex); + const suggestionText = text.slice(startSliceIndex, endSliceIndex); const mappedSuggestion = getMappedSuggestion(suggestionText); // We do not have a command if: @@ -257,12 +257,12 @@ export function findSuggestionInText( // something requiring autocomplete) if ( mappedSuggestion.type === "command" && - (!isFirstTextNode || startCharIndex !== 0 || endCharIndex !== text.length) + (!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length) ) { return null; } - return { mappedSuggestion, startOffset: startCharIndex, endOffset: startCharIndex + suggestionText.length }; + return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + suggestionText.length }; } /** From d292763cacf95d0fca0de6e510bcda9e688bcea2 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 15:05:41 +0100 Subject: [PATCH 27/34] Big refactor to address multiple comments, improve behaviour and add tests --- .../wysiwyg_composer/hooks/useSuggestion.ts | 71 +++++++------------ .../hooks/useSuggestion-test.tsx | 20 ++++-- 2 files changed, 41 insertions(+), 50 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 8394027e7b7..c1c5c8f5d81 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -204,9 +204,9 @@ export function processCommand( } /** - * Given some text content from a node and the cursor position, search through the content - * to find a mention or a command. If there is one present, return a slice of the content - * from the special character to the end of the input + * 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 text - the text content of a node * @param offset - the current cursor offset position within the node @@ -224,17 +224,12 @@ export function findSuggestionInText( return null; } - // As we will be searching in both directions from the cursor, set the starting indices - // based on the current cursor offset - let startSliceIndex = offset - 1; + // 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; while (shouldDecrementStartIndex(text, startSliceIndex)) { - // Special case - if we hit some whitespace, return null. This is to catch cases - // where user types a special character then whitespace - if (/\s/.test(text[startSliceIndex])) { - return null; - } startSliceIndex--; } @@ -242,16 +237,12 @@ export function findSuggestionInText( endSliceIndex++; } - // We have looped throught the text in both directions from the current cursor position - // whilst looking for special characters. - // We do not have a command or mention if: - // - the start or ending indices are outside the string - if (startSliceIndex < 0 || endSliceIndex > text.length) return null; + const wordAtCursor = text.slice(startSliceIndex, endSliceIndex); + const mappedSuggestion = getMappedSuggestion(wordAtCursor); - const suggestionText = text.slice(startSliceIndex, endSliceIndex); - const mappedSuggestion = getMappedSuggestion(suggestionText); + if (mappedSuggestion === null) return null; - // We do not have a command if: + // If we have a word that could be a command, it is not valid if: // - 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) @@ -262,52 +253,42 @@ export function findSuggestionInText( return null; } - return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + suggestionText.length }; + return { mappedSuggestion, startOffset: startSliceIndex, endOffset: startSliceIndex + wordAtCursor.length }; } /** - * Associated function for findSuggestionInText. Checks the character at the current location + * 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 left, false otherwise + * @returns true if check should keep moving backwards, false otherwise */ function shouldDecrementStartIndex(text: string, index: number): boolean { - // If the index is outside the string, return false - if (index < 0) return false; - - // We are inside the string so can guarantee that there is a character at the index - // The preceding character could be undefined if index === 0 - const mentionOrCommandChar = /[@#/]/; - const currentChar = text[index]; - const precedingChar = text[index - 1] ?? ""; + // If the index is at or outside the beginning of the string, return false + if (index <= 0) return false; - const currentCharIsMentionOrCommand = mentionOrCommandChar.test(currentChar); + // We are inside the string so can guarantee that there is a preceding character + const precedingChar = text[index - 1]; - // If we have not found a special character, continue searching - if (!currentCharIsMentionOrCommand) return true; - - // If we have found a special character, continue searching if the preceding character is not whitespace - // This enables us to handle any strings containing the special characters, such as email addresses - return /\S/.test(precedingChar); + // Keep searching backwards if the preceding character is not a space + return !/\s/.test(precedingChar); } /** - * Associated function for findSuggestionInText. Checks the character at the current location + * 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 right, false otherwise + * @returns true if check should keep moving forwards, false otherwise */ function shouldIncrementEndIndex(text: string, index: number): boolean { - // If the index is outside the string, return false + // If the index is at or outside the end of the string, return false if (index >= text.length) return false; - // We are inside the string so can guarantee that there is a character at the index - // Keep moving right if the current character is not a space - return /\S/.test(text[index]); + // Keep searching forwards if the current character is not a space + return !/\s/.test(text[index]); } /** @@ -317,7 +298,7 @@ function shouldIncrementEndIndex(text: string, index: number): boolean { * @param suggestionText - string that could be a mention of a command type suggestion * @returns an object representing the `MappedSuggestion` from that string */ -export function getMappedSuggestion(suggestionText: string): MappedSuggestion { +export function getMappedSuggestion(suggestionText: string): MappedSuggestion | null { const firstChar = suggestionText.charAt(0); const restOfString = suggestionText.slice(1); @@ -328,6 +309,6 @@ export function getMappedSuggestion(suggestionText: string): MappedSuggestion { case "@": return { keyChar: firstChar, text: restOfString, type: "mention" }; default: - return { keyChar: "", text: "", type: "unknown" }; + 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 4d8a1f9cca4..568bc613466 100644 --- a/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/hooks/useSuggestion-test.tsx @@ -245,7 +245,8 @@ describe("findSuggestionInText", () => { startOffset: 0, endOffset: text.length, }; - // test for cursor at after special character, before end, end + // 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); @@ -279,11 +280,20 @@ describe("findSuggestionInText", () => { it.each(mentionTestCases)("returns an object if %s is surrounded by text", (mention) => { const precedingText = "I want to mention "; const followingText = " in my message"; - expect(findSuggestionInText(precedingText + mention + followingText, precedingText.length + 3, true)).toEqual({ + + 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", () => { @@ -319,8 +329,8 @@ describe("findSuggestionInText", () => { }); describe("getMappedSuggestion", () => { - it("returns an empty mapped suggestion when first character is not / # @", () => { - expect(getMappedSuggestion("Zzz")).toEqual({ type: "unknown", keyChar: "", text: "" }); + 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 @", () => { From be88d719357637dea57ed398b37aa6ece6baf945 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 15:17:02 +0100 Subject: [PATCH 28/34] improve comments --- .../wysiwyg_composer/hooks/useSuggestion.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index c1c5c8f5d81..09ca8405924 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -229,14 +229,19 @@ export function findSuggestionInText( let startSliceIndex = offset; let endSliceIndex = offset; + // Search backwards from the current cursor position to find the start index of the word + // that the cursor is inside while (shouldDecrementStartIndex(text, startSliceIndex)) { startSliceIndex--; } + // Search fowards from the current cursor position to find the end index of the word + // that the cursor is inside 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); @@ -292,15 +297,14 @@ function shouldIncrementEndIndex(text: string, index: number): boolean { } /** - * Given a string that represents a suggestion in the composer, return an object that represents - * that text as a `MappedSuggestion`. + * Given a string, return a `MappedSuggestion` if the string contains a suggestion. Otherwise return null. * - * @param suggestionText - string that could be a mention of a command type suggestion - * @returns an object representing the `MappedSuggestion` from that string + * @param text - string to check for a suggestion + * @returns a `MappedSuggestion` if a suggestion is present, null otherwise */ -export function getMappedSuggestion(suggestionText: string): MappedSuggestion | null { - const firstChar = suggestionText.charAt(0); - const restOfString = suggestionText.slice(1); +export function getMappedSuggestion(text: string): MappedSuggestion | null { + const firstChar = text.charAt(0); + const restOfString = text.slice(1); switch (firstChar) { case "/": From 246ed7cb30c6faaaf91411df9b8a8e32c1e7cf6d Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 15:20:05 +0100 Subject: [PATCH 29/34] tidy up comment --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 09ca8405924..62899c631e0 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -230,13 +230,13 @@ export function findSuggestionInText( let endSliceIndex = offset; // Search backwards from the current cursor position to find the start index of the word - // that the cursor is inside + // containing the cursor while (shouldDecrementStartIndex(text, startSliceIndex)) { startSliceIndex--; } // Search fowards from the current cursor position to find the end index of the word - // that the cursor is inside + // containing the cursor while (shouldIncrementEndIndex(text, endSliceIndex)) { endSliceIndex++; } From 283884ffaf6962791e95bd822d3e8ee91ac28788 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 15:30:17 +0100 Subject: [PATCH 30/34] extend comment --- .../rooms/wysiwyg_composer/hooks/useSuggestion.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 62899c631e0..ee92e37a8d3 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -247,10 +247,14 @@ export function findSuggestionInText( if (mappedSuggestion === null) return null; - // If we have a word that could be a command, it is not valid if: - // - 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 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 ( mappedSuggestion.type === "command" && (!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length) From 86017bdf2abb6d999e5693d79f5ca4ccdc89870e Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 15:33:46 +0100 Subject: [PATCH 31/34] combine similar returns --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index ee92e37a8d3..1a032da8f37 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -245,8 +245,6 @@ export function findSuggestionInText( const wordAtCursor = text.slice(startSliceIndex, endSliceIndex); const mappedSuggestion = getMappedSuggestion(wordAtCursor); - if (mappedSuggestion === null) return null; - /** * 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 @@ -256,8 +254,9 @@ export function findSuggestionInText( * something requiring autocomplete) */ if ( - mappedSuggestion.type === "command" && - (!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length) + mappedSuggestion === null || + (mappedSuggestion.type === "command" && + (!isFirstTextNode || startSliceIndex !== 0 || endSliceIndex !== text.length)) ) { return null; } From be67256e1ff0f1f8cd32265fbf568b7124b7b1c4 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 15:35:33 +0100 Subject: [PATCH 32/34] update comment --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 1a032da8f37..d91e4ab6405 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -212,7 +212,7 @@ export function processCommand( * @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 null if no mention or command is found, otherwise the `MappedSuggestion` along with it's start and end offsets + * @returns the `MappedSuggestion` along with it's start and end offsets if found, otherwise null */ export function findSuggestionInText( text: string, From 14f8ecda1e99f782bfeb30319db511e52d0b773c Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Wed, 10 May 2023 15:38:50 +0100 Subject: [PATCH 33/34] remove single use variable --- .../views/rooms/wysiwyg_composer/hooks/useSuggestion.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index d91e4ab6405..0d2b96f21c8 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -277,10 +277,8 @@ function shouldDecrementStartIndex(text: string, index: number): boolean { if (index <= 0) return false; // We are inside the string so can guarantee that there is a preceding character - const precedingChar = text[index - 1]; - // Keep searching backwards if the preceding character is not a space - return !/\s/.test(precedingChar); + return !/\s/.test(text[index - 1]); } /** From 45224cf6175f0321b48c78d778fa778baaa1fc32 Mon Sep 17 00:00:00 2001 From: Alun Turner Date: Thu, 11 May 2023 13:31:08 +0100 Subject: [PATCH 34/34] fix comments --- .../wysiwyg_composer/hooks/useSuggestion.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts index 0d2b96f21c8..e8d1833cc89 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useSuggestion.ts @@ -21,13 +21,12 @@ import { SyntheticEvent, useState } from "react"; * Information about the current state of the `useSuggestion` hook. */ export type Suggestion = { - /** - * 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. - */ 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; @@ -210,9 +209,9 @@ export function processCommand( * * @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 + * @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 it's start and end offsets if found, otherwise null + * @returns the `MappedSuggestion` along with its start and end offsets if found, otherwise null */ export function findSuggestionInText( text: string, @@ -235,7 +234,7 @@ export function findSuggestionInText( startSliceIndex--; } - // Search fowards from the current cursor position to find the end index of the word + // Search forwards from the current cursor position to find the end index of the word // containing the cursor while (shouldIncrementEndIndex(text, endSliceIndex)) { endSliceIndex++;