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 = () => (
);
// 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 '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 === '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.
) : (