vojo/apps/widget-discord/src/App.tsx

1417 lines
54 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 { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import qrcodeGenerator from 'qrcode-generator';
import type { WidgetBootstrap } from './bootstrap';
import { WidgetApi, type RoomEvent } from './widget-api';
import { createT, type T } from './i18n';
import { parseEvent } from './bridge-protocol/parser';
import {
hydrateFromTimeline,
initialLoginState,
loginReducer,
type HydrateInput,
type LoginErrorFlag,
} from './state';
// Visual canon mirrors the Telegram widget — Dawn palette, fleet-violet
// accent, monospace handles. The Discord widget keeps Vojo's accent (per
// product decision: «used Vojo style») rather than adopting Discord
// blurple, so the panel reads as a coherent continuation of the host UI.
type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error';
type TranscriptLine = {
id: string;
ts: number;
kind: TranscriptKind;
text: string;
};
type Props = {
bootstrap: WidgetBootstrap;
// The WidgetApi is constructed in main.tsx synchronously, BEFORE React's
// first render — see widget-telegram for the cached-bundle race rationale.
api: WidgetApi;
};
const TRANSCRIPT_MAX = 200;
// Inline SVG refresh icon — same as TG widget for visual consistency.
const RefreshIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
<path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
<path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Inline SVG info icon — leads the AboutCard. Shared shape with the
// Telegram widget. WhatsApp uses a triangle warning glyph instead
// because its About modal also carries a Meta-ToS risk disclosure.
const InfoIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<circle cx="10" cy="10" r="7.5" />
<path d="M10 9.2 L10 14" stroke-linecap="round" />
<circle cx="10" cy="6.4" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Three QR finder squares + a few module dots — leads the QR-login
// card. Same shape across all three bot widgets.
const QrIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="3" y="3" width="5" height="5" rx="0.6" />
<rect x="12" y="3" width="5" height="5" rx="0.6" />
<rect x="3" y="12" width="5" height="5" rx="0.6" />
<path
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
stroke-linecap="round"
/>
</svg>
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Stays muted
// inside `.command-card.danger` so the rose accent remains a single
// accent on the title.
const LogoutIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Two chain links joined by a horizontal bar — leads the Discord-
// only Reconnect card. Visually distinct from RefreshIcon's circular
// arrows so the user can tell «re-establish bridge link» from
// «re-fetch status» at a glance.
const LinkIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M8 13 H5.5 a3 3 0 0 1 0 -6 H8" stroke-linecap="round" />
<path d="M12 7 H14.5 a3 3 0 0 1 0 6 H12" stroke-linecap="round" />
<line x1="7" y1="10" x2="13" y2="10" stroke-linecap="round" />
</svg>
);
// Linkifier — same heuristic as TG widget.
const URL_RE = /https?:\/\/[^\s)]+/g;
// Defense-in-depth: a Discord remoteauth login URL is the LIVE login
// secret. Today the bridge only emits it via `m.image` (which we route
// to a generic «QR-код выдан» diag, never a verbatim transcript line).
// But if a future bridge revision started echoing the URL into m.notice
// — say, for a chat-fallback fallback path — the existing transcript
// append would (a) store the URL in the DOM, (b) survive page reload via
// the hydrate replay, and (c) the linkifier would turn it into a
// clickable anchor that opens in the parent browser, leaving the active
// login token in the user's history. Scrubbing here makes the leak
// path closed even if the upstream wiring drifts.
const REMOTEAUTH_URL_RE =
/https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/(?:ra|login\/handoff)\/[A-Za-z0-9_\-+=.~?&/]+/gi;
const scrubLoginSecret = (body: string): string =>
body.replace(REMOTEAUTH_URL_RE, '[redacted login URL]');
const formatTime = (ts: number): string => {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
};
const renderBody = (body: string): ComponentChildren => {
const out: ComponentChildren[] = [];
let lastIndex = 0;
for (const match of body.matchAll(URL_RE)) {
const idx = match.index ?? 0;
if (idx > lastIndex) out.push(body.slice(lastIndex, idx));
out.push(
<a key={`${idx}-${match[0]}`} href={match[0]} target="_blank" rel="noreferrer noopener">
{match[0]}
</a>
);
lastIndex = idx + match[0].length;
}
if (lastIndex < body.length) out.push(body.slice(lastIndex));
return out.length === 0 ? body : out;
};
const localizeError = (err: LoginErrorFlag, t: T): string => {
switch (err.kind) {
case 'login_failed':
return t('auth-error.login-failed', { reason: err.reason ?? '' });
case 'captcha_required':
return t('auth-error.captcha-required');
case 'captcha_send_failed':
return t('auth-error.captcha-send-failed');
case 'captcha_expired':
return t('auth-error.captcha-expired');
case 'login_websocket_failed':
return t('auth-error.websocket-failed', { reason: err.reason ?? '' });
case 'connect_after_login_failed':
return t('auth-error.connect-after-login-failed', { reason: err.reason ?? '' });
case 'prepare_login_failed':
return t('auth-error.prepare-failed', { reason: err.reason ?? '' });
case 'already_logged_in':
return t('auth-error.already-logged-in');
case 'unknown_command':
return t('auth-error.unknown-command');
default: {
const exhaustive: never = err;
return String(exhaustive);
}
}
};
// Captcha is the only «not really an error, more of a suggestion» case —
// surface as warn (amber) rather than red. Everything else is a hard
// failure of the login attempt and gets red treatment.
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
if (err.kind === 'captcha_required') return 'warn';
if (err.kind === 'captcha_expired') return 'warn';
if (err.kind === 'already_logged_in') return 'warn';
return 'error';
};
// --------------------------------------------------------------------------
// QR panel
// --------------------------------------------------------------------------
// Discord remoteauth's server-side timeout sits around 2 minutes of
// inactivity (the bridge holds the websocket; Discord's gateway closes
// it from its side). 3 minutes is a slight safety margin: the user
// sees «expired» a touch after the server probably already dropped
// the WS, but never before, so they can't trust a dead QR. This MUST
// match HYDRATE_FRESHNESS_MS in state.ts so the timeline-resume window
// agrees with the panel countdown — diverging the two would mean a
// reload at e.g. 4 min restores the panel even though the panel
// itself would render «expired». Telegram's MTProto QR rotates and
// lives ~10 min, which is why the TG widget uses 10 min for both.
const QR_TIMEOUT_MS = 3 * 60 * 1000;
// Error-correction level M is a good trade-off for short URLs — more
// resilient to camera glare than L, smaller modules than Q. typeNumber=0
// auto-picks the smallest QR version that fits the payload.
const buildQrModules = (data: string): boolean[][] | null => {
if (!data) return null;
try {
const qr = qrcodeGenerator(0, 'M');
qr.addData(data);
qr.make();
const count = qr.getModuleCount();
const matrix: boolean[][] = [];
for (let r = 0; r < count; r += 1) {
const row: boolean[] = [];
for (let c = 0; c < count; c += 1) {
row.push(qr.isDark(r, c));
}
matrix.push(row);
}
return matrix;
} catch {
return null;
}
};
// Render the QR matrix as <rect> elements inside an SVG. We deliberately
// avoid `dangerouslySetInnerHTML` and any external QR-rendering service:
// the `https://discord.com/ra/...` URL IS the login secret, so it must
// never leave the iframe and must never reach a stringified-HTML path
// that bypasses Preact's escaping.
type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string };
const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => {
const count = matrix.length;
const margin = 4;
const totalUnits = count + margin * 2;
const cellPx = pixelSize / totalUnits;
const rects: ComponentChildren[] = [];
for (let r = 0; r < count; r += 1) {
for (let c = 0; c < count; c += 1) {
if (!matrix[r][c]) continue;
rects.push(
<rect
key={`${r}-${c}`}
x={(c + margin) * cellPx}
y={(r + margin) * cellPx}
width={cellPx + 0.5 /* +0.5 px overlap kills subpixel gaps on Android */}
height={cellPx + 0.5}
fill="#000"
/>
);
}
}
return (
<svg
width={pixelSize}
height={pixelSize}
viewBox={`0 0 ${pixelSize} ${pixelSize}`}
role="img"
aria-label={ariaLabel}
>
{rects}
</svg>
);
};
type QrPanelProps = {
state: {
kind: 'awaiting_qr_scan';
discordUrl: string;
firstShownAt: number;
lastError?: LoginErrorFlag;
};
t: T;
onCancel: () => void;
};
const QrPanel = ({ state, t, onCancel }: QrPanelProps) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const matrix = useMemo(() => buildQrModules(state.discordUrl), [state.discordUrl]);
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000));
const expired = elapsed >= QR_TIMEOUT_MS && state.firstShownAt > 0;
return (
<div class="auth-card auth-card-qr">
<div class="auth-card-title">{t('auth-card.qr.title')}</div>
<div class="auth-card-hint">{t('auth-card.qr.hint')}</div>
<div class="auth-card-qr-frame">
{matrix ? (
// The aria-label describes the PURPOSE, not the contents — the
// URL itself is the login secret and must not be exposed via
// AT-tree text content.
<QrSvg matrix={matrix} pixelSize={232} ariaLabel={t('auth-card.qr.aria')} />
) : (
<div class="auth-card-qr-placeholder" role="status" aria-live="polite">
<span class="dot" />
{t('auth-card.qr.preparing')}
</div>
)}
</div>
{!expired ? (
<div class="auth-card-countdown">
{t('auth-card.qr.countdown', {
minutes: String(Math.floor(remainingSeconds / 60)),
seconds: String(remainingSeconds % 60).padStart(2, '0'),
})}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.qr.expired')}</div>
)}
<ol class="auth-card-qr-steps">
<li>{t('auth-card.qr.step-1')}</li>
<li>{t('auth-card.qr.step-2')}</li>
<li>{t('auth-card.qr.step-3')}</li>
</ol>
{state.lastError ? (
<div class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
{localizeError(state.lastError, t)}
</div>
) : null}
<div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};
// --------------------------------------------------------------------------
// Captcha panel — Vojo-patched bridge surfaces an hCaptcha challenge via
// the `VOJO-CAPTCHA-CHALLENGE-V1` notice. The panel lazy-loads hCaptcha's
// own JS, renders the challenge widget into our container with the supplied
// sitekey + rqdata (the latter is REQUIRED for hCaptcha-enterprise — Discord
// uses the enterprise tier), and on solve hands the token back to the App
// which sends `login-captcha <token>` to the bridge.
// --------------------------------------------------------------------------
// Canonical hCaptcha bootstrapping for explicit render: combine
// `?render=explicit` (don't auto-render any data-sitekey div) with
// `?onload=fnname` (call our resolver when the SDK is fully initialized).
// docs.hcaptcha.com/configuration recommends this over polling
// `window.hcaptcha` because (a) the global appears before its methods are
// safe to call in some SDK versions, (b) onload fires once and is cheaper
// than a 50ms tick. We still keep a wall-clock timeout as a backstop —
// covers ad-block / CSP / third-party-cookie blocks where the script
// itself fails to load.
const HCAPTCHA_ONLOAD_FN = '__vojoHCaptchaOnload';
const HCAPTCHA_API_URL = `https://js.hcaptcha.com/1/api.js?render=explicit&onload=${HCAPTCHA_ONLOAD_FN}`;
const HCAPTCHA_LOAD_TIMEOUT_MS = 15_000;
// hCaptcha render() does NOT accept rqdata as a config field — for
// hCaptcha-enterprise (which Discord uses) rqdata must be applied via
// `setData(widgetId, { rqdata })` AFTER `render()`. Passing rqdata to
// render is silently ignored, the resulting token isn't bound to the
// challenge, and Discord rejects with `invalid-response` /
// `sitekey-secret-mismatch`. Sources:
// https://github.com/hCaptcha/vue-hcaptcha (search `setData(`)
// https://github.com/hCaptcha/react-hcaptcha/issues/190
type HCaptchaConfig = {
sitekey: string;
callback?: (token: string) => void;
'expired-callback'?: () => void;
'error-callback'?: () => void;
size?: 'normal' | 'compact' | 'invisible';
theme?: 'light' | 'dark';
};
type HCaptchaApi = {
render: (container: HTMLElement, config: HCaptchaConfig) => string;
setData: (widgetId: string, data: { rqdata: string }) => void;
reset: (id?: string) => void;
remove: (id: string) => void;
};
declare global {
interface Window {
hcaptcha?: HCaptchaApi;
[HCAPTCHA_ONLOAD_FN]?: () => void;
}
}
let hcaptchaScriptPromise: Promise<HCaptchaApi> | null = null;
const loadHCaptcha = (): Promise<HCaptchaApi> => {
if (typeof window === 'undefined') return Promise.reject(new Error('no window'));
// Already initialised (e.g. earlier mount of the captcha panel) — skip
// the script dance entirely.
if (window.hcaptcha) return Promise.resolve(window.hcaptcha);
if (hcaptchaScriptPromise) return hcaptchaScriptPromise;
hcaptchaScriptPromise = new Promise<HCaptchaApi>((resolve, reject) => {
const existing = document.querySelector(
`script[src^="https://js.hcaptcha.com/1/api.js"]`
) as HTMLScriptElement | null;
let timeoutHandle: number | undefined;
let settled = false;
const settle = (action: () => void) => {
if (settled) return;
settled = true;
if (timeoutHandle !== undefined) window.clearTimeout(timeoutHandle);
// Drop the global — leaves `window` clean if no other consumer
// is hooked. Future `loadHCaptcha()` calls hit the early-return
// `if (window.hcaptcha)` and skip this whole path.
delete window[HCAPTCHA_ONLOAD_FN];
action();
};
// `?onload=` calls our global once the SDK finishes initialising.
// We register BEFORE appending the script so the listener is in
// place if the script is already cached and runs synchronously.
window[HCAPTCHA_ONLOAD_FN] = () => {
if (window.hcaptcha) {
settle(() => resolve(window.hcaptcha as HCaptchaApi));
} else {
// Defence-in-depth: onload fired but global missing. Treat as
// a load failure and let the panel show the error UI.
settle(() => {
hcaptchaScriptPromise = null;
reject(new Error('hcaptcha onload fired without window.hcaptcha'));
});
}
};
if (!existing) {
const script = document.createElement('script');
script.src = HCAPTCHA_API_URL;
script.async = true;
script.defer = true;
script.addEventListener('error', () =>
settle(() => {
hcaptchaScriptPromise = null;
reject(new Error('hcaptcha script failed to load'));
})
);
document.head.appendChild(script);
} else if (window.hcaptcha) {
// A previous mount already loaded the SDK fully; resolve immediately.
settle(() => resolve(window.hcaptcha as HCaptchaApi));
return;
}
// Otherwise: existing script is mid-load — our onload hook will fire
// when it finishes.
timeoutHandle = window.setTimeout(() => {
settle(() => {
hcaptchaScriptPromise = null;
reject(new Error('hcaptcha script load timed out'));
});
}, HCAPTCHA_LOAD_TIMEOUT_MS);
});
return hcaptchaScriptPromise;
};
type CaptchaPanelProps = {
state: {
kind: 'awaiting_captcha_solve';
service: string;
sitekey: string;
rqdata: string;
rqtoken: string;
};
t: T;
onSolved: (token: string) => void;
onCancel: () => void;
// Fired when the hCaptcha challenge expires (either Discord's
// server-side rqtoken TTL or our local 90s timer beats the user). The
// App rolls back to disconnected with a localized warn so the user
// retries login-qr instead of trying to solve a dead challenge.
onExpired: () => void;
};
// Match the bridge's CAPTCHA_HYDRATE_FRESHNESS_MS — Discord invalidates
// rqtoken around the 2-minute mark; we expire the UI a little earlier so
// the user retries fresh instead of getting a server-side rejection.
const CAPTCHA_EXPIRY_MS = 90 * 1000;
const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelProps) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const widgetIdRef = useRef<string | null>(null);
const [loadError, setLoadError] = useState<boolean>(false);
// Set true once we've finished render + setData — gate the solve
// callback on this so a hCaptcha auto-pass (low-friction users) or a
// fast click before setData lands can't ship a token unbound to rqdata.
// Discord rejects unbound tokens with `invalid-response`, burning the
// rqtoken and forcing a fresh challenge.
const dataReadyRef = useRef<boolean>(false);
// Idempotency latch on the solve callback — guards against hCaptcha
// firing the callback twice (observed when users double-click "I am
// human" or when low-risk users get the silent auto-pass that some
// SDK versions deliver via two events). Without this, two
// login-captcha commands ship to the bridge with the same single-use
// token and the second one tears down the session via
// `response-already-used`.
const solvedRef = useRef<boolean>(false);
useEffect(() => {
let cancelled = false;
dataReadyRef.current = false;
solvedRef.current = false;
setLoadError(false);
// 90-second client-side expiry timer. Drives the localized
// "captcha_expired" error rather than letting the user solve a
// dead rqtoken and watch the bridge return a generic Discord
// failure.
const expiryTimer = window.setTimeout(() => {
if (!cancelled && !solvedRef.current) onExpired();
}, CAPTCHA_EXPIRY_MS);
// Pass the host theme into hCaptcha so the chrome inside the iframe
// matches Vojo's surface — the host writes `data-theme` on
// <html> at boot from the `?theme=` URL param. Without this the
// hCaptcha widget paints its default light variant inside our
// Dawn-dark card and reads as a foreign white box.
const hcaptchaTheme: 'light' | 'dark' =
document.documentElement.dataset.theme === 'light' ? 'light' : 'dark';
loadHCaptcha()
.then((api) => {
if (cancelled || !containerRef.current) return;
const id = api.render(containerRef.current, {
sitekey: state.sitekey,
theme: hcaptchaTheme,
size: 'normal',
callback: (token) => {
// Gate on dataReadyRef AND solvedRef. dataReadyRef rejects a
// token before setData bound rqdata; solvedRef rejects
// duplicate callbacks for the same challenge.
if (cancelled || !dataReadyRef.current || solvedRef.current) return;
solvedRef.current = true;
onSolved(token);
},
'expired-callback': () => {
if (cancelled || solvedRef.current) return;
onExpired();
},
'error-callback': () => {
if (!cancelled) setLoadError(true);
},
});
// Race guard: cancel may have flipped between the await and
// render. If so, the iframe is mounted into a detached node — clean
// up immediately rather than leaking the widget id.
if (cancelled) {
try {
api.remove(id);
} catch {
/* ignore */
}
return;
}
widgetIdRef.current = id;
// Bind the solve to Discord's challenge nonce. hCaptcha-enterprise
// (which Discord uses) requires setData AFTER render — passing
// rqdata into render() is silently ignored.
try {
api.setData(id, { rqdata: state.rqdata });
dataReadyRef.current = true;
} catch (err) {
// setData failed (unknown widget id, SDK bug, etc.) — tear the
// widget down so a stray callback can't ship an unbound token.
// Surface the load-error UI; the user can retry login-qr.
try {
api.remove(id);
} catch {
/* ignore */
}
widgetIdRef.current = null;
if (!cancelled) setLoadError(true);
// eslint-disable-next-line no-console
console.warn('[captcha] hcaptcha.setData failed', err);
}
})
.catch(() => {
if (!cancelled) setLoadError(true);
});
return () => {
cancelled = true;
window.clearTimeout(expiryTimer);
// Best-effort cleanup. hCaptcha's own remove() is idempotent and
// tolerates missing widget ids; if the iframe was never rendered
// (load failure) the catch is harmless.
try {
if (window.hcaptcha && widgetIdRef.current) {
window.hcaptcha.remove(widgetIdRef.current);
}
} catch {
/* ignore */
}
widgetIdRef.current = null;
};
// rqtoken is the per-challenge nonce — chained captchas reuse the same
// sitekey/rqdata-shape but bring a new rqtoken; we want a remount on
// any of these for safety. The whole CaptchaSolveState normally
// replaces wholesale, but listing all three guards against future
// partial mutations.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.sitekey, state.rqdata, state.rqtoken]);
return (
<div class="auth-card auth-card-captcha">
<div class="auth-card-title">{t('auth-card.captcha.title')}</div>
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
<div class="auth-card-captcha-frame">
<div ref={containerRef} class="auth-card-captcha-host" />
{loadError ? (
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
) : null}
</div>
<div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};
// --------------------------------------------------------------------------
// About card + modal
// --------------------------------------------------------------------------
type AboutCardProps = {
t: T;
onOpen: () => void;
};
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<InfoIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.about.name')}</div>
<div class="command-card-desc">{t('card.about.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
type AboutModalProps = {
t: T;
onClose: () => void;
};
const AboutModal = ({ t, onClose }: AboutModalProps) => {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div
class="about-overlay"
role="dialog"
aria-modal="true"
aria-label={t('about.title')}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div class="about-panel">
<header class="about-header">
<h2 class="about-title">{t('about.title')}</h2>
<button
type="button"
class="about-close-x"
onClick={onClose}
aria-label={t('about.aria-close')}
>
×
</button>
</header>
<div class="about-body">
<p>{t('about.body-1')}</p>
<p>{t('about.body-2')}</p>
<p>{t('about.body-3')}</p>
<p>
{t('about.github-label')}{' '}
<a href={t('about.github-url')} target="_blank" rel="noreferrer">
{t('about.github-url')}
</a>
</p>
<p>{t('about.body-4')}</p>
</div>
<div class="about-footer">
<button type="button" class="btn-primary" onClick={onClose}>
{t('about.close')}
</button>
</div>
</div>
</div>
);
};
// --------------------------------------------------------------------------
// Logout card with confirm-in-place
// --------------------------------------------------------------------------
type LogoutCardProps = {
t: T;
onConfirm: () => Promise<void>;
};
const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
const [confirming, setConfirming] = useState(false);
const [submitting, setSubmitting] = useState(false);
// Belt-and-suspenders against double-submit. `disabled={submitting}` covers
// 99% of cases, but there's a microtask window between click and Preact
// rendering the disabled state where a fast second click could fire.
const inFlight = useRef(false);
if (confirming) {
return (
<div class="command-card danger">
<div class="command-card-confirm">
<span class="command-card-confirm-prompt">{t('card.logout.confirm-prompt')}</span>
<button
type="button"
class="command-card-confirm-yes"
disabled={submitting}
onClick={async () => {
if (inFlight.current) return;
inFlight.current = true;
setSubmitting(true);
try {
await onConfirm();
} finally {
inFlight.current = false;
setSubmitting(false);
setConfirming(false);
}
}}
>
{t('card.logout.confirm-yes')}
</button>
<button
type="button"
class="command-card-confirm-no"
disabled={submitting}
onClick={() => setConfirming(false)}
>
{t('card.logout.confirm-no')}
</button>
</div>
</div>
);
}
return (
<button class="command-card danger" type="button" onClick={() => setConfirming(true)}>
<span class="command-card-lead-icon" aria-hidden="true">
<LogoutIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.logout.name')}</div>
<div class="command-card-desc">{t('card.logout.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
);
};
// --------------------------------------------------------------------------
// Main App
// --------------------------------------------------------------------------
export function App({ bootstrap, api }: Props) {
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
const [handshakeOk, setHandshakeOk] = useState(false);
const [aboutOpen, setAboutOpen] = useState(false);
// True while a `ping` probe is in flight from a refresh-card click.
const [refreshing, setRefreshing] = useState(false);
const seenEventIds = useRef(new Set<string>());
const [state, dispatch] = useReducer(loginReducer, initialLoginState);
// stateRef mirrors latest reducer state so async live-event listeners
// (attached once at mount) read current state without their stale
// closure capturing the initial `unknown` snapshot. Used by transcript
// diag gate for `qr_redacted`.
const stateRef = useRef(state);
useEffect(() => {
stateRef.current = state;
}, [state]);
const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]);
useEffect(() => {
document.documentElement.dataset.theme = theme;
}, [theme]);
// Capture-phase click interceptor for `<a target="_blank">` —
// inside Capacitor's Android WebView, cross-origin iframes silently
// drop those clicks (the WebView has no multi-window concept and
// the host's setupExternalLinkHandler can't see them across the
// origin boundary). We preventDefault and ask the host to open the
// URL via Browser.open / window.open. Modifier-clicks pass through
// untouched so users can still Ctrl/Cmd-click for new-tab on web.
useEffect(() => {
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement | null)?.closest?.(
'a[target="_blank"]'
) as HTMLAnchorElement | null;
if (!anchor?.href) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
api.openExternalUrl(anchor.href);
};
document.addEventListener('click', onClick, true);
return () => document.removeEventListener('click', onClick, true);
}, [api]);
const append = useCallback((line: Omit<TranscriptLine, 'id' | 'ts'>) => {
setTranscript((prev) => {
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];
return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next;
});
}, []);
// Newest-at-top — pin scroll to top on each new line.
const transcriptRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = transcriptRef.current;
if (!el) return;
el.scrollTop = 0;
}, [transcript.length]);
// Subscribe to widget-api events for capability handshake completion,
// live events, and theme updates. The `api` itself is constructed in
// main.tsx BEFORE React's first render so its postMessage listener is
// already attached — this effect only wires React state to the api's
// event surface. WidgetApi.on('ready', ...) self-replays if the
// handshake already finished by the time we attach.
useEffect(() => {
let disposed = false;
api.on('ready', () => {
setHandshakeOk(true);
append({ kind: 'diag', text: t('diag.ready') });
append({ kind: 'diag', text: t('diag.checking-status') });
void (async () => {
// Timeline-resume: scan the recent room history BEFORE firing
// ping. Discord's QR flow doesn't have multi-step prompts (no
// phone/code/password ladder), but a reload during an active QR
// scan SHOULD restore the QR panel — otherwise the user reloads,
// sees disconnected, hits «Войти по QR» again, and the bridge
// creates a SECOND remoteauth session in parallel with the first
// (commands.go has no session-deduplication; each call spins a
// fresh remoteauth.Client goroutine). The hydrate path here is
// identical in shape to the TG widget's: pull notices, images,
// and redactions in parallel and feed them chronologically into
// the hydrate reducer.
let hydrated = false;
try {
const settled = await Promise.allSettled([
api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }),
api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.text' }),
// QR images: Discord doesn't rotate, so 10 events is plenty
// (each login attempt produces exactly one m.image). Keep
// headroom for back-history if the user did multiple
// attempts in this room over time.
api.readTimeline({ limit: 10, type: 'm.room.message', msgtype: 'm.image' }),
api.readTimeline({ limit: 10, type: 'm.room.redaction' }),
]);
if (disposed) return;
const pickValue = (s: PromiseSettledResult<RoomEvent[]>): RoomEvent[] =>
s.status === 'fulfilled' ? s.value : [];
const notices = pickValue(settled[0]);
const texts = pickValue(settled[1]);
const qrImages = pickValue(settled[2]);
const redactions = pickValue(settled[3]);
const fromBot = (events: RoomEvent[]) =>
events.filter((e) => e.sender === bootstrap.botMxid);
// Sort by origin_server_ts ascending, tie-break on event_id.
// Without the tie-break, equal-timestamp events from different
// streams could process in nondeterministic order.
const merged = [
...fromBot(notices),
...fromBot(texts),
...fromBot(qrImages),
...fromBot(redactions),
].sort((a, b) => {
const tsDiff = a.origin_server_ts - b.origin_server_ts;
if (tsDiff !== 0) return tsDiff;
return a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0;
});
const inputs: HydrateInput[] = merged.map((e) => ({
ev: parseEvent(e),
ts: e.origin_server_ts,
}));
const restored = hydrateFromTimeline(inputs);
if (restored) {
// Conservative transcript replay. m.image events are replaced
// with a generic «QR-код выдан» diag — never replay the raw
// discord.com/ra/<token> body, that would persist the login
// token in DOM history past the bridge's redaction. Bot
// notices replay verbatim (they're already redacted of
// sensitive data by the bridge).
let appendedAnyHistory = false;
const seenQrIds = new Set<string>();
for (const e of merged) {
if (seenEventIds.current.has(e.event_id)) continue;
seenEventIds.current.add(e.event_id);
const parsed = parseEvent(e);
if (parsed.kind === 'qr_displayed') {
seenQrIds.add(parsed.eventId);
if (parsed.replacesEventId) seenQrIds.add(parsed.replacesEventId);
append({ kind: 'diag', text: t('diag.qr-issued') });
appendedAnyHistory = true;
} else if (parsed.kind === 'qr_redacted') {
if (seenQrIds.has(parsed.redactsEventId)) {
append({ kind: 'diag', text: t('diag.qr-consumed') });
appendedAnyHistory = true;
}
} else if (parsed.kind === 'captcha_challenge') {
// Captcha challenge body carries hCaptcha rqdata/rqtoken
// — short-lived but credential-bearing. Diag-only on
// hydrate too; the live path treats it identically.
append({ kind: 'diag', text: t('diag.captcha-issued') });
appendedAnyHistory = true;
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
// m.text / m.notice — body is safe to replay verbatim,
// BUT we still scrub any login-URL-shaped substring as
// belt-and-suspenders against future bridge wording
// drift that could echo the URL through a notice.
append({
kind: 'from-bot',
text: `${scrubLoginSecret(e.content.body ?? '')}`,
});
appendedAnyHistory = true;
}
}
if (appendedAnyHistory) {
append({ kind: 'diag', text: t('diag.history-marker') });
}
dispatch({ kind: 'hydrate', state: restored });
hydrated = true;
}
} catch {
if (!disposed) {
append({ kind: 'diag', text: t('diag.history-unavailable') });
}
}
if (disposed) return;
if (!hydrated) {
// Discord's status probe is `ping`, not `list-logins`. The reply
// routes through the reducer to disconnected / connected /
// connected_dead.
api.sendCommand('ping').catch((err) => {
if (disposed) return;
append({
kind: 'error',
text: t('diag.send-failed', { message: (err as Error).message }),
});
});
}
})();
});
api.on('themeChange', (name) => setTheme(name));
api.on('liveEvent', (ev: RoomEvent) => {
if (seenEventIds.current.has(ev.event_id)) return;
seenEventIds.current.add(ev.event_id);
// Defense-in-depth sender filter — the host's strict 1:1 invariant
// already guarantees this, but pinning to bootstrap.botMxid prevents
// (a) skipping our own outbound echoes (already appended optimistically),
// (b) third-party noise that somehow slips past the 1:1 invariant.
if (ev.sender !== bootstrap.botMxid) return;
const event = parseEvent(ev);
// Transcript routing is GATED on the parser's verdict, not raw event
// type. Same logic as TG widget: m.image bodies are NEVER appended
// verbatim (they ARE the login secret); QR-redaction diag fires only
// for the active QR.
if (event.kind === 'qr_displayed') {
append({ kind: 'diag', text: t('diag.qr-issued') });
} else if (event.kind === 'qr_redacted') {
const liveState = stateRef.current;
if (
liveState.kind === 'awaiting_qr_scan' &&
liveState.qrEventId === event.redactsEventId
) {
append({ kind: 'diag', text: t('diag.qr-consumed') });
}
} else if (event.kind === 'captcha_challenge') {
// The raw body carries Discord's hCaptcha sitekey + rqdata + rqtoken
// — short-lived but credential-bearing. Don't echo into the
// transcript DOM (where screenshots / accessibility tools could
// leak them). Diag-only display.
append({ kind: 'diag', text: t('diag.captcha-issued') });
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
const body = ev.content.body ?? '';
append({ kind: 'from-bot', text: `${scrubLoginSecret(body)}` });
}
dispatch({ kind: 'event', event });
// Fire `ping` after lifecycle transitions that need authoritative
// state reconciliation:
// * login_success — the success line lacks the discordId; ping
// picks it up so the connected pill can show the snowflake.
// * reconnect_ok / reconnect_no_op — flips us back into connected
// but with potentially-stale handle; ping refreshes.
// * already_logged_in — bridge says we tried login while already
// in. Without a re-ping the QR-form stays open with a warn
// banner forever (no QR will ever come because the bridge
// bails before remoteauth.New). Re-pinging routes us to the
// connected pill so the user can click logout if they wanted
// a fresh login.
if (
event.kind === 'login_success' ||
event.kind === 'reconnect_ok' ||
event.kind === 'reconnect_no_op' ||
event.kind === 'already_logged_in'
) {
api.sendCommand('ping').catch(() => {
/* surface in diag is overkill; the connected pill still shows
the handle even without the snowflake */
});
}
});
append({ kind: 'diag', text: t('diag.connecting') });
return () => {
disposed = true;
api.dispose();
};
// `api`, `bootstrap`, `t`, and `append` are stable for the App's lifetime.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Outbound bare-command + transcript echo. Errors append to transcript
// AND rethrow — callers decide whether to roll back optimistic transitions.
// `api` is a stable singleton owned by main.tsx; closing over it directly
// is safe (the App's lifetime is the iframe's, and api.dispose() in the
// unmount cleanup makes any in-flight sends fail loudly).
const sendBare = useCallback(
async (command: string): Promise<void> => {
append({ kind: 'from-user', text: `${command}` });
try {
await api.sendCommand(command);
} catch (err) {
append({
kind: 'error',
text: t('diag.send-failed', { message: (err as Error).message }),
});
throw err;
}
},
[append, api, t]
);
// Specialised send for hCaptcha tokens. The token is a single-use
// credential — we send it to the bridge but redact the body in the
// user-visible transcript so it doesn't sit in DOM (and screenshots /
// accessibility tools can't see it). The error path keeps the same
// shape as `sendBare` so the caller can `try { await … } catch { roll back }`.
const sendCaptchaToken = useCallback(
async (token: string): Promise<void> => {
append({ kind: 'from-user', text: '→ login-captcha [redacted]' });
try {
await api.sendCommand(`login-captcha ${token}`);
} catch (err) {
append({
kind: 'error',
text: t('diag.send-failed', { message: (err as Error).message }),
});
throw err;
}
},
[append, api, t]
);
// In-flight guard against double-tap. The button is on the disconnected
// screen which unmounts as soon as state advances, BUT a rapid second
// click can fire in the microtask window between dispatch and the next
// Preact commit (especially on Android WebView, where a tap-rebound can
// synthesise a second click). For login-qr, a duplicate would spin a
// SECOND remoteauth goroutine on the bridge in parallel — harmless but
// wastes a remoteauth session.
const loginInFlight = useRef(false);
const onClickLoginQr = useCallback(async () => {
if (loginInFlight.current) return;
loginInFlight.current = true;
dispatch({ kind: 'start_qr_login' });
try {
await sendBare('login-qr');
} catch {
dispatch({ kind: 'cancel_pending' });
} finally {
loginInFlight.current = false;
}
}, [sendBare]);
// Cancel is LOCAL — Discord legacy mautrix has no `cancel` command.
// Returns the widget to disconnected; the bridge's remoteauth goroutine
// continues until success / failure / internal timeout.
const onClickCancel = useCallback(() => {
dispatch({ kind: 'cancel_pending' });
}, []);
// Captcha solve callback — fires from hCaptcha's own iframe. We dispatch
// `submit_captcha_token` first (transition to qr_verifying immediately so
// the user sees a spinner), then send `login-captcha <token>` to the
// bridge. The bridge re-fires Discord's RemoteAuthLogin with the
// X-Captcha-Key header attached and replies via the live event stream.
const onCaptchaSolved = useCallback(
async (token: string) => {
dispatch({ kind: 'submit_captcha_token' });
try {
await sendCaptchaToken(token);
} catch {
// Couldn't deliver to the bridge — roll the optimistic
// qr_verifying back to disconnected with a localized error.
// Don't auto-fall-through to ping: the failure is local
// (transport), not a bridge state mismatch, and a stale ping
// would mask it.
dispatch({ kind: 'captcha_send_failed' });
}
},
[sendCaptchaToken]
);
// hCaptcha challenge expired (server-side rqtoken TTL or 90s local
// timer). Tell the user with a localized warn rather than letting them
// solve a dead challenge.
const onCaptchaExpired = useCallback(() => {
dispatch({ kind: 'captcha_expired' });
}, []);
const onClickRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
const start = Date.now();
try {
await sendBare('ping');
} catch {
/* transcript carries the failure */
}
// 500 ms minimum visible loading state — without this, a fast healthy
// transport (<100ms round-trip) skips a paint frame entirely and the
// click goes visually unacknowledged.
const elapsed = Date.now() - start;
if (elapsed < 500) {
await new Promise<void>((resolve) => {
window.setTimeout(resolve, 500 - elapsed);
});
}
setRefreshing(false);
}, [refreshing, sendBare]);
const onConfirmLogout = useCallback(async () => {
dispatch({ kind: 'request_logout' });
try {
await sendBare('logout');
} catch {
// Recovery: refire ping so the reducer recalibrates from bridge truth
// instead of leaving the UI stuck in logging_out forever.
sendBare('ping').catch(() => {
/* user can hit refresh */
});
}
}, [sendBare]);
const onClickReconnect = useCallback(async () => {
// Carry the current handle through `reconnecting` so the post-reconnect
// success path can flip directly to `connected{handle}` without
// bouncing through `unknown`. The handle is read from whichever
// pre-reconnect state we're in (connected_dead is the typical
// entry, but a manual disconnect path could leave us in connected
// and trigger reconnect from there).
const handle =
state.kind === 'connected_dead' || state.kind === 'connected'
? state.handle
: undefined;
dispatch({ kind: 'request_reconnect', handle });
try {
await sendBare('reconnect');
} catch {
sendBare('ping').catch(() => {
/* user can hit refresh */
});
}
}, [sendBare, state]);
// Convenience: render a status pill with optional recovery button.
type StatusRowProps = {
tone: 'connected' | 'disconnected' | 'checking';
label: string;
recovery?: { label: string; icon?: ComponentChildren; onClick: () => void; disabled?: boolean };
};
const StatusRow = ({ tone, label, recovery }: StatusRowProps) => {
const pill = (
<span class={`section-status ${tone}`} role="status">
<span class="dot" />
{label}
</span>
);
if (!recovery) return pill;
return (
<div class="section-recovery-row">
{pill}
<button
type="button"
class="recovery-action"
onClick={recovery.onClick}
disabled={recovery.disabled}
>
{recovery.icon ?? <RefreshIcon />}
{recovery.label}
</button>
</div>
);
};
return (
<div class="app">
{/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no
* longer renders an avatar/name/handle/description block — the host
* panel above the iframe carries that information plus the
* three-dots menu. «О боте» lives HERE in the widget body so it
* sits adjacent to the login/logout actions it explains. */}
{handshakeOk && state.kind === 'unknown' ? (
<section class="section">
<StatusRow
tone="checking"
label={t('status.unknown')}
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
/>
</section>
) : null}
{handshakeOk && state.kind === 'disconnected' ? (
<section class="section">
<StatusRow tone="disconnected" label={t('status.disconnected')} />
{state.lastError ? (
<div
class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}
style={{ marginBottom: '14px' }}
>
{localizeError(state.lastError, t)}
</div>
) : null}
<div class="command-grid">
<button class="command-card" type="button" onClick={onClickLoginQr}>
<span class="command-card-lead-icon" aria-hidden="true">
<QrIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.login-qr.name')}</div>
<div class="command-card-desc">{t('card.login-qr.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
<button
class={`command-card${refreshing ? ' refreshing' : ''}`}
type="button"
onClick={onClickRefresh}
disabled={refreshing}
>
<span class="command-card-lead-icon" aria-hidden="true">
<RefreshIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.refresh.name')}</div>
<div class="command-card-desc">
{refreshing ? t('card.refresh.in-flight') : t('card.refresh.desc')}
</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div>
</section>
) : null}
{state.kind === 'awaiting_qr_scan' ? (
<section class="section">
<QrPanel state={state} t={t} onCancel={onClickCancel} />
</section>
) : null}
{state.kind === 'awaiting_captcha_solve' ? (
<section class="section">
<CaptchaPanel
state={state}
t={t}
onSolved={onCaptchaSolved}
onCancel={onClickCancel}
onExpired={onCaptchaExpired}
/>
</section>
) : null}
{state.kind === 'qr_verifying' ? (
<section class="section">
<StatusRow
tone="checking"
label={t('status.qr-verifying')}
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
/>
</section>
) : null}
{state.kind === 'logging_out' ? (
<section class="section">
<StatusRow
tone="checking"
label={t('status.logging-out')}
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
/>
</section>
) : null}
{state.kind === 'reconnecting' ? (
<section class="section">
<StatusRow
tone="checking"
label={t('status.reconnecting')}
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
/>
</section>
) : null}
{state.kind === 'connected' ? (
<section class="section">
<StatusRow
tone="connected"
label={
state.handle
? t('status.connected-as', { handle: state.handle })
: t('status.connected')
}
/>
<div class="command-grid">
<LogoutCard t={t} onConfirm={onConfirmLogout} />
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div>
</section>
) : null}
{state.kind === 'connected_dead' ? (
<section class="section">
<StatusRow
tone="checking"
label={
state.reason === 'connection_dead'
? t('status.connection-dead')
: t('status.token-stored')
}
/>
<div class="command-grid">
{/* Reconnect — primary action for this state. The button uses
* the same command-card chrome so it visually matches Login /
* Logout cards. */}
<button class="command-card" type="button" onClick={onClickReconnect}>
<span class="command-card-lead-icon" aria-hidden="true">
<LinkIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.reconnect.name')}</div>
<div class="command-card-desc">{t('card.reconnect.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
<LogoutCard t={t} onConfirm={onConfirmLogout} />
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
</div>
</section>
) : null}
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}
<section class="section">
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
{transcript.length === 0 ? (
<div class="transcript-empty">{/* placeholder */}</div>
) : (
transcript
.slice()
.reverse()
.map((line) => (
<div key={line.id} class={`transcript-line ${line.kind}`}>
<span class="ts">{formatTime(line.ts)}</span>
<span class="body">
{line.kind === 'from-bot' ? renderBody(line.text) : line.text}
</span>
</div>
))
)}
</div>
</section>
</div>
);
}