mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
parent
5aacc3424d
commit
aeabda449d
8 changed files with 361 additions and 14 deletions
|
@ -18,7 +18,7 @@ import { RoomListEntry, RoomStateStore, useAccountData } from "@/api/statestore"
|
|||
import { RoomID } from "@/api/types"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { ModalCloseContext, ModalContext } from "../modal"
|
||||
import { ModalCloseContext } from "../modal"
|
||||
import SettingsView from "../settings/SettingsView.tsx"
|
||||
import DoorOpenIcon from "@/icons/door-open.svg?react"
|
||||
import MarkReadIcon from "@/icons/mark-read.svg?react"
|
||||
|
@ -91,11 +91,11 @@ const MarkReadButton = ({ room }: { room: RoomStateStore }) => {
|
|||
}
|
||||
|
||||
export const RoomMenu = ({ room, style }: RoomMenuProps) => {
|
||||
const openModal = use(ModalContext)
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const client = use(ClientContext)!
|
||||
const openSettings = () => {
|
||||
openModal({
|
||||
closeModal()
|
||||
window.openNestableModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
innerBoxClass: "settings-view",
|
||||
|
|
|
@ -97,6 +97,9 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr
|
|||
modal = content
|
||||
}
|
||||
}
|
||||
if (historyStateKey === "nestable_modal") {
|
||||
window.openNestableModal = openModal
|
||||
}
|
||||
return <ContextType value={openModal}>
|
||||
{children}
|
||||
{modal}
|
||||
|
|
|
@ -18,8 +18,7 @@ import { getRoomAvatarThumbnailURL, getRoomAvatarURL } from "@/api/media.ts"
|
|||
import { RoomStateStore } from "@/api/statestore"
|
||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import MainScreenContext from "../MainScreenContext.ts"
|
||||
import { LightboxContext } from "../modal"
|
||||
import { ModalContext } from "../modal"
|
||||
import { LightboxContext, NestableModalContext } from "../modal"
|
||||
import SettingsView from "../settings/SettingsView.tsx"
|
||||
import BackIcon from "@/icons/back.svg?react"
|
||||
import PeopleIcon from "@/icons/group.svg?react"
|
||||
|
@ -34,7 +33,7 @@ interface RoomViewHeaderProps {
|
|||
const RoomViewHeader = ({ room }: RoomViewHeaderProps) => {
|
||||
const roomMeta = useEventAsState(room.meta)
|
||||
const mainScreen = use(MainScreenContext)
|
||||
const openModal = use(ModalContext)
|
||||
const openModal = use(NestableModalContext)
|
||||
const openSettings = () => {
|
||||
openModal({
|
||||
dimmed: true,
|
||||
|
|
78
web/src/ui/settings/RoomStateExplorer.css
Normal file
78
web/src/ui/settings/RoomStateExplorer.css
Normal file
|
@ -0,0 +1,78 @@
|
|||
div.state-explorer-box {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
div.state-explorer {
|
||||
width: min(50rem, 80vw);
|
||||
max-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
div.state-button-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
overflow: auto;
|
||||
|
||||
> button {
|
||||
padding: .5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
> div.nav-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: .5rem;
|
||||
margin-top: .5rem;
|
||||
justify-content: space-between;
|
||||
|
||||
> button {
|
||||
padding: .5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
&.state-event-view {
|
||||
> div.state-event-content {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
|
||||
> textarea {
|
||||
width: 100%;
|
||||
padding: .5rem;
|
||||
box-sizing: border-box;
|
||||
resize: vertical;
|
||||
border: 1px solid var(--border-color);
|
||||
outline: none;
|
||||
border-radius: .5rem;
|
||||
|
||||
&:focus {
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
> div.state-header > div.new-event-type {
|
||||
display: flex;
|
||||
gap: .25rem;
|
||||
margin-bottom: .25rem;
|
||||
|
||||
> input {
|
||||
flex: 1;
|
||||
padding: .5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
box-sizing: border-box;
|
||||
border-radius: .5rem;
|
||||
outline: none;
|
||||
font-family: var(--monospace-font-stack);
|
||||
|
||||
&:focus {
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
246
web/src/ui/settings/RoomStateExplorer.tsx
Normal file
246
web/src/ui/settings/RoomStateExplorer.tsx
Normal file
|
@ -0,0 +1,246 @@
|
|||
// gomuks - A Matrix client written in Go.
|
||||
// Copyright (C) 2025 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 { use, useCallback, useState } from "react"
|
||||
import { RoomStateStore, useRoomState } from "@/api/statestore"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import JSONView from "../util/JSONView"
|
||||
import "./RoomStateExplorer.css"
|
||||
|
||||
interface StateExplorerProps {
|
||||
room: RoomStateStore
|
||||
}
|
||||
|
||||
interface StateEventViewProps {
|
||||
room: RoomStateStore
|
||||
type?: string
|
||||
stateKey?: string
|
||||
onBack: () => void
|
||||
onDone?: (type: string, stateKey: string) => void
|
||||
}
|
||||
|
||||
interface StateKeyListProps {
|
||||
room: RoomStateStore
|
||||
type: string
|
||||
onSelectStateKey: (stateKey: string) => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const StateEventView = ({ room, type, stateKey, onBack, onDone }: StateEventViewProps) => {
|
||||
const event = useRoomState(room, type, stateKey)
|
||||
const isNewEvent = type === undefined
|
||||
const [editingContent, setEditingContent] = useState<string | null>(isNewEvent ? "{\n\n}" : null)
|
||||
const [newType, setNewType] = useState<string>("")
|
||||
const [newStateKey, setNewStateKey] = useState<string>("")
|
||||
const client = use(ClientContext)!
|
||||
|
||||
const sendEdit = () => {
|
||||
let parsedContent
|
||||
try {
|
||||
parsedContent = JSON.parse(editingContent || "{}")
|
||||
} catch (err) {
|
||||
window.alert(`Failed to parse JSON: ${err}`)
|
||||
return
|
||||
}
|
||||
client.rpc.setState(
|
||||
room.roomID,
|
||||
type ?? newType,
|
||||
stateKey ?? newStateKey,
|
||||
parsedContent,
|
||||
).then(
|
||||
() => {
|
||||
console.log("Updated room state", room.roomID, type, stateKey)
|
||||
setEditingContent(null)
|
||||
if (isNewEvent) {
|
||||
onDone?.(newType, newStateKey)
|
||||
}
|
||||
},
|
||||
err => {
|
||||
console.error("Failed to update room state", err)
|
||||
window.alert(`Failed to update room state: ${err}`)
|
||||
},
|
||||
)
|
||||
}
|
||||
const stopEdit = () => setEditingContent(null)
|
||||
const startEdit = () => setEditingContent(JSON.stringify(event?.content || {}, null, 4))
|
||||
|
||||
return (
|
||||
<div className="state-explorer state-event-view">
|
||||
<div className="state-header">
|
||||
{isNewEvent
|
||||
? <>
|
||||
<h3>New state event</h3>
|
||||
<div className="new-event-type">
|
||||
<input
|
||||
type="text"
|
||||
value={newType}
|
||||
onChange={evt => setNewType(evt.target.value)}
|
||||
placeholder="Event type"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newStateKey}
|
||||
onChange={evt => setNewStateKey(evt.target.value)}
|
||||
placeholder="State key"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
: <h3><code>{type}</code> ({stateKey ? <code>{stateKey}</code> : "no state key"})</h3>
|
||||
}
|
||||
</div>
|
||||
<div className={`state-event-content`}>
|
||||
{editingContent !== null
|
||||
? <textarea rows={10} value={editingContent} onChange={evt => setEditingContent(evt.target.value)}/>
|
||||
: <JSONView data={event}/>
|
||||
}
|
||||
</div>
|
||||
<div className="nav-buttons">
|
||||
{editingContent !== null ? <>
|
||||
<button onClick={isNewEvent ? onBack : stopEdit}>Back</button>
|
||||
<button onClick={sendEdit}>Send</button>
|
||||
</> : <>
|
||||
<button onClick={onBack}>Back</button>
|
||||
<button onClick={startEdit}>Edit</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const StateKeyList = ({ room, type, onSelectStateKey, onBack }: StateKeyListProps) => {
|
||||
const stateMap = room.state.get(type)
|
||||
return (
|
||||
<div className="state-explorer state-key-list">
|
||||
<div className="state-header">
|
||||
<h3>State keys under <code>{type}</code></h3>
|
||||
</div>
|
||||
<div className="state-button-list">
|
||||
{Array.from(stateMap?.keys().map(stateKey => (
|
||||
<button key={stateKey} onClick={() => onSelectStateKey(stateKey)}>
|
||||
{stateKey ? <code>{stateKey}</code> : "<empty>"}
|
||||
</button>
|
||||
)) ?? [])}
|
||||
</div>
|
||||
<div className="nav-buttons">
|
||||
<button onClick={onBack}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StateExplorer = ({ room }: StateExplorerProps) => {
|
||||
const [creatingNew, setCreatingNew] = useState(false)
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null)
|
||||
const [selectedStateKey, setSelectedStateKey] = useState<string | null>(null)
|
||||
const [loadingState, setLoadingState] = useState(false)
|
||||
const client = use(ClientContext)!
|
||||
|
||||
const handleTypeSelect = (type: string) => {
|
||||
const stateKeysMap = room.state.get(type)
|
||||
if (!stateKeysMap) {
|
||||
return
|
||||
}
|
||||
|
||||
const stateKeys = Array.from(stateKeysMap.keys())
|
||||
if (stateKeys.length === 1 && stateKeys[0] === "") {
|
||||
// If there's only one state event with an empty key, view it directly
|
||||
setSelectedType(type)
|
||||
setSelectedStateKey("")
|
||||
} else {
|
||||
// Otherwise show the list of state keys
|
||||
setSelectedType(type)
|
||||
setSelectedStateKey(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
if (creatingNew) {
|
||||
setCreatingNew(false)
|
||||
} else if (selectedStateKey !== null && selectedType !== null) {
|
||||
setSelectedStateKey(null)
|
||||
const stateKeysMap = room.state.get(selectedType)
|
||||
if (stateKeysMap?.size === 1 && stateKeysMap.has("")) {
|
||||
setSelectedType(null)
|
||||
}
|
||||
} else if (selectedType !== null) {
|
||||
setSelectedType(null)
|
||||
}
|
||||
}, [selectedType, selectedStateKey, creatingNew, room])
|
||||
const handleNewEventDone = useCallback((type: string, stateKey: string) => {
|
||||
setCreatingNew(false)
|
||||
setSelectedType(type)
|
||||
setSelectedStateKey(stateKey)
|
||||
}, [])
|
||||
|
||||
if (creatingNew) {
|
||||
return <StateEventView
|
||||
room={room}
|
||||
onBack={handleBack}
|
||||
onDone={handleNewEventDone}
|
||||
/>
|
||||
} else if (selectedType !== null && selectedStateKey !== null) {
|
||||
return <StateEventView
|
||||
room={room}
|
||||
type={selectedType}
|
||||
stateKey={selectedStateKey}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
} else if (selectedType !== null) {
|
||||
return <StateKeyList
|
||||
room={room}
|
||||
type={selectedType}
|
||||
onSelectStateKey={setSelectedStateKey}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
} else {
|
||||
const loadRoomState = () => {
|
||||
setLoadingState(true)
|
||||
client.loadRoomState(room.roomID, {
|
||||
omitMembers: false,
|
||||
refetch: room.stateLoaded && room.fullMembersLoaded,
|
||||
}).then(
|
||||
() => {
|
||||
console.log("Room state loaded from devtools", room.roomID)
|
||||
},
|
||||
err => {
|
||||
console.error("Failed to fetch room state", err)
|
||||
window.alert(`Failed to fetch room state: ${err}`)
|
||||
},
|
||||
).finally(() => setLoadingState(false))
|
||||
}
|
||||
return <div className="state-explorer">
|
||||
<h3>Room State Explorer</h3>
|
||||
<div className="state-button-list">
|
||||
{Array.from(room.state?.keys().map(type => (
|
||||
<button key={type} onClick={() => handleTypeSelect(type)}>
|
||||
<code>{type}</code>
|
||||
</button>
|
||||
)) ?? [])}
|
||||
</div>
|
||||
<div className="nav-buttons">
|
||||
<button onClick={loadRoomState} disabled={loadingState}>
|
||||
{room.stateLoaded
|
||||
? room.fullMembersLoaded
|
||||
? "Resync full room state"
|
||||
: "Load room members"
|
||||
: "Load room state and members"}
|
||||
</button>
|
||||
<button onClick={() => setCreatingNew(true)}>Send new state event</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
export default StateExplorer
|
|
@ -14,15 +14,22 @@ div.settings-view {
|
|||
text-wrap: nowrap;
|
||||
}
|
||||
|
||||
button.leave-room {
|
||||
div.room-buttons {
|
||||
display: flex;
|
||||
gap: .5rem;
|
||||
|
||||
button {
|
||||
padding: .5rem 1rem;
|
||||
|
||||
&.leave-room {
|
||||
&:hover, &:focus {
|
||||
background-color: var(--error-color);
|
||||
color: var(--inverted-text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
width: min(60rem, 80vw);
|
||||
display: flex;
|
||||
|
|
|
@ -29,10 +29,10 @@ import {
|
|||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||
import useEvent from "@/util/useEvent.ts"
|
||||
import ClientContext from "../ClientContext.ts"
|
||||
import { LightboxContext } from "../modal"
|
||||
import { ModalCloseContext } from "../modal"
|
||||
import { LightboxContext, ModalCloseContext, ModalContext } from "../modal"
|
||||
import JSONView from "../util/JSONView.tsx"
|
||||
import Toggle from "../util/Toggle.tsx"
|
||||
import RoomStateExplorer from "./RoomStateExplorer.tsx"
|
||||
import CloseIcon from "@/icons/close.svg?react"
|
||||
import "./SettingsView.css"
|
||||
|
||||
|
@ -331,6 +331,7 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
|||
const roomMeta = useEventAsState(room.meta)
|
||||
const client = use(ClientContext)!
|
||||
const closeModal = use(ModalCloseContext)
|
||||
const openModal = use(ModalContext)
|
||||
const setPref = useCallback((
|
||||
context: PreferenceContext, key: keyof Preferences, value: PreferenceValueType | undefined,
|
||||
) => {
|
||||
|
@ -377,6 +378,14 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
|||
)
|
||||
}
|
||||
}
|
||||
const openDevtools = () => {
|
||||
openModal({
|
||||
dimmed: true,
|
||||
boxed: true,
|
||||
innerBoxClass: "state-explorer-box",
|
||||
content: <RoomStateExplorer room={room} />,
|
||||
})
|
||||
}
|
||||
const onClickOpenCSSApp = () => {
|
||||
client.rpc.requestOpenIDToken().then(
|
||||
resp => window.open(
|
||||
|
@ -407,7 +416,10 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
|||
{roomMeta.name && <div className="room-name">{roomMeta.name}</div>}
|
||||
<code>{room.roomID}</code>
|
||||
<div>{roomMeta.topic}</div>
|
||||
<div className="room-buttons">
|
||||
<button className="leave-room" onClick={onClickLeave}>Leave room</button>
|
||||
<button className="devtools" onClick={openDevtools}>Explore room state</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
|
|
2
web/src/vite-env.d.ts
vendored
2
web/src/vite-env.d.ts
vendored
|
@ -4,6 +4,7 @@
|
|||
import type Client from "@/api/client.ts"
|
||||
import type { GCSettings, RoomStateStore } from "@/api/statestore"
|
||||
import type { MainScreenContextFields } from "@/ui/MainScreenContext.ts"
|
||||
import type { openModal } from "@/ui/modal/contexts.ts"
|
||||
import type { RoomContextData } from "@/ui/roomview/roomcontext.ts"
|
||||
|
||||
declare global {
|
||||
|
@ -16,6 +17,7 @@ declare global {
|
|||
gcSettings: GCSettings
|
||||
hackyOpenEventContextMenu?: string
|
||||
closeModal: () => void
|
||||
openNestableModal: openModal
|
||||
gomuksAndroid?: true
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue