Files
cinny/src/app/components/ConsentRequiredPrompt.tsx
T
Lotus CIandClaude Opus 5.5 c96c47dd0d
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
feat: explain "Failed to send" when the homeserver wants its terms accepted
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
2026-09-24 21:08:15 -04:00

169 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}