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
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:
co-authored by
Claude Opus 5.5
parent
103c6f4624
commit
c96c47dd0d
@@ -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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ import { useTauriUpdateProgress, useTauriUpdater } from '../../hooks/useTauriUpd
|
|||||||
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
import { isNetworkUpdateError } from '../../utils/updateErrors';
|
||||||
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
|
import { invokeTauri, isTauri as isTauriApp, useTauriEvent } from '../../hooks/useTauri';
|
||||||
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
|
import { CloseBehaviorPrompt } from '../../components/CloseBehaviorPrompt';
|
||||||
|
import { ConsentRequiredPrompt } from '../../components/ConsentRequiredPrompt';
|
||||||
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
import { TauriDesktopFeatures } from '../../components/TauriDesktopFeatures';
|
||||||
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
import { KeyboardShortcutsDialog, useKeyboardShortcutsTrigger } from '../../features/shortcuts';
|
||||||
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
import { useRoomsListener } from '../../hooks/useRoomsListener';
|
||||||
@@ -1090,6 +1091,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<TauriUpdateFeature />
|
<TauriUpdateFeature />
|
||||||
<TauriDesktopFeatures />
|
<TauriDesktopFeatures />
|
||||||
<CloseBehaviorPrompt />
|
<CloseBehaviorPrompt />
|
||||||
|
<ConsentRequiredPrompt />
|
||||||
<LotusDenoiseFeature />
|
<LotusDenoiseFeature />
|
||||||
<DeepLinkNavigator />
|
<DeepLinkNavigator />
|
||||||
<KeyboardShortcutsFeature />
|
<KeyboardShortcutsFeature />
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user