From f5bcc9b851dc7799c56449d4bd90bbe7c68733a7 Mon Sep 17 00:00:00 2001 From: Ajay Bura <32841439+ajbura@users.noreply.github.com> Date: Sat, 14 Oct 2023 16:08:43 +1100 Subject: [PATCH] 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 --- package-lock.json | 2 + package.json | 2 + src/app/components/editor/Editor.tsx | 5 +- src/app/components/editor/common.ts | 9 + src/app/components/editor/index.ts | 1 + src/app/components/editor/input.ts | 327 ++++++++++++++++++ src/app/components/editor/output.ts | 8 +- src/app/components/editor/slate.d.ts | 19 +- src/app/organisms/room/RoomInput.tsx | 53 ++- src/app/organisms/room/RoomTimeline.tsx | 134 ++++--- src/app/organisms/room/message/Message.tsx | 104 +++++- .../organisms/room/message/MessageEditor.tsx | 295 ++++++++++++++++ src/app/organisms/room/message/Reactions.tsx | 9 +- src/app/utils/dom.ts | 6 +- src/app/utils/markdown.ts | 2 +- src/app/utils/matrix.ts | 9 + src/app/utils/room.ts | 69 ++++ src/app/utils/sanitize.ts | 11 +- 18 files changed, 957 insertions(+), 108 deletions(-) create mode 100644 src/app/components/editor/input.ts create mode 100644 src/app/organisms/room/message/MessageEditor.tsx diff --git a/package-lock.json b/package-lock.json index 6213a1d..70c90a9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "classnames": "2.3.2", "dateformat": "5.0.3", "dayjs": "1.11.10", + "domhandler": "5.0.3", "emojibase": "6.1.0", "emojibase-data": "7.0.1", "file-saver": "2.0.5", @@ -30,6 +31,7 @@ "focus-trap-react": "10.0.2", "folds": "1.5.0", "formik": "2.2.9", + "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "immer": "9.0.16", "is-hotkey": "0.2.0", diff --git a/package.json b/package.json index 8ee5cc5..7467126 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "classnames": "2.3.2", "dateformat": "5.0.3", "dayjs": "1.11.10", + "domhandler": "5.0.3", "emojibase": "6.1.0", "emojibase-data": "7.0.1", "file-saver": "2.0.5", @@ -40,6 +41,7 @@ "focus-trap-react": "10.0.2", "folds": "1.5.0", "formik": "2.2.9", + "html-dom-parser": "4.0.0", "html-react-parser": "4.2.0", "immer": "9.0.16", "is-hotkey": "0.2.0", diff --git a/src/app/components/editor/Editor.tsx b/src/app/components/editor/Editor.tsx index e5377f2..62b4134 100644 --- a/src/app/components/editor/Editor.tsx +++ b/src/app/components/editor/Editor.tsx @@ -50,12 +50,13 @@ const withVoid = (editor: Editor): Editor => { }; export const useEditor = (): Editor => { - const [editor] = useState(withInline(withVoid(withReact(withHistory(createEditor()))))); + const [editor] = useState(() => withInline(withVoid(withReact(withHistory(createEditor()))))); return editor; }; export type EditorChangeHandler = (value: Descendant[]) => void; type CustomEditorProps = { + editableName?: string; top?: ReactNode; bottom?: ReactNode; before?: ReactNode; @@ -71,6 +72,7 @@ type CustomEditorProps = { export const CustomEditor = forwardRef( ( { + editableName, top, bottom, before, @@ -137,6 +139,7 @@ export const CustomEditor = forwardRef( hideTrack > { }); 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; +}; diff --git a/src/app/components/editor/index.ts b/src/app/components/editor/index.ts index 76ccf56..7c63ce6 100644 --- a/src/app/components/editor/index.ts +++ b/src/app/components/editor/index.ts @@ -5,3 +5,4 @@ export * from './Elements'; export * from './keyboard'; export * from './output'; export * from './Toolbar'; +export * from './input'; diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts new file mode 100644 index 0000000..39db0e1 --- /dev/null +++ b/src/app/components/editor/input.ts @@ -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 = { + 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; +}; diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 92c86dd..89a5f7c 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -1,7 +1,8 @@ import { Descendant, Text } from 'slate'; + import { sanitizeText } from '../../utils/sanitize'; import { BlockType } from './Elements'; -import { CustomElement, FormattedText } from './slate'; +import { CustomElement } from './slate'; import { parseInlineMD } from '../../utils/markdown'; export type OutputOptions = { @@ -9,13 +10,13 @@ export type OutputOptions = { allowMarkdown?: boolean; }; -const textToCustomHtml = (node: FormattedText, opts: OutputOptions): string => { +const textToCustomHtml = (node: Text, opts: OutputOptions): string => { let string = sanitizeText(node.text); if (opts.allowTextFormatting) { if (node.bold) string = `${string}`; if (node.italic) string = `${string}`; if (node.underline) string = `${string}`; - if (node.strikeThrough) string = `${string}`; + if (node.strikeThrough) string = `${string}`; if (node.code) string = `${string}`; if (node.spoiler) string = `${string}`; } @@ -47,6 +48,7 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { return `
    ${children}
`; case BlockType.UnorderedList: return `
    ${children}
`; + case BlockType.Mention: return `${node.name}`; case BlockType.Emoticon: diff --git a/src/app/components/editor/slate.d.ts b/src/app/components/editor/slate.d.ts index 74b2070..ee046a0 100644 --- a/src/app/components/editor/slate.d.ts +++ b/src/app/components/editor/slate.d.ts @@ -23,13 +23,9 @@ export type FormattedText = Text & { export type LinkElement = { type: BlockType.Link; href: string; - children: FormattedText[]; -}; -export type SpoilerElement = { - type: 'spoiler'; - alert?: string; - children: FormattedText[]; + children: Text[]; }; + export type MentionElement = { type: BlockType.Mention; id: string; @@ -44,14 +40,16 @@ export type EmoticonElement = { children: Text[]; }; +export type InlineElement = Text | LinkElement | MentionElement | EmoticonElement; + export type ParagraphElement = { type: BlockType.Paragraph; - children: FormattedText[]; + children: InlineElement[]; }; export type HeadingElement = { type: BlockType.Heading; level: HeadingLevel; - children: FormattedText[]; + children: InlineElement[]; }; export type CodeLineElement = { type: BlockType.CodeLine; @@ -63,7 +61,7 @@ export type CodeBlockElement = { }; export type QuoteLineElement = { type: BlockType.QuoteLine; - children: FormattedText[]; + children: InlineElement[]; }; export type BlockQuoteElement = { type: BlockType.BlockQuote; @@ -71,7 +69,7 @@ export type BlockQuoteElement = { }; export type ListItemElement = { type: BlockType.ListItem; - children: FormattedText[]; + children: InlineElement[]; }; export type OrderedListElement = { type: BlockType.OrderedList; @@ -84,7 +82,6 @@ export type UnorderedListElement = { export type CustomElement = | LinkElement - // | SpoilerElement | MentionElement | EmoticonElement | ParagraphElement diff --git a/src/app/organisms/room/RoomInput.tsx b/src/app/organisms/room/RoomInput.tsx index 7564d5f..acb45b3 100644 --- a/src/app/organisms/room/RoomInput.tsx +++ b/src/app/organisms/room/RoomInput.tsx @@ -12,7 +12,7 @@ import { useAtom } from 'jotai'; import isHotkey from 'is-hotkey'; import { EventType, IContent, MsgType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; -import { Transforms, Range, Editor, Element } from 'slate'; +import { Transforms, Range, Editor } from 'slate'; import { Box, Dialog, @@ -51,6 +51,7 @@ import { resetEditorHistory, customHtmlEqualsPlainText, trimCustomHtml, + isEmptyEditor, } from '../../components/editor'; import { EmojiBoard, EmojiBoardTab } from '../../components/emoji-board'; import { UseStateProvider } from '../../components/UseStateProvider'; @@ -95,7 +96,12 @@ import navigation from '../../../client/state/navigation'; import cons from '../../../client/state/cons'; import { MessageReply } from '../../molecules/message/Message'; import colorMXID from '../../../util/colorMXID'; -import { parseReplyBody, parseReplyFormattedBody } from '../../utils/room'; +import { + parseReplyBody, + parseReplyFormattedBody, + trimReplyFromBody, + trimReplyFromFormattedBody, +} from '../../utils/room'; import { sanitizeText } from '../../utils/sanitize'; import { useScreenSize } from '../../hooks/useScreenSize'; @@ -264,13 +270,15 @@ export const RoomInput = forwardRef( let body = plainText; let formattedBody = customHtml; if (replyDraft) { - body = parseReplyBody(replyDraft.userId, replyDraft.userId) + body; + body = parseReplyBody(replyDraft.userId, trimReplyFromBody(replyDraft.body)) + body; formattedBody = parseReplyFormattedBody( roomId, replyDraft.userId, replyDraft.eventId, - replyDraft.formattedBody ?? sanitizeText(replyDraft.body) + replyDraft.formattedBody + ? trimReplyFromFormattedBody(replyDraft.formattedBody) + : sanitizeText(replyDraft.body) ) + formattedBody; } @@ -321,19 +329,25 @@ export const RoomInput = forwardRef( [submit, editor, setReplyDraft] ); - const handleKeyUp: KeyboardEventHandler = useCallback(() => { - const firstChildren = editor.children[0]; - if (firstChildren && Element.isElement(firstChildren)) { - const isEmpty = editor.children.length === 1 && Editor.isEmpty(editor, firstChildren); - sendTypingStatus(!isEmpty); - } + const handleKeyUp: KeyboardEventHandler = useCallback( + (evt) => { + if (isHotkey('escape', evt)) { + evt.preventDefault(); + return; + } - const prevWordRange = getPrevWorldRange(editor); - const query = prevWordRange - ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) - : undefined; - setAutocompleteQuery(query); - }, [editor, sendTypingStatus]); + sendTypingStatus(!isEmptyEditor(editor)); + + const prevWordRange = getPrevWorldRange(editor); + const query = prevWordRange + ? getAutocompleteQuery(editor, prevWordRange, AUTOCOMPLETE_PREFIXES) + : undefined; + setAutocompleteQuery(query); + }, + [editor, sendTypingStatus] + ); + + const handleCloseAutocomplete = useCallback(() => setAutocompleteQuery(undefined), []); const handleEmoticonSelect = (key: string, shortcode: string) => { editor.insertNode(createEmoticonElement(key, shortcode)); @@ -419,7 +433,7 @@ export const RoomInput = forwardRef( roomId={roomId} editor={editor} query={autocompleteQuery} - requestClose={() => setAutocompleteQuery(undefined)} + requestClose={handleCloseAutocomplete} /> )} {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( @@ -427,7 +441,7 @@ export const RoomInput = forwardRef( roomId={roomId} editor={editor} query={autocompleteQuery} - requestClose={() => setAutocompleteQuery(undefined)} + requestClose={handleCloseAutocomplete} /> )} {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( @@ -435,10 +449,11 @@ export const RoomInput = forwardRef( imagePackRooms={imagePackRooms} editor={editor} query={autocompleteQuery} - requestClose={() => setAutocompleteQuery(undefined)} + requestClose={handleCloseAutocomplete} /> )} ( ({ position, className, ...props }, ref) => ( @@ -226,34 +229,6 @@ export const getEventIdAbsoluteIndex = ( return baseIndex + eventIndex; }; -export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) => - timelineSet.relations.getChildEventsForEvent( - eventId, - RelationType.Annotation, - EventType.Reaction - ); - -export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) => - timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType); - -export const getLatestEdit = ( - targetEvent: MatrixEvent, - editEvents: MatrixEvent[] -): MatrixEvent | undefined => { - const eventByTargetSender = (rEvent: MatrixEvent) => - rEvent.getSender() === targetEvent.getSender(); - return editEvents.sort(matrixEventByRecency).find(eventByTargetSender); -}; - -export const getEditedEvent = ( - mEventId: string, - mEvent: MatrixEvent, - timelineSet: EventTimelineSet -): MatrixEvent | undefined => { - const edits = getEventEdits(timelineSet, mEventId, mEvent.getType()); - return edits && getLatestEdit(mEvent, edits.getRelations()); -}; - export const factoryGetFileSrcUrl = (httpUrl: string, mimeType: string, encFile?: IEncryptedFile) => async (): Promise => { if (encFile) { @@ -483,6 +458,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const myPowerLevel = getPowerLevel(mx.getUserId() ?? ''); const canRedact = canDoAction('redact', myPowerLevel); const canSendReaction = canSendEvent(MessageEvent.Reaction, myPowerLevel); + const [editId, setEditId] = useState(); const imagePackRooms: Room[] = useMemo(() => { const allParentSpaces = [ @@ -572,20 +548,21 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli const getScrollElement = useCallback(() => scrollRef.current, []); - const { getItems, scrollToItem, observeBackAnchor, observeFrontAnchor } = useVirtualPaginator({ - count: eventsLength, - limit: PAGINATION_LIMIT, - range: timeline.range, - onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []), - getScrollElement, - getItemElement: useCallback( - (index: number) => - (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? - undefined, - [] - ), - onEnd: handleTimelinePagination, - }); + const { getItems, scrollToItem, scrollToElement, observeBackAnchor, observeFrontAnchor } = + useVirtualPaginator({ + count: eventsLength, + limit: PAGINATION_LIMIT, + range: timeline.range, + onRangeChange: useCallback((r) => setTimeline((cs) => ({ ...cs, range: r })), []), + getScrollElement, + getItemElement: useCallback( + (index: number) => + (scrollRef.current?.querySelector(`[data-message-item="${index}"]`) as HTMLElement) ?? + undefined, + [] + ), + onEnd: handleTimelinePagination, + }); const loadEventTimeline = useEventTimelineLoader( mx, @@ -701,6 +678,29 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useCallback(() => atBottomAnchorRef.current, []) ); + // Handle up arrow edit + useKeyDown( + window, + useCallback( + (evt) => { + if ( + isHotkey('arrowup', evt) && + editableActiveElement() && + document.activeElement?.getAttribute('data-editable-name') === 'RoomInput' && + isEmptyEditor(editor) + ) { + const editableEvt = getLatestEditableEvt(room.getLiveTimeline(), (mEvt) => + canEditEvent(mx, mEvt) + ); + const editableEvtId = editableEvt?.getId(); + if (!editableEvtId) return; + setEditId(editableEvtId); + } + }, + [mx, room, editor] + ) + ); + useEffect(() => { if (eventId) { setTimeline(getEmptyTimeline()); @@ -771,6 +771,22 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli } }, [room, unreadInfo, liveTimelineLinked, rangeAtEnd, atBottom]); + // scroll out of view msg editor in view. + useEffect(() => { + if (editId) { + const editMsgElement = + (scrollRef.current?.querySelector(`[data-message-id="${editId}"]`) as HTMLElement) ?? + undefined; + if (editMsgElement) { + scrollToElement(editMsgElement, { + align: 'center', + behavior: 'smooth', + stopInView: true, + }); + } + } + }, [scrollToElement, editId]); + const handleJumpToLatest = () => { setTimeline(getInitialTimeline(room)); scrollToBottomRef.current.count += 1; @@ -901,6 +917,17 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli }, [mx, room] ); + const handleEdit = useCallback( + (editEvtId?: string) => { + if (editEvtId) { + setEditId(editEvtId); + return; + } + setEditId(undefined); + ReactEditor.focus(editor); + }, + [editor] + ); const renderBody = (body: string, customBody?: string) => { if (body === '') ; @@ -1153,12 +1180,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli void; @@ -211,21 +217,40 @@ export const MessageReadReceiptItem = as< export const MessageSourceCodeItem = as< 'button', { + room: Room; mEvent: MatrixEvent; onClose?: () => void; } ->(({ mEvent, onClose, ...props }, ref) => { +>(({ room, mEvent, onClose, ...props }, ref) => { const [open, setOpen] = useState(false); - const text = JSON.stringify( - mEvent.isEncrypted() + + const getContent = (evt: MatrixEvent) => + evt.isEncrypted() ? { - [`<== DECRYPTED_EVENT ==>`]: mEvent.getEffectiveEvent(), - [`<== ORIGINAL_EVENT ==>`]: mEvent.event, + [`<== DECRYPTED_EVENT ==>`]: evt.getEffectiveEvent(), + [`<== ORIGINAL_EVENT ==>`]: evt.event, } - : mEvent.event, - null, - 2 - ); + : evt.event; + + const getText = (): string => { + const evtId = mEvent.getId()!; + const evtTimeline = room.getTimelineForEvent(evtId); + const edits = + evtTimeline && + getEventEdits(evtTimeline.getTimelineSet(), evtId, mEvent.getType())?.getRelations(); + + if (!edits) return JSON.stringify(getContent(mEvent), null, 2); + + const content: Record = { + '<== MAIN_EVENT ==>': getContent(mEvent), + }; + + edits.forEach((editEvt, index) => { + content[`<== REPLACEMENT_EVENT_${index + 1} ==>`] = getContent(editEvt); + }); + + return JSON.stringify(content, null, 2); + }; const handleClose = () => { setOpen(false); @@ -247,7 +272,7 @@ export const MessageSourceCodeItem = as< @@ -537,6 +562,7 @@ export type MessageProps = { mEvent: MatrixEvent; collapse: boolean; highlight: boolean; + edit?: boolean; canDelete?: boolean; canSendReaction?: boolean; imagePackRooms?: Room[]; @@ -546,6 +572,7 @@ export type MessageProps = { onUserClick: MouseEventHandler; onUsernameClick: MouseEventHandler; onReplyClick: MouseEventHandler; + onEditId?: (eventId?: string) => void; onReactionToggle: (targetEventId: string, key: string, shortcode?: string) => void; reply?: ReactNode; reactions?: ReactNode; @@ -558,6 +585,7 @@ export const Message = as<'div', MessageProps>( mEvent, collapse, highlight, + edit, canDelete, canSendReaction, imagePackRooms, @@ -568,6 +596,7 @@ export const Message = as<'div', MessageProps>( onUsernameClick, onReplyClick, onReactionToggle, + onEditId, reply, reactions, children, @@ -644,7 +673,21 @@ export const Message = as<'div', MessageProps>( const msgContentJSX = ( {reply} - {children} + {edit && onEditId ? ( + onEditId()} + /> + ) : ( + children + )} {reactions} ); @@ -677,7 +720,7 @@ export const Message = as<'div', MessageProps>( onMouseLeave={hideOptions} ref={ref} > - {(hover || menu || emojiBoard) && ( + {!edit && (hover || menu || emojiBoard) && (
@@ -728,6 +771,16 @@ export const Message = as<'div', MessageProps>( > + {canEditEvent(mx, mEvent) && onEditId && ( + onEditId(mEvent.getId())} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} ( Reply + {canEditEvent(mx, mEvent) && onEditId && ( + } + radii="300" + data-event-id={mEvent.getId()} + onClick={() => { + onEditId(mEvent.getId()); + closeMenu(); + }} + > + + Edit Message + + + )} - + {((!mEvent.isRedacted() && canDelete) || mEvent.getSender() !== mx.getUserId()) && ( @@ -941,7 +1015,7 @@ export const Event = as<'div', EventProps>( eventId={mEvent.getId() ?? ''} onClose={closeMenu} /> - + {((!mEvent.isRedacted() && canDelete && !stateEvent) || (mEvent.getSender() !== mx.getUserId() && !stateEvent)) && ( diff --git a/src/app/organisms/room/message/MessageEditor.tsx b/src/app/organisms/room/message/MessageEditor.tsx new file mode 100644 index 0000000..9035747 --- /dev/null +++ b/src/app/organisms/room/message/MessageEditor.tsx @@ -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>(); + + 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 = + 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(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 ( +
+ {autocompleteQuery?.prefix === AutocompletePrefix.RoomMention && ( + + )} + {autocompleteQuery?.prefix === AutocompletePrefix.UserMention && ( + + )} + {autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && ( + + )} + + + + + ) : undefined + } + > + Save + + + Cancel + + + + setToolbar(!toolbar)} + > + + + + {(emojiBoard: boolean, setEmojiBoard) => ( + { + setEmojiBoard(false); + ReactEditor.focus(editor); + }} + /> + } + > + {(anchorRef) => ( + setEmojiBoard(true)} + variant="SurfaceVariant" + size="300" + radii="300" + > + + + )} + + )} + + + + {toolbar && ( +
+ + +
+ )} + + } + /> +
+ ); + } +); diff --git a/src/app/organisms/room/message/Reactions.tsx b/src/app/organisms/room/message/Reactions.tsx index 354820c..bc32c1a 100644 --- a/src/app/organisms/room/message/Reactions.tsx +++ b/src/app/organisms/room/message/Reactions.tsx @@ -12,7 +12,7 @@ import { toRem, } from 'folds'; import classNames from 'classnames'; -import { EventTimelineSet, EventType, RelationType, Room } from 'matrix-js-sdk'; +import { Room } from 'matrix-js-sdk'; import { type Relations } from 'matrix-js-sdk/lib/models/relations'; import FocusTrap from 'focus-trap-react'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; @@ -22,13 +22,6 @@ import { useRelations } from '../../../hooks/useRelations'; import * as css from './styles.css'; import { ReactionViewer } from '../reaction-viewer'; -export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) => - timelineSet.relations.getChildEventsForEvent( - eventId, - RelationType.Annotation, - EventType.Reaction - ); - export type ReactionsProps = { room: Room; mEventId: string; diff --git a/src/app/utils/dom.ts b/src/app/utils/dom.ts index a8dc4be..f39fe62 100644 --- a/src/app/utils/dom.ts +++ b/src/app/utils/dom.ts @@ -5,7 +5,11 @@ export const targetFromEvent = (evt: Event, selector: string): Element | undefin export const editableActiveElement = (): boolean => !!document.activeElement && - /^(input)|(textarea)$/.test(document.activeElement.nodeName.toLowerCase()); + (document.activeElement.nodeName.toLowerCase() === 'input' || + document.activeElement.nodeName.toLowerCase() === 'textbox' || + document.activeElement.getAttribute('contenteditable') === 'true' || + document.activeElement.getAttribute('role') === 'input' || + document.activeElement.getAttribute('role') === 'textbox'); export const isIntersectingScrollView = ( scrollElement: HTMLElement, diff --git a/src/app/utils/markdown.ts b/src/app/utils/markdown.ts index e4294d7..6db7a34 100644 --- a/src/app/utils/markdown.ts +++ b/src/app/utils/markdown.ts @@ -83,7 +83,7 @@ const StrikeRule: MDRule = { match: (text) => text.match(STRIKE_REG_1), html: (parse, match) => { const [, g1] = match; - return `${parse(g1)}`; + return `${parse(g1)}`; }, }; diff --git a/src/app/utils/matrix.ts b/src/app/utils/matrix.ts index 91bd80f..ba27879 100644 --- a/src/app/utils/matrix.ts +++ b/src/app/utils/matrix.ts @@ -28,6 +28,15 @@ export const isRoomId = (id: string): boolean => validMxId(id) && id.startsWith( export const isRoomAlias = (id: string): boolean => validMxId(id) && id.startsWith('#'); +export const parseMatrixToUrl = (url: string): [string | undefined, string | undefined] => { + const href = decodeURIComponent(url); + + const match = href.match(/^https?:\/\/matrix.to\/#\/([@!$+#]\S+:[^\\?|^\s|^\\/]+)(\?(via=\S+))?/); + if (!match) return [undefined, undefined]; + const [, g1AsMxId, , g3AsVia] = match; + return [g1AsMxId, g3AsVia]; +}; + export const getRoomWithCanonicalAlias = (mx: MatrixClient, alias: string): Room | undefined => mx.getRooms()?.find((room) => room.getCanonicalAlias() === alias); diff --git a/src/app/utils/room.ts b/src/app/utils/room.ts index af9505d..1dabdc0 100644 --- a/src/app/utils/room.ts +++ b/src/app/utils/room.ts @@ -2,17 +2,22 @@ import { IconName, IconSrc } from 'folds'; import { EventTimeline, + EventTimelineSet, + EventType, IPushRule, IPushRules, JoinRule, MatrixClient, MatrixEvent, + MsgType, NotificationCountType, + RelationType, Room, } from 'matrix-js-sdk'; import { CryptoBackend } from 'matrix-js-sdk/lib/common-crypto/CryptoBackend'; import { AccountDataEvent } from '../../types/matrix/accountData'; import { + MessageEvent, NotificationType, RoomToParents, RoomType, @@ -249,6 +254,21 @@ export const getRoomAvatarUrl = (mx: MatrixClient, room: Room): string | undefin return room.getAvatarUrl(mx.baseUrl, 32, 32, 'crop') ?? undefined; }; +export const trimReplyFromBody = (body: string): string => { + const match = body.match(/^>\s<.+?>\s.+\n\n/); + if (!match) return body; + return body.slice(match[0].length); +}; + +export const trimReplyFromFormattedBody = (formattedBody: string): string => { + const suffix = ''; + const i = formattedBody.lastIndexOf(suffix); + if (i < 0) { + return formattedBody; + } + return formattedBody.slice(i + suffix.length); +}; + export const parseReplyBody = (userId: string, body: string) => `> <${userId}> ${body.replace(/\n/g, '\n> ')}\n\n`; @@ -301,3 +321,52 @@ export const getReactionContent = (eventId: string, key: string, shortcode?: str }, shortcode, }); + +export const getEventReactions = (timelineSet: EventTimelineSet, eventId: string) => + timelineSet.relations.getChildEventsForEvent( + eventId, + RelationType.Annotation, + EventType.Reaction + ); + +export const getEventEdits = (timelineSet: EventTimelineSet, eventId: string, eventType: string) => + timelineSet.relations.getChildEventsForEvent(eventId, RelationType.Replace, eventType); + +export const getLatestEdit = ( + targetEvent: MatrixEvent, + editEvents: MatrixEvent[] +): MatrixEvent | undefined => { + const eventByTargetSender = (rEvent: MatrixEvent) => + rEvent.getSender() === targetEvent.getSender(); + return editEvents.sort((m1, m2) => m2.getTs() - m1.getTs()).find(eventByTargetSender); +}; + +export const getEditedEvent = ( + mEventId: string, + mEvent: MatrixEvent, + timelineSet: EventTimelineSet +): MatrixEvent | undefined => { + const edits = getEventEdits(timelineSet, mEventId, mEvent.getType()); + return edits && getLatestEdit(mEvent, edits.getRelations()); +}; + +export const canEditEvent = (mx: MatrixClient, mEvent: MatrixEvent) => + mEvent.getSender() === mx.getUserId() && + !mEvent.isRelation() && + mEvent.getType() === MessageEvent.RoomMessage && + (mEvent.getContent().msgtype === MsgType.Text || + mEvent.getContent().msgtype === MsgType.Emote || + mEvent.getContent().msgtype === MsgType.Notice); + +export const getLatestEditableEvt = ( + timeline: EventTimeline, + canEdit: (mEvent: MatrixEvent) => boolean +): MatrixEvent | undefined => { + const events = timeline.getEvents(); + + for (let i = events.length - 1; i >= 0; i -= 1) { + const evt = events[i]; + if (canEdit(evt)) return evt; + } + return undefined; +}; diff --git a/src/app/utils/sanitize.ts b/src/app/utils/sanitize.ts index 6a03ca7..8e7c128 100644 --- a/src/app/utils/sanitize.ts +++ b/src/app/utils/sanitize.ts @@ -56,12 +56,19 @@ const permittedTagToAttributes = { 'data-mx-maths', 'data-mx-pill', 'data-mx-ping', + 'data-md', ], div: ['data-mx-maths'], - a: ['name', 'target', 'href', 'rel'], + a: ['name', 'target', 'href', 'rel', 'data-md'], img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'], ol: ['start'], - code: ['class'], + code: ['class', 'data-md'], + strong: ['data-md'], + i: ['data-md'], + em: ['data-md'], + u: ['data-md'], + s: ['data-md'], + del: ['data-md'], }; const transformFontTag: Transformer = (tagName, attribs) => ({