Create a Matrix room, and rank the people in it

One form for a room or a space, since a space is a room with a different
creation type and nothing else - two forms would invent a distinction the
protocol has not got.

Encryption defaults to off and says why: it can be turned on later and
never off, so the reversible choice is the honest default. It is disabled
for a public room, where it would be a promise the room cannot keep.

The new room is not selected on creation. It arrives through the next sync
with whatever name and kind the server settled on, and jumping at a buffer
that does not exist yet would land on nothing.

Matrix ranks are a number, so the member menu offers the three the
protocol's own defaults are built around - 0 everybody, 50 what every
other client calls moderator, 100 the room's owner - rather than a
free-text box for a value nobody has a feel for.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 08:32:49 -04:00
co-authored by Claude Opus 5
parent 2fcf0859bf
commit fb12f67d66
3 changed files with 136 additions and 1 deletions
+1 -1
Submodule nobilis updated: 1ef66ab532...27ce6a112a
+109
View File
@@ -288,6 +288,114 @@ function KickJoin({ account }: { account: Account }): JSX.Element {
)
}
/**
* Making a room, or a space.
*
* One form for both, because a space is a room with a different creation type
* and nothing else - offering two would be inventing a distinction the
* protocol does not have.
*
* Encryption is offered and defaults to off, which is the choice that can
* still be changed: a room can be encrypted later and never un-encrypted, so
* the reversible default is the honest one.
*/
function CreateMatrixRoom({ account }: { account: Account }): JSX.Element {
const store = useStore()
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [topic, setTopic] = useState('')
const [isSpace, setIsSpace] = useState(false)
const [isPublic, setIsPublic] = useState(false)
const [encrypted, setEncrypted] = useState(false)
const [busy, setBusy] = useState(false)
if (!open) {
return (
<div className="field">
<button type="button" className="button" onClick={() => setOpen(true)}>
Create a room or space
</button>
</div>
)
}
const create = (): void => {
if (!name.trim()) return
setBusy(true)
void window.moho
.rpc('createMatrixRoom', { accountId: account.id, name, topic, isSpace, isPublic, encrypted })
.then(() => {
// Not selected here: the room arrives through the next sync with the
// name and kind the server settled on, and jumping at a buffer that
// does not exist yet would land on nothing.
store.toast('info', `Created ${name}. It will appear once the server confirms it.`)
setName('')
setTopic('')
setOpen(false)
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
return (
<div className="field">
<span className="small muted">{isSpace ? 'New space' : 'New room'}</span>
<input
className="text-field"
autoFocus
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
/>
<input
className="text-field"
placeholder="Topic (optional)"
value={topic}
onChange={(e) => setTopic(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
/>
<label className="checkbox-row">
<input type="checkbox" checked={isSpace} onChange={(e) => setIsSpace(e.target.checked)} />
<span>
Make it a space
<span className="small muted"> — a container for other rooms rather than a place to talk</span>
</span>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={isPublic} onChange={(e) => setIsPublic(e.target.checked)} />
<span>
Anyone can find and join it
<span className="small muted"> — otherwise it is invite only</span>
</span>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={encrypted}
disabled={isPublic}
onChange={(e) => setEncrypted(e.target.checked)}
/>
<span>
Encrypt it
<span className="small muted">
{' '}
— cannot be turned off later, so a room that might need it should have it from the start
</span>
</span>
</label>
<div className="field-row">
<button type="button" className="button primary" disabled={busy || !name.trim()} onClick={create}>
{busy ? 'Creating…' : 'Create'}
</button>
<button type="button" className="button" onClick={() => setOpen(false)}>
Cancel
</button>
</div>
</div>
)
}
/**
* Rooms somebody has asked this account to join.
*
@@ -404,6 +512,7 @@ function MatrixJoin({ account }: { account: Account }): JSX.Element {
.catch((e: Error) => store.toast('error', e.message))
}
/>
<CreateMatrixRoom account={account} />
</div>
)
}
+26
View File
@@ -216,6 +216,17 @@ export function NickList(): JSX.Element {
canCall={canCall && !!member.userId}
perms={perms}
service={account?.service}
onSetPower={(level) => {
if (!buffer || !account) return
void window.moho
.rpc('setMatrixPowerLevel', {
accountId: account.id,
bufferId: buffer.id,
userId: member.userId || member.nick,
level
})
.catch((e: Error) => store.toast('error', e.message))
}}
onToggleBlock={toggleBlocked}
onMention={() => store.startReply('', member.nick, '')}
onModerate={(action) => {
@@ -318,6 +329,8 @@ interface MemberRowProps {
onToggleBlock: (key: string) => void
onMention: () => void
onModerate: (action: ModerationAction) => void
/** Matrix only: give this member a power level outright. */
onSetPower: (level: number) => void
onOpenDm: () => void
onWhisper: () => void
onSendFile: () => void
@@ -335,6 +348,7 @@ function MemberRow({
canSendFile,
perms,
service,
onSetPower,
onToggleBlock,
onMention,
onModerate,
@@ -378,6 +392,18 @@ function MemberRow({
...(perms.canBan
? ([{ label: 'Ban', icon: 'gavel', danger: true, onClick: () => onModerate('ban') }] as MenuEntry[])
: []),
// Matrix ranks are a number rather than a flag, so they are offered as
// the three the protocol's own defaults are built around: 0 is everybody,
// 50 is what "moderator" means to every other client, 100 is the room's
// owner. Only where this account may change them at all.
...(service === 'matrix' && perms.canMute
? ([
{ separator: true },
{ label: 'Make moderator', icon: 'shield', onClick: () => onSetPower(50) },
{ label: 'Make admin', icon: 'star', onClick: () => onSetPower(100) },
{ label: 'Reset to default', icon: 'person', onClick: () => onSetPower(0) }
] as MenuEntry[])
: []),
// Channel ranks, which only IRC has. Both directions offered rather than
// one toggle: the member's own prefix says which way round it should be,
// and a menu that guessed wrong would take op from somebody by accident.