cetirizine

An experimental matrix client written in reactjs utilizing tailwind and storybook
git clone git://archive.git.mtrnord.blog/MTRNord/cetirizine.git
Log | Files | Refs | README | LICENSE

MentionsPlugin.tsx (9790B)


      1 import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
      2 import {
      3     LexicalTypeaheadMenuPlugin,
      4     QueryMatch,
      5     TypeaheadOption,
      6     useBasicTypeaheadTriggerMatch
      7 } from "@lexical/react/LexicalTypeaheadMenuPlugin";
      8 import { TextNode } from "lexical";
      9 import { FC, useCallback, useEffect, useMemo, useState } from "react";
     10 import * as ReactDOM from "react-dom";
     11 
     12 import { $createMentionNode } from "./MentionNode";
     13 import { Room } from "../../../../../app/sdk/room";
     14 import { IRoomMemberEvent } from "../../../../../app/sdk/api/events";
     15 import { SDKError } from "../../../../../app/sdk/utils";
     16 
     17 const PUNCTUATION =
     18     "\\.,\\+\\*\\?\\$\\@\\|#{}\\(\\)\\^\\-\\[\\]\\\\/!%'\"~=<>_:;";
     19 const NAME = "\\b[A-Z][^\\s" + PUNCTUATION + "]";
     20 
     21 const DocumentMentionsRegex = {
     22     NAME,
     23     PUNCTUATION
     24 };
     25 
     26 const CapitalizedNameMentionsRegex = new RegExp(
     27     "(^|[^#])((?:" + DocumentMentionsRegex.NAME + "{" + 1 + ",})$)"
     28 );
     29 
     30 const PUNC = DocumentMentionsRegex.PUNCTUATION;
     31 
     32 const TRIGGERS = ["@"].join("");
     33 
     34 // Chars we expect to see in a mention (non-space, non-punctuation).
     35 const VALID_CHARS = "[^" + TRIGGERS + PUNC + "\\s]";
     36 
     37 // Non-standard series of chars. Each series must be preceded and followed by
     38 // a valid char.
     39 const VALID_JOINS =
     40     "(?:" +
     41     "\\.[ |$]|" + // E.g. "r. " in "Mr. Smith"
     42     " |" + // E.g. " " in "Josh Duck"
     43     "[" +
     44     PUNC +
     45     "]|" + // E.g. "-' in "Salier-Hellendag"
     46     ")";
     47 
     48 const LENGTH_LIMIT = 75;
     49 
     50 const AtSignMentionsRegex = new RegExp(
     51     "(^|\\s|\\()(" +
     52     "[" +
     53     TRIGGERS +
     54     "]" +
     55     "((?:" +
     56     VALID_CHARS +
     57     VALID_JOINS +
     58     "){0," +
     59     LENGTH_LIMIT +
     60     "})" +
     61     ")$"
     62 );
     63 
     64 // 50 is the longest alias length limit.
     65 const ALIAS_LENGTH_LIMIT = 50;
     66 
     67 // Regex used to match alias.
     68 const AtSignMentionsRegexAliasRegex = new RegExp(
     69     "(^|\\s|\\()(" +
     70     "[" +
     71     TRIGGERS +
     72     "]" +
     73     "((?:" +
     74     VALID_CHARS +
     75     "){0," +
     76     ALIAS_LENGTH_LIMIT +
     77     "})" +
     78     ")$"
     79 );
     80 
     81 // At most, 5 suggestions are shown in the popup.
     82 const SUGGESTION_LIST_LENGTH_LIMIT = 5;
     83 
     84 const mentionsCache = new Map();
     85 
     86 const dummyLookupService = {
     87     search(string: string, room: Room, callback: (results: IRoomMemberEvent[]) => void): void {
     88         setTimeout(async () => {
     89             const members = await room.joinedMembers();
     90             if (members instanceof SDKError) {
     91                 console.error(members);
     92                 return
     93             }
     94             const results = members.filter((member) => {
     95                 if (member.content.displayname) {
     96                     return member.content.displayname.toLowerCase().includes(string.toLowerCase()) || member.state_key.toLowerCase().includes(string.toLowerCase());
     97                 } else {
     98                     return member.state_key.toLowerCase().includes(string.toLowerCase());
     99                 }
    100             });
    101             callback(results);
    102         }, 500);
    103     }
    104 };
    105 
    106 function useMentionLookupService(mentionString: string | null, room: Room) {
    107     const [results, setResults] = useState<IRoomMemberEvent[]>([]);
    108 
    109     useEffect(() => {
    110         const cachedResults = mentionsCache.get(mentionString);
    111 
    112         if (mentionString == null) {
    113             setResults([]);
    114             return;
    115         }
    116 
    117         if (cachedResults === null) {
    118             return;
    119         } else if (cachedResults !== undefined) {
    120             setResults(cachedResults);
    121             return;
    122         }
    123 
    124         mentionsCache.set(mentionString, null);
    125         dummyLookupService.search(mentionString, room, (newResults) => {
    126             mentionsCache.set(mentionString, newResults);
    127             setResults(newResults);
    128         });
    129     }, [mentionString]);
    130 
    131     return results;
    132 }
    133 
    134 function checkForCapitalizedNameMentions(
    135     text: string,
    136     minMatchLength: number
    137 ): QueryMatch | null {
    138     const match = CapitalizedNameMentionsRegex.exec(text);
    139     if (match !== null) {
    140         // The strategy ignores leading whitespace but we need to know it's
    141         // length to add it to the leadOffset
    142         const maybeLeadingWhitespace = match[1];
    143 
    144         const matchingString = match[2];
    145         if (matchingString != null && matchingString.length >= minMatchLength) {
    146             return {
    147                 leadOffset: match.index + maybeLeadingWhitespace.length,
    148                 matchingString,
    149                 replaceableString: matchingString
    150             };
    151         }
    152     }
    153     return null;
    154 }
    155 
    156 function checkForAtSignMentions(
    157     text: string,
    158     minMatchLength: number
    159 ): QueryMatch | null {
    160     let match = AtSignMentionsRegex.exec(text);
    161 
    162     if (match === null) {
    163         match = AtSignMentionsRegexAliasRegex.exec(text);
    164     }
    165     if (match !== null) {
    166         // The strategy ignores leading whitespace but we need to know it's
    167         // length to add it to the leadOffset
    168         const maybeLeadingWhitespace = match[1];
    169 
    170         const matchingString = match[3];
    171         if (matchingString.length >= minMatchLength) {
    172             return {
    173                 leadOffset: match.index + maybeLeadingWhitespace.length,
    174                 matchingString,
    175                 replaceableString: match[2]
    176             };
    177         }
    178     }
    179     return null;
    180 }
    181 
    182 function getPossibleQueryMatch(text: string): QueryMatch | null {
    183     const match = checkForAtSignMentions(text, 1);
    184     return match === null ? checkForCapitalizedNameMentions(text, 3) : match;
    185 }
    186 
    187 class MentionTypeaheadOption extends TypeaheadOption {
    188     event: IRoomMemberEvent;
    189     picture: JSX.Element;
    190 
    191     constructor(event: IRoomMemberEvent, picture: JSX.Element) {
    192         super(event.content.displayname || event.state_key);
    193         this.event = event;
    194         this.picture = picture;
    195     }
    196 }
    197 
    198 function MentionsTypeaheadMenuItem({
    199     index,
    200     isSelected,
    201     onClick,
    202     onMouseEnter,
    203     option
    204 }: {
    205     index: number;
    206     isSelected: boolean;
    207     onClick: () => void;
    208     onMouseEnter: () => void;
    209     option: MentionTypeaheadOption;
    210 }) {
    211     let className = "item";
    212     if (isSelected) {
    213         className += " selected";
    214     }
    215     return (
    216         <li
    217             key={option.key}
    218             tabIndex={-1}
    219             className={className}
    220             ref={option.setRefElement}
    221             role="option"
    222             aria-selected={isSelected}
    223             id={"typeahead-item-" + index}
    224             onMouseEnter={onMouseEnter}
    225             onClick={onClick}
    226         >
    227             {option.picture}
    228             <span className="text">{option.event.content.displayname || option.event.state_key}</span>
    229         </li>
    230     );
    231 }
    232 
    233 export type MentionsPluginOptions = {
    234     room: Room;
    235 }
    236 
    237 export const MentionsPlugin: FC<MentionsPluginOptions> = ({ room }): JSX.Element | null => {
    238     const [editor] = useLexicalComposerContext();
    239 
    240     const [queryString, setQueryString] = useState<string | null>(null);
    241 
    242     const results = useMentionLookupService(queryString, room);
    243 
    244     const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch("/", {
    245         minLength: 0
    246     });
    247 
    248     const options = useMemo(
    249         () =>
    250             results
    251                 .map((result) => new MentionTypeaheadOption(result, <i />))
    252                 .slice(0, SUGGESTION_LIST_LENGTH_LIMIT),
    253         [results]
    254     );
    255 
    256     const onSelectOption = useCallback(
    257         (
    258             selectedOption: MentionTypeaheadOption,
    259             nodeToReplace: TextNode | null,
    260             closeMenu: () => void
    261         ) => {
    262             editor.update(() => {
    263                 const mentionNode = $createMentionNode(selectedOption.event);
    264                 if (nodeToReplace) {
    265                     nodeToReplace.replace(mentionNode);
    266                 }
    267                 mentionNode.select();
    268                 closeMenu();
    269             });
    270         },
    271         [editor]
    272     );
    273 
    274     const checkForMentionMatch = useCallback(
    275         (text: string) => {
    276             const mentionMatch = getPossibleQueryMatch(text);
    277             const slashMatch = checkForSlashTriggerMatch(text, editor);
    278             return !slashMatch && mentionMatch ? mentionMatch : null;
    279         },
    280         [checkForSlashTriggerMatch, editor]
    281     );
    282 
    283     return (
    284         <LexicalTypeaheadMenuPlugin<MentionTypeaheadOption>
    285             anchorClassName="room-wrapper"
    286             onQueryChange={setQueryString}
    287             onSelectOption={onSelectOption}
    288             triggerFn={checkForMentionMatch}
    289             options={options}
    290             menuRenderFn={(
    291                 anchorElementRef,
    292                 { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }
    293             ) => anchorElementRef && results.length
    294                     ? ReactDOM.createPortal(
    295                         <div className="typeahead-popover mentions-menu">
    296                             <ul>
    297                                 {options.map((option, i: number) => (
    298                                     <MentionsTypeaheadMenuItem
    299                                         index={i}
    300                                         isSelected={selectedIndex === i}
    301                                         onClick={() => {
    302                                             setHighlightedIndex(i);
    303                                             selectOptionAndCleanUp(option);
    304                                         }}
    305                                         onMouseEnter={() => {
    306                                             setHighlightedIndex(i);
    307                                         }}
    308                                         key={option.key}
    309                                         option={option}
    310                                     />
    311                                 ))}
    312                             </ul>
    313                         </div>,
    314                         // @ts-ignore TODO: fix this
    315                         anchorElementRef.current
    316                     )
    317                     : <></>
    318             }
    319         />
    320     );
    321 }