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
#watchedRoomEmojiPacks: Record<string, CustomEmojiPack> | 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)
}

View file

@ -13,10 +13,13 @@
//
// 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 { 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<RoomID | null>(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) => {
this.client.store.activeRoomID = room?.roomID
this.keybindings.activeRoom = room
}
clickRoom = (evt: React.MouseEvent) => {
const roomID = evt.currentTarget.getAttribute("data-room-id")
if (roomID) {
setActiveRoom(roomID)
this.setActiveRoom(roomID)
} 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")
if (type === "pinned-messages" || type === "members") {
setRightPanel({ type })
this.setRightPanel({ type })
} else {
throw new Error(`Invalid right panel type ${type}`)
}
},
}), [setRightPanel, setActiveRoom])
useLayoutEffect(() => {
client.store.switchRoom = setActiveRoom
}, [client, setActiveRoom])
}
clearActiveRoom = () => this.setActiveRoom(null)
closeRightPanel = () => this.setRightPanel(null)
}
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(() => {
const styleTags = document.createElement("style")
styleTags.textContent = `
@ -101,11 +121,11 @@ const MainScreen = () => {
}
return <main className={classNames.join(" ")} style={extraStyle}>
<MainScreenContext value={context}>
<RoomList activeRoomID={activeRoomID}/>
<RoomList activeRoomID={activeRoom?.roomID ?? null}/>
{resizeHandle1}
{activeRoom
? <RoomView
key={activeRoomID}
key={activeRoom.roomID}
room={activeRoom}
rightPanel={rightPanel}
rightPanelResizeHandle={resizeHandle2}

View file

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

View file

@ -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,20 +194,20 @@ const MessageComposer = () => {
}
}
})
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent) => {
if (evt.key === "Enter" && !evt.shiftKey) {
const onComposerKeyDown = useEvent((evt: React.KeyboardEvent<HTMLTextAreaElement>) => {
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) {
} else if (fullKey === "ArrowUp" && inp.selectionStart === 0 && inp.selectionEnd === 0) {
const currentlyEditing = editing
? roomCtx.ownMessages.indexOf(editing.rowid)
: roomCtx.ownMessages.length
@ -216,7 +217,7 @@ const MessageComposer = () => {
roomCtx.setEditing(prevEventToEdit)
evt.preventDefault()
}
} else if (editing && evt.key === "ArrowDown" && inp.selectionStart === state.text.length) {
} 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
@ -224,9 +225,8 @@ const MessageComposer = () => {
// 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") {
} else if (editing && fullKey === "Escape") {
evt.stopPropagation()
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"
placeholder="Search rooms"
ref={roomFilterRef}
id="room-search"
/>
<div className="room-list">
{reverseMap(roomList, room =>

View file

@ -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<RoomContextData | undefined>(undefined)
if (roomContextDataRef.current === undefined) {
roomContextDataRef.current = new RoomContextData(room)
}
return <RoomContext value={roomContextDataRef.current}>
<div className="room-view" onKeyDown={onKeyDownRoomView} tabIndex={-1}>
<div className="room-view">
<RoomViewHeader room={room}/>
<TimelineView/>
<MessageComposer/>