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 = () => (
);
// 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 = () => (
);
// Three QR finder squares + a few module dots — leads the QR-login
// card. Same shape across all three bot widgets.
const QrIcon = () => (
);
// 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 = () => (
);
// 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 = () => (
);
// 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(
{match[0]}
);
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 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(
);
}
}
return (
);
};
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 (
{t('auth-card.qr.title')}
{t('auth-card.qr.hint')}
{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.
) : (
);
};
// --------------------------------------------------------------------------
// 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 ` 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 | null = null;
const loadHCaptcha = (): Promise => {
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((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(null);
const widgetIdRef = useRef(null);
const [loadError, setLoadError] = useState(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(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(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
// 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 (