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
+25 -22
View File
@@ -7,10 +7,12 @@ import { api, createApiClient } from '../api/client';
import { parseInviteInput } from '../utils/inviteParser';
import { Avatar } from './ui/Avatar';
import type { InvitePreview } from '@backspace/shared';
import { useT } from '../i18n';
type JoinPhase = 'preview' | 'connect' | 'fallback' | 'other-instance' | 'already-member';
export function JoinPage() {
const t = useT();
const { inviteCode: rawInviteCode } = useParams<{ inviteCode: string }>();
const navigate = useNavigate();
const token = useAuthStore((s) => s.token);
@@ -59,13 +61,13 @@ export function JoinPage() {
// Fetch preview on mount
useEffect(() => {
if (!rawInviteCode) {
setPreviewError('No invite code provided');
setPreviewError(t('join.page.noCode'));
setIsLoadingPreview(false);
return;
}
if (!parsed) {
setPreviewError('Invalid invite code');
setPreviewError(t('join.page.invalidCode'));
setIsLoadingPreview(false);
return;
}
@@ -84,7 +86,7 @@ export function JoinPage() {
const data = await client.spaces.invitePreview(parsed.code);
setPreview(data);
} catch (err) {
setPreviewError(err instanceof Error ? err.message : 'Failed to load invite');
setPreviewError(err instanceof Error ? err.message : t('join.page.loadFailed'));
} finally {
setIsLoadingPreview(false);
}
@@ -106,7 +108,7 @@ export function JoinPage() {
setPhase('connect');
setError('');
} else {
const msg = err instanceof Error ? err.message : 'Failed to join space';
const msg = err instanceof Error ? err.message : t('join.page.joinFailed');
if (msg.toLowerCase().includes('already a member')) {
setPhase('already-member');
setError('');
@@ -136,7 +138,7 @@ export function JoinPage() {
setFallbackPassword('');
setError('');
} else {
const msg = err instanceof Error ? err.message : 'Failed to connect';
const msg = err instanceof Error ? err.message : t('join.page.connectFailed');
if (msg.toLowerCase().includes('already a member')) {
setPhase('already-member');
setError('');
@@ -160,7 +162,7 @@ export function JoinPage() {
const space = await joinByCode(parsed.code, parsed.origin);
navigate(`/channels/${space.id}`);
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed to log in';
const msg = err instanceof Error ? err.message : t('join.page.loginFailed');
if (msg.toLowerCase().includes('already a member')) {
setPhase('already-member');
setError('');
@@ -204,7 +206,7 @@ export function JoinPage() {
<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>
<p className="text-txt-tertiary">Loading invite...</p>
<p className="text-txt-tertiary">{t('join.page.loading')}</p>
</div>
</div>
);
@@ -221,9 +223,9 @@ export function JoinPage() {
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
</div>
<h1 className="text-xl font-bold text-txt-primary mb-2">Invalid Invite</h1>
<h1 className="text-xl font-bold text-txt-primary mb-2">{t('join.page.invalid.title')}</h1>
<p className="text-txt-secondary text-sm mb-6">
{previewError || 'This invite link is invalid or has expired.'}
{previewError || t('join.page.invalid.body')}
</p>
{token ? (
<button
@@ -260,7 +262,7 @@ export function JoinPage() {
avatarColor={preview.avatarColor}
/>
</div>
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">You've been invited to join</p>
<p className="text-xs text-txt-tertiary uppercase tracking-wide mb-1">{t('join.page.invitedTo')}</p>
<h1 className="text-2xl font-bold text-txt-primary">{preview.spaceName}</h1>
{preview.description && (
<p className="text-txt-secondary text-sm mt-2">{preview.description}</p>
@@ -303,7 +305,7 @@ export function JoinPage() {
disabled={isJoining}
className="w-full 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"
>
{isJoining ? 'Joining...' : `Join as ${user.displayName || user.username}`}
{isJoining ? t('join.joining') : `Join as ${user.displayName || user.username}`}
</button>
<p className="text-center text-xs text-txt-tertiary">
Not you?{' '}
@@ -378,7 +380,7 @@ export function JoinPage() {
type="text"
value={otherDomain}
onChange={(e) => setOtherDomain(e.target.value)}
placeholder="e.g. my-instance.com"
placeholder={t('join.page.instance.placeholder')}
className="input-standard flex-1 py-2.5"
autoFocus
/>
@@ -431,7 +433,7 @@ export function JoinPage() {
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Your account password"
placeholder={t('join.connect.password.placeholder')}
className="input-standard w-full py-2.5"
disabled={isJoining}
autoFocus
@@ -454,7 +456,7 @@ export function JoinPage() {
disabled={isJoining || !password}
className="flex-1 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"
>
{isJoining ? 'Connecting...' : 'Connect & Join'}
{isJoining ? t('join.connect.connecting') : t('join.connect.submit')}
</button>
</div>
</form>
@@ -473,24 +475,24 @@ export function JoinPage() {
</div>
<div className="mb-4 space-y-3">
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Username</label>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.username')}</label>
<input
type="text"
value={fallbackUsername}
onChange={(e) => setFallbackUsername(e.target.value)}
placeholder="Your username on this instance"
placeholder={t('join.fallback.username.placeholder')}
className="input-standard w-full py-2.5"
disabled={isJoining}
autoComplete="username"
/>
</div>
<div>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">Password for this instance</label>
<label className="block text-xs font-bold text-txt-secondary uppercase mb-1.5">{t('join.fallback.password')}</label>
<input
type="password"
value={fallbackPassword}
onChange={(e) => setFallbackPassword(e.target.value)}
placeholder="Password on the remote instance"
placeholder={t('join.fallback.password.placeholder')}
className="input-standard w-full py-2.5"
disabled={isJoining}
autoFocus
@@ -511,7 +513,7 @@ export function JoinPage() {
disabled={isJoining || !fallbackUsername || !fallbackPassword}
className="flex-1 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"
>
{isJoining ? 'Logging in...' : 'Login & Join'}
{isJoining ? t('join.fallback.loggingIn') : t('join.fallback.submit')}
</button>
</div>
</form>
@@ -522,6 +524,7 @@ export function JoinPage() {
}
function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string; spaceId: string; navigate: (path: string) => void }) {
const t = useT();
useEffect(() => {
const timer = setTimeout(() => navigate(`/channels/${spaceId}`), 2000);
return () => clearTimeout(timer);
@@ -534,13 +537,13 @@ function AlreadyMemberCard({ spaceName, spaceId, navigate }: { spaceName: string
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div>
<p className="text-txt-primary font-medium mb-1">You're already in {spaceName}!</p>
<p className="text-txt-tertiary text-xs mb-4">Redirecting you now...</p>
<p className="text-txt-primary font-medium mb-1">{t('join.page.alreadyMember', { space: spaceName })}</p>
<p className="text-txt-tertiary text-xs mb-4">{t('join.page.redirecting')}</p>
<button
onClick={() => navigate(`/channels/${spaceId}`)}
className="px-6 py-2.5 bg-accent-primary hover:bg-accent-primary/80 text-white font-medium rounded transition-colors"
>
Go to {spaceName}
{t('join.page.goTo', { space: spaceName })}
</button>
</div>
);
+16 -14
View File
@@ -4,8 +4,10 @@ import { useAuthStore } from '../../stores/authStore';
import { api, RateLimitError } from '../../api/client';
import type { InstanceInfoResponse } from '@backspace/shared';
import { SourceCodeLink } from '../ui/SourceCodeLink';
import { useT } from '../../i18n';
export function LoginPage() {
const t = useT();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
@@ -46,11 +48,11 @@ export function LoginPage() {
setError('');
if (!username.trim()) {
setError('Username is required');
setError(t('auth.username.required'));
return;
}
if (!password) {
setError('Password is required');
setError(t('auth.password.required'));
return;
}
@@ -66,7 +68,7 @@ export function LoginPage() {
setRetryAfter(err.retryAfter);
setError('');
} else {
setError(err instanceof Error ? err.message : 'Login failed');
setError(err instanceof Error ? err.message : t('auth.login.failed'));
}
}
};
@@ -78,15 +80,15 @@ export function LoginPage() {
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(124,108,246,0.06)_0%,transparent_50%)]" />
<div className="w-full max-w-[480px] bg-surface-elevated rounded-md p-8 shadow-elevation-high relative z-10">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-txt-primary">Welcome back!</h1>
<p className="text-txt-tertiary mt-1">We're so excited to see you again!</p>
<h1 className="text-2xl font-bold text-txt-primary">{t('auth.login.title')}</h1>
<p className="text-txt-tertiary mt-1">{t('auth.login.subtitle')}</p>
</div>
<form onSubmit={handleSubmit}>
{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 login 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.login.rateLimited')}</p>
<p className="text-txt-secondary mt-0.5">{t('auth.retryIn', { seconds: retryAfter })}</p>
</div>
)}
@@ -98,7 +100,7 @@ export function LoginPage() {
<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"
@@ -112,7 +114,7 @@ export function LoginPage() {
<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"
@@ -129,16 +131,16 @@ export function LoginPage() {
className="w-full 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 })
: isLoading
? 'Logging in...'
: 'Log In'}
? t('auth.login.loggingIn')
: t('auth.login.submit')}
</button>
<p className="mt-3 text-sm text-txt-tertiary">
Need an account?{' '}
{t('auth.needAccount')}{' '}
<Link to={`/register${redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''}`} className="text-accent-primary hover:underline">
Register
{t('auth.register')}
</Link>
</p>
</form>
@@ -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}
@@ -1,5 +1,6 @@
import type { DmChannel, MessageWithUser, SpaceInviteSystemPayload, User } from '@backspace/shared';
import { SpaceInviteCard } from './SpaceInviteCard';
import { useT } from '../../i18n';
interface SystemMessageProps {
message: MessageWithUser;
@@ -13,15 +14,20 @@ interface SystemMessageProps {
dm?: Pick<DmChannel, 'members'> | null;
}
function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'members'> | null): string {
function resolveActorName(
message: MessageWithUser,
dm: Pick<DmChannel, 'members'> | null | undefined,
unknown: string,
someone: string,
): string {
if (dm) {
const fromRoster = dm.members.find(m => m.id === message.userId) as User | undefined;
if (fromRoster) {
return fromRoster.displayName ?? fromRoster.username ?? 'Unknown';
return fromRoster.displayName ?? fromRoster.username ?? unknown;
}
return 'Unknown';
return unknown;
}
return message.user?.displayName ?? message.user?.username ?? 'Someone';
return message.user?.displayName ?? message.user?.username ?? someone;
}
/**
@@ -32,10 +38,11 @@ function resolveActorName(message: MessageWithUser, dm?: Pick<DmChannel, 'member
* Exported so unit tests can render it directly without mounting MessageList.
*/
export function SystemMessage({ message, dm }: SystemMessageProps) {
const t = useT();
let data: Record<string, unknown> = {};
try { data = JSON.parse(message.content ?? '{}'); } catch { /* fall through to default branch */ }
const actorName = resolveActorName(message, dm);
const actorName = resolveActorName(message, dm, t('system.unknown'), t('system.someone'));
if (data.event === 'space_invite') {
return (
@@ -51,32 +58,32 @@ export function SystemMessage({ message, dm }: SystemMessageProps) {
switch (data.event) {
case 'member_added':
icon = '→'; // →
text = `${actorName} added ${data.targetDisplayName} to the group`;
text = t('system.memberAdded', { actor: actorName, target: String(data.targetDisplayName) });
break;
case 'member_removed':
if (data.reason === 'leave') {
icon = '←'; // ←
text = `${data.targetDisplayName} left the group`;
text = t('system.memberLeft', { target: String(data.targetDisplayName) });
} else {
icon = '←';
text = `${actorName} removed ${data.targetDisplayName} from the group`;
text = t('system.memberRemoved', { actor: actorName, target: String(data.targetDisplayName) });
}
break;
case 'owner_changed':
icon = '♛'; // ♛
text = `${data.newOwnerDisplayName} is now the group owner`;
text = t('system.ownerChanged', { owner: String(data.newOwnerDisplayName) });
break;
case 'name_changed':
icon = '✎'; // ✎
// newName === null is a meaningful "cleared" state — distinct from a
// missing field — so we test for a non-empty string explicitly.
text = typeof data.newName === 'string' && data.newName.length > 0
? `${actorName} renamed the group to "${data.newName}"`
: `${actorName} cleared the group name`;
? t('system.nameChanged', { actor: actorName, name: data.newName })
: t('system.nameCleared', { actor: actorName });
break;
case 'icon_changed':
icon = '\u{1F5BC}'; // 🖼
text = `${actorName} updated the group icon`;
text = t('system.iconChanged', { actor: actorName });
break;
default:
text = message.content ?? '';
@@ -15,6 +15,7 @@ import { useCanonicalUserView } from '../../utils/userViewLookup';
import { formatDmSidebarPreview, formatDmHeaderName } from '../../utils/dmFormatters';
import type { DmChannel, User } from '@backspace/shared';
import type { TaggedFriend } from '../../stores/socialStore';
import { useT } from '../../i18n';
const FALLBACK_USER = { id: '', username: '', createdAt: 0, isAdmin: false, replicatedInstances: [] } as unknown as User;
@@ -82,6 +83,7 @@ function MobileDmRow({
onContextMenu: (e: React.MouseEvent, id: string, isGroup: boolean) => void;
formatTimestamp: (ts: number) => string;
}) {
const t = useT();
const otherMembers = dm.members.filter(m => authUser ? !isSelf(m, authUser) : m.id !== authUser);
const isGroup = !!dm.ownerId;
const rawMainUser = otherMembers[0] ?? null;
@@ -93,7 +95,7 @@ function MobileDmRow({
// keep the canonical view of the single other member.
const name = isGroup
? formatDmHeaderName(dm, authUser ?? null)
: mainUser?.displayName ?? (parseFederatedUsername(mainUser?.username ?? '').baseName || 'Unknown');
: mainUser?.displayName ?? (parseFederatedUsername(mainUser?.username ?? '').baseName || t('system.unknown'));
// Show a single federation globe next to the group name when any non-self
// member is federated. No tooltip on mobile — the new `group-dm-info` screen
@@ -181,6 +183,7 @@ function MobileDmRow({
}
export function MobileDmsScreen() {
const t = useT();
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
const openModal = useUIStore((s) => s.openModal);
@@ -222,7 +225,7 @@ export function MobileDmsScreen() {
{
key: 'leave-group',
type: 'action',
label: 'Leave Group',
label: t('groupDm.leave'),
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>,
onClick: () => {
@@ -251,7 +254,7 @@ export function MobileDmsScreen() {
<div className="flex flex-col h-full bg-surface-base">
{/* Header */}
<header className="h-12 flex items-center justify-between px-4 border-b border-border-soft shrink-0">
<h1 className="text-base font-semibold text-txt-primary">Messages</h1>
<h1 className="text-base font-semibold text-txt-primary">{t('mobile.messages')}</h1>
<div className="flex items-center gap-1">
<button
onClick={() => pushMobileScreen('friends')}
@@ -295,7 +298,7 @@ export function MobileDmsScreen() {
{sortedDms.length === 0 && (
<div className="flex flex-col items-center justify-center h-40 opacity-80">
<Mascot state="sleeping" className="w-20 h-20 mb-2" />
<p className="text-txt-tertiary text-sm">No conversations yet.</p>
<p className="text-txt-tertiary text-sm">{t('mobile.noConversations')}</p>
</div>
)}
</div>
@@ -14,6 +14,7 @@ import { ImageCropModal } from '../ui/ImageCropModal';
import { ConfirmDialog } from '../ui/ConfirmDialog';
import { MobileScreenHeader } from './MobileScreenHeader';
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
import { useT } from '../../i18n';
const MAX_NAME_LENGTH = 50;
const MAX_GROUP_MEMBERS = 10;
@@ -63,6 +64,7 @@ interface MobileGroupDmInfoProps {
* `MobileChatScreen`'s members button).
*/
export function MobileGroupDmInfo({ params }: MobileGroupDmInfoProps) {
const t = useT();
const channelId = params?.channelId ?? null;
const dmChannels = useSpaceStore((s) => s.dmChannels);
@@ -28,6 +28,17 @@ import { FederationPanel } from '../modals/instanceSettingsPanels/FederationPane
import { StreamingPanel } from '../modals/instanceSettingsPanels/StreamingPanel';
import { StoragePanel } from '../modals/instanceSettingsPanels/StoragePanel';
import { UsersPanel } from '../modals/instanceSettingsPanels/UsersPanel';
import { translate, useLocaleStore, type TranslationKey } from '../../i18n';
/**
* `screenMap` holds plain functions that are *called*, not rendered as
* components, so a hook inside them would run under whichever component
* happens to invoke the map. The locale is read directly instead; screens
* remount on navigation, so a language switch shows on the next push.
*/
function tr(key: TranslationKey): string {
return translate(useLocaleStore.getState().locale, key);
}
/**
* Wrapper for the Federation sub-panel that forwards FederationPanel's
@@ -60,32 +71,32 @@ const screenMap: Record<string, (params?: Record<string, string>) => React.React
'settings-instance': () => <MobileInstancePanel />,
'settings-instance-general': () => (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="General" rightActions={<TransferIndicator />} />
<MobileScreenHeader title={tr('nav.general')} rightActions={<TransferIndicator />} />
<div className="flex-1 overflow-y-auto p-4"><GeneralPanel /></div>
</div>
),
'settings-instance-registration': () => (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Registration" rightActions={<TransferIndicator />} />
<MobileScreenHeader title={tr('nav.registration')} rightActions={<TransferIndicator />} />
<div className="flex-1 overflow-y-auto p-4"><RegistrationPanel /></div>
</div>
),
'settings-instance-federation': () => <MobileFederationPanelWrapper />,
'settings-instance-streaming': () => (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Streaming" rightActions={<TransferIndicator />} />
<MobileScreenHeader title={tr('nav.streaming')} rightActions={<TransferIndicator />} />
<div className="flex-1 overflow-y-auto p-4"><StreamingPanel /></div>
</div>
),
'settings-instance-storage': () => (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Storage" rightActions={<TransferIndicator />} />
<MobileScreenHeader title={tr('nav.storage')} rightActions={<TransferIndicator />} />
<div className="flex-1 overflow-y-auto p-4"><StoragePanel /></div>
</div>
),
'settings-instance-users': () => (
<div className="flex flex-col h-full bg-surface-base">
<MobileScreenHeader title="Users" rightActions={<TransferIndicator />} />
<MobileScreenHeader title={tr('nav.users')} rightActions={<TransferIndicator />} />
<div className="flex-1 overflow-y-auto p-4"><UsersPanel /></div>
</div>
),
@@ -20,12 +20,14 @@ import { MobileVoiceJoinSheet } from '../voice/MobileVoiceJoinSheet';
import { buildVoiceModMenuItems, VolumeSliderItem } from '../voice/voiceMenuItems';
import { joinVoiceChannel } from '../../utils/voice';
import { useDelayedLoading } from '../../hooks/useDelayedLoading';
import { useT } from '../../i18n';
type ResolvedItem =
| { type: 'space'; space: TaggedSpace }
| { type: 'folder'; folder: SpaceFolder; spaces: TaggedSpace[] };
export function MobileSpacesScreen() {
const t = useT();
const spaces = useSpaceStore((s) => s.spaces);
const channels = useSpaceStore((s) => s.channels);
const categories = useSpaceStore((s) => s.categories);
@@ -266,7 +268,7 @@ export function MobileSpacesScreen() {
folderData[folderId] = { name: null, color: null, spaceIds: [spaceId] };
await updateSpaceLayout(newLayout, folderData);
addToast('Folder created', 'success', 3000);
addToast(t('mobile.folderCreated'), 'success', 3000);
};
const handleMoveToFolder = async (spaceId: string, folderId: string) => {
@@ -283,7 +285,7 @@ export function MobileSpacesScreen() {
}
await updateSpaceLayout(newLayout, folderData);
addToast('Moved to folder', 'success', 3000);
addToast(t('mobile.movedToFolder'), 'success', 3000);
};
const handleRemoveFromFolder = async (spaceId: string) => {
@@ -317,7 +319,7 @@ export function MobileSpacesScreen() {
}
await updateSpaceLayout(newLayout, folderData);
addToast('Removed from folder', 'success', 3000);
addToast(t('mobile.removedFromFolder'), 'success', 3000);
};
const handleUngroup = async (folderId: string) => {
@@ -341,7 +343,7 @@ export function MobileSpacesScreen() {
await updateSpaceLayout(newLayout, folderData);
setOpenFolderId(null);
addToast('Folder ungrouped', 'success', 3000);
addToast(t('mobile.folderUngrouped'), 'success', 3000);
};
const handleUpdateFolder = async (folderId: string, updates: { name?: string | null; color?: string | null }) => {
@@ -374,7 +376,7 @@ export function MobileSpacesScreen() {
{
key: 'invite',
type: 'action',
label: 'Invite People',
label: t('mobile.invitePeople'),
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zM3 19.235v-.11a6.375 6.375 0 0112.75 0v.109A12.318 12.318 0 019.374 21c-2.331 0-4.512-.645-6.374-1.766z" /></svg>,
onClick: async () => {
try {
@@ -382,16 +384,16 @@ export function MobileSpacesScreen() {
const origin = (space as TaggedSpace)._instanceOrigin || window.location.origin;
const url = `${origin}/invite/${code}`;
await navigator.clipboard.writeText(url);
addToast('Invite link copied to clipboard', 'success', 3000);
addToast(t('mobile.inviteCopied'), 'success', 3000);
} catch {
addToast('Failed to generate invite', 'warning', 3000);
addToast(t('mobile.inviteFailed'), 'warning', 3000);
}
},
},
{
key: 'create-folder',
type: 'action',
label: 'Create Folder',
label: t('mobile.createFolder'),
hidden: inFolder,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M2.25 12.75V12A2.25 2.25 0 014.5 9.75h15A2.25 2.25 0 0121.75 12v.75m-8.69-6.44l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z" /></svg>,
onClick: () => handleCreateFolder(spaceId),
@@ -403,12 +405,12 @@ export function MobileSpacesScreen() {
items.push({
key: 'move-to-folder',
type: 'submenu',
label: 'Move to Folder',
label: t('mobile.moveToFolder'),
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" /></svg>,
children: availableFolders.map(f => ({
key: f.id,
type: 'action' as const,
label: f.name || 'Unnamed Folder',
label: f.name || t('mobile.unnamedFolder'),
onClick: () => handleMoveToFolder(spaceId, f.id),
})),
});
@@ -419,7 +421,7 @@ export function MobileSpacesScreen() {
items.push({
key: 'remove-from-folder',
type: 'action',
label: 'Remove from Folder',
label: t('mobile.removeFromFolder'),
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9 15L3 9m0 0l6-6M3 9h12a6 6 0 010 12h-3" /></svg>,
onClick: () => handleRemoveFromFolder(spaceId),
});
@@ -430,7 +432,7 @@ export function MobileSpacesScreen() {
items.push({
key: 'transfer',
type: 'action',
label: 'Transfer Ownership',
label: t('dm.transfer.title'),
hidden: !isOwner,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M7.5 21L3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /></svg>,
onClick: () => setTransferModalSpaceId(spaceId),
@@ -439,7 +441,7 @@ export function MobileSpacesScreen() {
items.push({
key: 'leave',
type: 'action',
label: 'Leave Space',
label: t('mobile.leaveSpace'),
hidden: isOwner,
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" /></svg>,
@@ -462,7 +464,7 @@ export function MobileSpacesScreen() {
{
key: 'channel-settings',
type: 'action',
label: 'Channel Settings',
label: t('mobile.channelSettings'),
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
onClick: () => openModal('channelSettings', { channelId }),
},
@@ -470,7 +472,7 @@ export function MobileSpacesScreen() {
{
key: 'delete-channel',
type: 'action',
label: 'Delete Channel',
label: t('mobile.deleteChannel'),
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
onClick: () => setDeleteChannelId(channelId),
@@ -490,7 +492,7 @@ export function MobileSpacesScreen() {
{
key: 'category-settings',
type: 'action',
label: 'Category Settings',
label: t('mobile.categorySettings'),
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 011.37.49l1.296 2.247a1.125 1.125 0 01-.26 1.431l-1.003.827c-.293.24-.438.613-.431.992a6.759 6.759 0 010 .255c-.007.378.138.75.43.99l1.005.828c.424.35.534.954.26 1.43l-1.298 2.247a1.125 1.125 0 01-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.57 6.57 0 01-.22.128c-.331.183-.581.495-.644.869l-.213 1.28c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.02-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 01-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 01-1.369-.49l-1.297-2.247a1.125 1.125 0 01.26-1.431l1.004-.827c.292-.24.437-.613.43-.992a6.932 6.932 0 010-.255c.007-.378-.138-.75-.43-.99l-1.004-.828a1.125 1.125 0 01-.26-1.43l1.297-2.247a1.125 1.125 0 011.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.281z" /><path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /></svg>,
onClick: () => openModal('categorySettings', { categoryId }),
},
@@ -498,7 +500,7 @@ export function MobileSpacesScreen() {
{
key: 'delete-category',
type: 'action',
label: 'Delete Category',
label: t('mobile.deleteCategory'),
danger: true,
icon: <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}><path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" /></svg>,
onClick: () => setDeleteCategoryId(categoryId),
@@ -558,7 +560,7 @@ export function MobileSpacesScreen() {
items.push({
key: 'mute-user',
type: 'checkbox',
label: 'Mute User',
label: t('mobile.muteUser'),
subscribe: useVoiceStore.subscribe,
getChecked: () => useVoiceStore.getState().participantMutes.get(userId) ?? false,
onChange: (checked) => useVoiceStore.getState().setParticipantMute(userId, checked),
@@ -818,7 +820,7 @@ export function MobileSpacesScreen() {
{/* Channel list */}
<div className="flex-1 overflow-y-auto px-2 py-2">
{showChannelSkeleton ? (
<div className="px-1 pt-1" role="status" aria-label="Loading channels">
<div className="px-1 pt-1" role="status" aria-label={t('mobile.loadingChannels')}>
{/* Uncategorized group — 3 channel rows. Sized to match the real
channel rows (`px-3 py-2 rounded-lg` with `text-sm`/`w-4 h-4`
icon → ~36px height per row). */}
@@ -901,7 +903,7 @@ export function MobileSpacesScreen() {
{isSpaceSettledEmpty && (
<div className="flex flex-col items-center justify-center h-32 opacity-80">
<Mascot state="idle" className="w-20 h-20 mb-2" />
<p className="text-txt-tertiary text-sm">No channels yet.</p>
<p className="text-txt-tertiary text-sm">{t('mobile.noChannels')}</p>
</div>
)}
</>
@@ -926,7 +928,7 @@ export function MobileSpacesScreen() {
<svg className="w-5 h-5 text-accent-mint" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
</svg>
<span className="text-sm text-txt-primary">Create Space</span>
<span className="text-sm text-txt-primary">{t('mobile.createSpace')}</span>
</button>
<button
onClick={() => { setShowAddSheet(false); openModal('joinSpace'); }}
@@ -935,7 +937,7 @@ export function MobileSpacesScreen() {
<svg className="w-5 h-5 text-accent-sky" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
<span className="text-sm text-txt-primary">Join Space</span>
<span className="text-sm text-txt-primary">{t('mobile.joinSpace')}</span>
</button>
<button
onClick={() => { setShowAddSheet(false); pushMobileScreen('explore'); }}
@@ -944,7 +946,7 @@ export function MobileSpacesScreen() {
<svg className="w-5 h-5 text-accent-lavender" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span className="text-sm text-txt-primary">Explore Spaces</span>
<span className="text-sm text-txt-primary">{t('mobile.exploreSpaces')}</span>
</button>
</div>
</div>
@@ -955,17 +957,17 @@ export function MobileSpacesScreen() {
{leaveConfirmSpaceId && (
<ConfirmDialog
isOpen={true}
title="Leave Space"
description="Are you sure you want to leave this space? You'll need a new invite to rejoin."
confirmLabel="Leave"
title={t('mobile.leaveSpace')}
description={t('mobile.leaveSpace.confirm')}
confirmLabel={t('mobile.leave')}
variant="danger"
onConfirm={async () => {
try {
await useSpaceStore.getState().leaveSpace(leaveConfirmSpaceId);
if (selectedSpaceId === leaveConfirmSpaceId) setSelectedSpaceId(null);
addToast('Left space', 'success', 3000);
addToast(t('mobile.leftSpace'), 'success', 3000);
} catch {
addToast('Failed to leave space', 'warning', 3000);
addToast(t('mobile.leaveSpaceFailed'), 'warning', 3000);
}
setLeaveConfirmSpaceId(null);
}}
@@ -976,16 +978,16 @@ export function MobileSpacesScreen() {
{deleteChannelId && (
<ConfirmDialog
isOpen={true}
title="Delete Channel"
description="Are you sure? This will permanently delete the channel and all its messages."
confirmLabel="Delete"
title={t('mobile.deleteChannel')}
description={t('mobile.deleteChannel.confirm')}
confirmLabel={t('mobile.delete')}
variant="danger"
onConfirm={async () => {
try {
await useSpaceStore.getState().deleteChannel(deleteChannelId);
addToast('Channel deleted', 'success', 3000);
addToast(t('mobile.channelDeleted'), 'success', 3000);
} catch {
addToast('Failed to delete channel', 'warning', 3000);
addToast(t('mobile.deleteChannelFailed'), 'warning', 3000);
}
setDeleteChannelId(null);
}}
@@ -996,16 +998,16 @@ export function MobileSpacesScreen() {
{deleteCategoryId && (
<ConfirmDialog
isOpen={true}
title="Delete Category"
description="Are you sure? Channels in this category will be moved to uncategorized."
confirmLabel="Delete"
title={t('mobile.deleteCategory')}
description={t('mobile.deleteCategory.confirm')}
confirmLabel={t('mobile.delete')}
variant="danger"
onConfirm={async () => {
try {
await useSpaceStore.getState().deleteCategory(deleteCategoryId);
addToast('Category deleted', 'success', 3000);
addToast(t('mobile.categoryDeleted'), 'success', 3000);
} catch {
addToast('Failed to delete category', 'warning', 3000);
addToast(t('category.delete.failed'), 'warning', 3000);
}
setDeleteCategoryId(null);
}}
@@ -13,6 +13,7 @@ import {
import { VoiceGrid } from '../voice/VoiceGrid';
import { deriveGridTiles } from '../../hooks/useLiveKit';
import { requestMicPermission } from '../../utils/voice';
import { useT } from '../../i18n';
/**
* Full-screen mobile voice/video call view.
@@ -29,6 +30,7 @@ import { requestMicPermission } from '../../utils/voice';
* "render-everything-then-let-the-user-pick" pattern.
*/
export function MobileVoiceFullScreen() {
const t = useT();
const popMobileScreen = useUIStore((s) => s.popMobileScreen);
const pushMobileScreen = useUIStore((s) => s.pushMobileScreen);
@@ -249,7 +251,7 @@ export function MobileVoiceFullScreen() {
}
const isDmCall = currentVoiceChannelId.startsWith('dm-');
let channelName = 'Voice Call';
let channelName = t('mobile.voice.title');
let spaceName = '';
if (isDmCall) {
@@ -298,7 +300,7 @@ export function MobileVoiceFullScreen() {
<button
onClick={popMobileScreen}
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
aria-label="Collapse call"
aria-label={t('mobile.voice.collapse')}
>
<svg
className="w-5 h-5"
@@ -329,7 +331,7 @@ export function MobileVoiceFullScreen() {
<button
onClick={() => pushMobileScreen('members')}
className="w-8 h-8 flex items-center justify-center text-txt-secondary hover:text-txt-primary"
aria-label="View members"
aria-label={t('mobile.voice.members')}
>
<svg
className="w-5 h-5"
@@ -411,7 +413,7 @@ export function MobileVoiceFullScreen() {
? 'bg-accent-rose/20 text-accent-rose'
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={isMuted ? 'Unmute' : 'Mute'}
aria-label={isMuted ? t('mobile.voice.unmute') : 'Mute'}
>
<svg
className="w-5 h-5"
@@ -443,7 +445,7 @@ export function MobileVoiceFullScreen() {
? 'bg-accent-rose/20 text-accent-rose'
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={isDeafened ? 'Undeafen' : 'Deafen'}
aria-label={isDeafened ? t('mobile.voice.undeafen') : t('mobile.voice.deafen')}
>
<svg
className="w-5 h-5"
@@ -479,7 +481,7 @@ export function MobileVoiceFullScreen() {
? 'bg-accent-mint/20 text-accent-mint'
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={isCameraOn ? 'Turn camera off' : 'Turn camera on'}
aria-label={isCameraOn ? t('mobile.voice.cameraOff') : t('mobile.voice.cameraOn')}
>
<svg
className="w-5 h-5"
@@ -501,7 +503,7 @@ export function MobileVoiceFullScreen() {
e.stopPropagation();
setCameraPickerOpen((v) => !v);
}}
aria-label="Switch camera"
aria-label={t('mobile.voice.switchCamera')}
aria-haspopup="menu"
aria-expanded={cameraPickerOpen}
className="absolute -top-1 -right-1 w-6 h-6 rounded-full bg-surface-elevated text-txt-primary flex items-center justify-center shadow-md border border-border-soft active:scale-95 transition-transform"
@@ -542,7 +544,7 @@ export function MobileVoiceFullScreen() {
: 'bg-surface-elevated text-txt-primary hover:bg-interactive-hover'
}`}
aria-label={
isScreenSharing ? 'Stop sharing screen' : 'Share screen'
isScreenSharing ? t('mobile.voice.stopSharing') : t('mobile.voice.shareScreen')
}
>
<svg
@@ -564,7 +566,7 @@ export function MobileVoiceFullScreen() {
<button
onClick={handleDisconnect}
className="w-12 h-12 rounded-full bg-accent-rose flex items-center justify-center text-white hover:bg-accent-rose/80 transition-colors"
aria-label="Disconnect from call"
aria-label={t('mobile.voice.disconnect')}
>
<svg
className="w-5 h-5"
@@ -594,7 +596,7 @@ export function MobileVoiceFullScreen() {
<div
ref={cameraPickerPopupRef}
role="menu"
aria-label="Select camera"
aria-label={t('mobile.voice.selectCamera')}
className="fixed z-[60] rounded-md bg-surface-elevated border border-border-hard py-1 shadow-lg overflow-y-auto"
style={{
left: cameraPickerRect.left,
@@ -17,6 +17,7 @@ import { BansPanel } from './spaceSettingsPanels/BansPanel';
import type { SpaceVisibility, JoinRequest } from '@backspace/shared';
function DiscoveryPanel({ spaceId }: { spaceId: string }) {
const t = useT();
const spaces = useSpaceStore((s) => s.spaces);
const updateSpace = useSpaceStore((s) => s.updateSpace);
const discoveryEnabled = useSettingsStore((s) => s.streamingLimits?.discoveryEnabled ?? true);
@@ -49,9 +50,9 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
setSaveError('');
try {
await api.spaces.update(spaceId, { visibility, description: description.trim() });
addToast('Settings saved', 'success', 2000);
addToast(t('space.overview.saved'), 'success', 2000);
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Failed to save');
setSaveError(err instanceof Error ? err.message : t('space.overview.saveFailed'));
} finally {
setSaving(false);
}
@@ -71,7 +72,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Discovery</h2>
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('space.discovery.title')}</h2>
{!discoveryEnabled && (
<div className="p-2.5 bg-accent-amber/10 border border-accent-amber/30 rounded text-[13px] text-accent-amber">
Space discovery is disabled by the instance administrator. Changing visibility will have no effect until discovery is re-enabled.
@@ -79,8 +80,8 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
)}
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Visibility</div>
<p className="text-xs text-txt-tertiary mb-2">Control who can discover and join this space.</p>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('space.discovery.visibility')}</div>
<p className="text-xs text-txt-tertiary mb-2">{t('space.discovery.visibility.description')}</p>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<div className="space-y-1.5">
{visibilityOptions.map((opt) => (
@@ -111,13 +112,13 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
</div>
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">Description</div>
<p className="text-xs text-txt-tertiary mb-2">A short summary shown on the Explore page.</p>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">{t('space.discovery.description')}</div>
<p className="text-xs text-txt-tertiary mb-2">{t('space.discovery.description.hint')}</p>
<div className="rounded-lg bg-white/[0.02] p-3.5">
<textarea
value={description}
onChange={(e) => setDescription(e.target.value.slice(0, 200))}
placeholder="A short description for the Explore page..."
placeholder={t('space.discovery.description.placeholder')}
rows={3}
className="input-standard w-full resize-none"
/>
@@ -148,7 +149,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
disabled={saving}
className="px-3 py-1.5 bg-accent-primary hover:bg-accent-primary/80 text-white text-sm font-medium rounded-full transition-colors disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save'}
{saving ? t('space.overview.saving') : 'Save'}
</button>
</div>
</div>
@@ -159,6 +160,7 @@ function DiscoveryPanel({ spaceId }: { spaceId: string }) {
}
function JoinRequestsSection({ spaceId }: { spaceId: string }) {
const t = useT();
const [requests, setRequests] = useState<JoinRequest[]>([]);
const [loading, setLoading] = useState(true);
const [actionError, setActionError] = useState('');
@@ -185,7 +187,7 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
await api.explore.decideJoinRequest(spaceId, requestId, action);
setRequests(prev => prev.filter(r => r.id !== requestId));
} catch (err) {
setActionError(err instanceof Error ? err.message : 'Action failed');
setActionError(err instanceof Error ? err.message : t('space.requests.failed'));
}
};
@@ -202,9 +204,9 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
)}
{loading ? (
<div className="text-sm text-txt-tertiary">Loading...</div>
<div className="text-sm text-txt-tertiary">{t('space.requests.loading')}</div>
) : requests.length === 0 ? (
<div className="text-sm text-txt-tertiary">No pending join requests</div>
<div className="text-sm text-txt-tertiary">{t('space.requests.empty')}</div>
) : (
<div className="space-y-2 max-h-[240px] overflow-y-auto scrollbar-thin">
{requests.map((req) => {
@@ -237,7 +239,7 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
<button
onClick={() => handleDecide(req.id, 'accept')}
className="p-1.5 rounded text-status-online hover:bg-status-online/20 transition-colors"
title="Accept"
title={t('space.requests.accept')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
@@ -246,7 +248,7 @@ function JoinRequestsSection({ spaceId }: { spaceId: string }) {
<button
onClick={() => handleDecide(req.id, 'decline')}
className="p-1.5 rounded text-txt-danger hover:bg-accent-rose/20 transition-colors"
title="Decline"
title={t('space.requests.decline')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
@@ -321,20 +323,20 @@ export function SpaceSettingsModal() {
{/* Nav list */}
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">General</div>
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>Overview</button>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.general')}</div>
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>{t('nav.overview')}</button>
{canManageSpace && (
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>Discovery</button>
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>{t('nav.discovery')}</button>
)}
<div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">Management</div>
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>Members</button>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.management')}</div>
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>{t('nav.members')}</button>
{canManageRoles && (
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>Roles</button>
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>{t('nav.roles')}</button>
)}
{canBanMembers && (
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>{t('nav.bans')}</button>
)}
{canManageSpace && (
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
@@ -363,20 +365,20 @@ export function SpaceSettingsModal() {
</div>
<div className="glass-bubble rounded-lg p-2 space-y-0.5">
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">General</div>
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>Overview</button>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.general')}</div>
<button onClick={() => handleTabClick('overview')} className={tabClass('overview')}>{t('nav.overview')}</button>
{canManageSpace && (
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>Discovery</button>
<button onClick={() => handleTabClick('discovery')} className={tabClass('discovery')}>{t('nav.discovery')}</button>
)}
<div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">Management</div>
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>Members</button>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.management')}</div>
<button onClick={() => handleTabClick('members')} className={tabClass('members')}>{t('nav.members')}</button>
{canManageRoles && (
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>Roles</button>
<button onClick={() => handleTabClick('roles')} className={tabClass('roles')}>{t('nav.roles')}</button>
)}
{canBanMembers && (
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>{t('nav.bans')}</button>
)}
{canManageSpace && (
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
@@ -398,7 +400,7 @@ export function SpaceSettingsModal() {
<button
onClick={() => setMobileView('tabs')}
className="flex items-center gap-1.5 text-txt-tertiary hover:text-txt-secondary mb-4 text-sm"
aria-label="Back to space settings menu"
aria-label={t('nav.back')}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z" />
@@ -137,23 +137,23 @@ export function UserSettingsModal() {
{/* Nav list */}
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.userSettings')}</div>
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
<div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.appSettings')}</div>
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>{t('settings.desktop.title')}</button>}
{isAdmin && (
<>
<div className="border-t border-white/[0.04] my-2 mx-2" />
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">Administration</div>
<button onClick={() => handleTabClick('instance')} className={tabClass('instance')}>Instance</button>
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">{t('nav.administration')}</div>
<button onClick={() => handleTabClick('instance')} className={tabClass('instance')}>{t('nav.instance')}</button>
{tab === 'instance' && <SidebarSubLinks />}
</>
)}
@@ -3,8 +3,10 @@ import { Toggle } from '../../ui/Toggle';
import { VideoSection } from './VideoSection';
import { AudioInputSection } from './AudioInputSection';
import { AudioOutputSection } from './AudioOutputSection';
import { useT } from '../../../i18n';
export function VoicePanel() {
const t = useT();
const echoCancellation = useVoiceStore((s) => s.echoCancellation);
const autoGainControl = useVoiceStore((s) => s.autoGainControl);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
@@ -18,19 +20,19 @@ export function VoicePanel() {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Voice &amp; Video</h2>
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('voice.title')}</h2>
<AudioInputSection />
<AudioOutputSection />
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Volume
{t('voice.settings.volume')}
</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="py-1">
<div className="flex items-center justify-between mb-2">
<div className="text-sm text-txt-primary">Sound Effects Volume</div>
<div className="text-sm text-txt-primary">{t('voice.settings.sfxVolume')}</div>
<div className="text-xs text-txt-tertiary tabular-nums">{soundEffectVolume}%</div>
</div>
<input
@@ -47,9 +49,9 @@ export function VoicePanel() {
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Play sound for every message</div>
<div className="text-sm text-txt-primary">{t('voice.settings.messageSound')}</div>
<div className="text-xs text-txt-tertiary">
Off (default): only DMs and messages that mention you. On: every channel.
{t('voice.settings.messageSound.description')}
</div>
</div>
<Toggle enabled={messageSoundAllChannels} onChange={setMessageSoundAllChannels} />
@@ -61,29 +63,29 @@ export function VoicePanel() {
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Voice Processing
{t('voice.settings.processing')}
</div>
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">AI Noise Suppression</div>
<div className="text-xs text-txt-tertiary">ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise</div>
<div className="text-sm text-txt-primary">{t('voice.settings.noise')}</div>
<div className="text-xs text-txt-tertiary">{t('voice.settings.noise.description')}</div>
</div>
<Toggle enabled={rnnoiseEnabled} onChange={setRnnoiseEnabled} />
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Echo Cancellation</div>
<div className="text-xs text-txt-tertiary">Cancels echo from your speakers feeding back into the mic. Always on for voice channels and calls.</div>
<div className="text-sm text-txt-primary">{t('voice.settings.echo')}</div>
<div className="text-xs text-txt-tertiary">{t('voice.settings.echo.description')}</div>
</div>
<Toggle enabled={echoCancellation} onChange={setEchoCancellation} />
</div>
<div className="flex items-center justify-between py-2">
<div>
<div className="text-sm text-txt-primary">Auto Gain Control</div>
<div className="text-xs text-txt-tertiary">Auto-adjusts mic volume — can cause voice ducking during streams</div>
<div className="text-sm text-txt-primary">{t('voice.settings.agc')}</div>
<div className="text-xs text-txt-tertiary">{t('voice.settings.agc.description')}</div>
</div>
<Toggle enabled={autoGainControl} onChange={setAutoGainControl} />
</div>
@@ -4,6 +4,7 @@ import { useTrackStats, AudioTrackStat, VideoTrackStat } from '../../hooks/useTr
import { getActiveRoom } from '../../hooks/useLiveKit';
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
import { usePortalContainer } from '../../hooks/usePortalContainer';
import { useT, translate, useLocaleStore, type TranslationKey } from '../../i18n';
interface ConnectionInfoPopoverProps {
open: boolean;
@@ -34,13 +35,20 @@ function jitterColor(ms: number): string {
return 'text-txt-danger';
}
/** Track labels are built outside the component, so the locale is read at call
* time. The popover re-renders on each stats tick, so a language switch shows
* through within a second. */
function tr(key: TranslationKey): string {
return translate(useLocaleStore.getState().locale, key);
}
function sourceLabel(source: string): string {
switch (source) {
case 'microphone': return 'Microphone';
case 'camera': return 'Camera';
case 'screen_share': return 'Screen';
case 'screen_share_audio': return 'Screen Audio';
default: return 'Unknown';
case 'microphone': return tr('voice.info.microphone');
case 'camera': return tr('voice.info.camera');
case 'screen_share': return tr('voice.info.screen');
case 'screen_share_audio': return tr('voice.info.screenAudio');
default: return tr('voice.info.unknown');
}
}
@@ -49,7 +57,7 @@ function trackLabel(direction: 'send' | 'recv', source: string, participantName:
if (direction === 'send') {
return `${sourceLabel(source)} ${arrow}`;
}
const name = participantName ?? 'Remote';
const name = participantName ?? tr('voice.info.remote');
return `${name} ${sourceLabel(source)} ${arrow}`;
}
@@ -118,6 +126,7 @@ function VideoTrackRow({ track }: { track: VideoTrackStat }) {
}
export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionInfoPopoverProps) {
const t = useT();
const popoverRef = useRef<HTMLDivElement>(null);
const portalContainer = usePortalContainer();
const stats = useTrackStats(open);
@@ -151,36 +160,36 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
className="w-[300px] glass rounded-lg overflow-hidden"
>
<div className="px-3 py-2 border-b border-border-hard">
<span className="text-[14px] font-bold text-txt-primary">Connection Info</span>
<span className="text-[14px] font-bold text-txt-primary">{t('voice.info.title')}</span>
</div>
<div className="px-3 py-2 max-h-[calc(100vh-32px)] overflow-y-auto scrollbar-thin">
{!room ? (
<div className="text-[12px] text-txt-tertiary py-2 text-center">Not connected</div>
<div className="text-[12px] text-txt-tertiary py-2 text-center">{t('voice.info.notConnected')}</div>
) : !stats ? (
<div className="text-[12px] text-txt-tertiary py-2 text-center">Gathering stats...</div>
<div className="text-[12px] text-txt-tertiary py-2 text-center">{t('voice.info.gathering')}</div>
) : (
<>
{/* Network */}
<SectionHeader title="Network" />
<SectionHeader title={t('voice.info.network')} />
<Row
label="Ping"
label={t('voice.info.ping')}
value={stats.network.ping !== null ? `${stats.network.ping} ms` : '\u2014'}
colorClass={stats.network.ping !== null ? pingColor(stats.network.ping) : undefined}
/>
<Row
label="Packet Loss"
label={t('voice.info.packetLoss')}
value={stats.network.packetLoss !== null ? `${stats.network.packetLoss.toFixed(1)}%` : '\u2014'}
colorClass={stats.network.packetLoss !== null ? lossColor(stats.network.packetLoss) : undefined}
/>
<Row
label="Jitter"
label={t('voice.info.jitter')}
value={stats.network.jitter !== null ? `${stats.network.jitter} ms` : '\u2014'}
colorClass={stats.network.jitter !== null ? jitterColor(stats.network.jitter) : undefined}
/>
<Row label="Server" value={stats.network.serverAddress ?? '\u2014'} />
<Row label={t('voice.info.server')} value={stats.network.serverAddress ?? '\u2014'} />
<Row
label="Protocol"
label={t('voice.info.protocol')}
value={
stats.network.protocol
? `${stats.network.protocol}${stats.network.candidateType ? ` (${stats.network.candidateType})` : ''}`
@@ -192,7 +201,7 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
{stats.audioTracks.length > 0 && (
<>
<Divider />
<SectionHeader title="Audio" />
<SectionHeader title={t('voice.info.audio')} />
{stats.audioTracks.map((t) => (
<AudioTrackRow key={t.key} track={t} />
))}
@@ -203,7 +212,7 @@ export function ConnectionInfoPopover({ open, onClose, anchorRef }: ConnectionIn
{stats.videoTracks.length > 0 && (
<>
<Divider />
<SectionHeader title="Video" />
<SectionHeader title={t('voice.info.video')} />
{stats.videoTracks.map((t) => (
<VideoTrackRow key={t.key} track={t} />
))}
@@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore } from '../../stores/spaceStore';
import { useDragToClose } from '../../hooks/useDragToClose';
import { VoiceUserRow } from './VoiceUserRow';
import { useT } from '../../i18n';
/**
* Permission state for the in-sheet camera preview. Mirrors the smaller
@@ -33,6 +34,7 @@ export function MobileVoiceJoinSheet({
onJoin,
}: MobileVoiceJoinSheetProps) {
const [preMuted, setPreMuted] = useState(false);
const t = useT();
const [visible, setVisible] = useState(false);
const voiceUsers = useVoiceStore((s) => s.voiceUsers);
@@ -284,13 +286,13 @@ export function MobileVoiceJoinSheet({
setPermState('denied');
setPreviewActive(false);
} else if (name === 'NotReadableError') {
setPreviewError('Camera is in use by another application.');
setPreviewError(t('settings.video.error.inUse'));
} else if (name === 'OverconstrainedError') {
setPreviewError('Selected camera is unavailable.');
setPreviewError(t('settings.video.error.unavailable'));
} else if (name === 'NotFoundError') {
setPreviewError('No camera detected.');
setPreviewError(t('settings.video.error.notFound'));
} else {
setPreviewError('Could not start camera preview.');
setPreviewError(t('settings.video.error.generic'));
}
}
};
@@ -364,13 +366,13 @@ export function MobileVoiceJoinSheet({
setPermState('denied');
setPreviewActive(false);
} else if (name === 'NotReadableError') {
setPreviewError('Camera is in use by another application.');
setPreviewError(t('settings.video.error.inUse'));
} else if (name === 'OverconstrainedError') {
setPreviewError('Selected camera is unavailable.');
setPreviewError(t('settings.video.error.unavailable'));
} else if (name === 'NotFoundError') {
setPreviewError('No camera detected.');
setPreviewError(t('settings.video.error.notFound'));
} else {
setPreviewError('Could not start camera preview.');
setPreviewError(t('settings.video.error.generic'));
}
}
}, [cameraDeviceId, stopPreview]);
@@ -387,7 +389,7 @@ export function MobileVoiceJoinSheet({
const selectedCameraLabel = useMemo(() => {
if (cameraDeviceId === null) return 'Auto';
const d = cameraDevices.find((c) => c.deviceId === cameraDeviceId);
return d?.label || 'Selected camera';
return d?.label || t('voice.join.selectedCamera');
}, [cameraDeviceId, cameraDevices]);
return createPortal(
@@ -457,13 +459,13 @@ export function MobileVoiceJoinSheet({
type="button"
onClick={startPreviewFromUser}
className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary active:bg-white/[0.03] transition-colors"
aria-label="Enable camera preview"
aria-label={t('voice.join.enablePreview')}
>
<svg width="32" height="32" viewBox="0 0 24 24" fill="currentColor" className="mb-1.5">
<path d="M17 10.5V7a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1v-3.5l4 4v-11l-4 4z" />
</svg>
<span className="text-xs">
{permState === 'unknown' ? 'Checking camera…' : 'Tap to preview camera'}
{t(permState === 'unknown' ? 'voice.join.checkingCamera' : 'voice.join.tapToPreview')}
</span>
</button>
)}
@@ -471,7 +473,7 @@ export function MobileVoiceJoinSheet({
{/* Denied state */}
{!previewActive && !previewError && permState === 'denied' && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-txt-tertiary px-6 text-center gap-2">
<span className="text-xs">Camera permission denied.</span>
<span className="text-xs">{t('voice.join.permissionDenied')}</span>
<button
type="button"
onClick={startPreviewFromUser}
@@ -573,8 +575,8 @@ export function MobileVoiceJoinSheet({
{/* Empty state */}
{userCount === 0 && (
<div className="px-5 mb-4 py-3 text-center">
<p className="text-sm text-txt-tertiary">No one is in this channel yet.</p>
<p className="text-xs text-txt-tertiary/60 mt-1">Be the first to join!</p>
<p className="text-sm text-txt-tertiary">{t('voice.join.empty')}</p>
<p className="text-xs text-txt-tertiary/60 mt-1">{t('voice.join.beFirst')}</p>
</div>
)}
@@ -584,7 +586,7 @@ export function MobileVoiceJoinSheet({
<button
onClick={() => setPreMuted(!preMuted)}
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
aria-label={preMuted ? 'Unmute microphone' : 'Mute microphone'}
aria-label={preMuted ? t('voice.join.unmute') : t('voice.join.mute')}
>
{preMuted ? (
/* Mic off icon */
@@ -607,14 +609,14 @@ export function MobileVoiceJoinSheet({
onClick={handleJoin}
className="bg-accent-mint text-black font-semibold rounded-full px-8 py-3 active:scale-95 transition-transform"
>
{isSwitching ? 'Switch Channel' : 'Join Voice'}
{t(isSwitching ? 'voice.join.switch' : 'voice.join.join')}
</button>
{/* Close button */}
<button
onClick={onClose}
className="w-12 h-12 bg-surface-elevated rounded-full flex items-center justify-center text-txt-secondary active:scale-95 transition-transform"
aria-label="Close"
aria-label={t('voice.join.close')}
>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.5} strokeLinecap="round" strokeLinejoin="round">
<path d="M6 18L18 6M6 6l12 12" />
@@ -12,12 +12,14 @@ import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { broadcastVoiceStatus } from '../../utils/voice';
import { handleCameraAction } from '../../utils/voiceActions';
import { useT } from '../../i18n';
/**
* VoiceControls renders the voice status + button rows.
* It has NO wrapper/card styling — the parent provides the container.
*/
export function VoiceControls() {
const t = useT();
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
@@ -55,7 +57,7 @@ export function VoiceControls() {
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName =
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? t('voice.dmCall') : t('voice.channel'));
// Jump back to where the call is happening. DM calls live under @me; space
// calls under the space captured at join time.
@@ -147,7 +149,7 @@ export function VoiceControls() {
if (!showConnectionInfo) setShowScreenShareSettings(false);
}}
className={`w-8 h-8 rounded-lg ${statusBgColor} flex items-center justify-center flex-shrink-0 hover:brightness-125 transition-all`}
title="Connection Info"
title={t('voice.info.title')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className={qualityColor}>
<path d="M1 9l2 2c4.97-4.97 13.03-4.97 18 0l2-2C16.93 2.93 7.08 2.93 1 9zm8 8l3 3 3-3c-1.65-1.66-4.34-1.66-6 0zm-4-4l2 2c2.76-2.76 7.24-2.76 10 0l2-2C15.14 9.14 8.87 9.14 5 13z" />
@@ -156,12 +158,12 @@ export function VoiceControls() {
<div className="min-w-0 flex-1">
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
{connectionError ? t('voice.failed') : isLiveKitConnected ? t('voice.connected') : t('voice.connecting')}
</div>
{canGoToCall ? (
<button
onClick={handleGoToCall}
title="Go to call"
title={t('voice.goToCall')}
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
>
{channelName}
@@ -177,7 +179,7 @@ export function VoiceControls() {
<button
onClick={handleDisconnect}
className="w-7 h-7 flex items-center justify-center text-txt-tertiary hover:text-txt-primary transition-colors rounded"
title="Disconnect"
title={t('voice.disconnect')}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 9C10.4 9 8.85 9.25 7.4 9.72V12.82C7.4 13.22 7.17 13.56 6.84 13.72C5.86 14.21 4.97 14.84 4.18 15.57C4 15.75 3.75 15.85 3.48 15.85C3.2 15.85 2.95 15.74 2.77 15.56L0.29 13.08C0.11 12.9 0 12.65 0 12.38C0 12.1 0.11 11.85 0.29 11.67C3.34 8.78 7.46 7 12 7S20.66 8.78 23.71 11.67C23.89 11.85 24 12.1 24 12.38C24 12.65 23.89 12.9 23.71 13.08L21.23 15.56C21.05 15.74 20.8 15.85 20.52 15.85C20.25 15.85 20 15.75 19.82 15.57C19.03 14.84 18.14 14.21 17.16 13.72C16.83 13.56 16.6 13.22 16.6 12.82V9.72C15.15 9.25 13.6 9 12 9Z" />
@@ -203,7 +205,7 @@ export function VoiceControls() {
? 'bg-surface-base text-status-online hover:bg-surface-channel'
: btnDefaultStyle
}`}
title={isCameraOn ? 'Turn Off Camera' : 'Turn On Camera'}
title={t(isCameraOn ? 'voice.camera.off' : 'voice.camera.on')}
>
{isCameraOn ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
@@ -226,7 +228,7 @@ export function VoiceControls() {
? 'bg-surface-base text-status-online hover:bg-surface-channel'
: btnDefaultStyle
}`}
title={isScreenSharing ? 'Stop Sharing' : 'Share Screen'}
title={t(isScreenSharing ? 'voice.share.stop' : 'voice.share.start')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M20 18C21.1 18 22 17.1 22 16V6C22 4.9 21.1 4 20 4H4C2.9 4 2 4.9 2 6V16C2 17.1 2.9 18 4 18H0V20H24V18H20ZM4 6H20V16H4V6Z" />
@@ -244,7 +246,7 @@ export function VoiceControls() {
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
: btnDefaultStyle
}`}
title="Soundboard"
title={t('voice.soundboard')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6Z" />
@@ -264,7 +266,7 @@ export function VoiceControls() {
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
: btnDefaultStyle
}`}
title="Video Quality"
title={t('voice.videoQuality')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 5v14h18V5H3zm16 12H5V7h14v10z" />
@@ -280,7 +282,7 @@ export function VoiceControls() {
? 'bg-surface-base text-status-online hover:bg-surface-channel'
: btnDefaultStyle
}`}
title={rnnoiseEnabled ? 'Disable AI Noise Suppression' : 'Enable AI Noise Suppression'}
title={t(rnnoiseEnabled ? 'voice.noise.disable' : 'voice.noise.enable')}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z" opacity={rnnoiseEnabled ? 0.15 : 0.08} />
@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'fs';
import { join } from 'path';
/**
* A component that calls `t(...)` without declaring `const t = useT()` is a
* build error, but it is one you only find after a full `tsc` run — and the
* mistake is easy to make in files that hold an exported component plus a
* private sub-component, because adding the hook to the exported one looks
* like it covered the file. This test names the offending component directly.
*
* It also flags the reverse: a hook declared above an early `return`, which is
* legal, versus below one, which breaks the rules of hooks at runtime rather
* than at compile time (React error #310 — this project shipped one once).
*/
function walk(dir: string): string[] {
return readdirSync(dir).flatMap((entry) => {
const full = join(dir, entry);
if (statSync(full).isDirectory()) return walk(full);
return full.endsWith('.tsx') && !full.includes('.test.') ? [full] : [];
});
}
const FUNCTION_START = /^(?:export )?function (\w+)\s*[(<]/gm;
/** `t(` not preceded by an identifier char, a dot, or a quote. */
const USES_T = /[^a-zA-Z_.'`]t\(/;
const DECLARES_T = 'const t = useT();';
describe('useT placement', () => {
const files = walk(join(__dirname, '..', 'components'));
it('every component calling t() declares the hook', () => {
const offenders: string[] = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const starts = [...src.matchAll(FUNCTION_START)];
for (let i = 0; i < starts.length; i++) {
const from = starts[i]!.index!;
const to = i + 1 < starts.length ? starts[i + 1]!.index! : src.length;
const body = src.slice(from, to);
if (USES_T.test(body) && !body.includes(DECLARES_T)) {
offenders.push(`${file.split('/src/')[1]} :: ${starts[i]![1]}`);
}
}
}
expect(offenders).toEqual([]);
});
it('the hook is never declared below an early return', () => {
const offenders: string[] = [];
for (const file of files) {
const src = readFileSync(file, 'utf8');
const starts = [...src.matchAll(FUNCTION_START)];
for (let i = 0; i < starts.length; i++) {
const from = starts[i]!.index!;
const to = i + 1 < starts.length ? starts[i + 1]!.index! : src.length;
const body = src.slice(from, to);
const hook = body.indexOf(DECLARES_T);
if (hook === -1) continue;
// An early return at the component's own indentation level, i.e. two
// spaces — nested returns inside callbacks are indented further.
const earlyReturn = body.search(/\n {2}(?:if \([^\n]*\) )?return /);
if (earlyReturn !== -1 && earlyReturn < hook) {
offenders.push(`${file.split('/src/')[1]} :: ${starts[i]![1]}`);
}
}
}
expect(offenders).toEqual([]);
});
});
+215
View File
@@ -804,6 +804,221 @@ export const en = {
'instance.general.reset': 'Reset',
'instance.general.saved': 'Settings saved',
'instance.general.saveFailed': 'Failed to save',
// System messages — timeline line and sidebar preview
'system.unknown': 'Unknown',
'system.someone': 'Someone',
'system.aMember': 'A member',
'system.memberAdded': '{actor} added {target} to the group',
'system.memberLeft': '{target} left the group',
'system.memberRemoved': '{actor} removed {target} from the group',
'system.ownerChanged': '{owner} is now the group owner',
'system.nameChanged': '{actor} renamed the group to "{name}"',
'system.nameCleared': '{actor} cleared the group name',
'system.iconChanged': '{actor} updated the group icon',
'system.preview.inviteTo': 'Sent invite to {space}',
'system.preview.invite': 'Sent a space invite',
'system.preview.memberAdded': '{actor} added {target}',
'system.preview.memberRemoved': '{actor} removed {target}',
'system.preview.nameChanged': '{actor} renamed the group',
'system.preview.generic': 'System message',
// Login, register and the invite landing page
'auth.username': 'Username',
'auth.password': 'Password',
'auth.username.required': 'Username is required',
'auth.password.required': 'Password is required',
'auth.login.title': 'Welcome back!',
'auth.login.subtitle': "We're so excited to see you again!",
'auth.login.submit': 'Log In',
'auth.login.loggingIn': 'Logging in...',
'auth.login.failed': 'Login failed',
'auth.login.rateLimited': 'Too many login attempts',
'auth.retryIn': 'Try again in {seconds}s',
'auth.needAccount': 'Need an account?',
'auth.register': 'Register',
'auth.haveAccount': 'Already have an account?',
'auth.register.title': 'Create an account',
'auth.register.confirmPassword': 'Confirm Password',
'auth.register.username.length': 'Username must be between 3 and 32 characters',
'auth.register.username.charset': 'Username can only contain lowercase letters, numbers, and underscores',
'auth.register.username.checking': 'Checking availability...',
'auth.register.username.available': 'Username is available',
'auth.register.username.taken': 'Username is already taken',
'auth.register.password.length': 'Password must be at least 6 characters',
'auth.register.password.mismatch': 'Passwords do not match',
'auth.register.failed': 'Registration failed',
'auth.register.rateLimited': 'Too many attempts',
'auth.register.continue': 'Continue',
'auth.register.invite': 'Invite code or link',
'auth.register.invite.checking': 'Checking...',
'auth.register.invite.validating': 'Validating invite...',
'auth.register.invite.expired': 'This invite link has expired. Ask the admin for a new one.',
'auth.register.invite.exhausted': 'This invite has reached its usage limit. Ask the admin to extend it.',
'auth.register.invite.invalid': 'Invalid invite code.',
'auth.register.invite.invalidLink': 'Invalid invite link — please request a new one',
'auth.register.profile.title': 'Make it yours',
'auth.register.profile.subtitle': 'Personalize your profile, or skip for now',
'auth.register.displayName': 'Display Name',
'auth.register.displayName.placeholder': 'Display name',
'auth.register.avatarColor': 'Avatar Color',
'auth.register.submit': 'Get Started',
'auth.register.creating': 'Creating account...',
'auth.register.back': 'Back',
'auth.register.skip': 'Skip for now',
'auth.register.cropAvatar': 'Crop Avatar',
'join.page.loading': 'Loading invite...',
'join.page.invalid.title': 'Invalid Invite',
'join.page.invalid.body': 'This invite link is invalid or has expired.',
'join.page.invitedTo': "You've been invited to join",
'join.page.noCode': 'No invite code provided',
'join.page.invalidCode': 'Invalid invite code',
'join.page.loadFailed': 'Failed to load invite',
'join.page.joinFailed': 'Failed to join space',
'join.page.connectFailed': 'Failed to connect',
'join.page.loginFailed': 'Failed to log in',
'join.page.instance.placeholder': 'e.g. my-instance.com',
'join.page.alreadyMember': "You're already in {space}!",
'join.page.goTo': 'Go to {space}',
'join.page.redirecting': 'Redirecting you now...',
// Voice — controls, connection info and the mobile join sheet
'voice.title': 'Voice & Video',
'voice.dmCall': 'DM Call',
'voice.channel': 'Voice Channel',
'voice.connected': 'Voice Connected',
'voice.connecting': 'Connecting...',
'voice.failed': 'Connection Failed',
'voice.goToCall': 'Go to call',
'voice.disconnect': 'Disconnect',
'voice.camera.on': 'Turn On Camera',
'voice.camera.off': 'Turn Off Camera',
'voice.share.start': 'Share Screen',
'voice.share.stop': 'Stop Sharing',
'voice.soundboard': 'Soundboard',
'voice.videoQuality': 'Video Quality',
'voice.noise.enable': 'Enable AI Noise Suppression',
'voice.noise.disable': 'Disable AI Noise Suppression',
'voice.info.title': 'Connection Info',
'voice.info.notConnected': 'Not connected',
'voice.info.gathering': 'Gathering stats...',
'voice.info.network': 'Network',
'voice.info.ping': 'Ping',
'voice.info.packetLoss': 'Packet Loss',
'voice.info.jitter': 'Jitter',
'voice.info.server': 'Server',
'voice.info.protocol': 'Protocol',
'voice.info.audio': 'Audio',
'voice.info.video': 'Video',
'voice.info.microphone': 'Microphone',
'voice.info.camera': 'Camera',
'voice.info.screen': 'Screen',
'voice.info.screenAudio': 'Screen Audio',
'voice.info.unknown': 'Unknown',
'voice.info.remote': 'Remote',
'voice.join.empty': 'No one is in this channel yet.',
'voice.join.beFirst': 'Be the first to join!',
'voice.join.mute': 'Mute microphone',
'voice.join.unmute': 'Unmute microphone',
'voice.join.switch': 'Switch Channel',
'voice.join.join': 'Join Voice',
'voice.join.close': 'Close',
'voice.join.selectedCamera': 'Selected camera',
'voice.join.enablePreview': 'Enable camera preview',
'voice.join.checkingCamera': 'Checking camera…',
'voice.join.tapToPreview': 'Tap to preview camera',
'voice.join.permissionDenied': 'Camera permission denied.',
'voice.settings.volume': 'Volume',
'voice.settings.sfxVolume': 'Sound Effects Volume',
'voice.settings.messageSound': 'Play sound for every message',
'voice.settings.messageSound.description': 'Off (default): only DMs and messages that mention you. On: every channel.',
'voice.settings.processing': 'Voice Processing',
'voice.settings.noise': 'AI Noise Suppression',
'voice.settings.noise.description': 'ML-based noise removal (RNNoise) — filters keyboard, fans, and background noise',
'voice.settings.echo': 'Echo Cancellation',
'voice.settings.echo.description': 'Cancels echo from your speakers feeding back into the mic. Always on for voice channels and calls.',
'voice.settings.agc': 'Auto Gain Control',
'voice.settings.agc.description': 'Auto-adjusts mic volume — can cause voice ducking during streams',
// Settings navigation (space, user and instance)
'nav.general': 'General',
'nav.management': 'Management',
'nav.overview': 'Overview',
'nav.discovery': 'Discovery',
'nav.members': 'Members',
'nav.roles': 'Roles',
'nav.bans': 'Bans',
'nav.userSettings': 'User Settings',
'nav.appSettings': 'App Settings',
'nav.administration': 'Administration',
'nav.instance': 'Instance',
'nav.registration': 'Registration',
'nav.federation': 'Federation',
'nav.streaming': 'Streaming',
'nav.storage': 'Storage',
'nav.users': 'Users',
'nav.back': 'Back to space settings menu',
'space.discovery.title': 'Discovery',
'space.discovery.visibility': 'Visibility',
'space.discovery.visibility.description': 'Control who can discover and join this space.',
'space.discovery.description': 'Description',
'space.discovery.description.hint': 'A short summary shown on the Explore page.',
'space.discovery.description.placeholder': 'A short description for the Explore page...',
'space.requests.loading': 'Loading...',
'space.requests.empty': 'No pending join requests',
'space.requests.accept': 'Accept',
'space.requests.decline': 'Decline',
'space.requests.failed': 'Action failed',
// Mobile screens
'mobile.messages': 'Messages',
'mobile.noConversations': 'No conversations yet.',
'mobile.loadingChannels': 'Loading channels',
'mobile.noChannels': 'No channels yet.',
'mobile.groupInfo': 'Group Info',
'mobile.info': 'Info',
'mobile.cancelEdit': 'Cancel edit',
'mobile.removeGroupIcon': 'Remove group icon',
'mobile.federatedGroup': 'Federated group',
'mobile.invitePeople': 'Invite People',
'mobile.inviteCopied': 'Invite link copied to clipboard',
'mobile.inviteFailed': 'Failed to generate invite',
'mobile.createFolder': 'Create Folder',
'mobile.folderCreated': 'Folder created',
'mobile.moveToFolder': 'Move to Folder',
'mobile.movedToFolder': 'Moved to folder',
'mobile.removeFromFolder': 'Remove from Folder',
'mobile.removedFromFolder': 'Removed from folder',
'mobile.folderUngrouped': 'Folder ungrouped',
'mobile.unnamedFolder': 'Unnamed Folder',
'mobile.leaveSpace': 'Leave Space',
'mobile.leave': 'Leave',
'mobile.leftSpace': 'Left space',
'mobile.leaveSpaceFailed': 'Failed to leave space',
'mobile.muteUser': 'Mute User',
'mobile.channelSettings': 'Channel Settings',
'mobile.deleteChannel': 'Delete Channel',
'mobile.channelDeleted': 'Channel deleted',
'mobile.categorySettings': 'Category Settings',
'mobile.deleteCategory': 'Delete Category',
'mobile.categoryDeleted': 'Category deleted',
'mobile.delete': 'Delete',
'mobile.createSpace': 'Create Space',
'mobile.joinSpace': 'Join Space',
'mobile.exploreSpaces': 'Explore Spaces',
'mobile.leaveSpace.confirm': "Are you sure you want to leave this space? You'll need a new invite to rejoin.",
'mobile.deleteChannel.confirm': 'Are you sure? This will permanently delete the channel and all its messages.',
'mobile.deleteCategory.confirm': 'Are you sure? Channels in this category will be moved to uncategorized.',
'mobile.deleteChannelFailed': 'Failed to delete channel',
'mobile.voice.title': 'Voice Call',
'mobile.voice.collapse': 'Collapse call',
'mobile.voice.members': 'View members',
'mobile.voice.unmute': 'Unmute',
'mobile.voice.deafen': 'Deafen',
'mobile.voice.undeafen': 'Undeafen',
'mobile.voice.cameraOn': 'Turn camera on',
'mobile.voice.cameraOff': 'Turn camera off',
'mobile.voice.switchCamera': 'Switch camera',
'mobile.voice.selectCamera': 'Select camera',
'mobile.voice.shareScreen': 'Share screen',
'mobile.voice.stopSharing': 'Stop sharing screen',
'mobile.voice.disconnect': 'Disconnect from call',
} as const;
export type TranslationKey = keyof typeof en;
+215
View File
@@ -803,4 +803,219 @@ export const ptBR: Partial<Dictionary> = {
'instance.general.reset': 'Desfazer',
'instance.general.saved': 'Configurações salvas',
'instance.general.saveFailed': 'Não foi possível salvar',
// Mensagens de sistema — linha na conversa e prévia na barra lateral
'system.unknown': 'Desconhecido',
'system.someone': 'Alguém',
'system.aMember': 'Um membro',
'system.memberAdded': '{actor} adicionou {target} ao grupo',
'system.memberLeft': '{target} saiu do grupo',
'system.memberRemoved': '{actor} removeu {target} do grupo',
'system.ownerChanged': '{owner} agora é dono do grupo',
'system.nameChanged': '{actor} mudou o nome do grupo para "{name}"',
'system.nameCleared': '{actor} tirou o nome do grupo',
'system.iconChanged': '{actor} trocou o ícone do grupo',
'system.preview.inviteTo': 'Convite enviado para {space}',
'system.preview.invite': 'Convite de servidor enviado',
'system.preview.memberAdded': '{actor} adicionou {target}',
'system.preview.memberRemoved': '{actor} removeu {target}',
'system.preview.nameChanged': '{actor} mudou o nome do grupo',
'system.preview.generic': 'Mensagem do sistema',
// Login, registro e a página de convite
'auth.username': 'Nome de usuário',
'auth.password': 'Senha',
'auth.username.required': 'O nome de usuário é obrigatório',
'auth.password.required': 'A senha é obrigatória',
'auth.login.title': 'Que bom te ver!',
'auth.login.subtitle': 'A gente estava com saudade!',
'auth.login.submit': 'Entrar',
'auth.login.loggingIn': 'Entrando...',
'auth.login.failed': 'Não foi possível entrar',
'auth.login.rateLimited': 'Tentativas de login demais',
'auth.retryIn': 'Tente de novo em {seconds}s',
'auth.needAccount': 'Ainda não tem conta?',
'auth.register': 'Criar conta',
'auth.haveAccount': 'Já tem conta?',
'auth.register.title': 'Criar uma conta',
'auth.register.confirmPassword': 'Confirmar a senha',
'auth.register.username.length': 'O nome de usuário precisa ter de 3 a 32 caracteres',
'auth.register.username.charset': 'O nome de usuário só aceita letras minúsculas, números e sublinhado',
'auth.register.username.checking': 'Verificando se está livre...',
'auth.register.username.available': 'Nome de usuário disponível',
'auth.register.username.taken': 'Este nome de usuário já está em uso',
'auth.register.password.length': 'A senha precisa ter pelo menos 6 caracteres',
'auth.register.password.mismatch': 'As senhas não batem',
'auth.register.failed': 'Não foi possível criar a conta',
'auth.register.rateLimited': 'Tentativas demais',
'auth.register.continue': 'Continuar',
'auth.register.invite': 'Código ou link de convite',
'auth.register.invite.checking': 'Verificando...',
'auth.register.invite.validating': 'Validando o convite...',
'auth.register.invite.expired': 'Este link de convite expirou. Peça um novo ao administrador.',
'auth.register.invite.exhausted': 'Este convite atingiu o limite de usos. Peça ao administrador para aumentar.',
'auth.register.invite.invalid': 'Código de convite inválido.',
'auth.register.invite.invalidLink': 'Link de convite inválido — peça um novo',
'auth.register.profile.title': 'Deixe com a sua cara',
'auth.register.profile.subtitle': 'Personalize o perfil, ou faça isso depois',
'auth.register.displayName': 'Nome de exibição',
'auth.register.displayName.placeholder': 'Nome de exibição',
'auth.register.avatarColor': 'Cor da foto',
'auth.register.submit': 'Começar',
'auth.register.creating': 'Criando a conta...',
'auth.register.back': 'Voltar',
'auth.register.skip': 'Deixar para depois',
'auth.register.cropAvatar': 'Recortar a foto',
'join.page.loading': 'Carregando o convite...',
'join.page.invalid.title': 'Convite inválido',
'join.page.invalid.body': 'Este link de convite é inválido ou expirou.',
'join.page.invitedTo': 'Você foi convidado para',
'join.page.noCode': 'Nenhum código de convite informado',
'join.page.invalidCode': 'Código de convite inválido',
'join.page.loadFailed': 'Não foi possível carregar o convite',
'join.page.joinFailed': 'Não foi possível entrar no servidor',
'join.page.connectFailed': 'Não foi possível conectar',
'join.page.loginFailed': 'Não foi possível entrar',
'join.page.instance.placeholder': 'ex.: minha-instancia.com',
'join.page.alreadyMember': 'Você já está em {space}!',
'join.page.goTo': 'Ir para {space}',
'join.page.redirecting': 'Redirecionando...',
// Voz — controles, informações de conexão e a folha de entrada no celular
'voice.title': 'Voz e Vídeo',
'voice.dmCall': 'Chamada de DM',
'voice.channel': 'Canal de voz',
'voice.connected': 'Voz conectada',
'voice.connecting': 'Conectando...',
'voice.failed': 'Falha na conexão',
'voice.goToCall': 'Ir para a call',
'voice.disconnect': 'Desconectar',
'voice.camera.on': 'Ligar a câmera',
'voice.camera.off': 'Desligar a câmera',
'voice.share.start': 'Compartilhar tela',
'voice.share.stop': 'Parar de compartilhar',
'voice.soundboard': 'Soundboard',
'voice.videoQuality': 'Qualidade do vídeo',
'voice.noise.enable': 'Ligar a supressão de ruído por IA',
'voice.noise.disable': 'Desligar a supressão de ruído por IA',
'voice.info.title': 'Informações da conexão',
'voice.info.notConnected': 'Desconectado',
'voice.info.gathering': 'Coletando dados...',
'voice.info.network': 'Rede',
'voice.info.ping': 'Ping',
'voice.info.packetLoss': 'Perda de pacotes',
'voice.info.jitter': 'Jitter',
'voice.info.server': 'Servidor',
'voice.info.protocol': 'Protocolo',
'voice.info.audio': 'Áudio',
'voice.info.video': 'Vídeo',
'voice.info.microphone': 'Microfone',
'voice.info.camera': 'Câmera',
'voice.info.screen': 'Tela',
'voice.info.screenAudio': 'Áudio da tela',
'voice.info.unknown': 'Desconhecido',
'voice.info.remote': 'Remoto',
'voice.join.empty': 'Ninguém neste canal ainda.',
'voice.join.beFirst': 'Seja o primeiro a entrar!',
'voice.join.mute': 'Desligar o microfone',
'voice.join.unmute': 'Ligar o microfone',
'voice.join.switch': 'Trocar de canal',
'voice.join.join': 'Entrar na voz',
'voice.join.close': 'Fechar',
'voice.join.selectedCamera': 'Câmera escolhida',
'voice.join.enablePreview': 'Ligar a prévia da câmera',
'voice.join.checkingCamera': 'Verificando a câmera…',
'voice.join.tapToPreview': 'Toque para ver a câmera',
'voice.join.permissionDenied': 'Permissão de câmera negada.',
'voice.settings.volume': 'Volume',
'voice.settings.sfxVolume': 'Volume dos efeitos sonoros',
'voice.settings.messageSound': 'Tocar som em toda mensagem',
'voice.settings.messageSound.description': 'Desligado (padrão): só DMs e mensagens que mencionam você. Ligado: todos os canais.',
'voice.settings.processing': 'Processamento de voz',
'voice.settings.noise': 'Supressão de ruído por IA',
'voice.settings.noise.description': 'Remoção de ruído por aprendizado de máquina (RNNoise) — filtra teclado, ventilador e barulho de fundo',
'voice.settings.echo': 'Cancelamento de eco',
'voice.settings.echo.description': 'Cancela o eco do seu som voltando pelo microfone. Sempre ligado em canais de voz e chamadas.',
'voice.settings.agc': 'Controle automático de ganho',
'voice.settings.agc.description': 'Ajusta o volume do microfone sozinho — pode abaixar a sua voz durante transmissões',
// Navegação das configurações (servidor, usuário e instância)
'nav.general': 'Geral',
'nav.management': 'Gerenciamento',
'nav.overview': 'Visão geral',
'nav.discovery': 'Descoberta',
'nav.members': 'Membros',
'nav.roles': 'Cargos',
'nav.bans': 'Banimentos',
'nav.userSettings': 'Configurações do usuário',
'nav.appSettings': 'Configurações do app',
'nav.administration': 'Administração',
'nav.instance': 'Instância',
'nav.registration': 'Registro',
'nav.federation': 'Federação',
'nav.streaming': 'Transmissão',
'nav.storage': 'Armazenamento',
'nav.users': 'Usuários',
'nav.back': 'Voltar ao menu de configurações do servidor',
'space.discovery.title': 'Descoberta',
'space.discovery.visibility': 'Visibilidade',
'space.discovery.visibility.description': 'Escolha quem pode descobrir e entrar neste servidor.',
'space.discovery.description': 'Descrição',
'space.discovery.description.hint': 'Um resumo curto que aparece na página do Explorar.',
'space.discovery.description.placeholder': 'Uma descrição curta para o Explorar...',
'space.requests.loading': 'Carregando...',
'space.requests.empty': 'Nenhum pedido de entrada pendente',
'space.requests.accept': 'Aceitar',
'space.requests.decline': 'Recusar',
'space.requests.failed': 'A ação falhou',
// Telas do celular
'mobile.messages': 'Mensagens',
'mobile.noConversations': 'Nenhuma conversa ainda.',
'mobile.loadingChannels': 'Carregando canais',
'mobile.noChannels': 'Nenhum canal ainda.',
'mobile.groupInfo': 'Informações do grupo',
'mobile.info': 'Informações',
'mobile.cancelEdit': 'Cancelar a edição',
'mobile.removeGroupIcon': 'Remover o ícone do grupo',
'mobile.federatedGroup': 'Grupo federado',
'mobile.invitePeople': 'Convidar pessoas',
'mobile.inviteCopied': 'Link de convite copiado',
'mobile.inviteFailed': 'Não foi possível gerar o convite',
'mobile.createFolder': 'Criar pasta',
'mobile.folderCreated': 'Pasta criada',
'mobile.moveToFolder': 'Mover para pasta',
'mobile.movedToFolder': 'Movido para a pasta',
'mobile.removeFromFolder': 'Tirar da pasta',
'mobile.removedFromFolder': 'Tirado da pasta',
'mobile.folderUngrouped': 'Pasta desfeita',
'mobile.unnamedFolder': 'Pasta sem nome',
'mobile.leaveSpace': 'Sair do servidor',
'mobile.leave': 'Sair',
'mobile.leftSpace': 'Você saiu do servidor',
'mobile.leaveSpaceFailed': 'Não foi possível sair do servidor',
'mobile.muteUser': 'Silenciar usuário',
'mobile.channelSettings': 'Configurações do canal',
'mobile.deleteChannel': 'Excluir canal',
'mobile.channelDeleted': 'Canal excluído',
'mobile.categorySettings': 'Configurações da categoria',
'mobile.deleteCategory': 'Excluir categoria',
'mobile.categoryDeleted': 'Categoria excluída',
'mobile.delete': 'Excluir',
'mobile.createSpace': 'Criar servidor',
'mobile.joinSpace': 'Entrar num servidor',
'mobile.exploreSpaces': 'Explorar servidores',
'mobile.leaveSpace.confirm': 'Quer mesmo sair deste servidor? Você vai precisar de um novo convite para voltar.',
'mobile.deleteChannel.confirm': 'Tem certeza? Isto apaga o canal e todas as mensagens dele para sempre.',
'mobile.deleteCategory.confirm': 'Tem certeza? Os canais desta categoria vão para o nível de cima.',
'mobile.deleteChannelFailed': 'Não foi possível excluir o canal',
'mobile.voice.title': 'Chamada de voz',
'mobile.voice.collapse': 'Minimizar a chamada',
'mobile.voice.members': 'Ver membros',
'mobile.voice.unmute': 'Ligar o microfone',
'mobile.voice.deafen': 'Desligar o som',
'mobile.voice.undeafen': 'Ligar o som',
'mobile.voice.cameraOn': 'Ligar a câmera',
'mobile.voice.cameraOff': 'Desligar a câmera',
'mobile.voice.switchCamera': 'Trocar de câmera',
'mobile.voice.selectCamera': 'Escolher a câmera',
'mobile.voice.shareScreen': 'Compartilhar a tela',
'mobile.voice.stopSharing': 'Parar de compartilhar',
'mobile.voice.disconnect': 'Sair da chamada',
};
+26 -15
View File
@@ -1,5 +1,16 @@
import type { DmChannel, DmMessageWithUser, DmLastMessagePreview, User, SpaceInviteSystemPayload } from '@backspace/shared';
import { parseFederatedUsername, isSelf } from './identity';
import { translate, useLocaleStore, type TranslationKey } from '../i18n';
/**
* Plain module, not a component — so the locale is read at call time instead of
* through the hook. Callers render these previews inside components that
* already re-render on the message list changing; a language switch therefore
* shows through on the next update rather than instantly.
*/
function tr(key: TranslationKey, params?: Record<string, string | number>): string {
return translate(useLocaleStore.getState().locale, key, params);
}
// ─── DM Preview Formatting ────────────────────────────────────────────────────
@@ -113,9 +124,9 @@ function asString(v: unknown): string | null {
}
function resolveDisplayName(user: User | null | undefined): string {
if (!user) return 'Unknown';
if (!user) return tr('system.unknown');
if (user.displayName) return user.displayName;
return parseFederatedUsername(user.username ?? '').baseName || 'Unknown';
return parseFederatedUsername(user.username ?? '').baseName || tr('system.unknown');
}
/**
@@ -135,35 +146,35 @@ function formatSystemPreview(content: string | null, actor: User | null | undefi
case 'space_invite': {
const spaceName = asString((data as Partial<SpaceInviteSystemPayload>).snapshot?.spaceName);
return spaceName
? `📨 Sent invite to ${spaceName}`
: '📨 Sent a space invite';
? `📨 ${tr('system.preview.inviteTo', { space: spaceName })}`
: `📨 ${tr('system.preview.invite')}`;
}
case 'member_added': {
const target = asString(data.targetDisplayName) ?? 'someone';
return `${actorName} added ${target}`;
const target = asString(data.targetDisplayName) ?? tr('system.someone');
return tr('system.preview.memberAdded', { actor: actorName, target });
}
case 'member_removed': {
const target = asString(data.targetDisplayName) ?? 'someone';
const target = asString(data.targetDisplayName) ?? tr('system.someone');
const reason = asString(data.reason);
if (reason === 'leave') return `${target} left the group`;
return `${actorName} removed ${target}`;
if (reason === 'leave') return tr('system.memberLeft', { target });
return tr('system.preview.memberRemoved', { actor: actorName, target });
}
case 'owner_changed': {
const newOwner = asString(data.newOwnerDisplayName) ?? 'A member';
return `${newOwner} is now the group owner`;
const newOwner = asString(data.newOwnerDisplayName) ?? tr('system.aMember');
return tr('system.ownerChanged', { owner: newOwner });
}
case 'name_changed': {
// newName === null is a meaningful "cleared" state — distinct from a
// missing field — so we check for a non-empty string explicitly.
return asString(data.newName)
? `${actorName} renamed the group`
: `${actorName} cleared the group name`;
? tr('system.preview.nameChanged', { actor: actorName })
: tr('system.nameCleared', { actor: actorName });
}
case 'icon_changed': {
return `${actorName} updated the group icon`;
return tr('system.iconChanged', { actor: actorName });
}
default:
return 'System message';
return tr('system.preview.generic');
}
}