Invite into a Matrix room, and stop implying a tick means more than it does

A field rather than a picker, because a Matrix address names a person
globally - there is no roster to choose from and nobody to look up first.
Offered on every Matrix room rather than gated on permission: the server
decides and says so plainly, and guessing here would only hide the action
from somebody who could have used it.

And the sessions panel now says what verifying actually does. moho does no
cross-signing, so a verification is recorded between these two sessions
and never published - Element goes on showing the device as unverified.
Somebody not told that would reasonably conclude one of the two clients is
broken, which is a worse outcome than the missing feature.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 08:22:51 -04:00
co-authored by Claude Opus 5
parent a02a0bd660
commit 2fcf0859bf
3 changed files with 73 additions and 1 deletions
+1 -1
Submodule nobilis updated: 78019ca762...1ef66ab532
@@ -6,6 +6,63 @@ import { formatFullTime } from '../lib/util'
import type { BufferEntry } from '../state/store'
import type { Message } from '../../../shared/wire'
/**
* Asking somebody into a Matrix room.
*
* A field rather than a picker, because a Matrix address names a person
* globally - there is no roster to choose from and nobody to look up first.
* Offered on every Matrix room rather than gated on permission: the server
* decides, and it says so plainly enough that guessing here would only mean
* hiding the action from somebody who could have used it.
*/
function InviteToRoom({ buffer }: { buffer: BufferEntry }): JSX.Element {
const store = useStore()
const [open, setOpen] = useState(false)
const [who, setWho] = useState('')
const [busy, setBusy] = useState(false)
const invite = (): void => {
const userId = who.trim()
if (!userId) return
setBusy(true)
void window.moho
.rpc('inviteMatrixMember', { accountId: buffer.accountId, bufferId: buffer.id, userId })
.then(() => {
store.toast('info', `Invited ${userId}`)
setWho('')
setOpen(false)
})
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setBusy(false))
}
if (!open) {
return <IconButton name="person_add" title="Invite somebody to this room" onClick={() => setOpen(true)} />
}
return (
<div className="conversation-search">
<Icon name="person_add" size={16} />
<input
autoFocus
type="text"
value={who}
placeholder="@someone:server"
disabled={busy}
onChange={(e) => setWho(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
setWho('')
setOpen(false)
}
if (e.key === 'Enter') invite()
}}
onBlur={() => !who.trim() && setOpen(false)}
/>
</div>
)
}
/**
* What a channel's restrictions mean, for the ones worth explaining.
*
@@ -160,6 +217,11 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
the conversation being read rather than on the window. */}
{isDiscord && isDm && <AddToConversation buffer={buffer} />}
{/* The same job on Matrix, where it needs no picker: a Matrix address
names somebody whether or not this account has ever met them, so
there is no friend list to choose from and nothing to look up. */}
{buffer.accountId.startsWith('matrix:') && <InviteToRoom buffer={buffer} />}
<div className="conversation-search">
<Icon name="search" size={16} />
<input
@@ -57,6 +57,16 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme
<div className="small muted">
Your other logged-in devices. Verify one to let encrypted history decrypt across both.
</div>
{/* Said because the tick is otherwise a promise this cannot keep.
moho does no cross-signing, so a verification here is recorded
between these two sessions and never published - Element will
still show the device as unverified, and somebody who was not
told that would reasonably conclude one of the two clients is
broken. */}
<div className="small muted">
Verifying marks a session as trusted <em>in moho</em>. moho does not do cross-signing,
so other clients will still show it unverified.
</div>
</div>
<button type="button" className="icon-button" title="Refresh" onClick={refresh}>
<Icon name="refresh" size={16} />