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 }