diff --git a/web/src/api/statestore/main.ts b/web/src/api/statestore/main.ts index 5b01b0a..d7c1256 100644 --- a/web/src/api/statestore/main.ts +++ b/web/src/api/statestore/main.ts @@ -61,7 +61,8 @@ export class StateStore { #emojiPackKeys: RoomStateGUID[] | null = null #watchedRoomEmojiPacks: Record | null = null #personalEmojiPack: CustomEmojiPack | null = null - switchRoom?: (roomID: RoomID) => void + switchRoom?: (roomID: RoomID | null) => void + activeRoomID?: RoomID imageAuthToken?: string #shouldHideRoom(entry: SyncRoom): boolean { @@ -159,6 +160,9 @@ export class StateStore { this.accountDataSubs.notify(ad.type) } for (const roomID of sync.left_rooms) { + if (this.activeRoomID === roomID) { + this.switchRoom?.(null) + } this.rooms.delete(roomID) changedRoomListEntries.set(roomID, null) } diff --git a/web/src/ui/MainScreen.tsx b/web/src/ui/MainScreen.tsx index f20be4a..316b5f5 100644 --- a/web/src/ui/MainScreen.tsx +++ b/web/src/ui/MainScreen.tsx @@ -13,10 +13,13 @@ // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -import { use, useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useState } from "react" +import { use, useEffect, useMemo, useReducer, useState } from "react" +import Client from "@/api/client.ts" +import { RoomStateStore } from "@/api/statestore" import type { RoomID } from "@/api/types" import ClientContext from "./ClientContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" +import Keybindings from "./keybindings.ts" import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" import RoomList from "./roomlist/RoomList.tsx" import RoomView from "./roomview/RoomView.tsx" @@ -30,45 +33,62 @@ const rpReducer = (prevState: RightPanelProps | null, newState: RightPanelProps return newState } -const MainScreen = () => { - const [activeRoomID, setActiveRoomID] = useState(null) - const [rightPanel, setRightPanel] = useReducer(rpReducer, null) - const client = use(ClientContext)! - const activeRoom = activeRoomID ? client.store.rooms.get(activeRoomID) : undefined - const setActiveRoom = useCallback((roomID: RoomID) => { +class ContextFields implements MainScreenContextFields { + public keybindings: Keybindings + + constructor( + public setRightPanel: (props: RightPanelProps | null) => void, + private directSetActiveRoom: (room: RoomStateStore | null) => void, + private client: Client, + ) { + this.keybindings = new Keybindings(client.store, this) + client.store.switchRoom = this.setActiveRoom + } + + setActiveRoom = (roomID: RoomID | null) => { console.log("Switching to room", roomID) - setActiveRoomID(roomID) - setRightPanel(null) - if (client.store.rooms.get(roomID)?.stateLoaded === false) { - client.loadRoomState(roomID) + const room = (roomID && this.client.store.rooms.get(roomID)) || null + this.directSetActiveRoom(room) + this.setRightPanel(null) + if (room?.stateLoaded === false) { + this.client.loadRoomState(room.roomID) .catch(err => console.error("Failed to load room state", err)) } - }, [client]) - const context: MainScreenContextFields = useMemo(() => ({ - setActiveRoom, - clickRoom: (evt: React.MouseEvent) => { - const roomID = evt.currentTarget.getAttribute("data-room-id") - if (roomID) { - setActiveRoom(roomID) - } else { - console.warn("No room ID :(", evt.currentTarget) - } - }, - clearActiveRoom: () => setActiveRoomID(null), - setRightPanel, - closeRightPanel: () => setRightPanel(null), - clickRightPanelOpener: (evt: React.MouseEvent) => { - const type = evt.currentTarget.getAttribute("data-target-panel") - if (type === "pinned-messages" || type === "members") { - setRightPanel({ type }) - } else { - throw new Error(`Invalid right panel type ${type}`) - } - }, - }), [setRightPanel, setActiveRoom]) - useLayoutEffect(() => { - client.store.switchRoom = setActiveRoom - }, [client, setActiveRoom]) + this.client.store.activeRoomID = room?.roomID + this.keybindings.activeRoom = room + } + + clickRoom = (evt: React.MouseEvent) => { + const roomID = evt.currentTarget.getAttribute("data-room-id") + if (roomID) { + this.setActiveRoom(roomID) + } else { + console.warn("No room ID :(", evt.currentTarget) + } + } + + clickRightPanelOpener = (evt: React.MouseEvent) => { + const type = evt.currentTarget.getAttribute("data-target-panel") + if (type === "pinned-messages" || type === "members") { + this.setRightPanel({ type }) + } else { + throw new Error(`Invalid right panel type ${type}`) + } + } + + clearActiveRoom = () => this.setActiveRoom(null) + closeRightPanel = () => this.setRightPanel(null) +} + +const MainScreen = () => { + const [activeRoom, directSetActiveRoom] = useState(null) + const [rightPanel, setRightPanel] = useReducer(rpReducer, null) + const client = use(ClientContext)! + const context = useMemo( + () => new ContextFields(setRightPanel, directSetActiveRoom, client), + [client], + ) + useEffect(() => context.keybindings.listen(), [context]) useEffect(() => { const styleTags = document.createElement("style") styleTags.textContent = ` @@ -101,11 +121,11 @@ const MainScreen = () => { } return
- + {resizeHandle1} {activeRoom ? void + setActiveRoom: (roomID: RoomID | null) => void clickRoom: (evt: React.MouseEvent) => void clearActiveRoom: () => void diff --git a/web/src/ui/composer/MessageComposer.tsx b/web/src/ui/composer/MessageComposer.tsx index 10f60e0..34f9bad 100644 --- a/web/src/ui/composer/MessageComposer.tsx +++ b/web/src/ui/composer/MessageComposer.tsx @@ -30,6 +30,7 @@ import { escapeMarkdown } from "@/util/markdown.ts" import useEvent from "@/util/useEvent.ts" import ClientContext from "../ClientContext.ts" import EmojiPicker from "../emojipicker/EmojiPicker.tsx" +import { keyToString } from "../keybindings.ts" import { ModalContext } from "../modal/Modal.tsx" import { useRoomContext } from "../roomview/roomcontext.ts" import { ReplyBody } from "../timeline/ReplyBody.tsx" @@ -193,40 +194,39 @@ const MessageComposer = () => { } } }) - const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => { - if (evt.key === "Enter" && !evt.shiftKey) { + const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => { + const inp = evt.currentTarget + const fullKey = keyToString(evt) + if (fullKey === "Enter") { sendMessage(evt) - } else if (autocomplete && !evt.ctrlKey && !evt.altKey) { - if (!evt.shiftKey && (evt.key === "Tab" || evt.key === "ArrowDown")) { + } else if (autocomplete) { + if (fullKey === "Tab" || fullKey === "ArrowDown") { setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? -1) + 1 }) evt.preventDefault() - } else if ((evt.shiftKey && evt.key === "Tab") || (!evt.shiftKey && evt.key === "ArrowUp")) { + } else if (fullKey === "Shift+Tab" || fullKey === "ArrowUp") { 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) + } else if (fullKey === "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() } - } - if (editing && evt.key === "Escape") { + } else if (editing && fullKey === "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() + } else if (editing && fullKey === "Escape") { + evt.stopPropagation() roomCtx.setEditing(null) } }) diff --git a/web/src/ui/keybindings.ts b/web/src/ui/keybindings.ts new file mode 100644 index 0000000..bcdf33d --- /dev/null +++ b/web/src/ui/keybindings.ts @@ -0,0 +1,101 @@ +// gomuks - A Matrix client written in Go. +// Copyright (C) 2024 Tulir Asokan +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . +import React from "react" +import { RoomStateStore, StateStore } from "@/api/statestore" +import { MainScreenContextFields } from "@/ui/MainScreenContext.ts" + +export function keyToString(evt: React.KeyboardEvent | KeyboardEvent) { + let key = evt.key + if (evt.shiftKey) { + key = "Shift+" + key + } + if (evt.altKey) { + key = "Alt+" + key + } + if (evt.metaKey) { + key = "Super+" + key + } + if (evt.ctrlKey) { + key = "Ctrl+" + key + } + return key +} + +type KeyMap = Record void> + +export default class Keybindings { + public activeRoom: RoomStateStore | null = null + constructor(private store: StateStore, private context: MainScreenContextFields) {} + + private keyDownMap: KeyMap = { + "Ctrl+k": () => document.getElementById("room-search")?.focus(), + "Alt+ArrowUp": () => { + if (!this.activeRoom) { + return + } + const activeRoomID = this.activeRoom.roomID + const selectedIdx = this.store.roomList.current.findLastIndex(room => room.room_id === activeRoomID) + if (selectedIdx < this.store.roomList.current.length - 1) { + this.context.setActiveRoom(this.store.roomList.current[selectedIdx + 1].room_id) + } else { + this.context.setActiveRoom(null) + } + }, + "Alt+ArrowDown": () => { + const selectedIdx = this.activeRoom + ? this.store.roomList.current.findLastIndex(room => room.room_id === this.activeRoom?.roomID) + : -1 + if (selectedIdx === -1) { + this.context.setActiveRoom(this.store.roomList.current[this.store.roomList.current.length - 1].room_id) + } else if (selectedIdx > 0) { + this.context.setActiveRoom(this.store.roomList.current[selectedIdx - 1].room_id) + } + }, + } + + private keyUpMap: KeyMap = { + "Escape": evt => evt.target === evt.currentTarget && this.context.clearActiveRoom(), + } + + listen(): () => void { + document.body.addEventListener("keydown", this.onKeyDown) + document.body.addEventListener("keyup", this.onKeyUp) + return () => { + document.body.removeEventListener("keydown", this.onKeyDown) + document.body.removeEventListener("keyup", this.onKeyUp) + } + } + + onKeyDown = (evt: KeyboardEvent) => { + const key = keyToString(evt) + const handler = this.keyDownMap[key] + if (handler !== undefined) { + evt.preventDefault() + handler(evt) + } else if ( + evt.target === evt.currentTarget + && this.keyUpMap[keyToString(evt)] === undefined + && (!evt.ctrlKey || evt.key === "v" || evt.key === "a") + && !evt.altKey + ) { + document.getElementById("message-composer")?.focus() + } + } + + onKeyUp = (evt: KeyboardEvent) => { + this.keyUpMap[keyToString(evt)]?.(evt) + } +} diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index 34716f9..11ecf68 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -45,6 +45,7 @@ const RoomList = ({ activeRoomID }: RoomListProps) => { type="text" placeholder="Search rooms" ref={roomFilterRef} + id="room-search" />
{reverseMap(roomList, room => diff --git a/web/src/ui/roomview/RoomView.tsx b/web/src/ui/roomview/RoomView.tsx index d5e0cb7..47398cc 100644 --- a/web/src/ui/roomview/RoomView.tsx +++ b/web/src/ui/roomview/RoomView.tsx @@ -28,19 +28,13 @@ interface RoomViewProps { rightPanelResizeHandle: JSX.Element } -const onKeyDownRoomView = (evt: React.KeyboardEvent) => { - if (evt.target === evt.currentTarget && (!evt.ctrlKey || evt.key === "v" || evt.key === "a") && !evt.altKey) { - document.getElementById("message-composer")?.focus() - } -} - const RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) => { const roomContextDataRef = useRef(undefined) if (roomContextDataRef.current === undefined) { roomContextDataRef.current = new RoomContextData(room) } return -
+