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

942 lines
36 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 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 = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
<path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
<path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
<path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Linkifier: 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(
<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;
};
// 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<LoginAction>;
send: (body: string, mask: 'phone' | 'code' | 'password') => 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. 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<unknown>): 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<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);
// 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 (
<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) => setValue((e.currentTarget as HTMLInputElement).value)}
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>
);
};
// 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<HTMLInputElement | null>(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 (
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.code.title')}</div>
<label class="auth-card-hint" for="auth-code-input">
{t('auth-card.code.label')}
</label>
<div class="auth-card-row">
<input
id="auth-code-input"
ref={inputRef}
class="auth-input code"
type="text"
autocomplete="one-time-code"
inputmode="numeric"
maxLength={6}
placeholder={t('auth-card.code.placeholder')}
value={value}
onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
disabled={submitting}
/>
<button type="submit" class="btn-primary" disabled={submitting || value.trim() === ''}>
{t('auth-card.code.submit')}
</button>
<button type="button" class="btn-text" onClick={sendCancel}>
{t('auth-card.cancel')}
</button>
</div>
{error ? (
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
{localizeError(error, t)}
</div>
) : 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 ? (
<div class="auth-card-countdown">
{t('auth-card.code.countdown', { seconds: String(countdownSeconds) })}
</div>
) : (
<div class="auth-card-countdown expired">{t('auth-card.code.countdown-done')}</div>
))}
{submitting && stillWaiting ? (
<div class="auth-card-waiting">{t('auth-card.waiting-hint')}</div>
) : null}
</form>
);
};
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<HTMLInputElement | null>(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 (
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
<div class="auth-card-title">{t('auth-card.password.title')}</div>
<div class="auth-card-hint">{t('auth-card.password.hint')}</div>
<label class="auth-card-hint" for="auth-password-input">
{t('auth-card.password.label')}
</label>
<div class="auth-card-row">
<div class="password-row">
<input
id="auth-password-input"
ref={inputRef}
class="auth-input password"
type={reveal ? 'text' : 'password'}
autocomplete="current-password"
value={value}
onInput={(e) => setValue((e.currentTarget as HTMLInputElement).value)}
disabled={submitting}
/>
<button
type="button"
class="btn-icon"
onClick={() => setReveal((v) => !v)}
aria-label={reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
>
{reveal ? t('auth-card.password.hide') : t('auth-card.password.show')}
</button>
</div>
<button type="submit" class="btn-primary" disabled={submitting || value === ''}>
{t('auth-card.password.submit')}
</button>
<button type="button" class="btn-text" onClick={sendCancel}>
{t('auth-card.cancel')}
</button>
</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>
);
};
// --------------------------------------------------------------------------
// 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);
// 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 (
<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}
>
<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 apiRef = useRef<WidgetApi | null>(api);
const seenEventIds = useRef(new Set<string>());
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<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;
});
}, []);
// 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<HTMLDivElement>(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<number | null>(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<void> => {
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<void> => {
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 (
<div class="app">
{/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no
* longer renders an avatar/name/handle/description block — the host
* panel above the iframe carries that information, 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' ? (
<section class="section">
<div class="section-recovery-row">
<span class="section-status checking" role="status">
<span class="dot" />
{t('status.unknown')}
</span>
{/* Recovery affordance — without this the user stares at the
* «Проверка статуса…» pill forever if the initial
* list-logins reply was dropped on the wire. */}
<button type="button" class="recovery-action" onClick={onClickRefresh}>
<RefreshIcon />
{t('card.refresh.label')}
</button>
</div>
</section>
) : null}
{handshakeOk && state.kind === 'disconnected' ? (
<section class="section">
{/* 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). */}
<span class="section-status disconnected" role="status">
<span class="dot" />
{t('status.disconnected')}
</span>
<div class="connect-row">
<div class="command-grid">
<button class="command-card" type="button" onClick={onClickLogin}>
<div class="command-card-body">
<div class="command-card-name">{t('card.login.name')}</div>
<div class="command-card-desc">{t('card.login.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
</div>
<button
type="button"
class="refresh-button"
onClick={onClickRefresh}
aria-label={t('card.refresh.aria')}
>
<RefreshIcon />
</button>
</div>
<p class="hint">{t('landing.hint')}</p>
</section>
) : null}
{state.kind === 'awaiting_phone' ? (
<section class="section">
<PhoneForm {...formProps} />
</section>
) : null}
{state.kind === 'awaiting_code' ? (
<section class="section">
<CodeForm {...formProps} />
</section>
) : null}
{state.kind === 'awaiting_password' ? (
<section class="section">
<PasswordForm {...formProps} />
</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>
{/* 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. */}
<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>
{/* 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. */}
<button type="button" class="recovery-action" onClick={onClickRefresh}>
<RefreshIcon />
{t('card.refresh.label')}
</button>
</div>
)}
<p class="hint">{t('auth-card.code.privacy-hint-history')}</p>
<div class="command-grid">
<LogoutCard loginId={state.loginId} t={t} onConfirm={onConfirmLogout} />
</div>
</section>
) : null}
<section class="section">
<h2 class="section-label">{t('section.transcript')}</h2>
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
{transcript.length === 0 ? (
<div class="transcript-empty">{/* placeholder kept blank intentionally */}</div>
) : (
transcript.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>
);
}