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';
// `/min` metadata (~15 KB gzip) covers all country calling codes + length
// validation. Sufficient for «is this a plausible phone number?» — the
// bridge does the authoritative validation server-side.
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
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;
// Minimum digits required before we surface an «invalid number» hint.
// Below this the user is still typing the country prefix and the
// formatter has nothing useful to validate.
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;
// Strip every character that isn't `+` or a digit, then guarantee a
// single leading `+` — whatsmeow's PairPhone validator fires
// `PHONE_NUMBER_NOT_INTERNATIONAL` without it.
const phoneToE164 = (raw: string): string => {
const cleaned = raw.replace(/[^\d+]/g, '');
if (cleaned.length === 0) return '';
return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
};
// AsYouType is stateful — call `.input()` on a fresh instance per render
// so paste / mid-string edits don't desync the formatter buffer from the
// React state.
type PhoneFormat = { formatted: string; country: string | undefined };
const formatPhoneInput = (raw: string): PhoneFormat => {
const e164 = phoneToE164(raw);
if (!e164) return { formatted: '', country: undefined };
const formatter = new AsYouType();
const formatted = formatter.input(e164);
return { formatted, country: formatter.getCountry() };
};
// ISO 3166-1 alpha-2 → regional-indicator-symbol emoji («RU» → 🇷🇺).
// Windows Chrome doesn't ship the flag glyphs and falls back to the
// two-letter code rendered as plain letters — still readable.
const countryToFlagEmoji = (cc: string | undefined): string => {
if (!cc || cc.length !== 2) return '';
const codePoints = cc
.toUpperCase()
.split('')
.map((c) => 127397 + c.charCodeAt(0));
return String.fromCodePoint(...codePoints);
};
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('');
// Country comes straight from the AsYouType call inside `onInput` so
// the formatter runs once per keystroke (instead of once for
// formatting and once more in a useMemo for the flag).
const [country, setCountry] = useState(undefined);
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();
}, []);
// Wire-format value (no spaces, single leading `+`) — what we send to
// the bridge, and what libphonenumber-js validates. Both helpers are
// cheap (regex on a 10-char string, length-table lookup) and safe to
// recompute every render without memoisation.
const e164 = phoneToE164(value);
const digitsCount = e164.replace('+', '').length;
const hasEnoughDigits = digitsCount >= PHONE_MIN_DIGITS_FOR_VALIDATION;
// `isValidPhoneNumber` is a soft hint, not a hard gate: stale `/min`
// metadata can reject freshly-allocated mobile pools, and whatsmeow's
// own validator on the bridge side is authoritative. Match the
// Stripe / Auth0 / WhatsApp Web warn-don't-block pattern.
const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);
const onSubmit = async (event: Event) => {
event.preventDefault();
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
setSubmitting(true);
dispatch({ kind: 'submit_phone' });
try {
// Strip visual spaces AsYouType inserted before sending — whatsmeow
// PairPhone wants raw E.164.
await send(e164);
// 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 || !hasEnoughDigits;
const submitLabel = inCooldown
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
: t('auth-card.phone.submit');
const flagEmoji = countryToFlagEmoji(country);
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). */}