mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 14:30:29 +03:00 
			
		
		
		
	Edit option (#1447)
* 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
This commit is contained in:
		
							parent
							
								
									152576e85d
								
							
						
					
					
						commit
						f5bcc9b851
					
				
					 18 changed files with 957 additions and 108 deletions
				
			
		
							
								
								
									
										2
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										2
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,3 +5,4 @@ export * from './Elements';
 | 
			
		|||
export * from './keyboard';
 | 
			
		||||
export * from './output';
 | 
			
		||||
export * from './Toolbar';
 | 
			
		||||
export * from './input';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										327
									
								
								src/app/components/editor/input.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										327
									
								
								src/app/components/editor/input.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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:
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										19
									
								
								src/app/components/editor/slate.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										19
									
								
								src/app/components/editor/slate.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
      const prevWordRange = getPrevWorldRange(editor);
 | 
			
		||||
      const query = prevWordRange
 | 
			
		||||
        ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
 | 
			
		||||
        : undefined;
 | 
			
		||||
      setAutocompleteQuery(query);
 | 
			
		||||
    }, [editor, sendTypingStatus]);
 | 
			
		||||
        sendTypingStatus(!isEmptyEditor(editor));
 | 
			
		||||
 | 
			
		||||
        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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)) && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										295
									
								
								src/app/organisms/room/message/MessageEditor.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										295
									
								
								src/app/organisms/room/message/MessageEditor.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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>`;
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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) => ({
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue