Edit option (#1447)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Sat, 14 Oct 2023 05:08:43 +0000 (16:08 +1100)
committerGitHub <noreply@github.com>
Sat, 14 Oct 2023 05:08:43 +0000 (10:38 +0530)
* add func to parse html to editor input

* add  plain to html input function

* re-construct markdown

* fix missing return

* fix falsy condition

* fix reading href instead of src of emoji

* add message editor - WIP

* fix plain to editor input func

* add save edit message functionality

* show edited event source code

* focus message input on after editing message

* use del tag for strike-through instead of s

* prevent autocomplete from re-opening after esc

* scroll out of view msg editor in view

* handle up arrow edit

* handle scroll to message editor without effect

* revert prev commit: effect run after editor render

* ignore relation event from editable

* allow data-md tag for del and em in sanitize html

* prevent edit without changes

* ignore previous reply when replying to msg

* fix up arrow edit not working sometime

18 files changed:
package-lock.json
package.json
src/app/components/editor/Editor.tsx
src/app/components/editor/common.ts
src/app/components/editor/index.ts
src/app/components/editor/input.ts [new file with mode: 0644]
src/app/components/editor/output.ts
src/app/components/editor/slate.d.ts
src/app/organisms/room/RoomInput.tsx
src/app/organisms/room/RoomTimeline.tsx
src/app/organisms/room/message/Message.tsx
src/app/organisms/room/message/MessageEditor.tsx [new file with mode: 0644]
src/app/organisms/room/message/Reactions.tsx
src/app/utils/dom.ts
src/app/utils/markdown.ts
src/app/utils/matrix.ts
src/app/utils/room.ts
src/app/utils/sanitize.ts

index 6213a1df7717bccfc37e04cf8549e92d364f2cfe..70c90a9aff0bca2e417ead8ca89d51473340b235 100644 (file)
@@ -23,6 +23,7 @@
         "classnames": "2.3.2",
         "dateformat": "5.0.3",
         "dayjs": "1.11.10",
+        "domhandler": "5.0.3",
         "emojibase": "6.1.0",
         "emojibase-data": "7.0.1",
         "file-saver": "2.0.5",
@@ -30,6 +31,7 @@
         "focus-trap-react": "10.0.2",
         "folds": "1.5.0",
         "formik": "2.2.9",
+        "html-dom-parser": "4.0.0",
         "html-react-parser": "4.2.0",
         "immer": "9.0.16",
         "is-hotkey": "0.2.0",
index 8ee5cc5480b24afa3d825d59e7724e65df6fa7d4..7467126e451c6e45e3dcbed449d841426ebad795 100644 (file)
@@ -33,6 +33,7 @@
     "classnames": "2.3.2",
     "dateformat": "5.0.3",
     "dayjs": "1.11.10",
+    "domhandler": "5.0.3",
     "emojibase": "6.1.0",
     "emojibase-data": "7.0.1",
     "file-saver": "2.0.5",
@@ -40,6 +41,7 @@
     "focus-trap-react": "10.0.2",
     "folds": "1.5.0",
     "formik": "2.2.9",
+    "html-dom-parser": "4.0.0",
     "html-react-parser": "4.2.0",
     "immer": "9.0.16",
     "is-hotkey": "0.2.0",
index e5377f2fc5ba72824388c06936228d89e4ee0962..62b4134592ddda30b4a45eddc63b40492e1347b8 100644 (file)
@@ -50,12 +50,13 @@ const withVoid = (editor: Editor): Editor => {
 };
 
 export const useEditor = (): Editor => {
-  const [editor] = useState(withInline(withVoid(withReact(withHistory(createEditor())))));
+  const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
   return editor;
 };
 
 export type EditorChangeHandler = (value: Descendant[]) => void;
 type CustomEditorProps = {
+  editableName?: string;
   top?: ReactNode;
   bottom?: ReactNode;
   before?: ReactNode;
@@ -71,6 +72,7 @@ type CustomEditorProps = {
 export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
   (
     {
+      editableName,
       top,
       bottom,
       before,
@@ -137,6 +139,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
               hideTrack
             >
               <Editable
+                data-editable-name={editableName}
                 className={css.EditorTextarea}
                 placeholder={placeholder}
                 renderPlaceholder={renderPlaceholder}
index 198840973ddf2acedaca8bbe20b36a5f6ed6a934..68717b38e4d0b5d2d16059b3c8675ffdcdeebe71 100644 (file)
@@ -221,3 +221,12 @@ export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
   });
   return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
 };
+
+export const isEmptyEditor = (editor: Editor): boolean => {
+  const firstChildren = editor.children[0];
+  if (firstChildren && Element.isElement(firstChildren)) {
+    const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
+    return isEmpty;
+  }
+  return false;
+};
index 76ccf5624ae0fc2e112bc95d677a3b9a97e29649..7c63ce61d8a3f7af264ff1a8a58078c269dfde5f 100644 (file)
@@ -5,3 +5,4 @@ export * from './Elements';
 export * from './keyboard';
 export * from './output';
 export * from './Toolbar';
+export * from './input';
diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts
new file mode 100644 (file)
index 0000000..39db0e1
--- /dev/null
@@ -0,0 +1,327 @@
+/* eslint-disable no-param-reassign */
+import { Descendant, Text } from 'slate';
+import parse from 'html-dom-parser';
+import { ChildNode, Element, isText, isTag } from 'domhandler';
+
+import { sanitizeCustomHtml } from '../../utils/sanitize';
+import { BlockType, MarkType } from './Elements';
+import {
+  BlockQuoteElement,
+  CodeBlockElement,
+  CodeLineElement,
+  EmoticonElement,
+  HeadingElement,
+  HeadingLevel,
+  InlineElement,
+  ListItemElement,
+  MentionElement,
+  OrderedListElement,
+  ParagraphElement,
+  QuoteLineElement,
+  UnorderedListElement,
+} from './slate';
+import { parseMatrixToUrl } from '../../utils/matrix';
+import { createEmoticonElement, createMentionElement } from './common';
+
+const markNodeToType: Record<string, MarkType> = {
+  b: MarkType.Bold,
+  strong: MarkType.Bold,
+  i: MarkType.Italic,
+  em: MarkType.Italic,
+  u: MarkType.Underline,
+  s: MarkType.StrikeThrough,
+  del: MarkType.StrikeThrough,
+  code: MarkType.Code,
+  span: MarkType.Spoiler,
+};
+
+const elementToTextMark = (node: Element): MarkType | undefined => {
+  const markType = markNodeToType[node.name];
+  if (!markType) return undefined;
+
+  if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
+    return undefined;
+  }
+  if (
+    markType === MarkType.Code &&
+    node.parent &&
+    'name' in node.parent &&
+    node.parent.name === 'pre'
+  ) {
+    return undefined;
+  }
+  return markType;
+};
+
+const parseNodeText = (node: ChildNode): string => {
+  if (isText(node)) {
+    return node.data;
+  }
+  if (isTag(node)) {
+    return node.children.map((child) => parseNodeText(child)).join('');
+  }
+  return '';
+};
+
+const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
+  if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
+    const { src, alt } = node.attribs;
+    if (!src) return undefined;
+    return createEmoticonElement(src, alt || 'Unknown Emoji');
+  }
+  if (node.name === 'a') {
+    const { href } = node.attribs;
+    if (typeof href !== 'string') return undefined;
+    const [mxId] = parseMatrixToUrl(href);
+    if (mxId) {
+      return createMentionElement(mxId, mxId, false);
+    }
+  }
+  return undefined;
+};
+
+const parseInlineNodes = (node: ChildNode): InlineElement[] => {
+  if (isText(node)) {
+    return [{ text: node.data }];
+  }
+  if (isTag(node)) {
+    const markType = elementToTextMark(node);
+    if (markType) {
+      const children = node.children.flatMap(parseInlineNodes);
+      if (node.attribs['data-md'] !== undefined) {
+        children.unshift({ text: node.attribs['data-md'] });
+        children.push({ text: node.attribs['data-md'] });
+      } else {
+        children.forEach((child) => {
+          if (Text.isText(child)) {
+            child[markType] = true;
+          }
+        });
+      }
+      return children;
+    }
+
+    const inlineNode = elementToInlineNode(node);
+    if (inlineNode) return [inlineNode];
+
+    if (node.name === 'a') {
+      const children = node.childNodes.flatMap(parseInlineNodes);
+      children.unshift({ text: '[' });
+      children.push({ text: `](${node.attribs.href})` });
+      return children;
+    }
+
+    return node.childNodes.flatMap(parseInlineNodes);
+  }
+
+  return [];
+};
+
+const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
+  const children: QuoteLineElement[] = [];
+  let lineHolder: InlineElement[] = [];
+
+  const appendLine = () => {
+    if (lineHolder.length === 0) return;
+
+    children.push({
+      type: BlockType.QuoteLine,
+      children: lineHolder,
+    });
+    lineHolder = [];
+  };
+
+  node.children.forEach((child) => {
+    if (isText(child)) {
+      lineHolder.push({ text: child.data });
+      return;
+    }
+    if (isTag(child)) {
+      if (child.name === 'br') {
+        appendLine();
+        return;
+      }
+
+      if (child.name === 'p') {
+        appendLine();
+        children.push({
+          type: BlockType.QuoteLine,
+          children: child.children.flatMap((c) => parseInlineNodes(c)),
+        });
+        return;
+      }
+
+      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+    }
+  });
+  appendLine();
+
+  return {
+    type: BlockType.BlockQuote,
+    children,
+  };
+};
+const parseCodeBlockNode = (node: Element): CodeBlockElement => {
+  const children: CodeLineElement[] = [];
+
+  const code = parseNodeText(node).trim();
+  code.split('\n').forEach((lineTxt) =>
+    children.push({
+      type: BlockType.CodeLine,
+      children: [
+        {
+          text: lineTxt,
+        },
+      ],
+    })
+  );
+
+  return {
+    type: BlockType.CodeBlock,
+    children,
+  };
+};
+const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => {
+  const children: ListItemElement[] = [];
+  let lineHolder: InlineElement[] = [];
+
+  const appendLine = () => {
+    if (lineHolder.length === 0) return;
+
+    children.push({
+      type: BlockType.ListItem,
+      children: lineHolder,
+    });
+    lineHolder = [];
+  };
+
+  node.children.forEach((child) => {
+    if (isText(child)) {
+      lineHolder.push({ text: child.data });
+      return;
+    }
+    if (isTag(child)) {
+      if (child.name === 'br') {
+        appendLine();
+        return;
+      }
+
+      if (child.name === 'li') {
+        appendLine();
+        children.push({
+          type: BlockType.ListItem,
+          children: child.children.flatMap((c) => parseInlineNodes(c)),
+        });
+        return;
+      }
+
+      parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
+    }
+  });
+  appendLine();
+
+  return {
+    type: node.name === 'ol' ? BlockType.OrderedList : BlockType.UnorderedList,
+    children,
+  };
+};
+const parseHeadingNode = (node: Element): HeadingElement => {
+  const children = node.children.flatMap((child) => parseInlineNodes(child));
+
+  const headingMatch = node.name.match(/^h([123456])$/);
+  const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
+  const level = parseInt(g1AsLevel, 10);
+  return {
+    type: BlockType.Heading,
+    level: (level <= 3 ? level : 3) as HeadingLevel,
+    children,
+  };
+};
+
+export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
+  const children: Descendant[] = [];
+
+  let lineHolder: InlineElement[] = [];
+
+  const appendLine = () => {
+    if (lineHolder.length === 0) return;
+
+    children.push({
+      type: BlockType.Paragraph,
+      children: lineHolder,
+    });
+    lineHolder = [];
+  };
+
+  domNodes.forEach((node) => {
+    if (isText(node)) {
+      lineHolder.push({ text: node.data });
+      return;
+    }
+    if (isTag(node)) {
+      if (node.name === 'br') {
+        appendLine();
+        return;
+      }
+
+      if (node.name === 'p') {
+        appendLine();
+        children.push({
+          type: BlockType.Paragraph,
+          children: node.children.flatMap((child) => parseInlineNodes(child)),
+        });
+        return;
+      }
+
+      if (node.name === 'blockquote') {
+        appendLine();
+        children.push(parseBlockquoteNode(node));
+        return;
+      }
+      if (node.name === 'pre') {
+        appendLine();
+        children.push(parseCodeBlockNode(node));
+        return;
+      }
+      if (node.name === 'ol' || node.name === 'ul') {
+        appendLine();
+        children.push(parseListNode(node));
+        return;
+      }
+
+      if (node.name.match(/^h[123456]$/)) {
+        appendLine();
+        children.push(parseHeadingNode(node));
+        return;
+      }
+
+      parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
+    }
+  });
+  appendLine();
+
+  return children;
+};
+
+export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
+  const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
+
+  const domNodes = parse(sanitizedHtml);
+  const editorNodes = domToEditorInput(domNodes);
+  return editorNodes;
+};
+
+export const plainToEditorInput = (text: string): Descendant[] => {
+  const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
+    const paragraphNode: ParagraphElement = {
+      type: BlockType.Paragraph,
+      children: [
+        {
+          text: lineText,
+        },
+      ],
+    };
+    return paragraphNode;
+  });
+  return editorNodes;
+};
index 92c86dd86b07fbd046c3e399f9bf4f803c802b39..89a5f7c58392cc04fd0f018c4b39caaf3411de8f 100644 (file)
@@ -1,7 +1,8 @@
 import { Descendant, Text } from 'slate';
+
 import { sanitizeText } from '../../utils/sanitize';
 import { BlockType } from './Elements';
-import { CustomElement, FormattedText } from './slate';
+import { CustomElement } from './slate';
 import { parseInlineMD } from '../../utils/markdown';
 
 export type OutputOptions = {
@@ -9,13 +10,13 @@ export type OutputOptions = {
   allowMarkdown?: boolean;
 };
 
-const textToCustomHtml = (node: FormattedText, opts: OutputOptions): string => {
+const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
   let string = sanitizeText(node.text);
   if (opts.allowTextFormatting) {
     if (node.bold) string = `<strong>${string}</strong>`;
     if (node.italic) string = `<i>${string}</i>`;
     if (node.underline) string = `<u>${string}</u>`;
-    if (node.strikeThrough) string = `<s>${string}</s>`;
+    if (node.strikeThrough) string = `<del>${string}</del>`;
     if (node.code) string = `<code>${string}</code>`;
     if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
   }
@@ -47,6 +48,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
       return `<ol>${children}</ol>`;
     case BlockType.UnorderedList:
       return `<ul>${children}</ul>`;
+
     case BlockType.Mention:
       return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
     case BlockType.Emoticon:
index 74b20708527808673ef6ae1b489b36acf576ca26..ee046a08240f2837ec2806fb4c75d702d40d204f 100644 (file)
@@ -23,13 +23,9 @@ export type FormattedText = Text & {
 export type LinkElement = {
   type: BlockType.Link;
   href: string;
-  children: FormattedText[];
-};
-export type SpoilerElement = {
-  type: 'spoiler';
-  alert?: string;
-  children: FormattedText[];
+  children: Text[];
 };
+
 export type MentionElement = {
   type: BlockType.Mention;
   id: string;
@@ -44,14 +40,16 @@ export type EmoticonElement = {
   children: Text[];
 };
 
+export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement;
+
 export type ParagraphElement = {
   type: BlockType.Paragraph;
-  children: FormattedText[];
+  children: InlineElement[];
 };
 export type HeadingElement = {
   type: BlockType.Heading;
   level: HeadingLevel;
-  children: FormattedText[];
+  children: InlineElement[];
 };
 export type CodeLineElement = {
   type: BlockType.CodeLine;
@@ -63,7 +61,7 @@ export type CodeBlockElement = {
 };
 export type QuoteLineElement = {
   type: BlockType.QuoteLine;
-  children: FormattedText[];
+  children: InlineElement[];
 };
 export type BlockQuoteElement = {
   type: BlockType.BlockQuote;
@@ -71,7 +69,7 @@ export type BlockQuoteElement = {
 };
 export type ListItemElement = {
   type: BlockType.ListItem;
-  children: FormattedText[];
+  children: InlineElement[];
 };
 export type OrderedListElement = {
   type: BlockType.OrderedList;
@@ -84,7 +82,6 @@ export type UnorderedListElement = {
 
 export type CustomElement =
   | LinkElement
-  // | SpoilerElement
   | MentionElement
   | EmoticonElement
   | ParagraphElement
index 7564d5f4402e7aa53512cc58306ca9e42ff924d1..acb45b327d90fe8f63a46a9cf034a736698f3a1c 100644 (file)
@@ -12,7 +12,7 @@ import { useAtom } from 'jotai';
 import isHotkey from 'is-hotkey';
 import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
 import { ReactEditor } from 'slate-react';
-import { Transforms, Range, Editor, Element } from 'slate';
+import { Transforms, Range, Editor } from 'slate';
 import {
   Box,
   Dialog,
@@ -51,6 +51,7 @@ import {
   resetEditorHistory,
   customHtmlEqualsPlainText,
   trimCustomHtml,
+  isEmptyEditor,
 } from '../../components/editor';
 import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
 import { UseStateProvider } from '../../components/UseStateProvider';
@@ -95,7 +96,12 @@ import navigation from '../../../client/state/navigation';
 import cons from '../../../client/state/cons';
 import { MessageReply } from '../../molecules/message/Message';
 import colorMXID from '../../../util/colorMXID';
-import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room';
+import {
+  parseReplyBody,
+  parseReplyFormattedBody,
+  trimReplyFromBody,
+  trimReplyFromFormattedBody,
+} from '../../utils/room';
 import { sanitizeText } from '../../utils/sanitize';
 import { useScreenSize } from '../../hooks/useScreenSize';
 
@@ -264,13 +270,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       let body = plainText;
       let formattedBody = customHtml;
       if (replyDraft) {
-        body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body;
+        body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
         formattedBody =
           parseReplyFormattedBody(
             roomId,
             replyDraft.userId,
             replyDraft.eventId,
-            replyDraft.formattedBody ?? sanitizeText(replyDraft.body)
+            replyDraft.formattedBody
+              ? trimReplyFromFormattedBody(replyDraft.formattedBody)
+              : sanitizeText(replyDraft.body)
           ) + formattedBody;
       }
 
@@ -321,19 +329,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       [submit, editor, setReplyDraft]
     );
 
-    const handleKeyUp: KeyboardEventHandler = useCallback(() => {
-      const firstChildren = editor.children[0];
-      if (firstChildren && Element.isElement(firstChildren)) {
-        const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
-        sendTypingStatus(!isEmpty);
-      }
+    const handleKeyUp: KeyboardEventHandler = useCallback(
+      (evt) => {
+        if (isHotkey('escape', evt)) {
+          evt.preventDefault();
+          return;
+        }
+
+        sendTypingStatus(!isEmptyEditor(editor));
+
+        const prevWordRange = getPrevWorldRange(editor);
+        const query = prevWordRange
+          ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+          : undefined;
+        setAutocompleteQuery(query);
+      },
+      [editor, sendTypingStatus]
+    );
 
-      const prevWordRange = getPrevWorldRange(editor);
-      const query = prevWordRange
-        ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
-        : undefined;
-      setAutocompleteQuery(query);
-    }, [editor, sendTypingStatus]);
+    const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
 
     const handleEmoticonSelect = (key: string, shortcode: string) => {
       editor.insertNode(createEmoticonElement(key, shortcode));
@@ -419,7 +433,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
             roomId={roomId}
             editor={editor}
             query={autocompleteQuery}
-            requestClose={() => setAutocompleteQuery(undefined)}
+            requestClose={handleCloseAutocomplete}
           />
         )}
         {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
@@ -427,7 +441,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
             roomId={roomId}
             editor={editor}
             query={autocompleteQuery}
-            requestClose={() => setAutocompleteQuery(undefined)}
+            requestClose={handleCloseAutocomplete}
           />
         )}
         {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
@@ -435,10 +449,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
             imagePackRooms={imagePackRooms}
             editor={editor}
             query={autocompleteQuery}
-            requestClose={() => setAutocompleteQuery(undefined)}
+            requestClose={handleCloseAutocomplete}
           />
         )}
         <CustomEditor
+          editableName="RoomInput"
           editor={editor}
           placeholder="Send a message..."
           onKeyDown={handleKeyDown}
index 03744c33df111a54482cdd5794ad3ef3dfaa45fc..65ea9ac5a64be6de55ff091cfc9dd9cb4735660f 100644 (file)
@@ -15,11 +15,9 @@ import {
   EventTimeline,
   EventTimelineSet,
   EventTimelineSetHandlerMap,
-  EventType,
   IEncryptedFile,
   MatrixClient,
   MatrixEvent,
-  RelationType,
   Room,
   RoomEvent,
   RoomEventHandlerMap,
@@ -45,6 +43,7 @@ import {
   config,
   toRem,
 } from 'folds';
+import isHotkey from 'is-hotkey';
 import Linkify from 'linkify-react';
 import {
   decryptFile,
@@ -53,13 +52,12 @@ import {
   getMxIdLocalPart,
   isRoomId,
   isUserId,
-  matrixEventByRecency,
 } from '../../utils/matrix';
 import { sanitizeCustomHtml } from '../../utils/sanitize';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
 import { useAlive } from '../../hooks/useAlive';
-import { scrollToBottom } from '../../utils/dom';
+import { editableActiveElement, scrollToBottom } from '../../utils/dom';
 import {
   DefaultPlaceholder,
   CompactPlaceholder,
@@ -80,7 +78,11 @@ import {
 } from '../../components/message';
 import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
 import {
+  canEditEvent,
   decryptAllTimelineEvent,
+  getEditedEvent,
+  getEventReactions,
+  getLatestEditableEvt,
   getMemberDisplayName,
   getReactionContent,
   isMembershipChanged,
@@ -124,11 +126,12 @@ import { useDebounce } from '../../hooks/useDebounce';
 import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
 import * as css from './RoomTimeline.css';
 import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
-import { createMentionElement, moveCursor } from '../../components/editor';
+import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
 import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
 import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
 import { MessageEvent } from '../../../types/matrix/room';
 import initMatrix from '../../../client/initMatrix';
+import { useKeyDown } from '../../hooks/useKeyDown';
 
 const TimelineFloat = as<'div', css.TimelineFloatVariants>(
   ({ position, className, ...props }, ref) => (
@@ -226,34 +229,6 @@ export const getEventIdAbsoluteIndex = (
   return baseIndex + eventIndex;
 };
 
-export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
-  timelineSet.relations.getChildEventsForEvent(
-    eventId,
-    RelationType.Annotation,
-    EventType.Reaction
-  );
-
-export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
-  timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
-
-export const getLatestEdit = (
-  targetEvent: MatrixEvent,
-  editEvents: MatrixEvent[]
-): MatrixEvent | undefined => {
-  const eventByTargetSender = (rEvent: MatrixEvent) =>
-    rEvent.getSender() === targetEvent.getSender();
-  return editEvents.sort(matrixEventByRecency).find(eventByTargetSender);
-};
-
-export const getEditedEvent = (
-  mEventId: string,
-  mEvent: MatrixEvent,
-  timelineSet: EventTimelineSet
-): MatrixEvent | undefined => {
-  const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
-  return edits && getLatestEdit(mEvent, edits.getRelations());
-};
-
 export const factoryGetFileSrcUrl =
   (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
     if (encFile) {
@@ -483,6 +458,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
   const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
   const canRedact = canDoAction('redact', myPowerLevel);
   const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
+  const [editId, setEditId] = useState<string>();
 
   const imagePackRooms: Room[] = useMemo(() => {
     const allParentSpaces = [
@@ -572,20 +548,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
 
   const getScrollElement = useCallback(() => scrollRef.current, []);
 
-  const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
-    count: eventsLength,
-    limit: PAGINATION_LIMIT,
-    range: timeline.range,
-    onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
-    getScrollElement,
-    getItemElement: useCallback(
-      (index: number) =>
-        (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
-        undefined,
-      []
-    ),
-    onEnd: handleTimelinePagination,
-  });
+  const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
+    useVirtualPaginator({
+      count: eventsLength,
+      limit: PAGINATION_LIMIT,
+      range: timeline.range,
+      onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
+      getScrollElement,
+      getItemElement: useCallback(
+        (index: number) =>
+          (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
+          undefined,
+        []
+      ),
+      onEnd: handleTimelinePagination,
+    });
 
   const loadEventTimeline = useEventTimelineLoader(
     mx,
@@ -701,6 +678,29 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
     useCallback(() => atBottomAnchorRef.current, [])
   );
 
+  // Handle up arrow edit
+  useKeyDown(
+    window,
+    useCallback(
+      (evt) => {
+        if (
+          isHotkey('arrowup', evt) &&
+          editableActiveElement() &&
+          document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
+          isEmptyEditor(editor)
+        ) {
+          const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
+            canEditEvent(mx, mEvt)
+          );
+          const editableEvtId = editableEvt?.getId();
+          if (!editableEvtId) return;
+          setEditId(editableEvtId);
+        }
+      },
+      [mx, room, editor]
+    )
+  );
+
   useEffect(() => {
     if (eventId) {
       setTimeline(getEmptyTimeline());
@@ -771,6 +771,22 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
     }
   }, [room, unreadInfo, liveTimelineLinked, rangeAtEnd, atBottom]);
 
+  // scroll out of view msg editor in view.
+  useEffect(() => {
+    if (editId) {
+      const editMsgElement =
+        (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
+        undefined;
+      if (editMsgElement) {
+        scrollToElement(editMsgElement, {
+          align: 'center',
+          behavior: 'smooth',
+          stopInView: true,
+        });
+      }
+    }
+  }, [scrollToElement, editId]);
+
   const handleJumpToLatest = () => {
     setTimeline(getInitialTimeline(room));
     scrollToBottomRef.current.count += 1;
@@ -901,6 +917,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
     },
     [mx, room]
   );
+  const handleEdit = useCallback(
+    (editEvtId?: string) => {
+      if (editEvtId) {
+        setEditId(editEvtId);
+        return;
+      }
+      setEditId(undefined);
+      ReactEditor.focus(editor);
+    },
+    [editor]
+  );
 
   const renderBody = (body: string, customBody?: string) => {
     if (body === '') <MessageEmptyContent />;
@@ -1153,12 +1180,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Message
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           messageSpacing={messageSpacing}
           messageLayout={messageLayout}
           collapse={collapse}
           highlight={highlighted}
+          edit={editId === mEventId}
           canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
           canSendReaction={canSendReaction}
           imagePackRooms={imagePackRooms}
@@ -1167,6 +1196,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
           onUsernameClick={handleUsernameClick}
           onReplyClick={handleReplyClick}
           onReactionToggle={handleReactionToggle}
+          onEditId={handleEdit}
           reply={
             replyEventId && (
               <Reply
@@ -1208,12 +1238,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Message
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           messageSpacing={messageSpacing}
           messageLayout={messageLayout}
           collapse={collapse}
           highlight={highlighted}
+          edit={editId === mEventId}
           canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
           canSendReaction={canSendReaction}
           imagePackRooms={imagePackRooms}
@@ -1222,6 +1254,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
           onUsernameClick={handleUsernameClick}
           onReplyClick={handleReplyClick}
           onReactionToggle={handleReactionToggle}
+          onEditId={handleEdit}
           reply={
             replyEventId && (
               <Reply
@@ -1280,6 +1313,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Message
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           messageSpacing={messageSpacing}
@@ -1325,6 +1359,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Event
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           highlight={highlighted}
@@ -1357,6 +1392,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Event
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           highlight={highlighted}
@@ -1390,6 +1426,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Event
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           highlight={highlighted}
@@ -1423,6 +1460,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Event
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           highlight={highlighted}
@@ -1457,6 +1495,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Event
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           highlight={highlighted}
@@ -1497,6 +1536,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
         <Event
           key={mEvent.getId()}
           data-message-item={item}
+          data-message-id={mEventId}
           room={room}
           mEvent={mEvent}
           highlight={highlighted}
index 13e43260d6f7d6b213ccc4de739ebee69207ea6c..4d18de22a5183127a8c1ca7ff14854b77e85b6ce 100644 (file)
@@ -45,7 +45,12 @@ import {
   Username,
 } from '../../../components/message';
 import colorMXID from '../../../../util/colorMXID';
-import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
+import {
+  canEditEvent,
+  getEventEdits,
+  getMemberAvatarMxc,
+  getMemberDisplayName,
+} from '../../../utils/room';
 import { getMxIdLocalPart } from '../../../utils/matrix';
 import { MessageLayout, MessageSpacing } from '../../../state/settings';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -56,6 +61,7 @@ import { TextViewer } from '../../../components/text-viewer';
 import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 import { EmojiBoard } from '../../../components/emoji-board';
 import { ReactionViewer } from '../reaction-viewer';
+import { MessageEditor } from './MessageEditor';
 
 export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
 
@@ -211,21 +217,40 @@ export const MessageReadReceiptItem = as<
 export const MessageSourceCodeItem = as<
   'button',
   {
+    room: Room;
     mEvent: MatrixEvent;
     onClose?: () => void;
   }
->(({ mEvent, onClose, ...props }, ref) => {
+>(({ room, mEvent, onClose, ...props }, ref) => {
   const [open, setOpen] = useState(false);
-  const text = JSON.stringify(
-    mEvent.isEncrypted()
+
+  const getContent = (evt: MatrixEvent) =>
+    evt.isEncrypted()
       ? {
-          [`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(),
-          [`<== ORIGINAL_EVENT ==>`]: mEvent.event,
+          [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
+          [`<== ORIGINAL_EVENT ==>`]: evt.event,
         }
-      : mEvent.event,
-    null,
-    2
-  );
+      : evt.event;
+
+  const getText = (): string => {
+    const evtId = mEvent.getId()!;
+    const evtTimeline = room.getTimelineForEvent(evtId);
+    const edits =
+      evtTimeline &&
+      getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
+
+    if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
+
+    const content: Record<string, unknown> = {
+      '<== MAIN_EVENT ==>': getContent(mEvent),
+    };
+
+    edits.forEach((editEvt, index) => {
+      content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
+    });
+
+    return JSON.stringify(content, null, 2);
+  };
 
   const handleClose = () => {
     setOpen(false);
@@ -247,7 +272,7 @@ export const MessageSourceCodeItem = as<
               <TextViewer
                 name="Source Code"
                 langName="json"
-                text={text}
+                text={getText()}
                 requestClose={handleClose}
               />
             </Modal>
@@ -537,6 +562,7 @@ export type MessageProps = {
   mEvent: MatrixEvent;
   collapse: boolean;
   highlight: boolean;
+  edit?: boolean;
   canDelete?: boolean;
   canSendReaction?: boolean;
   imagePackRooms?: Room[];
@@ -546,6 +572,7 @@ export type MessageProps = {
   onUserClick: MouseEventHandler<HTMLButtonElement>;
   onUsernameClick: MouseEventHandler<HTMLButtonElement>;
   onReplyClick: MouseEventHandler<HTMLButtonElement>;
+  onEditId?: (eventId?: string) => void;
   onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
   reply?: ReactNode;
   reactions?: ReactNode;
@@ -558,6 +585,7 @@ export const Message = as<'div', MessageProps>(
       mEvent,
       collapse,
       highlight,
+      edit,
       canDelete,
       canSendReaction,
       imagePackRooms,
@@ -568,6 +596,7 @@ export const Message = as<'div', MessageProps>(
       onUsernameClick,
       onReplyClick,
       onReactionToggle,
+      onEditId,
       reply,
       reactions,
       children,
@@ -644,7 +673,21 @@ export const Message = as<'div', MessageProps>(
     const msgContentJSX = (
       <Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
         {reply}
-        {children}
+        {edit && onEditId ? (
+          <MessageEditor
+            style={{
+              maxWidth: '100%',
+              width: '100vw',
+            }}
+            roomId={room.roomId}
+            room={room}
+            mEvent={mEvent}
+            imagePackRooms={imagePackRooms}
+            onCancel={() => onEditId()}
+          />
+        ) : (
+          children
+        )}
         {reactions}
       </Box>
     );
@@ -677,7 +720,7 @@ export const Message = as<'div', MessageProps>(
         onMouseLeave={hideOptions}
         ref={ref}
       >
-        {(hover || menu || emojiBoard) && (
+        {!edit && (hover || menu || emojiBoard) && (
           <div className={css.MessageOptionsBase}>
             <Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
               <Box gap="100">
@@ -728,6 +771,16 @@ export const Message = as<'div', MessageProps>(
                 >
                   <Icon src={Icons.ReplyArrow} size="100" />
                 </IconButton>
+                {canEditEvent(mx, mEvent) && onEditId && (
+                  <IconButton
+                    onClick={() => onEditId(mEvent.getId())}
+                    variant="SurfaceVariant"
+                    size="300"
+                    radii="300"
+                  >
+                    <Icon src={Icons.Pencil} size="100" />
+                  </IconButton>
+                )}
                 <PopOut
                   open={menu}
                   alignOffset={-5}
@@ -801,12 +854,33 @@ export const Message = as<'div', MessageProps>(
                               Reply
                             </Text>
                           </MenuItem>
+                          {canEditEvent(mx, mEvent) && onEditId && (
+                            <MenuItem
+                              size="300"
+                              after={<Icon size="100" src={Icons.Pencil} />}
+                              radii="300"
+                              data-event-id={mEvent.getId()}
+                              onClick={() => {
+                                onEditId(mEvent.getId());
+                                closeMenu();
+                              }}
+                            >
+                              <Text
+                                className={css.MessageMenuItemText}
+                                as="span"
+                                size="T300"
+                                truncate
+                              >
+                                Edit Message
+                              </Text>
+                            </MenuItem>
+                          )}
                           <MessageReadReceiptItem
                             room={room}
                             eventId={mEvent.getId() ?? ''}
                             onClose={closeMenu}
                           />
-                          <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
                         </Box>
                         {((!mEvent.isRedacted() && canDelete) ||
                           mEvent.getSender() !== mx.getUserId()) && (
@@ -941,7 +1015,7 @@ export const Event = as<'div', EventProps>(
                             eventId={mEvent.getId() ?? ''}
                             onClose={closeMenu}
                           />
-                          <MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
+                          <MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
                         </Box>
                         {((!mEvent.isRedacted() && canDelete && !stateEvent) ||
                           (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
diff --git a/src/app/organisms/room/message/MessageEditor.tsx b/src/app/organisms/room/message/MessageEditor.tsx
new file mode 100644 (file)
index 0000000..9035747
--- /dev/null
@@ -0,0 +1,295 @@
+import React, { KeyboardEventHandler, useCallback, useEffect, useState } from 'react';
+import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, Spinner, Text, as, config } from 'folds';
+import { Editor, Transforms } from 'slate';
+import { ReactEditor } from 'slate-react';
+import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
+import isHotkey from 'is-hotkey';
+import {
+  AUTOCOMPLETE_PREFIXES,
+  AutocompletePrefix,
+  AutocompleteQuery,
+  CustomEditor,
+  EmoticonAutocomplete,
+  RoomMentionAutocomplete,
+  Toolbar,
+  UserMentionAutocomplete,
+  createEmoticonElement,
+  customHtmlEqualsPlainText,
+  getAutocompleteQuery,
+  getPrevWorldRange,
+  htmlToEditorInput,
+  moveCursor,
+  plainToEditorInput,
+  toMatrixCustomHTML,
+  toPlainText,
+  trimCustomHtml,
+  useEditor,
+} from '../../../components/editor';
+import { useSetting } from '../../../state/hooks/settings';
+import { settingsAtom } from '../../../state/settings';
+import { UseStateProvider } from '../../../components/UseStateProvider';
+import { EmojiBoard } from '../../../components/emoji-board';
+import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
+import { useMatrixClient } from '../../../hooks/useMatrixClient';
+import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
+
+type MessageEditorProps = {
+  roomId: string;
+  room: Room;
+  mEvent: MatrixEvent;
+  imagePackRooms?: Room[];
+  onCancel: () => void;
+};
+export const MessageEditor = as<'div', MessageEditorProps>(
+  ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
+    const mx = useMatrixClient();
+    const editor = useEditor();
+    const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
+    const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+    const [toolbar, setToolbar] = useState(globalToolbar);
+
+    const [autocompleteQuery, setAutocompleteQuery] =
+      useState<AutocompleteQuery<AutocompletePrefix>>();
+
+    const getPrevBodyAndFormattedBody = useCallback(() => {
+      const evtId = mEvent.getId()!;
+      const evtTimeline = room.getTimelineForEvent(evtId);
+      const editedEvent =
+        evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
+
+      const { body, formatted_body: customHtml }: Record<string, unknown> =
+        editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
+
+      return [body, customHtml];
+    }, [room, mEvent]);
+
+    const [saveState, save] = useAsyncCallback(
+      useCallback(async () => {
+        const plainText = toPlainText(editor.children).trim();
+        const customHtml = trimCustomHtml(
+          toMatrixCustomHTML(editor.children, {
+            allowTextFormatting: true,
+            allowMarkdown: isMarkdown,
+          })
+        );
+
+        const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
+
+        if (plainText === '') return undefined;
+        if (
+          typeof prevCustomHtml === 'string' &&
+          trimReplyFromFormattedBody(prevCustomHtml) === customHtml
+        ) {
+          return undefined;
+        }
+        if (!prevCustomHtml && typeof prevBody === 'string' && prevBody === plainText) {
+          return undefined;
+        }
+
+        const newContent: IContent = {
+          msgtype: mEvent.getContent().msgtype,
+          body: plainText,
+        };
+
+        if (!customHtmlEqualsPlainText(customHtml, plainText)) {
+          newContent.format = 'org.matrix.custom.html';
+          newContent.formatted_body = customHtml;
+        }
+
+        const content: IContent = {
+          ...newContent,
+          body: `* ${plainText}`,
+          'm.new_content': newContent,
+          'm.relates_to': {
+            event_id: mEvent.getId(),
+            rel_type: RelationType.Replace,
+          },
+        };
+
+        return mx.sendMessage(roomId, content);
+      }, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
+    );
+
+    const handleSave = useCallback(() => {
+      if (saveState.status !== AsyncStatus.Loading) {
+        save();
+      }
+    }, [saveState, save]);
+
+    const handleKeyDown: KeyboardEventHandler = useCallback(
+      (evt) => {
+        if (isHotkey('enter', evt)) {
+          evt.preventDefault();
+          handleSave();
+        }
+        if (isHotkey('escape', evt)) {
+          evt.preventDefault();
+          onCancel();
+        }
+      },
+      [onCancel, handleSave]
+    );
+
+    const handleKeyUp: KeyboardEventHandler = useCallback(
+      (evt) => {
+        if (isHotkey('escape', evt)) {
+          evt.preventDefault();
+          return;
+        }
+
+        const prevWordRange = getPrevWorldRange(editor);
+        const query = prevWordRange
+          ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
+          : undefined;
+        setAutocompleteQuery(query);
+      },
+      [editor]
+    );
+
+    const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
+
+    const handleEmoticonSelect = (key: string, shortcode: string) => {
+      editor.insertNode(createEmoticonElement(key, shortcode));
+      moveCursor(editor);
+    };
+
+    useEffect(() => {
+      const [body, customHtml] = getPrevBodyAndFormattedBody();
+
+      const initialValue =
+        typeof customHtml === 'string'
+          ? htmlToEditorInput(customHtml)
+          : plainToEditorInput(typeof body === 'string' ? body : '');
+
+      Transforms.select(editor, {
+        anchor: Editor.start(editor, []),
+        focus: Editor.end(editor, []),
+      });
+
+      editor.insertFragment(initialValue);
+      ReactEditor.focus(editor);
+    }, [editor, getPrevBodyAndFormattedBody]);
+
+    useEffect(() => {
+      if (saveState.status === AsyncStatus.Success) {
+        onCancel();
+      }
+    }, [saveState, onCancel]);
+
+    return (
+      <div {...props} ref={ref}>
+        {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
+          <RoomMentionAutocomplete
+            roomId={roomId}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
+          <UserMentionAutocomplete
+            roomId={roomId}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
+          <EmoticonAutocomplete
+            imagePackRooms={imagePackRooms || []}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
+        <CustomEditor
+          editor={editor}
+          placeholder="Edit message..."
+          onKeyDown={handleKeyDown}
+          onKeyUp={handleKeyUp}
+          bottom={
+            <>
+              <Box
+                style={{ padding: config.space.S200, paddingTop: 0 }}
+                alignItems="End"
+                justifyContent="SpaceBetween"
+                gap="100"
+              >
+                <Box gap="Inherit">
+                  <Chip
+                    onClick={handleSave}
+                    variant="Primary"
+                    radii="Pill"
+                    disabled={saveState.status === AsyncStatus.Loading}
+                    outlined
+                    before={
+                      saveState.status === AsyncStatus.Loading ? (
+                        <Spinner variant="Primary" fill="Soft" size="100" />
+                      ) : undefined
+                    }
+                  >
+                    <Text size="B300">Save</Text>
+                  </Chip>
+                  <Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
+                    <Text size="B300">Cancel</Text>
+                  </Chip>
+                </Box>
+                <Box gap="Inherit">
+                  <IconButton
+                    variant="SurfaceVariant"
+                    size="300"
+                    radii="300"
+                    onClick={() => setToolbar(!toolbar)}
+                  >
+                    <Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
+                  </IconButton>
+                  <UseStateProvider initial={false}>
+                    {(emojiBoard: boolean, setEmojiBoard) => (
+                      <PopOut
+                        alignOffset={-8}
+                        position="Top"
+                        align="End"
+                        open={!!emojiBoard}
+                        content={
+                          <EmojiBoard
+                            imagePackRooms={imagePackRooms ?? []}
+                            returnFocusOnDeactivate={false}
+                            onEmojiSelect={handleEmoticonSelect}
+                            onCustomEmojiSelect={handleEmoticonSelect}
+                            requestClose={() => {
+                              setEmojiBoard(false);
+                              ReactEditor.focus(editor);
+                            }}
+                          />
+                        }
+                      >
+                        {(anchorRef) => (
+                          <IconButton
+                            ref={anchorRef}
+                            aria-pressed={emojiBoard}
+                            onClick={() => setEmojiBoard(true)}
+                            variant="SurfaceVariant"
+                            size="300"
+                            radii="300"
+                          >
+                            <Icon size="400" src={Icons.Smile} filled={emojiBoard} />
+                          </IconButton>
+                        )}
+                      </PopOut>
+                    )}
+                  </UseStateProvider>
+                </Box>
+              </Box>
+              {toolbar && (
+                <div>
+                  <Line variant="SurfaceVariant" size="300" />
+                  <Toolbar />
+                </div>
+              )}
+            </>
+          }
+        />
+      </div>
+    );
+  }
+);
index 354820cdcd535cd71907bdd811139594b773873b..bc32c1a34f049466435e25c39100c2cf9b11f6a0 100644 (file)
@@ -12,7 +12,7 @@ import {
   toRem,
 } from 'folds';
 import classNames from 'classnames';
-import { EventTimelineSet, EventType, RelationType, Room } from 'matrix-js-sdk';
+import { Room } from 'matrix-js-sdk';
 import { type Relations } from 'matrix-js-sdk/lib/models/relations';
 import FocusTrap from 'focus-trap-react';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -22,13 +22,6 @@ import { useRelations } from '../../../hooks/useRelations';
 import * as css from './styles.css';
 import { ReactionViewer } from '../reaction-viewer';
 
-export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
-  timelineSet.relations.getChildEventsForEvent(
-    eventId,
-    RelationType.Annotation,
-    EventType.Reaction
-  );
-
 export type ReactionsProps = {
   room: Room;
   mEventId: string;
index a8dc4be2c8cb3dce0661bd8a91b4d7aff791c9e7..f39fe623a3dd3a13b5725cd44d191f34cf6cbcd8 100644 (file)
@@ -5,7 +5,11 @@ export const targetFromEvent = (evt: Event, selector: string): Element | undefin
 
 export const editableActiveElement = (): boolean =>
   !!document.activeElement &&
-  /^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase());
+  (document.activeElement.nodeName.toLowerCase() === 'input' ||
+    document.activeElement.nodeName.toLowerCase() === 'textbox' ||
+    document.activeElement.getAttribute('contenteditable') === 'true' ||
+    document.activeElement.getAttribute('role') === 'input' ||
+    document.activeElement.getAttribute('role') === 'textbox');
 
 export const isIntersectingScrollView = (
   scrollElement: HTMLElement,
index e4294d7d6ce441ea50508674c9f1f1a9d36d870a..6db7a3495f5156c1e405b2f18088ea5563a78dfe 100644 (file)
@@ -83,7 +83,7 @@ const StrikeRule: MDRule = {
   match: (text) => text.match(STRIKE_REG_1),
   html: (parse, match) => {
     const [, g1] = match;
-    return `<s data-md="${STRIKE_MD_1}">${parse(g1)}</s>`;
+    return `<del data-md="${STRIKE_MD_1}">${parse(g1)}</del>`;
   },
 };
 
index 91bd80f39063ea4a00600872682249988b0dbcc6..ba27879e587fe660ea534179a3c44cbad6c4808c 100644 (file)
@@ -28,6 +28,15 @@ export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith(
 
 export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
 
+export const parseMatrixToUrl = (url: string): [string | undefined, string | undefined] => {
+  const href = decodeURIComponent(url);
+
+  const match = href.match(/^https?:\/\/matrix.to\/#\/([@!$+#]\S+:[^\\?|^\s|^\\/]+)(\?(via=\S+))?/);
+  if (!match) return [undefined, undefined];
+  const [, g1AsMxId, , g3AsVia] = match;
+  return [g1AsMxId, g3AsVia];
+};
+
 export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
   mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
 
index af9505d4029c15cfdc2503ed07c80b801f854c0d..1dabdc0748515910a6f7edace13c6243d02bd742 100644 (file)
@@ -2,17 +2,22 @@ import { IconName, IconSrc } from 'folds';
 
 import {
   EventTimeline,
+  EventTimelineSet,
+  EventType,
   IPushRule,
   IPushRules,
   JoinRule,
   MatrixClient,
   MatrixEvent,
+  MsgType,
   NotificationCountType,
+  RelationType,
   Room,
 } from 'matrix-js-sdk';
 import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
 import { AccountDataEvent } from '../../types/matrix/accountData';
 import {
+  MessageEvent,
   NotificationType,
   RoomToParents,
   RoomType,
@@ -249,6 +254,21 @@ export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefin
   return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
 };
 
+export const trimReplyFromBody = (body: string): string => {
+  const match = body.match(/^>\s<.+?>\s.+\n\n/);
+  if (!match) return body;
+  return body.slice(match[0].length);
+};
+
+export const trimReplyFromFormattedBody = (formattedBody: string): string => {
+  const suffix = '</mx-reply>';
+  const i = formattedBody.lastIndexOf(suffix);
+  if (i < 0) {
+    return formattedBody;
+  }
+  return formattedBody.slice(i + suffix.length);
+};
+
 export const parseReplyBody = (userId: string, body: string) =>
   `> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`;
 
@@ -301,3 +321,52 @@ export const getReactionContent = (eventId: string, key: string, shortcode?: str
   },
   shortcode,
 });
+
+export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
+  timelineSet.relations.getChildEventsForEvent(
+    eventId,
+    RelationType.Annotation,
+    EventType.Reaction
+  );
+
+export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
+  timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
+
+export const getLatestEdit = (
+  targetEvent: MatrixEvent,
+  editEvents: MatrixEvent[]
+): MatrixEvent | undefined => {
+  const eventByTargetSender = (rEvent: MatrixEvent) =>
+    rEvent.getSender() === targetEvent.getSender();
+  return editEvents.sort((m1, m2) => m2.getTs() - m1.getTs()).find(eventByTargetSender);
+};
+
+export const getEditedEvent = (
+  mEventId: string,
+  mEvent: MatrixEvent,
+  timelineSet: EventTimelineSet
+): MatrixEvent | undefined => {
+  const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
+  return edits && getLatestEdit(mEvent, edits.getRelations());
+};
+
+export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) =>
+  mEvent.getSender() === mx.getUserId() &&
+  !mEvent.isRelation() &&
+  mEvent.getType() === MessageEvent.RoomMessage &&
+  (mEvent.getContent().msgtype === MsgType.Text ||
+    mEvent.getContent().msgtype === MsgType.Emote ||
+    mEvent.getContent().msgtype === MsgType.Notice);
+
+export const getLatestEditableEvt = (
+  timeline: EventTimeline,
+  canEdit: (mEvent: MatrixEvent) => boolean
+): MatrixEvent | undefined => {
+  const events = timeline.getEvents();
+
+  for (let i = events.length - 1; i >= 0; i -= 1) {
+    const evt = events[i];
+    if (canEdit(evt)) return evt;
+  }
+  return undefined;
+};
index 6a03ca7d587095750d84d7f22662c69c3b5bd0c7..8e7c1283b02dc58bd0963ae1339c06b834c3e654 100644 (file)
@@ -56,12 +56,19 @@ const permittedTagToAttributes = {
     'data-mx-maths',
     'data-mx-pill',
     'data-mx-ping',
+    'data-md',
   ],
   div: ['data-mx-maths'],
-  a: ['name', 'target', 'href', 'rel'],
+  a: ['name', 'target', 'href', 'rel', 'data-md'],
   img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
   ol: ['start'],
-  code: ['class'],
+  code: ['class', 'data-md'],
+  strong: ['data-md'],
+  i: ['data-md'],
+  em: ['data-md'],
+  u: ['data-md'],
+  s: ['data-md'],
+  del: ['data-md'],
 };
 
 const transformFontTag: Transformer = (tagName, attribs) => ({