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

commit 09211bb3975c4759e8d3f2ad3a49c75469406823
parent b893edb8999642779473e35881809a2ed0761075
Author: MTRNord <mtrnord1@gmail.com>
Date:   Sun,  7 May 2023 21:41:15 +0200

Add audio event to storybook, try to fix build

Diffstat:
M.github/workflows/deploy.yaml | 2+-
M.storybook/main.ts | 3++-
Msrc/components/events/messageEvent.stories.tsx | 22++++++++++++++++++++++
Asrc/components/input/chat/plugins/mentions/MentionNode.ts | 124+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/components/input/chat/plugins/mentions/MentionsPlugin.tsx | 385+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 534 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml @@ -20,7 +20,7 @@ jobs: npx browserslist@latest --update-db patch node_modules/@matrix-org/matrix-sdk-crypto-js/pkg/matrix_sdk_crypto_js.js < .github/crypto-dep.patch npm run build -- --base /cetirizine/ - npm run build-storybook -- --disable-telemetry -o dist/storybook + NODE_OPTIONS='--max-old-space-size=2048' npm run build-storybook -- --disable-telemetry -o dist/storybook cp dist/index.html dist/404.html - name: Deploy 🚀 diff --git a/.storybook/main.ts b/.storybook/main.ts @@ -22,7 +22,8 @@ const config: StorybookConfig = { return mergeConfig(config, { // https://github.com/storybookjs/storybook/issues/22223 build: { - target: 'esnext' + target: 'esnext', + sourceMap: false, } }); }, diff --git a/src/components/events/messageEvent.stories.tsx b/src/components/events/messageEvent.stories.tsx @@ -79,4 +79,26 @@ export const ImageEvent: Story = { room_id: "!jEsUZKDJdhlrceRyVU:example.org" } } +}; + +export const AudioEvent: Story = { + args: { + event: { + content: { + body: "mixaund-bright-future.mp3", + info: { + mimetype: "audio/mpeg", + size: 9329941 + }, + msgtype: "m.audio", + url: "mxc://midnightthoughts.space/4ffaba6ebc3d29165b4549ef47bbc07b6468aab8" + }, + origin_server_ts: 1683125956695, + sender: "@example:example.org", + type: "m.room.message", + unsigned: {}, + event_id: "$143273582443PhrSn:example.org", + room_id: "!jEsUZKDJdhlrceRyVU:example.org" + } + } }; \ No newline at end of file diff --git a/src/components/input/chat/plugins/mentions/MentionNode.ts b/src/components/input/chat/plugins/mentions/MentionNode.ts @@ -0,0 +1,124 @@ +import type { Spread } from "lexical"; + +import { + DOMConversionMap, + DOMConversionOutput, + DOMExportOutput, + EditorConfig, + LexicalNode, + NodeKey, + SerializedTextNode, + TextNode +} from "lexical"; + +export type SerializedMentionNode = Spread< + { + mentionName: string; + type: "mention"; + version: 1; + }, + SerializedTextNode +>; + +function convertMentionElement( + domNode: HTMLElement +): DOMConversionOutput | null { + const textContent = domNode.textContent; + + if (textContent !== null) { + const node = $createMentionNode(textContent); + return { + node + }; + } + + return null; +} + +const mentionStyle = "background-color: rgba(24, 119, 232, 0.2)"; +export class MentionNode extends TextNode { + __mention: string; + + static getType(): string { + return "mention"; + } + + static clone(node: MentionNode): MentionNode { + return new MentionNode(node.__mention, node.__text, node.__key); + } + static importJSON(serializedNode: SerializedMentionNode): MentionNode { + const node = $createMentionNode(serializedNode.mentionName); + node.setTextContent(serializedNode.text); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); + return node; + } + + constructor(mentionName: string, text?: string, key?: NodeKey) { + super(text ?? mentionName, key); + this.__mention = mentionName; + } + + exportJSON(): SerializedMentionNode { + return { + ...super.exportJSON(), + mentionName: this.__mention, + type: "mention", + version: 1 + }; + } + + createDOM(config: EditorConfig): HTMLElement { + const dom = super.createDOM(config); + dom.style.cssText = mentionStyle; + dom.className = "mention"; + return dom; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement("span"); + element.setAttribute("data-lexical-mention", "true"); + element.textContent = this.__text; + return { element }; + } + + isSegmented(): false { + return false; + } + + static importDOM(): DOMConversionMap | null { + return { + span: (domNode: HTMLElement) => { + if (!domNode.hasAttribute("data-lexical-mention")) { + return null; + } + return { + conversion: convertMentionElement, + priority: 1 + }; + } + }; + } + + isTextEntity(): true { + return true; + } + + isToken(): true { + return true; + } +} + +export function $createMentionNode(mentionName: string): MentionNode { + const mentionNode = new MentionNode(mentionName); + mentionNode.setMode("segmented").toggleDirectionless(); + return mentionNode; +} + +export function $isMentionNode( + node: LexicalNode | null | undefined +): node is MentionNode { + return node instanceof MentionNode; +} diff --git a/src/components/input/chat/plugins/mentions/MentionsPlugin.tsx b/src/components/input/chat/plugins/mentions/MentionsPlugin.tsx @@ -0,0 +1,385 @@ +import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext"; +import { + LexicalTypeaheadMenuPlugin, + QueryMatch, + TypeaheadOption, + useBasicTypeaheadTriggerMatch +} from "@lexical/react/LexicalTypeaheadMenuPlugin"; +import { TextNode } from "lexical"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import * as React from "react"; +import * as ReactDOM from "react-dom"; + +import { $createMentionNode } from "./MentionNode"; + +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 = 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 = [ + "Aayla Secura", + "Admiral Dodd Rancit", + "Aurra Sing", + "BB-8", + "Bo-Katan Kryze", + "Breha Antilles-Organa", + "C-3PO", + "Captain Quarsh Panaka", + "Chewbacca", + "Darth Tyranus", + "Daultay Dofine", + "Dexter Jettster", + "Ebe E. Endocott", + "Eli Vanto", + "Ezra Bridger", + "Faro Argyus", + "Finis Valorum", + "FN-2003", + 'Garazeb "Zeb" Orrelios', + "Grand Inquisitor", + "Greeata Jendowanian", + "Hammerhead", + "Han Solo", + "Hevy", + "Hondo Ohnaka", + "Ima-Gun Di", + "Inquisitors", + "Inspector Thanoth", + "Jabba", + "Janus Greejatus", + "Jaxxon", + "K-2SO", + "Kanan Jarrus", + "Kylo Ren", + "L3-37", + "Lieutenant Kaydel Ko Connix", + "Luke Skywalker", + "Mace Windu", + "Maximilian Veers", + "Mother Talzin", + "Nahdar Vebb", + "Nahdonnis Praji", + "Nien Nunb", + "Obi-Wan Kenobi", + "Odd Ball", + "Orrimarko", + "Petty Officer Thanisson", + "Pooja Naberrie", + "PZ-4CO", + "Quarrie", + "Quiggold", + "Quinlan Vos", + "R2-D2", + "Raymus Antilles", + "Ree-Yees", + "Sana Starros", + "Shmi Skywalker", + "Shu Mai", + "Tallissan Lintra", + "Tarfful", + "Thane Kyrell", + "U9-C4", + "Unkar Plutt", + "Val Beckett", + "Vice Admiral Amilyn Holdo", + "Vober Dand", + "WAC-47", + "Wedge Antilles", + "Wicket W. Warrick", + "Xamuel Lennox", + "Yaddle", + "Yarael Poof", + "Yoda", + "Zam Wesell", + "Ziro the Hutt", + "Zuckuss" +]; + +const dummyLookupService = { + search(string: string, callback: (results: Array<string>) => 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<Array<string>>([]); + + 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); + + 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 ( + <li + key={option.key} + tabIndex={-1} + className={className} + ref={option.setRefElement} + role="option" + aria-selected={isSelected} + id={"typeahead-item-" + index} + onMouseEnter={onMouseEnter} + onClick={onClick} + > + {option.picture} + <span className="text">{option.name}</span> + </li> + ); +} + +export default function MentionsPlugin(): JSX.Element | null { + const [editor] = useLexicalComposerContext(); + + const [queryString, setQueryString] = useState<string | null>(null); + + const results = useMentionLookupService(queryString); + + const checkForSlashTriggerMatch = useBasicTypeaheadTriggerMatch("/", { + minLength: 0 + }); + + const options = useMemo( + () => + results + .map((result) => new MentionTypeaheadOption(result, <i />)) + .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) => { + const mentionMatch = getPossibleQueryMatch(text); + const slashMatch = checkForSlashTriggerMatch(text, editor); + return !slashMatch && mentionMatch ? mentionMatch : null; + }, + [checkForSlashTriggerMatch, editor] + ); + + return ( + <LexicalTypeaheadMenuPlugin<MentionTypeaheadOption> + onQueryChange={setQueryString} + onSelectOption={onSelectOption} + triggerFn={checkForMentionMatch} + options={options} + menuRenderFn={( + anchorElementRef, + { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex } + ) => + anchorElementRef && results.length + ? ReactDOM.createPortal( + <div className="typeahead-popover mentions-menu"> + <ul> + {options.map((option, i: number) => ( + <MentionsTypeaheadMenuItem + index={i} + isSelected={selectedIndex === i} + onClick={() => { + setHighlightedIndex(i); + selectOptionAndCleanUp(option); + }} + onMouseEnter={() => { + setHighlightedIndex(i); + }} + key={option.key} + option={option} + /> + ))} + </ul> + </div>, + // @ts-ignore TODO: fix this + anchorElementRef + ) + : null + } + /> + ); +}