1
0
Fork 0
forked from Mirrors/gomuks

web/main: add keybindings for room list

Fixes #472
This commit is contained in:
Tulir Asokan 2024-11-01 01:29:32 +02:00
parent f68070807c
commit 245d81b9ce
7 changed files with 194 additions and 74 deletions

View file

@ -61,7 +61,8 @@ export class StateStore {
#emojiPackKeys: RoomStateGUID[] | null = null #emojiPackKeys: RoomStateGUID[] | null = null
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null #watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | null = null
#personalEmojiPack: CustomEmojiPack | null = null #personalEmojiPack: CustomEmojiPack | null = null
switchRoom?: (roomID: RoomID) => void switchRoom?: (roomID: RoomID | null) => void
activeRoomID?: RoomID
imageAuthToken?: string imageAuthToken?: string
#shouldHideRoom(entry: SyncRoom): boolean { #shouldHideRoom(entry: SyncRoom): boolean {
@ -159,6 +160,9 @@ export class StateStore {
this.accountDataSubs.notify(ad.type) this.accountDataSubs.notify(ad.type)
} }
for (const roomID of sync.left_rooms) { for (const roomID of sync.left_rooms) {
if (this.activeRoomID === roomID) {
this.switchRoom?.(null)
}
this.rooms.delete(roomID) this.rooms.delete(roomID)
changedRoomListEntries.set(roomID, null) changedRoomListEntries.set(roomID, null)
} }

View file

@ -13,10 +13,13 @@
// //
// You should have received a copy of the GNU Affero General Public License // You should have received a copy of the GNU Affero General Public License
// 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 { 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 type { RoomID } from "@/api/types"
import ClientContext from "./ClientContext.ts" import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts" import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import Keybindings from "./keybindings.ts"
import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx" import RightPanel, { RightPanelProps } from "./rightpanel/RightPanel.tsx"
import RoomList from "./roomlist/RoomList.tsx" import RoomList from "./roomlist/RoomList.tsx"
import RoomView from "./roomview/RoomView.tsx" import RoomView from "./roomview/RoomView.tsx"
@ -30,45 +33,62 @@ const rpReducer = (prevState: RightPanelProps | null, newState: RightPanelProps
return newState return newState
} }
const MainScreen = () => { class ContextFields implements MainScreenContextFields {
const [activeRoomID, setActiveRoomID] = useState<RoomID | null>(null) public keybindings: Keybindings
const [rightPanel, setRightPanel] = useReducer(rpReducer, null)
const client = use(ClientContext)! constructor(
const activeRoom = activeRoomID ? client.store.rooms.get(activeRoomID) : undefined public setRightPanel: (props: RightPanelProps | null) => void,
const setActiveRoom = useCallback((roomID: RoomID) => { 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) console.log("Switching to room", roomID)
setActiveRoomID(roomID) const room = (roomID && this.client.store.rooms.get(roomID)) || null
setRightPanel(null) this.directSetActiveRoom(room)
if (client.store.rooms.get(roomID)?.stateLoaded === false) { this.setRightPanel(null)
client.loadRoomState(roomID) if (room?.stateLoaded === false) {
this.client.loadRoomState(room.roomID)
.catch(err => console.error("Failed to load room state", err)) .catch(err => console.error("Failed to load room state", err))
} }
}, [client]) this.client.store.activeRoomID = room?.roomID
const context: MainScreenContextFields = useMemo(() => ({ this.keybindings.activeRoom = room
setActiveRoom, }
clickRoom: (evt: React.MouseEvent) => {
const roomID = evt.currentTarget.getAttribute("data-room-id") clickRoom = (evt: React.MouseEvent) => {
if (roomID) { const roomID = evt.currentTarget.getAttribute("data-room-id")
setActiveRoom(roomID) if (roomID) {
} else { this.setActiveRoom(roomID)
console.warn("No room ID :(", evt.currentTarget) } else {
} console.warn("No room ID :(", evt.currentTarget)
}, }
clearActiveRoom: () => setActiveRoomID(null), }
setRightPanel,
closeRightPanel: () => setRightPanel(null), clickRightPanelOpener = (evt: React.MouseEvent) => {
clickRightPanelOpener: (evt: React.MouseEvent) => { const type = evt.currentTarget.getAttribute("data-target-panel")
const type = evt.currentTarget.getAttribute("data-target-panel") if (type === "pinned-messages" || type === "members") {
if (type === "pinned-messages" || type === "members") { this.setRightPanel({ type })
setRightPanel({ type }) } else {
} else { throw new Error(`Invalid right panel type ${type}`)
throw new Error(`Invalid right panel type ${type}`) }
} }
},
}), [setRightPanel, setActiveRoom]) clearActiveRoom = () => this.setActiveRoom(null)
useLayoutEffect(() => { closeRightPanel = () => this.setRightPanel(null)
client.store.switchRoom = setActiveRoom }
}, [client, setActiveRoom])
const MainScreen = () => {
const [activeRoom, directSetActiveRoom] = useState<RoomStateStore | null>(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(() => { useEffect(() => {
const styleTags = document.createElement("style") const styleTags = document.createElement("style")
styleTags.textContent = ` styleTags.textContent = `
@ -101,11 +121,11 @@ const MainScreen = () => {
} }
return <main className={classNames.join(" ")} style={extraStyle}> return <main className={classNames.join(" ")} style={extraStyle}>
<MainScreenContext value={context}> <MainScreenContext value={context}>
<RoomList activeRoomID={activeRoomID}/> <RoomList activeRoomID={activeRoom?.roomID ?? null}/>
{resizeHandle1} {resizeHandle1}
{activeRoom {activeRoom
? <RoomView ? <RoomView
key={activeRoomID} key={activeRoom.roomID}
room={activeRoom} room={activeRoom}
rightPanel={rightPanel} rightPanel={rightPanel}
rightPanelResizeHandle={resizeHandle2} rightPanelResizeHandle={resizeHandle2}

View file

@ -18,7 +18,7 @@ import type { RoomID } from "@/api/types"
import type { RightPanelProps } from "./rightpanel/RightPanel.tsx" import type { RightPanelProps } from "./rightpanel/RightPanel.tsx"
export interface MainScreenContextFields { export interface MainScreenContextFields {
setActiveRoom: (roomID: RoomID) => void setActiveRoom: (roomID: RoomID | null) => void
clickRoom: (evt: React.MouseEvent) => void clickRoom: (evt: React.MouseEvent) => void
clearActiveRoom: () => void clearActiveRoom: () => void

View file

@ -30,6 +30,7 @@ import { escapeMarkdown } from "@/util/markdown.ts"
import useEvent from "@/util/useEvent.ts" import useEvent from "@/util/useEvent.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import EmojiPicker from "../emojipicker/EmojiPicker.tsx" import EmojiPicker from "../emojipicker/EmojiPicker.tsx"
import { keyToString } from "../keybindings.ts"
import { ModalContext } from "../modal/Modal.tsx" import { ModalContext } from "../modal/Modal.tsx"
import { useRoomContext } from "../roomview/roomcontext.ts" import { useRoomContext } from "../roomview/roomcontext.ts"
import { ReplyBody } from "../timeline/ReplyBody.tsx" import { ReplyBody } from "../timeline/ReplyBody.tsx"
@ -193,40 +194,39 @@ const MessageComposer = () => {
} }
} }
}) })
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => { const onComposerKeyDown = useEvent((evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (evt.key === "Enter" && !evt.shiftKey) { const inp = evt.currentTarget
const fullKey = keyToString(evt)
if (fullKey === "Enter") {
sendMessage(evt) sendMessage(evt)
} else if (autocomplete && !evt.ctrlKey && !evt.altKey) { } else if (autocomplete) {
if (!evt.shiftKey && (evt.key === "Tab" || evt.key === "ArrowDown")) { if (fullKey === "Tab" || fullKey === "ArrowDown") {
setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? -1) + 1 }) setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? -1) + 1 })
evt.preventDefault() 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 }) setAutocomplete({ ...autocomplete, selected: (autocomplete.selected ?? 0) - 1 })
evt.preventDefault() evt.preventDefault()
} }
} else if (!autocomplete && textInput.current) { } else if (fullKey === "ArrowUp" && inp.selectionStart === 0 && inp.selectionEnd === 0) {
const inp = textInput.current const currentlyEditing = editing
if (evt.key === "ArrowUp" && inp.selectionStart === 0 && inp.selectionEnd === 0) { ? roomCtx.ownMessages.indexOf(editing.rowid)
const currentlyEditing = editing : roomCtx.ownMessages.length
? roomCtx.ownMessages.indexOf(editing.rowid) const prevEventToEditID = roomCtx.ownMessages[currentlyEditing - 1]
: roomCtx.ownMessages.length const prevEventToEdit = prevEventToEditID ? room.eventsByRowID.get(prevEventToEditID) : undefined
const prevEventToEditID = roomCtx.ownMessages[currentlyEditing - 1] if (prevEventToEdit) {
const prevEventToEdit = prevEventToEditID ? room.eventsByRowID.get(prevEventToEditID) : undefined roomCtx.setEditing(prevEventToEdit)
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() evt.preventDefault()
} }
} } else if (editing && fullKey === "ArrowDown" && inp.selectionStart === state.text.length) {
if (editing && evt.key === "Escape") { 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) roomCtx.setEditing(null)
} }
}) })

101
web/src/ui/keybindings.ts Normal file
View file

@ -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 <https://www.gnu.org/licenses/>.
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<string, (evt: KeyboardEvent) => 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)
}
}

View file

@ -45,6 +45,7 @@ const RoomList = ({ activeRoomID }: RoomListProps) => {
type="text" type="text"
placeholder="Search rooms" placeholder="Search rooms"
ref={roomFilterRef} ref={roomFilterRef}
id="room-search"
/> />
<div className="room-list"> <div className="room-list">
{reverseMap(roomList, room => {reverseMap(roomList, room =>

View file

@ -28,19 +28,13 @@ interface RoomViewProps {
rightPanelResizeHandle: JSX.Element 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 RoomView = ({ room, rightPanelResizeHandle, rightPanel }: RoomViewProps) => {
const roomContextDataRef = useRef<RoomContextData | undefined>(undefined) const roomContextDataRef = useRef<RoomContextData | undefined>(undefined)
if (roomContextDataRef.current === undefined) { if (roomContextDataRef.current === undefined) {
roomContextDataRef.current = new RoomContextData(room) roomContextDataRef.current = new RoomContextData(room)
} }
return <RoomContext value={roomContextDataRef.current}> return <RoomContext value={roomContextDataRef.current}>
<div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}> <div className="room-view">
<RoomViewHeader room={room}/> <RoomViewHeader room={room}/>
<TimelineView/> <TimelineView/>
<MessageComposer/> <MessageComposer/>