vojo/apps/widget-discord/src/App.tsx

965 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
// 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 '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 <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>
);
};
// --------------------------------------------------------------------------
// About card + modal
// --------------------------------------------------------------------------
type AboutCardProps = {
t: T;
onOpen: () => void;
};
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card" type="button" onClick={onOpen}>
<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)}>
<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]);
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 (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<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]
);
// 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<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}>
<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}
>
<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">
<RefreshIcon />
</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 === '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}>
<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>
);
}