diff --git a/web/src/ui/widget/PermissionPrompt.tsx b/web/src/ui/widget/PermissionPrompt.tsx new file mode 100644 index 0000000..50974a5 --- /dev/null +++ b/web/src/ui/widget/PermissionPrompt.tsx @@ -0,0 +1,111 @@ +// 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 { MatrixCapabilities } from "matrix-widget-api" +import { use, useState } from "react" +import { ModalCloseContext } from "../modal" + +interface PermissionPromptProps { + capabilities: Set + onConfirm: (approvedCapabilities: Set) => void +} + +const getCapabilityName = (capability: string): string => { + const paramIdx = capability.indexOf(":") + const capabilityID = paramIdx === -1 ? capability : capability.slice(0, paramIdx) + const parameter = paramIdx === -1 ? null : capability.slice(paramIdx + 1) + + // Map capability IDs to human-readable names + const capabilityNames: Record = { + [MatrixCapabilities.MSC2931Navigate]: "Navigate to other rooms", + [MatrixCapabilities.MSC3846TurnServers]: "Request TURN servers from the homeserver", + [MatrixCapabilities.MSC4157SendDelayedEvent]: "Send delayed events", + [MatrixCapabilities.MSC4157UpdateDelayedEvent]: "Update delayed events", + [MatrixCapabilities.MSC4039UploadFile]: "Upload files", + [MatrixCapabilities.MSC4039DownloadFile]: "Download files", + "org.matrix.msc2762.timeline": "Read room history", + "org.matrix.msc2762.send.event": "Send timeline events", + "org.matrix.msc2762.receive.event": "Receive timeline events", + "org.matrix.msc2762.send.state_event": "Send state events", + "org.matrix.msc2762.receive.state_event": "Receive state events", + "org.matrix.msc3819.send.to_device": "Send to-device events", + "org.matrix.msc3819.receive.to_device": "Receive to-device events", + } + + const name = capabilityNames[capabilityID] || capabilityID + + if (parameter) { + return `${name} (${parameter})` + } + + return name +} + +const PermissionPrompt = ({ capabilities, onConfirm }: PermissionPromptProps) => { + const [selectedCapabilities, setSelectedCapabilities] = useState>(() => new Set(capabilities)) + const closeModal = use(ModalCloseContext) + + const handleToggleCapability = (capability: string) => { + const newCapabilities = new Set(selectedCapabilities) + if (newCapabilities.has(capability)) { + newCapabilities.delete(capability) + } else { + newCapabilities.add(capability) + } + setSelectedCapabilities(newCapabilities) + } + + const doConfirm = () => { + onConfirm(selectedCapabilities) + closeModal() + } + + const doReject = () => { + onConfirm(new Set()) + closeModal() + } + + return <> +

Widget Permissions

+

This widget is requesting the following permissions:

+ +
+ {Array.from(capabilities).map((capability) => ( +
+ +
+ ))} +
+ +
+ + +
+ +} + +export default PermissionPrompt diff --git a/web/src/ui/widget/Widget.css b/web/src/ui/widget/Widget.css index 9563544..fc21322 100644 --- a/web/src/ui/widget/Widget.css +++ b/web/src/ui/widget/Widget.css @@ -7,3 +7,18 @@ div.right-panel-content.widget, div.right-panel-content.element-call { border: none; } } + +div.permission-prompt { + > h2 { + margin: 0; + } + + > div.permission-actions { + display: flex; + justify-content: space-between; + + > button { + padding: .5rem 1rem; + } + } +} diff --git a/web/src/ui/widget/widget.tsx b/web/src/ui/widget/widget.tsx index e7eda7a..a213be5 100644 --- a/web/src/ui/widget/widget.tsx +++ b/web/src/ui/widget/widget.tsx @@ -19,6 +19,7 @@ import type Client from "@/api/client" import type { RoomStateStore, WidgetListener } from "@/api/statestore" import type { MemDBEvent, RoomID, SyncToDevice } from "@/api/types" import { getDisplayname } from "@/util/validation" +import PermissionPrompt from "./PermissionPrompt" import { memDBEventToIRoomEvent } from "./util" import GomuksWidgetDriver from "./widgetDriver" import "./Widget.css" @@ -69,9 +70,24 @@ class WidgetListenerImpl implements WidgetListener { } } +const openPermissionPrompt = (requested: Set): Promise> => { + return new Promise(resolve => { + window.openModal({ + content: , + dimmed: true, + boxed: true, + noDismiss: true, + innerBoxClass: "permission-prompt", + }) + }) +} + const ReactWidget = ({ room, info, client, onClose }: WidgetProps) => { const wrappedWidget = new WrappedWidget(info) - const driver = new GomuksWidgetDriver(client, room) + const driver = new GomuksWidgetDriver(client, room, openPermissionPrompt) const widgetURL = addLegacyParams(wrappedWidget.getCompleteUrl({ widgetRoomId: room.roomID, currentUserId: client.userID, diff --git a/web/src/ui/widget/widgetDriver.ts b/web/src/ui/widget/widgetDriver.ts index 785bfbc..fdeb790 100644 --- a/web/src/ui/widget/widgetDriver.ts +++ b/web/src/ui/widget/widgetDriver.ts @@ -37,12 +37,16 @@ class GomuksWidgetDriver extends WidgetDriver { private openIDToken: IOpenIDCredentials | null = null private openIDExpiry: number | null = null - constructor(private client: Client, private room: RoomStateStore) { + constructor( + private client: Client, + private room: RoomStateStore, + private openPermissionPrompt: (requested: Set) => Promise>, + ) { super() } async validateCapabilities(requested: Set): Promise> { - return new Set(requested) + return this.openPermissionPrompt(requested) } async sendEvent(