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

1459 lines
56 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 { Dispatch } 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, type StringKey } from './i18n';
import { parseEvent } from './bridge-protocol/parser';
import {
hydrateFromTimeline,
initialLoginState,
loginReducer,
type HydrateInput,
type LoginAction,
type LoginErrorFlag,
type LoginState,
} from './state';
// Visual canon mirrors the Telegram and Discord widgets — Dawn palette,
// fleet-violet accent, monospace handles. We DO NOT adopt WhatsApp's
// signature green; the panel is meant to read as a coherent continuation
// of the host UI ("Vojo style"), not a WhatsApp clone.
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;
// 8 s — between submit and bot reply on the phone form. Applies only to
// the pairing-code phone form because there's no separate "code" form
// like Telegram has (WhatsApp's pairing-code is shown by the bridge,
// not typed by the user).
const STILL_WAITING_DELAY_MS = 8_000;
// Inline SVG refresh icon — shared with TG / Discord widgets.
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>
);
// Smartphone outline — leads the «Войти по номеру» card. Shared shape
// across TG (text-login) and WhatsApp (pairing-code login) since both
// flows boil down to «вы вводите номер с телефона».
const PhoneIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
</svg>
);
// Three QR finder squares + a few module dots — leads every QR-login
// card. The finder pattern is the strongest «this is QR» visual cue;
// no need to draw a full code.
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». Picks up the
// rose tint via `.command-card.danger` cascade only on the title;
// the lead icon stays muted so the rose stays a single accent.
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>
);
// Triangle warning glyph — leads the WhatsApp-only AboutCard (which
// carries `command-card warn` for the amber outline) and re-appears
// inside the AboutModal's risk-disclosure callout. Stroke-only so it
// picks up the amber tint via `currentColor` in either context.
const WarningIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M10 8.5 L10 12" stroke-linecap="round" />
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
</svg>
);
// Linkifier — same heuristic as TG / Discord widgets.
const URL_RE = /https?:\/\/[^\s)]+/g;
// WA-only: defence-in-depth scrub of any whatsmeow QR payload from text
// before appending to the transcript. Today the bridge only emits the
// payload via `m.image` (which we explicitly route to a generic
// «QR-код обновлён» diag, never verbatim), but if a future bridge
// revision starts echoing the payload into m.notice — say for a
// chat-fallback debug surface — the existing transcript append would
// store the adv-secret segment in the DOM. The adv-secret IS the live
// cryptographic material the phone signs to prove possession; even one
// accidental transcript line would survive page reloads via the hydrate
// replay. Keep this scrubber in sync with the parser's WA_QR_PAYLOAD_RE
// (bridgev2_v0264.ts) — they describe the same upstream shape.
//
// Anchoring rationale (frontend review #1): the upstream `makeQRData`
// always prefixes the first field with `<digit>@<base64>` (e.g.
// `2@AbCd...` — the leading digit is the protocol generation, currently
// `2`). The ref field is also always at least 16 chars long, and the
// three trailing base64 fields hover around 24-44 chars each. We
// therefore require:
// - leading `\d@` to anchor on the unmistakable WA-protocol prefix,
// - each segment to be at least 8 chars long.
// That's narrow enough that an unrelated body matching the shape by
// accident is implausible, while still tolerant of future protocol
// version-bumps from `2@` to e.g. `3@`. Reject patterns are e.g.
// «error: a,b,c,d in field» — without the digit prefix and the segment
// length floor, the old regex would clobber that.
const WA_QR_PAYLOAD_GLOBAL_RE =
/\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
const scrubLoginSecret = (body: string): string =>
body.replace(WA_QR_PAYLOAD_GLOBAL_RE, '[redacted QR payload]');
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}`;
};
// Build transcript children from a body string with naive URL linkification.
// Plain markdown formatting from the bot (backticks, asterisks) shows
// literal — the upstream wording isn't load-bearing on rendering, and
// re-implementing markdown here is a worse trade-off than rendering
// raw.
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;
};
// WhatsApp's pairing-code flow only takes the phone number as user
// input — the code itself is shown by the bridge, not typed by the
// user, so user-typed values that hit the transcript never need
// masking. Telegram-style mask helpers were intentionally not ported.
const localizeError = (err: LoginErrorFlag, t: T): string => {
switch (err.kind) {
case 'login_failed':
return t('auth-error.login-failed', { reason: err.reason ?? '' });
case 'invalid_value':
return t('auth-error.invalid-value', { reason: err.reason ?? '' });
case 'submit_failed':
return t('auth-error.submit-failed', { reason: err.reason ?? '' });
case 'login_in_progress':
return t('auth-error.login-in-progress');
case 'max_logins':
return t('auth-error.max-logins', { limit: String(err.limit ?? '?') });
case 'unknown_command':
return t('auth-error.unknown-command');
case 'start_failed':
return t('auth-error.start-failed', { reason: err.reason ?? '' });
case 'prepare_failed':
return t('auth-error.prepare-failed', { reason: err.reason ?? '' });
case 'external_logout': {
const subKey: StringKey =
err.reason === 'another_device'
? 'auth-error.external-logout.another-device'
: err.reason === 'phone_logged_out'
? 'auth-error.external-logout.phone-logged-out'
: 'auth-error.external-logout.unknown';
return t(subKey);
}
default: {
const exhaustive: never = err;
return String(exhaustive);
}
}
};
// Centralised tone affordance: red `.auth-card-error` vs amber
// `.auth-card-warn`. WhatsApp-side soft errors (rate-limited, retry-able
// pairing failure) are warnings; hard validation errors are red.
// `external_logout` is special — it's surfaced as a top-level banner,
// not a form-side error tone.
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
if (err.kind === 'submit_failed') return 'warn';
if (err.kind === 'login_in_progress') return 'warn';
return 'error';
};
// --------------------------------------------------------------------------
// Phone form (pairing-code flow only)
// --------------------------------------------------------------------------
type FormProps = {
state: LoginState;
t: T;
dispatch: Dispatch<LoginAction>;
send: (body: string) => Promise<void>;
sendCancel: () => Promise<void>;
// Phone-form cooldown plumbing — lifted to App so it survives the form's
// unmount on Cancel + remount on a fresh «Войти по коду» click.
phoneCooldownEnd: number | null;
setPhoneCooldownEnd: (ts: number | null) => void;
};
// Phone-submit cooldown — WhatsApp throttles repeated pairing-code
// requests at the connector level (`Rate limited by WhatsApp`).
// 60 s matches the rough recovery window that observed flood replies
// stop firing after.
const PHONE_COOLDOWN_MS = 60_000;
const useCooldownSeconds = (until: number | null): number => {
const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
const [seconds, setSeconds] = useState(compute);
useEffect(() => {
if (!until) {
setSeconds(0);
return undefined;
}
setSeconds(compute());
const timer = window.setInterval(() => {
const next = Math.max(0, Math.ceil((until - Date.now()) / 1000));
setSeconds(next);
if (next <= 0) window.clearInterval(timer);
}, 1000);
return () => window.clearInterval(timer);
// The deps array is intentionally controlled: re-run only when the
// future timestamp itself changes. `compute` would otherwise change
// every render and re-trigger setInterval.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [until]);
return seconds;
};
const useStillWaitingHint = (deps: ReadonlyArray<unknown>): boolean => {
const [show, setShow] = useState(false);
useEffect(() => {
setShow(false);
const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return show;
};
const PhoneForm = ({
state,
t,
dispatch,
send,
sendCancel,
phoneCooldownEnd,
setPhoneCooldownEnd,
}: FormProps) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const stillWaiting = useStillWaitingHint([submitting]);
const cooldownSeconds = useCooldownSeconds(phoneCooldownEnd);
const inCooldown = cooldownSeconds > 0;
const error = state.kind === 'awaiting_phone' ? state.lastError : undefined;
useEffect(() => {
inputRef.current?.focus();
}, []);
const onSubmit = async (event: Event) => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting || inCooldown) return;
setSubmitting(true);
dispatch({ kind: 'submit_phone' });
try {
await send(trimmed);
// Cooldown locks retries ONLY after the Matrix transport accepted
// the message. If `await send` threw (network down, capability
// race), no pairing-code request was attempted at the WhatsApp
// side — punishing the user for an issue they can fix by clicking
// again would be wrong. The cooldown is also cleared by the App
// when the bridge replies with `invalid_value` (malformed phone
// — bridgev2 rejects before WhatsApp dispatch).
setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
} catch {
/* transcript carries the diagnostic; form stays open for retry */
} finally {
setSubmitting(false);
}
};
const tone = error ? errorTone(error) : undefined;
const submitDisabled = submitting || inCooldown || value.trim() === '';
const submitLabel = inCooldown
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
: t('auth-card.phone.submit');
return (
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.phone.title')}</div>
<label class="auth-card-hint" for="auth-phone-input">
{t('auth-card.phone.label')}
</label>
<div class="auth-card-row">
<input
id="auth-phone-input"
ref={inputRef}
class="auth-input"
type="tel"
autocomplete="tel"
inputmode="tel"
placeholder={t('auth-card.phone.placeholder')}
value={value}
onInput={(e) => {
// Auto-prepend `+` so the user never has to remember to type
// it — the connector's PHONE_NUMBER_NOT_INTERNATIONAL error
// fires for anything without a leading `+` (whatsmeow
// PairPhone's validator). Skipping locale-specific
// formatting (8→+7 etc.) keeps the rule single-line.
//
// trimStart on the raw input so that a paste of « +12345…»
// (some clipboard sources include a leading space) still
// resolves to a single `+`, instead of producing the
// double-prefix `+ +12345…` bridgev2 then rejects.
const raw = (e.currentTarget as HTMLInputElement).value.trimStart();
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
}}
disabled={submitting}
/>
<button type="submit" class="btn-primary" disabled={submitDisabled}>
{submitLabel}
</button>
<button type="button" class="btn-text" onClick={sendCancel}>
{t('auth-card.cancel')}
</button>
</div>
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
{error ? (
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
{localizeError(error, t)}
</div>
) : null}
{submitting && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
// --------------------------------------------------------------------------
// QR panel
// --------------------------------------------------------------------------
// Whatsmeow's QR rotation schedule (verified upstream pair.go::qrIntervals):
// first QR: 60 s
// QRs 2..6: 20 s each → 5 × 20 s = 100 s
// Total active window: 160 s = 2 min 40 s. After the last QR, the
// bridge surfaces `Login failed: Entering code or scanning QR timed
// out. Please try again.` We render a soft timeout countdown (3 min,
// matching HYDRATE_FRESHNESS_MS so a reload past the panel-expiry
// also can't restore the dead flow), NOT a hard kill — when it expires
// the panel switches to a recovery hint.
const QR_TIMEOUT_MS = 3 * 60 * 1000;
// Error-correction level M — same trade-off as TG/Discord (more
// resilient to camera glare than L, smaller modules than Q).
// typeNumber=0 auto-picks the smallest version that fits the payload;
// for a whatsmeow handshake (~140 chars) this lands around version 7-8.
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 whatsmeow handshake IS the login secret (the adv-secret field is
// what the phone signs to prove possession), 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';
qrData: string;
firstShownAt: number;
lastError?: LoginErrorFlag;
};
t: T;
sendCancel: () => Promise<void>;
};
const QrPanel = ({ state, t, sendCancel }: 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.qrData), [state.qrData]);
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 ? (
<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={sendCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};
// --------------------------------------------------------------------------
// Pairing-code panel
// --------------------------------------------------------------------------
// Pairing code server-side validity at WhatsApp's gateway is roughly the
// same as QR (~3 minutes — verified empirically against whatsmeow
// PairPhone behaviour, no explicit constant in the lib). We share the
// same 3-min window as QR_TIMEOUT_MS / HYDRATE_FRESHNESS_MS so reload
// past expiry can't restore a dead flow.
const PAIRING_CODE_TIMEOUT_MS = 3 * 60 * 1000;
type PairingCodePanelProps = {
state: {
kind: 'pairing_code_shown';
code: string;
firstShownAt: number;
lastError?: LoginErrorFlag;
};
t: T;
sendCancel: () => Promise<void>;
};
const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
const remainingSeconds = Math.max(
0,
Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)
);
const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0;
return (
<div class="auth-card auth-card-pairing">
<div class="auth-card-title">{t('auth-card.pairing-code.title')}</div>
<div class="auth-card-hint">{t('auth-card.pairing-code.hint')}</div>
<div class="auth-card-pairing-frame">
{state.code ? (
// <output> is the semantic element for "result of a process"
// (HTML form-results spec); screenreaders read the digits from
// its text content. The supplemental purpose-description is
// attached via aria-describedby on a hidden sibling so it
// doesn't override the digits in screenreader output (frontend
// review #2: a `<div>` with both aria-label and visible text
// can hide the digits behind the label in NVDA/JAWS).
// user-select: all on the text element keeps one-tap copy
// working on touch devices.
<>
<output
class="auth-card-pairing-code-text"
aria-describedby="auth-pairing-code-desc"
>
{state.code}
</output>
<span id="auth-pairing-code-desc" class="visually-hidden">
{t('auth-card.pairing-code.aria')}
</span>
</>
) : (
<div class="auth-card-pairing-placeholder" role="status" aria-live="polite">
<span class="dot" />
{t('auth-card.pairing-code.preparing')}
</div>
)}
</div>
{!expired ? (
<div class="auth-card-countdown">
{t('auth-card.pairing-code.countdown', {
minutes: String(Math.floor(remainingSeconds / 60)),
seconds: String(remainingSeconds % 60).padStart(2, '0'),
})}
</div>
) : (
<div class="auth-card-countdown expired">
{t('auth-card.pairing-code.expired')}
</div>
)}
<ol class="auth-card-pairing-steps">
<li>{t('auth-card.pairing-code.step-1')}</li>
<li>{t('auth-card.pairing-code.step-2')}</li>
<li>{t('auth-card.pairing-code.step-3')}</li>
<li>{t('auth-card.pairing-code.step-4')}</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={sendCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};
// --------------------------------------------------------------------------
// About card + modal
// --------------------------------------------------------------------------
type AboutCardProps = {
t: T;
onOpen: () => void;
};
// WhatsApp-only: AboutCard carries the `warn` modifier so the amber
// border + amber-tinted name signal that the modal behind it includes
// the Meta-ToS risk disclosure. The leading icon is the triangle
// WarningIcon (not the info-circle used by TG / Discord) for the
// same reason — the hybrid card description («о работе и рисках»)
// stays believable when the icon previews the «risks» half.
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
<button class="command-card warn" type="button" onClick={onOpen}>
<span class="command-card-lead-icon" aria-hidden="true">
<WarningIcon />
</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">
{/* WhatsApp-specific Meta-ToS account-ban risk disclosure.
* Lives at the top of the About modal as an amber-tinted
* block — same content the dedicated WarningCard used to
* carry on the disconnected screen, folded in here so the
* About card is the single info entry point (matches the
* TG / Discord shape; the amber block carries the «risks»
* half of the hybrid card description). */}
<aside class="about-warn-callout" aria-label={t('warning.title')}>
<header class="about-warn-callout-head">
<span class="about-warn-callout-icon" aria-hidden="true">
<WarningIcon />
</span>
<h3 class="about-warn-callout-title">{t('warning.title')}</h3>
</header>
<p>{t('warning.body-1')}</p>
<p>
{t('warning.tos-label')}{' '}
<a href={t('warning.tos-url')} target="_blank" rel="noreferrer noopener">
{t('warning.tos-url')}
</a>
</p>
</aside>
<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 noopener">
{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 = {
loginId: string | undefined;
t: T;
onConfirm: (loginId: string) => Promise<void>;
};
const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => {
const [confirming, setConfirming] = useState(false);
const [submitting, setSubmitting] = useState(false);
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={!loginId || submitting}
onClick={async () => {
if (!loginId || inFlight.current) return;
inFlight.current = true;
setSubmitting(true);
try {
await onConfirm(loginId);
} 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)}
disabled={!loginId}
title={!loginId ? t('card.logout.gated') : undefined}
>
<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);
const [refreshing, setRefreshing] = useState(false);
const seenEventIds = useRef(new Set<string>());
const [state, dispatch] = useReducer(loginReducer, initialLoginState);
// Mirror latest state for async callbacks (live event listeners attached
// once at mount). Used for the QR-redaction transcript gate (only show
// «QR использован» when the redaction targets the active QR).
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. The host's web-side fallback
// (allow-popups in the iframe sandbox) still works without us, but
// routing every click through the host gives one consistent code
// path for both web and native instead of two race-prone ones.
useEffect(() => {
const onClick = (e: MouseEvent) => {
const anchor = (e.target as HTMLElement | null)?.closest?.(
'a[target="_blank"]'
) as HTMLAnchorElement | null;
if (!anchor?.href) return;
// Allow modifier-clicks (Ctrl/Cmd-click → open in background tab,
// Shift-click → new window) to keep their browser-native
// behaviour on web. preventDefault would override these.
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 ordering: pin scroll to TOP whenever a new line lands.
const transcriptRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = transcriptRef.current;
if (!el) return;
el.scrollTop = 0;
}, [transcript.length]);
// App-level cooldown state for phone-form Send Code button.
const [phoneCooldownEnd, setPhoneCooldownEnd] = useState<number | null>(null);
// Clear the cooldown across two distinct triggers:
// 1. Bridge rejected the phone number client-side (`invalid_value`)
// — the value was rejected at validate-time, BEFORE the WhatsApp
// dispatch, so no rate-limited capacity was used. Punishing the
// user for a typo would be wrong. The cooldown stays in place
// for `submit_failed` (WhatsApp-side rate limit etc.).
// 2. State left awaiting_phone via cancel / logout / login_success
// — the previous flow ended; a fresh login attempt is a fresh
// conversation (functional review #9). Clearing on every
// non-form / non-pairing-form state covers that without
// enumerating individual transitions.
useEffect(() => {
if (phoneCooldownEnd === null) return;
const formStillOpen =
state.kind === 'awaiting_phone' ||
state.kind === 'awaiting_pairing_code' ||
state.kind === 'pairing_code_shown';
const phoneInvalidValue =
state.kind === 'awaiting_phone' && state.lastError?.kind === 'invalid_value';
if (!formStillOpen || phoneInvalidValue) {
setPhoneCooldownEnd(null);
}
}, [state, phoneCooldownEnd]);
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: read recent history BEFORE firing
// list-logins so reload-mid-flow restores the pending form
// (phone, pairing-code, QR) the user actually had open.
let hydrated = false;
try {
// Promise.allSettled (not all): one stream's failure must not
// take down the others. The notice path is the most useful
// single source — it carries phone-prompt, pairing-code
// instructions, the code itself, and login-success confirms.
const settled = await Promise.allSettled([
api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }),
// QR images: whatsmeow rotates ~every 20 s after the first
// 60 s. Total active window 2 min 40 s = 6 events. Limit 50
// gives plenty of headroom for slower rotations and
// out-of-order delivery.
api.readTimeline({ limit: 50, 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 qrImages = pickValue(settled[1]);
const redactions = pickValue(settled[2]);
const fromBot = (events: RoomEvent[]) =>
events.filter((e) => e.sender === bootstrap.botMxid);
// Sort by origin_server_ts ascending, tie-break on event_id —
// see widget-telegram for full rationale of deterministic
// tie-breaking on simultaneous events from different streams.
const merged = [...fromBot(notices), ...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. Body for m.image is the
// raw whatsmeow QR payload (login secret) — replaced with a
// generic «QR обновлён» diag. Body for m.notice carrying a
// pairing-code is replaced with a generic «Код для входа
// выдан» diag for the same defence-in-depth reason: even
// though the code is shown in the panel, the transcript
// shouldn't carry a copy that survives a code rotation.
// m.text user echoes are NOT replayed (would resurface the
// user's phone number from history).
//
// Dedupe via seenEventIds: a live event for the same
// notice/image/redaction may already have arrived during
// the readTimeline await.
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 === 'pairing_code_displayed') {
// Don't echo the code itself — the panel handles
// display, transcript stays neutral.
append({ kind: 'diag', text: t('diag.pairing-code-issued') });
appendedAnyHistory = true;
} else if (parsed.kind === 'connection_warning') {
append({
kind: 'diag',
text: t('diag.connection-warning', { text: parsed.text }),
});
appendedAnyHistory = true;
} else if (parsed.kind === 'external_logout') {
append({ kind: 'error', text: t('diag.external-logout') });
appendedAnyHistory = true;
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
// m.text / m.notice — body is safe to replay verbatim
// AFTER scrubbing any QR-shaped substring (defence-in-
// depth: a future bridge could in theory leak the
// payload into a notice).
const bodyRaw = e.content.body ?? '';
append({ kind: 'from-bot', text: `${scrubLoginSecret(bodyRaw)}` });
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) {
api.sendCommand('list-logins').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);
// Sender filter — the strict 1:1 invariant already pins the only
// senders to user + bot, but anchoring on bootstrap.botMxid covers
// (a) skipping our own outbound echoes (we append optimistically
// with masking) and (b) defence-in-depth against any third-party
// noise.
if (ev.sender !== bootstrap.botMxid) return;
const event = parseEvent(ev);
// Transcript routing GATED on parser verdict, not raw event type.
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 === 'pairing_code_displayed') {
// Same scrubbing principle as QR — never put the code body
// verbatim into the transcript. Panel renders the code; here
// we just log a neutral diag.
append({ kind: 'diag', text: t('diag.pairing-code-issued') });
} else if (event.kind === 'connection_warning') {
// Bridge connection hiccup — surface verbatim wording so the
// user can see what happened, but DON'T touch state.
append({
kind: 'diag',
text: t('diag.connection-warning', { text: event.text }),
});
} else if (event.kind === 'external_logout') {
// Hard transition (handled by reducer below) + a louder
// transcript echo than ordinary diag lines.
append({ kind: 'error', text: t('diag.external-logout') });
} 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 });
// After a fresh login_success the bridge's success line doesn't
// include the loginId. Re-run list-logins so the reducer's
// `connected` state can pick up the loginId for the future
// logout call.
if (event.kind === 'login_success') {
api.sendCommand('list-logins').catch(() => {
/* connected hero still works without a loginId until the
user clicks logout; the gated tooltip guides them */
});
}
});
append({ kind: 'diag', text: t('diag.connecting') });
return () => {
disposed = true;
api.dispose();
};
// `api`, `bootstrap`, `t`, and `append` are stable for App's
// lifetime; the effect intentionally runs once at mount.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Outbound command + transcript echo. Errors are appended AND rethrown
// — callers decide whether to roll back optimistic state transitions.
const send = useCallback(
async (body: string): Promise<void> => {
append({ kind: 'from-user', text: `${body}` });
try {
await api.sendCommand(body);
} catch (err) {
append({
kind: 'error',
text: t('diag.send-failed', { message: (err as Error).message }),
});
throw err;
}
},
[api, append, t]
);
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;
}
},
[api, append, t]
);
const sendCancel = useCallback(async () => {
dispatch({ kind: 'cancel_pending' });
try {
await sendBare('cancel');
} catch {
/* already showing disconnected; transcript carries the failure */
}
}, [sendBare]);
// In-flight guard against double-tap. The buttons are 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 React commit (especially on Android WebView).
const loginInFlight = useRef(false);
// QR-flow: optimistic awaiting_qr_scan + rollback on send failure.
const onClickLoginQr = useCallback(async () => {
if (loginInFlight.current) return;
loginInFlight.current = true;
dispatch({ kind: 'start_qr_login' });
try {
// Send the FULL command — bare `!wa login` would trigger
// flow_required because WhatsApp has 2 flows.
await sendBare('login qr');
} catch {
dispatch({ kind: 'cancel_pending' });
} finally {
loginInFlight.current = false;
}
}, [sendBare]);
// Phone-flow (pairing-code): optimistic awaiting_phone + rollback.
const onClickLoginPairing = useCallback(async () => {
if (loginInFlight.current) return;
loginInFlight.current = true;
dispatch({ kind: 'start_phone_login' });
try {
await sendBare('login phone');
} catch {
dispatch({ kind: 'cancel_pending' });
} finally {
loginInFlight.current = false;
}
}, [sendBare]);
const onClickRefresh = useCallback(async () => {
if (refreshing) return;
setRefreshing(true);
const start = Date.now();
try {
await sendBare('list-logins');
} catch {
/* transcript carries the failure */
}
// 500 ms minimum visible loading state — matches TG widget rationale.
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 (loginId: string) => {
dispatch({ kind: 'request_logout', loginId });
try {
await sendBare(`logout ${loginId}`);
} catch {
sendBare('list-logins').catch(() => {
/* nothing more we can do — user can hit refresh */
});
}
},
[sendBare]
);
const formProps: FormProps = {
state,
t,
dispatch,
send,
sendCancel,
phoneCooldownEnd,
setPhoneCooldownEnd,
};
// Disconnected-screen warning banner. Shows whenever we land in
// `disconnected` with a structured lastError — gives the user a
// visible explanation instead of an unexplained return to the
// command grid (functional review #22: a QR-window timeout
// surfaces as `login_failed: Entering code or scanning QR timed
// out. Please try again.` and without this banner the user sees
// only the disappearance of the QR panel and no «why»).
//
// external_logout is the loudest case (the bridge is genuinely
// gone), so it stays at amber tone. Other lastError shapes
// (login_failed / start_failed / prepare_failed / max_logins /
// unknown_command) get the same banner — they're equally worth
// surfacing, and a unified affordance is simpler than per-class
// chrome.
const disconnectedBanner =
state.kind === 'disconnected' && state.lastError ? (
<div class="section-warn-banner" role="status">
<span class="dot" />
<span>{localizeError(state.lastError, t)}</span>
</div>
) : null;
return (
<div class="app">
{handshakeOk && state.kind === 'unknown' ? (
<section class="section">
<div class="section-recovery-row">
<span class="section-status checking" role="status">
<span class="dot" />
{t('status.unknown')}
</span>
<button type="button" class="recovery-action" onClick={onClickRefresh}>
<RefreshIcon />
{t('card.refresh.label')}
</button>
</div>
</section>
) : null}
{handshakeOk && state.kind === 'disconnected' ? (
<section class="section">
{disconnectedBanner}
<span class="section-status disconnected" role="status">
<span class="dot" />
{t('status.disconnected')}
</span>
<div class="command-grid">
{/* Login order mirrors the Telegram widget: phone-flow
* («Войти по номеру») first, QR second. Both are valid
* primary paths; phone-flow is the more familiar entry
* point for users coming from Telegram or used to
* SMS-style codes, so it leads. The Meta-ToS risk
* disclosure now lives inside the AboutCard modal as
* an amber callout — the hybrid card description tells
* the user it's there. */}
<button class="command-card" type="button" onClick={onClickLoginPairing}>
<span class="command-card-lead-icon" aria-hidden="true">
<PhoneIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.login-pairing.name')}</div>
<div class="command-card-desc">{t('card.login-pairing.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
<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_phone' ? (
<section class="section">
<PhoneForm {...formProps} />
</section>
) : null}
{state.kind === 'awaiting_pairing_code' ? (
<section class="section">
<div class="section-recovery-row">
<span class="section-status checking" role="status">
<span class="dot" />
{t('auth-card.pairing-code.preparing')}
</span>
<button type="button" class="recovery-action" onClick={sendCancel}>
{t('auth-card.cancel')}
</button>
</div>
</section>
) : null}
{state.kind === 'pairing_code_shown' ? (
<section class="section">
<PairingCodePanel state={state} t={t} sendCancel={sendCancel} />
</section>
) : null}
{state.kind === 'awaiting_qr_scan' ? (
<section class="section">
<QrPanel state={state} t={t} sendCancel={sendCancel} />
</section>
) : null}
{/* `pairing_verifying` is reserved-but-unreachable from the live
* reducer today — the bridge does not redact the pairing-code on
* success. Hydrate could in principle restore it (and would, if a
* future bridge ever adds redaction), so we keep the rendering
* branch alive. See state.ts «pairing_verifying» comment. */}
{state.kind === 'qr_verifying' || state.kind === 'pairing_verifying' ? (
<section class="section">
<div class="section-recovery-row">
<span class="section-status checking" role="status">
<span class="dot" />
{state.kind === 'qr_verifying'
? t('status.qr-verifying')
: t('status.pairing-verifying')}
</span>
{/* Recovery refresh — if the bridge stalls between
* scan-accept and the success/failure follow-up, refresh
* fires `list-logins` to recalibrate. */}
<button type="button" class="recovery-action" onClick={onClickRefresh}>
<RefreshIcon />
{t('card.refresh.label')}
</button>
</div>
</section>
) : null}
{state.kind === 'logging_out' ? (
<section class="section">
<div class="section-recovery-row">
<span class="section-status checking" role="status">
<span class="dot" />
{t('status.logging-out')}
</span>
<button type="button" class="recovery-action" onClick={onClickRefresh}>
<RefreshIcon />
{t('card.refresh.label')}
</button>
</div>
</section>
) : null}
{state.kind === 'connected' ? (
<section class="section">
{state.loginId ? (
<span class="section-status connected" role="status">
<span class="dot" />
{state.handle
? t('status.connected-as', { handle: state.handle })
: t('status.connected')}
</span>
) : (
<div class="section-recovery-row">
<span class="section-status connected" role="status">
<span class="dot" />
{state.handle
? t('status.connected-as', { handle: state.handle })
: t('status.connected')}
</span>
<button type="button" class="recovery-action" onClick={onClickRefresh}>
<RefreshIcon />
{t('card.refresh.label')}
</button>
</div>
)}
<div class="command-grid">
<LogoutCard loginId={state.loginId} 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 kept blank intentionally */}</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>
);
}