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 ( {rects} ); }; 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. ) : (
{t('auth-card.qr.preparing')}
)}
{!expired ? (
{t('auth-card.qr.countdown', { minutes: String(Math.floor(remainingSeconds / 60)), seconds: String(remainingSeconds % 60).padStart(2, '0'), })}
) : (
{t('auth-card.qr.expired')}
)}
  1. {t('auth-card.qr.step-1')}
  2. {t('auth-card.qr.step-2')}
  3. {t('auth-card.qr.step-3')}
{state.lastError ? (
{localizeError(state.lastError, t)}
) : null}
); }; // -------------------------------------------------------------------------- // About card + modal // -------------------------------------------------------------------------- type AboutCardProps = { t: T; onOpen: () => void; }; const AboutCard = ({ t, onOpen }: AboutCardProps) => ( ); 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 ( ); }; // -------------------------------------------------------------------------- // 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 ? (
{localizeError(state.lastError, t)}
) : null}
setAboutOpen(true)} />
) : null} {state.kind === 'awaiting_qr_scan' ? (
) : null} {state.kind === 'qr_verifying' ? (
) : null} {state.kind === 'logging_out' ? (
) : null} {state.kind === 'reconnecting' ? (
) : null} {state.kind === 'connected' ? (
setAboutOpen(true)} />
) : null} {state.kind === 'connected_dead' ? (
{/* Reconnect — primary action for this state. The button uses * the same command-card chrome so it visually matches Login / * Logout cards. */} setAboutOpen(true)} />
) : null} {aboutOpen ? setAboutOpen(false)} /> : null}
{transcript.length === 0 ? (
{/* placeholder */}
) : ( transcript .slice() .reverse() .map((line) => (
{formatTime(line.ts)} {line.kind === 'from-bot' ? renderBody(line.text) : line.text}
)) )}
); }