"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",
"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"
{
flexGrow: 1,
height: '100%',
- padding: `${toRem(13)} 0`,
+ padding: `${toRem(13)} ${toRem(1)}`,
selectors: {
[`${EditorTextareaScroll}:first-child &`]: {
paddingLeft: toRem(13),
[`${EditorTextareaScroll}:last-child &`]: {
paddingRight: toRem(13),
},
+ '&:focus': {
+ outline: 'none',
+ },
},
},
]);
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';
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;
};
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;
};
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 && (
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
</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,
{children}
</RenderLinkElement>
);
+ case BlockType.Command:
+ return (
+ <RenderCommandElement attributes={attributes} element={element}>
+ {children}
+ </RenderCommandElement>
+ );
default:
return (
<Text className={css.Paragraph} {...attributes}>
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';
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => requestClose(),
+ returnFocusOnDeactivate: false,
clickOutsideDeactivates: true,
allowOutsideClick: true,
isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt),
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';
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';
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';
RoomMention = '#',
UserMention = '@',
Emoticon = ':',
+ Command = '/',
}
export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [
AutocompletePrefix.RoomMention,
AutocompletePrefix.UserMention,
AutocompletePrefix.Emoticon,
+ AutocompletePrefix.Command,
];
export type AutocompleteQuery<TPrefix extends string> = {
+++ /dev/null
-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;
-};
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';
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,
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,
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,
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';
: node.key;
case BlockType.Link:
return `<a href="${node.href}">${node.children}</a>`;
+ case BlockType.Command:
+ return `/${node.command}`;
default:
return children;
}
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;
}
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);
+};
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;
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;
| LinkElement
| MentionElement
| EmoticonElement
+ | CommandElement
| ParagraphElement
| HeadingElement
| CodeLineElement
--- /dev/null
+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',
+}
--- /dev/null
+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;
+};
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';
maxLength={50}
after={<Icon src={Icons.Search} size="50" />}
onChange={handleOnChange}
- autoFocus
+ autoFocus={!mobileOrTablet()}
/>
</Box>
</Header>
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} />
)
);
export const Video = style([
DefaultReset,
{
- objectFit: 'cover',
+ objectFit: 'contain',
width: '100%',
height: '100%',
},
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';
--- /dev/null
+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;
+};
--- /dev/null
+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>
+ );
+}
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,
customHtmlEqualsPlainText,
trimCustomHtml,
isEmptyEditor,
+ getBeginCommand,
+ trimCommand,
} from '../../components/editor';
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
import { UseStateProvider } from '../../components/UseStateProvider';
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 {
} 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));
}, [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];
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;
}
const content: IContent = {
- msgtype: MsgType.Text,
+ msgtype: msgType,
body,
};
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
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();
}
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(
[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));
requestClose={handleCloseAutocomplete}
/>
)}
+ {autocompleteQuery?.prefix === AutocompletePrefix.Command && (
+ <CommandAutocomplete
+ room={room}
+ editor={editor}
+ query={autocompleteQuery}
+ requestClose={handleCloseAutocomplete}
+ />
+ )}
<CustomEditor
editableName="RoomInput"
editor={editor}
onStickerSelect={handleStickerSelect}
requestClose={() => {
setEmojiBoardTab(undefined);
- ReactEditor.focus(editor);
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
}}
/>
}
<>
{canMessage && (
<RoomInput
+ room={room}
editor={editor}
roomId={roomId}
roomViewRef={roomViewRef}
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();
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();
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;
({ 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);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
- if (isHotkey('enter', evt)) {
+ if (enterForNewline ? isHotkey('shift+enter', evt) : isHotkey('enter', evt)) {
evt.preventDefault();
handleSave();
}
onCancel();
}
},
- [onCancel, handleSave]
+ [onCancel, handleSave, enterForNewline]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
[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));
});
editor.insertFragment(initialValue);
- ReactEditor.focus(editor);
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor, getPrevBodyAndFormattedBody]);
useEffect(() => {
onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => {
setEmojiBoard(false);
- ReactEditor.focus(editor);
+ if (!mobileOrTablet()) ReactEditor.focus(editor);
}}
/>
}
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');
/>
}
/>
+ <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={(
isPeopleDrawer: boolean;
useSystemEmoji: boolean;
+ enterForNewline: boolean;
messageLayout: MessageLayout;
messageSpacing: MessageSpacing;
hideMembershipEvents: boolean;
isPeopleDrawer: true,
useSystemEmoji: false,
+ enterForNewline: false,
messageLayout: 0,
messageSpacing: '400',
hideMembershipEvents: false,
},
});
+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,
{
lineHeight: '1em',
verticalAlign: 'middle',
position: 'relative',
- top: '-0.25em',
+ top: '-0.32em',
borderRadius: config.radii.R300,
},
],
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));
+}
me: 'text/me',
cvs: 'text/cvs',
tvs: 'text/tvs',
+ sql: 'text/sql',
};
export const ALLOWED_BLOB_MIME_TYPES = [
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;
+};