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

View file

@ -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<HTMLDivElement, RoomInputProps>(
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<HTMLDivElement, RoomInputProps>(
[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<AutocompletePrefix>(editor, prevWordRange, AUTOCOMPLETE_PREFIXES)
: undefined;
setAutocompleteQuery(query);
}, [editor, sendTypingStatus]);
sendTypingStatus(!isEmptyEditor(editor));
const prevWordRange = getPrevWorldRange(editor);
const query = prevWordRange
? getAutocompleteQuery<AutocompletePrefix>(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<HTMLDivElement, RoomInputProps>(
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.UserMention && (
@ -427,7 +441,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
roomId={roomId}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
requestClose={handleCloseAutocomplete}
/>
)}
{autocompleteQuery?.prefix === AutocompletePrefix.Emoticon && (
@ -435,10 +449,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
imagePackRooms={imagePackRooms}
editor={editor}
query={autocompleteQuery}
requestClose={() => setAutocompleteQuery(undefined)}
requestClose={handleCloseAutocomplete}
/>
)}
<CustomEditor
editableName="RoomInput"
editor={editor}
placeholder="Send a message..."
onKeyDown={handleKeyDown}

View file

@ -15,11 +15,9 @@ import {
EventTimeline,
EventTimelineSet,
EventTimelineSetHandlerMap,
EventType,
IEncryptedFile,
MatrixClient,
MatrixEvent,
RelationType,
Room,
RoomEvent,
RoomEventHandlerMap,
@ -45,6 +43,7 @@ import {
config,
toRem,
} from 'folds';
import isHotkey from 'is-hotkey';
import Linkify from 'linkify-react';
import {
decryptFile,
@ -53,13 +52,12 @@ import {
getMxIdLocalPart,
isRoomId,
isUserId,
matrixEventByRecency,
} from '../../utils/matrix';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { scrollToBottom } from '../../utils/dom';
import { editableActiveElement, scrollToBottom } from '../../utils/dom';
import {
DefaultPlaceholder,
CompactPlaceholder,
@ -80,7 +78,11 @@ import {
} from '../../components/message';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import {
canEditEvent,
decryptAllTimelineEvent,
getEditedEvent,
getEventReactions,
getLatestEditableEvt,
getMemberDisplayName,
getReactionContent,
isMembershipChanged,
@ -124,11 +126,12 @@ import { useDebounce } from '../../hooks/useDebounce';
import { getResizeObserverEntry, useResizeObserver } from '../../hooks/useResizeObserver';
import * as css from './RoomTimeline.css';
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 { usePowerLevelsAPI } from '../../hooks/usePowerLevels';
import { MessageEvent } from '../../../types/matrix/room';
import initMatrix from '../../../client/initMatrix';
import { useKeyDown } from '../../hooks/useKeyDown';
const TimelineFloat = as<'div', css.TimelineFloatVariants>(
({ 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<string> => {
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<string>();
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 === '') <MessageEmptyContent />;
@ -1153,12 +1180,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
imagePackRooms={imagePackRooms}
@ -1167,6 +1196,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
@ -1208,12 +1238,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
messageLayout={messageLayout}
collapse={collapse}
highlight={highlighted}
edit={editId === mEventId}
canDelete={canRedact || mEvent.getSender() === mx.getUserId()}
canSendReaction={canSendReaction}
imagePackRooms={imagePackRooms}
@ -1222,6 +1254,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onUsernameClick={handleUsernameClick}
onReplyClick={handleReplyClick}
onReactionToggle={handleReactionToggle}
onEditId={handleEdit}
reply={
replyEventId && (
<Reply
@ -1280,6 +1313,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Message
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
messageSpacing={messageSpacing}
@ -1325,6 +1359,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
@ -1357,6 +1392,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
@ -1390,6 +1426,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
@ -1423,6 +1460,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
@ -1457,6 +1495,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}
@ -1497,6 +1536,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
<Event
key={mEvent.getId()}
data-message-item={item}
data-message-id={mEventId}
room={room}
mEvent={mEvent}
highlight={highlighted}

View file

@ -45,7 +45,12 @@ import {
Username,
} from '../../../components/message';
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 { MessageLayout, MessageSpacing } from '../../../state/settings';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
@ -56,6 +61,7 @@ import { TextViewer } from '../../../components/text-viewer';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { EmojiBoard } from '../../../components/emoji-board';
import { ReactionViewer } from '../reaction-viewer';
import { MessageEditor } from './MessageEditor';
export type ReactionHandler = (keyOrMxc: string, shortcode: string) => 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<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 = () => {
setOpen(false);
@ -247,7 +272,7 @@ export const MessageSourceCodeItem = as<
<TextViewer
name="Source Code"
langName="json"
text={text}
text={getText()}
requestClose={handleClose}
/>
</Modal>
@ -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<HTMLButtonElement>;
onUsernameClick: MouseEventHandler<HTMLButtonElement>;
onReplyClick: MouseEventHandler<HTMLButtonElement>;
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 = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{reply}
{children}
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={room.roomId}
room={room}
mEvent={mEvent}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
{reactions}
</Box>
);
@ -677,7 +720,7 @@ export const Message = as<'div', MessageProps>(
onMouseLeave={hideOptions}
ref={ref}
>
{(hover || menu || emojiBoard) && (
{!edit && (hover || menu || emojiBoard) && (
<div className={css.MessageOptionsBase}>
<Menu className={css.MessageOptionsBar} variant="SurfaceVariant">
<Box gap="100">
@ -728,6 +771,16 @@ export const Message = as<'div', MessageProps>(
>
<Icon src={Icons.ReplyArrow} size="100" />
</IconButton>
{canEditEvent(mx, mEvent) && onEditId && (
<IconButton
onClick={() => onEditId(mEvent.getId())}
variant="SurfaceVariant"
size="300"
radii="300"
>
<Icon src={Icons.Pencil} size="100" />
</IconButton>
)}
<PopOut
open={menu}
alignOffset={-5}
@ -801,12 +854,33 @@ export const Message = as<'div', MessageProps>(
Reply
</Text>
</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
room={room}
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
<MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete) ||
mEvent.getSender() !== mx.getUserId()) && (
@ -941,7 +1015,7 @@ export const Event = as<'div', EventProps>(
eventId={mEvent.getId() ?? ''}
onClose={closeMenu}
/>
<MessageSourceCodeItem mEvent={mEvent} onClose={closeMenu} />
<MessageSourceCodeItem room={room} mEvent={mEvent} onClose={closeMenu} />
</Box>
{((!mEvent.isRedacted() && canDelete && !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,
} 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;