diff --git a/app/soapbox/features/compose/editor/plugins/mention-plugin.ts b/app/soapbox/features/compose/editor/plugins/mention-plugin.ts deleted file mode 100644 index 0d0e0a604..000000000 --- a/app/soapbox/features/compose/editor/plugins/mention-plugin.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) Meta Platforms, Inc. and affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity'; -import { useCallback, useEffect } from 'react'; - -import { $createMentionNode, MentionNode } from '../nodes/mention-node'; - -import type { TextNode } from 'lexical'; - -const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i'); - -export function MentionPlugin(): JSX.Element | null { - const [editor] = useLexicalComposerContext(); - - useEffect(() => { - if (!editor.hasNodes([MentionNode])) { - throw new Error('MentionPlugin: MentionNode not registered on editor'); - } - }, [editor]); - - const createMentionNode = useCallback((textNode: TextNode): MentionNode => { - return $createMentionNode(textNode.getTextContent()); - }, []); - - const getMentionMatch = useCallback((text: string) => { - const matchArr = REGEX.exec(text); - - if (matchArr === null) { - return null; - } - - const mentionLength = matchArr[3].length + 1; - const startOffset = matchArr.index + matchArr[1].length; - const endOffset = startOffset + mentionLength; - return { - end: endOffset, - start: startOffset, - }; - }, []); - - useLexicalTextEntity( - getMentionMatch, - MentionNode, - createMentionNode, - ); - - return null; -} diff --git a/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx new file mode 100644 index 000000000..d1d34495a --- /dev/null +++ b/app/soapbox/features/compose/editor/plugins/mention-plugin.tsx @@ -0,0 +1,357 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { + LexicalTypeaheadMenuPlugin, + QueryMatch, + TypeaheadOption, + useBasicTypeaheadTriggerMatch, +} from '@lexical/react/LexicalTypeaheadMenuPlugin'; +import { useLexicalTextEntity } from '@lexical/react/useLexicalTextEntity'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import ReactDOM from 'react-dom'; + +import { $createMentionNode, MentionNode } from '../nodes/mention-node'; + +import type { TextNode } from 'lexical'; + +const REGEX = new RegExp('(^|$|(?:^|\\s))([@])([a-z\\d_-]+(?:@[^@\\s]+)?)', 'i'); + +const PUNCTUATION = + '\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%\'"~=<>_:;'; +const NAME = '\\b[A-Z][^\\s' + PUNCTUATION + ']'; + +const DocumentMentionsRegex = { + NAME, + PUNCTUATION, +}; + +const CapitalizedNameMentionsRegex = new RegExp( + '(^|[^#])((?:' + DocumentMentionsRegex.NAME + '{' + 1 + ',})$)', +); + +const PUNC = DocumentMentionsRegex.PUNCTUATION; + +const TRIGGERS = ['@'].join(''); + +// Chars we expect to see in a mention (non-space, non-punctuation). +const VALID_CHARS = '[^' + TRIGGERS + PUNC + '\\s]'; + +// Non-standard series of chars. Each series must be preceded and followed by +// a valid char. +// const VALID_JOINS = +// '(?:' + +// '\\.[ |$]|' + // E.g. "r. " in "Mr. Smith" +// ' |' + // E.g. " " in "Josh Duck" +// '[' + +// PUNC + +// ']|' + // E.g. "-' in "Salier-Hellendag" +// ')'; + +// const LENGTH_LIMIT = 75; + +const AtSignMentionsRegex = REGEX; /* new RegExp( + '(^|\\s|\\()(' + + '[' + + TRIGGERS + + ']' + + '((?:' + + VALID_CHARS + + VALID_JOINS + + '){0,' + + LENGTH_LIMIT + + '})' + + ')$', +); */ + +// 50 is the longest alias length limit. +const ALIAS_LENGTH_LIMIT = 50; + +// Regex used to match alias. +const AtSignMentionsRegexAliasRegex = new RegExp( + '(^|\\s|\\()(' + + '[' + + TRIGGERS + + ']' + + '((?:' + + VALID_CHARS + + '){0,' + + ALIAS_LENGTH_LIMIT + + '})' + + ')$', +); + +// At most, 5 suggestions are shown in the popup. +const SUGGESTION_LIST_LENGTH_LIMIT = 5; + +const mentionsCache = new Map(); + +const dummyMentionsData = ['Test']; + +const dummyLookupService = { + search(string: string, callback: (results: Array) => void): void { + setTimeout(() => { + const results = dummyMentionsData.filter((mention) => + mention.toLowerCase().includes(string.toLowerCase()), + ); + callback(results); + }, 500); + }, +}; + +function useMentionLookupService(mentionString: string | null) { + const [results, setResults] = useState>([]); + + useEffect(() => { + const cachedResults = mentionsCache.get(mentionString); + + if (mentionString === null) { + setResults([]); + return; + } + + if (cachedResults === null) { + return; + } else if (cachedResults !== undefined) { + setResults(cachedResults); + return; + } + + mentionsCache.set(mentionString, null); + dummyLookupService.search(mentionString, (newResults) => { + mentionsCache.set(mentionString, newResults); + setResults(newResults); + }); + }, [mentionString]); + + return results; +} + +function checkForCapitalizedNameMentions( + text: string, + minMatchLength: number, +): QueryMatch | null { + const match = CapitalizedNameMentionsRegex.exec(text); + if (match !== null) { + // The strategy ignores leading whitespace but we need to know it's + // length to add it to the leadOffset + const maybeLeadingWhitespace = match[1]; + + const matchingString = match[2]; + if (matchingString !== null && matchingString.length >= minMatchLength) { + return { + leadOffset: match.index + maybeLeadingWhitespace.length, + matchingString, + replaceableString: matchingString, + }; + } + } + return null; +} + +function checkForAtSignMentions( + text: string, + minMatchLength: number, +): QueryMatch | null { + let match = AtSignMentionsRegex.exec(text); + console.log(text, match); + + if (match === null) { + match = AtSignMentionsRegexAliasRegex.exec(text); + } + if (match !== null) { + // The strategy ignores leading whitespace but we need to know it's + // length to add it to the leadOffset + const maybeLeadingWhitespace = match[1]; + + const matchingString = match[3]; + if (matchingString.length >= minMatchLength) { + return { + leadOffset: match.index + maybeLeadingWhitespace.length, + matchingString, + replaceableString: match[2], + }; + } + } + return null; +} + +function getPossibleQueryMatch(text: string): QueryMatch | null { + const match = checkForAtSignMentions(text, 1); + return match === null ? checkForCapitalizedNameMentions(text, 3) : match; +} + +class MentionTypeaheadOption extends TypeaheadOption { + + name: string; + picture: JSX.Element; + + constructor(name: string, picture: JSX.Element) { + super(name); + this.name = name; + this.picture = picture; + } + +} + +function MentionsTypeaheadMenuItem({ + index, + isSelected, + onClick, + onMouseEnter, + option, +}: { + index: number + isSelected: boolean + onClick: () => void + onMouseEnter: () => void + option: MentionTypeaheadOption +}) { + let className = 'item'; + if (isSelected) { + className += ' selected'; + } + return ( +
  • + {option.picture} + {option.name} +
  • + ); +} + +export function MentionPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + const [queryString, setQueryString] = useState(null); + + const results = useMentionLookupService(queryString); + + const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch('/', { + minLength: 0, + }); + + const options = useMemo( + () => + results + .map( + (result) => + new MentionTypeaheadOption(result, ), + ) + .slice(0, SUGGESTION_LIST_LENGTH_LIMIT), + [results], + ); + + const onSelectOption = useCallback( + ( + selectedOption: MentionTypeaheadOption, + nodeToReplace: TextNode | null, + closeMenu: () => void, + ) => { + editor.update(() => { + const mentionNode = $createMentionNode(selectedOption.name); + if (nodeToReplace) { + nodeToReplace.replace(mentionNode); + } + mentionNode.select(); + closeMenu(); + }); + }, + [editor], + ); + + const checkForMentionMatch = useCallback( + (text: string) => { + console.log(text); + const mentionMatch = getPossibleQueryMatch(text); + const slashMatch = checkForSlashTriggerMatch(text, editor); + return !slashMatch && mentionMatch ? mentionMatch : null; + }, + [checkForSlashTriggerMatch, editor], + ); + + useEffect(() => { + if (!editor.hasNodes([MentionNode])) { + throw new Error('MentionPlugin: MentionNode not registered on editor'); + } + }, [editor]); + + const createMentionNode = useCallback((textNode: TextNode): MentionNode => { + return $createMentionNode(textNode.getTextContent()); + }, []); + + const getMentionMatch = useCallback((text: string) => { + const matchArr = REGEX.exec(text); + + if (matchArr === null) { + return null; + } + + const mentionLength = matchArr[3].length + 1; + const startOffset = matchArr.index + matchArr[1].length; + const endOffset = startOffset + mentionLength; + return { + end: endOffset, + start: startOffset, + }; + }, []); + + useLexicalTextEntity( + getMentionMatch, + MentionNode, + createMentionNode, + ); + + return ( + + onQueryChange={setQueryString} + onSelectOption={onSelectOption} + triggerFn={checkForMentionMatch} + options={options} + menuRenderFn={( + anchorElementRef, + { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, + ) => + anchorElementRef.current && results.length + ? ReactDOM.createPortal( +
    +
      + {options.map((option, i: number) => ( + { + setHighlightedIndex(i); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(i); + }} + key={option.key} + option={option} + /> + ))} +
    +
    , + anchorElementRef.current, + ) + : null + } + /> + ); +}