move message editor to new sdk

This commit is contained in:
Ajay Bura 2026-08-27 23:22:01 +05:30
commit 6652e55584
5 changed files with 291 additions and 175 deletions

View file

@ -74,6 +74,7 @@ import { OutboxView } from './OutboxView';
import { useUserReadReceipt } from '../../hooks/useReadReceipt';
import { useMessageParserOptions } from '../../hooks/useMessageParserOptions';
import { MessageContent } from '../../components/MessageContent';
import { MessageEditor } from './message/MessageEditor';
type RoomTimelineProps = {
eventId?: string;
@ -433,20 +434,30 @@ export function RoomTimeline({ eventId, roomInputRef, editor, timeline }: RoomTi
roomId={roomId}
decryptionManager={ct.decryption}
>
{(contentEvent) => (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={contentEvent.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
{(contentEvent) =>
editId === event.event_id ? (
<MessageEditor
style={{ maxWidth: '100%', width: '100vw' }}
roomId={roomId}
event={contentEvent}
imagePackRooms={imagePackRooms}
onCancel={handleEdit}
/>
) : (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={contentEvent.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)
}
</MessageContent>
)}
{reactionJSX}

View file

@ -0,0 +1,178 @@
import { useCallback, useState } from 'react';
import { Editor } from 'slate';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { useClient } from '../../../hooks/useClient';
import { useRoom } from '../../../hooks/useRoom';
import { useEnsureRoomEncryptionSession } from './useEnsureRoomEncryptionSession';
import {
EventSenderIdle,
LocalEvent,
LocalEventSender,
LocalMessage,
} from '../../../../client/room/local-event';
import { MessageEvent } from '../../../../types/matrix/room';
import {
MentionsContent,
MessageContent,
MsgType,
RelationType,
} from '../../../../types/matrix/common';
import { rContent } from '../../../../client/reader';
import {
customHtmlEqualsPlainText,
getMentions,
toMatrixCustomHTML,
toPlainText,
trimCustomHtml,
} from '../../../components/editor';
import { useSetting } from '../../../state/hooks/settings';
import { getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { useAsyncCallback } from '../../../hooks/useAsyncCallback';
const useSendLocalMessage = () => {
const ct = useClient();
const roomId = useRoom();
const prepareEncryptionSession = useEnsureRoomEncryptionSession();
const [localEventSender, setLocalEventSender] = useState<LocalEventSender>();
const sendLocalMessage = useCallback(
async (localMessage: LocalMessage) => {
let wireMessage = localMessage.clone();
if (ct.encryptedRooms.read.has(roomId)) {
await prepareEncryptionSession();
const encStr = await ct.machine.roomEncryption.encryptMessageEvent(
roomId,
localMessage.type,
localMessage.content
);
const encContent = JSON.parse(encStr);
wireMessage = new LocalMessage(roomId, MessageEvent.RoomMessageEncrypted, encContent);
}
const txnId = ct.getTxnId();
const localEvent = new LocalEvent(localMessage, wireMessage, txnId, Date.now());
const sender = new LocalEventSender(localEvent, ct.requestAuthJSON);
if (sender.state.read instanceof EventSenderIdle) {
sender.state.read.send();
}
setLocalEventSender(sender);
},
[ct, roomId, prepareEncryptionSession]
);
return {
localEventSender,
sendLocalMessage,
};
};
export const useSendEditedMessage = (editor: Editor, event: MessageEventWithoutRoomId) => {
const ct = useClient();
const roomId = useRoom();
const [isMarkdown] = useSetting('isMarkdown');
const { localEventSender, sendLocalMessage } = useSendLocalMessage();
const getPrevContent = useCallback((): {
body?: string;
formattedBody?: string;
mentions?: MentionsContent;
} => {
const content = rContent.newContent(event.content) ?? event.content;
const body = rContent.body(content);
const formattedBody = rContent.formattedBody(content);
const mentions: MentionsContent | undefined = rContent.mentions(content);
return {
body,
formattedBody,
mentions,
};
}, [event]);
const [saveState, save] = useAsyncCallback(
useCallback(
async (onCancel: () => void) => {
const plainText = toPlainText(editor.children, isMarkdown).trim();
const customHtml = trimCustomHtml(
toMatrixCustomHTML(editor.children, {
allowTextFormatting: true,
allowBlockMarkdown: isMarkdown,
allowInlineMarkdown: isMarkdown,
})
);
const prevContent = getPrevContent();
if (plainText === '') {
onCancel();
return undefined;
}
if (prevContent.body) {
if (
prevContent.formattedBody &&
trimReplyFromFormattedBody(prevContent.formattedBody) === customHtml
) {
onCancel();
return undefined;
}
if (
!prevContent.formattedBody &&
prevContent.body === plainText &&
customHtmlEqualsPlainText(customHtml, plainText)
) {
onCancel();
return undefined;
}
}
const newContent: MessageContent = {
msgtype: (rContent.msgtype(event.content) as MsgType) ?? MsgType.Text,
body: plainText,
};
const mentionData = getMentions(ct, editor);
prevContent.mentions?.user_ids?.forEach((prevMentionId) => {
mentionData.users.add(prevMentionId);
});
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
newContent['m.mentions'] = mMentions;
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
newContent.format = 'org.matrix.custom.html';
newContent.formatted_body = customHtml;
}
const content: MessageContent & {
'm.new_content': MessageContent;
} = {
...newContent,
body: `* ${plainText}`,
'm.new_content': newContent,
'm.relates_to': {
event_id: event.event_id,
rel_type: RelationType.Replace,
},
};
const messageEvent = new LocalMessage(roomId, event.type, content);
return sendLocalMessage(messageEvent);
},
[ct, roomId, editor, event, isMarkdown, getPrevContent, sendLocalMessage]
)
);
return {
saveState,
localEventSender,
save,
};
};

View file

@ -61,7 +61,6 @@ 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';
import { UserAvatar } from '../../../components/user-avatar';
import { copyToClipboard } from '../../../utils/dom';
import { stopPropagation } from '../../../utils/keyboard';
@ -890,7 +889,6 @@ export const Message = as<'div', MessageProps>(
ref
) => {
const ct = useClient();
const roomId = useRoom();
const senderId = event.sender;
const [hover, setHover] = useState(false);
@ -902,25 +900,6 @@ export const Message = as<'div', MessageProps>(
const senderDisplayName = membersAPI.getName(senderId);
const senderAvatarMxc = membersAPI.getAvatar(senderId);
const msgContentJSX = (
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{edit && onEditId ? (
<MessageEditor
style={{
maxWidth: '100%',
width: '100vw',
}}
roomId={roomId}
event={event}
imagePackRooms={imagePackRooms}
onCancel={() => onEditId()}
/>
) : (
children
)}
</Box>
);
const handleContextMenu: MouseEventHandler<HTMLDivElement> = (evt) => {
if (evt.altKey || !window.getSelection()?.isCollapsed || edit) return;
const tag = (evt.target as any).tagName;
@ -1241,7 +1220,9 @@ export const Message = as<'div', MessageProps>(
messageLayout={messageLayout}
onContextMenu={handleContextMenu}
>
{msgContentJSX}
<Box direction="Column" alignSelf="Start" style={{ maxWidth: '100%' }}>
{children}
</Box>
</RenderMessageLayout>
</MessageBase>
);

View file

@ -3,6 +3,7 @@ import React, {
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import {
@ -17,9 +18,9 @@ import {
Spinner,
Text,
as,
color,
config,
} from 'folds';
import { Editor, Transforms } from 'slate';
import { ReactEditor } from 'slate-react';
import { MessageEventWithoutRoomId } from 'neurone/lib/api/types';
import { isKeyHotkey } from 'is-hotkey';
@ -33,27 +34,27 @@ import {
Toolbar,
UserMentionAutocomplete,
createEmoticonElement,
customHtmlEqualsPlainText,
getAutocompleteQuery,
getPrevWorldRange,
htmlToEditorInput,
moveCursor,
plainToEditorInput,
toMatrixCustomHTML,
toPlainText,
trimCustomHtml,
useEditor,
getMentions,
} from '../../../components/editor';
import { useSetting } from '../../../state/hooks/settings';
import { UseStateProvider } from '../../../components/UseStateProvider';
import { EmojiBoard } from '../../../components/emoji-board';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room';
import { AsyncStatus } from '../../../hooks/useAsyncCallback';
import { mobileOrTablet } from '../../../utils/user-agent';
import { useComposingCheck } from '../../../hooks/useComposingCheck';
import { useClient } from '../../../hooks/useClient';
import { MentionsContent, MessageContent, RelationType } from '../../../../types/matrix/common';
import { useSendEditedMessage } from '../hooks/useSendEditedMessage';
import { useObservableValueOptionally } from '../../../hooks/useObservableValue';
import { rContent } from '../../../../client/reader';
import {
EventSenderError,
EventSenderPending,
EventSenderSuccess,
} from '../../../../client/room/local-event';
type MessageEditorProps = {
roomId: string;
@ -63,7 +64,6 @@ type MessageEditorProps = {
};
export const MessageEditor = as<'div', MessageEditorProps>(
({ roomId, event, imagePackRooms, onCancel, ...props }, ref) => {
const ct = useClient();
const editor = useEditor();
const [enterForNewline] = useSetting('enterForNewline');
const [globalToolbar] = useSetting('editorToolbar');
@ -74,96 +74,17 @@ export const MessageEditor = as<'div', MessageEditorProps>(
const [autocompleteQuery, setAutocompleteQuery] =
useState<AutocompleteQuery<AutocompletePrefix>>();
const getPrevBodyAndFormattedBody = useCallback((): [
string | undefined,
string | undefined,
MentionsContent | undefined
] => {
const evtId = event.event_id;
const evtTimeline = room.getTimelineForEvent(evtId);
const editedEvent =
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
const { saveState, localEventSender, save } = useSendEditedMessage(editor, event);
const content: IContent = editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent();
const { body, formatted_body: customHtml }: Record<string, unknown> = content;
const mMentions: IMentions | undefined = content['m.mentions'];
return [
typeof body === 'string' ? body : undefined,
typeof customHtml === 'string' ? customHtml : undefined,
mMentions,
];
}, [roomId, event]);
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, prevMentions] = getPrevBodyAndFormattedBody();
if (plainText === '') return undefined;
if (prevBody) {
if (prevCustomHtml && trimReplyFromFormattedBody(prevCustomHtml) === customHtml) {
return undefined;
}
if (
!prevCustomHtml &&
prevBody === plainText &&
customHtmlEqualsPlainText(customHtml, plainText)
) {
return undefined;
}
}
const prevContent = event.content as MessageContent;
const newContent: MessageContent = {
msgtype: prevContent.msgtype,
body: plainText,
};
const mentionData = getMentions(ct, editor);
prevMentions?.user_ids?.forEach((prevMentionId) => {
mentionData.users.add(prevMentionId);
});
const mMentions = getMentionContent(Array.from(mentionData.users), mentionData.room);
newContent['m.mentions'] = mMentions;
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
newContent.format = 'org.matrix.custom.html';
newContent.formatted_body = customHtml;
}
const content: MessageContent = {
...newContent,
body: `* ${plainText}`,
'm.new_content': newContent,
'm.relates_to': {
event_id: event.event_id,
rel_type: RelationType.Replace,
},
};
// TODO: send message
// return mx.sendMessage(roomId, content);
return Promise.resolve();
}, [ct, editor, event, isMarkdown, getPrevBodyAndFormattedBody])
);
const senderState = useObservableValueOptionally(localEventSender?.state);
const saving =
saveState.status === AsyncStatus.Loading || senderState instanceof EventSenderPending;
const handleSave = useCallback(() => {
if (saveState.status !== AsyncStatus.Loading) {
save();
save(onCancel);
}
}, [saveState, save]);
}, [saveState, save, onCancel]);
const handleKeyDown: KeyboardEventHandler = useCallback(
(evt) => {
@ -209,27 +130,27 @@ export const MessageEditor = as<'div', MessageEditorProps>(
};
useEffect(() => {
const [body, customHtml] = getPrevBodyAndFormattedBody();
if (!mobileOrTablet()) ReactEditor.focus(editor);
}, [editor]);
const initialEditorValue = useMemo(() => {
const prevContent = rContent.newContent(event.content) ?? event.content;
const customHtml = rContent.formattedBody(prevContent);
const body = rContent.body(prevContent);
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]);
return initialValue;
}, [event, isMarkdown]);
useEffect(() => {
if (saveState.status === AsyncStatus.Success) {
if (senderState instanceof EventSenderSuccess) {
onCancel();
}
}, [saveState, onCancel]);
}, [senderState, onCancel]);
return (
<div {...props} ref={ref}>
@ -259,6 +180,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
)}
<CustomEditor
editor={editor}
initialValue={initialEditorValue}
placeholder="Edit message..."
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
@ -270,24 +192,37 @@ export const MessageEditor = as<'div', MessageEditorProps>(
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 direction="Column" gap="200">
{senderState instanceof EventSenderError && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{senderState.error?.data.error ??
(senderState.cause instanceof Error
? senderState.cause.message
: 'Unknown Error')}
</Text>
)}
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{saveState.error instanceof Error ? saveState.error.message : 'Unknown Error'}
</Text>
)}
<Box>
<Chip
onClick={handleSave}
variant="Primary"
radii="Pill"
disabled={saving}
outlined
before={
saving ? <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>
<Box gap="Inherit">
<IconButton

View file

@ -72,6 +72,7 @@ import { OutboxView } from '../OutboxView';
import { useUserReadReceipt } from '../../../hooks/useReadReceipt';
import { useMessageParserOptions } from '../../../hooks/useMessageParserOptions';
import { MessageContent } from '../../../components/MessageContent';
import { MessageEditor } from '../message/MessageEditor';
type TimelineViewProps = {
threadTimeline: ThreadTimeline;
@ -339,20 +340,30 @@ export function TimelineView({ threadTimeline, threadInputRef }: TimelineViewPro
roomId={roomId}
decryptionManager={ct.decryption}
>
{(contentEvent) => (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={contentEvent.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)}
{(contentEvent) =>
editId === event.event_id ? (
<MessageEditor
style={{ maxWidth: '100%', width: '100vw' }}
roomId={roomId}
event={contentEvent}
imagePackRooms={imagePackRooms}
onCancel={handleEdit}
/>
) : (
<RenderMessageContent
displayName={membersAPI.getName(event.sender)}
eventType={contentEvent.type}
ts={event.origin_server_ts}
edited={!!editedEvent}
content={rContent.newContent(contentEvent.content) ?? contentEvent.content}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment={messageLayout === MessageLayout.Bubble}
/>
)
}
</MessageContent>
)}
{reactionJSX}