mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-19 18:13:41 -05:00
parent
f3dae06346
commit
fd8e5150a0
7 changed files with 464 additions and 7 deletions
|
@ -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<string, unknown>
|
||||
}
|
||||
|
||||
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<string, unknown>
|
||||
}[]
|
||||
initial_state?: CreateRoomInitialState[]
|
||||
room_version?: string
|
||||
creation_content?: Record<string, unknown>
|
||||
power_level_content_override?: Record<string, unknown>
|
||||
|
|
1
web/src/icons/add-circle.svg
Normal file
1
web/src/icons/add-circle.svg
Normal 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
1
web/src/icons/add.svg
Normal 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 |
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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: <CreateRoomView />,
|
||||
})
|
||||
}
|
||||
const onClickSpace = useCallback((evt: React.MouseEvent<HTMLDivElement>) => {
|
||||
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 === "" && <button onClick={openCreateRoom} title="Create room">
|
||||
<AddCircleIcon/>
|
||||
</button>}
|
||||
<button onClick={clearQuery} disabled={query === ""}>
|
||||
{query !== "" ? <CloseIcon/> : <SearchIcon/>}
|
||||
</button>
|
||||
|
|
132
web/src/ui/roomview/CreateRoomView.css
Normal file
132
web/src/ui/roomview/CreateRoomView.css
Normal 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%;
|
||||
}
|
||||
}
|
305
web/src/ui/roomview/CreateRoomView.tsx
Normal file
305
web/src/ui/roomview/CreateRoomView.tsx
Normal 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
|
Loading…
Add table
Reference in a new issue