feat: explain "Failed to send" when the homeserver wants its terms accepted
CI / Build & Quality Checks (push) Successful in 1m45s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 14s
CI / Playwright smoke (e2e) (push) Successful in 6m58s

A homeserver with a consent requirement (Synapse user_consent) rejects sends
with 403 M_CONSENT_NOT_GIVEN until the user accepts its current terms. The
message just showed "Failed to send" with no reason.

Listen for the SDK's HttpApiEvent.NoConsent and show a dialog naming the
user's own homeserver (the client works with any server, so no Lotus-specific
wording), with "Review and accept" opening the server's consent_uri (http(s)
only; anything else is dropped) and "I've accepted — retry sending" resending
every event that failed for this reason. "Later" snoozes it for 10 s so
background retries don't re-open it immediately.

Verified in Chromium against a local Synapse with the send endpoint answering
M_CONSENT_NOT_GIVEN: dialog shows, link opens, retry delivers the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-24 21:08:15 -04:00
co-authored by Claude Opus 5.5
parent 103c6f4624
commit c96c47dd0d
4 changed files with 236 additions and 0 deletions
@@ -0,0 +1,168 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Button,
Dialog,
Header,
Icon,
Icons,
Overlay,
OverlayBackdrop,
OverlayCenter,
Text,
config,
} from 'folds';
import {
EventStatus,
HttpApiEvent,
HttpApiEventHandlerMap,
MatrixEvent,
Room,
RoomEvent,
RoomEventHandlerMap,
} from 'matrix-js-sdk';
import { useMatrixClient } from '../hooks/useMatrixClient';
import { useModalStyle } from '../hooks/useModalStyle';
import { failedForConsent, safeConsentUri } from '../utils/consent';
// After "Later", don't re-open for every background retry; the next send the
// user makes after this window explains again.
const SNOOZE_MS = 10_000;
/**
* A homeserver with a consent requirement (e.g. Synapse's user_consent) blocks
* sending until its terms are accepted, answering 403 M_CONSENT_NOT_GIVEN with a `consent_uri`. Without
* this the message just shows "Failed to send". The SDK emits
* HttpApiEvent.NoConsent for every such response; we explain why, link to the
* acceptance page, and offer to resend what was blocked once accepted.
*/
export function ConsentRequiredPrompt() {
const mx = useMatrixClient();
const [consentUri, setConsentUri] = useState<string>();
const [open, setOpen] = useState(false);
const [opened, setOpened] = useState(false);
const [blockedCount, setBlockedCount] = useState(0);
const snoozedUntil = useRef(0);
const blocked = useRef(new Map<MatrixEvent, Room>());
const modalStyle = useModalStyle(440);
const server = mx.getDomain() ?? 'Your homeserver';
useEffect(() => {
const onNoConsent: HttpApiEventHandlerMap[HttpApiEvent.NoConsent] = (_message, uri) => {
const safe = safeConsentUri(uri);
if (safe) setConsentUri(safe);
if (Date.now() >= snoozedUntil.current) setOpen(true);
};
const onLocalEcho: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (event, room) => {
if (failedForConsent(event)) blocked.current.set(event, room);
else if (!blocked.current.delete(event)) return;
setBlockedCount(blocked.current.size);
};
mx.on(HttpApiEvent.NoConsent, onNoConsent);
mx.on(RoomEvent.LocalEchoUpdated, onLocalEcho);
return () => {
mx.removeListener(HttpApiEvent.NoConsent, onNoConsent);
mx.removeListener(RoomEvent.LocalEchoUpdated, onLocalEcho);
};
}, [mx]);
const review = useCallback(() => {
if (!consentUri) return;
window.open(consentUri, '_blank', 'noopener,noreferrer');
setOpened(true);
}, [consentUri]);
const retry = useCallback(() => {
setOpen(false);
setOpened(false);
const pending = Array.from(blocked.current.entries());
blocked.current.clear();
setBlockedCount(0);
pending.forEach(([event, room]) => {
if (event.status === EventStatus.NOT_SENT) mx.resendEvent(event, room);
});
}, [mx]);
const later = useCallback(() => {
snoozedUntil.current = Date.now() + SNOOZE_MS;
setOpen(false);
}, []);
if (!open) return null;
return (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: '#consent-review',
fallbackFocus: '#consent-later',
onDeactivate: later,
clickOutsideDeactivates: true,
escapeDeactivates: true,
}}
>
<Dialog
variant="Surface"
role="alertdialog"
aria-modal="true"
aria-labelledby="consent-title"
aria-describedby="consent-body"
style={modalStyle}
>
<Header
style={{
padding: `0 ${config.space.S400}`,
borderBottomWidth: config.borderWidth.B300,
}}
variant="Surface"
size="500"
>
<Text as="h2" size="H4" id="consent-title">
Accept the terms to keep chatting
</Text>
</Header>
<Box style={{ padding: config.space.S400 }} direction="Column" gap="400">
<Box id="consent-body" direction="Column" gap="200">
<Text priority="400">
Your message wasn&apos;t sent. <b>{server}</b> requires you to review and accept
its terms of service before you can send messages.
</Text>
<Text priority="400">
{consentUri
? 'Nothing you sent is lost: accept the terms, then come back here and retry.'
: 'Your homeserver didn’t include a link to its terms. Contact its administrator, or accept them in another Matrix app, then retry.'}
</Text>
</Box>
<Box direction="Column" gap="200">
{consentUri && (
<Button
id="consent-review"
variant={opened ? 'Secondary' : 'Primary'}
fill={opened ? 'Soft' : 'Solid'}
onClick={review}
after={<Icon size="100" src={Icons.External} />}
>
<Text size="B400">Review and accept</Text>
</Button>
)}
<Button
variant={opened ? 'Primary' : 'Secondary'}
fill={opened ? 'Solid' : 'Soft'}
onClick={retry}
>
<Text size="B400">
{blockedCount > 0 ? "I've accepted — retry sending" : "I've accepted"}
</Text>
</Button>
<Button id="consent-later" variant="Secondary" fill="None" onClick={later}>
<Text size="B400">Later</Text>
</Button>
</Box>
</Box>
</Dialog>
</FocusTrap>
</OverlayCenter>
</Overlay>
);
}