feat(bridge-widgets): format phone input with libphonenumber-js AsYouType, show country flag, soft-warn on invalid before letting bridgev2 arbitrate
This commit is contained in:
parent
7b3a4145a7
commit
6c052bbff9
12 changed files with 332 additions and 91 deletions
7
apps/widget-telegram/package-lock.json
generated
7
apps/widget-telegram/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "@vojo/widget-telegram",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
|
|
@ -1611,6 +1612,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz",
|
||||
"integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "^1.4.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,12 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'p
|
|||
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. Avoid `/max`
|
||||
// (~60 KB) since the widget is a separate Preact bundle and ships into
|
||||
// the bot iframe on cold start.
|
||||
import { AsYouType, isValidPhoneNumber } from 'libphonenumber-js/min';
|
||||
import type { WidgetBootstrap } from './bootstrap';
|
||||
import { WidgetApi, type RoomEvent } from './widget-api';
|
||||
import { createT, type T } from './i18n';
|
||||
|
|
@ -222,6 +228,49 @@ type FormProps = {
|
|||
// 60 s matches Telegram Desktop's own "Resend code" lockout.
|
||||
const PHONE_COOLDOWN_MS = 60_000;
|
||||
|
||||
// Minimum digit count before we'd dare call a number «invalid». Below
|
||||
// this threshold the user is still typing the country prefix and the
|
||||
// formatter has nothing to validate against — showing red here would
|
||||
// blink at every keystroke. 7 covers single-digit calling codes (US/RU
|
||||
// = 1 + 6 digits is the shortest reasonable subscriber number).
|
||||
const PHONE_MIN_DIGITS_FOR_VALIDATION = 7;
|
||||
|
||||
// Strip every character that isn't `+` or a digit, then guarantee a
|
||||
// single leading `+` (bridgev2's E.164 validator rejects anything
|
||||
// without it). Used both as the AsYouType input AND as the wire-format
|
||||
// stripped value sent to the bridge — so paste-friendly cleanup
|
||||
// («+1 (213) 373-4253», «+7-905-…») falls out for free.
|
||||
const phoneToE164 = (raw: string): string => {
|
||||
const cleaned = raw.replace(/[^\d+]/g, '');
|
||||
if (cleaned.length === 0) return '';
|
||||
return cleaned.startsWith('+') ? cleaned : `+${cleaned}`;
|
||||
};
|
||||
|
||||
// AsYouType is stateful — calling `.input()` repeatedly with a growing
|
||||
// string mutates the internal char buffer. Use a fresh instance per
|
||||
// call so editing in the middle of the string (paste, backspace) can't
|
||||
// desync the formatter state from the input value.
|
||||
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' → 🇷🇺.
|
||||
// Browsers without flag-emoji fonts (Windows Chrome) fall back to the
|
||||
// two-letter code rendered as letter glyphs, which is 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);
|
||||
};
|
||||
|
||||
// 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.
|
||||
|
|
@ -271,6 +320,11 @@ const PhoneForm = ({
|
|||
setPhoneCooldownEnd,
|
||||
}: FormProps) => {
|
||||
const [value, setValue] = useState('');
|
||||
// Country is captured directly from the AsYouType formatter in
|
||||
// `onInput` so we don't run AsYouType a second time per keystroke
|
||||
// just to read `getCountry()` — keeps the formatter call count at
|
||||
// one per actual user input event instead of one per render.
|
||||
const [country, setCountry] = useState<string | undefined>(undefined);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stillWaiting = useStillWaitingHint([submitting]);
|
||||
|
|
@ -282,17 +336,35 @@ const PhoneForm = ({
|
|||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Wire-format value (no spaces, single leading `+`) — what we send to
|
||||
// the bridge, and what libphonenumber-js validates. `phoneToE164` is
|
||||
// a regex on a 10-char string and `isValidPhoneNumber` (`/min`
|
||||
// metadata) is a length-table lookup — both 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` from `/min` metadata is intentionally treated as
|
||||
// a soft hint, not a hard gate: the libphonenumber-js README itself
|
||||
// warns that strict validation can reject newly-allocated mobile pools
|
||||
// until the package is bumped, and bridgev2 has the authoritative word
|
||||
// (it replies `invalid_value` + the App-level effect clears the
|
||||
// cooldown). Matches Stripe / Auth0 / WhatsApp Web's warn-don't-block
|
||||
// pattern.
|
||||
const showInvalidHint = hasEnoughDigits && !isValidPhoneNumber(e164);
|
||||
|
||||
const onSubmit = async (event: Event) => {
|
||||
event.preventDefault();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || submitting || inCooldown) return;
|
||||
if (!e164 || submitting || inCooldown || !hasEnoughDigits) 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');
|
||||
// Strip the visual spaces / dashes AsYouType inserted before sending
|
||||
// — bridgev2 normalises but server-side validation expects raw E.164.
|
||||
await send(e164, '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
|
||||
|
|
@ -309,10 +381,11 @@ const PhoneForm = ({
|
|||
};
|
||||
|
||||
const tone = error ? errorTone(error) : undefined;
|
||||
const submitDisabled = submitting || inCooldown || value.trim() === '';
|
||||
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 (
|
||||
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
||||
|
|
@ -321,27 +394,42 @@ const PhoneForm = ({
|
|||
{t('auth-card.phone.label')}
|
||||
</label>
|
||||
<div class="auth-card-row">
|
||||
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
|
||||
{flagEmoji ? (
|
||||
<span class="auth-phone-flag" aria-hidden="true">
|
||||
{flagEmoji}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
id="auth-phone-input"
|
||||
ref={inputRef}
|
||||
class="auth-input"
|
||||
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
|
||||
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 — bridgev2 rejects anything without a leading `+` per
|
||||
// its E.164 input validator. Skipping the special-case
|
||||
// formatting (8→+7 etc.) on purpose: keeping the rule at one
|
||||
// line of logic means there's nothing to misinterpret a
|
||||
// pasted international number as a Russian trunk number.
|
||||
// Re-format on every keystroke via a fresh AsYouType. The
|
||||
// formatter strips non-digit/non-`+` chars (so pastes like
|
||||
// «+1 (213) 373-4253» normalise), auto-prepends `+` if
|
||||
// missing (bridgev2 rejects without it), and groups digits
|
||||
// by country convention. Caret jumps to end — acceptable
|
||||
// for left-to-right phone entry; mid-string edits remain
|
||||
// possible but the caret resets. Avoiding 8→+7 special-
|
||||
// casing on purpose so a paste of an international number
|
||||
// can't be misread as a Russian trunk dial. Country is
|
||||
// captured here (instead of recomputed via useMemo) so the
|
||||
// single AsYouType call covers both formatting and flag
|
||||
// detection.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
|
||||
const next = formatPhoneInput(raw);
|
||||
setValue(next.formatted);
|
||||
setCountry(next.country);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
|
@ -350,6 +438,9 @@ const PhoneForm = ({
|
|||
</button>
|
||||
</div>
|
||||
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
|
||||
{showInvalidHint && !error ? (
|
||||
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||||
{localizeError(error, t)}
|
||||
|
|
@ -1182,10 +1273,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
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
|
||||
) {
|
||||
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||
}
|
||||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||
|
|
@ -1273,7 +1361,6 @@ export function App({ bootstrap, api }: Props) {
|
|||
}
|
||||
}, [sendBare]);
|
||||
|
||||
|
||||
// In-flight guard against double-tap. The button is 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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ export const EN: Record<StringKey, string> = {
|
|||
'auth-card.phone.hint': 'SMS may take up to 30 seconds.',
|
||||
'auth-card.phone.submit': 'Send code',
|
||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
||||
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.",
|
||||
'auth-card.code.title': 'Verification code',
|
||||
'auth-card.code.label': 'SMS code',
|
||||
'auth-card.code.placeholder': '123456',
|
||||
|
|
@ -65,7 +66,8 @@ export const EN: Record<StringKey, string> = {
|
|||
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||
'auth-card.qr.step-1': 'Open Settings → Devices in the Telegram app.',
|
||||
'auth-card.qr.step-2': 'Tap “Link Device” and scan this QR code.',
|
||||
'auth-card.qr.step-3': 'If two-step verification is on, enter your cloud password on the next step.',
|
||||
'auth-card.qr.step-3':
|
||||
'If two-step verification is on, enter your cloud password on the next step.',
|
||||
'auth-error.invalid-code': 'Code is invalid. Please try again.',
|
||||
'auth-error.wrong-password': 'Password is incorrect. Please try again.',
|
||||
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
||||
|
|
|
|||
|
|
@ -71,6 +71,7 @@ export const RU = {
|
|||
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
|
||||
'auth-card.phone.submit': 'Отправить код',
|
||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
||||
// --- Code form ---------------------------------------------------------
|
||||
'auth-card.code.title': 'Код подтверждения',
|
||||
'auth-card.code.label': 'Код из SMS',
|
||||
|
|
|
|||
|
|
@ -573,6 +573,46 @@ body {
|
|||
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
||||
}
|
||||
|
||||
/* Soft-warn variant for client-side phone validation. The bridge still
|
||||
* has the authoritative word (and the cooldown clears itself on
|
||||
* `invalid_value`), so we use amber rather than the harder rose tone
|
||||
* reserved for server-confirmed errors. */
|
||||
.auth-input.warn {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.auth-input.warn:focus {
|
||||
box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22);
|
||||
}
|
||||
|
||||
/* Phone-input shell: lets us position a country-flag emoji over the
|
||||
* input's left padding without splitting the input's own background /
|
||||
* border / focus ring. The shell IS the layout flex child; the input
|
||||
* fills it. `with-flag` bumps text padding-left so the digits clear
|
||||
* the flag glyph. */
|
||||
.auth-phone-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell .auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell.with-flag .auth-input {
|
||||
padding-left: 44px;
|
||||
}
|
||||
.auth-phone-flag {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.auth-input.code,
|
||||
.auth-input.password {
|
||||
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||
|
|
|
|||
7
apps/widget-whatsapp/package-lock.json
generated
7
apps/widget-whatsapp/package-lock.json
generated
|
|
@ -8,6 +8,7 @@
|
|||
"name": "@vojo/widget-whatsapp",
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "1.4.4"
|
||||
},
|
||||
|
|
@ -1611,6 +1612,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/libphonenumber-js": {
|
||||
"version": "1.11.7",
|
||||
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.11.7.tgz",
|
||||
"integrity": "sha512-x2xON4/Qg2bRIS11KIN9yCNYUjhtiEjNyptjX0mX+pyKHecxuJVLIpfX1lq9ZD6CrC/rB+y4GBi18c6CEcUR+A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"libphonenumber-js": "^1.11.7",
|
||||
"preact": "10.22.1",
|
||||
"qrcode-generator": "1.4.4"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'p
|
|||
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';
|
||||
|
|
@ -98,11 +102,7 @@ const LogoutIcon = () => (
|
|||
// 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 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>
|
||||
|
|
@ -136,8 +136,7 @@ const URL_RE = /https?:\/\/[^\s)]+/g;
|
|||
// 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 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]');
|
||||
|
||||
|
|
@ -243,6 +242,44 @@ type FormProps = {
|
|||
// 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);
|
||||
|
|
@ -287,6 +324,10 @@ const PhoneForm = ({
|
|||
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<string | undefined>(undefined);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const stillWaiting = useStillWaitingHint([submitting]);
|
||||
|
|
@ -298,14 +339,28 @@ const PhoneForm = ({
|
|||
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();
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || submitting || inCooldown) return;
|
||||
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
|
||||
setSubmitting(true);
|
||||
dispatch({ kind: 'submit_phone' });
|
||||
try {
|
||||
await send(trimmed);
|
||||
// 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
|
||||
|
|
@ -322,10 +377,11 @@ const PhoneForm = ({
|
|||
};
|
||||
|
||||
const tone = error ? errorTone(error) : undefined;
|
||||
const submitDisabled = submitting || inCooldown || value.trim() === '';
|
||||
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 (
|
||||
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
||||
|
|
@ -334,31 +390,38 @@ const PhoneForm = ({
|
|||
{t('auth-card.phone.label')}
|
||||
</label>
|
||||
<div class="auth-card-row">
|
||||
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
|
||||
{flagEmoji ? (
|
||||
<span class="auth-phone-flag" aria-hidden="true">
|
||||
{flagEmoji}
|
||||
</span>
|
||||
) : null}
|
||||
<input
|
||||
id="auth-phone-input"
|
||||
ref={inputRef}
|
||||
class="auth-input"
|
||||
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
|
||||
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);
|
||||
// Re-format on every keystroke via a fresh AsYouType. Strips
|
||||
// non-digit / non-`+` chars (so a paste of «+1 (213) 373-4253»
|
||||
// or « +1…» normalises), auto-prepends `+` if missing
|
||||
// (whatsmeow PairPhone rejects otherwise), and groups digits
|
||||
// per country convention. Caret jumps to end on re-format —
|
||||
// acceptable for left-to-right phone entry. Country is read
|
||||
// from the same formatter call so the flag updates without
|
||||
// a second AsYouType pass.
|
||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
||||
const next = formatPhoneInput(raw);
|
||||
setValue(next.formatted);
|
||||
setCountry(next.country);
|
||||
}}
|
||||
disabled={submitting}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="btn-primary" disabled={submitDisabled}>
|
||||
{submitLabel}
|
||||
</button>
|
||||
|
|
@ -367,6 +430,9 @@ const PhoneForm = ({
|
|||
</button>
|
||||
</div>
|
||||
<div class="auth-card-hint">{t('auth-card.phone.hint')}</div>
|
||||
{showInvalidHint && !error ? (
|
||||
<div class="auth-card-warn">{t('auth-card.phone.invalid')}</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||||
{localizeError(error, t)}
|
||||
|
|
@ -556,10 +622,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
|||
}, []);
|
||||
|
||||
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
|
||||
const remainingSeconds = Math.max(
|
||||
0,
|
||||
Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)
|
||||
);
|
||||
const remainingSeconds = Math.max(0, Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000));
|
||||
const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0;
|
||||
|
||||
return (
|
||||
|
|
@ -578,10 +641,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
|||
// 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"
|
||||
>
|
||||
<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">
|
||||
|
|
@ -603,9 +663,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
|||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div class="auth-card-countdown expired">
|
||||
{t('auth-card.pairing-code.expired')}
|
||||
</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>
|
||||
|
|
@ -1058,10 +1116,7 @@ export function App({ bootstrap, api }: Props) {
|
|||
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
|
||||
) {
|
||||
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') {
|
||||
|
|
|
|||
|
|
@ -47,11 +47,13 @@ export const EN: Record<StringKey, string> = {
|
|||
'Enter your phone number including the country code. WhatsApp will then generate an 8-character pairing code that you enter in the WhatsApp app.',
|
||||
'auth-card.phone.submit': 'Get code',
|
||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
||||
'auth-card.phone.invalid': "This doesn't look like a complete international phone number.",
|
||||
'auth-card.pairing-code.title': 'Enter this code in WhatsApp',
|
||||
'auth-card.pairing-code.hint':
|
||||
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
|
||||
'auth-card.pairing-code.preparing': 'Preparing the code…',
|
||||
'auth-card.pairing-code.aria': 'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
|
||||
'auth-card.pairing-code.aria':
|
||||
'Pairing code for WhatsApp sign-in. Enter it in the app on your phone.',
|
||||
'auth-card.pairing-code.countdown': 'Time left to enter: {minutes}:{seconds}',
|
||||
'auth-card.pairing-code.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
|
||||
|
|
@ -83,8 +85,7 @@ export const EN: Record<StringKey, string> = {
|
|||
'WhatsApp unlinked this device from another device. Sign in again.',
|
||||
'auth-error.external-logout.phone-logged-out':
|
||||
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
|
||||
'auth-error.external-logout.unknown':
|
||||
'WhatsApp dropped the session. Sign in again.',
|
||||
'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.',
|
||||
'card.logout.name': 'Sign out of WhatsApp',
|
||||
'card.logout.desc': 'End the session for this account',
|
||||
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ export const RU = {
|
|||
'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.',
|
||||
'auth-card.phone.submit': 'Получить код',
|
||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
||||
// --- Pairing-code form -------------------------------------------------
|
||||
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
|
||||
'auth-card.pairing-code.hint':
|
||||
|
|
@ -104,7 +105,8 @@ export const RU = {
|
|||
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
|
||||
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
||||
'auth-card.pairing-code.step-3': 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
||||
'auth-card.pairing-code.step-3':
|
||||
'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
||||
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
|
||||
// --- QR form -----------------------------------------------------------
|
||||
'auth-card.qr.title': 'Вход по QR-коду',
|
||||
|
|
@ -147,8 +149,7 @@ export const RU = {
|
|||
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
|
||||
'auth-error.external-logout.phone-logged-out':
|
||||
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
|
||||
'auth-error.external-logout.unknown':
|
||||
'WhatsApp разорвал сессию. Войдите снова.',
|
||||
'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.',
|
||||
// --- Logout ------------------------------------------------------------
|
||||
'card.logout.name': 'Выйти из WhatsApp',
|
||||
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||
|
|
|
|||
|
|
@ -592,6 +592,44 @@ body {
|
|||
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
||||
}
|
||||
|
||||
/* Soft-warn for client-side phone validation. The bridge still has the
|
||||
* final say (and the cooldown self-clears on `invalid_value`), so amber
|
||||
* is the right register — server-confirmed errors keep rose. */
|
||||
.auth-input.warn {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
.auth-input.warn:focus {
|
||||
box-shadow: 0 0 0 3px rgba(231, 178, 90, 0.22);
|
||||
}
|
||||
|
||||
/* Phone-input shell — host for the country-flag emoji positioned over
|
||||
* the input's left padding (no need to split the input's background /
|
||||
* border / focus ring across two siblings). `with-flag` bumps
|
||||
* padding-left so digits clear the glyph. */
|
||||
.auth-phone-shell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell .auth-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.auth-phone-shell.with-flag .auth-input {
|
||||
padding-left: 44px;
|
||||
}
|
||||
.auth-phone-flag {
|
||||
position: absolute;
|
||||
left: 14px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Note: TG-style `.auth-input.code` / `.auth-input.password` /
|
||||
* `.password-row` / `.btn-icon` selectors were intentionally NOT
|
||||
* carried over — WhatsApp has no SMS-code form (pairing-code is
|
||||
|
|
@ -835,7 +873,7 @@ body {
|
|||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: rgba(212, 184, 138, 0.10);
|
||||
background: rgba(212, 184, 138, 0.1);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue