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
|
#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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
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"
|
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 =>
|
||||||
|
|
|
@ -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/>
|
||||||
|
|
Loading…
Add table
Reference in a new issue