import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks'; import type { Dispatch } from 'preact/hooks'; import type { ComponentChildren } from 'preact'; import type { WidgetBootstrap } from './bootstrap'; import { WidgetApi, type RoomEvent } from './widget-api'; import { createT, type T } from './i18n'; import { parseReply } from './bridge-protocol/parser'; import { initialLoginState, loginReducer, type LoginAction, type LoginErrorFlag, type LoginState, } from './state'; // Visual canon: «Боты · Commands IDE» mockup at // docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx // function BotsDesktop (lines 651-707) — Dawn palette, fleet-violet accent, // 56px square avatar, monospace handles. M12 adds three inline form cards // (.auth-card) and a destructive logout card (.command-card.danger) inside // the same vocabulary. 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, so its message listener is attached before the host's // ClientWidgetApi sends its capabilities request on iframe `load`. // Constructing inside a useEffect here would race with the cached-bundle // case (second mount after «Show chat» → «Show widget») and silently // miss the handshake. Keep the construction at module-load. api: WidgetApi; }; const TRANSCRIPT_MAX = 200; // 8s — see plan: hint window between submit and bot reply. SMS-side latency // is owned by the user (they can see the form is open and waiting). const STILL_WAITING_DELAY_MS = 8_000; // 30s countdown shown on the code form. The phone-form hint warns about a // 30-second SMS window; this is the visible counterpart on the form that // actually waits for the SMS to arrive. const CODE_COUNTDOWN_SECONDS = 30; // Inline SVG refresh icon — replaces the unicode «⟳» glyph that read more // like ASCII art than UI in the previous draft. Stroke-only, so it picks // up `currentColor` from the parent button and matches the Dawn palette // without a colored asset. const RefreshIcon = () => ( ); // Linkifier: matches plain http/https URLs in transcript bodies. Bot replies // regularly carry matrix.to URLs (success line includes one), and our M11 // scaffold previously rendered them as inert text. const URL_RE = /https?:\/\/[^\s)]+/g; 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. // We render in plaintext-with-anchors mode only; markdown formatting from the // bot (backticks, asterisks) shows literal — it's not load-bearing for the // authentication flow and rendering raw is safer than reimplementing markdown. 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; }; // Mask outbound secrets before they hit the local transcript. The widget // never *sends* the masked form — `WidgetApi.sendCommand` always carries // the real value over the wire. Server-side redaction handles password // and token; the 2FA phone code is NOT redacted upstream (bridgev2 only // redacts Password/Token field types — see plan «Architectural facts» // note 5), so the privacy hint shown after success matters. const redactOutbound = (body: string, kind: 'phone' | 'code' | 'password'): string => { if (kind === 'phone') return body; if (kind === 'code') return '••••••'; return '••••••••'; }; const localizeError = (err: LoginErrorFlag, t: T): string => { switch (err.kind) { case 'invalid_code': return t('auth-error.invalid-code'); case 'wrong_password': return t('auth-error.wrong-password'); 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 ?? '' }); default: { const exhaustive: never = err; return String(exhaustive); } } }; // Centralised affordance: does the current error get rendered as red // .auth-card-error or yellow .auth-card-warn? Telegram-side soft errors // (FloodWait, banned, unregistered — all funneled through submit_failed) // are warnings. Wrong-input errors are red. const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => { if (err.kind === 'submit_failed') return 'warn'; if (err.kind === 'login_in_progress') return 'warn'; return 'error'; }; // -------------------------------------------------------------------------- // Form components // -------------------------------------------------------------------------- type FormProps = { state: LoginState; t: T; dispatch: Dispatch; send: (body: string, mask: 'phone' | 'code' | 'password') => Promise; sendCancel: () => Promise; // Phone-form cooldown plumbing — lifted to App so it survives the form's // unmount on Cancel + remount on a fresh «Войти по номеру» click. Without // this, a user could spam Send Code → Cancel → repeat and fire SMS at // Telegram's rate-limit ceiling before bridgev2 even sees a flood. phoneCooldownEnd: number | null; setPhoneCooldownEnd: (ts: number | null) => void; }; // Phone-submit cooldown — Telegram throttles repeat SMS hard, and a // half-second of UI lag on submit makes spamming the button trivial. // 60 s matches Telegram Desktop's own "Resend code" lockout. const PHONE_COOLDOWN_MS = 60_000; // Tick once per second while a future timestamp is still in the future. // Returns the seconds remaining (0 once expired). When `until` is null // the hook is idle. 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); // `compute` is referentially fresh each render but captures `until`; // depending on it would re-run on every render. The effect only needs // to re-run when `until` itself changes. // 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); // The deps array is intentionally controlled by the caller — restart // the timer on every meaningful state change (e.g. submit). // 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); // Clear any stale error optimistically so the form looks ready for the // next attempt; a fresh error will re-arrive from the bot if the // submit fails server-side. dispatch({ kind: 'submit_phone' }); try { await send(trimmed, 'phone'); // Cooldown locks retries ONLY after the Matrix transport accepted // the message. If `await send` threw (network down, capability // race, etc.), no SMS was attempted at the Telegram side — locking // the form for 60s would punish the user for an issue they can fix // by clicking again. The cooldown is also cleared by the App when // the bot replies with `invalid_value` (malformed phone format — // bridgev2 rejects before SMS dispatch); see App-level effect. 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')}
setValue((e.currentTarget as HTMLInputElement).value)} disabled={submitting} />
{t('auth-card.phone.hint')}
{error ? (
{localizeError(error, t)}
) : null} {submitting && stillWaiting ? (
{t('auth-card.waiting-hint')}
) : null}
); }; // Tick-down hook for the SMS countdown. Starts on mount, decrements every // second until 0, holds. Returns a single number; the caller decides what // to render at 0 (we flip to a «didn't arrive — try again» hint). const useCountdown = (initialSeconds: number): number => { const [remaining, setRemaining] = useState(initialSeconds); useEffect(() => { if (remaining <= 0) return undefined; const timer = window.setTimeout(() => setRemaining((s) => Math.max(0, s - 1)), 1000); return () => window.clearTimeout(timer); }, [remaining]); return remaining; }; const CodeForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => { const [value, setValue] = useState(''); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(null); const stillWaiting = useStillWaitingHint([submitting]); // SMS countdown ticks from mount of the code form (i.e. when the bot // confirmed it sent a code). Independent of `submitting` — the timer // is about SMS arrival, not bot-reply latency. const countdownSeconds = useCountdown(CODE_COUNTDOWN_SECONDS); const error = state.kind === 'awaiting_code' ? state.lastError : undefined; useEffect(() => { inputRef.current?.focus(); }, []); const onSubmit = async (event: Event) => { event.preventDefault(); const trimmed = value.trim(); if (!trimmed || submitting) return; setSubmitting(true); // Clear input value AND stale error optimistically — user is making a // fresh attempt; the old red-state would conflict visually with the new // transcript-side outcome (which may itself be a different error). setValue(''); dispatch({ kind: 'submit_code' }); try { await send(trimmed, 'code'); } catch { /* transcript carries the diagnostic; form stays open for retry */ } finally { setSubmitting(false); } }; const tone = error ? errorTone(error) : undefined; return (
{t('auth-card.code.title')}
setValue((e.currentTarget as HTMLInputElement).value)} disabled={submitting} />
{error ? (
{localizeError(error, t)}
) : null} {/* * SMS countdown is suppressed while the form is submitting — the * bot-latency hint takes over once the user has typed and clicked. * Both rendering simultaneously stacks two distinct waits (SMS * arrival vs bot reply) on the same form and reads as clutter. */} {!submitting && (countdownSeconds > 0 ? (
{t('auth-card.code.countdown', { seconds: String(countdownSeconds) })}
) : (
{t('auth-card.code.countdown-done')}
))} {submitting && stillWaiting ? (
{t('auth-card.waiting-hint')}
) : null}
); }; const PasswordForm = ({ state, t, dispatch, send, sendCancel }: FormProps) => { const [value, setValue] = useState(''); const [reveal, setReveal] = useState(false); const [submitting, setSubmitting] = useState(false); const inputRef = useRef(null); const stillWaiting = useStillWaitingHint([submitting]); const error = state.kind === 'awaiting_password' ? state.lastError : undefined; useEffect(() => { inputRef.current?.focus(); }, []); const onSubmit = async (event: Event) => { event.preventDefault(); if (!value || submitting) return; setSubmitting(true); // Capture the plaintext into a local and drop it from component state // BEFORE awaiting the send. If the send throws or the form unmounts // mid-flight, no plaintext lingers in React state or the DOM input — // server-side redaction (bridgev2/commands/login.go:226) only fires // after the message arrives, and we don't want a window where the // password sits accessible to Preact Devtools / source maps. const password = value; setValue(''); dispatch({ kind: 'submit_password' }); try { await send(password, 'password'); } catch { /* transcript carries the diagnostic; user retypes */ } finally { setSubmitting(false); } }; const tone = error ? errorTone(error) : undefined; return (
{t('auth-card.password.title')}
{t('auth-card.password.hint')}
setValue((e.currentTarget as HTMLInputElement).value)} disabled={submitting} />
{error ? (
{localizeError(error, t)}
) : null} {submitting && stillWaiting ? (
{t('auth-card.waiting-hint')}
) : null}
); }; // -------------------------------------------------------------------------- // 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); // Belt-and-suspenders against double-submit. `disabled={submitting}` on the // button covers 99% of cases, but there's a microtask window between click // and React/Preact rendering the disabled state where a fast second click // could fire — the ref closes that window synchronously. 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 apiRef = useRef(api); const seenEventIds = useRef(new Set()); const [state, dispatch] = useReducer(loginReducer, initialLoginState); const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]); useEffect(() => { document.documentElement.dataset.theme = theme; }, [theme]); const append = useCallback((line: Omit) => { setTranscript((prev) => { const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }]; return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next; }); }, []); // Pin the transcript to the bottom whenever a new line lands. Bot replies // are append-only so this is the right default; users who scroll up will // be snapped back on the next message — acceptable here because the // transcript is a passive log, not a primary reading surface (forms and // status pills are above it). const transcriptRef = useRef(null); useEffect(() => { const el = transcriptRef.current; if (!el) return; el.scrollTop = el.scrollHeight; }, [transcript.length]); // App-level cooldown state for phone-form Send Code button. Lifted out of // PhoneForm so it survives the form's unmount on Cancel + remount on a // fresh login click — otherwise the user could spam Send→Cancel→repeat // and saturate Telegram's SMS rate limit before bridgev2 reports back. const [phoneCooldownEnd, setPhoneCooldownEnd] = useState(null); // Clear the cooldown when bridge replies that our phone was malformed — // `invalid_value` on the awaiting_phone form means the value was // rejected at validate-time, BEFORE bridgev2 dispatched any Telegram // API call. No SMS was attempted, so the 60s lock would just punish a // typo. The cooldown stays in place for `submit_failed` (Telegram-side // FloodWait/banned/etc — those cases SMS may have been attempted). useEffect(() => { if ( state.kind === 'awaiting_phone' && state.lastError?.kind === 'invalid_value' && phoneCooldownEnd !== null ) { setPhoneCooldownEnd(null); } }, [state, phoneCooldownEnd]); // 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 (cached-bundle // remount: bundle parses near-instantly and the host's capabilities // request can resolve before this useEffect runs). useEffect(() => { api.on('ready', () => { setHandshakeOk(true); append({ kind: 'diag', text: t('diag.ready') }); append({ kind: 'diag', text: t('diag.checking-status') }); api.sendCommand('list-logins').catch((err) => { 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. Phase 1's strict 1:1 invariant // already guarantees the only senders in this DM are the user and // the bot, but pinning to bootstrap.botMxid covers both: // (a) skip our own outbound echoes (those are appended // optimistically with masking applied at click time — the live // event would render the unmasked password back into the // transcript), and // (b) ignore any third-party noise that somehow slips past the // 1:1 invariant. if (ev.sender !== bootstrap.botMxid) return; const body = ev.content.body ?? ''; append({ kind: 'from-bot', text: `← ${body}` }); // Bot reply → LoginEvent → state machine. Ignore msgtype-specific // routing — bridgev2 sends every login reply as m.notice; the host // driver already filters to m.text/m.notice on the receive path. const event = parseReply(body); dispatch({ kind: 'event', event }); // After a fresh login_success the bridge doesn't send the loginId in // the success line. 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(() => { /* surface in diag is overkill; the connected hero still works without a loginId until the user clicks logout */ }); } }); append({ kind: 'diag', text: t('diag.connecting') }); return () => { // App-level unmount tears down the iframe window entirely (host // detaches the iframe DOM node), so dispose just clears pending // request promises. Don't null `apiRef.current` — `api` is a // module-level singleton owned by main.tsx, not by this component. api.dispose(); }; // `api`, `bootstrap`, `t`, and `append` are stable for the App's // lifetime; the effect intentionally runs once at mount. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Outbound command + transcript echo. `mask` decides what shows up in the // local transcript; the wire payload always carries the real value. // Errors are appended to the transcript AND rethrown — callers decide // whether to roll back optimistic state transitions. const send = useCallback( async (body: string, mask: 'phone' | 'code' | 'password' = 'phone'): Promise => { const api = apiRef.current; if (!api) throw new Error('widget transport not ready'); append({ kind: 'from-user', text: `→ ${redactOutbound(body, mask)}` }); try { await api.sendCommand(body); } catch (err) { append({ kind: 'error', text: t('diag.send-failed', { message: (err as Error).message }), }); throw err; } }, [append, t] ); const sendBare = useCallback( async (command: string): Promise => { const api = apiRef.current; if (!api) throw new Error('widget transport not ready'); 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, t] ); // Optimistic transition + rollback on send failure. Cancel is panic-UX — // we want immediate disconnected state. If the send fails, the bot just // doesn't know we cancelled; its CommandState times out eventually. const sendCancel = useCallback(async () => { dispatch({ kind: 'cancel_pending' }); try { await sendBare('cancel'); } catch { /* already showing disconnected; transcript carries the failure */ } }, [sendBare]); // Optimistic awaiting_phone + rollback to disconnected on send failure. // Without rollback the user would see the phone form open with no command // ever delivered to the bot. const onClickLogin = useCallback(async () => { dispatch({ kind: 'start_login' }); try { await sendBare('login phone'); } catch { dispatch({ kind: 'cancel_pending' }); } }, [sendBare]); const onClickRefresh = useCallback(() => { sendBare('list-logins').catch(() => { /* transcript carries the failure */ }); }, [sendBare]); // Optimistic logging_out + recovery on send failure: refire list-logins // so the reducer recalibrates from the bridge's truth instead of leaving // the UI stuck in logging_out forever. 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, }; return (
{/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no * longer renders an avatar/name/handle/description block — the host * panel above the iframe carries that information, with the * «Настроить» dropdown that controls show-chat / mark-read / * notifications / leave-room. The widget body starts with the * action-relevant section for the current state. */} {handshakeOk && state.kind === 'unknown' ? (
{t('status.unknown')} {/* Recovery affordance — without this the user stares at the * «Проверка статуса…» pill forever if the initial * list-logins reply was dropped on the wire. */}
) : null} {handshakeOk && state.kind === 'disconnected' ? (
{/* Status pill replaces the section header — the pill itself * carries the section's identity («Войдите в Telegram» says * what this surface is for and what state we're in, so a * separate «Подключение» label was redundant). */} {t('status.disconnected')}

{t('landing.hint')}

) : null} {state.kind === 'awaiting_phone' ? (
) : null} {state.kind === 'awaiting_code' ? (
) : null} {state.kind === 'awaiting_password' ? (
) : null} {state.kind === 'logging_out' ? (
{t('status.logging-out')} {/* If the bot's `Logged out` reply never arrives, refresh * fires `list-logins` and the reducer recalibrates from * bridge truth (gets routed back to disconnected/connected * via logins_listed/not_logged_in). Without this there's no * way out of `logging_out` short of a page reload. */}
) : 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')} {/* Visible refresh when we don't yet have a loginId. The * post-login_success list-logins is the normal source — * if it dropped, the logout card stays disabled forever * with only an invisible tooltip. Surface the recovery * action explicitly so the user isn't trapped. */}
)}

{t('auth-card.code.privacy-hint-history')}

) : null}
{transcript.length === 0 ? (
{/* placeholder kept blank intentionally */}
) : ( transcript.map((line) => (
{formatTime(line.ts)} {line.kind === 'from-bot' ? renderBody(line.text) : line.text}
)) )}
); }