Files
cinny/src/app/features/room/message/MessageEditor.tsx
Ajay Bura 7456c152b7 Escape markdown sequences (#2208)
* escape inline markdown character

* fix typo

* improve document around custom markdown plugin and add escape sequence utils

* recover inline escape sequences on edit

* remove escape sequences from plain text body

* use `s` for strike-through instead of del

* escape block markdown sequences

* fix remove escape sequence was not removing all slashes from plain text

* recover block sequences on edit
2025-02-21 19:19:24 +11:00

332 lines
11 KiB
TypeScript

import React, {
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useState,
} from 'react';
import {
Box,
Chip,
Icon,
IconButton,
Icons,
Line,
PopOut,
RectCords,
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 { isKeyHotkey } 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';
import { mobileOrTablet } from '../../../utils/user-agent';
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 [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [toolbar, setToolbar] = useState(globalToolbar);
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
const getPrevBodyAndFormattedBody = useCallback((): [
string | undefined,
string | undefined
] => {
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 [
typeof body === 'string' ? body : undefined,
typeof customHtml === 'string' ? customHtml : undefined,
];
}, [room, mEvent]);
const [saveState, save] = useAsyncCallback(
useCallback(async () => {
const plainText = toPlainText(editor.children, isMarkdown).trim();
const customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
})
);
const [prevBody, prevCustomHtml] = getPrevBodyAndFormattedBody();
if (plainText === '') return undefined;
if (prevBody) {
if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
return undefined;
}
if (
!prevCustomHtml &&
prevBody === plainText &&
customHtmlEqualsPlainText(customHtml, 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 (isKeyHotkey('mod+enter', evt) || (!enterForNewline && isKeyHotkey('enter', evt))) {
evt.preventDefault();
handleSave();
}
if (isKeyHotkey('escape', evt)) {
evt.preventDefault();
onCancel();
}
},
[onCancel, handleSave, enterForNewline]
);
const handleKeyUp: KeyboardEventHandler = useCallback(
(evt) => {
if (isKeyHotkey('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(() => {
ReactEditor.focus(editor);
setAutocompleteQuery(undefined);
}, [editor]);
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, isMarkdown)
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
Transforms.select(editor, {
anchor: Editor.start(editor, []),
focus: Editor.end(editor, []),
});
editor.insertFragment(initialValue);
if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
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
room={room}
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={undefined}>
{(anchor: RectCords | undefined, setAnchor) => (
<PopOut
anchor={anchor}
alignOffset={-8}
position="Top"
align="End"
content={
<EmojiBoard
imagePackRooms={imagePackRooms ?? []}
returnFocusOnDeactivate={false}
onEmojiSelect={handleEmoticonSelect}
onCustomEmojiSelect={handleEmoticonSelect}
requestClose={() => {
setAnchor(undefined);
if (!mobileOrTablet()) ReactEditor.focus(editor);
}}
/>
}
>
<IconButton
aria-pressed={anchor !== undefined}
onClick={
((evt) =>
setAnchor(
evt.currentTarget.getBoundingClientRect()
)) as MouseEventHandler<HTMLButtonElement>
}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon size="400" src={Icons.Smile} filled={anchor !== undefined} />
</IconButton>
</PopOut>
)}
</UseStateProvider>
</Box>
</Box>
{toolbar && (
<div>
<Line variant="SurfaceVariant" size="300" />
<Toolbar />
</div>
)}
</>
}
/>
</div>
);
}
);