web: add room create dialog

Fixes #610
This commit is contained in:
Tulir Asokan 2025-03-29 01:19:17 +02:00
parent f3dae06346
commit fd8e5150a0
7 changed files with 464 additions and 7 deletions

View file

@ -332,6 +332,12 @@ export interface RespOpenIDToken {
export type RoomVisibility = "public" | "private" export type RoomVisibility = "public" | "private"
export type RoomPreset = "private_chat" | "public_chat" | "trusted_private_chat" export type RoomPreset = "private_chat" | "public_chat" | "trusted_private_chat"
export interface CreateRoomInitialState {
type: EventType
state_key?: string
content: Record<string, unknown>
}
export interface ReqCreateRoom { export interface ReqCreateRoom {
visibility?: RoomVisibility visibility?: RoomVisibility
room_alias_name?: string room_alias_name?: string
@ -340,11 +346,7 @@ export interface ReqCreateRoom {
invite?: UserID[] invite?: UserID[]
preset?: RoomPreset preset?: RoomPreset
is_direct?: boolean is_direct?: boolean
initial_state?: { initial_state?: CreateRoomInitialState[]
type: EventType
state_key?: string
content: Record<string, unknown>
}[]
room_version?: string room_version?: string
creation_content?: Record<string, unknown> creation_content?: Record<string, unknown>
power_level_content_override?: Record<string, unknown> power_level_content_override?: Record<string, unknown>

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M440-280h80v-160h160v-80H520v-160h-80v160H280v80h160v160Zm40 200q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm0-80q134 0 227-93t93-227q0-134-93-227t-227-93q-134 0-227 93t-93 227q0 134 93 227t227 93Zm0-320Z"/></svg>

After

Width:  |  Height:  |  Size: 468 B

1
web/src/icons/add.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M440-440H200v-80h240v-240h80v240h240v80H520v240h-80v-240Z"/></svg>

After

Width:  |  Height:  |  Size: 183 B

View file

@ -88,9 +88,10 @@ div.room-search-wrapper {
} }
> button { > button {
height: 3rem; height: 2.5rem;
width: 3rem; width: 2.5rem;
border-radius: 0; border-radius: 0;
color: var(--text-color) !important;
} }
} }

View file

@ -22,9 +22,12 @@ import toSearchableString from "@/util/searchablestring.ts"
import ClientContext from "../ClientContext.ts" import ClientContext from "../ClientContext.ts"
import MainScreenContext from "../MainScreenContext.ts" import MainScreenContext from "../MainScreenContext.ts"
import { keyToString } from "../keybindings.ts" import { keyToString } from "../keybindings.ts"
import { ModalContext } from "../modal"
import CreateRoomView from "../roomview/CreateRoomView.tsx"
import Entry from "./Entry.tsx" import Entry from "./Entry.tsx"
import FakeSpace from "./FakeSpace.tsx" import FakeSpace from "./FakeSpace.tsx"
import Space from "./Space.tsx" import Space from "./Space.tsx"
import AddCircleIcon from "@/icons/add-circle.svg?react"
import CloseIcon from "@/icons/close.svg?react" import CloseIcon from "@/icons/close.svg?react"
import SearchIcon from "@/icons/search.svg?react" import SearchIcon from "@/icons/search.svg?react"
import "./RoomList.css" import "./RoomList.css"
@ -36,6 +39,7 @@ interface RoomListProps {
const RoomList = ({ activeRoomID, space }: RoomListProps) => { const RoomList = ({ activeRoomID, space }: RoomListProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const openModal = use(ModalContext)
const mainScreen = use(MainScreenContext) const mainScreen = use(MainScreenContext)
const roomList = useEventAsState(client.store.roomList) const roomList = useEventAsState(client.store.roomList)
const spaces = useEventAsState(client.store.topLevelSpaces) const spaces = useEventAsState(client.store.topLevelSpaces)
@ -46,6 +50,14 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
client.store.currentRoomListQuery = toSearchableString(evt.target.value) client.store.currentRoomListQuery = toSearchableString(evt.target.value)
directSetQuery(evt.target.value) directSetQuery(evt.target.value)
} }
const openCreateRoom = () => {
openModal({
dimmed: true,
boxed: true,
boxClass: "create-room-view-modal",
content: <CreateRoomView />,
})
}
const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => { const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!) const store = client.store.getSpaceStore(evt.currentTarget.getAttribute("data-target-space")!)
mainScreen.setSpace(store) mainScreen.setSpace(store)
@ -117,6 +129,9 @@ const RoomList = ({ activeRoomID, space }: RoomListProps) => {
ref={searchInputRef} ref={searchInputRef}
id="room-search" id="room-search"
/> />
{query === "" && <button onClick={openCreateRoom} title="Create room">
<AddCircleIcon/>
</button>}
<button onClick={clearQuery} disabled={query === ""}> <button onClick={clearQuery} disabled={query === ""}>
{query !== "" ? <CloseIcon/> : <SearchIcon/>} {query !== "" ? <CloseIcon/> : <SearchIcon/>}
</button> </button>

View file

@ -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%;
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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<RoomPreset>("private_chat")
const [name, setName] = useState("")
const [topic, setTopic] = useState("")
const [invite, setInvite] = useState<UserID[]>([])
const [isDirect, setIsDirect] = useState(false)
const [isEncrypted, setIsEncrypted] = useState(true)
const [initialState, setInitialState] = useState<initialStateEntry[]>([])
const [roomVersion, setRoomVersion] = useState<RoomVersion | "">("")
const [roomID, setRoomID] = useState("")
const [creationContent, setCreationContent] = useState<string>("{\n\n}")
const [powerLevelContentOverride, setPowerLevelContentOverride] = useState<string>(() => `{
"users": {
${JSON.stringify(client.store.userID)}: 9001
}
}`)
const onSubmit = (evt: React.FormEvent<HTMLFormElement>) => {
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 <form className="create-room-view" onSubmit={onSubmit}>
<h2>Create a new room</h2>
<div className="form-fields">
<label htmlFor="room-create-name" title="The name of the room">Name</label>
<input
id="room-create-name"
type="text"
placeholder="Meow room"
value={name}
onChange={e => setName(e.target.value)}
/>
<label htmlFor="room-create-topic" title="A short description of the room">Topic</label>
<input
id="room-create-topic"
type="text"
placeholder="A room for meowing"
value={topic}
onChange={e => setTopic(e.target.value)}
/>
<label htmlFor="room-create-encrypted" title="Whether the room is encrypted">
Encrypted
</label>
<input
id="room-create-encrypted"
type="checkbox"
checked={isEncrypted}
onChange={e => setIsEncrypted(e.target.checked)}
/>
<label htmlFor="room-create-preset" title="Preset for join rules and history visibility">Preset</label>
<select id="room-create-preset" value={preset} onChange={e => setPreset(e.target.value as RoomPreset)}>
<option value="public_chat">Public chat</option>
<option value="private_chat">Private chat</option>
<option value="trusted_private_chat">Trusted private chat</option>
</select>
</div>
<div className="form-fields item-list" id="room-create-invite">
<div className="item-list-header">
Users to invite
<button
className="item-list-add"
type="button"
onClick={() => setInvite([...invite, ""])}
><InviteIcon /></button>
</div>
{invite.map((id, index) => {
const onChange = (e: React.ChangeEvent<HTMLInputElement>) =>
setInvite([...invite.slice(0, index), e.target.value, ...invite.slice(index + 1)])
const onRemove = () => setInvite([...invite.slice(0, index), ...invite.slice(index + 1)])
return <Fragment key={index}>
<input
className="item-list-item"
type="text"
placeholder={`@user:${serverName}`}
value={id}
onChange={onChange}
/>
<button
className="item-list-remove"
type="button"
onClick={onRemove}
><CloseIcon /></button>
</Fragment>
})}
</div>
<details>
<summary>Advanced options</summary>
<div className="form-fields">
<label htmlFor="room-create-is-direct" title="Whether the room is a direct chat">
Direct chat
</label>
<input
id="room-create-is-direct"
type="checkbox"
checked={isDirect}
onChange={e => setIsDirect(e.target.checked)}
/>
<label
htmlFor="room-create-version"
title="The version of the room to create. If unset, the server will decide"
>
Room version
</label>
<input
id="room-create-version"
type="text"
placeholder="11"
value={roomVersion}
onChange={e => setRoomVersion(e.target.value as RoomVersion)}
/>
<label htmlFor="room-create-id" title="Custom room ID. Only works if supported by the server.">
Room ID
</label>
<input
id="room-create-id"
type="text"
placeholder={`!meow:${serverName}`}
value={roomID}
onChange={e => setRoomID(e.target.value)}
/>
<label htmlFor="room-create-power-level-override" title="Override power levels in the room">
Power level override
</label>
<textarea
id="room-create-power-level-override"
value={powerLevelContentOverride}
onChange={e => setPowerLevelContentOverride(e.target.value)}
rows={5}
/>
<label htmlFor="room-create-creation-content" title="Override the creation content of the room">
Creation content
</label>
<textarea
id="room-create-creation-content"
value={creationContent}
onChange={e => setCreationContent(e.target.value)}
rows={3}
/>
</div>
<div className="form-fields item-list state-event-list" id="room-create-initial-state">
<div className="item-list-header">
Initial state
<button
className="item-list-state-add"
type="button"
onClick={() => setInitialState([
...initialState,
{ type: "", stateKey: "", content: "{\n\n}" },
])}
><AddIcon /></button>
</div>
{initialState.map((state, index) => {
const onChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => setInitialState([
...initialState.slice(0, index),
{
...initialState[index],
[e.target.dataset.field!]: e.target.value,
},
...initialState.slice(index + 1),
])
const onRemove = () => setInitialState([
...initialState.slice(0, index),
...initialState.slice(index + 1),
])
return <Fragment key={index}>
<div className="item-list-item state-event-form">
<input
className="state-event-type"
type="text"
data-field="type"
placeholder="Event type"
value={state.type}
onChange={onChange}
/>
<input
className="state-event-key"
type="text"
data-field="stateKey"
placeholder="State key"
value={state.stateKey}
onChange={onChange}
/>
<textarea
className="state-event-content"
data-field="content"
placeholder="Event content"
value={state.content}
onChange={onChange}
rows={3}
/>
</div>
<button
className="item-list-remove"
type="button"
onClick={onRemove}
><CloseIcon /></button>
</Fragment>
})}
</div>
</details>
<button type="submit" disabled={loading}>Create</button>
{error && <div className="error">{error}</div>}
</form>
}
export default CreateRoomView