Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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.
144 changes: 143 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,53 @@ 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;
}
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.
}
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 +1173,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
isEncrypted,
setEditingScheduledDelayId,
setScheduledTime,
editDraft,
setEditDraft,
setServerMaxDelayMs,
]);

Expand Down Expand Up @@ -1145,6 +1241,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 +1260,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
showAudioRecorder,
editor,
onEditLastMessage,
editDraft,
setEditDraft,
]
);

Expand Down Expand Up @@ -1429,6 +1533,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
27 changes: 24 additions & 3 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
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
31 changes: 31 additions & 0 deletions src/app/features/settings/experimental/EditInInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { SequenceCard } from '$components/sequence-card';
import { SettingTile } from '$components/setting-tile';
import { useSetting } from '$state/hooks/settings';
import { settingsAtom } from '$state/settings';
import { Box, Switch, Text } from 'folds';
import { SequenceCardStyle } from '../styles.css';

export function EditInInput() {
const [editInInput, setEditInInput] = useSetting(settingsAtom, 'editInInput');

return (
<Box direction="Column" gap="100">
<Text size="L400">Discord-Style Message Editing</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
focusId="experimental-edit-in-input"
title="Edit messages in the composer"
description="When editing a message, load its content into the main text input instead of editing inline in the timeline. Cancel with Escape or the × button."
after={
<Switch
variant="Primary"
value={editInInput}
onChange={setEditInInput}
title={editInInput ? 'Disable edit in composer' : 'Enable edit in composer'}
/>
}
/>
</SequenceCard>
</Box>
);
}
2 changes: 2 additions & 0 deletions src/app/features/settings/experimental/Experimental.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Sync } from '../general';
import { SettingsSectionPage } from '../SettingsSectionPage';
import { BandwidthSavingEmojis } from './BandwithSavingEmojis';
import { MSC4268HistoryShare } from './MSC4268HistoryShare';
import { EditInInput } from './EditInInput';

function PersonaToggle() {
const [showPersonaSetting, setShowPersonaSetting] = useSetting(
Expand Down Expand Up @@ -59,6 +60,7 @@ export function Experimental({ requestBack, requestClose }: Readonly<Experimenta
<br />
<Box direction="Column" gap="700">
<Sync />
<EditInInput />
<MSC4268HistoryShare />
<BandwidthSavingEmojis />
<PersonaToggle />
Expand Down
9 changes: 9 additions & 0 deletions src/app/state/room/roomInputDrafts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,3 +58,12 @@ export type TReplyDraftAtom = ReturnType<typeof createReplyDraftAtom>;
export const roomIdToReplyDraftAtomFamily = atomFamily<string, TReplyDraftAtom>(() =>
createReplyDraftAtom()
);

export type IEditDraft = {
eventId: string;
};
const createEditDraftAtom = () => atom<IEditDraft | undefined>(undefined);
export type TEditDraftAtom = ReturnType<typeof createEditDraftAtom>;
export const roomIdToEditDraftAtomFamily = atomFamily<string, TEditDraftAtom>(() =>
createEditDraftAtom()
);
Loading
Loading