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.
) : (
);
};
// --------------------------------------------------------------------------
// Logout card with confirm-in-place
// --------------------------------------------------------------------------
type LogoutCardProps = {
t: T;
onConfirm: () => Promise;
};
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 (
{t('card.logout.confirm-prompt')}
);
}
return (
);
};
// --------------------------------------------------------------------------
// Main App
// --------------------------------------------------------------------------
export function App({ bootstrap, api }: Props) {
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
const [transcript, setTranscript] = useState([]);
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());
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]);
const append = useCallback((line: Omit) => {
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(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[] =>
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/ 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();
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 (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 (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 => {
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]
);
// 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' });
}, []);
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((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 = (
{label}
);
if (!recovery) return pill;
return (
{pill}
);
};
return (
{/* 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' ? (
) : null}
{handshakeOk && state.kind === 'disconnected' ? (
{state.lastError ? (
{/* Reconnect — primary action for this state. The button uses
* the same command-card chrome so it visually matches Login /
* Logout cards. */}
setAboutOpen(true)} />