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

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:
2026-09-02 21:13:54 -03:00
co-authored by Claude Opus 5
parent 94f5da4013
commit 699855849d
20 changed files with 825 additions and 252 deletions
@@ -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}