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-modal": "3.16.1",
|
||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"sanitize-html": "2.8.0",
|
"sanitize-html": "2.8.0",
|
||||||
"slate": "0.90.0",
|
"slate": "0.94.1",
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
"slate-react": "0.90.0",
|
"slate-react": "0.98.4",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"twemoji": "14.0.2",
|
"twemoji": "14.0.2",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
|
@ -5766,9 +5766,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate": {
|
"node_modules/slate": {
|
||||||
"version": "0.90.0",
|
"version": "0.94.1",
|
||||||
"resolved": "https://registry.npmjs.org/slate/-/slate-0.90.0.tgz",
|
"resolved": "https://registry.npmjs.org/slate/-/slate-0.94.1.tgz",
|
||||||
"integrity": "sha512-dv8idv0JjYyHiAJcVKf5yWKPDMTDi+PSZyfjsnquEI8VB5nmTVGjeJab06lc3o69O7aN05ROwO9/OY8mU1IUPA==",
|
"integrity": "sha512-GH/yizXr1ceBoZ9P9uebIaHe3dC/g6Plpf9nlUwnvoyf6V1UOYrRwkabtOCd3ZfIGxomY4P7lfgLr7FPH8/BKA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"immer": "^9.0.6",
|
"immer": "^9.0.6",
|
||||||
"is-plain-object": "^5.0.0",
|
"is-plain-object": "^5.0.0",
|
||||||
|
@ -5787,9 +5787,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/slate-react": {
|
"node_modules/slate-react": {
|
||||||
"version": "0.90.0",
|
"version": "0.98.4",
|
||||||
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.90.0.tgz",
|
"resolved": "https://registry.npmjs.org/slate-react/-/slate-react-0.98.4.tgz",
|
||||||
"integrity": "sha512-z6pGd6jjU5VazLxlDi6zL3a6yaPBPJ+A2VyIlE/h/rvDywaLYGvk0xcrA9NrK71Dr47HK5ZN2zFEZNleh6wlPA==",
|
"integrity": "sha512-8Of3v9hFuX8rIRc86LuuBhU9t8ps+9ARKL4yyhCrKQYZ93Ep/LFA3GvPGvtf3zYuVadZ8tkhRH8tbHOGNAndLw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@types/is-hotkey": "^0.1.1",
|
"@types/is-hotkey": "^0.1.1",
|
||||||
|
|
|
@ -66,9 +66,9 @@
|
||||||
"react-modal": "3.16.1",
|
"react-modal": "3.16.1",
|
||||||
"react-range": "1.8.14",
|
"react-range": "1.8.14",
|
||||||
"sanitize-html": "2.8.0",
|
"sanitize-html": "2.8.0",
|
||||||
"slate": "0.90.0",
|
"slate": "0.94.1",
|
||||||
"slate-history": "0.93.0",
|
"slate-history": "0.93.0",
|
||||||
"slate-react": "0.90.0",
|
"slate-react": "0.98.4",
|
||||||
"tippy.js": "6.3.7",
|
"tippy.js": "6.3.7",
|
||||||
"twemoji": "14.0.2",
|
"twemoji": "14.0.2",
|
||||||
"ua-parser-js": "1.0.35"
|
"ua-parser-js": "1.0.35"
|
||||||
|
|
|
@ -26,7 +26,7 @@ export const EditorTextarea = style([
|
||||||
{
|
{
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
height: '100%',
|
height: '100%',
|
||||||
padding: `${toRem(13)} 0`,
|
padding: `${toRem(13)} ${toRem(1)}`,
|
||||||
selectors: {
|
selectors: {
|
||||||
[`${EditorTextareaScroll}:first-child &`]: {
|
[`${EditorTextareaScroll}:first-child &`]: {
|
||||||
paddingLeft: toRem(13),
|
paddingLeft: toRem(13),
|
||||||
|
@ -34,6 +34,9 @@ export const EditorTextarea = style([
|
||||||
[`${EditorTextareaScroll}:last-child &`]: {
|
[`${EditorTextareaScroll}:last-child &`]: {
|
||||||
paddingRight: toRem(13),
|
paddingRight: toRem(13),
|
||||||
},
|
},
|
||||||
|
'&:focus': {
|
||||||
|
outline: 'none',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -18,7 +18,8 @@ import {
|
||||||
RenderPlaceholderProps,
|
RenderPlaceholderProps,
|
||||||
} from 'slate-react';
|
} from 'slate-react';
|
||||||
import { withHistory } from 'slate-history';
|
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 { CustomElement } from './slate';
|
||||||
import * as css from './Editor.css';
|
import * as css from './Editor.css';
|
||||||
import { toggleKeyboardShortcut } from './keyboard';
|
import { toggleKeyboardShortcut } from './keyboard';
|
||||||
|
@ -34,8 +35,9 @@ const withInline = (editor: Editor): Editor => {
|
||||||
const { isInline } = editor;
|
const { isInline } = editor;
|
||||||
|
|
||||||
editor.isInline = (element) =>
|
editor.isInline = (element) =>
|
||||||
[BlockType.Mention, BlockType.Emoticon, BlockType.Link].includes(element.type) ||
|
[BlockType.Mention, BlockType.Emoticon, BlockType.Link, BlockType.Command].includes(
|
||||||
isInline(element);
|
element.type
|
||||||
|
) || isInline(element);
|
||||||
|
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
@ -44,7 +46,8 @@ const withVoid = (editor: Editor): Editor => {
|
||||||
const { isVoid } = editor;
|
const { isVoid } = editor;
|
||||||
|
|
||||||
editor.isVoid = (element) =>
|
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 editor;
|
||||||
};
|
};
|
||||||
|
@ -122,7 +125,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.Editor} ref={ref}>
|
<div className={css.Editor} ref={ref}>
|
||||||
<Slate editor={editor} value={initialValue} onChange={onChange}>
|
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||||
{top}
|
{top}
|
||||||
<Box alignItems="Start">
|
<Box alignItems="Start">
|
||||||
{before && (
|
{before && (
|
||||||
|
|
|
@ -1,34 +1,18 @@
|
||||||
import { Scroll, Text } from 'folds';
|
import { Scroll, Text } from 'folds';
|
||||||
import React from 'react';
|
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 * as css from '../../styles/CustomHtml.css';
|
||||||
import { EmoticonElement, LinkElement, MentionElement } from './slate';
|
import { CommandElement, EmoticonElement, LinkElement, MentionElement } from './slate';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
|
import { getBeginCommand } from './utils';
|
||||||
export enum MarkType {
|
import { BlockType } from './types';
|
||||||
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',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Put this at the start and end of an inline component to work around this Chromium bug:
|
// 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
|
// https://bugs.chromium.org/p/chromium/issues/detail?id=1249405
|
||||||
|
@ -62,6 +46,29 @@ function RenderMentionElement({
|
||||||
</span>
|
</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({
|
function RenderEmoticonElement({
|
||||||
attributes,
|
attributes,
|
||||||
|
@ -200,6 +207,12 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
|
||||||
{children}
|
{children}
|
||||||
</RenderLinkElement>
|
</RenderLinkElement>
|
||||||
);
|
);
|
||||||
|
case BlockType.Command:
|
||||||
|
return (
|
||||||
|
<RenderCommandElement attributes={attributes} element={element}>
|
||||||
|
{children}
|
||||||
|
</RenderCommandElement>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<Text className={css.Paragraph} {...attributes}>
|
<Text className={css.Paragraph} {...attributes}>
|
||||||
|
|
|
@ -25,9 +25,9 @@ import {
|
||||||
removeAllMark,
|
removeAllMark,
|
||||||
toggleBlock,
|
toggleBlock,
|
||||||
toggleMark,
|
toggleMark,
|
||||||
} from './common';
|
} from './utils';
|
||||||
import * as css from './Editor.css';
|
import * as css from './Editor.css';
|
||||||
import { BlockType, MarkType } from './Elements';
|
import { BlockType, MarkType } from './types';
|
||||||
import { HeadingLevel } from './slate';
|
import { HeadingLevel } from './slate';
|
||||||
import { isMacOS } from '../../utils/user-agent';
|
import { isMacOS } from '../../utils/user-agent';
|
||||||
import { KeySymbol } from '../../utils/key-symbol';
|
import { KeySymbol } from '../../utils/key-symbol';
|
||||||
|
|
|
@ -19,6 +19,7 @@ export function AutocompleteMenu({ headerContent, requestClose, children }: Auto
|
||||||
focusTrapOptions={{
|
focusTrapOptions={{
|
||||||
initialFocus: false,
|
initialFocus: false,
|
||||||
onDeactivate: () => requestClose(),
|
onDeactivate: () => requestClose(),
|
||||||
|
returnFocusOnDeactivate: false,
|
||||||
clickOutsideDeactivates: true,
|
clickOutsideDeactivates: true,
|
||||||
allowOutsideClick: true,
|
allowOutsideClick: true,
|
||||||
isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt),
|
isKeyForward: (evt: KeyboardEvent) => isHotkey('arrowdown', evt),
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
useAsyncSearch,
|
useAsyncSearch,
|
||||||
} from '../../../hooks/useAsyncSearch';
|
} from '../../../hooks/useAsyncSearch';
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { createEmoticonElement, moveCursor, replaceWithElement } from '../common';
|
import { createEmoticonElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||||
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
import { useRelevantImagePacks } from '../../../hooks/useImagePacks';
|
||||||
import { IEmoji, emojis } from '../../../plugins/emoji';
|
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 { Avatar, AvatarFallback, AvatarImage, Icon, Icons, MenuItem, Text, color } from 'folds';
|
||||||
import { MatrixClient } from 'matrix-js-sdk';
|
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 { getRoomAvatarUrl, joinRuleToIconSrc } from '../../../utils/room';
|
||||||
import { roomIdByActivity } from '../../../../util/sort';
|
import { roomIdByActivity } from '../../../../util/sort';
|
||||||
import initMatrix from '../../../../client/initMatrix';
|
import initMatrix from '../../../../client/initMatrix';
|
||||||
|
|
|
@ -13,7 +13,7 @@ import {
|
||||||
useAsyncSearch,
|
useAsyncSearch,
|
||||||
} from '../../../hooks/useAsyncSearch';
|
} from '../../../hooks/useAsyncSearch';
|
||||||
import { onTabPress } from '../../../utils/keyboard';
|
import { onTabPress } from '../../../utils/keyboard';
|
||||||
import { createMentionElement, moveCursor, replaceWithElement } from '../common';
|
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||||
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
|
import { getMxIdLocalPart, getMxIdServer, validMxId } from '../../../utils/matrix';
|
||||||
|
|
||||||
|
|
|
@ -4,11 +4,13 @@ export enum AutocompletePrefix {
|
||||||
RoomMention = '#',
|
RoomMention = '#',
|
||||||
UserMention = '@',
|
UserMention = '@',
|
||||||
Emoticon = ':',
|
Emoticon = ':',
|
||||||
|
Command = '/',
|
||||||
}
|
}
|
||||||
export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [
|
export const AUTOCOMPLETE_PREFIXES: readonly AutocompletePrefix[] = [
|
||||||
AutocompletePrefix.RoomMention,
|
AutocompletePrefix.RoomMention,
|
||||||
AutocompletePrefix.UserMention,
|
AutocompletePrefix.UserMention,
|
||||||
AutocompletePrefix.Emoticon,
|
AutocompletePrefix.Emoticon,
|
||||||
|
AutocompletePrefix.Command,
|
||||||
];
|
];
|
||||||
|
|
||||||
export type AutocompleteQuery<TPrefix extends string> = {
|
export type AutocompleteQuery<TPrefix extends string> = {
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
export * from './autocomplete';
|
export * from './autocomplete';
|
||||||
export * from './common';
|
export * from './utils';
|
||||||
export * from './Editor';
|
export * from './Editor';
|
||||||
export * from './Elements';
|
export * from './Elements';
|
||||||
export * from './keyboard';
|
export * from './keyboard';
|
||||||
export * from './output';
|
export * from './output';
|
||||||
export * from './Toolbar';
|
export * from './Toolbar';
|
||||||
export * from './input';
|
export * from './input';
|
||||||
|
export * from './types';
|
||||||
|
|
|
@ -4,7 +4,7 @@ import parse from 'html-dom-parser';
|
||||||
import { ChildNode, Element, isText, isTag } from 'domhandler';
|
import { ChildNode, Element, isText, isTag } from 'domhandler';
|
||||||
|
|
||||||
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||||
import { BlockType, MarkType } from './Elements';
|
import { BlockType, MarkType } from './types';
|
||||||
import {
|
import {
|
||||||
BlockQuoteElement,
|
BlockQuoteElement,
|
||||||
CodeBlockElement,
|
CodeBlockElement,
|
||||||
|
@ -21,7 +21,7 @@ import {
|
||||||
UnorderedListElement,
|
UnorderedListElement,
|
||||||
} from './slate';
|
} from './slate';
|
||||||
import { parseMatrixToUrl } from '../../utils/matrix';
|
import { parseMatrixToUrl } from '../../utils/matrix';
|
||||||
import { createEmoticonElement, createMentionElement } from './common';
|
import { createEmoticonElement, createMentionElement } from './utils';
|
||||||
|
|
||||||
const markNodeToType: Record<string, MarkType> = {
|
const markNodeToType: Record<string, MarkType> = {
|
||||||
b: MarkType.Bold,
|
b: MarkType.Bold,
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import { isHotkey } from 'is-hotkey';
|
import { isHotkey } from 'is-hotkey';
|
||||||
import { KeyboardEvent } from 'react';
|
import { KeyboardEvent } from 'react';
|
||||||
import { Editor } from 'slate';
|
import { Editor } from 'slate';
|
||||||
import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './common';
|
import { isAnyMarkActive, isBlockActive, removeAllMark, toggleBlock, toggleMark } from './utils';
|
||||||
import { BlockType, MarkType } from './Elements';
|
import { BlockType, MarkType } from './types';
|
||||||
|
|
||||||
export const INLINE_HOTKEYS: Record<string, MarkType> = {
|
export const INLINE_HOTKEYS: Record<string, MarkType> = {
|
||||||
'mod+b': MarkType.Bold,
|
'mod+b': MarkType.Bold,
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { Descendant, Text } from 'slate';
|
import { Descendant, Text } from 'slate';
|
||||||
|
|
||||||
import { sanitizeText } from '../../utils/sanitize';
|
import { sanitizeText } from '../../utils/sanitize';
|
||||||
import { BlockType } from './Elements';
|
import { BlockType } from './types';
|
||||||
import { CustomElement } from './slate';
|
import { CustomElement } from './slate';
|
||||||
import { parseInlineMD } from '../../utils/markdown';
|
import { parseInlineMD } from '../../utils/markdown';
|
||||||
|
|
||||||
|
@ -57,6 +57,8 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
|
||||||
: node.key;
|
: node.key;
|
||||||
case BlockType.Link:
|
case BlockType.Link:
|
||||||
return `<a href="${node.href}">${node.children}</a>`;
|
return `<a href="${node.href}">${node.children}</a>`;
|
||||||
|
case BlockType.Command:
|
||||||
|
return `/${node.command}`;
|
||||||
default:
|
default:
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
@ -104,6 +106,8 @@ const elementToPlainText = (node: CustomElement, children: string): string => {
|
||||||
return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key;
|
return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key;
|
||||||
case BlockType.Link:
|
case BlockType.Link:
|
||||||
return `[${node.children}](${node.href})`;
|
return `[${node.children}](${node.href})`;
|
||||||
|
case BlockType.Command:
|
||||||
|
return `/${node.command}`;
|
||||||
default:
|
default:
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
@ -129,4 +133,12 @@ export const toPlainText = (node: Descendant | Descendant[]): string => {
|
||||||
export const customHtmlEqualsPlainText = (customHtml: string, plain: string): boolean =>
|
export const customHtmlEqualsPlainText = (customHtml: string, plain: string): boolean =>
|
||||||
customHtml.replace(/<br\/>/g, '\n') === sanitizeText(plain);
|
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 { BaseEditor } from 'slate';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { HistoryEditor } from 'slate-history';
|
import { HistoryEditor } from 'slate-history';
|
||||||
import { BlockType } from './Elements';
|
import { BlockType } from './types';
|
||||||
|
|
||||||
export type HeadingLevel = 1 | 2 | 3;
|
export type HeadingLevel = 1 | 2 | 3;
|
||||||
|
|
||||||
|
@ -39,8 +39,13 @@ export type EmoticonElement = {
|
||||||
shortcode: string;
|
shortcode: string;
|
||||||
children: Text[];
|
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 = {
|
export type ParagraphElement = {
|
||||||
type: BlockType.Paragraph;
|
type: BlockType.Paragraph;
|
||||||
|
@ -84,6 +89,7 @@ export type CustomElement =
|
||||||
| LinkElement
|
| LinkElement
|
||||||
| MentionElement
|
| MentionElement
|
||||||
| EmoticonElement
|
| EmoticonElement
|
||||||
|
| CommandElement
|
||||||
| ParagraphElement
|
| ParagraphElement
|
||||||
| HeadingElement
|
| HeadingElement
|
||||||
| CodeLineElement
|
| 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 { BasePoint, BaseRange, Editor, Element, Point, Range, Text, Transforms } from 'slate';
|
||||||
import { BlockType, MarkType } from './Elements';
|
import { BlockType, MarkType } from './types';
|
||||||
import { EmoticonElement, FormattedText, HeadingLevel, LinkElement, MentionElement } from './slate';
|
import {
|
||||||
|
CommandElement,
|
||||||
|
EmoticonElement,
|
||||||
|
FormattedText,
|
||||||
|
HeadingLevel,
|
||||||
|
LinkElement,
|
||||||
|
MentionElement,
|
||||||
|
} from './slate';
|
||||||
|
|
||||||
const ALL_MARK_TYPE: MarkType[] = [
|
const ALL_MARK_TYPE: MarkType[] = [
|
||||||
MarkType.Bold,
|
MarkType.Bold,
|
||||||
|
@ -54,6 +61,9 @@ const NESTED_BLOCK = [
|
||||||
];
|
];
|
||||||
|
|
||||||
export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
|
export const toggleBlock = (editor: Editor, format: BlockType, option?: BlockOption) => {
|
||||||
|
Transforms.collapse(editor, {
|
||||||
|
edge: 'end',
|
||||||
|
});
|
||||||
const isActive = isBlockActive(editor, format);
|
const isActive = isBlockActive(editor, format);
|
||||||
|
|
||||||
Transforms.unwrapNodes(editor, {
|
Transforms.unwrapNodes(editor, {
|
||||||
|
@ -163,17 +173,23 @@ export const createLinkElement = (
|
||||||
children: typeof children === 'string' ? [{ text: children }] : children,
|
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) => {
|
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||||
Transforms.select(editor, selectRange);
|
Transforms.select(editor, selectRange);
|
||||||
Transforms.insertNodes(editor, element);
|
Transforms.insertNodes(editor, element);
|
||||||
|
Transforms.collapse(editor, {
|
||||||
|
edge: 'end',
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
||||||
// without timeout move cursor doesn't works properly.
|
Transforms.move(editor);
|
||||||
setTimeout(() => {
|
if (withSpace) editor.insertText(' ');
|
||||||
Transforms.move(editor);
|
|
||||||
if (withSpace) editor.insertText(' ');
|
|
||||||
}, 100);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface PointUntilCharOptions {
|
interface PointUntilCharOptions {
|
||||||
|
@ -230,3 +246,16 @@ export const isEmptyEditor = (editor: Editor): boolean => {
|
||||||
}
|
}
|
||||||
return false;
|
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 { useDebounce } from '../../hooks/useDebounce';
|
||||||
import { useThrottle } from '../../hooks/useThrottle';
|
import { useThrottle } from '../../hooks/useThrottle';
|
||||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||||
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
|
|
||||||
const RECENT_GROUP_ID = 'recent_group';
|
const RECENT_GROUP_ID = 'recent_group';
|
||||||
const SEARCH_GROUP_ID = 'search_group';
|
const SEARCH_GROUP_ID = 'search_group';
|
||||||
|
@ -782,7 +783,7 @@ export function EmojiBoard({
|
||||||
maxLength={50}
|
maxLength={50}
|
||||||
after={<Icon src={Icons.Search} size="50" />}
|
after={<Icon src={Icons.Search} size="50" />}
|
||||||
onChange={handleOnChange}
|
onChange={handleOnChange}
|
||||||
autoFocus
|
autoFocus={!mobileOrTablet()}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</Header>
|
</Header>
|
||||||
|
|
|
@ -5,6 +5,6 @@ import * as css from './media.css';
|
||||||
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
|
export const Video = forwardRef<HTMLVideoElement, VideoHTMLAttributes<HTMLVideoElement>>(
|
||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
// eslint-disable-next-line jsx-a11y/media-has-caption
|
// 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([
|
export const Video = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
objectFit: 'cover',
|
objectFit: 'contain',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
height: '100%',
|
height: '100%',
|
||||||
},
|
},
|
||||||
|
|
|
@ -5,8 +5,8 @@ import React, { useEffect, useState } from 'react';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import { getMemberDisplayName } from '../../utils/room';
|
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
|
||||||
import { getMxIdLocalPart, trimReplyFromBody } from '../../utils/matrix';
|
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||||
import { LinePlaceholder } from './placeholder';
|
import { LinePlaceholder } from './placeholder';
|
||||||
import { randomNumberBetween } from '../../utils/common';
|
import { randomNumberBetween } from '../../utils/common';
|
||||||
import * as css from './Reply.css';
|
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 isHotkey from 'is-hotkey';
|
||||||
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
|
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
import { Transforms, Range, Editor } from 'slate';
|
import { Transforms, Editor } from 'slate';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
@ -52,6 +52,8 @@ import {
|
||||||
customHtmlEqualsPlainText,
|
customHtmlEqualsPlainText,
|
||||||
trimCustomHtml,
|
trimCustomHtml,
|
||||||
isEmptyEditor,
|
isEmptyEditor,
|
||||||
|
getBeginCommand,
|
||||||
|
trimCommand,
|
||||||
} from '../../components/editor';
|
} from '../../components/editor';
|
||||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||||
|
@ -92,8 +94,6 @@ import {
|
||||||
getImageMsgContent,
|
getImageMsgContent,
|
||||||
getVideoMsgContent,
|
getVideoMsgContent,
|
||||||
} from './msgContent';
|
} from './msgContent';
|
||||||
import navigation from '../../../client/state/navigation';
|
|
||||||
import cons from '../../../client/state/cons';
|
|
||||||
import { MessageReply } from '../../molecules/message/Message';
|
import { MessageReply } from '../../molecules/message/Message';
|
||||||
import colorMXID from '../../../util/colorMXID';
|
import colorMXID from '../../../util/colorMXID';
|
||||||
import {
|
import {
|
||||||
|
@ -104,17 +104,22 @@ import {
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { sanitizeText } from '../../utils/sanitize';
|
import { sanitizeText } from '../../utils/sanitize';
|
||||||
import { useScreenSize } from '../../hooks/useScreenSize';
|
import { useScreenSize } from '../../hooks/useScreenSize';
|
||||||
|
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||||
|
import { Command, SHRUG, useCommands } from '../../hooks/useCommands';
|
||||||
|
import { mobileOrTablet } from '../../utils/user-agent';
|
||||||
|
|
||||||
interface RoomInputProps {
|
interface RoomInputProps {
|
||||||
editor: Editor;
|
editor: Editor;
|
||||||
roomViewRef: RefObject<HTMLElement>;
|
roomViewRef: RefObject<HTMLElement>;
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
room: Room;
|
||||||
}
|
}
|
||||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, roomViewRef, roomId }, ref) => {
|
({ editor, roomViewRef, roomId, room }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = mx.getRoom(roomId);
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
|
const commands = useCommands(mx, room);
|
||||||
|
|
||||||
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(roomId));
|
||||||
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(roomId));
|
||||||
|
@ -176,36 +181,19 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
}, [editor, msgDraft]);
|
}, [editor, msgDraft]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ReactEditor.focus(editor);
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
return () => {
|
return () => {
|
||||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
if (!isEmptyEditor(editor)) {
|
||||||
setMsgDraft(parsedDraft);
|
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||||
|
setMsgDraft(parsedDraft);
|
||||||
|
} else {
|
||||||
|
roomIdToMsgDraftAtomFamily.remove(roomId);
|
||||||
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
};
|
};
|
||||||
}, [roomId, editor, setMsgDraft]);
|
}, [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(
|
const handleRemoveUpload = useCallback(
|
||||||
(upload: TUploadContent | TUploadContent[]) => {
|
(upload: TUploadContent | TUploadContent[]) => {
|
||||||
const uploads = Array.isArray(upload) ? upload : [upload];
|
const uploads = Array.isArray(upload) ? upload : [upload];
|
||||||
|
@ -257,13 +245,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
const submit = useCallback(() => {
|
const submit = useCallback(() => {
|
||||||
uploadBoardHandlers.current?.handleSend();
|
uploadBoardHandlers.current?.handleSend();
|
||||||
|
|
||||||
const plainText = toPlainText(editor.children).trim();
|
const commandName = getBeginCommand(editor);
|
||||||
const customHtml = trimCustomHtml(
|
|
||||||
|
let plainText = toPlainText(editor.children).trim();
|
||||||
|
let customHtml = trimCustomHtml(
|
||||||
toMatrixCustomHTML(editor.children, {
|
toMatrixCustomHTML(editor.children, {
|
||||||
allowTextFormatting: true,
|
allowTextFormatting: true,
|
||||||
allowMarkdown: isMarkdown,
|
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;
|
if (plainText === '') return;
|
||||||
|
|
||||||
|
@ -283,7 +296,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
}
|
}
|
||||||
|
|
||||||
const content: IContent = {
|
const content: IContent = {
|
||||||
msgtype: MsgType.Text,
|
msgtype: msgType,
|
||||||
body,
|
body,
|
||||||
};
|
};
|
||||||
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
if (replyDraft || !customHtmlEqualsPlainText(formattedBody, body)) {
|
||||||
|
@ -302,11 +315,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft();
|
setReplyDraft();
|
||||||
sendTypingStatus(false);
|
sendTypingStatus(false);
|
||||||
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown]);
|
}, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]);
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (isHotkey('enter', evt)) {
|
if (enterForNewline ? isHotkey('shift+enter', evt) : isHotkey('enter', evt)) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
}
|
}
|
||||||
|
@ -314,19 +327,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
setReplyDraft();
|
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(
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
|
@ -347,7 +349,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
[editor, sendTypingStatus]
|
[editor, sendTypingStatus]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
|
setAutocompleteQuery(undefined);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||||
|
@ -452,6 +457,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
requestClose={handleCloseAutocomplete}
|
requestClose={handleCloseAutocomplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{autocompleteQuery?.prefix === AutocompletePrefix.Command && (
|
||||||
|
<CommandAutocomplete
|
||||||
|
room={room}
|
||||||
|
editor={editor}
|
||||||
|
query={autocompleteQuery}
|
||||||
|
requestClose={handleCloseAutocomplete}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<CustomEditor
|
<CustomEditor
|
||||||
editableName="RoomInput"
|
editableName="RoomInput"
|
||||||
editor={editor}
|
editor={editor}
|
||||||
|
@ -523,7 +536,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
onStickerSelect={handleStickerSelect}
|
onStickerSelect={handleStickerSelect}
|
||||||
requestClose={() => {
|
requestClose={() => {
|
||||||
setEmojiBoardTab(undefined);
|
setEmojiBoardTab(undefined);
|
||||||
ReactEditor.focus(editor);
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
@ -81,6 +81,7 @@ function RoomView({ room, eventId }) {
|
||||||
<>
|
<>
|
||||||
{canMessage && (
|
{canMessage && (
|
||||||
<RoomInput
|
<RoomInput
|
||||||
|
room={room}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
roomViewRef={roomViewRef}
|
roomViewRef={roomViewRef}
|
||||||
|
|
|
@ -696,7 +696,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
const hideOptions = () => setHover(false);
|
const hideOptions = () => setHover(false);
|
||||||
|
|
||||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||||
if (evt.altKey) return;
|
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
|
||||||
const tag = (evt.target as any).tagName;
|
const tag = (evt.target as any).tagName;
|
||||||
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
@ -965,7 +965,7 @@ export const Event = as<'div', EventProps>(
|
||||||
const hideOptions = () => setHover(false);
|
const hideOptions = () => setHover(false);
|
||||||
|
|
||||||
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
|
||||||
if (evt.altKey) return;
|
if (evt.altKey || !window.getSelection()?.isCollapsed) return;
|
||||||
const tag = (evt.target as any).tagName;
|
const tag = (evt.target as any).tagName;
|
||||||
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
if (typeof tag === 'string' && tag.toLowerCase() === 'a') return;
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
|
|
|
@ -32,6 +32,7 @@ import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
|
import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||||
|
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||||
|
|
||||||
type MessageEditorProps = {
|
type MessageEditorProps = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
|
@ -44,6 +45,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const editor = useEditor();
|
const editor = useEditor();
|
||||||
|
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
const [toolbar, setToolbar] = useState(globalToolbar);
|
const [toolbar, setToolbar] = useState(globalToolbar);
|
||||||
|
@ -118,7 +120,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||||
|
|
||||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||||
(evt) => {
|
(evt) => {
|
||||||
if (isHotkey('enter', evt)) {
|
if (enterForNewline ? isHotkey('shift+enter', evt) : isHotkey('enter', evt)) {
|
||||||
evt.preventDefault();
|
evt.preventDefault();
|
||||||
handleSave();
|
handleSave();
|
||||||
}
|
}
|
||||||
|
@ -127,7 +129,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||||
onCancel();
|
onCancel();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onCancel, handleSave]
|
[onCancel, handleSave, enterForNewline]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
|
@ -146,7 +148,10 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||||
[editor]
|
[editor]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
|
const handleCloseAutocomplete = useCallback(() => {
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
setAutocompleteQuery(undefined);
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||||
|
@ -167,7 +172,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||||
});
|
});
|
||||||
|
|
||||||
editor.insertFragment(initialValue);
|
editor.insertFragment(initialValue);
|
||||||
ReactEditor.focus(editor);
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
}, [editor, getPrevBodyAndFormattedBody]);
|
}, [editor, getPrevBodyAndFormattedBody]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -258,7 +263,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||||
onCustomEmojiSelect={handleEmoticonSelect}
|
onCustomEmojiSelect={handleEmoticonSelect}
|
||||||
requestClose={() => {
|
requestClose={() => {
|
||||||
setEmojiBoard(false);
|
setEmojiBoard(false);
|
||||||
ReactEditor.focus(editor);
|
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,6 +49,7 @@ import { settingsAtom } from '../../state/settings';
|
||||||
function AppearanceSection() {
|
function AppearanceSection() {
|
||||||
const [, updateState] = useState({});
|
const [, updateState] = useState({});
|
||||||
|
|
||||||
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
const [messageLayout, setMessageLayout] = useSetting(settingsAtom, 'messageLayout');
|
||||||
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
const [messageSpacing, setMessageSpacing] = useSetting(settingsAtom, 'messageSpacing');
|
||||||
const [useSystemEmoji, setUseSystemEmoji] = useSetting(settingsAtom, 'useSystemEmoji');
|
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
|
<SettingTile
|
||||||
title="Inline Markdown formatting"
|
title="Inline Markdown formatting"
|
||||||
options={(
|
options={(
|
||||||
|
|
|
@ -11,6 +11,7 @@ export interface Settings {
|
||||||
isPeopleDrawer: boolean;
|
isPeopleDrawer: boolean;
|
||||||
useSystemEmoji: boolean;
|
useSystemEmoji: boolean;
|
||||||
|
|
||||||
|
enterForNewline: boolean;
|
||||||
messageLayout: MessageLayout;
|
messageLayout: MessageLayout;
|
||||||
messageSpacing: MessageSpacing;
|
messageSpacing: MessageSpacing;
|
||||||
hideMembershipEvents: boolean;
|
hideMembershipEvents: boolean;
|
||||||
|
@ -30,6 +31,7 @@ const defaultSettings: Settings = {
|
||||||
isPeopleDrawer: true,
|
isPeopleDrawer: true,
|
||||||
useSystemEmoji: false,
|
useSystemEmoji: false,
|
||||||
|
|
||||||
|
enterForNewline: false,
|
||||||
messageLayout: 0,
|
messageLayout: 0,
|
||||||
messageSpacing: '400',
|
messageSpacing: '400',
|
||||||
hideMembershipEvents: false,
|
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([
|
export const EmoticonBase = style([
|
||||||
DefaultReset,
|
DefaultReset,
|
||||||
{
|
{
|
||||||
|
@ -166,7 +191,7 @@ export const Emoticon = recipe({
|
||||||
lineHeight: '1em',
|
lineHeight: '1em',
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
top: '-0.25em',
|
top: '-0.32em',
|
||||||
borderRadius: config.radii.R300,
|
borderRadius: config.radii.R300,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
|
@ -162,10 +162,10 @@ export const factoryEventSentBy = (senderId: string) => (ev: MatrixEvent) =>
|
||||||
export const eventWithShortcode = (ev: MatrixEvent) =>
|
export const eventWithShortcode = (ev: MatrixEvent) =>
|
||||||
typeof ev.getContent().shortcode === 'string';
|
typeof ev.getContent().shortcode === 'string';
|
||||||
|
|
||||||
export const trimReplyFromBody = (body: string): string => {
|
export function hasDMWith(mx: MatrixClient, userId: string) {
|
||||||
if (body.match(/^> <.+>/) === null) return body;
|
const dmLikeRooms = mx
|
||||||
|
.getRooms()
|
||||||
|
.filter((room) => mx.isRoomEncrypted(room.roomId) && room.getMembers().length <= 2);
|
||||||
|
|
||||||
const trimmedBody = body.slice(body.indexOf('\n\n') + 2);
|
return dmLikeRooms.find((room) => room.getMember(userId));
|
||||||
|
}
|
||||||
return trimmedBody || body;
|
|
||||||
};
|
|
||||||
|
|
|
@ -92,6 +92,7 @@ export const READABLE_EXT_TO_MIME_TYPE: Record<string, string> = {
|
||||||
me: 'text/me',
|
me: 'text/me',
|
||||||
cvs: 'text/cvs',
|
cvs: 'text/cvs',
|
||||||
tvs: 'text/tvs',
|
tvs: 'text/tvs',
|
||||||
|
sql: 'text/sql',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const ALLOWED_BLOB_MIME_TYPES = [
|
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 ua = () => UAParser(window.navigator.userAgent);
|
||||||
|
|
||||||
export const isMacOS = () => ua().os.name === 'Mac OS';
|
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…
Reference in a new issue