feat(i18n): translate the five systems users actually see
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Ordered by what the group hits, not by string count.
- Chat system messages ("X added Y to the group") and their sidebar
preview twin. These are content, not chrome, so they sat in English in
the middle of conversations.
- Login, register and the invite landing page — the first screen anyone
invited to the instance sees.
- Voice — controls, connection info, the mobile join sheet, settings tab.
- Mobile screens.
- Settings navigation across space, user and instance, plus Discovery and
join requests.
Three call sites build strings outside a component and so read the locale
directly rather than through the hook: the sidebar preview formatter, the
connection-info track labels, and MobileShell's screen map — which holds
plain functions that are *called*, not rendered, where a hook would run
under whichever component invoked the map. Each says so at the definition;
all three re-render often enough that a language switch shows through
promptly.
Adds a test for a mistake made three times in this series: a file with an
exported component plus a private sub-component, where only the exported one
got the hook. tsc catches it, but only after a full build and without naming
the component. The test names it, and also flags the inverse — a hook placed
below an early return, which tsc accepts and React breaks at runtime (this
project shipped that once, as error #310). Verified by mutation: removing a
hook makes it fail and print the component.
Dictionary is at 856 keys, en and pt-BR at exact parity.
Verified: web build (tsc + vite), 492 web tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { api, RateLimitError } from '../../api/client';
|
||||
import { useTransferStore } from '../../stores/transferStore';
|
||||
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
|
||||
import { SourceCodeLink } from '../ui/SourceCodeLink';
|
||||
import { useT } from '../../i18n';
|
||||
|
||||
// Single-source regex for extracting a bare invite token from a pasted full URL.
|
||||
// Token format: 22 chars base64url ([A-Za-z0-9_-]).
|
||||
@@ -18,6 +19,7 @@ const INVITE_URL_REGEX = /[?&]invite=([A-Za-z0-9_-]{22})/;
|
||||
type UsernameStatus = 'idle' | 'checking' | 'available' | 'taken' | 'invalid';
|
||||
|
||||
export function RegisterPage() {
|
||||
const t = useT();
|
||||
// Step state
|
||||
const [step, setStep] = useState<1 | 2>(1);
|
||||
const [direction, setDirection] = useState<'forward' | 'back'>('forward');
|
||||
@@ -187,18 +189,18 @@ export function RegisterPage() {
|
||||
|
||||
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||
setUsernameStatus(trimmed.length > 0 ? 'invalid' : 'idle');
|
||||
setUsernameStatusMessage(trimmed.length > 0 ? 'Username must be between 3 and 32 characters' : '');
|
||||
setUsernameStatusMessage(trimmed.length > 0 ? t('auth.register.username.length') : '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
||||
setUsernameStatus('invalid');
|
||||
setUsernameStatusMessage('Username can only contain lowercase letters, numbers, and underscores');
|
||||
setUsernameStatusMessage(t('auth.register.username.charset'));
|
||||
return;
|
||||
}
|
||||
|
||||
setUsernameStatus('checking');
|
||||
setUsernameStatusMessage('Checking availability...');
|
||||
setUsernameStatusMessage(t('auth.register.username.checking'));
|
||||
|
||||
usernameCheckTimerRef.current = setTimeout(async () => {
|
||||
const controller = new AbortController();
|
||||
@@ -213,10 +215,10 @@ export function RegisterPage() {
|
||||
setUsernameStatusMessage(result.reason);
|
||||
} else if (result.available) {
|
||||
setUsernameStatus('available');
|
||||
setUsernameStatusMessage('Username is available');
|
||||
setUsernameStatusMessage(t('auth.register.username.available'));
|
||||
} else {
|
||||
setUsernameStatus('taken');
|
||||
setUsernameStatusMessage('Username is already taken');
|
||||
setUsernameStatusMessage(t('auth.register.username.taken'));
|
||||
}
|
||||
} catch {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -265,30 +267,30 @@ export function RegisterPage() {
|
||||
|
||||
const trimmed = username.trim();
|
||||
if (!trimmed) {
|
||||
setError('Username is required');
|
||||
setError(t('auth.username.required'));
|
||||
return;
|
||||
}
|
||||
if (trimmed.length < 3 || trimmed.length > 32) {
|
||||
setError('Username must be between 3 and 32 characters');
|
||||
setError(t('auth.register.username.length'));
|
||||
return;
|
||||
}
|
||||
if (!/^[a-z0-9_]+$/.test(trimmed)) {
|
||||
setError('Username can only contain lowercase letters, numbers, and underscores');
|
||||
setError(t('auth.register.username.charset'));
|
||||
return;
|
||||
}
|
||||
if (usernameStatus === 'taken' || usernameStatus === 'invalid') {
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
setError('Password is required');
|
||||
setError(t('auth.password.required'));
|
||||
return;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters');
|
||||
setError(t('auth.register.password.length'));
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
setError(t('auth.register.password.mismatch'));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -379,7 +381,7 @@ export function RegisterPage() {
|
||||
setRetryAfter(err.retryAfter);
|
||||
setError('');
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Registration failed');
|
||||
setError(err instanceof Error ? err.message : t('auth.register.failed'));
|
||||
}
|
||||
setIsRegistering(false);
|
||||
}
|
||||
@@ -418,7 +420,7 @@ export function RegisterPage() {
|
||||
{step === 1 ? (
|
||||
<div key="step1" className={`w-full${direction === 'back' ? ' animate-step-back' : ''}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-txt-primary">Create an account</h1>
|
||||
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.title')}</h1>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleContinue}>
|
||||
@@ -439,7 +441,7 @@ export function RegisterPage() {
|
||||
type="text"
|
||||
value={manualInviteToken}
|
||||
onChange={(e) => setManualInviteToken(e.target.value)}
|
||||
placeholder="Invite code or link"
|
||||
placeholder={t('auth.register.invite')}
|
||||
// text-base on mobile prevents iOS Safari from auto-zooming
|
||||
// when the field is focused (any <input> with font-size <16px triggers zoom).
|
||||
className="input-standard w-full px-3 py-2 text-base md:text-sm"
|
||||
@@ -447,7 +449,7 @@ export function RegisterPage() {
|
||||
autoComplete="off"
|
||||
/>
|
||||
{inviteChecking && (
|
||||
<div className="text-xs text-txt-tertiary">Checking...</div>
|
||||
<div className="text-xs text-txt-tertiary">{t('auth.register.invite.checking')}</div>
|
||||
)}
|
||||
{!inviteChecking && inviteCheck?.valid === true && (
|
||||
<div className="text-xs text-status-online flex items-center gap-1">
|
||||
@@ -459,9 +461,9 @@ export function RegisterPage() {
|
||||
)}
|
||||
{!inviteChecking && inviteCheck?.valid === false && (
|
||||
<div className="text-xs text-txt-danger">
|
||||
{inviteCheck.reason === 'expired' && 'This invite link has expired. Ask the admin for a new one.'}
|
||||
{inviteCheck.reason === 'exhausted' && 'This invite has reached its usage limit. Ask the admin to extend it.'}
|
||||
{inviteCheck.reason === 'invalid' && 'Invalid invite code.'}
|
||||
{inviteCheck.reason === 'expired' && t('auth.register.invite.expired')}
|
||||
{inviteCheck.reason === 'exhausted' && t('auth.register.invite.exhausted')}
|
||||
{inviteCheck.reason === 'invalid' && t('auth.register.invite.invalid')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -480,7 +482,7 @@ export function RegisterPage() {
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<span>Validating invite...</span>
|
||||
<span>{t('auth.register.invite.validating')}</span>
|
||||
</>
|
||||
) : inviteCheck?.valid === true ? (
|
||||
<>
|
||||
@@ -494,17 +496,17 @@ export function RegisterPage() {
|
||||
<svg className="w-3 h-3 flex-shrink-0 mt-0.5 md:mt-0" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>Invalid invite link — please request a new one</span>
|
||||
<span>{t('auth.register.invite.invalidLink')}</span>
|
||||
</>
|
||||
) : (
|
||||
<>Validating invite...</>
|
||||
<>{t('auth.register.invite.validating')}</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Username <span className="text-txt-danger">*</span>
|
||||
{t('auth.username')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -544,7 +546,7 @@ export function RegisterPage() {
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Password <span className="text-txt-danger">*</span>
|
||||
{t('auth.password')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -557,7 +559,7 @@ export function RegisterPage() {
|
||||
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Confirm Password <span className="text-txt-danger">*</span>
|
||||
{t('auth.register.confirmPassword')} <span className="text-txt-danger">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
@@ -575,7 +577,7 @@ export function RegisterPage() {
|
||||
// tighter desktop look from before.
|
||||
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Continue
|
||||
{t('auth.register.continue')}
|
||||
</button>
|
||||
|
||||
{/* Helper text when invite is required but not yet entered */}
|
||||
@@ -586,9 +588,9 @@ export function RegisterPage() {
|
||||
)}
|
||||
|
||||
<p className="mt-3 text-sm text-txt-tertiary">
|
||||
Already have an account?{' '}
|
||||
{t('auth.haveAccount')}{' '}
|
||||
<Link to={`/login${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
|
||||
Log In
|
||||
{t('auth.login.submit')}
|
||||
</Link>
|
||||
</p>
|
||||
</form>
|
||||
@@ -596,14 +598,14 @@ export function RegisterPage() {
|
||||
) : (
|
||||
<div key="step2" className={`w-full${direction === 'forward' ? ' animate-step-forward' : ''}`}>
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="text-2xl font-bold text-txt-primary">Make it yours</h1>
|
||||
<p className="text-txt-tertiary text-sm mt-1">Personalize your profile, or skip for now</p>
|
||||
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.register.profile.title')}</h1>
|
||||
<p className="text-txt-tertiary text-sm mt-1">{t('auth.register.profile.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{retryAfter > 0 && (
|
||||
<div className="mb-4 p-3 bg-accent-amber/10 border border-accent-amber/30 rounded text-sm">
|
||||
<p className="font-medium text-accent-amber">Too many attempts</p>
|
||||
<p className="text-txt-secondary mt-0.5">Try again in {retryAfter}s</p>
|
||||
<p className="font-medium text-accent-amber">{t('auth.register.rateLimited')}</p>
|
||||
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -652,13 +654,13 @@ export function RegisterPage() {
|
||||
{/* Display Name */}
|
||||
<div className="mb-5">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Display Name
|
||||
{t('auth.register.displayName')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={username.trim() || 'Display name'}
|
||||
placeholder={username.trim() || t('auth.register.displayName.placeholder')}
|
||||
className="input-standard w-full py-2.5 text-base md:text-sm"
|
||||
autoComplete="name"
|
||||
/>
|
||||
@@ -667,7 +669,7 @@ export function RegisterPage() {
|
||||
{/* Avatar Color Picker */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-xs font-bold text-txt-secondary uppercase mb-2">
|
||||
Avatar Color
|
||||
{t('auth.register.avatarColor')}
|
||||
</label>
|
||||
{/* Color swatch row: gap tightens on narrow viewports so the 7 swatches
|
||||
fit inside a 360 px viewport (p-6 inner content area is ~280 px;
|
||||
@@ -701,10 +703,10 @@ export function RegisterPage() {
|
||||
className="w-full py-3 md:py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{retryAfter > 0
|
||||
? `Try again in ${retryAfter}s`
|
||||
? t('auth.retryIn', { seconds: retryAfter })
|
||||
: isRegistering
|
||||
? 'Creating account...'
|
||||
: 'Get Started'}
|
||||
? t('auth.register.creating')
|
||||
: t('auth.register.submit')}
|
||||
</button>
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
@@ -715,7 +717,7 @@ export function RegisterPage() {
|
||||
// py-2 px-1 widens the tap area on mobile while keeping the visual link style.
|
||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
||||
>
|
||||
Back
|
||||
{t('auth.register.back')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -723,7 +725,7 @@ export function RegisterPage() {
|
||||
disabled={isDisabled}
|
||||
className="text-sm text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50 py-2 px-1 -mx-1"
|
||||
>
|
||||
Skip for now
|
||||
{t('auth.register.skip')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -745,7 +747,7 @@ export function RegisterPage() {
|
||||
onClose={() => setAvatarCropSrc(null)}
|
||||
imageSrc={avatarCropSrc}
|
||||
onCropComplete={handleAvatarCropComplete}
|
||||
title="Crop Avatar"
|
||||
title={t('auth.register.cropAvatar')}
|
||||
aspectRatio={1}
|
||||
cropShape="round"
|
||||
maxOutputDimension={256}
|
||||
|
||||
Reference in New Issue
Block a user