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",
|
"classnames": "2.3.2",
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.10",
|
"dayjs": "1.11.10",
|
||||||
|
"domhandler": "5.0.3",
|
||||||
"emojibase": "6.1.0",
|
"emojibase": "6.1.0",
|
||||||
"emojibase-data": "7.0.1",
|
"emojibase-data": "7.0.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
|
@ -30,6 +31,7 @@
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "1.5.0",
|
"folds": "1.5.0",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
|
|
|
@ -33,6 +33,7 @@
|
||||||
"classnames": "2.3.2",
|
"classnames": "2.3.2",
|
||||||
"dateformat": "5.0.3",
|
"dateformat": "5.0.3",
|
||||||
"dayjs": "1.11.10",
|
"dayjs": "1.11.10",
|
||||||
|
"domhandler": "5.0.3",
|
||||||
"emojibase": "6.1.0",
|
"emojibase": "6.1.0",
|
||||||
"emojibase-data": "7.0.1",
|
"emojibase-data": "7.0.1",
|
||||||
"file-saver": "2.0.5",
|
"file-saver": "2.0.5",
|
||||||
|
@ -40,6 +41,7 @@
|
||||||
"focus-trap-react": "10.0.2",
|
"focus-trap-react": "10.0.2",
|
||||||
"folds": "1.5.0",
|
"folds": "1.5.0",
|
||||||
"formik": "2.2.9",
|
"formik": "2.2.9",
|
||||||
|
"html-dom-parser": "4.0.0",
|
||||||
"html-react-parser": "4.2.0",
|
"html-react-parser": "4.2.0",
|
||||||
"immer": "9.0.16",
|
"immer": "9.0.16",
|
||||||
"is-hotkey": "0.2.0",
|
"is-hotkey": "0.2.0",
|
||||||
|
|
|
@ -50,12 +50,13 @@ const withVoid = (editor: Editor): Editor => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useEditor = (): Editor => {
|
export const useEditor = (): Editor => {
|
||||||
const [editor] = useState(withInline(withVoid(withReact(withHistory(createEditor())))));
|
const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor())))));
|
||||||
return editor;
|
return editor;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EditorChangeHandler = (value: Descendant[]) => void;
|
export type EditorChangeHandler = (value: Descendant[]) => void;
|
||||||
type CustomEditorProps = {
|
type CustomEditorProps = {
|
||||||
|
editableName?: string;
|
||||||
top?: ReactNode;
|
top?: ReactNode;
|
||||||
bottom?: ReactNode;
|
bottom?: ReactNode;
|
||||||
before?: ReactNode;
|
before?: ReactNode;
|
||||||
|
@ -71,6 +72,7 @@ type CustomEditorProps = {
|
||||||
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
editableName,
|
||||||
top,
|
top,
|
||||||
bottom,
|
bottom,
|
||||||
before,
|
before,
|
||||||
|
@ -137,6 +139,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||||
hideTrack
|
hideTrack
|
||||||
>
|
>
|
||||||
<Editable
|
<Editable
|
||||||
|
data-editable-name={editableName}
|
||||||
className={css.EditorTextarea}
|
className={css.EditorTextarea}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
renderPlaceholder={renderPlaceholder}
|
renderPlaceholder={renderPlaceholder}
|
||||||
|
|
|
@ -221,3 +221,12 @@ export const getPrevWorldRange = (editor: Editor): BaseRange | undefined => {
|
||||||
});
|
});
|
||||||
return worldStartPoint && Editor.range(editor, worldStartPoint, cursorPoint);
|
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 './keyboard';
|
||||||
export * from './output';
|
export * from './output';
|
||||||
export * from './Toolbar';
|
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 { Descendant, Text } from 'slate';
|
||||||
|
|
||||||
import { sanitizeText } from '../../utils/sanitize';
|
import { sanitizeText } from '../../utils/sanitize';
|
||||||
import { BlockType } from './Elements';
|
import { BlockType } from './Elements';
|
||||||
import { CustomElement, FormattedText } from './slate';
|
import { CustomElement } from './slate';
|
||||||
import { parseInlineMD } from '../../utils/markdown';
|
import { parseInlineMD } from '../../utils/markdown';
|
||||||
|
|
||||||
export type OutputOptions = {
|
export type OutputOptions = {
|
||||||
|
@ -9,13 +10,13 @@ export type OutputOptions = {
|
||||||
allowMarkdown?: boolean;
|
allowMarkdown?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const textToCustomHtml = (node: FormattedText, opts: OutputOptions): string => {
|
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
||||||
let string = sanitizeText(node.text);
|
let string = sanitizeText(node.text);
|
||||||
if (opts.allowTextFormatting) {
|
if (opts.allowTextFormatting) {
|
||||||
if (node.bold) string = `<strong>${string}</strong>`;
|
if (node.bold) string = `<strong>${string}</strong>`;
|
||||||
if (node.italic) string = `<i>${string}</i>`;
|
if (node.italic) string = `<i>${string}</i>`;
|
||||||
if (node.underline) string = `<u>${string}</u>`;
|
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.code) string = `<code>${string}</code>`;
|
||||||
if (node.spoiler) string = `<span data-mx-spoiler>${string}</span>`;
|
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>`;
|
return `<ol>${children}</ol>`;
|
||||||
case BlockType.UnorderedList:
|
case BlockType.UnorderedList:
|
||||||
return `<ul>${children}</ul>`;
|
return `<ul>${children}</ul>`;
|
||||||
|
|
||||||
case BlockType.Mention:
|
case BlockType.Mention:
|
||||||
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
|
return `<a href="https://matrix.to/#/${node.id}">${node.name}</a>`;
|
||||||
case BlockType.Emoticon:
|
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 = {
|
export type LinkElement = {
|
||||||
type: BlockType.Link;
|
type: BlockType.Link;
|
||||||
href: string;
|
href: string;
|
||||||
children: FormattedText[];
|
children: Text[];
|
||||||
};
|
|
||||||
export type SpoilerElement = {
|
|
||||||
type: 'spoiler';
|
|
||||||
alert?: string;
|
|
||||||
children: FormattedText[];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MentionElement = {
|
export type MentionElement = {
|
||||||
type: BlockType.Mention;
|
type: BlockType.Mention;
|
||||||
id: string;
|
id: string;
|
||||||
|
@ -44,14 +40,16 @@ export type EmoticonElement = {
|
||||||
children: Text[];
|
children: Text[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement;
|
||||||
|
|
||||||
export type ParagraphElement = {
|
export type ParagraphElement = {
|
||||||
type: BlockType.Paragraph;
|
type: BlockType.Paragraph;
|
||||||
children: FormattedText[];
|
children: InlineElement[];
|
||||||
};
|
};
|
||||||
export type HeadingElement = {
|
export type HeadingElement = {
|
||||||
type: BlockType.Heading;
|
type: BlockType.Heading;
|
||||||
level: HeadingLevel;
|
level: HeadingLevel;
|
||||||
children: FormattedText[];
|
children: InlineElement[];
|
||||||
};
|
};
|
||||||
export type CodeLineElement = {
|
export type CodeLineElement = {
|
||||||
type: BlockType.CodeLine;
|
type: BlockType.CodeLine;
|
||||||
|
@ -63,7 +61,7 @@ export type CodeBlockElement = {
|
||||||
};
|
};
|
||||||
export type QuoteLineElement = {
|
export type QuoteLineElement = {
|
||||||
type: BlockType.QuoteLine;
|
type: BlockType.QuoteLine;
|
||||||
children: FormattedText[];
|
children: InlineElement[];
|
||||||
};
|
};
|
||||||
export type BlockQuoteElement = {
|
export type BlockQuoteElement = {
|
||||||
type: BlockType.BlockQuote;
|
type: BlockType.BlockQuote;
|
||||||
|
@ -71,7 +69,7 @@ export type BlockQuoteElement = {
|
||||||
};
|
};
|
||||||
export type ListItemElement = {
|
export type ListItemElement = {
|
||||||
type: BlockType.ListItem;
|
type: BlockType.ListItem;
|
||||||
children: FormattedText[];
|
children: InlineElement[];
|
||||||
};
|
};
|
||||||
export type OrderedListElement = {
|
export type OrderedListElement = {
|
||||||
type: BlockType.OrderedList;
|
type: BlockType.OrderedList;
|
||||||
|
@ -84,7 +82,6 @@ export type UnorderedListElement = {
|
||||||
|
|
||||||
export type CustomElement =
|
export type CustomElement =
|
||||||
| LinkElement
|
| LinkElement
|
||||||
// | SpoilerElement
|
|
||||||
| MentionElement
|
| MentionElement
|
||||||
| EmoticonElement
|
| EmoticonElement
|
||||||
| ParagraphElement
|
| ParagraphElement
|
||||||
|
|
|
@ -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, Element } from 'slate';
|
import { Transforms, Range, Editor } from 'slate';
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
Dialog,
|
Dialog,
|
||||||
|
@ -51,6 +51,7 @@ import {
|
||||||
resetEditorHistory,
|
resetEditorHistory,
|
||||||
customHtmlEqualsPlainText,
|
customHtmlEqualsPlainText,
|
||||||
trimCustomHtml,
|
trimCustomHtml,
|
||||||
|
isEmptyEditor,
|
||||||
} 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';
|
||||||
|
@ -95,7 +96,12 @@ import navigation from '../../../client/state/navigation';
|
||||||
import cons from '../../../client/state/cons';
|
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 { parseReplyBody, parseReplyFormattedBody } from '../../utils/room';
|
import {
|
||||||
|
parseReplyBody,
|
||||||
|
parseReplyFormattedBody,
|
||||||
|
trimReplyFromBody,
|
||||||
|
trimReplyFromFormattedBody,
|
||||||
|
} from '../../utils/room';
|
||||||
import { sanitizeText } from '../../utils/sanitize';
|
import { sanitizeText } from '../../utils/sanitize';
|
||||||
import { useScreenSize } from '../../hooks/useScreenSize';
|
import { useScreenSize } from '../../hooks/useScreenSize';
|
||||||
|
|
||||||
|
@ -264,13 +270,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
let body = plainText;
|
let body = plainText;
|
||||||
let formattedBody = customHtml;
|
let formattedBody = customHtml;
|
||||||
if (replyDraft) {
|
if (replyDraft) {
|
||||||
body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body;
|
body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body;
|
||||||
formattedBody =
|
formattedBody =
|
||||||
parseReplyFormattedBody(
|
parseReplyFormattedBody(
|
||||||
roomId,
|
roomId,
|
||||||
replyDraft.userId,
|
replyDraft.userId,
|
||||||
replyDraft.eventId,
|
replyDraft.eventId,
|
||||||
replyDraft.formattedBody ?? sanitizeText(replyDraft.body)
|
replyDraft.formattedBody
|
||||||
|
? trimReplyFromFormattedBody(replyDraft.formattedBody)
|
||||||
|
: sanitizeText(replyDraft.body)
|
||||||
) + formattedBody;
|
) + formattedBody;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -321,19 +329,25 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
[submit, editor, setReplyDraft]
|
[submit, editor, setReplyDraft]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleKeyUp: KeyboardEventHandler = useCallback(() => {
|
const handleKeyUp: KeyboardEventHandler = useCallback(
|
||||||
const firstChildren = editor.children[0];
|
(evt) => {
|
||||||
if (firstChildren && Element.isElement(firstChildren)) {
|
if (isHotkey('escape', evt)) {
|
||||||
const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren);
|
evt.preventDefault();
|
||||||
sendTypingStatus(!isEmpty);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sendTypingStatus(!isEmptyEditor(editor));
|
||||||
|
|
||||||
const prevWordRange = getPrevWorldRange(editor);
|
const prevWordRange = getPrevWorldRange(editor);
|
||||||
const query = prevWordRange
|
const query = prevWordRange
|
||||||
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
|
||||||
: undefined;
|
: undefined;
|
||||||
setAutocompleteQuery(query);
|
setAutocompleteQuery(query);
|
||||||
}, [editor, sendTypingStatus]);
|
},
|
||||||
|
[editor, sendTypingStatus]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []);
|
||||||
|
|
||||||
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
const handleEmoticonSelect = (key: string, shortcode: string) => {
|
||||||
editor.insertNode(createEmoticonElement(key, shortcode));
|
editor.insertNode(createEmoticonElement(key, shortcode));
|
||||||
|
@ -419,7 +433,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
query={autocompleteQuery}
|
query={autocompleteQuery}
|
||||||
requestClose={() => setAutocompleteQuery(undefined)}
|
requestClose={handleCloseAutocomplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
|
||||||
|
@ -427,7 +441,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
roomId={roomId}
|
roomId={roomId}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
query={autocompleteQuery}
|
query={autocompleteQuery}
|
||||||
requestClose={() => setAutocompleteQuery(undefined)}
|
requestClose={handleCloseAutocomplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
|
||||||
|
@ -435,10 +449,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
imagePackRooms={imagePackRooms}
|
imagePackRooms={imagePackRooms}
|
||||||
editor={editor}
|
editor={editor}
|
||||||
query={autocompleteQuery}
|
query={autocompleteQuery}
|
||||||
requestClose={() => setAutocompleteQuery(undefined)}
|
requestClose={handleCloseAutocomplete}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<CustomEditor
|
<CustomEditor
|
||||||
|
editableName="RoomInput"
|
||||||
editor={editor}
|
editor={editor}
|
||||||
placeholder="Send a message..."
|
placeholder="Send a message..."
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
|
|
|
@ -15,11 +15,9 @@ import {
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
EventTimelineSet,
|
EventTimelineSet,
|
||||||
EventTimelineSetHandlerMap,
|
EventTimelineSetHandlerMap,
|
||||||
EventType,
|
|
||||||
IEncryptedFile,
|
IEncryptedFile,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
RelationType,
|
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
RoomEventHandlerMap,
|
RoomEventHandlerMap,
|
||||||
|
@ -45,6 +43,7 @@ import {
|
||||||
config,
|
config,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
|
import isHotkey from 'is-hotkey';
|
||||||
import Linkify from 'linkify-react';
|
import Linkify from 'linkify-react';
|
||||||
import {
|
import {
|
||||||
decryptFile,
|
decryptFile,
|
||||||
|
@ -53,13 +52,12 @@ import {
|
||||||
getMxIdLocalPart,
|
getMxIdLocalPart,
|
||||||
isRoomId,
|
isRoomId,
|
||||||
isUserId,
|
isUserId,
|
||||||
matrixEventByRecency,
|
|
||||||
} from '../../utils/matrix';
|
} from '../../utils/matrix';
|
||||||
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
import { sanitizeCustomHtml } from '../../utils/sanitize';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
|
||||||
import { useAlive } from '../../hooks/useAlive';
|
import { useAlive } from '../../hooks/useAlive';
|
||||||
import { scrollToBottom } from '../../utils/dom';
|
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
|
||||||
import {
|
import {
|
||||||
DefaultPlaceholder,
|
DefaultPlaceholder,
|
||||||
CompactPlaceholder,
|
CompactPlaceholder,
|
||||||
|
@ -80,7 +78,11 @@ import {
|
||||||
} from '../../components/message';
|
} from '../../components/message';
|
||||||
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
|
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
|
||||||
import {
|
import {
|
||||||
|
canEditEvent,
|
||||||
decryptAllTimelineEvent,
|
decryptAllTimelineEvent,
|
||||||
|
getEditedEvent,
|
||||||
|
getEventReactions,
|
||||||
|
getLatestEditableEvt,
|
||||||
getMemberDisplayName,
|
getMemberDisplayName,
|
||||||
getReactionContent,
|
getReactionContent,
|
||||||
isMembershipChanged,
|
isMembershipChanged,
|
||||||
|
@ -124,11 +126,12 @@ import { useDebounce } from '../../hooks/useDebounce';
|
||||||
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
|
||||||
import * as css from './RoomTimeline.css';
|
import * as css from './RoomTimeline.css';
|
||||||
import { inSameDay, minuteDifference, timeDayMonthYear, today, yesterday } from '../../utils/time';
|
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 { roomIdToReplyDraftAtomFamily } from '../../state/roomInputDrafts';
|
||||||
import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
import { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
|
||||||
import { MessageEvent } from '../../../types/matrix/room';
|
import { MessageEvent } from '../../../types/matrix/room';
|
||||||
import initMatrix from '../../../client/initMatrix';
|
import initMatrix from '../../../client/initMatrix';
|
||||||
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
|
|
||||||
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
|
||||||
({ position, className, ...props }, ref) => (
|
({ position, className, ...props }, ref) => (
|
||||||
|
@ -226,34 +229,6 @@ export const getEventIdAbsoluteIndex = (
|
||||||
return baseIndex + eventIndex;
|
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 =
|
export const factoryGetFileSrcUrl =
|
||||||
(httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
|
(httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise<string> => {
|
||||||
if (encFile) {
|
if (encFile) {
|
||||||
|
@ -483,6 +458,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
|
const myPowerLevel = getPowerLevel(mx.getUserId() ?? '');
|
||||||
const canRedact = canDoAction('redact', myPowerLevel);
|
const canRedact = canDoAction('redact', myPowerLevel);
|
||||||
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
|
const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel);
|
||||||
|
const [editId, setEditId] = useState<string>();
|
||||||
|
|
||||||
const imagePackRooms: Room[] = useMemo(() => {
|
const imagePackRooms: Room[] = useMemo(() => {
|
||||||
const allParentSpaces = [
|
const allParentSpaces = [
|
||||||
|
@ -572,7 +548,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
|
|
||||||
const getScrollElement = useCallback(() => scrollRef.current, []);
|
const getScrollElement = useCallback(() => scrollRef.current, []);
|
||||||
|
|
||||||
const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({
|
const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } =
|
||||||
|
useVirtualPaginator({
|
||||||
count: eventsLength,
|
count: eventsLength,
|
||||||
limit: PAGINATION_LIMIT,
|
limit: PAGINATION_LIMIT,
|
||||||
range: timeline.range,
|
range: timeline.range,
|
||||||
|
@ -701,6 +678,29 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
useCallback(() => atBottomAnchorRef.current, [])
|
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(() => {
|
useEffect(() => {
|
||||||
if (eventId) {
|
if (eventId) {
|
||||||
setTimeline(getEmptyTimeline());
|
setTimeline(getEmptyTimeline());
|
||||||
|
@ -771,6 +771,22 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
}
|
}
|
||||||
}, [room, unreadInfo, liveTimelineLinked, rangeAtEnd, atBottom]);
|
}, [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 = () => {
|
const handleJumpToLatest = () => {
|
||||||
setTimeline(getInitialTimeline(room));
|
setTimeline(getInitialTimeline(room));
|
||||||
scrollToBottomRef.current.count += 1;
|
scrollToBottomRef.current.count += 1;
|
||||||
|
@ -901,6 +917,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
},
|
},
|
||||||
[mx, room]
|
[mx, room]
|
||||||
);
|
);
|
||||||
|
const handleEdit = useCallback(
|
||||||
|
(editEvtId?: string) => {
|
||||||
|
if (editEvtId) {
|
||||||
|
setEditId(editEvtId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditId(undefined);
|
||||||
|
ReactEditor.focus(editor);
|
||||||
|
},
|
||||||
|
[editor]
|
||||||
|
);
|
||||||
|
|
||||||
const renderBody = (body: string, customBody?: string) => {
|
const renderBody = (body: string, customBody?: string) => {
|
||||||
if (body === '') <MessageEmptyContent />;
|
if (body === '') <MessageEmptyContent />;
|
||||||
|
@ -1153,12 +1180,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Message
|
<Message
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
edit={editId === mEventId}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
imagePackRooms={imagePackRooms}
|
imagePackRooms={imagePackRooms}
|
||||||
|
@ -1167,6 +1196,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
onUsernameClick={handleUsernameClick}
|
onUsernameClick={handleUsernameClick}
|
||||||
onReplyClick={handleReplyClick}
|
onReplyClick={handleReplyClick}
|
||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
|
onEditId={handleEdit}
|
||||||
reply={
|
reply={
|
||||||
replyEventId && (
|
replyEventId && (
|
||||||
<Reply
|
<Reply
|
||||||
|
@ -1208,12 +1238,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Message
|
<Message
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
messageLayout={messageLayout}
|
messageLayout={messageLayout}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
edit={editId === mEventId}
|
||||||
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
|
||||||
canSendReaction={canSendReaction}
|
canSendReaction={canSendReaction}
|
||||||
imagePackRooms={imagePackRooms}
|
imagePackRooms={imagePackRooms}
|
||||||
|
@ -1222,6 +1254,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
onUsernameClick={handleUsernameClick}
|
onUsernameClick={handleUsernameClick}
|
||||||
onReplyClick={handleReplyClick}
|
onReplyClick={handleReplyClick}
|
||||||
onReactionToggle={handleReactionToggle}
|
onReactionToggle={handleReactionToggle}
|
||||||
|
onEditId={handleEdit}
|
||||||
reply={
|
reply={
|
||||||
replyEventId && (
|
replyEventId && (
|
||||||
<Reply
|
<Reply
|
||||||
|
@ -1280,6 +1313,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Message
|
<Message
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
messageSpacing={messageSpacing}
|
messageSpacing={messageSpacing}
|
||||||
|
@ -1325,6 +1359,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Event
|
<Event
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
@ -1357,6 +1392,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Event
|
<Event
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
@ -1390,6 +1426,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Event
|
<Event
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
@ -1423,6 +1460,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Event
|
<Event
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
@ -1457,6 +1495,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Event
|
<Event
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
@ -1497,6 +1536,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||||
<Event
|
<Event
|
||||||
key={mEvent.getId()}
|
key={mEvent.getId()}
|
||||||
data-message-item={item}
|
data-message-item={item}
|
||||||
|
data-message-id={mEventId}
|
||||||
room={room}
|
room={room}
|
||||||
mEvent={mEvent}
|
mEvent={mEvent}
|
||||||
highlight={highlighted}
|
highlight={highlighted}
|
||||||
|
|
|
@ -45,7 +45,12 @@ import {
|
||||||
Username,
|
Username,
|
||||||
} from '../../../components/message';
|
} from '../../../components/message';
|
||||||
import colorMXID from '../../../../util/colorMXID';
|
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 { getMxIdLocalPart } from '../../../utils/matrix';
|
||||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
@ -56,6 +61,7 @@ import { TextViewer } from '../../../components/text-viewer';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
import { ReactionViewer } from '../reaction-viewer';
|
import { ReactionViewer } from '../reaction-viewer';
|
||||||
|
import { MessageEditor } from './MessageEditor';
|
||||||
|
|
||||||
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void;
|
||||||
|
|
||||||
|
@ -211,21 +217,40 @@ export const MessageReadReceiptItem = as<
|
||||||
export const MessageSourceCodeItem = as<
|
export const MessageSourceCodeItem = as<
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
|
room: Room;
|
||||||
mEvent: MatrixEvent;
|
mEvent: MatrixEvent;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
>(({ mEvent, onClose, ...props }, ref) => {
|
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const text = JSON.stringify(
|
|
||||||
mEvent.isEncrypted()
|
const getContent = (evt: MatrixEvent) =>
|
||||||
|
evt.isEncrypted()
|
||||||
? {
|
? {
|
||||||
[`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(),
|
[`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(),
|
||||||
[`<== ORIGINAL_EVENT ==>`]: mEvent.event,
|
[`<== ORIGINAL_EVENT ==>`]: evt.event,
|
||||||
}
|
}
|
||||||
: mEvent.event,
|
: evt.event;
|
||||||
null,
|
|
||||||
2
|
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 = () => {
|
const handleClose = () => {
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
|
@ -247,7 +272,7 @@ export const MessageSourceCodeItem = as<
|
||||||
<TextViewer
|
<TextViewer
|
||||||
name="Source Code"
|
name="Source Code"
|
||||||
langName="json"
|
langName="json"
|
||||||
text={text}
|
text={getText()}
|
||||||
requestClose={handleClose}
|
requestClose={handleClose}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
@ -537,6 +562,7 @@ export type MessageProps = {
|
||||||
mEvent: MatrixEvent;
|
mEvent: MatrixEvent;
|
||||||
collapse: boolean;
|
collapse: boolean;
|
||||||
highlight: boolean;
|
highlight: boolean;
|
||||||
|
edit?: boolean;
|
||||||
canDelete?: boolean;
|
canDelete?: boolean;
|
||||||
canSendReaction?: boolean;
|
canSendReaction?: boolean;
|
||||||
imagePackRooms?: Room[];
|
imagePackRooms?: Room[];
|
||||||
|
@ -546,6 +572,7 @@ export type MessageProps = {
|
||||||
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
onUserClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
onReplyClick: MouseEventHandler<HTMLButtonElement>;
|
onReplyClick: MouseEventHandler<HTMLButtonElement>;
|
||||||
|
onEditId?: (eventId?: string) => void;
|
||||||
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void;
|
||||||
reply?: ReactNode;
|
reply?: ReactNode;
|
||||||
reactions?: ReactNode;
|
reactions?: ReactNode;
|
||||||
|
@ -558,6 +585,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
mEvent,
|
mEvent,
|
||||||
collapse,
|
collapse,
|
||||||
highlight,
|
highlight,
|
||||||
|
edit,
|
||||||
canDelete,
|
canDelete,
|
||||||
canSendReaction,
|
canSendReaction,
|
||||||
imagePackRooms,
|
imagePackRooms,
|
||||||
|
@ -568,6 +596,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
onUsernameClick,
|
onUsernameClick,
|
||||||
onReplyClick,
|
onReplyClick,
|
||||||
onReactionToggle,
|
onReactionToggle,
|
||||||
|
onEditId,
|
||||||
reply,
|
reply,
|
||||||
reactions,
|
reactions,
|
||||||
children,
|
children,
|
||||||
|
@ -644,7 +673,21 @@ export const Message = as<'div', MessageProps>(
|
||||||
const msgContentJSX = (
|
const msgContentJSX = (
|
||||||
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
|
||||||
{reply}
|
{reply}
|
||||||
{children}
|
{edit && onEditId ? (
|
||||||
|
<MessageEditor
|
||||||
|
style={{
|
||||||
|
maxWidth: '100%',
|
||||||
|
width: '100vw',
|
||||||
|
}}
|
||||||
|
roomId={room.roomId}
|
||||||
|
room={room}
|
||||||
|
mEvent={mEvent}
|
||||||
|
imagePackRooms={imagePackRooms}
|
||||||
|
onCancel={() => onEditId()}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
children
|
||||||
|
)}
|
||||||
{reactions}
|
{reactions}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
@ -677,7 +720,7 @@ export const Message = as<'div', MessageProps>(
|
||||||
onMouseLeave={hideOptions}
|
onMouseLeave={hideOptions}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
{(hover || menu || emojiBoard) && (
|
{!edit && (hover || menu || emojiBoard) && (
|
||||||
<div className={css.MessageOptionsBase}>
|
<div className={css.MessageOptionsBase}>
|
||||||
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
|
||||||
<Box gap="100">
|
<Box gap="100">
|
||||||
|
@ -728,6 +771,16 @@ export const Message = as<'div', MessageProps>(
|
||||||
>
|
>
|
||||||
<Icon src={Icons.ReplyArrow} size="100" />
|
<Icon src={Icons.ReplyArrow} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{canEditEvent(mx, mEvent) && onEditId && (
|
||||||
|
<IconButton
|
||||||
|
onClick={() => onEditId(mEvent.getId())}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
size="300"
|
||||||
|
radii="300"
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Pencil} size="100" />
|
||||||
|
</IconButton>
|
||||||
|
)}
|
||||||
<PopOut
|
<PopOut
|
||||||
open={menu}
|
open={menu}
|
||||||
alignOffset={-5}
|
alignOffset={-5}
|
||||||
|
@ -801,12 +854,33 @@ export const Message = as<'div', MessageProps>(
|
||||||
Reply
|
Reply
|
||||||
</Text>
|
</Text>
|
||||||
</MenuItem>
|
</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
|
<MessageReadReceiptItem
|
||||||
room={room}
|
room={room}
|
||||||
eventId={mEvent.getId() ?? ''}
|
eventId={mEvent.getId() ?? ''}
|
||||||
onClose={closeMenu}
|
onClose={closeMenu}
|
||||||
/>
|
/>
|
||||||
<MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
|
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
</Box>
|
</Box>
|
||||||
{((!mEvent.isRedacted() && canDelete) ||
|
{((!mEvent.isRedacted() && canDelete) ||
|
||||||
mEvent.getSender() !== mx.getUserId()) && (
|
mEvent.getSender() !== mx.getUserId()) && (
|
||||||
|
@ -941,7 +1015,7 @@ export const Event = as<'div', EventProps>(
|
||||||
eventId={mEvent.getId() ?? ''}
|
eventId={mEvent.getId() ?? ''}
|
||||||
onClose={closeMenu}
|
onClose={closeMenu}
|
||||||
/>
|
/>
|
||||||
<MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
|
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||||
</Box>
|
</Box>
|
||||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||||
(mEvent.getSender() !== mx.getUserId() && !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,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import classNames from 'classnames';
|
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 { type Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||||
import FocusTrap from 'focus-trap-react';
|
import FocusTrap from 'focus-trap-react';
|
||||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
@ -22,13 +22,6 @@ import { useRelations } from '../../../hooks/useRelations';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { ReactionViewer } from '../reaction-viewer';
|
import { ReactionViewer } from '../reaction-viewer';
|
||||||
|
|
||||||
export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) =>
|
|
||||||
timelineSet.relations.getChildEventsForEvent(
|
|
||||||
eventId,
|
|
||||||
RelationType.Annotation,
|
|
||||||
EventType.Reaction
|
|
||||||
);
|
|
||||||
|
|
||||||
export type ReactionsProps = {
|
export type ReactionsProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
mEventId: string;
|
mEventId: string;
|
||||||
|
|
|
@ -5,7 +5,11 @@ export const targetFromEvent = (evt: Event, selector: string): Element | undefin
|
||||||
|
|
||||||
export const editableActiveElement = (): boolean =>
|
export const editableActiveElement = (): boolean =>
|
||||||
!!document.activeElement &&
|
!!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 = (
|
export const isIntersectingScrollView = (
|
||||||
scrollElement: HTMLElement,
|
scrollElement: HTMLElement,
|
||||||
|
|
|
@ -83,7 +83,7 @@ const StrikeRule: MDRule = {
|
||||||
match: (text) => text.match(STRIKE_REG_1),
|
match: (text) => text.match(STRIKE_REG_1),
|
||||||
html: (parse, match) => {
|
html: (parse, match) => {
|
||||||
const [, g1] = 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 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 =>
|
export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined =>
|
||||||
mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
|
mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias);
|
||||||
|
|
||||||
|
|
|
@ -2,17 +2,22 @@ import { IconName, IconSrc } from 'folds';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
|
EventTimelineSet,
|
||||||
|
EventType,
|
||||||
IPushRule,
|
IPushRule,
|
||||||
IPushRules,
|
IPushRules,
|
||||||
JoinRule,
|
JoinRule,
|
||||||
MatrixClient,
|
MatrixClient,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
|
MsgType,
|
||||||
NotificationCountType,
|
NotificationCountType,
|
||||||
|
RelationType,
|
||||||
Room,
|
Room,
|
||||||
} from 'matrix-js-sdk';
|
} from 'matrix-js-sdk';
|
||||||
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend';
|
||||||
import { AccountDataEvent } from '../../types/matrix/accountData';
|
import { AccountDataEvent } from '../../types/matrix/accountData';
|
||||||
import {
|
import {
|
||||||
|
MessageEvent,
|
||||||
NotificationType,
|
NotificationType,
|
||||||
RoomToParents,
|
RoomToParents,
|
||||||
RoomType,
|
RoomType,
|
||||||
|
@ -249,6 +254,21 @@ export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefin
|
||||||
return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined;
|
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) =>
|
export const parseReplyBody = (userId: string, body: string) =>
|
||||||
`> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`;
|
`> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`;
|
||||||
|
|
||||||
|
@ -301,3 +321,52 @@ export const getReactionContent = (eventId: string, key: string, shortcode?: str
|
||||||
},
|
},
|
||||||
shortcode,
|
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-maths',
|
||||||
'data-mx-pill',
|
'data-mx-pill',
|
||||||
'data-mx-ping',
|
'data-mx-ping',
|
||||||
|
'data-md',
|
||||||
],
|
],
|
||||||
div: ['data-mx-maths'],
|
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'],
|
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
|
||||||
ol: ['start'],
|
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) => ({
|
const transformFontTag: Transformer = (tagName, attribs) => ({
|
||||||
|
|
Loading…
Reference in a new issue