mirror of
				https://github.com/cinnyapp/cinny.git
				synced 2025-11-04 06:20:28 +03:00 
			
		
		
		
	Editor Commands (#1450)
* 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
This commit is contained in:
		
							parent
							
								
									4d0b6b93bc
								
							
						
					
					
						commit
						613e6d6503
					
				
					 34 changed files with 620 additions and 131 deletions
				
			
		
							
								
								
									
										16
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							
							
						
						
									
										16
									
								
								package-lock.json
									
										
									
										generated
									
									
									
								
							| 
						 | 
				
			
			@ -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"
 | 
			
		||||
| 
						 | 
				
			
			@ -5766,9 +5766,9 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "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",
 | 
			
		||||
| 
						 | 
				
			
			@ -5787,9 +5787,9 @@
 | 
			
		|||
      }
 | 
			
		||||
    },
 | 
			
		||||
    "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",
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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',
 | 
			
		||||
      },
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
]);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 && (
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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),
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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> = {
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										10
									
								
								src/app/components/editor/slate.d.ts
									
										
									
									
										vendored
									
									
								
							
							
						
						
									
										10
									
								
								src/app/components/editor/slate.d.ts
									
										
									
									
										vendored
									
									
								
							| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										24
									
								
								src/app/components/editor/types.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/app/components/editor/types.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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',
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,13 @@
 | 
			
		|||
import { BasePoint, BaseRange, Editor, Element, Point, Range, Transforms } from 'slate';
 | 
			
		||||
import { BlockType, MarkType } from './Elements';
 | 
			
		||||
import { EmoticonElement, FormattedText, HeadingLevel, LinkElement, MentionElement } from './slate';
 | 
			
		||||
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,
 | 
			
		||||
| 
						 | 
				
			
			@ -54,6 +61,9 @@ const NESTED_BLOCK = [
 | 
			
		|||
];
 | 
			
		||||
 | 
			
		||||
export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
 | 
			
		||||
  Transforms.collapse(editor, {
 | 
			
		||||
    edge: 'end',
 | 
			
		||||
  });
 | 
			
		||||
  const isActive = isBlockActive(editor, format);
 | 
			
		||||
 | 
			
		||||
  Transforms.unwrapNodes(editor, {
 | 
			
		||||
| 
						 | 
				
			
			@ -163,17 +173,23 @@ export const createLinkElement = (
 | 
			
		|||
  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) => {
 | 
			
		||||
  // without timeout move cursor doesn't works properly.
 | 
			
		||||
  setTimeout(() => {
 | 
			
		||||
    Transforms.move(editor);
 | 
			
		||||
    if (withSpace) editor.insertText(' ');
 | 
			
		||||
  }, 100);
 | 
			
		||||
  Transforms.move(editor);
 | 
			
		||||
  if (withSpace) editor.insertText(' ');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
interface PointUntilCharOptions {
 | 
			
		||||
| 
						 | 
				
			
			@ -230,3 +246,16 @@ export const isEmptyEditor = (editor: Editor): boolean => {
 | 
			
		|||
  }
 | 
			
		||||
  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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			@ -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>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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} />
 | 
			
		||||
  )
 | 
			
		||||
);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -13,7 +13,7 @@ export const Image = style([
 | 
			
		|||
export const Video = style([
 | 
			
		||||
  DefaultReset,
 | 
			
		||||
  {
 | 
			
		||||
    objectFit: 'cover',
 | 
			
		||||
    objectFit: 'contain',
 | 
			
		||||
    width: '100%',
 | 
			
		||||
    height: '100%',
 | 
			
		||||
  },
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										219
									
								
								src/app/hooks/useCommands.ts
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										219
									
								
								src/app/hooks/useCommands.ts
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										109
									
								
								src/app/organisms/room/CommandAutocomplete.tsx
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								src/app/organisms/room/CommandAutocomplete.tsx
									
										
									
									
									
										Normal file
									
								
							| 
						 | 
				
			
			@ -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>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
                        }}
 | 
			
		||||
                      />
 | 
			
		||||
                    }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -81,6 +81,7 @@ function RoomView({ room, eventId }) {
 | 
			
		|||
              <>
 | 
			
		||||
                {canMessage && (
 | 
			
		||||
                  <RoomInput
 | 
			
		||||
                    room={room}
 | 
			
		||||
                    editor={editor}
 | 
			
		||||
                    roomId={roomId}
 | 
			
		||||
                    roomViewRef={roomViewRef}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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();
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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);
 | 
			
		||||
                            }}
 | 
			
		||||
                          />
 | 
			
		||||
                        }
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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={(
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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,
 | 
			
		||||
    },
 | 
			
		||||
  ],
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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));
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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 = [
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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;
 | 
			
		||||
};
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue