web/composer: allow switching edit target with arrow keys

This commit is contained in:
Tulir Asokan 2024-10-25 03:23:56 +03:00
parent cc0067bb3f
commit f88b1b5b7f
4 changed files with 46 additions and 16 deletions

View file

@ -89,32 +89,30 @@ const MessageComposer = () => {
roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => { roomCtx.setEditing = useCallback((evt: MemDBEvent | null) => {
if (evt === null) { if (evt === null) {
rawSetEditing(null) rawSetEditing(null)
setState(emptyComposer) setState(draftStore.get(room.roomID) ?? emptyComposer)
return 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 evtContent = evt.content as MessageEventContent
const mediaMsgTypes = ["m.image", "m.audio", "m.video", "m.file"] 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) rawSetEditing(evt)
setState({ setState({
media: isMedia ? evtContent as MediaMessageEventContent : null, media: isMedia ? evtContent as MediaMessageEventContent : null,
text: (!evt.content.filename || evt.content.filename !== evt.content.body) ? (evtContent.body ?? "") : "", text: (!evt.content.filename || evt.content.filename !== evt.content.body) ? (evtContent.body ?? "") : "",
}) })
textInput.current?.focus() textInput.current?.focus()
}, [state]) }, [room.roomID])
const sendMessage = useEvent((evt: React.FormEvent) => { const sendMessage = useEvent((evt: React.FormEvent) => {
evt.preventDefault() evt.preventDefault()
if (state.text === "" && !state.media) { if (state.text === "" && !state.media) {
return return
} }
setState(emptyComposer) if (editing) {
setState(draftStore.get(room.roomID) ?? emptyComposer)
} else {
setState(emptyComposer)
}
rawSetEditing(null) rawSetEditing(null)
setAutocomplete(null) setAutocomplete(null)
const mentions: Mentions = { const mentions: Mentions = {
@ -184,8 +182,7 @@ const MessageComposer = () => {
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => { const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => {
if (evt.key === "Enter" && !evt.shiftKey) { if (evt.key === "Enter" && !evt.shiftKey) {
sendMessage(evt) sendMessage(evt)
} } else if (autocomplete && !evt.ctrlKey && !evt.altKey) {
if (autocomplete && !evt.ctrlKey && !evt.altKey) {
if (!evt.shiftKey && (evt.key === "Tab" || evt.key === "ArrowDown")) { if (!evt.shiftKey && (evt.key === "Tab" || evt.key === "ArrowDown")) {
setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? -1) + 1 }) setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? -1) + 1 })
evt.preventDefault() evt.preventDefault()
@ -193,6 +190,30 @@ const MessageComposer = () => {
setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? 0) - 1 }) setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? 0) - 1 })
evt.preventDefault() 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<HTMLTextAreaElement>) => { const onChange = useEvent((evt: React.ChangeEvent<HTMLTextAreaElement>) => {

View file

@ -15,7 +15,7 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>. // along with this program. If not, see <https://www.gnu.org/licenses/>.
import { RefObject, createContext, createRef, use } from "react" import { RefObject, createContext, createRef, use } from "react"
import { RoomStateStore } from "@/api/statestore" import { RoomStateStore } from "@/api/statestore"
import { EventID, MemDBEvent } from "@/api/types" import { EventID, EventRowID, MemDBEvent } from "@/api/types"
import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts" import { NonNullCachedEventDispatcher } from "@/util/eventdispatcher.ts"
const noop = (name: string) => () => { const noop = (name: string) => () => {
@ -27,6 +27,7 @@ export class RoomContextData {
public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo") public setReplyTo: (eventID: EventID | null) => void = noop("setReplyTo")
public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing") public setEditing: (evt: MemDBEvent | null) => void = noop("setEditing")
public isEditing = new NonNullCachedEventDispatcher<boolean>(false) public isEditing = new NonNullCachedEventDispatcher<boolean>(false)
public ownMessages: EventRowID[] = []
constructor(public store: RoomStateStore) {} constructor(public store: RoomStateStore) {}

View file

@ -68,7 +68,8 @@ const EventMenu = ({ evt }: EventHoverMenuProps) => {
title={isEditing ? "Can't reply to messages while editing a message" : undefined} title={isEditing ? "Can't reply to messages while editing a message" : undefined}
onClick={onClickReply} onClick={onClickReply}
><ReplyIcon/></button> ><ReplyIcon/></button>
{evt.sender === userID && evt.type === "m.room.message" && <button onClick={onClickEdit}><EditIcon/></button>} {evt.sender === userID && evt.type === "m.room.message" && evt.relation_type !== "m.replace"
&& <button onClick={onClickEdit}><EditIcon/></button>}
{ownPL >= pinPL && (pins.includes(evt.event_id) {ownPL >= pinPL && (pins.includes(evt.event_id)
? <button onClick={onClickUnpin}><UnpinIcon/></button> ? <button onClick={onClickUnpin}><UnpinIcon/></button>
: <button onClick={onClickPin}><PinIcon/></button>)} : <button onClick={onClickPin}><PinIcon/></button>)}

View file

@ -53,6 +53,7 @@ const TimelineView = () => {
oldScrollHeight.current = timelineViewRef.current.scrollHeight oldScrollHeight.current = timelineViewRef.current.scrollHeight
} }
useLayoutEffect(() => { useLayoutEffect(() => {
const bottomRef = roomCtx.timelineBottomRef
if (bottomRef.current && scrolledToBottom.current) { if (bottomRef.current && scrolledToBottom.current) {
// For any timeline changes, if we were at the bottom, scroll to the new bottom // For any timeline changes, if we were at the bottom, scroll to the new bottom
bottomRef.current.scrollIntoView() bottomRef.current.scrollIntoView()
@ -61,7 +62,13 @@ const TimelineView = () => {
timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current timelineViewRef.current.scrollTop += timelineViewRef.current.scrollHeight - oldScrollHeight.current
} }
prevOldestTimelineRow.current = timeline[0]?.timeline_rowid ?? 0 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(() => { useEffect(() => {
const newestEvent = timeline[timeline.length - 1] const newestEvent = timeline[timeline.length - 1]
if ( if (