1459 lines
56 KiB
TypeScript
1459 lines
56 KiB
TypeScript
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 = () => (
|
||
<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>
|
||
);
|
||
|
||
// Smartphone outline — leads the «Войти по номеру» card. Shared shape
|
||
// across TG (text-login) and WhatsApp (pairing-code login) since both
|
||
// flows boil down to «вы вводите номер с телефона».
|
||
const PhoneIcon = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<rect x="6" y="2.5" width="8" height="15" rx="1.6" />
|
||
<line x1="8.6" y1="14.5" x2="11.4" y2="14.5" stroke-linecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
// 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 = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<rect x="3" y="3" width="5" height="5" rx="0.6" />
|
||
<rect x="12" y="3" width="5" height="5" rx="0.6" />
|
||
<rect x="3" y="12" width="5" height="5" rx="0.6" />
|
||
<path
|
||
d="M12 12 H13.5 M15.5 12 H17 M12 14.5 H14 M16 14.5 H17 M12 17 H13.5 M15.5 17 H17"
|
||
stroke-linecap="round"
|
||
/>
|
||
</svg>
|
||
);
|
||
|
||
// 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 = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<path d="M11 3.5 H4.5 V16.5 H11" stroke-linecap="round" stroke-linejoin="round" />
|
||
<line x1="9" y1="10" x2="17" y2="10" stroke-linecap="round" />
|
||
<path d="M14 7 L17 10 L14 13" stroke-linecap="round" stroke-linejoin="round" />
|
||
</svg>
|
||
);
|
||
|
||
// 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 = () => (
|
||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||
<path
|
||
d="M10 3.2 L17.5 16.5 L2.5 16.5 Z"
|
||
stroke-linecap="round"
|
||
stroke-linejoin="round"
|
||
/>
|
||
<path d="M10 8.5 L10 12" stroke-linecap="round" />
|
||
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
|
||
</svg>
|
||
);
|
||
|
||
// 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 `<digit>@<base64>` (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(
|
||
<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;
|
||
};
|
||
|
||
// 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<LoginAction>;
|
||
send: (body: string) => 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.
|
||
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<unknown>): 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<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);
|
||
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 (
|
||
<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) => {
|
||
// Auto-prepend `+` so the user never has to remember to type
|
||
// it — the connector's PHONE_NUMBER_NOT_INTERNATIONAL error
|
||
// fires for anything without a leading `+` (whatsmeow
|
||
// PairPhone's validator). Skipping locale-specific
|
||
// formatting (8→+7 etc.) keeps the rule single-line.
|
||
//
|
||
// trimStart on the raw input so that a paste of « +12345…»
|
||
// (some clipboard sources include a leading space) still
|
||
// resolves to a single `+`, instead of producing the
|
||
// double-prefix `+ +12345…` bridgev2 then rejects.
|
||
const raw = (e.currentTarget as HTMLInputElement).value.trimStart();
|
||
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
|
||
}}
|
||
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>
|
||
);
|
||
};
|
||
|
||
// --------------------------------------------------------------------------
|
||
// 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 <rect> 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(
|
||
<rect
|
||
key={`${r}-${c}`}
|
||
x={(c + margin) * cellPx}
|
||
y={(r + margin) * cellPx}
|
||
width={cellPx + 0.5 /* +0.5 px overlap kills subpixel gaps on Android */}
|
||
height={cellPx + 0.5}
|
||
fill="#000"
|
||
/>
|
||
);
|
||
}
|
||
}
|
||
return (
|
||
<svg
|
||
width={pixelSize}
|
||
height={pixelSize}
|
||
viewBox={`0 0 ${pixelSize} ${pixelSize}`}
|
||
role="img"
|
||
aria-label={ariaLabel}
|
||
>
|
||
{rects}
|
||
</svg>
|
||
);
|
||
};
|
||
|
||
type QrPanelProps = {
|
||
state: {
|
||
kind: 'awaiting_qr_scan';
|
||
qrData: string;
|
||
firstShownAt: number;
|
||
lastError?: LoginErrorFlag;
|
||
};
|
||
t: T;
|
||
sendCancel: () => Promise<void>;
|
||
};
|
||
|
||
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 (
|
||
<div class="auth-card auth-card-qr">
|
||
<div class="auth-card-title">{t('auth-card.qr.title')}</div>
|
||
<div class="auth-card-hint">{t('auth-card.qr.hint')}</div>
|
||
<div class="auth-card-qr-frame">
|
||
{matrix ? (
|
||
<QrSvg matrix={matrix} pixelSize={232} ariaLabel={t('auth-card.qr.aria')} />
|
||
) : (
|
||
<div class="auth-card-qr-placeholder" role="status" aria-live="polite">
|
||
<span class="dot" />
|
||
{t('auth-card.qr.preparing')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!expired ? (
|
||
<div class="auth-card-countdown">
|
||
{t('auth-card.qr.countdown', {
|
||
minutes: String(Math.floor(remainingSeconds / 60)),
|
||
seconds: String(remainingSeconds % 60).padStart(2, '0'),
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div class="auth-card-countdown expired">{t('auth-card.qr.expired')}</div>
|
||
)}
|
||
<ol class="auth-card-qr-steps">
|
||
<li>{t('auth-card.qr.step-1')}</li>
|
||
<li>{t('auth-card.qr.step-2')}</li>
|
||
<li>{t('auth-card.qr.step-3')}</li>
|
||
</ol>
|
||
{state.lastError ? (
|
||
<div class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||
{localizeError(state.lastError, t)}
|
||
</div>
|
||
) : null}
|
||
<div class="auth-card-row">
|
||
<button type="button" class="btn-text" onClick={sendCancel}>
|
||
{t('auth-card.cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --------------------------------------------------------------------------
|
||
// 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<void>;
|
||
};
|
||
|
||
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 (
|
||
<div class="auth-card auth-card-pairing">
|
||
<div class="auth-card-title">{t('auth-card.pairing-code.title')}</div>
|
||
<div class="auth-card-hint">{t('auth-card.pairing-code.hint')}</div>
|
||
<div class="auth-card-pairing-frame">
|
||
{state.code ? (
|
||
// <output> is the semantic element for "result of a process"
|
||
// (HTML form-results spec); screenreaders read the digits from
|
||
// its text content. The supplemental purpose-description is
|
||
// attached via aria-describedby on a hidden sibling so it
|
||
// doesn't override the digits in screenreader output (frontend
|
||
// review #2: a `<div>` with both aria-label and visible text
|
||
// can hide the digits behind the label in NVDA/JAWS).
|
||
// user-select: all on the text element keeps one-tap copy
|
||
// working on touch devices.
|
||
<>
|
||
<output
|
||
class="auth-card-pairing-code-text"
|
||
aria-describedby="auth-pairing-code-desc"
|
||
>
|
||
{state.code}
|
||
</output>
|
||
<span id="auth-pairing-code-desc" class="visually-hidden">
|
||
{t('auth-card.pairing-code.aria')}
|
||
</span>
|
||
</>
|
||
) : (
|
||
<div class="auth-card-pairing-placeholder" role="status" aria-live="polite">
|
||
<span class="dot" />
|
||
{t('auth-card.pairing-code.preparing')}
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!expired ? (
|
||
<div class="auth-card-countdown">
|
||
{t('auth-card.pairing-code.countdown', {
|
||
minutes: String(Math.floor(remainingSeconds / 60)),
|
||
seconds: String(remainingSeconds % 60).padStart(2, '0'),
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div class="auth-card-countdown expired">
|
||
{t('auth-card.pairing-code.expired')}
|
||
</div>
|
||
)}
|
||
<ol class="auth-card-pairing-steps">
|
||
<li>{t('auth-card.pairing-code.step-1')}</li>
|
||
<li>{t('auth-card.pairing-code.step-2')}</li>
|
||
<li>{t('auth-card.pairing-code.step-3')}</li>
|
||
<li>{t('auth-card.pairing-code.step-4')}</li>
|
||
</ol>
|
||
{state.lastError ? (
|
||
<div class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||
{localizeError(state.lastError, t)}
|
||
</div>
|
||
) : null}
|
||
<div class="auth-card-row">
|
||
<button type="button" class="btn-text" onClick={sendCancel}>
|
||
{t('auth-card.cancel')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --------------------------------------------------------------------------
|
||
// 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) => (
|
||
<button class="command-card warn" type="button" onClick={onOpen}>
|
||
<span class="command-card-lead-icon" aria-hidden="true">
|
||
<WarningIcon />
|
||
</span>
|
||
<div class="command-card-body">
|
||
<div class="command-card-name">{t('card.about.name')}</div>
|
||
<div class="command-card-desc">{t('card.about.desc')}</div>
|
||
</div>
|
||
<span class="command-card-chevron" aria-hidden="true">
|
||
›
|
||
</span>
|
||
</button>
|
||
);
|
||
|
||
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 (
|
||
<div
|
||
class="about-overlay"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={t('about.title')}
|
||
onClick={(e) => {
|
||
if (e.target === e.currentTarget) onClose();
|
||
}}
|
||
>
|
||
<div class="about-panel">
|
||
<header class="about-header">
|
||
<h2 class="about-title">{t('about.title')}</h2>
|
||
<button
|
||
type="button"
|
||
class="about-close-x"
|
||
onClick={onClose}
|
||
aria-label={t('about.aria-close')}
|
||
>
|
||
×
|
||
</button>
|
||
</header>
|
||
<div class="about-body">
|
||
{/* 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). */}
|
||
<aside class="about-warn-callout" aria-label={t('warning.title')}>
|
||
<header class="about-warn-callout-head">
|
||
<span class="about-warn-callout-icon" aria-hidden="true">
|
||
<WarningIcon />
|
||
</span>
|
||
<h3 class="about-warn-callout-title">{t('warning.title')}</h3>
|
||
</header>
|
||
<p>{t('warning.body-1')}</p>
|
||
<p>
|
||
{t('warning.tos-label')}{' '}
|
||
<a href={t('warning.tos-url')} target="_blank" rel="noreferrer noopener">
|
||
{t('warning.tos-url')}
|
||
</a>
|
||
</p>
|
||
</aside>
|
||
<p>{t('about.body-1')}</p>
|
||
<p>{t('about.body-2')}</p>
|
||
<p>{t('about.body-3')}</p>
|
||
<p>
|
||
{t('about.github-label')}{' '}
|
||
<a href={t('about.github-url')} target="_blank" rel="noreferrer noopener">
|
||
{t('about.github-url')}
|
||
</a>
|
||
</p>
|
||
<p>{t('about.body-4')}</p>
|
||
</div>
|
||
<div class="about-footer">
|
||
<button type="button" class="btn-primary" onClick={onClose}>
|
||
{t('about.close')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// --------------------------------------------------------------------------
|
||
// 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);
|
||
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}
|
||
>
|
||
<span class="command-card-lead-icon" aria-hidden="true">
|
||
<LogoutIcon />
|
||
</span>
|
||
<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 [aboutOpen, setAboutOpen] = useState(false);
|
||
const [refreshing, setRefreshing] = useState(false);
|
||
const seenEventIds = useRef(new Set<string>());
|
||
const [state, dispatch] = useReducer(loginReducer, initialLoginState);
|
||
|
||
// Mirror latest state for async callbacks (live event listeners attached
|
||
// once at mount). Used for the QR-redaction transcript gate (only show
|
||
// «QR использован» when the redaction targets the active QR).
|
||
const stateRef = useRef(state);
|
||
useEffect(() => {
|
||
stateRef.current = state;
|
||
}, [state]);
|
||
|
||
const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]);
|
||
|
||
useEffect(() => {
|
||
document.documentElement.dataset.theme = theme;
|
||
}, [theme]);
|
||
|
||
// Capture-phase click interceptor for `<a target="_blank">` —
|
||
// inside Capacitor's Android WebView, cross-origin iframes silently
|
||
// drop those clicks (the WebView has no multi-window concept and
|
||
// the host's setupExternalLinkHandler can't see them across the
|
||
// origin boundary). We preventDefault and ask the host to open the
|
||
// URL via Browser.open / window.open. The host's web-side fallback
|
||
// (allow-popups in the iframe sandbox) still works without us, but
|
||
// routing every click through the host gives one consistent code
|
||
// path for both web and native instead of two race-prone ones.
|
||
useEffect(() => {
|
||
const onClick = (e: MouseEvent) => {
|
||
const anchor = (e.target as HTMLElement | null)?.closest?.(
|
||
'a[target="_blank"]'
|
||
) as HTMLAnchorElement | null;
|
||
if (!anchor?.href) return;
|
||
// Allow modifier-clicks (Ctrl/Cmd-click → open in background tab,
|
||
// Shift-click → new window) to keep their browser-native
|
||
// behaviour on web. preventDefault would override these.
|
||
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
||
e.preventDefault();
|
||
api.openExternalUrl(anchor.href);
|
||
};
|
||
document.addEventListener('click', onClick, true);
|
||
return () => document.removeEventListener('click', onClick, true);
|
||
}, [api]);
|
||
|
||
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;
|
||
});
|
||
}, []);
|
||
|
||
// Newest-at-top ordering: pin scroll to TOP whenever a new line lands.
|
||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||
useEffect(() => {
|
||
const el = transcriptRef.current;
|
||
if (!el) return;
|
||
el.scrollTop = 0;
|
||
}, [transcript.length]);
|
||
|
||
// App-level cooldown state for phone-form Send Code button.
|
||
const [phoneCooldownEnd, setPhoneCooldownEnd] = useState<number | null>(null);
|
||
|
||
// Clear the cooldown across two distinct triggers:
|
||
// 1. Bridge rejected the phone number client-side (`invalid_value`)
|
||
// — the value was rejected at validate-time, BEFORE the WhatsApp
|
||
// dispatch, so no rate-limited capacity was used. Punishing the
|
||
// user for a typo would be wrong. The cooldown stays in place
|
||
// for `submit_failed` (WhatsApp-side rate limit etc.).
|
||
// 2. State left awaiting_phone via cancel / logout / login_success
|
||
// — the previous flow ended; a fresh login attempt is a fresh
|
||
// conversation (functional review #9). Clearing on every
|
||
// non-form / non-pairing-form state covers that without
|
||
// enumerating individual transitions.
|
||
useEffect(() => {
|
||
if (phoneCooldownEnd === null) return;
|
||
const formStillOpen =
|
||
state.kind === 'awaiting_phone' ||
|
||
state.kind === 'awaiting_pairing_code' ||
|
||
state.kind === 'pairing_code_shown';
|
||
const phoneInvalidValue =
|
||
state.kind === 'awaiting_phone' && state.lastError?.kind === 'invalid_value';
|
||
if (!formStillOpen || phoneInvalidValue) {
|
||
setPhoneCooldownEnd(null);
|
||
}
|
||
}, [state, phoneCooldownEnd]);
|
||
|
||
useEffect(() => {
|
||
let disposed = false;
|
||
|
||
api.on('ready', () => {
|
||
setHandshakeOk(true);
|
||
append({ kind: 'diag', text: t('diag.ready') });
|
||
append({ kind: 'diag', text: t('diag.checking-status') });
|
||
|
||
void (async () => {
|
||
// Timeline-resume scan: read recent history BEFORE firing
|
||
// list-logins so reload-mid-flow restores the pending form
|
||
// (phone, pairing-code, QR) the user actually had open.
|
||
let hydrated = false;
|
||
try {
|
||
// Promise.allSettled (not all): one stream's failure must not
|
||
// take down the others. The notice path is the most useful
|
||
// single source — it carries phone-prompt, pairing-code
|
||
// instructions, the code itself, and login-success confirms.
|
||
const settled = await Promise.allSettled([
|
||
api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }),
|
||
// QR images: whatsmeow rotates ~every 20 s after the first
|
||
// 60 s. Total active window 2 min 40 s = 6 events. Limit 50
|
||
// gives plenty of headroom for slower rotations and
|
||
// out-of-order delivery.
|
||
api.readTimeline({ limit: 50, type: 'm.room.message', msgtype: 'm.image' }),
|
||
api.readTimeline({ limit: 10, type: 'm.room.redaction' }),
|
||
]);
|
||
if (disposed) return;
|
||
const pickValue = (s: PromiseSettledResult<RoomEvent[]>): RoomEvent[] =>
|
||
s.status === 'fulfilled' ? s.value : [];
|
||
const notices = pickValue(settled[0]);
|
||
const qrImages = pickValue(settled[1]);
|
||
const redactions = pickValue(settled[2]);
|
||
|
||
const fromBot = (events: RoomEvent[]) =>
|
||
events.filter((e) => e.sender === bootstrap.botMxid);
|
||
// Sort by origin_server_ts ascending, tie-break on event_id —
|
||
// see widget-telegram for full rationale of deterministic
|
||
// tie-breaking on simultaneous events from different streams.
|
||
const merged = [...fromBot(notices), ...fromBot(qrImages), ...fromBot(redactions)].sort(
|
||
(a, b) => {
|
||
const tsDiff = a.origin_server_ts - b.origin_server_ts;
|
||
if (tsDiff !== 0) return tsDiff;
|
||
return a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0;
|
||
}
|
||
);
|
||
|
||
const inputs: HydrateInput[] = merged.map((e) => ({
|
||
ev: parseEvent(e),
|
||
ts: e.origin_server_ts,
|
||
}));
|
||
const restored = hydrateFromTimeline(inputs);
|
||
|
||
if (restored) {
|
||
// Conservative transcript replay. Body for m.image is the
|
||
// raw whatsmeow QR payload (login secret) — replaced with a
|
||
// generic «QR обновлён» diag. Body for m.notice carrying a
|
||
// pairing-code is replaced with a generic «Код для входа
|
||
// выдан» diag for the same defence-in-depth reason: even
|
||
// though the code is shown in the panel, the transcript
|
||
// shouldn't carry a copy that survives a code rotation.
|
||
// m.text user echoes are NOT replayed (would resurface the
|
||
// user's phone number from history).
|
||
//
|
||
// Dedupe via seenEventIds: a live event for the same
|
||
// notice/image/redaction may already have arrived during
|
||
// the readTimeline await.
|
||
let appendedAnyHistory = false;
|
||
const seenQrIds = new Set<string>();
|
||
for (const e of merged) {
|
||
if (seenEventIds.current.has(e.event_id)) continue;
|
||
seenEventIds.current.add(e.event_id);
|
||
const parsed = parseEvent(e);
|
||
if (parsed.kind === 'qr_displayed') {
|
||
seenQrIds.add(parsed.eventId);
|
||
if (parsed.replacesEventId) seenQrIds.add(parsed.replacesEventId);
|
||
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||
appendedAnyHistory = true;
|
||
} else if (parsed.kind === 'qr_redacted') {
|
||
if (seenQrIds.has(parsed.redactsEventId)) {
|
||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||
appendedAnyHistory = true;
|
||
}
|
||
} else if (parsed.kind === 'pairing_code_displayed') {
|
||
// Don't echo the code itself — the panel handles
|
||
// display, transcript stays neutral.
|
||
append({ kind: 'diag', text: t('diag.pairing-code-issued') });
|
||
appendedAnyHistory = true;
|
||
} else if (parsed.kind === 'connection_warning') {
|
||
append({
|
||
kind: 'diag',
|
||
text: t('diag.connection-warning', { text: parsed.text }),
|
||
});
|
||
appendedAnyHistory = true;
|
||
} else if (parsed.kind === 'external_logout') {
|
||
append({ kind: 'error', text: t('diag.external-logout') });
|
||
appendedAnyHistory = true;
|
||
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||
// m.text / m.notice — body is safe to replay verbatim
|
||
// AFTER scrubbing any QR-shaped substring (defence-in-
|
||
// depth: a future bridge could in theory leak the
|
||
// payload into a notice).
|
||
const bodyRaw = e.content.body ?? '';
|
||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(bodyRaw)}` });
|
||
appendedAnyHistory = true;
|
||
}
|
||
}
|
||
if (appendedAnyHistory) {
|
||
append({ kind: 'diag', text: t('diag.history-marker') });
|
||
}
|
||
|
||
dispatch({ kind: 'hydrate', state: restored });
|
||
hydrated = true;
|
||
}
|
||
} catch {
|
||
if (!disposed) {
|
||
append({ kind: 'diag', text: t('diag.history-unavailable') });
|
||
}
|
||
}
|
||
|
||
if (disposed) return;
|
||
if (!hydrated) {
|
||
api.sendCommand('list-logins').catch((err) => {
|
||
if (disposed) return;
|
||
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);
|
||
// Sender filter — the strict 1:1 invariant already pins the only
|
||
// senders to user + bot, but anchoring on bootstrap.botMxid covers
|
||
// (a) skipping our own outbound echoes (we append optimistically
|
||
// with masking) and (b) defence-in-depth against any third-party
|
||
// noise.
|
||
if (ev.sender !== bootstrap.botMxid) return;
|
||
|
||
const event = parseEvent(ev);
|
||
|
||
// Transcript routing GATED on parser verdict, not raw event type.
|
||
if (event.kind === 'qr_displayed') {
|
||
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||
} else if (event.kind === 'qr_redacted') {
|
||
const liveState = stateRef.current;
|
||
if (
|
||
liveState.kind === 'awaiting_qr_scan' &&
|
||
liveState.qrEventId === event.redactsEventId
|
||
) {
|
||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||
}
|
||
} else if (event.kind === 'pairing_code_displayed') {
|
||
// Same scrubbing principle as QR — never put the code body
|
||
// verbatim into the transcript. Panel renders the code; here
|
||
// we just log a neutral diag.
|
||
append({ kind: 'diag', text: t('diag.pairing-code-issued') });
|
||
} else if (event.kind === 'connection_warning') {
|
||
// Bridge connection hiccup — surface verbatim wording so the
|
||
// user can see what happened, but DON'T touch state.
|
||
append({
|
||
kind: 'diag',
|
||
text: t('diag.connection-warning', { text: event.text }),
|
||
});
|
||
} else if (event.kind === 'external_logout') {
|
||
// Hard transition (handled by reducer below) + a louder
|
||
// transcript echo than ordinary diag lines.
|
||
append({ kind: 'error', text: t('diag.external-logout') });
|
||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||
const body = ev.content.body ?? '';
|
||
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
|
||
}
|
||
|
||
dispatch({ kind: 'event', event });
|
||
|
||
// After a fresh login_success the bridge's success line doesn't
|
||
// include the loginId. 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(() => {
|
||
/* connected hero still works without a loginId until the
|
||
user clicks logout; the gated tooltip guides them */
|
||
});
|
||
}
|
||
});
|
||
|
||
append({ kind: 'diag', text: t('diag.connecting') });
|
||
|
||
return () => {
|
||
disposed = true;
|
||
api.dispose();
|
||
};
|
||
// `api`, `bootstrap`, `t`, and `append` are stable for App's
|
||
// lifetime; the effect intentionally runs once at mount.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Outbound command + transcript echo. Errors are appended AND rethrown
|
||
// — callers decide whether to roll back optimistic state transitions.
|
||
const send = useCallback(
|
||
async (body: string): Promise<void> => {
|
||
append({ kind: 'from-user', text: `→ ${body}` });
|
||
try {
|
||
await api.sendCommand(body);
|
||
} catch (err) {
|
||
append({
|
||
kind: 'error',
|
||
text: t('diag.send-failed', { message: (err as Error).message }),
|
||
});
|
||
throw err;
|
||
}
|
||
},
|
||
[api, append, t]
|
||
);
|
||
|
||
const sendBare = useCallback(
|
||
async (command: string): Promise<void> => {
|
||
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;
|
||
}
|
||
},
|
||
[api, append, t]
|
||
);
|
||
|
||
const sendCancel = useCallback(async () => {
|
||
dispatch({ kind: 'cancel_pending' });
|
||
try {
|
||
await sendBare('cancel');
|
||
} catch {
|
||
/* already showing disconnected; transcript carries the failure */
|
||
}
|
||
}, [sendBare]);
|
||
|
||
// In-flight guard against double-tap. The buttons are on the
|
||
// disconnected screen which unmounts as soon as state advances, BUT a
|
||
// rapid second click can fire in the microtask window between dispatch
|
||
// and the next React commit (especially on Android WebView).
|
||
const loginInFlight = useRef(false);
|
||
|
||
// QR-flow: optimistic awaiting_qr_scan + rollback on send failure.
|
||
const onClickLoginQr = useCallback(async () => {
|
||
if (loginInFlight.current) return;
|
||
loginInFlight.current = true;
|
||
dispatch({ kind: 'start_qr_login' });
|
||
try {
|
||
// Send the FULL command — bare `!wa login` would trigger
|
||
// flow_required because WhatsApp has 2 flows.
|
||
await sendBare('login qr');
|
||
} catch {
|
||
dispatch({ kind: 'cancel_pending' });
|
||
} finally {
|
||
loginInFlight.current = false;
|
||
}
|
||
}, [sendBare]);
|
||
|
||
// Phone-flow (pairing-code): optimistic awaiting_phone + rollback.
|
||
const onClickLoginPairing = useCallback(async () => {
|
||
if (loginInFlight.current) return;
|
||
loginInFlight.current = true;
|
||
dispatch({ kind: 'start_phone_login' });
|
||
try {
|
||
await sendBare('login phone');
|
||
} catch {
|
||
dispatch({ kind: 'cancel_pending' });
|
||
} finally {
|
||
loginInFlight.current = false;
|
||
}
|
||
}, [sendBare]);
|
||
|
||
const onClickRefresh = useCallback(async () => {
|
||
if (refreshing) return;
|
||
setRefreshing(true);
|
||
const start = Date.now();
|
||
try {
|
||
await sendBare('list-logins');
|
||
} catch {
|
||
/* transcript carries the failure */
|
||
}
|
||
// 500 ms minimum visible loading state — matches TG widget rationale.
|
||
const elapsed = Date.now() - start;
|
||
if (elapsed < 500) {
|
||
await new Promise<void>((resolve) => {
|
||
window.setTimeout(resolve, 500 - elapsed);
|
||
});
|
||
}
|
||
setRefreshing(false);
|
||
}, [refreshing, sendBare]);
|
||
|
||
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,
|
||
};
|
||
|
||
// Disconnected-screen warning banner. Shows whenever we land in
|
||
// `disconnected` with a structured lastError — gives the user a
|
||
// visible explanation instead of an unexplained return to the
|
||
// command grid (functional review #22: a QR-window timeout
|
||
// surfaces as `login_failed: Entering code or scanning QR timed
|
||
// out. Please try again.` and without this banner the user sees
|
||
// only the disappearance of the QR panel and no «why»).
|
||
//
|
||
// external_logout is the loudest case (the bridge is genuinely
|
||
// gone), so it stays at amber tone. Other lastError shapes
|
||
// (login_failed / start_failed / prepare_failed / max_logins /
|
||
// unknown_command) get the same banner — they're equally worth
|
||
// surfacing, and a unified affordance is simpler than per-class
|
||
// chrome.
|
||
const disconnectedBanner =
|
||
state.kind === 'disconnected' && state.lastError ? (
|
||
<div class="section-warn-banner" role="status">
|
||
<span class="dot" />
|
||
<span>{localizeError(state.lastError, t)}</span>
|
||
</div>
|
||
) : null;
|
||
|
||
return (
|
||
<div class="app">
|
||
{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>
|
||
<button type="button" class="recovery-action" onClick={onClickRefresh}>
|
||
<RefreshIcon />
|
||
{t('card.refresh.label')}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{handshakeOk && state.kind === 'disconnected' ? (
|
||
<section class="section">
|
||
{disconnectedBanner}
|
||
<span class="section-status disconnected" role="status">
|
||
<span class="dot" />
|
||
{t('status.disconnected')}
|
||
</span>
|
||
<div class="command-grid">
|
||
{/* Login order mirrors the Telegram widget: phone-flow
|
||
* («Войти по номеру») first, QR second. Both are valid
|
||
* primary paths; phone-flow is the more familiar entry
|
||
* point for users coming from Telegram or used to
|
||
* SMS-style codes, so it leads. The Meta-ToS risk
|
||
* disclosure now lives inside the AboutCard modal as
|
||
* an amber callout — the hybrid card description tells
|
||
* the user it's there. */}
|
||
<button class="command-card" type="button" onClick={onClickLoginPairing}>
|
||
<span class="command-card-lead-icon" aria-hidden="true">
|
||
<PhoneIcon />
|
||
</span>
|
||
<div class="command-card-body">
|
||
<div class="command-card-name">{t('card.login-pairing.name')}</div>
|
||
<div class="command-card-desc">{t('card.login-pairing.desc')}</div>
|
||
</div>
|
||
<span class="command-card-chevron" aria-hidden="true">
|
||
›
|
||
</span>
|
||
</button>
|
||
<button class="command-card" type="button" onClick={onClickLoginQr}>
|
||
<span class="command-card-lead-icon" aria-hidden="true">
|
||
<QrIcon />
|
||
</span>
|
||
<div class="command-card-body">
|
||
<div class="command-card-name">{t('card.login-qr.name')}</div>
|
||
<div class="command-card-desc">{t('card.login-qr.desc')}</div>
|
||
</div>
|
||
<span class="command-card-chevron" aria-hidden="true">
|
||
›
|
||
</span>
|
||
</button>
|
||
<button
|
||
class={`command-card${refreshing ? ' refreshing' : ''}`}
|
||
type="button"
|
||
onClick={onClickRefresh}
|
||
disabled={refreshing}
|
||
>
|
||
<span class="command-card-lead-icon" aria-hidden="true">
|
||
<RefreshIcon />
|
||
</span>
|
||
<div class="command-card-body">
|
||
<div class="command-card-name">{t('card.refresh.name')}</div>
|
||
<div class="command-card-desc">
|
||
{refreshing ? t('card.refresh.in-flight') : t('card.refresh.desc')}
|
||
</div>
|
||
</div>
|
||
<span class="command-card-chevron" aria-hidden="true">
|
||
›
|
||
</span>
|
||
</button>
|
||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{state.kind === 'awaiting_phone' ? (
|
||
<section class="section">
|
||
<PhoneForm {...formProps} />
|
||
</section>
|
||
) : null}
|
||
{state.kind === 'awaiting_pairing_code' ? (
|
||
<section class="section">
|
||
<div class="section-recovery-row">
|
||
<span class="section-status checking" role="status">
|
||
<span class="dot" />
|
||
{t('auth-card.pairing-code.preparing')}
|
||
</span>
|
||
<button type="button" class="recovery-action" onClick={sendCancel}>
|
||
{t('auth-card.cancel')}
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
{state.kind === 'pairing_code_shown' ? (
|
||
<section class="section">
|
||
<PairingCodePanel state={state} t={t} sendCancel={sendCancel} />
|
||
</section>
|
||
) : null}
|
||
{state.kind === 'awaiting_qr_scan' ? (
|
||
<section class="section">
|
||
<QrPanel state={state} t={t} sendCancel={sendCancel} />
|
||
</section>
|
||
) : null}
|
||
{/* `pairing_verifying` is reserved-but-unreachable from the live
|
||
* reducer today — the bridge does not redact the pairing-code on
|
||
* success. Hydrate could in principle restore it (and would, if a
|
||
* future bridge ever adds redaction), so we keep the rendering
|
||
* branch alive. See state.ts «pairing_verifying» comment. */}
|
||
{state.kind === 'qr_verifying' || state.kind === 'pairing_verifying' ? (
|
||
<section class="section">
|
||
<div class="section-recovery-row">
|
||
<span class="section-status checking" role="status">
|
||
<span class="dot" />
|
||
{state.kind === 'qr_verifying'
|
||
? t('status.qr-verifying')
|
||
: t('status.pairing-verifying')}
|
||
</span>
|
||
{/* Recovery refresh — if the bridge stalls between
|
||
* scan-accept and the success/failure follow-up, refresh
|
||
* fires `list-logins` to recalibrate. */}
|
||
<button type="button" class="recovery-action" onClick={onClickRefresh}>
|
||
<RefreshIcon />
|
||
{t('card.refresh.label')}
|
||
</button>
|
||
</div>
|
||
</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>
|
||
<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>
|
||
<button type="button" class="recovery-action" onClick={onClickRefresh}>
|
||
<RefreshIcon />
|
||
{t('card.refresh.label')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div class="command-grid">
|
||
<LogoutCard loginId={state.loginId} t={t} onConfirm={onConfirmLogout} />
|
||
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||
</div>
|
||
</section>
|
||
) : null}
|
||
|
||
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}
|
||
|
||
<section class="section">
|
||
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
|
||
{transcript.length === 0 ? (
|
||
<div class="transcript-empty">{/* placeholder kept blank intentionally */}</div>
|
||
) : (
|
||
transcript
|
||
.slice()
|
||
.reverse()
|
||
.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>
|
||
);
|
||
}
|