Edit option (#1447)
* add func to parse html to editor input * add plain to html input function * re-construct markdown * fix missing return * fix falsy condition * fix reading href instead of src of emoji * add message editor - WIP * fix plain to editor input func * add save edit message functionality * show edited event source code * focus message input on after editing message * use del tag for strike-through instead of s * prevent autocomplete from re-opening after esc * scroll out of view msg editor in view * handle up arrow edit * handle scroll to message editor without effect * revert prev commit: effect run after editor render * ignore relation event from editable * allow data-md tag for del and em in sanitize html * prevent edit without changes * ignore previous reply when replying to msg * fix up arrow edit not working sometime
This commit is contained in:
parent
152576e85d
commit
f5bcc9b851
18 changed files with 957 additions and 108 deletions
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -23,6 +23,7 @@
|
|||
"classnames": "2.3.2",
|
||||
"dateformat": "5.0.3",
|
||||
"dayjs": "1.11.10",
|
||||
"domhandler": "5.0.3",
|
||||
"emojibase": "6.1.0",
|
||||
"emojibase-data": "7.0.1",
|
||||
"file-saver": "2.0.5",
|
||||
|
@ -30,6 +31,7 @@
|
|||
"focus-trap-react": "10.0.2",
|
||||
"folds": "1.5.0",
|
||||
"formik": "2.2.9",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
|
|
|
@ -33,6 +33,7 @@
|
|||
"classnames": "2.3.2",
|
||||
"dateformat": "5.0.3",
|
||||
"dayjs": "1.11.10",
|
||||
"domhandler": "5.0.3",
|
||||
"emojibase": "6.1.0",
|
||||
"emojibase-data": "7.0.1",
|
||||
"file-saver": "2.0.5",
|
||||
|
@ -40,6 +41,7 @@
|
|||
"focus-trap-react": "10.0.2",
|
||||
"folds": "1.5.0",
|
||||
"formik": "2.2.9",
|
||||
"html-dom-parser": "4.0.0",
|
||||
"html-react-parser": "4.2.0",
|
||||
"immer": "9.0.16",
|
||||
"is-hotkey": "0.2.0",
|
||||
|
|
|
@ -50,12 +50,13 @@ const withVoid = (editor: Editor): Editor => {
|
|||
};
|
||||
|
||||
export const useEditor = (): Editor => {
|
||||
const [editor] = useState(withInline(withVoid(withReact(withHistory(createEditor())))));
|
||||
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
|
||||
return editor;
|
||||
};
|
||||
|
||||
export type EditorChangeHandler = (value: Descendant[]) => void;
|
||||
type CustomEditorProps = {
|
||||
editableName?: string;
|
||||
top?: ReactNode;
|
||||
bottom?: ReactNode;
|
||||
before?: ReactNode;
|
||||
|
@ -71,6 +72,7 @@ type CustomEditorProps = {
|
|||
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
(
|
||||
{
|
||||
editableName,
|
||||
top,
|
||||
bottom,
|
||||
before,
|
||||
|
@ -137,6 +139,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
|||
hideTrack
|
||||
>
|
||||
<Editable
|
||||
data-editable-name={editableName}
|
||||
className={css.EditorTextarea}
|
||||
placeholder={placeholder}
|
||||
renderPlaceholder={renderPlaceholder}
|
||||
|
|
|
@ -221,3 +221,12 @@ export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
|
|||
});
|
||||
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
|
||||
};
|
||||
|
||||
export const isEmptyEditor = (editor: Editor): boolean => {
|
||||
const firstChildren = editor.children[0];
|
||||
if (firstChildren && Element.isElement(firstChildren)) {
|
||||
const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
|
||||
return isEmpty;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
|
|
@ -5,3 +5,4 @@ export * from './Elements';
|
|||
export * from './keyboard';
|
||||
export * from './output';
|
||||
export * from './Toolbar';
|
||||
export * from './input';
|
||||
|
|
327
src/app/components/editor/input.ts
Normal file
327
src/app/components/editor/input.ts
Normal file
|
@ -0,0 +1,327 @@
|
|||
/* eslint-disable no-param-reassign */
|
||||
import { Descendant, Text } from 'slate';
|
||||
import parse from 'html-dom-parser';
|
||||
import { ChildNode, Element, isText, isTag } from 'domhandler';
|
||||
|
||||
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||
import { BlockType, MarkType } from './Elements';
|
||||
import {
|
||||
BlockQuoteElement,
|
||||
CodeBlockElement,
|
||||
CodeLineElement,
|
||||
EmoticonElement,
|
||||
HeadingElement,
|
||||
HeadingLevel,
|
||||
InlineElement,
|
||||
ListItemElement,
|
||||
MentionElement,
|
||||
OrderedListElement,
|
||||
ParagraphElement,
|
||||
QuoteLineElement,
|
||||
UnorderedListElement,
|
||||
} from './slate';
|
||||
import { parseMatrixToUrl } from '../../utils/matrix';
|
||||
import { createEmoticonElement, createMentionElement } from './common';
|
||||
|
||||
const markNodeToType: Record<string, MarkType> = {
|
||||
b: MarkType.Bold,
|
||||
strong: MarkType.Bold,
|
||||
i: MarkType.Italic,
|
||||
em: MarkType.Italic,
|
||||
u: MarkType.Underline,
|
||||
s: MarkType.StrikeThrough,
|
||||
del: MarkType.StrikeThrough,
|
||||
code: MarkType.Code,
|
||||
span: MarkType.Spoiler,
|
||||
};
|
||||
|
||||
const elementToTextMark = (node: Element): MarkType | undefined => {
|
||||
const markType = markNodeToType[node.name];
|
||||
if (!markType) return undefined;
|
||||
|
||||
if (markType === MarkType.Spoiler && node.attribs['data-mx-spoiler'] === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
markType === MarkType.Code &&
|
||||
node.parent &&
|
||||
'name' in node.parent &&
|
||||
node.parent.name === 'pre'
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return markType;
|
||||
};
|
||||
|
||||
const parseNodeText = (node: ChildNode): string => {
|
||||
if (isText(node)) {
|
||||
return node.data;
|
||||
}
|
||||
if (isTag(node)) {
|
||||
return node.children.map((child) => parseNodeText(child)).join('');
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
const elementToInlineNode = (node: Element): MentionElement | EmoticonElement | undefined => {
|
||||
if (node.name === 'img' && node.attribs['data-mx-emoticon'] !== undefined) {
|
||||
const { src, alt } = node.attribs;
|
||||
if (!src) return undefined;
|
||||
return createEmoticonElement(src, alt || 'Unknown Emoji');
|
||||
}
|
||||
if (node.name === 'a') {
|
||||
const { href } = node.attribs;
|
||||
if (typeof href !== 'string') return undefined;
|
||||
const [mxId] = parseMatrixToUrl(href);
|
||||
if (mxId) {
|
||||
return createMentionElement(mxId, mxId, false);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseInlineNodes = (node: ChildNode): InlineElement[] => {
|
||||
if (isText(node)) {
|
||||
return [{ text: node.data }];
|
||||
}
|
||||
if (isTag(node)) {
|
||||
const markType = elementToTextMark(node);
|
||||
if (markType) {
|
||||
const children = node.children.flatMap(parseInlineNodes);
|
||||
if (node.attribs['data-md'] !== undefined) {
|
||||
children.unshift({ text: node.attribs['data-md'] });
|
||||
children.push({ text: node.attribs['data-md'] });
|
||||
} else {
|
||||
children.forEach((child) => {
|
||||
if (Text.isText(child)) {
|
||||
child[markType] = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
const inlineNode = elementToInlineNode(node);
|
||||
if (inlineNode) return [inlineNode];
|
||||
|
||||
if (node.name === 'a') {
|
||||
const children = node.childNodes.flatMap(parseInlineNodes);
|
||||
children.unshift({ text: '[' });
|
||||
children.push({ text: `](${node.attribs.href})` });
|
||||
return children;
|
||||
}
|
||||
|
||||
return node.childNodes.flatMap(parseInlineNodes);
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
const parseBlockquoteNode = (node: Element): BlockQuoteElement => {
|
||||
const children: QuoteLineElement[] = [];
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
const appendLine = () => {
|
||||
if (lineHolder.length === 0) return;
|
||||
|
||||
children.push({
|
||||
type: BlockType.QuoteLine,
|
||||
children: lineHolder,
|
||||
});
|
||||
lineHolder = [];
|
||||
};
|
||||
|
||||
node.children.forEach((child) => {
|
||||
if (isText(child)) {
|
||||
lineHolder.push({ text: child.data });
|
||||
return;
|
||||
}
|
||||
if (isTag(child)) {
|
||||
if (child.name === 'br') {
|
||||
appendLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.name === 'p') {
|
||||
appendLine();
|
||||
children.push({
|
||||
type: BlockType.QuoteLine,
|
||||
children: child.children.flatMap((c) => parseInlineNodes(c)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
|
||||
}
|
||||
});
|
||||
appendLine();
|
||||
|
||||
return {
|
||||
type: BlockType.BlockQuote,
|
||||
children,
|
||||
};
|
||||
};
|
||||
const parseCodeBlockNode = (node: Element): CodeBlockElement => {
|
||||
const children: CodeLineElement[] = [];
|
||||
|
||||
const code = parseNodeText(node).trim();
|
||||
code.split('\n').forEach((lineTxt) =>
|
||||
children.push({
|
||||
type: BlockType.CodeLine,
|
||||
children: [
|
||||
{
|
||||
text: lineTxt,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
type: BlockType.CodeBlock,
|
||||
children,
|
||||
};
|
||||
};
|
||||
const parseListNode = (node: Element): OrderedListElement | UnorderedListElement => {
|
||||
const children: ListItemElement[] = [];
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
const appendLine = () => {
|
||||
if (lineHolder.length === 0) return;
|
||||
|
||||
children.push({
|
||||
type: BlockType.ListItem,
|
||||
children: lineHolder,
|
||||
});
|
||||
lineHolder = [];
|
||||
};
|
||||
|
||||
node.children.forEach((child) => {
|
||||
if (isText(child)) {
|
||||
lineHolder.push({ text: child.data });
|
||||
return;
|
||||
}
|
||||
if (isTag(child)) {
|
||||
if (child.name === 'br') {
|
||||
appendLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (child.name === 'li') {
|
||||
appendLine();
|
||||
children.push({
|
||||
type: BlockType.ListItem,
|
||||
children: child.children.flatMap((c) => parseInlineNodes(c)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
parseInlineNodes(child).forEach((inlineNode) => lineHolder.push(inlineNode));
|
||||
}
|
||||
});
|
||||
appendLine();
|
||||
|
||||
return {
|
||||
type: node.name === 'ol' ? BlockType.OrderedList : BlockType.UnorderedList,
|
||||
children,
|
||||
};
|
||||
};
|
||||
const parseHeadingNode = (node: Element): HeadingElement => {
|
||||
const children = node.children.flatMap((child) => parseInlineNodes(child));
|
||||
|
||||
const headingMatch = node.name.match(/^h([123456])$/);
|
||||
const [, g1AsLevel] = headingMatch ?? ['h3', '3'];
|
||||
const level = parseInt(g1AsLevel, 10);
|
||||
return {
|
||||
type: BlockType.Heading,
|
||||
level: (level <= 3 ? level : 3) as HeadingLevel,
|
||||
children,
|
||||
};
|
||||
};
|
||||
|
||||
export const domToEditorInput = (domNodes: ChildNode[]): Descendant[] => {
|
||||
const children: Descendant[] = [];
|
||||
|
||||
let lineHolder: InlineElement[] = [];
|
||||
|
||||
const appendLine = () => {
|
||||
if (lineHolder.length === 0) return;
|
||||
|
||||
children.push({
|
||||
type: BlockType.Paragraph,
|
||||
children: lineHolder,
|
||||
});
|
||||
lineHolder = [];
|
||||
};
|
||||
|
||||
domNodes.forEach((node) => {
|
||||
if (isText(node)) {
|
||||
lineHolder.push({ text: node.data });
|
||||
return;
|
||||
}
|
||||
if (isTag(node)) {
|
||||
if (node.name === 'br') {
|
||||
appendLine();
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.name === 'p') {
|
||||
appendLine();
|
||||
children.push({
|
||||
type: BlockType.Paragraph,
|
||||
children: node.children.flatMap((child) => parseInlineNodes(child)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.name === 'blockquote') {
|
||||
appendLine();
|
||||
children.push(parseBlockquoteNode(node));
|
||||
return;
|
||||
}
|
||||
if (node.name === 'pre') {
|
||||
appendLine();
|
||||
children.push(parseCodeBlockNode(node));
|
||||
return;
|
||||
}
|
||||
if (node.name === 'ol' || node.name === 'ul') {
|
||||
appendLine();
|
||||
children.push(parseListNode(node));
|
||||
return;
|
||||
}
|
||||
|
||||
if (node.name.match(/^h[123456]$/)) {
|
||||
appendLine();
|
||||
children.push(parseHeadingNode(node));
|
||||
return;
|
||||
}
|
||||
|
||||
parseInlineNodes(node).forEach((inlineNode) => lineHolder.push(inlineNode));
|
||||
}
|
||||
});
|
||||
appendLine();
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
export const htmlToEditorInput = (unsafeHtml: string): Descendant[] => {
|
||||
const sanitizedHtml = sanitizeCustomHtml(unsafeHtml);
|
||||
|
||||
const domNodes = parse(sanitizedHtml);
|
||||
const editorNodes = domToEditorInput(domNodes);
|
||||
return editorNodes;
|
||||
};
|
||||
|
||||
export const plainToEditorInput = (text: string): Descendant[] => {
|
||||
const editorNodes: Descendant[] = text.split('\n').map((lineText) => {
|
||||
const paragraphNode: ParagraphElement = {
|
||||
type: BlockType.Paragraph,
|
||||
children: [
|
||||
{
|
||||
text: lineText,
|
||||
},
|
||||
],
|
||||
};
|
||||
return paragraphNode;
|
||||
});
|
||||
return editorNodes;
|
||||
};
|
|
@ -1,7 +1,8 @@
|
|||
import { Descendant, Text } from 'slate';
|
||||
|
||||
import { sanitizeText } from '../../utils/sanitize';
|
||||
import { BlockType } from './Elements';
|
||||
import { CustomElement, FormattedText } from './slate';
|
||||
import { CustomElement } from './slate';
|
||||
import { parseInlineMD } from '../../utils/markdown';
|
||||
|
||||
export type OutputOptions = {
|
||||
|
@ -9,13 +10,13 @@ export type OutputOptions = {
|
|||
allowMarkdown?: boolean;
|
||||
};
|
||||
|
||||
const textToCustomHtml = (node: FormattedText, opts: OutputOptions): string => {
|
||||
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
||||
let string = sanitizeText(node.text);
|
||||
if (opts.allowTextFormatting) {
|
||||
if (node.bold) string = `<strong>${string}</strong>`;
|
||||
if (node.italic) string = `<i>${string}</i>`;
|
||||
if (node.underline) string = `<u>${string}</u>`;
|
||||
if (node.strikeThrough) string = `<s>${string}</s>`;
|
||||
if (node.strikeThrough) string = `<del>${string}</del>`;
|
||||
if (node.code) string = `<code>${string}</code>`;
|
||||
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
|
||||
}
|
||||
|
@ -47,6 +48,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => {
|
|||
return `<ol>${children}</ol>`;
|
||||
case BlockType.UnorderedList:
|
||||
return `<ul>${children}</ul>`;
|
||||
|
||||
case BlockType.Mention:
|
||||
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
|
||||
case BlockType.Emoticon:
|
||||
|
|
19
src/app/components/editor/slate.d.ts
vendored
19
src/app/components/editor/slate.d.ts
vendored
|
@ -23,13 +23,9 @@ export type FormattedText = Text & {
|
|||
export type LinkElement = {
|
||||
type: BlockType.Link;
|
||||
href: string;
|
||||
children: FormattedText[];
|
||||
};
|
||||
export type SpoilerElement = {
|
||||
type: 'spoiler';
|
||||
alert?: string;
|
||||
children: FormattedText[];
|
||||
children: Text[];
|
||||
};
|
||||
|
||||
export type MentionElement = {
|
||||
type: BlockType.Mention;
|
||||
id: string;
|
||||
|
@ -44,14 +40,16 @@ export type EmoticonElement = {
|
|||
children: Text[];
|
||||
};
|
||||
|
||||
export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement;
|
||||
|
||||
export type ParagraphElement = {
|
||||
type: BlockType.Paragraph;
|
||||
children: FormattedText[];
|
||||
children: InlineElement[];
|
||||
};
|
||||
export type HeadingElement = {
|
||||
type: BlockType.Heading;
|
||||
level: HeadingLevel;
|
||||
children: FormattedText[];
|
||||
children: InlineElement[];
|
||||
};
|
||||
export type CodeLineElement = {
|
||||
type: BlockType.CodeLine;
|
||||
|
@ -63,7 +61,7 @@ export type CodeBlockElement = {
|
|||
};
|
||||
export type QuoteLineElement = {
|
||||
type: BlockType.QuoteLine;
|
||||
children: FormattedText[];
|
||||
children: InlineElement[];
|
||||
};
|
||||
export type BlockQuoteElement = {
|
||||
type: BlockType.BlockQuote;
|
||||
|
@ -71,7 +69,7 @@ export type BlockQuoteElement = {
|
|||
};
|
||||
export type ListItemElement = {
|
||||
type: BlockType.ListItem;
|
||||
children: FormattedText[];
|
||||
children: InlineElement[];
|
||||
};
|
||||
export type OrderedListElement = {
|
||||
type: BlockType.OrderedList;
|
||||
|
@ -84,7 +82,6 @@ export type UnorderedListElement = {
|
|||
|
||||
export type CustomElement =
|
||||
| LinkElement
|
||||
// | SpoilerElement
|
||||
| MentionElement
|
||||
| EmoticonElement
|
||||
| ParagraphElement
|
||||
|
|
|
@ -12,7 +12,7 @@ import { useAtom } from 'jotai';
|
|||
import isHotkey from 'is-hotkey';
|
||||
import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Transforms, Range, Editor, Element } from 'slate';
|
||||
import { Transforms, Range, Editor } from 'slate';
|
||||
import {
|
||||
Box,
|
||||
Dialog,
|
||||
|
@ -51,6 +51,7 @@ import {
|
|||
resetEditorHistory,
|
||||
customHtmlEqualsPlainText,
|
||||
trimCustomHtml,
|
||||
isEmptyEditor,
|
||||
} from '../../components/editor';
|
||||
import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
|
@ -95,7 +96,12 @@ import navigation from '../../../client/state/navigation';
|
|||
import cons from '../../../client/state/cons';
|
||||
import { MessageReply } from '../../molecules/message/Message';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room';
|
||||
import {
|
||||
parseReplyBody,
|
||||
parseReplyFormattedBody,
|
||||
trimReplyFromBody,
|
||||
trimReplyFromFormattedBody,
|
||||
} from '../../utils/room';
|
||||
import { sanitizeText } from '../../utils/sanitize';
|
||||
import { useScreenSize } from '../../hooks/useScreenSize';
|
||||
|
||||
|
@ -264,13 +270,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
let body = plainText;
|
||||
let formattedBody = customHtml;
|
||||
if (replyDraft) {
|
||||
body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body;
|
||||
body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
|
||||
formattedBody =
|
||||
parseReplyFormattedBody(
|
||||
roomId,
|
||||
replyDraft.userId,
|
||||
replyDraft.eventId,
|
||||
replyDraft.formattedBody ?? sanitizeText(replyDraft.body)
|
||||
replyDraft.formattedBody
|
||||
? trimReplyFromFormattedBody(replyDraft.formattedBody)
|
||||
: sanitizeText(replyDraft.body)
|
||||
) + formattedBody;
|
||||
}
|
||||
|
||||
|
@ -321,19 +329,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
[submit, editor, setReplyDraft]
|
||||
);
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(() => {
|
||||
const firstChildren = editor.children[0];
|
||||
if (firstChildren && Element.isElement(firstChildren)) {
|
||||
const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
|
||||
sendTypingStatus(!isEmpty);
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (isHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
sendTypingStatus(!isEmptyEditor(editor));
|
||||
|
||||
const prevWordRange = getPrevWorldRange(editor);
|
||||
const query = prevWordRange
|
||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||||
: undefined;
|
||||
setAutocompleteQuery(query);
|
||||
}, [editor, sendTypingStatus]);
|
||||
},
|
||||
[editor, sendTypingStatus]
|
||||
);
|
||||
|
||||
const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
|
||||
|
||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||
|
@ -419,7 +433,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={() => setAutocompleteQuery(undefined)}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||||
|
@ -427,7 +441,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={() => setAutocompleteQuery(undefined)}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
||||
|
@ -435,10 +449,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
imagePackRooms={imagePackRooms}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={() => setAutocompleteQuery(undefined)}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
<CustomEditor
|
||||
editableName="RoomInput"
|
||||
editor={editor}
|
||||
placeholder="Send a message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
|
|
|
@ -15,11 +15,9 @@ import {
|
|||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventTimelineSetHandlerMap,
|
||||
EventType,
|
||||
IEncryptedFile,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
RelationType,
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomEventHandlerMap,
|
||||
|
@ -45,6 +43,7 @@ import {
|
|||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import Linkify from 'linkify-react';
|
||||
import {
|
||||
decryptFile,
|
||||
|
@ -53,13 +52,12 @@ import {
|
|||
getMxIdLocalPart,
|
||||
isRoomId,
|
||||
isUserId,
|
||||
matrixEventByRecency,
|
||||
} from '../../utils/matrix';
|
||||
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../hooks/useAlive';
|
||||
import { scrollToBottom } from '../../utils/dom';
|
||||
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
CompactPlaceholder,
|
||||
|
@ -80,7 +78,11 @@ import {
|
|||
} from '../../components/message';
|
||||
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
|
||||
import {
|
||||
canEditEvent,
|
||||
decryptAllTimelineEvent,
|
||||
getEditedEvent,
|
||||
getEventReactions,
|
||||
getLatestEditableEvt,
|
||||
getMemberDisplayName,
|
||||
getReactionContent,
|
||||
isMembershipChanged,
|
||||
|
@ -124,11 +126,12 @@ import { useDebounce } from '../../hooks/useDebounce';
|
|||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import * as css from './RoomTimeline.css';
|
||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
||||
import { createMentionElement, moveCursor } from '../../components/editor';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../components/editor';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
|
||||
import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
||||
import { MessageEvent } from '../../../types/matrix/room';
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||
|
||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||
({ position, className, ...props }, ref) => (
|
||||
|
@ -226,34 +229,6 @@ export const getEventIdAbsoluteIndex = (
|
|||
return baseIndex + eventIndex;
|
||||
};
|
||||
|
||||
export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
|
||||
timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction
|
||||
);
|
||||
|
||||
export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
|
||||
timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
|
||||
|
||||
export const getLatestEdit = (
|
||||
targetEvent: MatrixEvent,
|
||||
editEvents: MatrixEvent[]
|
||||
): MatrixEvent | undefined => {
|
||||
const eventByTargetSender = (rEvent: MatrixEvent) =>
|
||||
rEvent.getSender() === targetEvent.getSender();
|
||||
return editEvents.sort(matrixEventByRecency).find(eventByTargetSender);
|
||||
};
|
||||
|
||||
export const getEditedEvent = (
|
||||
mEventId: string,
|
||||
mEvent: MatrixEvent,
|
||||
timelineSet: EventTimelineSet
|
||||
): MatrixEvent | undefined => {
|
||||
const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
|
||||
return edits && getLatestEdit(mEvent, edits.getRelations());
|
||||
};
|
||||
|
||||
export const factoryGetFileSrcUrl =
|
||||
(httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
|
||||
if (encFile) {
|
||||
|
@ -483,6 +458,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
|
||||
const canRedact = canDoAction('redact', myPowerLevel);
|
||||
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
|
||||
const [editId, setEditId] = useState<string>();
|
||||
|
||||
const imagePackRooms: Room[] = useMemo(() => {
|
||||
const allParentSpaces = [
|
||||
|
@ -572,7 +548,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
|
||||
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||
|
||||
const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
|
||||
const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
|
||||
useVirtualPaginator({
|
||||
count: eventsLength,
|
||||
limit: PAGINATION_LIMIT,
|
||||
range: timeline.range,
|
||||
|
@ -701,6 +678,29 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
useCallback(() => atBottomAnchorRef.current, [])
|
||||
);
|
||||
|
||||
// Handle up arrow edit
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (
|
||||
isHotkey('arrowup', evt) &&
|
||||
editableActiveElement() &&
|
||||
document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' &&
|
||||
isEmptyEditor(editor)
|
||||
) {
|
||||
const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) =>
|
||||
canEditEvent(mx, mEvt)
|
||||
);
|
||||
const editableEvtId = editableEvt?.getId();
|
||||
if (!editableEvtId) return;
|
||||
setEditId(editableEvtId);
|
||||
}
|
||||
},
|
||||
[mx, room, editor]
|
||||
)
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (eventId) {
|
||||
setTimeline(getEmptyTimeline());
|
||||
|
@ -771,6 +771,22 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
}
|
||||
}, [room, unreadInfo, liveTimelineLinked, rangeAtEnd, atBottom]);
|
||||
|
||||
// scroll out of view msg editor in view.
|
||||
useEffect(() => {
|
||||
if (editId) {
|
||||
const editMsgElement =
|
||||
(scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ??
|
||||
undefined;
|
||||
if (editMsgElement) {
|
||||
scrollToElement(editMsgElement, {
|
||||
align: 'center',
|
||||
behavior: 'smooth',
|
||||
stopInView: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [scrollToElement, editId]);
|
||||
|
||||
const handleJumpToLatest = () => {
|
||||
setTimeline(getInitialTimeline(room));
|
||||
scrollToBottomRef.current.count += 1;
|
||||
|
@ -901,6 +917,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
},
|
||||
[mx, room]
|
||||
);
|
||||
const handleEdit = useCallback(
|
||||
(editEvtId?: string) => {
|
||||
if (editEvtId) {
|
||||
setEditId(editEvtId);
|
||||
return;
|
||||
}
|
||||
setEditId(undefined);
|
||||
ReactEditor.focus(editor);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const renderBody = (body: string, customBody?: string) => {
|
||||
if (body === '') <MessageEmptyContent />;
|
||||
|
@ -1153,12 +1180,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Message
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canSendReaction={canSendReaction}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
@ -1167,6 +1196,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
onUsernameClick={handleUsernameClick}
|
||||
onReplyClick={handleReplyClick}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={handleEdit}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
|
@ -1208,12 +1238,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Message
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
messageLayout={messageLayout}
|
||||
collapse={collapse}
|
||||
highlight={highlighted}
|
||||
edit={editId === mEventId}
|
||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||
canSendReaction={canSendReaction}
|
||||
imagePackRooms={imagePackRooms}
|
||||
|
@ -1222,6 +1254,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
onUsernameClick={handleUsernameClick}
|
||||
onReplyClick={handleReplyClick}
|
||||
onReactionToggle={handleReactionToggle}
|
||||
onEditId={handleEdit}
|
||||
reply={
|
||||
replyEventId && (
|
||||
<Reply
|
||||
|
@ -1280,6 +1313,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Message
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
messageSpacing={messageSpacing}
|
||||
|
@ -1325,6 +1359,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Event
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
|
@ -1357,6 +1392,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Event
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
|
@ -1390,6 +1426,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Event
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
|
@ -1423,6 +1460,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Event
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
|
@ -1457,6 +1495,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Event
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
|
@ -1497,6 +1536,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
|||
<Event
|
||||
key={mEvent.getId()}
|
||||
data-message-item={item}
|
||||
data-message-id={mEventId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
highlight={highlighted}
|
||||
|
|
|
@ -45,7 +45,12 @@ import {
|
|||
Username,
|
||||
} from '../../../components/message';
|
||||
import colorMXID from '../../../../util/colorMXID';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../../utils/room';
|
||||
import {
|
||||
canEditEvent,
|
||||
getEventEdits,
|
||||
getMemberAvatarMxc,
|
||||
getMemberDisplayName,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
@ -56,6 +61,7 @@ import { TextViewer } from '../../../components/text-viewer';
|
|||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { ReactionViewer } from '../reaction-viewer';
|
||||
import { MessageEditor } from './MessageEditor';
|
||||
|
||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||
|
||||
|
@ -211,21 +217,40 @@ export const MessageReadReceiptItem = as<
|
|||
export const MessageSourceCodeItem = as<
|
||||
'button',
|
||||
{
|
||||
room: Room;
|
||||
mEvent: MatrixEvent;
|
||||
onClose?: () => void;
|
||||
}
|
||||
>(({ mEvent, onClose, ...props }, ref) => {
|
||||
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const text = JSON.stringify(
|
||||
mEvent.isEncrypted()
|
||||
|
||||
const getContent = (evt: MatrixEvent) =>
|
||||
evt.isEncrypted()
|
||||
? {
|
||||
[`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(),
|
||||
[`<== ORIGINAL_EVENT ==>`]: mEvent.event,
|
||||
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
|
||||
[`<== ORIGINAL_EVENT ==>`]: evt.event,
|
||||
}
|
||||
: mEvent.event,
|
||||
null,
|
||||
2
|
||||
);
|
||||
: evt.event;
|
||||
|
||||
const getText = (): string => {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const edits =
|
||||
evtTimeline &&
|
||||
getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations();
|
||||
|
||||
if (!edits) return JSON.stringify(getContent(mEvent), null, 2);
|
||||
|
||||
const content: Record<string, unknown> = {
|
||||
'<== MAIN_EVENT ==>': getContent(mEvent),
|
||||
};
|
||||
|
||||
edits.forEach((editEvt, index) => {
|
||||
content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt);
|
||||
});
|
||||
|
||||
return JSON.stringify(content, null, 2);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
|
@ -247,7 +272,7 @@ export const MessageSourceCodeItem = as<
|
|||
<TextViewer
|
||||
name="Source Code"
|
||||
langName="json"
|
||||
text={text}
|
||||
text={getText()}
|
||||
requestClose={handleClose}
|
||||
/>
|
||||
</Modal>
|
||||
|
@ -537,6 +562,7 @@ export type MessageProps = {
|
|||
mEvent: MatrixEvent;
|
||||
collapse: boolean;
|
||||
highlight: boolean;
|
||||
edit?: boolean;
|
||||
canDelete?: boolean;
|
||||
canSendReaction?: boolean;
|
||||
imagePackRooms?: Room[];
|
||||
|
@ -546,6 +572,7 @@ export type MessageProps = {
|
|||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onReplyClick: MouseEventHandler<HTMLButtonElement>;
|
||||
onEditId?: (eventId?: string) => void;
|
||||
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||
reply?: ReactNode;
|
||||
reactions?: ReactNode;
|
||||
|
@ -558,6 +585,7 @@ export const Message = as<'div', MessageProps>(
|
|||
mEvent,
|
||||
collapse,
|
||||
highlight,
|
||||
edit,
|
||||
canDelete,
|
||||
canSendReaction,
|
||||
imagePackRooms,
|
||||
|
@ -568,6 +596,7 @@ export const Message = as<'div', MessageProps>(
|
|||
onUsernameClick,
|
||||
onReplyClick,
|
||||
onReactionToggle,
|
||||
onEditId,
|
||||
reply,
|
||||
reactions,
|
||||
children,
|
||||
|
@ -644,7 +673,21 @@ export const Message = as<'div', MessageProps>(
|
|||
const msgContentJSX = (
|
||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||
{reply}
|
||||
{children}
|
||||
{edit && onEditId ? (
|
||||
<MessageEditor
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
width: '100vw',
|
||||
}}
|
||||
roomId={room.roomId}
|
||||
room={room}
|
||||
mEvent={mEvent}
|
||||
imagePackRooms={imagePackRooms}
|
||||
onCancel={() => onEditId()}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
{reactions}
|
||||
</Box>
|
||||
);
|
||||
|
@ -677,7 +720,7 @@ export const Message = as<'div', MessageProps>(
|
|||
onMouseLeave={hideOptions}
|
||||
ref={ref}
|
||||
>
|
||||
{(hover || menu || emojiBoard) && (
|
||||
{!edit && (hover || menu || emojiBoard) && (
|
||||
<div className={css.MessageOptionsBase}>
|
||||
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
||||
<Box gap="100">
|
||||
|
@ -728,6 +771,16 @@ export const Message = as<'div', MessageProps>(
|
|||
>
|
||||
<Icon src={Icons.ReplyArrow} size="100" />
|
||||
</IconButton>
|
||||
{canEditEvent(mx, mEvent) && onEditId && (
|
||||
<IconButton
|
||||
onClick={() => onEditId(mEvent.getId())}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon src={Icons.Pencil} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<PopOut
|
||||
open={menu}
|
||||
alignOffset={-5}
|
||||
|
@ -801,12 +854,33 @@ export const Message = as<'div', MessageProps>(
|
|||
Reply
|
||||
</Text>
|
||||
</MenuItem>
|
||||
{canEditEvent(mx, mEvent) && onEditId && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Pencil} />}
|
||||
radii="300"
|
||||
data-event-id={mEvent.getId()}
|
||||
onClick={() => {
|
||||
onEditId(mEvent.getId());
|
||||
closeMenu();
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
className={css.MessageMenuItemText}
|
||||
as="span"
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Edit Message
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
<MessageReadReceiptItem
|
||||
room={room}
|
||||
eventId={mEvent.getId() ?? ''}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
<MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
</Box>
|
||||
{((!mEvent.isRedacted() && canDelete) ||
|
||||
mEvent.getSender() !== mx.getUserId()) && (
|
||||
|
@ -941,7 +1015,7 @@ export const Event = as<'div', EventProps>(
|
|||
eventId={mEvent.getId() ?? ''}
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
<MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
</Box>
|
||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||
(mEvent.getSender() !== mx.getUserId() && !stateEvent)) && (
|
||||
|
|
295
src/app/organisms/room/message/MessageEditor.tsx
Normal file
295
src/app/organisms/room/message/MessageEditor.tsx
Normal file
|
@ -0,0 +1,295 @@
|
|||
import React, { KeyboardEventHandler, useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Chip, Icon, IconButton, Icons, Line, PopOut, Spinner, Text, as, config } from 'folds';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { IContent, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
||||
import isHotkey from 'is-hotkey';
|
||||
import {
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
AutocompletePrefix,
|
||||
AutocompleteQuery,
|
||||
CustomEditor,
|
||||
EmoticonAutocomplete,
|
||||
RoomMentionAutocomplete,
|
||||
Toolbar,
|
||||
UserMentionAutocomplete,
|
||||
createEmoticonElement,
|
||||
customHtmlEqualsPlainText,
|
||||
getAutocompleteQuery,
|
||||
getPrevWorldRange,
|
||||
htmlToEditorInput,
|
||||
moveCursor,
|
||||
plainToEditorInput,
|
||||
toMatrixCustomHTML,
|
||||
toPlainText,
|
||||
trimCustomHtml,
|
||||
useEditor,
|
||||
} from '../../../components/editor';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getEditedEvent, trimReplyFromFormattedBody } from '../../../utils/room';
|
||||
|
||||
type MessageEditorProps = {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
mEvent: MatrixEvent;
|
||||
imagePackRooms?: Room[];
|
||||
onCancel: () => void;
|
||||
};
|
||||
export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
({ room, roomId, mEvent, imagePackRooms, onCancel, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const editor = useEditor();
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [toolbar, setToolbar] = useState(globalToolbar);
|
||||
|
||||
const [autocompleteQuery, setAutocompleteQuery] =
|
||||
useState<AutocompleteQuery<AutocompletePrefix>>();
|
||||
|
||||
const getPrevBodyAndFormattedBody = useCallback(() => {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const editedEvent =
|
||||
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
|
||||
|
||||
const { body, formatted_body: customHtml }: Record<string, unknown> =
|
||||
editedEvent?.getContent()['m.new.content'] ?? mEvent.getContent();
|
||||
|
||||
return [body, customHtml];
|
||||
}, [room, mEvent]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
const plainText = toPlainText(editor.children).trim();
|
||||
const customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
allowTextFormatting: true,
|
||||
allowMarkdown: isMarkdown,
|
||||
})
|
||||
);
|
||||
|
||||
const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
|
||||
|
||||
if (plainText === '') return undefined;
|
||||
if (
|
||||
typeof prevCustomHtml === 'string' &&
|
||||
trimReplyFromFormattedBody(prevCustomHtml) === customHtml
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (!prevCustomHtml && typeof prevBody === 'string' && prevBody === plainText) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const newContent: IContent = {
|
||||
msgtype: mEvent.getContent().msgtype,
|
||||
body: plainText,
|
||||
};
|
||||
|
||||
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
|
||||
newContent.format = 'org.matrix.custom.html';
|
||||
newContent.formatted_body = customHtml;
|
||||
}
|
||||
|
||||
const content: IContent = {
|
||||
...newContent,
|
||||
body: `* ${plainText}`,
|
||||
'm.new_content': newContent,
|
||||
'm.relates_to': {
|
||||
event_id: mEvent.getId(),
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
};
|
||||
|
||||
return mx.sendMessage(roomId, content);
|
||||
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody])
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (saveState.status !== AsyncStatus.Loading) {
|
||||
save();
|
||||
}
|
||||
}, [saveState, save]);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (isHotkey('enter', evt)) {
|
||||
evt.preventDefault();
|
||||
handleSave();
|
||||
}
|
||||
if (isHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
},
|
||||
[onCancel, handleSave]
|
||||
);
|
||||
|
||||
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||
(evt) => {
|
||||
if (isHotkey('escape', evt)) {
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const prevWordRange = getPrevWorldRange(editor);
|
||||
const query = prevWordRange
|
||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||||
: undefined;
|
||||
setAutocompleteQuery(query);
|
||||
},
|
||||
[editor]
|
||||
);
|
||||
|
||||
const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
|
||||
|
||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||
moveCursor(editor);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const [body, customHtml] = getPrevBodyAndFormattedBody();
|
||||
|
||||
const initialValue =
|
||||
typeof customHtml === 'string'
|
||||
? htmlToEditorInput(customHtml)
|
||||
: plainToEditorInput(typeof body === 'string' ? body : '');
|
||||
|
||||
Transforms.select(editor, {
|
||||
anchor: Editor.start(editor, []),
|
||||
focus: Editor.end(editor, []),
|
||||
});
|
||||
|
||||
editor.insertFragment(initialValue);
|
||||
ReactEditor.focus(editor);
|
||||
}, [editor, getPrevBodyAndFormattedBody]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState.status === AsyncStatus.Success) {
|
||||
onCancel();
|
||||
}
|
||||
}, [saveState, onCancel]);
|
||||
|
||||
return (
|
||||
<div {...props} ref={ref}>
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && (
|
||||
<RoomMentionAutocomplete
|
||||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||||
<UserMentionAutocomplete
|
||||
roomId={roomId}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
||||
<EmoticonAutocomplete
|
||||
imagePackRooms={imagePackRooms || []}
|
||||
editor={editor}
|
||||
query={autocompleteQuery}
|
||||
requestClose={handleCloseAutocomplete}
|
||||
/>
|
||||
)}
|
||||
<CustomEditor
|
||||
editor={editor}
|
||||
placeholder="Edit message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
bottom={
|
||||
<>
|
||||
<Box
|
||||
style={{ padding: config.space.S200, paddingTop: 0 }}
|
||||
alignItems="End"
|
||||
justifyContent="SpaceBetween"
|
||||
gap="100"
|
||||
>
|
||||
<Box gap="Inherit">
|
||||
<Chip
|
||||
onClick={handleSave}
|
||||
variant="Primary"
|
||||
radii="Pill"
|
||||
disabled={saveState.status === AsyncStatus.Loading}
|
||||
outlined
|
||||
before={
|
||||
saveState.status === AsyncStatus.Loading ? (
|
||||
<Spinner variant="Primary" fill="Soft" size="100" />
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Chip>
|
||||
<Chip onClick={onCancel} variant="SurfaceVariant" radii="Pill">
|
||||
<Text size="B300">Cancel</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
<Box gap="Inherit">
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon size="400" src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
<UseStateProvider initial={false}>
|
||||
{(emojiBoard: boolean, setEmojiBoard) => (
|
||||
<PopOut
|
||||
alignOffset={-8}
|
||||
position="Top"
|
||||
align="End"
|
||||
open={!!emojiBoard}
|
||||
content={
|
||||
<EmojiBoard
|
||||
imagePackRooms={imagePackRooms ?? []}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoard(false);
|
||||
ReactEditor.focus(editor);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(anchorRef) => (
|
||||
<IconButton
|
||||
ref={anchorRef}
|
||||
aria-pressed={emojiBoard}
|
||||
onClick={() => setEmojiBoard(true)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="400" src={Icons.Smile} filled={emojiBoard} />
|
||||
</IconButton>
|
||||
)}
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
</Box>
|
||||
</Box>
|
||||
{toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
|
@ -12,7 +12,7 @@ import {
|
|||
toRem,
|
||||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { EventTimelineSet, EventType, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { type Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
@ -22,13 +22,6 @@ import { useRelations } from '../../../hooks/useRelations';
|
|||
import * as css from './styles.css';
|
||||
import { ReactionViewer } from '../reaction-viewer';
|
||||
|
||||
export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
|
||||
timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction
|
||||
);
|
||||
|
||||
export type ReactionsProps = {
|
||||
room: Room;
|
||||
mEventId: string;
|
||||
|
|
|
@ -5,7 +5,11 @@ export const targetFromEvent = (evt: Event, selector: string): Element | undefin
|
|||
|
||||
export const editableActiveElement = (): boolean =>
|
||||
!!document.activeElement &&
|
||||
/^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase());
|
||||
(document.activeElement.nodeName.toLowerCase() === 'input' ||
|
||||
document.activeElement.nodeName.toLowerCase() === 'textbox' ||
|
||||
document.activeElement.getAttribute('contenteditable') === 'true' ||
|
||||
document.activeElement.getAttribute('role') === 'input' ||
|
||||
document.activeElement.getAttribute('role') === 'textbox');
|
||||
|
||||
export const isIntersectingScrollView = (
|
||||
scrollElement: HTMLElement,
|
||||
|
|
|
@ -83,7 +83,7 @@ const StrikeRule: MDRule = {
|
|||
match: (text) => text.match(STRIKE_REG_1),
|
||||
html: (parse, match) => {
|
||||
const [, g1] = match;
|
||||
return `<s data-md="${STRIKE_MD_1}">${parse(g1)}</s>`;
|
||||
return `<del data-md="${STRIKE_MD_1}">${parse(g1)}</del>`;
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -28,6 +28,15 @@ export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith(
|
|||
|
||||
export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#');
|
||||
|
||||
export const parseMatrixToUrl = (url: string): [string | undefined, string | undefined] => {
|
||||
const href = decodeURIComponent(url);
|
||||
|
||||
const match = href.match(/^https?:\/\/matrix.to\/#\/([@!$+#]\S+:[^\\?|^\s|^\\/]+)(\?(via=\S+))?/);
|
||||
if (!match) return [undefined, undefined];
|
||||
const [, g1AsMxId, , g3AsVia] = match;
|
||||
return [g1AsMxId, g3AsVia];
|
||||
};
|
||||
|
||||
export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
|
||||
mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
|
||||
|
||||
|
|
|
@ -2,17 +2,22 @@ import { IconName, IconSrc } from 'folds';
|
|||
|
||||
import {
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventType,
|
||||
IPushRule,
|
||||
IPushRules,
|
||||
JoinRule,
|
||||
MatrixClient,
|
||||
MatrixEvent,
|
||||
MsgType,
|
||||
NotificationCountType,
|
||||
RelationType,
|
||||
Room,
|
||||
} from 'matrix-js-sdk';
|
||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||
import {
|
||||
MessageEvent,
|
||||
NotificationType,
|
||||
RoomToParents,
|
||||
RoomType,
|
||||
|
@ -249,6 +254,21 @@ export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefin
|
|||
return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
|
||||
};
|
||||
|
||||
export const trimReplyFromBody = (body: string): string => {
|
||||
const match = body.match(/^>\s<.+?>\s.+\n\n/);
|
||||
if (!match) return body;
|
||||
return body.slice(match[0].length);
|
||||
};
|
||||
|
||||
export const trimReplyFromFormattedBody = (formattedBody: string): string => {
|
||||
const suffix = '</mx-reply>';
|
||||
const i = formattedBody.lastIndexOf(suffix);
|
||||
if (i < 0) {
|
||||
return formattedBody;
|
||||
}
|
||||
return formattedBody.slice(i + suffix.length);
|
||||
};
|
||||
|
||||
export const parseReplyBody = (userId: string, body: string) =>
|
||||
`> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`;
|
||||
|
||||
|
@ -301,3 +321,52 @@ export const getReactionContent = (eventId: string, key: string, shortcode?: str
|
|||
},
|
||||
shortcode,
|
||||
});
|
||||
|
||||
export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
|
||||
timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
RelationType.Annotation,
|
||||
EventType.Reaction
|
||||
);
|
||||
|
||||
export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) =>
|
||||
timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType);
|
||||
|
||||
export const getLatestEdit = (
|
||||
targetEvent: MatrixEvent,
|
||||
editEvents: MatrixEvent[]
|
||||
): MatrixEvent | undefined => {
|
||||
const eventByTargetSender = (rEvent: MatrixEvent) =>
|
||||
rEvent.getSender() === targetEvent.getSender();
|
||||
return editEvents.sort((m1, m2) => m2.getTs() - m1.getTs()).find(eventByTargetSender);
|
||||
};
|
||||
|
||||
export const getEditedEvent = (
|
||||
mEventId: string,
|
||||
mEvent: MatrixEvent,
|
||||
timelineSet: EventTimelineSet
|
||||
): MatrixEvent | undefined => {
|
||||
const edits = getEventEdits(timelineSet, mEventId, mEvent.getType());
|
||||
return edits && getLatestEdit(mEvent, edits.getRelations());
|
||||
};
|
||||
|
||||
export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) =>
|
||||
mEvent.getSender() === mx.getUserId() &&
|
||||
!mEvent.isRelation() &&
|
||||
mEvent.getType() === MessageEvent.RoomMessage &&
|
||||
(mEvent.getContent().msgtype === MsgType.Text ||
|
||||
mEvent.getContent().msgtype === MsgType.Emote ||
|
||||
mEvent.getContent().msgtype === MsgType.Notice);
|
||||
|
||||
export const getLatestEditableEvt = (
|
||||
timeline: EventTimeline,
|
||||
canEdit: (mEvent: MatrixEvent) => boolean
|
||||
): MatrixEvent | undefined => {
|
||||
const events = timeline.getEvents();
|
||||
|
||||
for (let i = events.length - 1; i >= 0; i -= 1) {
|
||||
const evt = events[i];
|
||||
if (canEdit(evt)) return evt;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
|
|
@ -56,12 +56,19 @@ const permittedTagToAttributes = {
|
|||
'data-mx-maths',
|
||||
'data-mx-pill',
|
||||
'data-mx-ping',
|
||||
'data-md',
|
||||
],
|
||||
div: ['data-mx-maths'],
|
||||
a: ['name', 'target', 'href', 'rel'],
|
||||
a: ['name', 'target', 'href', 'rel', 'data-md'],
|
||||
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
|
||||
ol: ['start'],
|
||||
code: ['class'],
|
||||
code: ['class', 'data-md'],
|
||||
strong: ['data-md'],
|
||||
i: ['data-md'],
|
||||
em: ['data-md'],
|
||||
u: ['data-md'],
|
||||
s: ['data-md'],
|
||||
del: ['data-md'],
|
||||
};
|
||||
|
||||
const transformFontTag: Transformer = (tagName, attribs) => ({
|
||||
|
|
Loading…
Reference in a new issue