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 (
);
};
// 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 (
);
};
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 (
);
};
// --------------------------------------------------------------------------
// 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('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. */}
{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. */}