* prevent context menu when editing message * send sticker body (#1479) * update emojiboard search text reaction input label * stop generating upload image thumbnail (#1475) * maintain upload order * Fix message options spinner variant * add markdown toggle in editor toolbar * fix heading toggle icon update with cursor move * add hotkeys for heading * change editor markdown btn style * use Ctrl + Enter to send message (#1470) * fix reaction tooltip word-break * add shift in editor hokeys with number * stop parsing markdown in link
309 lines
10 KiB
TypeScript
309 lines
10 KiB
TypeScript
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 { 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).trim();
|
|
const customHtml = trimCustomHtml(
|
|
toMatrixCustomHTML(editor.children, {
|
|
allowTextFormatting: true,
|
|
allowMarkdown: 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)
|
|
: plainToEditorInput(typeof body === 'string' ? body : '');
|
|
|
|
Transforms.select(editor, {
|
|
anchor: Editor.start(editor, []),
|
|
focus: Editor.end(editor, []),
|
|
});
|
|
|
|
editor.insertFragment(initialValue);
|
|
if (!mobileOrTablet()) 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
|
|
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={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);
|
|
if (!mobileOrTablet()) 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>
|
|
);
|
|
}
|
|
);
|