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:
Ajay Bura 2023-10-14 16:08:43 +11:00 committed by GitHub
parent 152576e85d
commit f5bcc9b851
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 957 additions and 108 deletions

2
package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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}

View file

@ -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;
};

View file

@ -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';

View 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;
};

View file

@ -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:

View file

@ -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

View file

@ -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;
} }
const prevWordRange = getPrevWorldRange(editor); sendTypingStatus(!isEmptyEditor(editor));
const query = prevWordRange
? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) const prevWordRange = getPrevWorldRange(editor);
: undefined; const query = prevWordRange
setAutocompleteQuery(query); ? getAutocompleteQuery<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
}, [editor, sendTypingStatus]); : undefined;
setAutocompleteQuery(query);
},
[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}

View file

@ -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,20 +548,21 @@ 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 } =
count: eventsLength, useVirtualPaginator({
limit: PAGINATION_LIMIT, count: eventsLength,
range: timeline.range, limit: PAGINATION_LIMIT,
onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []), range: timeline.range,
getScrollElement, onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []),
getItemElement: useCallback( getScrollElement,
(index: number) => getItemElement: useCallback(
(scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? (index: number) =>
undefined, (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ??
[] undefined,
), []
onEnd: handleTimelinePagination, ),
}); onEnd: handleTimelinePagination,
});
const loadEventTimeline = useEventTimelineLoader( const loadEventTimeline = useEventTimelineLoader(
mx, mx,
@ -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}

View file

@ -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)) && (

View 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>
);
}
);

View file

@ -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;

View file

@ -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,

View file

@ -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>`;
}, },
}; };

View file

@ -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);

View file

@ -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;
};

View file

@ -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) => ({