Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/edit-in-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
default: minor
---

Add experimental Discord-style edit-in-input toggle.
172 changes: 171 additions & 1 deletion src/app/features/room/RoomInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import {
BlockType,
} from '$components/editor';
import { plainToEditorInput } from '$components/editor/input';
import { htmlToMarkdown } from '$plugins/markdown';
import { EmojiBoard, EmojiBoardTab } from '$components/emoji-board';
import { UseStateProvider } from '$components/UseStateProvider';
import type { TUploadContent } from '$utils/matrix';
Expand All @@ -80,6 +81,7 @@ import {
roomIdToReplyDraftAtomFamily,
roomIdToUploadItemsAtomFamily,
roomUploadAtomFamily,
roomIdToEditDraftAtomFamily,
} from '$state/room/roomInputDrafts';
import { UploadCardRenderer } from '$components/upload-card';
import type { UploadBoardImperativeHandlers } from '$components/upload-board';
Expand All @@ -91,7 +93,12 @@ import { safeFile } from '$utils/mimeTypes';
import { fulfilledPromiseSettledResult } from '$utils/common';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { getMentionContent, isThreadRelationEvent, reactionOrEditEvent } from '$utils/room';
import {
getMentionContent,
isThreadRelationEvent,
reactionOrEditEvent,
getEditedEvent,
} from '$utils/room';
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '$hooks/useCommands';
import { mobileOrTablet } from '$utils/user-agent';
import { useElementSizeObserver } from '$hooks/useElementSizeObserver';
Expand Down Expand Up @@ -294,6 +301,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(

const [msgDraft, setMsgDraft] = useAtom(roomIdToMsgDraftAtomFamily(draftKey));
const [replyDraft, setReplyDraft] = useAtom(roomIdToReplyDraftAtomFamily(draftKey));
const [editDraft, setEditDraft] = useAtom(roomIdToEditDraftAtomFamily(draftKey));

const [uploadBoard, setUploadBoard] = useState(true);
const [selectedFiles, setSelectedFiles] = useAtom(roomIdToUploadItemsAtomFamily(draftKey));
Expand Down Expand Up @@ -458,6 +466,45 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
}, [replyDraft?.eventId, editor]);

const prevEditEventId = useRef(editDraft?.eventId);
useEffect(() => {
if (editDraft?.eventId === prevEditEventId.current) return;
prevEditEventId.current = editDraft?.eventId;

if (!editDraft) {
// Edit was cancelled — editor was already reset by the cancel handler
return;
}

const editEvent = room.findEventById(editDraft.eventId);
if (!editEvent) return;

const evtId = editEvent.getId();
const evtTimeline = evtId ? room.getTimelineForEvent(evtId) : undefined;
const editedVersion =
evtTimeline && evtId
? getEditedEvent(evtId, editEvent, evtTimeline.getTimelineSet())
: undefined;
const content = editedVersion?.getContent()['m.new_content'] ?? editEvent.getContent();
const body = typeof content.body === 'string' ? content.body : '';
const formattedBody =
typeof content.formatted_body === 'string' ? content.formatted_body : undefined;

const initialValue = plainToEditorInput(formattedBody ? htmlToMarkdown(formattedBody) : body);

resetEditor(editor);
resetEditorHistory(editor);
Transforms.insertFragment(editor, initialValue);
requestAnimationFrame(() => {
try {
ReactEditor.focus(editor);
moveCursor(editor);
} catch {
// ignore focus errors
}
});
}, [editDraft, editor, room]);

const handleFileMetadata = useCallback(
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
setSelectedFiles({
Expand Down Expand Up @@ -827,6 +874,81 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(

if (plainText === '') return;

// Discord-style edit: when an editDraft is active, send an m.replace event
// instead of a new message and clear the edit state.
if (editDraft) {
const editEvent = room.findEventById(editDraft.eventId);
if (editEvent) {
const oldContent = editEvent.getContent();
const msgtype = (oldContent.msgtype as string) ?? MsgType.Text;

const newContent: IContent = { msgtype, body: plainText };
if (!customHtmlEqualsPlainText(customHtml, plainText)) {
newContent.format = 'org.matrix.custom.html';
newContent.formatted_body = customHtml;
}
// Preserve media and extension fields from the original event so
// that image/file/sticker captions retain their attachments, and
// vendor extensions (spoiler, link previews, per-message profile)
// are not silently dropped.
for (const key of [
'filename',
'info',
'file',
'url',
'page.codeberg.everypizza.msc4193.spoiler',
'com.beeper.linkpreviews',
'com.beeper.per_message_profile',
] as const) {
if (key in oldContent) {
newContent[key as string] = oldContent[key as string];
}
}
const mentionData = getMentions(mx, roomId, editor);
newContent['m.mentions'] = getMentionContent(
Array.from(mentionData.users),
mentionData.room
);

const sendContent: IContent = {
...oldContent,
'm.relates_to': {
event_id: editDraft.eventId,
rel_type: RelationType.Replace,
},
body: `* ${plainText}`,
'm.new_content': newContent,
'm.mentions': newContent['m.mentions'],
};
if (newContent.format) {
sendContent.format = newContent.format;
sendContent.formatted_body = `* ${newContent.formatted_body as string}`;
}
Comment thread
Just-Insane marked this conversation as resolved.

resetEditor(editor);
resetEditorHistory(editor);
setInputKey((prev) => prev + 1);
setEditDraft(undefined);
sendTypingStatus(false);

mx.sendMessage(roomId, sendContent as RoomMessageEventContent).catch((error: unknown) => {
log.error('failed to send edit', { roomId }, error);
});
Comment thread
Just-Insane marked this conversation as resolved.
} else {
// Original event evicted from timeline — cannot send edit.
// Clear the edit state so the user is not stuck.
log.error('failed to send edit: original event not found', {
roomId,
eventId: editDraft.eventId,
});
setEditDraft(undefined);
resetEditor(editor);
resetEditorHistory(editor);
sendTypingStatus(false);
}
return;
}

// PluralKit-style proxy wrappers (per-message profile proxies) must be stripped
// *before* building `content`, otherwise we end up sending the wrapper verbatim.
let proxiedPerMessageProfile:
Expand Down Expand Up @@ -1079,6 +1201,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
isEncrypted,
setEditingScheduledDelayId,
setScheduledTime,
editDraft,
setEditDraft,
setServerMaxDelayMs,
]);

Expand Down Expand Up @@ -1145,6 +1269,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setAutocompleteQuery(undefined);
return;
}
if (editDraft) {
setEditDraft(undefined);
resetEditor(editor);
resetEditorHistory(editor);
return;
}
setReplyDraft(undefined);
}
},
Expand All @@ -1158,6 +1288,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
showAudioRecorder,
editor,
onEditLastMessage,
editDraft,
setEditDraft,
]
);

Expand Down Expand Up @@ -1429,6 +1561,44 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</Box>
</div>
)}
{editDraft && (
<div>
<Box
alignItems="Center"
gap="300"
style={{
padding: `${config.space.S200} ${config.space.S300} 0`,
}}
>
<IconButton
onClick={() => {
setEditDraft(undefined);
resetEditor(editor);
resetEditorHistory(editor);
}}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Cancel edit"
title="Cancel edit"
>
<Icon src={Icons.Cross} size="50" />
</IconButton>
<Box
direction="Row"
gap="200"
alignItems="Center"
grow="Yes"
style={{ minWidth: 0 }}
>
<Icon size="100" src={Icons.Pencil} />
<Text size="T300" truncate>
Editing message
</Text>
</Box>
</Box>
</div>
)}
{sendError && (
<div>
<Box
Expand Down
31 changes: 26 additions & 5 deletions src/app/features/room/RoomTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ import { useRoomAbbreviationsContext } from '$hooks/useRoomAbbreviations';
import { buildAbbrReplaceTextNode } from '$components/message/RenderBody';
import { profilesCacheAtom } from '$state/userRoomProfile';
import { roomToParentsAtom } from '$state/room/roomToParents';
import { roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts';
import {
roomIdToReplyDraftAtomFamily,
roomIdToEditDraftAtomFamily,
} from '$state/room/roomInputDrafts';
import { roomIdToOpenThreadAtomFamily } from '$state/room/roomToOpenThread';
import {
getRoomUnreadInfo,
Expand Down Expand Up @@ -136,6 +139,19 @@ export function RoomTimeline({
const { editId, handleEdit } = useMessageEdit(editor, { onReset: onEditorReset, alive });
const { navigateRoom } = useRoomNavigate();

const [editInInput] = useSetting(settingsAtom, 'editInInput');
const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(room.roomId));
const handleEditCallback = useCallback(
(id?: string) => {
if (editInInput) {
setEditDraft(id ? { eventId: id } : undefined);
return;
}
handleEdit(id);
},
[editInInput, handleEdit, setEditDraft]
);

const [hideReads] = useSetting(settingsAtom, 'hideReads');
const [messageLayout] = useSetting(settingsAtom, 'messageLayout');
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
Expand Down Expand Up @@ -616,7 +632,12 @@ export function RoomTimeline({
hideNickAvatarEvents,
showHiddenEvents,
},
state: { focusItem: timelineSync.focusItem, editId, activeReplyId, openThreadId },
state: {
focusItem: timelineSync.focusItem,
editId: editInInput ? undefined : editId,
activeReplyId,
openThreadId,
},
permissions: {
canRedact: permissions.action('redact', mx.getSafeUserId()),
canDeleteOwn: permissions.event('m.room.redaction', mx.getSafeUserId()),
Expand All @@ -628,7 +649,7 @@ export function RoomTimeline({
onUsernameClick: actions.handleUsernameClick,
onReplyClick: actions.handleReplyClick,
onReactionToggle: actions.handleReactionToggle,
onEditId: actions.handleEdit,
onEditId: handleEditCallback,
Comment thread
Just-Insane marked this conversation as resolved.
onResend: actions.handleResend,
onDeleteFailedSend: actions.handleDeleteFailedSend,
setOpenThread: actions.setOpenThread,
Expand Down Expand Up @@ -813,9 +834,9 @@ export function RoomTimeline({
e.mEvent.getType() === 'm.room.message' &&
!e.mEvent.isRedacted()
);
if (found?.mEvent.getId()) actions.handleEdit(found.mEvent.getId());
if (found?.mEvent.getId()) handleEditCallback(found.mEvent.getId());
};
}, [onEditLastMessageRef, mx, actions]);
}, [onEditLastMessageRef, mx, handleEditCallback]);

useEffect(() => {
const v = vListRef.current;
Expand Down
26 changes: 23 additions & 3 deletions src/app/features/room/ThreadDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ import { useRoomCreators } from '$hooks/useRoomCreators';
import { useImagePackRooms } from '$hooks/useImagePackRooms';
import { useOpenUserRoomProfile } from '$state/hooks/userRoomProfile';
import type { IReplyDraft } from '$state/room/roomInputDrafts';
import { roomIdToReplyDraftAtomFamily } from '$state/room/roomInputDrafts';
import {
roomIdToReplyDraftAtomFamily,
roomIdToEditDraftAtomFamily,
} from '$state/room/roomInputDrafts';
import { roomToParentsAtom } from '$state/room/roomToParents';
import { useIgnoredUsers } from '$hooks/useIgnoredUsers';
import { useGetMemberPowerTag } from '$hooks/useMemberPowerTag';
Expand Down Expand Up @@ -124,6 +127,18 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
const serverFetchAttemptedRef = useRef<string | null>(null);
const autoFillInProgressRef = useRef(false);
const { editId, handleEdit } = useMessageEdit(editor);
const [editInInput] = useSetting(settingsAtom, 'editInInput');
const setEditDraft = useSetAtom(roomIdToEditDraftAtomFamily(threadRootId));
const handleEditCallback = useCallback(
(id?: string) => {
if (editInInput) {
setEditDraft(id ? { eventId: id } : undefined);
return;
}
handleEdit(id);
},
[editInInput, handleEdit, setEditDraft]
);
const nicknames = useAtomValue(nicknamesAtom);
const pushProcessor = useMemo(() => new PushProcessor(mx), [mx]);
const useAuthentication = useMediaAuthentication();
Expand Down Expand Up @@ -711,7 +726,12 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
showHiddenEvents,
hideThreadChip: true,
},
state: { focusItem, editId, activeReplyId, openThreadId: threadRootId },
state: {
focusItem,
editId: editInInput ? undefined : editId,
activeReplyId,
openThreadId: threadRootId,
},
permissions: {
canRedact,
canDeleteOwn,
Expand All @@ -723,7 +743,7 @@ export function ThreadDrawer({ room, threadRootId, onClose, overlay }: ThreadDra
onUsernameClick: handleUsernameClick,
onReplyClick: handleReplyClick,
onReactionToggle: handleReactionToggle,
onEditId: handleEdit,
onEditId: handleEditCallback,
onResend: handleResend,
onDeleteFailedSend: handleDeleteFailedSend,
setOpenThread: () => {},
Expand Down
Loading
Loading