Show the verification wherever it starts

The emoji comparison lived inside the Matrix account panel, which was fine
while the only flow was one you started there. A request can now arrive
from another of your sessions or from another person through a room you
share, and a dialog only that panel can draw is a request nobody can
answer - so it moved out over the window, beside the profile card and the
call banner.

An incoming request was also handled nowhere at all: the daemon recorded it
and no case in the store ever read it. It now opens the dialog and says who
is asking.

Whose verification it is leads the dialog, because the two cases mean
different things: your own session is a device you are letting in, and
somebody else's is a person you are vouching for - and the emoji should be
compared out loud, not typed into the chat you are verifying.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-04 20:08:13 -04:00
co-authored by Claude Opus 5
parent 5e9a5c3582
commit 612eae7799
7 changed files with 165 additions and 82 deletions
+1 -1
Submodule nobilis updated: 8d4855cfa6...e552bbc5e2
+7
View File
@@ -10,6 +10,7 @@ import { Composer } from './components/Composer'
import { MembershipGate } from './components/MembershipGate'
import { NickList } from './components/NickList'
import { ThreadPanel } from './components/ThreadPanel'
import { VerificationDialog } from './components/VerificationDialog'
import { ProfileCard } from './components/ProfileCard'
import { ConversationTools } from './components/ConversationTools'
import { AccountsPanel } from './components/AccountsPanel'
@@ -193,6 +194,12 @@ export default function App(): JSX.Element {
{/* Over everything: an answer to something just asked. */}
<ProfileCard />
{/* Over everything, because a verification can now start somewhere
else entirely - another of your sessions, or another person in a
room you share - and a dialog only the account panel can show is a
request nobody can answer. */}
<VerificationDialog />
{/* Over everything: a call arrives while you are looking elsewhere. */}
<IncomingCallPanel />
{/* Over everything for the same reason: a file is offered while you are
+26
View File
@@ -332,6 +332,32 @@
/* --- verifying somebody ------------------------------------------------
Over the window rather than inside the account panel, because a request
can arrive from another session or another person at any moment - and the
panel it used to live in is usually closed when one does. */
.verification-dialog {
position: fixed;
z-index: 70;
right: var(--spacing-m);
bottom: var(--spacing-m);
width: min(360px, calc(100vw - var(--spacing-m) * 2));
display: flex;
flex-direction: column;
gap: var(--spacing-s);
padding: var(--spacing-m);
background: var(--surface-container-high);
border: 1px solid var(--outline-strong);
border-radius: var(--corner-radius);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5);
}
.verification-head {
display: flex;
align-items: center;
gap: var(--spacing-s);
}
/* --- panels hanging off the header -------------------------------------
Fixed to the window rather than laid out in the header, because the header
is a row that has to compact: a panel inside it either widens the row past
@@ -426,88 +426,14 @@ export function MatrixAccountTools({ account }: { account: Account }): JSX.Eleme
</div>
))}
{/* The emoji comparison is drawn over the whole window now rather
than in here: a verification can arrive from another session, or
from another person through a room you share, and this panel is
not necessarily open when one does. */}
{active && (
<div className="verification-box">
{active.emoji ? (
<>
<div className="setting-text">Do these match on the other device?</div>
<div className="sas-emoji">
{active.emoji.map((e, i) => (
<div key={i} className="sas-cell">
<span className="sas-symbol">{e.symbol}</span>
<span className="small muted">{e.description}</span>
</div>
))}
</div>
<div className="button-row">
<button
type="button"
className="button"
onClick={() =>
void rpc('confirmMatrixVerification', {
accountId: account.id,
verificationId: active.verificationId,
matches: true
})
}
>
They match
</button>
<button
type="button"
className="button danger"
onClick={() =>
void rpc('confirmMatrixVerification', {
accountId: account.id,
verificationId: active.verificationId,
matches: false
})
}
>
They don&apos;t
</button>
</div>
</>
) : (
<>
<div className="setting-text">
Verification {active.state || 'starting'}…
</div>
<p className="small muted">
Accept the request on your other session; the emoji to compare will appear here.
</p>
<div className="button-row">
{active.state === 'requested' && (
<button
type="button"
className="button"
onClick={() =>
void rpc('respondMatrixVerification', {
accountId: account.id,
verificationId: active.verificationId,
accept: true
})
}
>
Accept
</button>
)}
<button
type="button"
className="button subtle"
onClick={() =>
void rpc('cancelMatrixVerification', {
accountId: account.id,
verificationId: active.verificationId
})
}
>
Cancel
</button>
</div>
</>
)}
</div>
<p className="small muted">
A verification is in progress — the comparison is shown over the window.
</p>
)}
<div className="divider-h" />
@@ -0,0 +1,88 @@
import { Icon } from './Icon'
import { useChat, useStore } from '../state/hooks'
/**
* The emoji comparison, over everything.
*
* It used to live inside the Matrix account panel, which was fine while the
* only flow was one you started there. A verification can now arrive from
* another person - through the room you share, the way Element sends one -
* and a dialog nobody can see is a request nobody can answer.
*
* Whose verification it is leads, because the two cases mean different
* things: your own session is a device you are letting in, and somebody
* else's is a person you are vouching for.
*/
export function VerificationDialog(): JSX.Element | null {
const store = useStore()
const active = useChat((s) => s.matrixVerification)
if (!active) return null
const who = active.otherUser || active.fromUser || ''
const mine = active.isSelf !== false && (!who || who === active.accountId.replace(/^matrix:/, ''))
const rpc = (method: string, params: Record<string, unknown>): void => {
void window.moho
.rpc(method, { accountId: active.accountId, verificationId: active.verificationId, ...params })
.catch((e: Error) => store.toast('error', e.message))
}
return (
<div className="verification-dialog">
<div className="verification-head">
<Icon name={mine ? 'devices' : 'person_check'} size={18} />
<div className="setting-text">
<div>{mine ? 'Verifying your other session' : `Verifying ${who}`}</div>
{active.otherDevice && <div className="small muted">{active.otherDevice}</div>}
</div>
</div>
{active.emoji ? (
<>
<div className="small muted">
{mine
? 'Do these match on your other device?'
: 'Do these match on their screen? Ask them - out loud, not in this chat.'}
</div>
<div className="sas-emoji">
{active.emoji.map((e, i) => (
<div key={i} className="sas-cell">
<span className="sas-symbol">{e.symbol}</span>
<span className="small muted">{e.description}</span>
</div>
))}
</div>
<div className="button-row">
<button type="button" className="button" onClick={() => rpc('confirmMatrixVerification', { matches: true })}>
They match
</button>
<button
type="button"
className="button danger"
onClick={() => rpc('confirmMatrixVerification', { matches: false })}
>
They don&apos;t
</button>
</div>
</>
) : (
<>
<div className="small muted">
{active.state === 'requested'
? 'Waiting for you to accept.'
: `Verification ${active.state || 'starting'}…`}
</div>
<div className="button-row">
{active.state === 'requested' && (
<button type="button" className="button" onClick={() => rpc('respondMatrixVerification', { accept: true })}>
Accept
</button>
)}
<button type="button" className="button subtle" onClick={() => rpc('cancelMatrixVerification', {})}>
Cancel
</button>
</div>
</>
)}
</div>
)
}
+23
View File
@@ -1757,6 +1757,29 @@ export class ChatStore {
// Verification is a multi-step flow whose steps all arrive as pushes:
// a status change, then the emoji to compare, then a final result.
// Somebody asking to verify, rather than us asking them. Nothing
// handled this, so a request arriving from another session - or from
// another person, now that those reach here at all - was recorded by
// the daemon and shown nowhere.
case 'matrixVerificationIncoming':
this.set({
matrixVerification: {
accountId: data.accountId,
verificationId: data.verificationId,
state: 'requested',
otherUser: data.fromUser,
otherDevice: data.fromDevice,
isSelf: data.isSelf
}
})
this.toast(
'info',
data.isSelf === false
? `${data.fromUser} wants to verify with you`
: 'Another of your sessions wants to verify'
)
break
case 'matrixVerificationStatus':
this.set({
matrixVerification: { ...(this.state.matrixVerification || {}), ...data }
+13
View File
@@ -508,6 +508,19 @@ export interface MatrixVerification {
verificationId: string
state?: string
emoji?: SasEmoji[]
/**
* Who is on the other end.
*
* For one of your own sessions this is you and the device is the
* interesting half; for somebody else it is the whole point - a dialog
* that cannot say whose emoji these are is one nobody should press yes on.
*/
otherUser?: string
otherDevice?: string | null
isSelf?: boolean
/** Set on an incoming request that has not been answered yet. */
fromUser?: string
fromDevice?: string
}
export interface RoomPermissions {