From f88b1b5b7fcc90076d63f622ff4599a20b7d19ea Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Fri, 25 Oct 2024 03:23:56 +0300 Subject: [PATCH] web/composer: allow switching edit target with arrow keys --- web/src/ui/composer/MessageComposer.tsx | 47 ++++++++++++++++++------- web/src/ui/roomcontext.ts | 3 +- web/src/ui/timeline/EventMenu.tsx | 3 +- web/src/ui/timeline/TimelineView.tsx | 9 ++++- 4 files changed, 46 insertions(+), 16 deletions(-) diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index 22ba8f0..ddf879e 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -89,32 +89,30 @@ const MessageComposer = () => { roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => { if (evt === null) { rawSetEditing(null) - setState(emptyComposer) + setState(draftStore.get(room.roomID) ?? emptyComposer) return } - if (state.text || state.media) { - // TODO save as draft instead of discarding? - const ok = window.confirm("Discard current message to start editing?") - if (!ok) { - return - } - } const evtContent = evt.content as MessageEventContent const mediaMsgTypes = ["m.image", "m.audio", "m.video", "m.file"] - const isMedia = mediaMsgTypes.includes(evtContent.msgtype) && (evt.content?.url || evt.content?.file?.url) + const isMedia = mediaMsgTypes.includes(evtContent.msgtype) + && Boolean(evt.content?.url || evt.content?.file?.url) rawSetEditing(evt) setState({ media: isMedia ? evtContent as MediaMessageEventContent : null, text: (!evt.content.filename || evt.content.filename !== evt.content.body) ? (evtContent.body ?? "") : "", }) textInput.current?.focus() - }, [state]) + }, [room.roomID]) const sendMessage = useEvent((evt: React.FormEvent) => { evt.preventDefault() if (state.text === "" && !state.media) { return } - setState(emptyComposer) + if (editing) { + setState(draftStore.get(room.roomID) ?? emptyComposer) + } else { + setState(emptyComposer) + } rawSetEditing(null) setAutocomplete(null) const mentions: Mentions = { @@ -184,8 +182,7 @@ const MessageComposer = () => { const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => { if (evt.key === "Enter" && !evt.shiftKey) { sendMessage(evt) - } - if (autocomplete && !evt.ctrlKey && !evt.altKey) { + } else if (autocomplete && !evt.ctrlKey && !evt.altKey) { if (!evt.shiftKey && (evt.key === "Tab" || evt.key === "ArrowDown")) { setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? -1) + 1 }) evt.preventDefault() @@ -193,6 +190,30 @@ const MessageComposer = () => { setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? 0) - 1 }) evt.preventDefault() } + } else if (!autocomplete && textInput.current) { + const inp = textInput.current + if (evt.key === "ArrowUp" && inp.selectionStart === 0 && inp.selectionEnd === 0) { + const currentlyEditing = editing + ? roomCtx.ownMessages.indexOf(editing.rowid) + : roomCtx.ownMessages.length + const prevEventToEditID = roomCtx.ownMessages[currentlyEditing - 1] + const prevEventToEdit = prevEventToEditID ? room.eventsByRowID.get(prevEventToEditID) : undefined + if (prevEventToEdit) { + roomCtx.setEditing(prevEventToEdit) + evt.preventDefault() + } + } else if (editing && evt.key === "ArrowDown" && inp.selectionStart === state.text.length) { + const currentlyEditingIdx = roomCtx.ownMessages.indexOf(editing.rowid) + const nextEventToEdit = currentlyEditingIdx + ? room.eventsByRowID.get(roomCtx.ownMessages[currentlyEditingIdx + 1]) : undefined + roomCtx.setEditing(nextEventToEdit ?? null) + // This timeout is very hacky and probably doesn't work in every case + setTimeout(() => inp.setSelectionRange(0, 0), 0) + evt.preventDefault() + } + } + if (editing && evt.key === "Escape") { + roomCtx.setEditing(null) } }) const onChange = useEvent((evt: React.ChangeEvent) => { diff --git a/web/src/ui/roomcontext.ts b/web/src/ui/roomcontext.ts index 3d7e694..8090655 100644 --- a/web/src/ui/roomcontext.ts +++ b/web/src/ui/roomcontext.ts @@ -15,7 +15,7 @@ // along with this program. If not, see . import { RefObject, createContext, createRef, use } from "react" import { RoomStateStore } from "@/api/statestore" -import { EventID, MemDBEvent } from "@/api/types" +import { EventID, EventRowID, MemDBEvent } from "@/api/types" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" const noop = (name: string) => () => { @@ -27,6 +27,7 @@ export class RoomContextData { public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo") public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing") public isEditing = new NonNullCachedEventDispatcher(false) + public ownMessages: EventRowID[] = [] constructor(public store: RoomStateStore) {} diff --git a/web/src/ui/timeline/EventMenu.tsx b/web/src/ui/timeline/EventMenu.tsx index db2ed5f..6a15729 100644 --- a/web/src/ui/timeline/EventMenu.tsx +++ b/web/src/ui/timeline/EventMenu.tsx @@ -68,7 +68,8 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => { title={isEditing ? "Can't reply to messages while editing a message" : undefined} onClick={onClickReply} > - {evt.sender === userID && evt.type === "m.room.message" && } + {evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace" + && } {ownPL >= pinPL && (pins.includes(evt.event_id) ? : )} diff --git a/web/src/ui/timeline/TimelineView.tsx b/web/src/ui/timeline/TimelineView.tsx index 0d9403a..8e1a3e6 100644 --- a/web/src/ui/timeline/TimelineView.tsx +++ b/web/src/ui/timeline/TimelineView.tsx @@ -53,6 +53,7 @@ const TimelineView = () => { oldScrollHeight.current = timelineViewRef.current.scrollHeight } useLayoutEffect(() => { + const bottomRef = roomCtx.timelineBottomRef if (bottomRef.current && scrolledToBottom.current) { // For any timeline changes, if we were at the bottom, scroll to the new bottom bottomRef.current.scrollIntoView() @@ -61,7 +62,13 @@ const TimelineView = () => { timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current } prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 - }, [bottomRef, timeline]) + roomCtx.ownMessages = timeline + .filter(evt => evt !== null + && evt.sender === client.userID + && evt.type === "m.room.message" + && evt.relation_type !== "m.replace") + .map(evt => evt!.rowid) + }, [client.userID, roomCtx, timeline]) useEffect(() => { const newestEvent = timeline[timeline.length - 1] if (