From c96c47dd0d85dbf27646075f67ae0d9da5af09aa Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Thu, 24 Sep 2026 21:08:15 -0400 Subject: [PATCH] feat: explain "Failed to send" when the homeserver wants its terms accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/app/components/ConsentRequiredPrompt.tsx | 168 +++++++++++++++++++ src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/app/utils/consent.test.ts | 45 +++++ src/app/utils/consent.ts | 21 +++ 4 files changed, 236 insertions(+) create mode 100644 src/app/components/ConsentRequiredPrompt.tsx create mode 100644 src/app/utils/consent.test.ts create mode 100644 src/app/utils/consent.ts diff --git a/src/app/components/ConsentRequiredPrompt.tsx b/src/app/components/ConsentRequiredPrompt.tsx new file mode 100644 index 000000000..d683c81b1 --- /dev/null +++ b/src/app/components/ConsentRequiredPrompt.tsx @@ -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(); + const [open, setOpen] = useState(false); + const [opened, setOpened] = useState(false); + const [blockedCount, setBlockedCount] = useState(0); + const snoozedUntil = useRef(0); + const blocked = useRef(new Map()); + 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 ( + }> + + + +
+ + Accept the terms to keep chatting + +
+ + + + Your message wasn't sent. {server} requires you to review and accept + its terms of service before you can send messages. + + + {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.'} + + + + {consentUri && ( + + )} + + + + +
+
+
+
+ ); +} diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 69e361780..59f71c790 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -71,6 +71,7 @@ import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpd import { isNetworkUpdateError } from '../../utils/updateErrors'; import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri'; import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt'; +import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt'; import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures'; import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts'; import { useRoomsListener } from '../../hooks/useRoomsListener'; @@ -1090,6 +1091,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + diff --git a/src/app/utils/consent.test.ts b/src/app/utils/consent.test.ts new file mode 100644 index 000000000..c3bd9899d --- /dev/null +++ b/src/app/utils/consent.test.ts @@ -0,0 +1,45 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { EventStatus, MatrixError, MatrixEvent } from 'matrix-js-sdk'; +import { CONSENT_ERRCODE, failedForConsent, safeConsentUri } from './consent'; + +describe('safeConsentUri', () => { + it('keeps http(s) links from any homeserver', () => { + assert.equal( + safeConsentUri('https://matrix.example.org/_matrix/consent?u=a&h=b'), + 'https://matrix.example.org/_matrix/consent?u=a&h=b', + ); + assert.equal( + safeConsentUri('http://localhost:8008/_matrix/consent'), + 'http://localhost:8008/_matrix/consent', + ); + }); + + it('rejects other schemes and junk', () => { + // eslint-disable-next-line no-script-url + assert.equal(safeConsentUri('javascript:alert(1)'), undefined); + assert.equal(safeConsentUri('file:///etc/passwd'), undefined); + assert.equal(safeConsentUri('not a url'), undefined); + assert.equal(safeConsentUri(undefined), undefined); + assert.equal(safeConsentUri(42), undefined); + }); +}); + +describe('failedForConsent', () => { + const echo = (status: EventStatus, errcode?: string) => { + const ev = new MatrixEvent({ type: 'm.room.message', content: { body: 'hi' } }); + ev.setStatus(status); + if (errcode) ev.error = new MatrixError({ errcode, error: 'x' }, 403); + return ev; + }; + + it('matches a send that failed for consent', () => { + assert.equal(failedForConsent(echo(EventStatus.NOT_SENT, CONSENT_ERRCODE)), true); + }); + + it('ignores other failures and non-failed echoes', () => { + assert.equal(failedForConsent(echo(EventStatus.NOT_SENT, 'M_FORBIDDEN')), false); + assert.equal(failedForConsent(echo(EventStatus.NOT_SENT)), false); + assert.equal(failedForConsent(echo(EventStatus.SENDING, CONSENT_ERRCODE)), false); + }); +}); diff --git a/src/app/utils/consent.ts b/src/app/utils/consent.ts new file mode 100644 index 000000000..22a339263 --- /dev/null +++ b/src/app/utils/consent.ts @@ -0,0 +1,21 @@ +import { EventStatus, MatrixEvent } from 'matrix-js-sdk'; + +export const CONSENT_ERRCODE = 'M_CONSENT_NOT_GIVEN'; + +/** + * The consent page comes from whichever homeserver the user is on (this client + * works with any of them), so only ever open a plain web link. + */ +export const safeConsentUri = (uri: unknown): string | undefined => { + if (typeof uri !== 'string') return undefined; + try { + const url = new URL(uri); + return url.protocol === 'https:' || url.protocol === 'http:' ? url.href : undefined; + } catch { + return undefined; + } +}; + +/** True when a local echo failed because the server wants the ToS accepted. */ +export const failedForConsent = (event: MatrixEvent): boolean => + event.status === EventStatus.NOT_SENT && event.error?.errcode === CONSENT_ERRCODE;