Editor Commands (#1450)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Wed, 18 Oct 2023 02:15:30 +0000 (13:15 +1100)
committerGitHub <noreply@github.com>
Wed, 18 Oct 2023 02:15:30 +0000 (07:45 +0530)
* add commands hook

* add commands in editor

* add command auto complete menu

* add commands in room input

* remove old reply code from room input

* fix video component css

* do not auto focus input on android or ios

* fix crash on enable block after selection

* fix circular deps in editor

* fix autocomplete return focus move editor cursor

* remove unwanted keydown from room input

* fix emoji alignment in editor

* test ipad user agent

* refactor isAndroidOrIOS to mobileOrTablet

* update slate & slate-react

* downgrade slate-react to 0.98.4
0.99.0 has breaking changes with ReactEditor.focus

* add sql to readable ext mimetype

* fix empty editor formatting gets saved as draft

* add option to use enter for newline

* remove empty msg draft from atom family

* prevent msg ctx menu from open on text selection

35 files changed:
package-lock.json
package.json
src/app/components/editor/Editor.css.ts
src/app/components/editor/Editor.tsx
src/app/components/editor/Elements.tsx
src/app/components/editor/Toolbar.tsx
src/app/components/editor/autocomplete/AutocompleteMenu.tsx
src/app/components/editor/autocomplete/EmoticonAutocomplete.tsx
src/app/components/editor/autocomplete/RoomMentionAutocomplete.tsx
src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx
src/app/components/editor/autocomplete/autocompleteQuery.ts
src/app/components/editor/common.ts [deleted file]
src/app/components/editor/index.ts
src/app/components/editor/input.ts
src/app/components/editor/keyboard.ts
src/app/components/editor/output.ts
src/app/components/editor/slate.d.ts
src/app/components/editor/types.ts [new file with mode: 0644]
src/app/components/editor/utils.ts [new file with mode: 0644]
src/app/components/emoji-board/EmojiBoard.tsx
src/app/components/media/Video.tsx
src/app/components/media/media.css.ts
src/app/components/message/Reply.tsx
src/app/hooks/useCommands.ts [new file with mode: 0644]
src/app/organisms/room/CommandAutocomplete.tsx [new file with mode: 0644]
src/app/organisms/room/RoomInput.tsx
src/app/organisms/room/RoomView.jsx
src/app/organisms/room/message/Message.tsx
src/app/organisms/room/message/MessageEditor.tsx
src/app/organisms/settings/Settings.jsx
src/app/state/settings.ts
src/app/styles/CustomHtml.css.ts
src/app/utils/matrix.ts
src/app/utils/mimeTypes.ts
src/app/utils/user-agent.ts

index 70c90a9aff0bca2e417ead8ca89d51473340b235..1ef2fd4fe9a75eab3c0fa6bfc0c15960d2638549 100644 (file)
@@ -56,9 +56,9 @@
         "react-modal": "3.16.1",
         "react-range": "1.8.14",
         "sanitize-html": "2.8.0",
-        "slate": "0.90.0",
+        "slate": "0.94.1",
         "slate-history": "0.93.0",
-        "slate-react": "0.90.0",
+        "slate-react": "0.98.4",
         "tippy.js": "6.3.7",
         "twemoji": "14.0.2",
         "ua-parser-js": "1.0.35"
       }
     },
     "node_modules/slate": {
-      "version": "0.90.0",
-      "resolved": "https://registry.npmjs.org/slate/-/slate-0.90.0.tgz",
-      "integrity": "sha512-dv8idv0JjYyHiAJcVKf5yWKPDMTDi+PSZyfjsnquEI8VB5nmTVGjeJab06lc3o69O7aN05ROwO9/OY8mU1IUPA==",
+      "version": "0.94.1",
+      "resolved": "https://registry.npmjs.org/slate/-/slate-0.94.1.tgz",
+      "integrity": "sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==",
       "dependencies": {
         "immer": "^9.0.6",
         "is-plain-object": "^5.0.0",
       }
     },
     "node_modules/slate-react": {
-      "version": "0.90.0",
-      "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.90.0.tgz",
-      "integrity": "sha512-z6pGd6jjU5VazLxlDi6zL3a6yaPBPJ+A2VyIlE/h/rvDywaLYGvk0xcrA9NrK71Dr47HK5ZN2zFEZNleh6wlPA==",
+      "version": "0.98.4",
+      "resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.98.4.tgz",
+      "integrity": "sha512-8Of3v9hFuX8rIRc86LuuBhU9t8ps+9ARKL4yyhCrKQYZ93Ep/LFA3GvPGvtf3zYuVadZ8tkhRH8tbHOGNAndLw==",
       "dependencies": {
         "@juggle/resize-observer": "^3.4.0",
         "@types/is-hotkey": "^0.1.1",
index 7467126e451c6e45e3dcbed449d841426ebad795..f7bce7cf482037214803d3a30d300c1a595dd7a6 100644 (file)
@@ -66,9 +66,9 @@
     "react-modal": "3.16.1",
     "react-range": "1.8.14",
     "sanitize-html": "2.8.0",
-    "slate": "0.90.0",
+    "slate": "0.94.1",
     "slate-history": "0.93.0",
-    "slate-react": "0.90.0",
+    "slate-react": "0.98.4",
     "tippy.js": "6.3.7",
     "twemoji": "14.0.2",
     "ua-parser-js": "1.0.35"
index 9ec8cfaf80b963a2f90b7a2078fa3d4efbfca88c..edce743f2aca18ed385216af21e5b3245dc0f58c 100644 (file)
@@ -26,7 +26,7 @@ export const EditorTextarea = style([
   {
     flexGrow: 1,
     height: '100%',
-    padding: `${toRem(13)} 0`,
+    padding: `${toRem(13)} ${toRem(1)}`,
     selectors: {
       [`${EditorTextareaScroll}:first-child &`]: {
         paddingLeft: toRem(13),
@@ -34,6 +34,9 @@ export const EditorTextarea = style([
       [`${EditorTextareaScroll}:last-child &`]: {
         paddingRight: toRem(13),
       },
+      '&:focus': {
+        outline: 'none',
+      },
     },
   },
 ]);
index 62b4134592ddda30b4a45eddc63b40492e1347b8..044d083793eaecc79aebfcf8d413a548fbc6020d 100644 (file)
@@ -18,7 +18,8 @@ import {
   RenderPlaceholderProps,
 } from 'slate-react';
 import { withHistory } from 'slate-history';
-import { BlockType, RenderElement, RenderLeaf } from './Elements';
+import { BlockType } from './types';
+import { RenderElement, RenderLeaf } from './Elements';
 import { CustomElement } from './slate';
 import * as css from './Editor.css';
 import { toggleKeyboardShortcut } from './keyboard';
@@ -34,8 +35,9 @@ const withInline = (editor: Editor): Editor => {
   const { isInline } = editor;
 
   editor.isInline = (element) =>
-    [BlockType.Mention, BlockType.Emoticon, BlockType.Link].includes(element.type) ||
-    isInline(element);
+    [BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
+      element.type
+    ) || isInline(element);
 
   return editor;
 };
@@ -44,7 +46,8 @@ const withVoid = (editor: Editor): Editor => {
   const { isVoid } = editor;
 
   editor.isVoid = (element) =>
-    [BlockType.Mention, BlockType.Emoticon].includes(element.type) || isVoid(element);
+    [BlockType.Mention, BlockType.Emoticon, BlockType.Command].includes(element.type) ||
+    isVoid(element);
 
   return editor;
 };
@@ -122,7 +125,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
 
     return (
       <div className={css.Editor} ref={ref}>
-        <Slate editor={editor} value={initialValue} onChange={onChange}>
+        <Slate editor={editor} initialValue={initialValue} onChange={onChange}>
           {top}
           <Box alignItems="Start">
             {before && (
index 2df8099368c20f852cd6b5849db4f668cb47f0e2..c4767ab9294a63e6a7482c45430082d6f233d74d 100644 (file)
@@ -1,34 +1,18 @@
 import { Scroll, Text } from 'folds';
 import React from 'react';
-import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react';
+import {
+  RenderElementProps,
+  RenderLeafProps,
+  useFocused,
+  useSelected,
+  useSlate,
+} from 'slate-react';
 
 import * as css from '../../styles/CustomHtml.css';
-import { EmoticonElement, LinkElement, MentionElement } from './slate';
+import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
-
-export enum MarkType {
-  Bold = 'bold',
-  Italic = 'italic',
-  Underline = 'underline',
-  StrikeThrough = 'strikeThrough',
-  Code = 'code',
-  Spoiler = 'spoiler',
-}
-
-export enum BlockType {
-  Paragraph = 'paragraph',
-  Heading = 'heading',
-  CodeLine = 'code-line',
-  CodeBlock = 'code-block',
-  QuoteLine = 'quote-line',
-  BlockQuote = 'block-quote',
-  ListItem = 'list-item',
-  OrderedList = 'ordered-list',
-  UnorderedList = 'unordered-list',
-  Mention = 'mention',
-  Emoticon = 'emoticon',
-  Link = 'link',
-}
+import { getBeginCommand } from './utils';
+import { BlockType } from './types';
 
 // Put this at the start and end of an inline component to work around this Chromium bug:
 // https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
@@ -62,6 +46,29 @@ function RenderMentionElement({
     </span>
   );
 }
+function RenderCommandElement({
+  attributes,
+  element,
+  children,
+}: { element: CommandElement } & RenderElementProps) {
+  const selected = useSelected();
+  const focused = useFocused();
+  const editor = useSlate();
+
+  return (
+    <span
+      {...attributes}
+      className={css.Command({
+        focus: selected && focused,
+        active: getBeginCommand(editor) === element.command,
+      })}
+      contentEditable={false}
+    >
+      {`/${element.command}`}
+      {children}
+    </span>
+  );
+}
 
 function RenderEmoticonElement({
   attributes,
@@ -200,6 +207,12 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
           {children}
         </RenderLinkElement>
       );
+    case BlockType.Command:
+      return (
+        <RenderCommandElement attributes={attributes} element={element}>
+          {children}
+        </RenderCommandElement>
+      );
     default:
       return (
         <Text className={css.Paragraph} {...attributes}>
index 72e2c38cfb84340af604e817c730943a31708798..342dd1060adafdaf906521844618d15052299767 100644 (file)
@@ -25,9 +25,9 @@ import {
   removeAllMark,
   toggleBlock,
   toggleMark,
-} from './common';
+} from './utils';
 import * as css from './Editor.css';
-import { BlockType, MarkType } from './Elements';
+import { BlockType, MarkType } from './types';
 import { HeadingLevel } from './slate';
 import { isMacOS } from '../../utils/user-agent';
 import { KeySymbol } from '../../utils/key-symbol';
index d89cda0948fd2b0d2e6e62372a1e2aa1e06a5854..e7c8df38828d84baa98a15b5b86b5e8e05016587 100644 (file)
@@ -19,6 +19,7 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto
           focusTrapOptions={{
             initialFocus: false,
             onDeactivate: () => requestClose(),
+            returnFocusOnDeactivate: false,
             clickOutsideDeactivates: true,
             allowOutsideClick: true,
             isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt),
index 2e556000caea37ca7dd544bb23932299bb5439e2..bc98667eac8b575ed8053724826a83c939e9d617 100644 (file)
@@ -12,7 +12,7 @@ import {
   useAsyncSearch,
 } from '../../../hooks/useAsyncSearch';
 import { onTabPress } from '../../../utils/keyboard';
-import { createEmoticonElement, moveCursor, replaceWithElement } from '../common';
+import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
 import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
 import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
 import { IEmoji, emojis } from '../../../plugins/emoji';
index baa217ca9a862ba8182c52fd04d9b5362893706b..31acd2c5416fed2296dbce8c7164186926532175 100644 (file)
@@ -3,7 +3,7 @@ import { Editor } from 'slate';
 import { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
 import { MatrixClient } from 'matrix-js-sdk';
 
-import { createMentionElement, moveCursor, replaceWithElement } from '../common';
+import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 import { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
 import { roomIdByActivity } from '../../../../util/sort';
 import initMatrix from '../../../../client/initMatrix';
index 00ecb015872966d4487bd07861e698862aa85847..a99274a5d94ee66e15592c89404598a692a75d23 100644 (file)
@@ -13,7 +13,7 @@ import {
   useAsyncSearch,
 } from '../../../hooks/useAsyncSearch';
 import { onTabPress } from '../../../utils/keyboard';
-import { createMentionElement, moveCursor, replaceWithElement } from '../common';
+import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
 import { useKeyDown } from '../../../hooks/useKeyDown';
 import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
 
index 96dabc57928c4c978231808cdf4086ba9d543816..1baa44a1374ec00f645e90ccc92a3b1d6053d3a0 100644 (file)
@@ -4,11 +4,13 @@ export enum AutocompletePrefix {
   RoomMention = '#',
   UserMention = '@',
   Emoticon = ':',
+  Command = '/',
 }
 export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [
   AutocompletePrefix.RoomMention,
   AutocompletePrefix.UserMention,
   AutocompletePrefix.Emoticon,
+  AutocompletePrefix.Command,
 ];
 
 export type AutocompleteQuery<TPrefix extends string> = {
diff --git a/src/app/components/editor/common.ts b/src/app/components/editor/common.ts
deleted file mode 100644 (file)
index 68717b3..0000000
+++ /dev/null
@@ -1,232 +0,0 @@
-import { BasePoint, BaseRange, Editor, Element, Point, Range, Transforms } from 'slate';
-import { BlockType, MarkType } from './Elements';
-import { EmoticonElement, FormattedText, HeadingLevel, LinkElement, MentionElement } from './slate';
-
-const ALL_MARK_TYPE: MarkType[] = [
-  MarkType.Bold,
-  MarkType.Code,
-  MarkType.Italic,
-  MarkType.Spoiler,
-  MarkType.StrikeThrough,
-  MarkType.Underline,
-];
-
-export const isMarkActive = (editor: Editor, format: MarkType) => {
-  const marks = Editor.marks(editor);
-  return marks ? marks[format] === true : false;
-};
-
-export const isAnyMarkActive = (editor: Editor) => {
-  const marks = Editor.marks(editor);
-  return marks && !!ALL_MARK_TYPE.find((type) => marks[type] === true);
-};
-
-export const toggleMark = (editor: Editor, format: MarkType) => {
-  const isActive = isMarkActive(editor, format);
-
-  if (isActive) {
-    Editor.removeMark(editor, format);
-  } else {
-    Editor.addMark(editor, format, true);
-  }
-};
-
-export const removeAllMark = (editor: Editor) => {
-  ALL_MARK_TYPE.forEach((mark) => {
-    if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark);
-  });
-};
-
-export const isBlockActive = (editor: Editor, format: BlockType) => {
-  const [match] = Editor.nodes(editor, {
-    match: (node) => Element.isElement(node) && node.type === format,
-  });
-
-  return !!match;
-};
-
-type BlockOption = { level: HeadingLevel };
-const NESTED_BLOCK = [
-  BlockType.OrderedList,
-  BlockType.UnorderedList,
-  BlockType.BlockQuote,
-  BlockType.CodeBlock,
-];
-
-export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
-  const isActive = isBlockActive(editor, format);
-
-  Transforms.unwrapNodes(editor, {
-    match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type),
-    split: true,
-  });
-
-  if (isActive) {
-    Transforms.setNodes(editor, {
-      type: BlockType.Paragraph,
-    });
-    return;
-  }
-
-  if (format === BlockType.OrderedList || format === BlockType.UnorderedList) {
-    Transforms.setNodes(editor, {
-      type: BlockType.ListItem,
-    });
-    const block = {
-      type: format,
-      children: [],
-    };
-    Transforms.wrapNodes(editor, block);
-    return;
-  }
-  if (format === BlockType.CodeBlock) {
-    Transforms.setNodes(editor, {
-      type: BlockType.CodeLine,
-    });
-    const block = {
-      type: format,
-      children: [],
-    };
-    Transforms.wrapNodes(editor, block);
-    return;
-  }
-
-  if (format === BlockType.BlockQuote) {
-    Transforms.setNodes(editor, {
-      type: BlockType.QuoteLine,
-    });
-    const block = {
-      type: format,
-      children: [],
-    };
-    Transforms.wrapNodes(editor, block);
-    return;
-  }
-
-  if (format === BlockType.Heading) {
-    Transforms.setNodes(editor, {
-      type: format,
-      level: option?.level ?? 1,
-    });
-  }
-
-  Transforms.setNodes(editor, {
-    type: format,
-  });
-};
-
-export const resetEditor = (editor: Editor) => {
-  Transforms.delete(editor, {
-    at: {
-      anchor: Editor.start(editor, []),
-      focus: Editor.end(editor, []),
-    },
-  });
-
-  toggleBlock(editor, BlockType.Paragraph);
-  removeAllMark(editor);
-};
-
-export const resetEditorHistory = (editor: Editor) => {
-  // eslint-disable-next-line no-param-reassign
-  editor.history = {
-    undos: [],
-    redos: [],
-  };
-};
-
-export const createMentionElement = (
-  id: string,
-  name: string,
-  highlight: boolean
-): MentionElement => ({
-  type: BlockType.Mention,
-  id,
-  highlight,
-  name,
-  children: [{ text: '' }],
-});
-
-export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({
-  type: BlockType.Emoticon,
-  key,
-  shortcode,
-  children: [{ text: '' }],
-});
-
-export const createLinkElement = (
-  href: string,
-  children: string | FormattedText[]
-): LinkElement => ({
-  type: BlockType.Link,
-  href,
-  children: typeof children === 'string' ? [{ text: children }] : children,
-});
-
-export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
-  Transforms.select(editor, selectRange);
-  Transforms.insertNodes(editor, element);
-};
-
-export const moveCursor = (editor: Editor, withSpace?: boolean) => {
-  // without timeout move cursor doesn't works properly.
-  setTimeout(() => {
-    Transforms.move(editor);
-    if (withSpace) editor.insertText(' ');
-  }, 100);
-};
-
-interface PointUntilCharOptions {
-  match: (char: string) => boolean;
-  reverse?: boolean;
-}
-export const getPointUntilChar = (
-  editor: Editor,
-  cursorPoint: BasePoint,
-  options: PointUntilCharOptions
-): BasePoint | undefined => {
-  let targetPoint: BasePoint | undefined;
-  let prevPoint: BasePoint | undefined;
-  let char: string | undefined;
-
-  const pointItr = Editor.positions(editor, {
-    at: {
-      anchor: Editor.start(editor, []),
-      focus: Editor.point(editor, cursorPoint, { edge: 'start' }),
-    },
-    unit: 'character',
-    reverse: options.reverse,
-  });
-
-  // eslint-disable-next-line no-restricted-syntax
-  for (const point of pointItr) {
-    if (!Point.equals(point, cursorPoint) && prevPoint) {
-      char = Editor.string(editor, { anchor: point, focus: prevPoint });
-
-      if (options.match(char)) break;
-      targetPoint = point;
-    }
-    prevPoint = point;
-  }
-  return targetPoint;
-};
-
-export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
-  const { selection } = editor;
-  if (!selection || !Range.isCollapsed(selection)) return undefined;
-  const [cursorPoint] = Range.edges(selection);
-  const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
-    reverse: true,
-    match: (char) => char === ' ',
-  });
-  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 7c63ce61d8a3f7af264ff1a8a58078c269dfde5f..aae0137d14075ba9c7f7b21568abfd0cb4aa928b 100644 (file)
@@ -1,8 +1,9 @@
 export * from './autocomplete';
-export * from './common';
+export * from './utils';
 export * from './Editor';
 export * from './Elements';
 export * from './keyboard';
 export * from './output';
 export * from './Toolbar';
 export * from './input';
+export * from './types';
index 39db0e1b6e107cd4ef1a1a80c70b0690afb7a826..37aa7244299cf5926daba5f9cb3ae8dd523bb712 100644 (file)
@@ -4,7 +4,7 @@ import parse from 'html-dom-parser';
 import { ChildNode, Element, isText, isTag } from 'domhandler';
 
 import { sanitizeCustomHtml } from '../../utils/sanitize';
-import { BlockType, MarkType } from './Elements';
+import { BlockType, MarkType } from './types';
 import {
   BlockQuoteElement,
   CodeBlockElement,
@@ -21,7 +21,7 @@ import {
   UnorderedListElement,
 } from './slate';
 import { parseMatrixToUrl } from '../../utils/matrix';
-import { createEmoticonElement, createMentionElement } from './common';
+import { createEmoticonElement, createMentionElement } from './utils';
 
 const markNodeToType: Record<string, MarkType> = {
   b: MarkType.Bold,
index b6e1c3f4fdcba3afd0c88de348eb5efc289dfe21..b6d4d692bd560c2aa26773ac4d87672a8590ed52 100644 (file)
@@ -1,8 +1,8 @@
 import { isHotkey } from 'is-hotkey';
 import { KeyboardEvent } from 'react';
 import { Editor } from 'slate';
-import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './common';
-import { BlockType, MarkType } from './Elements';
+import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './utils';
+import { BlockType, MarkType } from './types';
 
 export const INLINE_HOTKEYS: Record<string, MarkType> = {
   'mod+b': MarkType.Bold,
index 89a5f7c58392cc04fd0f018c4b39caaf3411de8f..307ef8a2b6c9973db555a348093c6e037bd948a8 100644 (file)
@@ -1,7 +1,7 @@
 import { Descendant, Text } from 'slate';
 
 import { sanitizeText } from '../../utils/sanitize';
-import { BlockType } from './Elements';
+import { BlockType } from './types';
 import { CustomElement } from './slate';
 import { parseInlineMD } from '../../utils/markdown';
 
@@ -57,6 +57,8 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
         : node.key;
     case BlockType.Link:
       return `<a href="${node.href}">${node.children}</a>`;
+    case BlockType.Command:
+      return `/${node.command}`;
     default:
       return children;
   }
@@ -104,6 +106,8 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
       return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key;
     case BlockType.Link:
       return `[${node.children}](${node.href})`;
+    case BlockType.Command:
+      return `/${node.command}`;
     default:
       return children;
   }
@@ -129,4 +133,12 @@ export const toPlainText = (node: Descendant | Descendant[]): string => {
 export const customHtmlEqualsPlainText = (customHtml: string, plain: string): boolean =>
   customHtml.replace(/<br\/>/g, '\n') === sanitizeText(plain);
 
-export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '');
+export const trimCustomHtml = (customHtml: string) => customHtml.replace(/<br\/>$/g, '').trim();
+
+export const trimCommand = (cmdName: string, str: string) => {
+  const cmdRegX = new RegExp(`^(\\s+)?(\\/${cmdName})([^\\S\n]+)?`);
+
+  const match = str.match(cmdRegX);
+  if (!match) return str;
+  return str.slice(match[0].length);
+};
index ee046a08240f2837ec2806fb4c75d702d40d204f..1b08ae884c2cd74099c7c41111eb66b76917cc8c 100644 (file)
@@ -1,7 +1,7 @@
 import { BaseEditor } from 'slate';
 import { ReactEditor } from 'slate-react';
 import { HistoryEditor } from 'slate-history';
-import { BlockType } from './Elements';
+import { BlockType } from './types';
 
 export type HeadingLevel = 1 | 2 | 3;
 
@@ -39,8 +39,13 @@ export type EmoticonElement = {
   shortcode: string;
   children: Text[];
 };
+export type CommandElement = {
+  type: BlockType.Command;
+  command: string;
+  children: Text[];
+};
 
-export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement;
+export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement | CommandElement;
 
 export type ParagraphElement = {
   type: BlockType.Paragraph;
@@ -84,6 +89,7 @@ export type CustomElement =
   | LinkElement
   | MentionElement
   | EmoticonElement
+  | CommandElement
   | ParagraphElement
   | HeadingElement
   | CodeLineElement
diff --git a/src/app/components/editor/types.ts b/src/app/components/editor/types.ts
new file mode 100644 (file)
index 0000000..9a108ec
--- /dev/null
@@ -0,0 +1,24 @@
+export enum MarkType {
+  Bold = 'bold',
+  Italic = 'italic',
+  Underline = 'underline',
+  StrikeThrough = 'strikeThrough',
+  Code = 'code',
+  Spoiler = 'spoiler',
+}
+
+export enum BlockType {
+  Paragraph = 'paragraph',
+  Heading = 'heading',
+  CodeLine = 'code-line',
+  CodeBlock = 'code-block',
+  QuoteLine = 'quote-line',
+  BlockQuote = 'block-quote',
+  ListItem = 'list-item',
+  OrderedList = 'ordered-list',
+  UnorderedList = 'unordered-list',
+  Mention = 'mention',
+  Emoticon = 'emoticon',
+  Link = 'link',
+  Command = 'command',
+}
diff --git a/src/app/components/editor/utils.ts b/src/app/components/editor/utils.ts
new file mode 100644 (file)
index 0000000..9bdfde1
--- /dev/null
@@ -0,0 +1,261 @@
+import { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
+import { BlockType, MarkType } from './types';
+import {
+  CommandElement,
+  EmoticonElement,
+  FormattedText,
+  HeadingLevel,
+  LinkElement,
+  MentionElement,
+} from './slate';
+
+const ALL_MARK_TYPE: MarkType[] = [
+  MarkType.Bold,
+  MarkType.Code,
+  MarkType.Italic,
+  MarkType.Spoiler,
+  MarkType.StrikeThrough,
+  MarkType.Underline,
+];
+
+export const isMarkActive = (editor: Editor, format: MarkType) => {
+  const marks = Editor.marks(editor);
+  return marks ? marks[format] === true : false;
+};
+
+export const isAnyMarkActive = (editor: Editor) => {
+  const marks = Editor.marks(editor);
+  return marks && !!ALL_MARK_TYPE.find((type) => marks[type] === true);
+};
+
+export const toggleMark = (editor: Editor, format: MarkType) => {
+  const isActive = isMarkActive(editor, format);
+
+  if (isActive) {
+    Editor.removeMark(editor, format);
+  } else {
+    Editor.addMark(editor, format, true);
+  }
+};
+
+export const removeAllMark = (editor: Editor) => {
+  ALL_MARK_TYPE.forEach((mark) => {
+    if (isMarkActive(editor, mark)) Editor.removeMark(editor, mark);
+  });
+};
+
+export const isBlockActive = (editor: Editor, format: BlockType) => {
+  const [match] = Editor.nodes(editor, {
+    match: (node) => Element.isElement(node) && node.type === format,
+  });
+
+  return !!match;
+};
+
+type BlockOption = { level: HeadingLevel };
+const NESTED_BLOCK = [
+  BlockType.OrderedList,
+  BlockType.UnorderedList,
+  BlockType.BlockQuote,
+  BlockType.CodeBlock,
+];
+
+export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
+  Transforms.collapse(editor, {
+    edge: 'end',
+  });
+  const isActive = isBlockActive(editor, format);
+
+  Transforms.unwrapNodes(editor, {
+    match: (node) => Element.isElement(node) && NESTED_BLOCK.includes(node.type),
+    split: true,
+  });
+
+  if (isActive) {
+    Transforms.setNodes(editor, {
+      type: BlockType.Paragraph,
+    });
+    return;
+  }
+
+  if (format === BlockType.OrderedList || format === BlockType.UnorderedList) {
+    Transforms.setNodes(editor, {
+      type: BlockType.ListItem,
+    });
+    const block = {
+      type: format,
+      children: [],
+    };
+    Transforms.wrapNodes(editor, block);
+    return;
+  }
+  if (format === BlockType.CodeBlock) {
+    Transforms.setNodes(editor, {
+      type: BlockType.CodeLine,
+    });
+    const block = {
+      type: format,
+      children: [],
+    };
+    Transforms.wrapNodes(editor, block);
+    return;
+  }
+
+  if (format === BlockType.BlockQuote) {
+    Transforms.setNodes(editor, {
+      type: BlockType.QuoteLine,
+    });
+    const block = {
+      type: format,
+      children: [],
+    };
+    Transforms.wrapNodes(editor, block);
+    return;
+  }
+
+  if (format === BlockType.Heading) {
+    Transforms.setNodes(editor, {
+      type: format,
+      level: option?.level ?? 1,
+    });
+  }
+
+  Transforms.setNodes(editor, {
+    type: format,
+  });
+};
+
+export const resetEditor = (editor: Editor) => {
+  Transforms.delete(editor, {
+    at: {
+      anchor: Editor.start(editor, []),
+      focus: Editor.end(editor, []),
+    },
+  });
+
+  toggleBlock(editor, BlockType.Paragraph);
+  removeAllMark(editor);
+};
+
+export const resetEditorHistory = (editor: Editor) => {
+  // eslint-disable-next-line no-param-reassign
+  editor.history = {
+    undos: [],
+    redos: [],
+  };
+};
+
+export const createMentionElement = (
+  id: string,
+  name: string,
+  highlight: boolean
+): MentionElement => ({
+  type: BlockType.Mention,
+  id,
+  highlight,
+  name,
+  children: [{ text: '' }],
+});
+
+export const createEmoticonElement = (key: string, shortcode: string): EmoticonElement => ({
+  type: BlockType.Emoticon,
+  key,
+  shortcode,
+  children: [{ text: '' }],
+});
+
+export const createLinkElement = (
+  href: string,
+  children: string | FormattedText[]
+): LinkElement => ({
+  type: BlockType.Link,
+  href,
+  children: typeof children === 'string' ? [{ text: children }] : children,
+});
+
+export const createCommandElement = (command: string): CommandElement => ({
+  type: BlockType.Command,
+  command,
+  children: [{ text: '' }],
+});
+
+export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
+  Transforms.select(editor, selectRange);
+  Transforms.insertNodes(editor, element);
+  Transforms.collapse(editor, {
+    edge: 'end',
+  });
+};
+
+export const moveCursor = (editor: Editor, withSpace?: boolean) => {
+  Transforms.move(editor);
+  if (withSpace) editor.insertText(' ');
+};
+
+interface PointUntilCharOptions {
+  match: (char: string) => boolean;
+  reverse?: boolean;
+}
+export const getPointUntilChar = (
+  editor: Editor,
+  cursorPoint: BasePoint,
+  options: PointUntilCharOptions
+): BasePoint | undefined => {
+  let targetPoint: BasePoint | undefined;
+  let prevPoint: BasePoint | undefined;
+  let char: string | undefined;
+
+  const pointItr = Editor.positions(editor, {
+    at: {
+      anchor: Editor.start(editor, []),
+      focus: Editor.point(editor, cursorPoint, { edge: 'start' }),
+    },
+    unit: 'character',
+    reverse: options.reverse,
+  });
+
+  // eslint-disable-next-line no-restricted-syntax
+  for (const point of pointItr) {
+    if (!Point.equals(point, cursorPoint) && prevPoint) {
+      char = Editor.string(editor, { anchor: point, focus: prevPoint });
+
+      if (options.match(char)) break;
+      targetPoint = point;
+    }
+    prevPoint = point;
+  }
+  return targetPoint;
+};
+
+export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
+  const { selection } = editor;
+  if (!selection || !Range.isCollapsed(selection)) return undefined;
+  const [cursorPoint] = Range.edges(selection);
+  const worldStartPoint = getPointUntilChar(editor, cursorPoint, {
+    reverse: true,
+    match: (char) => char === ' ',
+  });
+  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;
+};
+
+export const getBeginCommand = (editor: Editor): string | undefined => {
+  const lineBlock = editor.children[0];
+  if (!Element.isElement(lineBlock)) return undefined;
+  if (lineBlock.type !== BlockType.Paragraph) return undefined;
+
+  const [firstInline, secondInline] = lineBlock.children;
+  const isEmptyText = Text.isText(firstInline) && firstInline.text.trim() === '';
+  if (!isEmptyText) return undefined;
+  if (Element.isElement(secondInline) && secondInline.type === BlockType.Command)
+    return secondInline.command;
+  return undefined;
+};
index 81730e3db41d2d486ff58f17a6cb38f235f38b34..067ebe39cc4e6e74da963b62b67c4a76acbcc9e1 100644 (file)
@@ -47,6 +47,7 @@ import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearc
 import { useDebounce } from '../../hooks/useDebounce';
 import { useThrottle } from '../../hooks/useThrottle';
 import { addRecentEmoji } from '../../plugins/recent-emoji';
+import { mobileOrTablet } from '../../utils/user-agent';
 
 const RECENT_GROUP_ID = 'recent_group';
 const SEARCH_GROUP_ID = 'search_group';
@@ -782,7 +783,7 @@ export function EmojiBoard({
                 maxLength={50}
                 after={<Icon src={Icons.Search} size="50" />}
                 onChange={handleOnChange}
-                autoFocus
+                autoFocus={!mobileOrTablet()}
               />
             </Box>
           </Header>
index ab13c5bdc3b5bbe04f0be26d993a388ae6cae9d0..03108c3296096516fa0ed56dd29dba570295dd78 100644 (file)
@@ -5,6 +5,6 @@ import * as css from './media.css';
 export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
   ({ className, ...props }, ref) => (
     // eslint-disable-next-line jsx-a11y/media-has-caption
-    <video className={classNames(css.Image, className)} {...props} ref={ref} />
+    <video className={classNames(css.Video, className)} {...props} ref={ref} />
   )
 );
index 46d17ca2cd24a2c69082d6279f37c02175ebe624..b563ef3ed9620cb2498b8cce8b5fc0c6e4847738 100644 (file)
@@ -13,7 +13,7 @@ export const Image = style([
 export const Video = style([
   DefaultReset,
   {
-    objectFit: 'cover',
+    objectFit: 'contain',
     width: '100%',
     height: '100%',
   },
index 67f4df4082e145580ea133e96bf25d807030becf..a8800fa88aa0dd9fe32d0b5aa4a26708bc4c6d8d 100644 (file)
@@ -5,8 +5,8 @@ import React, { useEffect, useState } from 'react';
 import to from 'await-to-js';
 import classNames from 'classnames';
 import colorMXID from '../../../util/colorMXID';
-import { getMemberDisplayName } from '../../utils/room';
-import { getMxIdLocalPart, trimReplyFromBody } from '../../utils/matrix';
+import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
+import { getMxIdLocalPart } from '../../utils/matrix';
 import { LinePlaceholder } from './placeholder';
 import { randomNumberBetween } from '../../utils/common';
 import * as css from './Reply.css';
diff --git a/src/app/hooks/useCommands.ts b/src/app/hooks/useCommands.ts
new file mode 100644 (file)
index 0000000..ad464c6
--- /dev/null
@@ -0,0 +1,219 @@
+import { MatrixClient, Room } from 'matrix-js-sdk';
+import { useMemo } from 'react';
+import { hasDMWith, isRoomAlias, isRoomId, isUserId } from '../utils/matrix';
+import { selectRoom } from '../../client/action/navigation';
+import { hasDevices } from '../../util/matrixUtil';
+import * as roomActions from '../../client/action/room';
+
+export const SHRUG = '¯\\_(ツ)_/¯';
+
+export function parseUsersAndReason(payload: string): {
+  users: string[];
+  reason?: string;
+} {
+  let reason: string | undefined;
+  let ids: string = payload;
+
+  const reasonMatch = payload.match(/\s-r\s/);
+  if (reasonMatch) {
+    ids = payload.slice(0, reasonMatch.index);
+    reason = payload.slice((reasonMatch.index ?? 0) + reasonMatch[0].length);
+    if (reason.trim() === '') reason = undefined;
+  }
+  const rawIds = ids.split(' ');
+  const users = rawIds.filter((id) => isUserId(id));
+  return {
+    users,
+    reason,
+  };
+}
+
+export type CommandExe = (payload: string) => Promise<void>;
+
+export enum Command {
+  Me = 'me',
+  Notice = 'notice',
+  Shrug = 'shrug',
+  StartDm = 'startdm',
+  Join = 'join',
+  Leave = 'leave',
+  Invite = 'invite',
+  DisInvite = 'disinvite',
+  Kick = 'kick',
+  Ban = 'ban',
+  UnBan = 'unban',
+  Ignore = 'ignore',
+  UnIgnore = 'unignore',
+  MyRoomNick = 'myroomnick',
+  MyRoomAvatar = 'myroomavatar',
+  ConvertToDm = 'converttodm',
+  ConvertToRoom = 'converttoroom',
+}
+
+export type CommandContent = {
+  name: string;
+  description: string;
+  exe: CommandExe;
+};
+
+export type CommandRecord = Record<Command, CommandContent>;
+
+export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
+  const commands: CommandRecord = useMemo(
+    () => ({
+      [Command.Me]: {
+        name: Command.Me,
+        description: 'Send action message',
+        exe: async () => undefined,
+      },
+      [Command.Notice]: {
+        name: Command.Notice,
+        description: 'Send notice message',
+        exe: async () => undefined,
+      },
+      [Command.Shrug]: {
+        name: Command.Shrug,
+        description: 'Send Â¯\\_(ツ)_/¯ as message',
+        exe: async () => undefined,
+      },
+      [Command.StartDm]: {
+        name: Command.StartDm,
+        description: 'Start direct message with user. Example: /startdm userId1',
+        exe: async (payload) => {
+          const rawIds = payload.split(' ');
+          const userIds = rawIds.filter((id) => isUserId(id) && id !== mx.getUserId());
+          if (userIds.length === 0) return;
+          if (userIds.length === 1) {
+            const dmRoomId = hasDMWith(mx, userIds[0]);
+            if (dmRoomId) {
+              selectRoom(dmRoomId);
+              return;
+            }
+          }
+          const devices = await Promise.all(userIds.map(hasDevices));
+          const isEncrypt = devices.every((hasDevice) => hasDevice);
+          const result = await roomActions.createDM(userIds, isEncrypt);
+          selectRoom(result.room_id);
+        },
+      },
+      [Command.Join]: {
+        name: Command.Join,
+        description: 'Join room with address. Example: /join address1 address2',
+        exe: async (payload) => {
+          const rawIds = payload.split(' ');
+          const roomIds = rawIds.filter(
+            (idOrAlias) => isRoomId(idOrAlias) || isRoomAlias(idOrAlias)
+          );
+          roomIds.map((id) => roomActions.join(id));
+        },
+      },
+      [Command.Leave]: {
+        name: Command.Leave,
+        description: 'Leave current room.',
+        exe: async (payload) => {
+          if (payload.trim() === '') {
+            roomActions.leave(room.roomId);
+            return;
+          }
+          const rawIds = payload.split(' ');
+          const roomIds = rawIds.filter((id) => isRoomId(id));
+          roomIds.map((id) => roomActions.leave(id));
+        },
+      },
+      [Command.Invite]: {
+        name: Command.Invite,
+        description: 'Invite user to room. Example: /invite userId1 userId2 [-r reason]',
+        exe: async (payload) => {
+          const { users, reason } = parseUsersAndReason(payload);
+          users.map((id) => roomActions.invite(room.roomId, id, reason));
+        },
+      },
+      [Command.DisInvite]: {
+        name: Command.DisInvite,
+        description: 'Disinvite user to room. Example: /disinvite userId1 userId2 [-r reason]',
+        exe: async (payload) => {
+          const { users, reason } = parseUsersAndReason(payload);
+          users.map((id) => roomActions.kick(room.roomId, id, reason));
+        },
+      },
+      [Command.Kick]: {
+        name: Command.Kick,
+        description: 'Kick user from room. Example: /kick userId1 userId2 [-r reason]',
+        exe: async (payload) => {
+          const { users, reason } = parseUsersAndReason(payload);
+          users.map((id) => roomActions.kick(room.roomId, id, reason));
+        },
+      },
+      [Command.Ban]: {
+        name: Command.Ban,
+        description: 'Ban user from room. Example: /ban userId1 userId2 [-r reason]',
+        exe: async (payload) => {
+          const { users, reason } = parseUsersAndReason(payload);
+          users.map((id) => roomActions.ban(room.roomId, id, reason));
+        },
+      },
+      [Command.UnBan]: {
+        name: Command.UnBan,
+        description: 'Unban user from room. Example: /unban userId1 userId2',
+        exe: async (payload) => {
+          const rawIds = payload.split(' ');
+          const users = rawIds.filter((id) => isUserId(id));
+          users.map((id) => roomActions.unban(room.roomId, id));
+        },
+      },
+      [Command.Ignore]: {
+        name: Command.Ignore,
+        description: 'Ignore user. Example: /ignore userId1 userId2',
+        exe: async (payload) => {
+          const rawIds = payload.split(' ');
+          const userIds = rawIds.filter((id) => isUserId(id));
+          if (userIds.length > 0) roomActions.ignore(userIds);
+        },
+      },
+      [Command.UnIgnore]: {
+        name: Command.UnIgnore,
+        description: 'Unignore user. Example: /unignore userId1 userId2',
+        exe: async (payload) => {
+          const rawIds = payload.split(' ');
+          const userIds = rawIds.filter((id) => isUserId(id));
+          if (userIds.length > 0) roomActions.unignore(userIds);
+        },
+      },
+      [Command.MyRoomNick]: {
+        name: Command.MyRoomNick,
+        description: 'Change nick in current room.',
+        exe: async (payload) => {
+          const nick = payload.trim();
+          if (nick === '') return;
+          roomActions.setMyRoomNick(room.roomId, nick);
+        },
+      },
+      [Command.MyRoomAvatar]: {
+        name: Command.MyRoomAvatar,
+        description: 'Change profile picture in current room. Example /myroomavatar mxc://xyzabc',
+        exe: async (payload) => {
+          if (payload.match(/^mxc:\/\/\S+$/)) {
+            roomActions.setMyRoomAvatar(room.roomId, payload);
+          }
+        },
+      },
+      [Command.ConvertToDm]: {
+        name: Command.ConvertToDm,
+        description: 'Convert room to direct message',
+        exe: async () => {
+          roomActions.convertToDm(room.roomId);
+        },
+      },
+      [Command.ConvertToRoom]: {
+        name: Command.ConvertToRoom,
+        description: 'Convert direct message to room',
+        exe: async () => {
+          roomActions.convertToRoom(room.roomId);
+        },
+      },
+    }),
+    [mx, room]
+  );
+
+  return commands;
+};
diff --git a/src/app/organisms/room/CommandAutocomplete.tsx b/src/app/organisms/room/CommandAutocomplete.tsx
new file mode 100644 (file)
index 0000000..31903ac
--- /dev/null
@@ -0,0 +1,109 @@
+import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
+import { Editor } from 'slate';
+import { Box, MenuItem, Text } from 'folds';
+import { Room } from 'matrix-js-sdk';
+import { Command, useCommands } from '../../hooks/useCommands';
+import {
+  AutocompleteMenu,
+  AutocompleteQuery,
+  createCommandElement,
+  moveCursor,
+  replaceWithElement,
+} from '../../components/editor';
+import { UseAsyncSearchOptions, useAsyncSearch } from '../../hooks/useAsyncSearch';
+import { useMatrixClient } from '../../hooks/useMatrixClient';
+import { useKeyDown } from '../../hooks/useKeyDown';
+import { onTabPress } from '../../utils/keyboard';
+
+type CommandAutoCompleteHandler = (commandName: string) => void;
+
+type CommandAutocompleteProps = {
+  room: Room;
+  editor: Editor;
+  query: AutocompleteQuery<string>;
+  requestClose: () => void;
+};
+
+const SEARCH_OPTIONS: UseAsyncSearchOptions = {
+  matchOptions: {
+    contain: true,
+  },
+};
+
+export function CommandAutocomplete({
+  room,
+  editor,
+  query,
+  requestClose,
+}: CommandAutocompleteProps) {
+  const mx = useMatrixClient();
+  const commands = useCommands(mx, room);
+  const commandNames = useMemo(() => Object.keys(commands) as Command[], [commands]);
+
+  const [result, search, resetSearch] = useAsyncSearch(
+    commandNames,
+    useCallback((commandName: string) => commandName, []),
+    SEARCH_OPTIONS
+  );
+
+  const autoCompleteNames = result ? result.items : commandNames;
+
+  useEffect(() => {
+    if (query.text) search(query.text);
+    else resetSearch();
+  }, [query.text, search, resetSearch]);
+
+  const handleAutocomplete: CommandAutoCompleteHandler = (commandName) => {
+    const cmdEl = createCommandElement(commandName);
+    replaceWithElement(editor, query.range, cmdEl);
+    moveCursor(editor, true);
+    requestClose();
+  };
+
+  useKeyDown(window, (evt: KeyboardEvent) => {
+    onTabPress(evt, () => {
+      if (autoCompleteNames.length === 0) {
+        return;
+      }
+      const cmdName = autoCompleteNames[0];
+      handleAutocomplete(cmdName);
+    });
+  });
+
+  return autoCompleteNames.length === 0 ? null : (
+    <AutocompleteMenu
+      headerContent={
+        <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
+          <Text size="L400">Commands</Text>
+          <Text size="T200" priority="300" truncate>
+            Begin your message with command
+          </Text>
+        </Box>
+      }
+      requestClose={requestClose}
+    >
+      {autoCompleteNames.map((commandName) => (
+        <MenuItem
+          key={commandName}
+          as="button"
+          radii="300"
+          onKeyDown={(evt: ReactKeyboardEvent<HTMLButtonElement>) =>
+            onTabPress(evt, () => handleAutocomplete(commandName))
+          }
+          onClick={() => handleAutocomplete(commandName)}
+        >
+          <Box grow="Yes" direction="Row" gap="200" justifyContent="SpaceBetween">
+            <Box shrink="No">
+              <Text style={{ flexGrow: 1 }} size="B400" truncate>
+                {`/${commandName}`}
+              </Text>
+            </Box>
+            <Text truncate priority="300" size="T200">
+              {commands[commandName].description}
+            </Text>
+          </Box>
+        </MenuItem>
+      ))}
+    </AutocompleteMenu>
+  );
+}
index acb45b327d90fe8f63a46a9cf034a736698f3a1c..81c29b03040b21406a5e2906de369c9af5c58d57 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 } from 'slate';
+import { Transforms, Editor } from 'slate';
 import {
   Box,
   Dialog,
@@ -52,6 +52,8 @@ import {
   customHtmlEqualsPlainText,
   trimCustomHtml,
   isEmptyEditor,
+  getBeginCommand,
+  trimCommand,
 } from '../../components/editor';
 import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
 import { UseStateProvider } from '../../components/UseStateProvider';
@@ -92,8 +94,6 @@ import {
   getImageMsgContent,
   getVideoMsgContent,
 } from './msgContent';
-import navigation from '../../../client/state/navigation';
-import cons from '../../../client/state/cons';
 import { MessageReply } from '../../molecules/message/Message';
 import colorMXID from '../../../util/colorMXID';
 import {
@@ -104,17 +104,22 @@ import {
 } from '../../utils/room';
 import { sanitizeText } from '../../utils/sanitize';
 import { useScreenSize } from '../../hooks/useScreenSize';
+import { CommandAutocomplete } from './CommandAutocomplete';
+import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
+import { mobileOrTablet } from '../../utils/user-agent';
 
 interface RoomInputProps {
   editor: Editor;
   roomViewRef: RefObject<HTMLElement>;
   roomId: string;
+  room: Room;
 }
 export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
-  ({ editor, roomViewRef, roomId }, ref) => {
+  ({ editor, roomViewRef, roomId, room }, ref) => {
     const mx = useMatrixClient();
-    const room = mx.getRoom(roomId);
+    const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
     const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
+    const commands = useCommands(mx, room);
 
     const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
     const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
@@ -176,36 +181,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
     }, [editor, msgDraft]);
 
     useEffect(() => {
-      ReactEditor.focus(editor);
+      if (!mobileOrTablet()) ReactEditor.focus(editor);
       return () => {
-        const parsedDraft = JSON.parse(JSON.stringify(editor.children));
-        setMsgDraft(parsedDraft);
+        if (!isEmptyEditor(editor)) {
+          const parsedDraft = JSON.parse(JSON.stringify(editor.children));
+          setMsgDraft(parsedDraft);
+        } else {
+          roomIdToMsgDraftAtomFamily.remove(roomId);
+        }
         resetEditor(editor);
         resetEditorHistory(editor);
       };
     }, [roomId, editor, setMsgDraft]);
 
-    useEffect(() => {
-      const handleReplyTo = (
-        userId: string,
-        eventId: string,
-        body: string,
-        formattedBody: string
-      ) => {
-        setReplyDraft({
-          userId,
-          eventId,
-          body,
-          formattedBody,
-        });
-        ReactEditor.focus(editor);
-      };
-      navigation.on(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
-      return () => {
-        navigation.removeListener(cons.events.navigation.REPLY_TO_CLICKED, handleReplyTo);
-      };
-    }, [setReplyDraft, editor]);
-
     const handleRemoveUpload = useCallback(
       (upload: TUploadContent | TUploadContent[]) => {
         const uploads = Array.isArray(upload) ? upload : [upload];
@@ -257,13 +245,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
     const submit = useCallback(() => {
       uploadBoardHandlers.current?.handleSend();
 
-      const plainText = toPlainText(editor.children).trim();
-      const customHtml = trimCustomHtml(
+      const commandName = getBeginCommand(editor);
+
+      let plainText = toPlainText(editor.children).trim();
+      let customHtml = trimCustomHtml(
         toMatrixCustomHTML(editor.children, {
           allowTextFormatting: true,
           allowMarkdown: isMarkdown,
         })
       );
+      let msgType = MsgType.Text;
+
+      if (commandName) {
+        plainText = trimCommand(commandName, plainText);
+        customHtml = trimCommand(commandName, customHtml);
+      }
+      if (commandName === Command.Me) {
+        msgType = MsgType.Emote;
+      } else if (commandName === Command.Notice) {
+        msgType = MsgType.Notice;
+      } else if (commandName === Command.Shrug) {
+        plainText = `${SHRUG} ${plainText}`;
+        customHtml = `${SHRUG} ${customHtml}`;
+      } else if (commandName) {
+        const commandContent = commands[commandName as Command];
+        if (commandContent) {
+          commandContent.exe(plainText);
+        }
+        resetEditor(editor);
+        resetEditorHistory(editor);
+        sendTypingStatus(false);
+        return;
+      }
 
       if (plainText === '') return;
 
@@ -283,7 +296,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       }
 
       const content: IContent = {
-        msgtype: MsgType.Text,
+        msgtype: msgType,
         body,
       };
       if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
@@ -302,11 +315,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       resetEditorHistory(editor);
       setReplyDraft();
       sendTypingStatus(false);
-    }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown]);
+    }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
 
     const handleKeyDown: KeyboardEventHandler = useCallback(
       (evt) => {
-        if (isHotkey('enter', evt)) {
+        if (enterForNewline ? isHotkey('shift+enter', evt) : isHotkey('enter', evt)) {
           evt.preventDefault();
           submit();
         }
@@ -314,19 +327,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
           evt.preventDefault();
           setReplyDraft();
         }
-
-        if (editor.selection && Range.isCollapsed(editor.selection)) {
-          if (isHotkey('arrowleft', evt)) {
-            evt.preventDefault();
-            Transforms.move(editor, { unit: 'offset', reverse: true });
-          }
-          if (isHotkey('arrowright', evt)) {
-            evt.preventDefault();
-            Transforms.move(editor, { unit: 'offset' });
-          }
-        }
       },
-      [submit, editor, setReplyDraft]
+      [submit, setReplyDraft, enterForNewline]
     );
 
     const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -347,7 +349,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
       [editor, sendTypingStatus]
     );
 
-    const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
+    const handleCloseAutocomplete = useCallback(() => {
+      setAutocompleteQuery(undefined);
+      ReactEditor.focus(editor);
+    }, [editor]);
 
     const handleEmoticonSelect = (key: string, shortcode: string) => {
       editor.insertNode(createEmoticonElement(key, shortcode));
@@ -452,6 +457,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
             requestClose={handleCloseAutocomplete}
           />
         )}
+        {autocompleteQuery?.prefix === AutocompletePrefix.Command && (
+          <CommandAutocomplete
+            room={room}
+            editor={editor}
+            query={autocompleteQuery}
+            requestClose={handleCloseAutocomplete}
+          />
+        )}
         <CustomEditor
           editableName="RoomInput"
           editor={editor}
@@ -523,7 +536,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
                         onStickerSelect={handleStickerSelect}
                         requestClose={() => {
                           setEmojiBoardTab(undefined);
-                          ReactEditor.focus(editor);
+                          if (!mobileOrTablet()) ReactEditor.focus(editor);
                         }}
                       />
                     }
index e183c9510bfccec39677edff2d023179fb3c14bb..9d97cb605025e85574283a686c509eb3afddc2b1 100644 (file)
@@ -81,6 +81,7 @@ function RoomView({ room, eventId }) {
               <>
                 {canMessage && (
                   <RoomInput
+                    room={room}
                     editor={editor}
                     roomId={roomId}
                     roomViewRef={roomViewRef}
index 4d18de22a5183127a8c1ca7ff14854b77e85b6ce..cd0f751993f583c9f2c8a4964c65ddac7ae58169 100644 (file)
@@ -696,7 +696,7 @@ export const Message = as<'div', MessageProps>(
     const hideOptions = () => setHover(false);
 
     const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
-      if (evt.altKey) return;
+      if (evt.altKey || !window.getSelection()?.isCollapsed) return;
       const tag = (evt.target as any).tagName;
       if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
       evt.preventDefault();
@@ -965,7 +965,7 @@ export const Event = as<'div', EventProps>(
     const hideOptions = () => setHover(false);
 
     const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
-      if (evt.altKey) return;
+      if (evt.altKey || !window.getSelection()?.isCollapsed) return;
       const tag = (evt.target as any).tagName;
       if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
       evt.preventDefault();
index 903574790f0628880a077c4f77ac20cddda6abd4..6d1c492863036aac9ec7c3d7ceee4e073e327216 100644 (file)
@@ -32,6 +32,7 @@ import { EmojiBoard } from '../../../components/emoji-board';
 import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
 import { useMatrixClient } from '../../../hooks/useMatrixClient';
 import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
+import { mobileOrTablet } from '../../../utils/user-agent';
 
 type MessageEditorProps = {
   roomId: string;
@@ -44,6 +45,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
   ({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
     const mx = useMatrixClient();
     const editor = useEditor();
+    const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
     const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
     const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
     const [toolbar, setToolbar] = useState(globalToolbar);
@@ -118,7 +120,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
 
     const handleKeyDown: KeyboardEventHandler = useCallback(
       (evt) => {
-        if (isHotkey('enter', evt)) {
+        if (enterForNewline ? isHotkey('shift+enter', evt) : isHotkey('enter', evt)) {
           evt.preventDefault();
           handleSave();
         }
@@ -127,7 +129,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
           onCancel();
         }
       },
-      [onCancel, handleSave]
+      [onCancel, handleSave, enterForNewline]
     );
 
     const handleKeyUp: KeyboardEventHandler = useCallback(
@@ -146,7 +148,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
       [editor]
     );
 
-    const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
+    const handleCloseAutocomplete = useCallback(() => {
+      ReactEditor.focus(editor);
+      setAutocompleteQuery(undefined);
+    }, [editor]);
 
     const handleEmoticonSelect = (key: string, shortcode: string) => {
       editor.insertNode(createEmoticonElement(key, shortcode));
@@ -167,7 +172,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
       });
 
       editor.insertFragment(initialValue);
-      ReactEditor.focus(editor);
+      if (!mobileOrTablet()) ReactEditor.focus(editor);
     }, [editor, getPrevBodyAndFormattedBody]);
 
     useEffect(() => {
@@ -258,7 +263,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
                             onCustomEmojiSelect={handleEmoticonSelect}
                             requestClose={() => {
                               setEmojiBoard(false);
-                              ReactEditor.focus(editor);
+                              if (!mobileOrTablet()) ReactEditor.focus(editor);
                             }}
                           />
                         }
index bd9ce0441cdf615f5bb5c6bbdf9d9df557061ca1..962a80b6635c64089890461f7cba0212c17545e5 100644 (file)
@@ -49,6 +49,7 @@ import { settingsAtom } from '../../state/settings';
 function AppearanceSection() {
   const [, updateState] = useState({});
 
+  const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
   const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
   const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
   const [useSystemEmoji, setUseSystemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
@@ -138,6 +139,16 @@ function AppearanceSection() {
           />
           }
         />
+        <SettingTile
+          title="Use ENTER for Newline"
+          options={(
+            <Toggle
+              isActive={enterForNewline}
+              onToggle={() => setEnterForNewline(!enterForNewline) }
+            />
+          )}
+          content={<Text variant="b3">Use SHIFT + ENTER to send message and ENTER for newline.</Text>}
+        />
         <SettingTile
           title="Inline Markdown formatting"
           options={(
index 667c7c275d06fd1cc2ccc19932c34be885ab52b5..f59951d1c576b4b6390a8bbe1b423479bc64ff49 100644 (file)
@@ -11,6 +11,7 @@ export interface Settings {
   isPeopleDrawer: boolean;
   useSystemEmoji: boolean;
 
+  enterForNewline: boolean;
   messageLayout: MessageLayout;
   messageSpacing: MessageSpacing;
   hideMembershipEvents: boolean;
@@ -30,6 +31,7 @@ const defaultSettings: Settings = {
   isPeopleDrawer: true,
   useSystemEmoji: false,
 
+  enterForNewline: false,
   messageLayout: 0,
   messageSpacing: '400',
   hideMembershipEvents: false,
index 0ace90c78b938273dfe6a663d46aa47cb60f3998..2a06c0fb80a408a60f26bccbe33a5f7afdfd9e93 100644 (file)
@@ -142,6 +142,31 @@ export const Mention = recipe({
   },
 });
 
+export const Command = recipe({
+  base: [
+    DefaultReset,
+    {
+      padding: `0 ${toRem(2)}`,
+      borderRadius: config.radii.R300,
+      fontWeight: config.fontWeight.W500,
+    },
+  ],
+  variants: {
+    focus: {
+      true: {
+        boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Warning.OnContainer}`,
+      },
+    },
+    active: {
+      true: {
+        backgroundColor: color.Warning.Container,
+        color: color.Warning.OnContainer,
+        boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Warning.ContainerLine}`,
+      },
+    },
+  },
+});
+
 export const EmoticonBase = style([
   DefaultReset,
   {
@@ -166,7 +191,7 @@ export const Emoticon = recipe({
       lineHeight: '1em',
       verticalAlign: 'middle',
       position: 'relative',
-      top: '-0.25em',
+      top: '-0.32em',
       borderRadius: config.radii.R300,
     },
   ],
index ba27879e587fe660ea534179a3c44cbad6c4808c..9303c9ac0cd9ba81273d3b663bd842af4efe9e84 100644 (file)
@@ -162,10 +162,10 @@ export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
 export const eventWithShortcode = (ev: MatrixEvent) =>
   typeof ev.getContent().shortcode === 'string';
 
-export const trimReplyFromBody = (body: string): string => {
-  if (body.match(/^> <.+>/) === null) return body;
+export function hasDMWith(mx: MatrixClient, userId: string) {
+  const dmLikeRooms = mx
+    .getRooms()
+    .filter((room) => mx.isRoomEncrypted(room.roomId) && room.getMembers().length <= 2);
 
-  const trimmedBody = body.slice(body.indexOf('\n\n') + 2);
-
-  return trimmedBody || body;
-};
+  return dmLikeRooms.find((room) => room.getMember(userId));
+}
index 2a923677f6559ad35d8239a7ad957ae1d8f5a4d4..ad91f18a6aaf38e1e41df301b564f2ca86d4a735 100644 (file)
@@ -92,6 +92,7 @@ export const READABLE_EXT_TO_MIME_TYPE: Record<string, string> = {
   me: 'text/me',
   cvs: 'text/cvs',
   tvs: 'text/tvs',
+  sql: 'text/sql',
 };
 
 export const ALLOWED_BLOB_MIME_TYPES = [
index 61a903f5aa7ec8f6b52a7d9b06deac84cc6a4b1c..ca6d03d1d3a73f388fa2aa202c54e4fb8161bfde 100644 (file)
@@ -3,3 +3,11 @@ import { UAParser } from 'ua-parser-js';
 export const ua = () => UAParser(window.navigator.userAgent);
 
 export const isMacOS = () => ua().os.name === 'Mac OS';
+
+export const mobileOrTablet = (): boolean => {
+  const userAgent = ua();
+  const { os, device } = userAgent;
+  if (device.type === 'mobile' || device.type === 'tablet') return true;
+  if (os.name === 'Android' || os.name === 'iOS') return true;
+  return false;
+};