From fd8e5150a013805ff4ea72de8ef3669bfd1e6369 Mon Sep 17 00:00:00 2001 From: Tulir Asokan Date: Sat, 29 Mar 2025 01:19:17 +0200 Subject: [PATCH] web: add room create dialog Fixes #610 --- web/src/api/types/mxtypes.ts | 12 +- web/src/icons/add-circle.svg | 1 + web/src/icons/add.svg | 1 + web/src/ui/roomlist/RoomList.css | 5 +- web/src/ui/roomlist/RoomList.tsx | 15 ++ web/src/ui/roomview/CreateRoomView.css | 132 +++++++++++ web/src/ui/roomview/CreateRoomView.tsx | 305 +++++++++++++++++++++++++ 7 files changed, 464 insertions(+), 7 deletions(-) create mode 100644 web/src/icons/add-circle.svg create mode 100644 web/src/icons/add.svg create mode 100644 web/src/ui/roomview/CreateRoomView.css create mode 100644 web/src/ui/roomview/CreateRoomView.tsx diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 5242a8c..ff4c642 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -332,6 +332,12 @@ export interface RespOpenIDToken { export type RoomVisibility = "public" | "private" export type RoomPreset = "private_chat" | "public_chat" | "trusted_private_chat" +export interface CreateRoomInitialState { + type: EventType + state_key?: string + content: Record +} + export interface ReqCreateRoom { visibility?: RoomVisibility room_alias_name?: string @@ -340,11 +346,7 @@ export interface ReqCreateRoom { invite?: UserID[] preset?: RoomPreset is_direct?: boolean - initial_state?: { - type: EventType - state_key?: string - content: Record - }[] + initial_state?: CreateRoomInitialState[] room_version?: string creation_content?: Record power_level_content_override?: Record diff --git a/web/src/icons/add-circle.svg b/web/src/icons/add-circle.svg new file mode 100644 index 0000000..eb6022e --- /dev/null +++ b/web/src/icons/add-circle.svg @@ -0,0 +1 @@ + diff --git a/web/src/icons/add.svg b/web/src/icons/add.svg new file mode 100644 index 0000000..58e5e6a --- /dev/null +++ b/web/src/icons/add.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/roomlist/RoomList.css b/web/src/ui/roomlist/RoomList.css index 0d084d2..3db0b54 100644 --- a/web/src/ui/roomlist/RoomList.css +++ b/web/src/ui/roomlist/RoomList.css @@ -88,9 +88,10 @@ div.room-search-wrapper { } > button { - height: 3rem; - width: 3rem; + height: 2.5rem; + width: 2.5rem; border-radius: 0; + color: var(--text-color) !important; } } diff --git a/web/src/ui/roomlist/RoomList.tsx b/web/src/ui/roomlist/RoomList.tsx index cc80423..6408555 100644 --- a/web/src/ui/roomlist/RoomList.tsx +++ b/web/src/ui/roomlist/RoomList.tsx @@ -22,9 +22,12 @@ import toSearchableString from "@/util/searchablestring.ts" import ClientContext from "../ClientContext.ts" import MainScreenContext from "../MainScreenContext.ts" import { keyToString } from "../keybindings.ts" +import { ModalContext } from "../modal" +import CreateRoomView from "../roomview/CreateRoomView.tsx" import Entry from "./Entry.tsx" import FakeSpace from "./FakeSpace.tsx" import Space from "./Space.tsx" +import AddCircleIcon from "@/icons/add-circle.svg?react" import CloseIcon from "@/icons/close.svg?react" import SearchIcon from "@/icons/search.svg?react" import "./RoomList.css" @@ -36,6 +39,7 @@ interface RoomListProps { const RoomList = ({ activeRoomID, space }: RoomListProps) => { const client = use(ClientContext)! + const openModal = use(ModalContext) const mainScreen = use(MainScreenContext) const roomList = useEventAsState(client.store.roomList) const spaces = useEventAsState(client.store.topLevelSpaces) @@ -46,6 +50,14 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => { client.store.currentRoomListQuery = toSearchableString(evt.target.value) directSetQuery(evt.target.value) } + const openCreateRoom = () => { + openModal({ + dimmed: true, + boxed: true, + boxClass: "create-room-view-modal", + content: , + }) + } const onClickSpace = useCallback((evt: React.MouseEvent) => { const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!) mainScreen.setSpace(store) @@ -117,6 +129,9 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => { ref={searchInputRef} id="room-search" /> + {query === "" && } diff --git a/web/src/ui/roomview/CreateRoomView.css b/web/src/ui/roomview/CreateRoomView.css new file mode 100644 index 0000000..caf5cb0 --- /dev/null +++ b/web/src/ui/roomview/CreateRoomView.css @@ -0,0 +1,132 @@ +form.create-room-view { + display: flex; + flex-direction: column; + gap: 1rem; + + width: 100%; + + input, select, textarea { + box-sizing: border-box; + width: 100%; + padding: .5rem; + border: 1px solid var(--border-color); + border-radius: .25rem; + font-family: var(--monospace-font-stack); + font-size: 1em; + background-color: var(--background-color); + + &:hover { + border-color: var(--primary-color); + } + + &:focus { + outline: none; + border-color: var(--primary-color-dark); + } + } + + textarea { + resize: vertical; + } + + div.form-fields { + display: grid; + grid-template-columns: auto 1fr; + gap: .5rem; + align-items: center; + + > label { + grid-column: 1; + } + + > input, > select, > textarea { + grid-column: 2; + + &#room-create-id { + font-family: var(--monospace-font-stack); + } + } + + > input[type="checkbox"] { + width: 1.5rem; + height: 1.5rem; + padding: 0; + margin: 0; + accent-color: var(--primary-color-dark); + } + } + + div.form-fields.item-list { + grid-template-columns: 1fr auto; + gap: .25rem; + + > div.item-list-header { + display: flex; + gap: .5rem; + grid-column: 1 / span 2; + align-items: center; + + > button.item-list-add { + padding: 0 .5rem; + } + } + + > .item-list-item { + grid-column: 1; + } + + > button.item-list-remove { + grid-column: 2; + padding: .25rem; + } + } + + div.state-event-form { + display: grid; + gap: .25rem; + grid-template: + "type stateKey" auto + "content content" 1fr; + margin-bottom: .5rem; + + > input.state-event-type { + grid-area: type; + } + + > input.state-event-key { + grid-area: stateKey; + } + + > textarea.state-event-content { + grid-area: content; + } + } + + > div.invite-user-ids { + display: flex; + flex-direction: column; + gap: .5rem; + } + + > button { + padding: .5rem; + } + + > div.error { + border: 2px solid var(--error-color); + border-radius: .25rem; + padding: .5rem; + } + + > h2 { + margin: 0; + } +} + +div.create-room-view-modal { + width: min(35rem, 80vw); + + > div.modal-box-inner { + width: 100%; + } +} diff --git a/web/src/ui/roomview/CreateRoomView.tsx b/web/src/ui/roomview/CreateRoomView.tsx new file mode 100644 index 0000000..0588876 --- /dev/null +++ b/web/src/ui/roomview/CreateRoomView.tsx @@ -0,0 +1,305 @@ +// 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 { Fragment, use, useState } from "react" +import { CreateRoomInitialState, RoomPreset, RoomVersion, UserID } from "@/api/types" +import { getServerName } from "@/util/validation" +import ClientContext from "../ClientContext" +import MainScreenContext from "../MainScreenContext" +import { ModalCloseContext } from "../modal" +import AddIcon from "@/icons/add.svg?react" +import CloseIcon from "@/icons/close.svg?react" +import InviteIcon from "@/icons/person-add.svg?react" +import "./CreateRoomView.css" + +interface initialStateEntry { + type: string + stateKey: string + content: string +} + +const CreateRoomView = () => { + const client = use(ClientContext)! + const closeModal = use(ModalCloseContext) + const mainScreen = use(MainScreenContext) + const [loading, setLoading] = useState(false) + const [error, setError] = useState("") + const [preset, setPreset] = useState("private_chat") + const [name, setName] = useState("") + const [topic, setTopic] = useState("") + const [invite, setInvite] = useState([]) + const [isDirect, setIsDirect] = useState(false) + const [isEncrypted, setIsEncrypted] = useState(true) + const [initialState, setInitialState] = useState([]) + const [roomVersion, setRoomVersion] = useState("") + const [roomID, setRoomID] = useState("") + const [creationContent, setCreationContent] = useState("{\n\n}") + const [powerLevelContentOverride, setPowerLevelContentOverride] = useState(() => `{ + "users": { + ${JSON.stringify(client.store.userID)}: 9001 + } +}`) + + const onSubmit = (evt: React.FormEvent) => { + let creation_content, power_level_content_override + try { + creation_content = JSON.parse(creationContent) + } catch (err) { + setError(`Failed to parse creation content: ${err}`) + return + } + try { + power_level_content_override = JSON.parse(powerLevelContentOverride) + } catch (err) { + setError(`Failed to parse power level content override: ${err}`) + return + } + let reqInitialState: CreateRoomInitialState[] + try { + reqInitialState = initialState.filter(state => state.type && state.content).map(state => ({ + type: state.type, + state_key: state.stateKey, + content: JSON.parse(state.content), + })) + } catch (err) { + setError(`Failed to parse initial state: ${err}`) + return + } + evt.preventDefault() + setLoading(true) + setError("") + if (isEncrypted) { + reqInitialState.push({ + type: "m.room.encryption", + content: { + algorithm: "m.megolm.v1.aes-sha2", + }, + }) + } + client.rpc.createRoom({ + name: name || undefined, + topic: topic || undefined, + preset, + is_direct: isDirect, + invite: invite.filter(id => !!id), + initial_state: reqInitialState, + room_version: roomVersion || undefined, + creation_content, + power_level_content_override, + "fi.mau.room_id": roomID || undefined, + }).then(resp => { + closeModal() + console.log("Created room:", resp.room_id) + + // FIXME this is a hacky way to work around the room taking time to come down /sync + setTimeout(() => { + mainScreen.setActiveRoom(resp.room_id) + }, 1000) + }, err => { + setError(`${err}`.replace(/^Error: /, "")) + setLoading(false) + }) + } + + const serverName = getServerName(client.store.userID) + + return
+

Create a new room

+ +
+ + setName(e.target.value)} + /> + + setTopic(e.target.value)} + /> + + setIsEncrypted(e.target.checked)} + /> + + + +
+
+
+ Users to invite + +
+ {invite.map((id, index) => { + const onChange = (e: React.ChangeEvent) => + setInvite([...invite.slice(0, index), e.target.value, ...invite.slice(index + 1)]) + const onRemove = () => setInvite([...invite.slice(0, index), ...invite.slice(index + 1)]) + return + + + + })} +
+
+ Advanced options +
+ + setIsDirect(e.target.checked)} + /> + + setRoomVersion(e.target.value as RoomVersion)} + /> + + setRoomID(e.target.value)} + /> + +