Skip to content

Commit

Permalink
web/settings: add room state explorer
Browse files Browse the repository at this point in the history
Fixes #516
Closes #526
  • Loading branch information
tulir committed Mar 2, 2025
1 parent 5aacc34 commit aeabda4
Show file tree
Hide file tree
Showing 8 changed files with 361 additions and 14 deletions.
6 changes: 3 additions & 3 deletions web/src/ui/menu/RoomMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions web/src/ui/modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
5 changes: 2 additions & 3 deletions web/src/ui/roomview/RoomViewHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions web/src/ui/settings/RoomStateExplorer.css
Original file line number Diff line number Diff line change
@@ -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 changes: 246 additions & 0 deletions web/src/ui/settings/RoomStateExplorer.tsx
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit aeabda4

Please sign in to comment.