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",
|
"name": "@vojo/widget-telegram",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"libphonenumber-js": "^1.11.7",
|
||||||
"preact": "10.22.1",
|
"preact": "10.22.1",
|
||||||
"qrcode-generator": "^1.4.4"
|
"qrcode-generator": "^1.4.4"
|
||||||
},
|
},
|
||||||
|
|
@ -1611,6 +1612,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"libphonenumber-js": "^1.11.7",
|
||||||
"preact": "10.22.1",
|
"preact": "10.22.1",
|
||||||
"qrcode-generator": "^1.4.4"
|
"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 { Dispatch } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import qrcodeGenerator from 'qrcode-generator';
|
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 type { WidgetBootstrap } from './bootstrap';
|
||||||
import { WidgetApi, type RoomEvent } from './widget-api';
|
import { WidgetApi, type RoomEvent } from './widget-api';
|
||||||
import { createT, type T } from './i18n';
|
import { createT, type T } from './i18n';
|
||||||
|
|
@ -222,6 +228,49 @@ type FormProps = {
|
||||||
// 60 s matches Telegram Desktop's own "Resend code" lockout.
|
// 60 s matches Telegram Desktop's own "Resend code" lockout.
|
||||||
const PHONE_COOLDOWN_MS = 60_000;
|
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.
|
// Tick once per second while a future timestamp is still in the future.
|
||||||
// Returns the seconds remaining (0 once expired). When `until` is null
|
// Returns the seconds remaining (0 once expired). When `until` is null
|
||||||
// the hook is idle.
|
// the hook is idle.
|
||||||
|
|
@ -271,6 +320,11 @@ const PhoneForm = ({
|
||||||
setPhoneCooldownEnd,
|
setPhoneCooldownEnd,
|
||||||
}: FormProps) => {
|
}: FormProps) => {
|
||||||
const [value, setValue] = useState('');
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const stillWaiting = useStillWaitingHint([submitting]);
|
const stillWaiting = useStillWaitingHint([submitting]);
|
||||||
|
|
@ -282,17 +336,35 @@ const PhoneForm = ({
|
||||||
inputRef.current?.focus();
|
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) => {
|
const onSubmit = async (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = value.trim();
|
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
|
||||||
if (!trimmed || submitting || inCooldown) return;
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
// Clear any stale error optimistically so the form looks ready for the
|
// 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
|
// next attempt; a fresh error will re-arrive from the bot if the
|
||||||
// submit fails server-side.
|
// submit fails server-side.
|
||||||
dispatch({ kind: 'submit_phone' });
|
dispatch({ kind: 'submit_phone' });
|
||||||
try {
|
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
|
// Cooldown locks retries ONLY after the Matrix transport accepted
|
||||||
// the message. If `await send` threw (network down, capability
|
// the message. If `await send` threw (network down, capability
|
||||||
// race, etc.), no SMS was attempted at the Telegram side — locking
|
// race, etc.), no SMS was attempted at the Telegram side — locking
|
||||||
|
|
@ -309,10 +381,11 @@ const PhoneForm = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const tone = error ? errorTone(error) : undefined;
|
const tone = error ? errorTone(error) : undefined;
|
||||||
const submitDisabled = submitting || inCooldown || value.trim() === '';
|
const submitDisabled = submitting || inCooldown || !hasEnoughDigits;
|
||||||
const submitLabel = inCooldown
|
const submitLabel = inCooldown
|
||||||
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
|
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
|
||||||
: t('auth-card.phone.submit');
|
: t('auth-card.phone.submit');
|
||||||
|
const flagEmoji = countryToFlagEmoji(country);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
||||||
|
|
@ -321,27 +394,42 @@ const PhoneForm = ({
|
||||||
{t('auth-card.phone.label')}
|
{t('auth-card.phone.label')}
|
||||||
</label>
|
</label>
|
||||||
<div class="auth-card-row">
|
<div class="auth-card-row">
|
||||||
<input
|
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
|
||||||
id="auth-phone-input"
|
{flagEmoji ? (
|
||||||
ref={inputRef}
|
<span class="auth-phone-flag" aria-hidden="true">
|
||||||
class="auth-input"
|
{flagEmoji}
|
||||||
type="tel"
|
</span>
|
||||||
autocomplete="tel"
|
) : null}
|
||||||
inputmode="tel"
|
<input
|
||||||
placeholder={t('auth-card.phone.placeholder')}
|
id="auth-phone-input"
|
||||||
value={value}
|
ref={inputRef}
|
||||||
onInput={(e) => {
|
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
|
||||||
// Auto-prepend `+` so the user never has to remember to type
|
type="tel"
|
||||||
// it — bridgev2 rejects anything without a leading `+` per
|
autocomplete="tel"
|
||||||
// its E.164 input validator. Skipping the special-case
|
inputmode="tel"
|
||||||
// formatting (8→+7 etc.) on purpose: keeping the rule at one
|
placeholder={t('auth-card.phone.placeholder')}
|
||||||
// line of logic means there's nothing to misinterpret a
|
value={value}
|
||||||
// pasted international number as a Russian trunk number.
|
onInput={(e) => {
|
||||||
const raw = (e.currentTarget as HTMLInputElement).value;
|
// Re-format on every keystroke via a fresh AsYouType. The
|
||||||
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
|
// formatter strips non-digit/non-`+` chars (so pastes like
|
||||||
}}
|
// «+1 (213) 373-4253» normalise), auto-prepends `+` if
|
||||||
disabled={submitting}
|
// 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;
|
||||||
|
const next = formatPhoneInput(raw);
|
||||||
|
setValue(next.formatted);
|
||||||
|
setCountry(next.country);
|
||||||
|
}}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<button type="submit" class="btn-primary" disabled={submitDisabled}>
|
<button type="submit" class="btn-primary" disabled={submitDisabled}>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -350,6 +438,9 @@ const PhoneForm = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-card-hint">{t('auth-card.phone.hint')}</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 ? (
|
{error ? (
|
||||||
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||||||
{localizeError(error, t)}
|
{localizeError(error, t)}
|
||||||
|
|
@ -1182,10 +1273,7 @@ export function App({ bootstrap, api }: Props) {
|
||||||
append({ kind: 'diag', text: t('diag.qr-issued') });
|
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||||
} else if (event.kind === 'qr_redacted') {
|
} else if (event.kind === 'qr_redacted') {
|
||||||
const liveState = stateRef.current;
|
const liveState = stateRef.current;
|
||||||
if (
|
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||||
liveState.kind === 'awaiting_qr_scan' &&
|
|
||||||
liveState.qrEventId === event.redactsEventId
|
|
||||||
) {
|
|
||||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
}
|
}
|
||||||
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||||
|
|
@ -1273,7 +1361,6 @@ export function App({ bootstrap, api }: Props) {
|
||||||
}
|
}
|
||||||
}, [sendBare]);
|
}, [sendBare]);
|
||||||
|
|
||||||
|
|
||||||
// In-flight guard against double-tap. The button is on the disconnected
|
// In-flight guard against double-tap. The button is on the disconnected
|
||||||
// screen which unmounts as soon as state advances, BUT a rapid second
|
// screen which unmounts as soon as state advances, BUT a rapid second
|
||||||
// click can fire in the microtask window between dispatch and the next
|
// 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.hint': 'SMS may take up to 30 seconds.',
|
||||||
'auth-card.phone.submit': 'Send code',
|
'auth-card.phone.submit': 'Send code',
|
||||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
'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.title': 'Verification code',
|
||||||
'auth-card.code.label': 'SMS code',
|
'auth-card.code.label': 'SMS code',
|
||||||
'auth-card.code.placeholder': '123456',
|
'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.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-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-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.invalid-code': 'Code is invalid. Please try again.',
|
||||||
'auth-error.wrong-password': 'Password is incorrect. Please try again.',
|
'auth-error.wrong-password': 'Password is incorrect. Please try again.',
|
||||||
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
'auth-error.invalid-value': 'Value not accepted: {reason}',
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ export const RU = {
|
||||||
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
|
'auth-card.phone.hint': 'SMS может идти до 30 секунд.',
|
||||||
'auth-card.phone.submit': 'Отправить код',
|
'auth-card.phone.submit': 'Отправить код',
|
||||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||||
|
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
||||||
// --- Code form ---------------------------------------------------------
|
// --- Code form ---------------------------------------------------------
|
||||||
'auth-card.code.title': 'Код подтверждения',
|
'auth-card.code.title': 'Код подтверждения',
|
||||||
'auth-card.code.label': 'Код из SMS',
|
'auth-card.code.label': 'Код из SMS',
|
||||||
|
|
|
||||||
|
|
@ -573,6 +573,46 @@ body {
|
||||||
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
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.code,
|
||||||
.auth-input.password {
|
.auth-input.password {
|
||||||
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
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",
|
"name": "@vojo/widget-whatsapp",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"libphonenumber-js": "^1.11.7",
|
||||||
"preact": "10.22.1",
|
"preact": "10.22.1",
|
||||||
"qrcode-generator": "1.4.4"
|
"qrcode-generator": "1.4.4"
|
||||||
},
|
},
|
||||||
|
|
@ -1611,6 +1612,12 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/lru-cache": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"libphonenumber-js": "^1.11.7",
|
||||||
"preact": "10.22.1",
|
"preact": "10.22.1",
|
||||||
"qrcode-generator": "1.4.4"
|
"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 { Dispatch } from 'preact/hooks';
|
||||||
import type { ComponentChildren } from 'preact';
|
import type { ComponentChildren } from 'preact';
|
||||||
import qrcodeGenerator from 'qrcode-generator';
|
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 type { WidgetBootstrap } from './bootstrap';
|
||||||
import { WidgetApi, type RoomEvent } from './widget-api';
|
import { WidgetApi, type RoomEvent } from './widget-api';
|
||||||
import { createT, type T, type StringKey } from './i18n';
|
import { createT, type T, type StringKey } from './i18n';
|
||||||
|
|
@ -98,11 +102,7 @@ const LogoutIcon = () => (
|
||||||
// picks up the amber tint via `currentColor` in either context.
|
// picks up the amber tint via `currentColor` in either context.
|
||||||
const WarningIcon = () => (
|
const WarningIcon = () => (
|
||||||
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||||
<path
|
<path d="M10 3.2 L17.5 16.5 L2.5 16.5 Z" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
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" />
|
<path d="M10 8.5 L10 12" stroke-linecap="round" />
|
||||||
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
|
<circle cx="10" cy="14.2" r="0.7" fill="currentColor" stroke="none" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -136,8 +136,7 @@ const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||||
// version-bumps from `2@` to e.g. `3@`. Reject patterns are e.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
|
// «error: a,b,c,d in field» — without the digit prefix and the segment
|
||||||
// length floor, the old regex would clobber that.
|
// length floor, the old regex would clobber that.
|
||||||
const WA_QR_PAYLOAD_GLOBAL_RE =
|
const WA_QR_PAYLOAD_GLOBAL_RE = /\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
|
||||||
/\d@[A-Za-z0-9+/=@:_.\-]{7,}(?:,[A-Za-z0-9+/=@:_.\-]{8,}){3}/g;
|
|
||||||
const scrubLoginSecret = (body: string): string =>
|
const scrubLoginSecret = (body: string): string =>
|
||||||
body.replace(WA_QR_PAYLOAD_GLOBAL_RE, '[redacted QR payload]');
|
body.replace(WA_QR_PAYLOAD_GLOBAL_RE, '[redacted QR payload]');
|
||||||
|
|
||||||
|
|
@ -199,8 +198,8 @@ const localizeError = (err: LoginErrorFlag, t: T): string => {
|
||||||
err.reason === 'another_device'
|
err.reason === 'another_device'
|
||||||
? 'auth-error.external-logout.another-device'
|
? 'auth-error.external-logout.another-device'
|
||||||
: err.reason === 'phone_logged_out'
|
: err.reason === 'phone_logged_out'
|
||||||
? 'auth-error.external-logout.phone-logged-out'
|
? 'auth-error.external-logout.phone-logged-out'
|
||||||
: 'auth-error.external-logout.unknown';
|
: 'auth-error.external-logout.unknown';
|
||||||
return t(subKey);
|
return t(subKey);
|
||||||
}
|
}
|
||||||
default: {
|
default: {
|
||||||
|
|
@ -243,6 +242,44 @@ type FormProps = {
|
||||||
// stop firing after.
|
// stop firing after.
|
||||||
const PHONE_COOLDOWN_MS = 60_000;
|
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 useCooldownSeconds = (until: number | null): number => {
|
||||||
const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
|
const compute = () => (until ? Math.max(0, Math.ceil((until - Date.now()) / 1000)) : 0);
|
||||||
const [seconds, setSeconds] = useState(compute);
|
const [seconds, setSeconds] = useState(compute);
|
||||||
|
|
@ -287,6 +324,10 @@ const PhoneForm = ({
|
||||||
setPhoneCooldownEnd,
|
setPhoneCooldownEnd,
|
||||||
}: FormProps) => {
|
}: FormProps) => {
|
||||||
const [value, setValue] = useState('');
|
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 [submitting, setSubmitting] = useState(false);
|
||||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const stillWaiting = useStillWaitingHint([submitting]);
|
const stillWaiting = useStillWaitingHint([submitting]);
|
||||||
|
|
@ -298,14 +339,28 @@ const PhoneForm = ({
|
||||||
inputRef.current?.focus();
|
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) => {
|
const onSubmit = async (event: Event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const trimmed = value.trim();
|
if (!e164 || submitting || inCooldown || !hasEnoughDigits) return;
|
||||||
if (!trimmed || submitting || inCooldown) return;
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
dispatch({ kind: 'submit_phone' });
|
dispatch({ kind: 'submit_phone' });
|
||||||
try {
|
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
|
// Cooldown locks retries ONLY after the Matrix transport accepted
|
||||||
// the message. If `await send` threw (network down, capability
|
// the message. If `await send` threw (network down, capability
|
||||||
// race), no pairing-code request was attempted at the WhatsApp
|
// race), no pairing-code request was attempted at the WhatsApp
|
||||||
|
|
@ -322,10 +377,11 @@ const PhoneForm = ({
|
||||||
};
|
};
|
||||||
|
|
||||||
const tone = error ? errorTone(error) : undefined;
|
const tone = error ? errorTone(error) : undefined;
|
||||||
const submitDisabled = submitting || inCooldown || value.trim() === '';
|
const submitDisabled = submitting || inCooldown || !hasEnoughDigits;
|
||||||
const submitLabel = inCooldown
|
const submitLabel = inCooldown
|
||||||
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
|
? t('auth-card.phone.cooldown', { seconds: String(cooldownSeconds) })
|
||||||
: t('auth-card.phone.submit');
|
: t('auth-card.phone.submit');
|
||||||
|
const flagEmoji = countryToFlagEmoji(country);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
<form class={`auth-card${tone === 'error' ? ' error' : ''}`} onSubmit={onSubmit}>
|
||||||
|
|
@ -334,31 +390,38 @@ const PhoneForm = ({
|
||||||
{t('auth-card.phone.label')}
|
{t('auth-card.phone.label')}
|
||||||
</label>
|
</label>
|
||||||
<div class="auth-card-row">
|
<div class="auth-card-row">
|
||||||
<input
|
<div class={`auth-phone-shell${flagEmoji ? ' with-flag' : ''}`}>
|
||||||
id="auth-phone-input"
|
{flagEmoji ? (
|
||||||
ref={inputRef}
|
<span class="auth-phone-flag" aria-hidden="true">
|
||||||
class="auth-input"
|
{flagEmoji}
|
||||||
type="tel"
|
</span>
|
||||||
autocomplete="tel"
|
) : null}
|
||||||
inputmode="tel"
|
<input
|
||||||
placeholder={t('auth-card.phone.placeholder')}
|
id="auth-phone-input"
|
||||||
value={value}
|
ref={inputRef}
|
||||||
onInput={(e) => {
|
class={`auth-input${showInvalidHint ? ' warn' : ''}`}
|
||||||
// Auto-prepend `+` so the user never has to remember to type
|
type="tel"
|
||||||
// it — the connector's PHONE_NUMBER_NOT_INTERNATIONAL error
|
autocomplete="tel"
|
||||||
// fires for anything without a leading `+` (whatsmeow
|
inputmode="tel"
|
||||||
// PairPhone's validator). Skipping locale-specific
|
placeholder={t('auth-card.phone.placeholder')}
|
||||||
// formatting (8→+7 etc.) keeps the rule single-line.
|
value={value}
|
||||||
//
|
onInput={(e) => {
|
||||||
// trimStart on the raw input so that a paste of « +12345…»
|
// Re-format on every keystroke via a fresh AsYouType. Strips
|
||||||
// (some clipboard sources include a leading space) still
|
// non-digit / non-`+` chars (so a paste of «+1 (213) 373-4253»
|
||||||
// resolves to a single `+`, instead of producing the
|
// or « +1…» normalises), auto-prepends `+` if missing
|
||||||
// double-prefix `+ +12345…` bridgev2 then rejects.
|
// (whatsmeow PairPhone rejects otherwise), and groups digits
|
||||||
const raw = (e.currentTarget as HTMLInputElement).value.trimStart();
|
// per country convention. Caret jumps to end on re-format —
|
||||||
setValue(raw.length > 0 && !raw.startsWith('+') ? `+${raw}` : raw);
|
// acceptable for left-to-right phone entry. Country is read
|
||||||
}}
|
// from the same formatter call so the flag updates without
|
||||||
disabled={submitting}
|
// 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}>
|
<button type="submit" class="btn-primary" disabled={submitDisabled}>
|
||||||
{submitLabel}
|
{submitLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -367,6 +430,9 @@ const PhoneForm = ({
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-card-hint">{t('auth-card.phone.hint')}</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 ? (
|
{error ? (
|
||||||
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
<div class={tone === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||||||
{localizeError(error, t)}
|
{localizeError(error, t)}
|
||||||
|
|
@ -556,10 +622,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
|
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
|
||||||
const remainingSeconds = Math.max(
|
const remainingSeconds = Math.max(0, Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000));
|
||||||
0,
|
|
||||||
Math.ceil((PAIRING_CODE_TIMEOUT_MS - elapsed) / 1000)
|
|
||||||
);
|
|
||||||
const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0;
|
const expired = elapsed >= PAIRING_CODE_TIMEOUT_MS && state.firstShownAt > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -578,10 +641,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
||||||
// user-select: all on the text element keeps one-tap copy
|
// user-select: all on the text element keeps one-tap copy
|
||||||
// working on touch devices.
|
// working on touch devices.
|
||||||
<>
|
<>
|
||||||
<output
|
<output class="auth-card-pairing-code-text" aria-describedby="auth-pairing-code-desc">
|
||||||
class="auth-card-pairing-code-text"
|
|
||||||
aria-describedby="auth-pairing-code-desc"
|
|
||||||
>
|
|
||||||
{state.code}
|
{state.code}
|
||||||
</output>
|
</output>
|
||||||
<span id="auth-pairing-code-desc" class="visually-hidden">
|
<span id="auth-pairing-code-desc" class="visually-hidden">
|
||||||
|
|
@ -603,9 +663,7 @@ const PairingCodePanel = ({ state, t, sendCancel }: PairingCodePanelProps) => {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div class="auth-card-countdown expired">
|
<div class="auth-card-countdown expired">{t('auth-card.pairing-code.expired')}</div>
|
||||||
{t('auth-card.pairing-code.expired')}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<ol class="auth-card-pairing-steps">
|
<ol class="auth-card-pairing-steps">
|
||||||
<li>{t('auth-card.pairing-code.step-1')}</li>
|
<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') });
|
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||||
} else if (event.kind === 'qr_redacted') {
|
} else if (event.kind === 'qr_redacted') {
|
||||||
const liveState = stateRef.current;
|
const liveState = stateRef.current;
|
||||||
if (
|
if (liveState.kind === 'awaiting_qr_scan' && liveState.qrEventId === event.redactsEventId) {
|
||||||
liveState.kind === 'awaiting_qr_scan' &&
|
|
||||||
liveState.qrEventId === event.redactsEventId
|
|
||||||
) {
|
|
||||||
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
}
|
}
|
||||||
} else if (event.kind === 'pairing_code_displayed') {
|
} 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.',
|
'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.submit': 'Get code',
|
||||||
'auth-card.phone.cooldown': 'Retry in {seconds}s',
|
'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.title': 'Enter this code in WhatsApp',
|
||||||
'auth-card.pairing-code.hint':
|
'auth-card.pairing-code.hint':
|
||||||
'Open WhatsApp on your phone and enter this code under Linked devices → Link with phone number.',
|
'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.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.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.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||||
'auth-card.pairing-code.step-1': 'Open WhatsApp on your phone.',
|
'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.',
|
'WhatsApp unlinked this device from another device. Sign in again.',
|
||||||
'auth-error.external-logout.phone-logged-out':
|
'auth-error.external-logout.phone-logged-out':
|
||||||
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
|
'You signed out of WhatsApp on the phone — all linked devices were unlinked. Sign in again.',
|
||||||
'auth-error.external-logout.unknown':
|
'auth-error.external-logout.unknown': 'WhatsApp dropped the session. Sign in again.',
|
||||||
'WhatsApp dropped the session. Sign in again.',
|
|
||||||
'card.logout.name': 'Sign out of WhatsApp',
|
'card.logout.name': 'Sign out of WhatsApp',
|
||||||
'card.logout.desc': 'End the session for this account',
|
'card.logout.desc': 'End the session for this account',
|
||||||
'card.logout.confirm-prompt': 'Sign out for real?',
|
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,7 @@ export const RU = {
|
||||||
'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.',
|
'Введите номер с кодом страны. После этого WhatsApp создаст 8-символьный код — его нужно будет ввести в приложении.',
|
||||||
'auth-card.phone.submit': 'Получить код',
|
'auth-card.phone.submit': 'Получить код',
|
||||||
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
'auth-card.phone.cooldown': 'Повтор через {seconds} сек',
|
||||||
|
'auth-card.phone.invalid': 'Похоже, номер ещё не полный или введён с ошибкой.',
|
||||||
// --- Pairing-code form -------------------------------------------------
|
// --- Pairing-code form -------------------------------------------------
|
||||||
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
|
'auth-card.pairing-code.title': 'Введите этот код в WhatsApp',
|
||||||
'auth-card.pairing-code.hint':
|
'auth-card.pairing-code.hint':
|
||||||
|
|
@ -104,7 +105,8 @@ export const RU = {
|
||||||
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
'auth-card.pairing-code.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
|
'auth-card.pairing-code.step-1': 'Откройте WhatsApp на телефоне.',
|
||||||
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
'auth-card.pairing-code.step-2': 'Перейдите в «Настройки → Связанные устройства».',
|
||||||
'auth-card.pairing-code.step-3': 'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
'auth-card.pairing-code.step-3':
|
||||||
|
'Нажмите «Привязать устройство → Привязать с помощью номера телефона».',
|
||||||
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
|
'auth-card.pairing-code.step-4': 'Введите этот код и подтвердите вход на телефоне.',
|
||||||
// --- QR form -----------------------------------------------------------
|
// --- QR form -----------------------------------------------------------
|
||||||
'auth-card.qr.title': 'Вход по QR-коду',
|
'auth-card.qr.title': 'Вход по QR-коду',
|
||||||
|
|
@ -147,8 +149,7 @@ export const RU = {
|
||||||
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
|
'WhatsApp отвязал это устройство с другого устройства. Войдите снова.',
|
||||||
'auth-error.external-logout.phone-logged-out':
|
'auth-error.external-logout.phone-logged-out':
|
||||||
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
|
'Вы вышли из WhatsApp на телефоне — все связанные устройства отвязаны. Войдите снова.',
|
||||||
'auth-error.external-logout.unknown':
|
'auth-error.external-logout.unknown': 'WhatsApp разорвал сессию. Войдите снова.',
|
||||||
'WhatsApp разорвал сессию. Войдите снова.',
|
|
||||||
// --- Logout ------------------------------------------------------------
|
// --- Logout ------------------------------------------------------------
|
||||||
'card.logout.name': 'Выйти из WhatsApp',
|
'card.logout.name': 'Выйти из WhatsApp',
|
||||||
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,44 @@ body {
|
||||||
box-shadow: 0 0 0 3px rgba(192, 142, 123, 0.22);
|
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` /
|
/* Note: TG-style `.auth-input.code` / `.auth-input.password` /
|
||||||
* `.password-row` / `.btn-icon` selectors were intentionally NOT
|
* `.password-row` / `.btn-icon` selectors were intentionally NOT
|
||||||
* carried over — WhatsApp has no SMS-code form (pairing-code is
|
* carried over — WhatsApp has no SMS-code form (pairing-code is
|
||||||
|
|
@ -835,7 +873,7 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background: rgba(212, 184, 138, 0.10);
|
background: rgba(212, 184, 138, 0.1);
|
||||||
border: 1px solid var(--amber);
|
border: 1px solid var(--amber);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue