Show a Matrix invitation where joining already happens

Pending invites sit above the join field, because they are the one thing
on that page that arrived on its own - everything else there is a room
you already knew the name of and went looking for.

The inviter is shown next to the name: a room with no name of its own
lists as its raw id, and who asked is the part that makes it answerable.
Answering does not remove the row by itself; the next sync stops listing
it, which is what confirms the server agreed.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-08-29 10:20:47 -04:00
co-authored by Claude Opus 5
parent fe65896cca
commit 629948b1b3
3 changed files with 138 additions and 1 deletions
+1 -1
Submodule nobilis updated: d0b3adb642...9226c92314
+37
View File
@@ -1910,6 +1910,43 @@ img.smilie {
max-width: 560px;
}
/* Pending Matrix invitations, above the join field.
A row is "what it is" against "what you can do about it", with the name
allowed to shrink: a room id is long, and the two buttons must stay
reachable rather than being pushed off the edge by it. */
.invite-list {
display: flex;
flex-direction: column;
gap: var(--spacing-xxs);
}
.invite-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-s);
padding: var(--spacing-s);
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius-small);
background: var(--surface-variant);
}
.invite-what {
display: flex;
flex-direction: column;
min-width: 0;
}
.invite-name {
font-weight: 600;
}
.invite-actions {
display: flex;
gap: var(--spacing-xxs);
flex: none;
}
.join-friends-header {
display: flex;
align-items: center;
+100
View File
@@ -11,6 +11,15 @@ import type { Account, DiscordFriend } from '../../../shared/wire'
*/
const GROUP_DM_MAX = 9
/** A room this account has been invited to and not yet answered. */
type MatrixInvite = {
roomId: string
name: string
inviter: string | null
avatarUrl: string | null
isDirect: boolean
}
/**
* Per-protocol join pages rather than one generic "join by name" field: each
* service's mechanism is genuinely different (IRC joins by channel name,
@@ -106,10 +115,101 @@ function IrcJoin({ account }: { account: Account }): JSX.Element {
)
}
/**
* Rooms somebody has asked this account to join.
*
* Shown above the join field because it is the one thing here that arrived
* on its own: everything else on this page is a room you already know the
* name of and went looking for. An invite was invisible until recently -
* the daemon never read them out of sync - so it is worth being plain about
* who sent it, since a room with no name of its own shows as its raw id and
* the inviter is the only part that makes it answerable.
*/
function MatrixInvites({ account }: { account: Account }): JSX.Element | null {
const store = useStore()
const [invites, setInvites] = useState<MatrixInvite[]>([])
const [answering, setAnswering] = useState<string | null>(null)
useEffect(() => {
const load = (): void => {
void window.moho
.rpc<MatrixInvite[]>('listMatrixInvites', { accountId: account.id })
// An older daemon has no such method, and no invites to show.
.then(setInvites)
.catch(() => setInvites([]))
}
load()
// The daemon pushes the whole set whenever it changes, so accepting from
// another client empties this without anybody refreshing.
return window.moho.onEvent((frame) => {
if (frame.event !== 'matrixInvites') return
const data = frame.data as { accountId: string; invites: MatrixInvite[] }
if (data.accountId === account.id) setInvites(data.invites ?? [])
})
}, [account.id])
if (invites.length === 0) return null
const answer = (invite: MatrixInvite, accept: boolean): void => {
setAnswering(invite.roomId)
void window.moho
.rpc(accept ? 'acceptMatrixInvite' : 'declineMatrixInvite', {
accountId: account.id,
roomId: invite.roomId
})
// Not removed from the list here: the next sync stops listing it,
// which is what actually confirms the server agreed.
.catch((e: Error) => store.toast('error', e.message))
.finally(() => setAnswering(null))
}
return (
<div className="field">
<span className="small muted">
{invites.length === 1 ? 'You have been invited to' : `You have ${invites.length} invitations`}
</span>
<div className="invite-list">
{invites.map((invite) => (
<div className="invite-row" key={invite.roomId}>
<div className="invite-what">
<span className="invite-name ellipsis">{invite.name}</span>
{invite.inviter && (
<span className="small muted ellipsis">
from {invite.inviter}
{invite.isDirect ? ' · direct message' : ''}
</span>
)}
</div>
<div className="invite-actions">
<button
type="button"
className="button primary"
disabled={answering === invite.roomId}
onClick={() => answer(invite, true)}
>
Accept
</button>
<button
type="button"
className="button"
disabled={answering === invite.roomId}
onClick={() => answer(invite, false)}
>
Decline
</button>
</div>
</div>
))}
</div>
</div>
)
}
function MatrixJoin({ account }: { account: Account }): JSX.Element {
const store = useStore()
return (
<div className="panel join-panel">
<MatrixInvites account={account} />
<SubmitField
label="Join a room or space"
// A Space is just a room with an m.space creation type, joined through