diff --git a/web/src/ui/menu/RoomMenu.tsx b/web/src/ui/menu/RoomMenu.tsx index 859b88c..d430f97 100644 --- a/web/src/ui/menu/RoomMenu.tsx +++ b/web/src/ui/menu/RoomMenu.tsx @@ -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", diff --git a/web/src/ui/modal/Modal.tsx b/web/src/ui/modal/Modal.tsx index 620d8bf..1188cd1 100644 --- a/web/src/ui/modal/Modal.tsx +++ b/web/src/ui/modal/Modal.tsx @@ -97,6 +97,9 @@ const ModalWrapper = ({ children, ContextType, historyStateKey }: ModalWrapperPr modal = content } } + if (historyStateKey === "nestable_modal") { + window.openNestableModal = openModal + } return {children} {modal} diff --git a/web/src/ui/roomview/RoomViewHeader.tsx b/web/src/ui/roomview/RoomViewHeader.tsx index 16ce3af..12413fe 100644 --- a/web/src/ui/roomview/RoomViewHeader.tsx +++ b/web/src/ui/roomview/RoomViewHeader.tsx @@ -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, diff --git a/web/src/ui/settings/RoomStateExplorer.css b/web/src/ui/settings/RoomStateExplorer.css new file mode 100644 index 0000000..fd98533 --- /dev/null +++ b/web/src/ui/settings/RoomStateExplorer.css @@ -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); + } + } + } + } +} diff --git a/web/src/ui/settings/RoomStateExplorer.tsx b/web/src/ui/settings/RoomStateExplorer.tsx new file mode 100644 index 0000000..c93c86a --- /dev/null +++ b/web/src/ui/settings/RoomStateExplorer.tsx @@ -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 . +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(isNewEvent ? "{\n\n}" : null) + const [newType, setNewType] = useState("") + const [newStateKey, setNewStateKey] = useState("") + 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 ( +
+
+ {isNewEvent + ? <> +

New state event

+
+ setNewType(evt.target.value)} + placeholder="Event type" + /> + setNewStateKey(evt.target.value)} + placeholder="State key" + /> +
+ + :

{type} ({stateKey ? {stateKey} : "no state key"})

+ } +
+
+ {editingContent !== null + ?