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 = () => ( ); // Smartphone outline — leads the «Войти по номеру» card. Shared shape // across TG (text-login) and WhatsApp (pairing-code login) since both // flows boil down to «вы вводите номер с телефона». const PhoneIcon = () => ( ); // 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 = () => ( ); // 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 = () => ( ); // 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 = () => ( ); // 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 `@` (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( {match[0]} ); 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; send: (body: string) => Promise; sendCancel: () => Promise; // 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): 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(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 (
{t('auth-card.phone.title')}
{ // 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} />
{t('auth-card.phone.hint')}
{error ? (
{localizeError(error, t)}
) : null} {submitting && stillWaiting ? (
{t('auth-card.waiting-hint')}
) : null}
); }; // -------------------------------------------------------------------------- // 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 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( ); } } return ( {rects} ); }; type QrPanelProps = { state: { kind: 'awaiting_qr_scan'; qrData: string; firstShownAt: number; lastError?: LoginErrorFlag; }; t: T; sendCancel: () => Promise; }; 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 (
{t('auth-card.qr.title')}
{t('auth-card.qr.hint')}
{matrix ? ( ) : (
{t('auth-card.qr.preparing')}
)}
{!expired ? (
{t('auth-card.qr.countdown', { minutes: String(Math.floor(remainingSeconds / 60)), seconds: String(remainingSeconds % 60).padStart(2, '0'), })}
) : (
{t('auth-card.qr.expired')}
)}
  1. {t('auth-card.qr.step-1')}
  2. {t('auth-card.qr.step-2')}
  3. {t('auth-card.qr.step-3')}
{state.lastError ? (
{localizeError(state.lastError, t)}
) : null}
); }; // -------------------------------------------------------------------------- // 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; }; 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 (
{t('auth-card.pairing-code.title')}
{t('auth-card.pairing-code.hint')}
{state.code ? ( // 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 `
` 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. <> {state.code} {t('auth-card.pairing-code.aria')} ) : (
{t('auth-card.pairing-code.preparing')}
)}
{!expired ? (
{t('auth-card.pairing-code.countdown', { minutes: String(Math.floor(remainingSeconds / 60)), seconds: String(remainingSeconds % 60).padStart(2, '0'), })}
) : (
{t('auth-card.pairing-code.expired')}
)}
  1. {t('auth-card.pairing-code.step-1')}
  2. {t('auth-card.pairing-code.step-2')}
  3. {t('auth-card.pairing-code.step-3')}
  4. {t('auth-card.pairing-code.step-4')}
{state.lastError ? (
{localizeError(state.lastError, t)}
) : null}
); }; // -------------------------------------------------------------------------- // 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) => ( ); type AboutModalProps = { t: T; onClose: () => void; }; const AboutModal = ({ t, onClose }: AboutModalProps) => { useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [onClose]); return ( ); }; // -------------------------------------------------------------------------- // Logout card with confirm-in-place // -------------------------------------------------------------------------- type LogoutCardProps = { loginId: string | undefined; t: T; onConfirm: (loginId: string) => Promise; }; const LogoutCard = ({ loginId, t, onConfirm }: LogoutCardProps) => { const [confirming, setConfirming] = useState(false); const [submitting, setSubmitting] = useState(false); const inFlight = useRef(false); if (confirming) { return (
{t('card.logout.confirm-prompt')}
); } return ( ); }; // -------------------------------------------------------------------------- // Main App // -------------------------------------------------------------------------- export function App({ bootstrap, api }: Props) { const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme); const [transcript, setTranscript] = useState([]); const [handshakeOk, setHandshakeOk] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); const [refreshing, setRefreshing] = useState(false); const seenEventIds = useRef(new Set()); 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 `` — // 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) => { 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(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(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[] => 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(); 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 => { 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 => { 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((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 ? (
{localizeError(state.lastError, t)}
) : null; return (
{handshakeOk && state.kind === 'unknown' ? (
{t('status.unknown')}
) : null} {handshakeOk && state.kind === 'disconnected' ? (
{disconnectedBanner} {t('status.disconnected')}
{/* 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. */} setAboutOpen(true)} />
) : null} {state.kind === 'awaiting_phone' ? (
) : null} {state.kind === 'awaiting_pairing_code' ? (
{t('auth-card.pairing-code.preparing')}
) : null} {state.kind === 'pairing_code_shown' ? (
) : null} {state.kind === 'awaiting_qr_scan' ? (
) : 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' ? (
{state.kind === 'qr_verifying' ? t('status.qr-verifying') : t('status.pairing-verifying')} {/* Recovery refresh — if the bridge stalls between * scan-accept and the success/failure follow-up, refresh * fires `list-logins` to recalibrate. */}
) : null} {state.kind === 'logging_out' ? (
{t('status.logging-out')}
) : null} {state.kind === 'connected' ? (
{state.loginId ? ( {state.handle ? t('status.connected-as', { handle: state.handle }) : t('status.connected')} ) : (
{state.handle ? t('status.connected-as', { handle: state.handle }) : t('status.connected')}
)}
setAboutOpen(true)} />
) : null} {aboutOpen ? setAboutOpen(false)} /> : null}
{transcript.length === 0 ? (
{/* placeholder kept blank intentionally */}
) : ( transcript .slice() .reverse() .map((line) => (
{formatTime(line.ts)} {line.kind === 'from-bot' ? renderBody(line.text) : line.text}
)) )}
); }