1417 lines
54 KiB
TypeScript
1417 lines
54 KiB
TypeScript
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>
|
||
);
|
||
}
|