forked from Mirrors/gomuks
parent
f68070807c
commit
245d81b9ce
7 changed files with 194 additions and 74 deletions
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
101
web/src/ui/keybindings.ts
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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 =>
|
||||
|
|
|
@ -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/>
|
||||
|
|
Loading…
Add table
Reference in a new issue