import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
import type { Dispatch } from 'preact/hooks';
import type { ComponentChildren } from 'preact';
import qrcodeGenerator from 'qrcode-generator';
import type { WidgetBootstrap } from './bootstrap';
import { WidgetApi, type RoomEvent } from './widget-api';
import { createT, type T, type StringKey } from './i18n';
import { parseEvent } from './bridge-protocol/parser';
import {
hydrateFromTimeline,
initialLoginState,
loginReducer,
type HydrateInput,
type LoginAction,
type LoginErrorFlag,
type LoginState,
} from './state';
// Visual canon mirrors the Telegram and Discord widgets — Dawn palette,
// fleet-violet accent, monospace handles. We DO NOT adopt WhatsApp's
// signature green; the panel is meant to read as a coherent continuation
// of the host UI ("Vojo style"), not a WhatsApp clone.
type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error';
type TranscriptLine = {
id: string;
ts: number;
kind: TranscriptKind;
text: string;
};
type Props = {
bootstrap: WidgetBootstrap;
// The WidgetApi is constructed in main.tsx synchronously, BEFORE React's
// first render — see widget-telegram for the cached-bundle race rationale.
api: WidgetApi;
};
const TRANSCRIPT_MAX = 200;
// 8 s — between submit and bot reply on the phone form. Applies only to
// the pairing-code phone form because there's no separate "code" form
// like Telegram has (WhatsApp's pairing-code is shown by the bridge,
// not typed by the user).
const STILL_WAITING_DELAY_MS = 8_000;
// Inline SVG refresh icon — shared with TG / Discord widgets.
const RefreshIcon = () => (
);
// Smartphone outline — leads the «Войти по номеру» card. Shared shape
// across TG (text-login) and WhatsApp (pairing-code login) since both
// flows boil down to «вы вводите номер с телефона».
const PhoneIcon = () => (
);
// Three QR finder squares + a few module dots — leads every QR-login
// card. The finder pattern is the strongest «this is QR» visual cue;
// no need to draw a full code.
const QrIcon = () => (
);
// Sign-out arrow leaving an open box — leads the destructive logout
// card. Open right side conveys «out of the session». Picks up the
// rose tint via `.command-card.danger` cascade only on the title;
// the lead icon stays muted so the rose stays a single accent.
const LogoutIcon = () => (
);
// Triangle warning glyph — leads the WhatsApp-only AboutCard (which
// carries `command-card warn` for the amber outline) and re-appears
// inside the AboutModal's risk-disclosure callout. Stroke-only so it
// picks up the amber tint via `currentColor` in either context.
const WarningIcon = () => (
);
// Linkifier — same heuristic as TG / Discord widgets.
const URL_RE = /https?:\/\/[^\s)]+/g;
// WA-only: defence-in-depth scrub of any whatsmeow QR payload from text
// before appending to the transcript. Today the bridge only emits the
// payload via `m.image` (which we explicitly route to a generic
// «QR-код обновлён» diag, never verbatim), but if a future bridge
// revision starts echoing the payload into m.notice — say for a
// chat-fallback debug surface — the existing transcript append would
// store the adv-secret segment in the DOM. The adv-secret IS the live
// cryptographic material the phone signs to prove possession; even one
// accidental transcript line would survive page reloads via the hydrate
// replay. Keep this scrubber in sync with the parser's WA_QR_PAYLOAD_RE
// (bridgev2_v0264.ts) — they describe the same upstream shape.
//
// Anchoring rationale (frontend review #1): the upstream `makeQRData`
// always prefixes the first field with `@` (e.g.
// `2@AbCd...` — the leading digit is the protocol generation, currently
// `2`). The ref field is also always at least 16 chars long, and the
// three trailing base64 fields hover around 24-44 chars each. We
// therefore require:
// - leading `\d@` to anchor on the unmistakable WA-protocol prefix,
// - each segment to be at least 8 chars long.
// That's narrow enough that an unrelated body matching the shape by
// accident is implausible, while still tolerant of future protocol
// version-bumps from `2@` to e.g. `3@`. Reject patterns are e.g.
// «error: a,b,c,d in field» — without the digit prefix and the segment
// length floor, the old regex would clobber that.
const WA_QR_PAYLOAD_GLOBAL_RE =
/\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
const scrubLoginSecret = (body: string): string =>
body.replace(WA_QR_PAYLOAD_GLOBAL_RE, '[redacted QR payload]');
const formatTime = (ts: number): string => {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${hh}:${mm}:${ss}`;
};
// Build transcript children from a body string with naive URL linkification.
// Plain markdown formatting from the bot (backticks, asterisks) shows
// literal — the upstream wording isn't load-bearing on rendering, and
// re-implementing markdown here is a worse trade-off than rendering
// raw.
const renderBody = (body: string): ComponentChildren => {
const out: ComponentChildren[] = [];
let lastIndex = 0;
for (const match of body.matchAll(URL_RE)) {
const idx = match.index ?? 0;
if (idx > lastIndex) out.push(body.slice(lastIndex, idx));
out.push(
{match[0]}
);
lastIndex = idx + match[0].length;
}
if (lastIndex < body.length) out.push(body.slice(lastIndex));
return out.length === 0 ? body : out;
};
// WhatsApp's pairing-code flow only takes the phone number as user
// input — the code itself is shown by the bridge, not typed by the
// user, so user-typed values that hit the transcript never need
// masking. Telegram-style mask helpers were intentionally not ported.
const localizeError = (err: LoginErrorFlag, t: T): string => {
switch (err.kind) {
case 'login_failed':
return t('auth-error.login-failed', { reason: err.reason ?? '' });
case 'invalid_value':
return t('auth-error.invalid-value', { reason: err.reason ?? '' });
case 'submit_failed':
return t('auth-error.submit-failed', { reason: err.reason ?? '' });
case 'login_in_progress':
return t('auth-error.login-in-progress');
case 'max_logins':
return t('auth-error.max-logins', { limit: String(err.limit ?? '?') });
case 'unknown_command':
return t('auth-error.unknown-command');
case 'start_failed':
return t('auth-error.start-failed', { reason: err.reason ?? '' });
case 'prepare_failed':
return t('auth-error.prepare-failed', { reason: err.reason ?? '' });
case 'external_logout': {
const subKey: StringKey =
err.reason === 'another_device'
? 'auth-error.external-logout.another-device'
: err.reason === 'phone_logged_out'
? 'auth-error.external-logout.phone-logged-out'
: 'auth-error.external-logout.unknown';
return t(subKey);
}
default: {
const exhaustive: never = err;
return String(exhaustive);
}
}
};
// Centralised tone affordance: red `.auth-card-error` vs amber
// `.auth-card-warn`. WhatsApp-side soft errors (rate-limited, retry-able
// pairing failure) are warnings; hard validation errors are red.
// `external_logout` is special — it's surfaced as a top-level banner,
// not a form-side error tone.
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
if (err.kind === 'submit_failed') return 'warn';
if (err.kind === 'login_in_progress') return 'warn';
return 'error';
};
// --------------------------------------------------------------------------
// Phone form (pairing-code flow only)
// --------------------------------------------------------------------------
type FormProps = {
state: LoginState;
t: T;
dispatch: Dispatch;
send: (body: string) => Promise;
sendCancel: () => Promise;
// Phone-form cooldown plumbing — lifted to App so it survives the form's
// unmount on Cancel + remount on a fresh «Войти по коду» click.
phoneCooldownEnd: number | null;
setPhoneCooldownEnd: (ts: number | null) => void;
};
// Phone-submit cooldown — WhatsApp throttles repeated pairing-code
// requests at the connector level (`Rate limited by WhatsApp`).
// 60 s matches the rough recovery window that observed flood replies
// stop firing after.
const PHONE_COOLDOWN_MS = 60_000;
const useCooldownSeconds = (until: number | null): number => {
const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
const [seconds, setSeconds] = useState(compute);
useEffect(() => {
if (!until) {
setSeconds(0);
return undefined;
}
setSeconds(compute());
const timer = window.setInterval(() => {
const next = Math.max(0, Math.ceil((until - Date.now()) / 1000));
setSeconds(next);
if (next <= 0) window.clearInterval(timer);
}, 1000);
return () => window.clearInterval(timer);
// The deps array is intentionally controlled: re-run only when the
// future timestamp itself changes. `compute` would otherwise change
// every render and re-trigger setInterval.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [until]);
return seconds;
};
const useStillWaitingHint = (deps: ReadonlyArray): boolean => {
const [show, setShow] = useState(false);
useEffect(() => {
setShow(false);
const timer = window.setTimeout(() => setShow(true), STILL_WAITING_DELAY_MS);
return () => window.clearTimeout(timer);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
return show;
};
const PhoneForm = ({
state,
t,
dispatch,
send,
sendCancel,
phoneCooldownEnd,
setPhoneCooldownEnd,
}: FormProps) => {
const [value, setValue] = useState('');
const [submitting, setSubmitting] = useState(false);
const inputRef = useRef(null);
const stillWaiting = useStillWaitingHint([submitting]);
const cooldownSeconds = useCooldownSeconds(phoneCooldownEnd);
const inCooldown = cooldownSeconds > 0;
const error = state.kind === 'awaiting_phone' ? state.lastError : undefined;
useEffect(() => {
inputRef.current?.focus();
}, []);
const onSubmit = async (event: Event) => {
event.preventDefault();
const trimmed = value.trim();
if (!trimmed || submitting || inCooldown) return;
setSubmitting(true);
dispatch({ kind: 'submit_phone' });
try {
await send(trimmed);
// Cooldown locks retries ONLY after the Matrix transport accepted
// the message. If `await send` threw (network down, capability
// race), no pairing-code request was attempted at the WhatsApp
// side — punishing the user for an issue they can fix by clicking
// again would be wrong. The cooldown is also cleared by the App
// when the bridge replies with `invalid_value` (malformed phone
// — bridgev2 rejects before WhatsApp dispatch).
setPhoneCooldownEnd(Date.now() + PHONE_COOLDOWN_MS);
} catch {
/* transcript carries the diagnostic; form stays open for retry */
} finally {
setSubmitting(false);
}
};
const tone = error ? errorTone(error) : undefined;
const submitDisabled = submitting || inCooldown || value.trim() === '';
const submitLabel = inCooldown
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
: t('auth-card.phone.submit');
return (
);
};
// --------------------------------------------------------------------------
// QR panel
// --------------------------------------------------------------------------
// Whatsmeow's QR rotation schedule (verified upstream pair.go::qrIntervals):
// first QR: 60 s
// QRs 2..6: 20 s each → 5 × 20 s = 100 s
// Total active window: 160 s = 2 min 40 s. After the last QR, the
// bridge surfaces `Login failed: Entering code or scanning QR timed
// out. Please try again.` We render a soft timeout countdown (3 min,
// matching HYDRATE_FRESHNESS_MS so a reload past the panel-expiry
// also can't restore the dead flow), NOT a hard kill — when it expires
// the panel switches to a recovery hint.
const QR_TIMEOUT_MS = 3 * 60 * 1000;
// Error-correction level M — same trade-off as TG/Discord (more
// resilient to camera glare than L, smaller modules than Q).
// typeNumber=0 auto-picks the smallest version that fits the payload;
// for a whatsmeow handshake (~140 chars) this lands around version 7-8.
const buildQrModules = (data: string): boolean[][] | null => {
if (!data) return null;
try {
const qr = qrcodeGenerator(0, 'M');
qr.addData(data);
qr.make();
const count = qr.getModuleCount();
const matrix: boolean[][] = [];
for (let r = 0; r < count; r += 1) {
const row: boolean[] = [];
for (let c = 0; c < count; c += 1) {
row.push(qr.isDark(r, c));
}
matrix.push(row);
}
return matrix;
} catch {
return null;
}
};
// Render the QR matrix as elements inside an SVG. We deliberately
// avoid `dangerouslySetInnerHTML` and any external QR-rendering service:
// the whatsmeow handshake IS the login secret (the adv-secret field is
// what the phone signs to prove possession), so it must never leave the
// iframe and must never reach a stringified-HTML path that bypasses
// Preact's escaping.
type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string };
const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => {
const count = matrix.length;
const margin = 4;
const totalUnits = count + margin * 2;
const cellPx = pixelSize / totalUnits;
const rects: ComponentChildren[] = [];
for (let r = 0; r < count; r += 1) {
for (let c = 0; c < count; c += 1) {
if (!matrix[r][c]) continue;
rects.push(
);
}
}
return (
);
};
type QrPanelProps = {
state: {
kind: 'awaiting_qr_scan';
qrData: string;
firstShownAt: number;
lastError?: LoginErrorFlag;
};
t: T;
sendCancel: () => Promise;
};
const QrPanel = ({ state, t, sendCancel }: QrPanelProps) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const matrix = useMemo(() => buildQrModules(state.qrData), [state.qrData]);
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000));
const expired = elapsed >= QR_TIMEOUT_MS && state.firstShownAt > 0;
return (
);
};
// --------------------------------------------------------------------------
// Pairing-code panel
// --------------------------------------------------------------------------
// Pairing code server-side validity at WhatsApp's gateway is roughly the
// same as QR (~3 minutes — verified empirically against whatsmeow
// PairPhone behaviour, no explicit constant in the lib). We share the
// same 3-min window as QR_TIMEOUT_MS / HYDRATE_FRESHNESS_MS so reload
// past expiry can't restore a dead flow.
const PAIRING_CODE_TIMEOUT_MS = 3 * 60 * 1000;
type PairingCodePanelProps = {
state: {
kind: 'pairing_code_shown';
code: string;
firstShownAt: number;
lastError?: LoginErrorFlag;
};
t: T;
sendCancel: () => Promise;
};
const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
const remainingSeconds = Math.max(
0,
Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)
);
const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0;
return (
{t('auth-card.pairing-code.title')}
{t('auth-card.pairing-code.hint')}
{state.code ? (
//
);
};
// --------------------------------------------------------------------------
// About card + modal
// --------------------------------------------------------------------------
type AboutCardProps = {
t: T;
onOpen: () => void;
};
// WhatsApp-only: AboutCard carries the `warn` modifier so the amber
// border + amber-tinted name signal that the modal behind it includes
// the Meta-ToS risk disclosure. The leading icon is the triangle
// WarningIcon (not the info-circle used by TG / Discord) for the
// same reason — the hybrid card description («о работе и рисках»)
// stays believable when the icon previews the «risks» half.
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
);
type AboutModalProps = {
t: T;
onClose: () => void;
};
const AboutModal = ({ t, onClose }: AboutModalProps) => {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
{
if (e.target === e.currentTarget) onClose();
}}
>
{t('about.title')}
{/* WhatsApp-specific Meta-ToS account-ban risk disclosure.
* Lives at the top of the About modal as an amber-tinted
* block — same content the dedicated WarningCard used to
* carry on the disconnected screen, folded in here so the
* About card is the single info entry point (matches the
* TG / Discord shape; the amber block carries the «risks»
* half of the hybrid card description). */}