942 lines
36 KiB
TypeScript
942 lines
36 KiB
TypeScript
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>
|
||
);
|
||
}
|