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