From 699855849d3eb1d1bedd31875471103fceff377e Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Wed, 2 Sep 2026 21:13:54 -0300 Subject: [PATCH] feat(i18n): translate the five systems users actually see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/roadmap-resenha.md | 11 +- packages/web/src/components/JoinPage.tsx | 47 ++-- .../web/src/components/auth/LoginPage.tsx | 30 +-- .../web/src/components/auth/RegisterPage.tsx | 82 +++---- .../web/src/components/chat/SystemMessage.tsx | 31 ++- .../src/components/layout/MobileDmsScreen.tsx | 11 +- .../components/layout/MobileGroupDmInfo.tsx | 2 + .../web/src/components/layout/MobileShell.tsx | 21 +- .../components/layout/MobileSpacesScreen.tsx | 78 +++---- .../layout/MobileVoiceFullScreen.tsx | 22 +- .../src/components/modals/SpaceSettings.tsx | 60 ++--- .../src/components/modals/UserSettings.tsx | 10 +- .../modals/settingsPanels/VoicePanel.tsx | 26 ++- .../voice/ConnectionInfoPopover.tsx | 43 ++-- .../components/voice/MobileVoiceJoinSheet.tsx | 36 +-- .../src/components/voice/VoiceControls.tsx | 22 +- packages/web/src/i18n/hookPlacement.test.ts | 74 ++++++ packages/web/src/i18n/locales/en.ts | 215 ++++++++++++++++++ packages/web/src/i18n/locales/pt-BR.ts | 215 ++++++++++++++++++ packages/web/src/utils/dmFormatters.ts | 41 ++-- 20 files changed, 825 insertions(+), 252 deletions(-) create mode 100644 packages/web/src/i18n/hookPlacement.test.ts diff --git a/docs/roadmap-resenha.md b/docs/roadmap-resenha.md index 916e0421..9c579ac1 100644 --- a/docs/roadmap-resenha.md +++ b/docs/roadmap-resenha.md @@ -110,6 +110,12 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho. - Configurações do servidor → Cargos (incluindo a tabela de permissões) - Configurações da categoria (renomear, privacidade, exceções, excluir) - Configurações da instância → Geral (nome, descoberta, busca de GIFs) +- Mensagens de sistema do chat e a prévia delas na barra lateral +- Login, registro e a página de convite +- Voz: controles, informações de conexão, folha de entrada no celular, aba Voz +- Telas do celular (`Mobile*.tsx`) +- Navegação das configurações (servidor, usuário e instância) + Descoberta e + pedidos de entrada Os rótulos de permissão viraram um conjunto único (`perm.*`), usado pelo painel de cargos e pelos editores de exceção de canal e de categoria — antes havia @@ -118,8 +124,9 @@ duas cópias da mesma lista. ### Fila sugerida de tradução Configurações → Instância: registro, federação, transmissão, armazenamento e -usuários · chrome do `SpaceSettings` e do `UserSettings` (abas) · -`ConnectedInstances` · `UserProfileModal` · telas móveis (`Mobile*.tsx`) +usuários (os cinco painéis grandes, só o dono vê) · `ConnectedInstances` · +`UserProfileModal` · `MainContent` e `ChannelSidebar` · mensagens de erro +vindas de stores e hooks (`instanceStore`, `useLiveKit`, `transferStore`) ## Correções pendentes diff --git a/packages/web/src/components/JoinPage.tsx b/packages/web/src/components/JoinPage.tsx index d8ad1b34..5faa0cdf 100644 --- a/packages/web/src/components/JoinPage.tsx +++ b/packages/web/src/components/JoinPage.tsx @@ -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() { -

Loading invite...

+

{t('join.page.loading')}

); @@ -221,9 +223,9 @@ export function JoinPage() { -

Invalid Invite

+

{t('join.page.invalid.title')}

- {previewError || 'This invite link is invalid or has expired.'} + {previewError || t('join.page.invalid.body')}

{token ? (

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')} @@ -473,24 +475,24 @@ export function JoinPage() {

- + 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" />
- + 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')}
@@ -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
-

You're already in {spaceName}!

-

Redirecting you now...

+

{t('join.page.alreadyMember', { space: spaceName })}

+

{t('join.page.redirecting')}

); diff --git a/packages/web/src/components/auth/LoginPage.tsx b/packages/web/src/components/auth/LoginPage.tsx index 00574413..0d8a9e3c 100644 --- a/packages/web/src/components/auth/LoginPage.tsx +++ b/packages/web/src/components/auth/LoginPage.tsx @@ -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() {
-

Welcome back!

-

We're so excited to see you again!

+

{t('auth.login.title')}

+

{t('auth.login.subtitle')}

{retryAfter > 0 && (
-

Too many login attempts

-

Try again in {retryAfter}s

+

{t('auth.login.rateLimited')}

+

{t('auth.retryIn', { seconds: retryAfter })}

)} @@ -98,7 +100,7 @@ export function LoginPage() {
{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')}

- Need an account?{' '} + {t('auth.needAccount')}{' '} - Register + {t('auth.register')}

diff --git a/packages/web/src/components/auth/RegisterPage.tsx b/packages/web/src/components/auth/RegisterPage.tsx index 997390fe..09b1e83f 100644 --- a/packages/web/src/components/auth/RegisterPage.tsx +++ b/packages/web/src/components/auth/RegisterPage.tsx @@ -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 ? (
-

Create an account

+

{t('auth.register.title')}

@@ -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 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 && ( -
Checking...
+
{t('auth.register.invite.checking')}
)} {!inviteChecking && inviteCheck?.valid === true && (
@@ -459,9 +461,9 @@ export function RegisterPage() { )} {!inviteChecking && inviteCheck?.valid === false && (
- {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')}
)}
@@ -480,7 +482,7 @@ export function RegisterPage() { - Validating invite... + {t('auth.register.invite.validating')} ) : inviteCheck?.valid === true ? ( <> @@ -494,17 +496,17 @@ export function RegisterPage() { - Invalid invite link — please request a new one + {t('auth.register.invite.invalidLink')} ) : ( - <>Validating invite... + <>{t('auth.register.invite.validating')} )}
)}
- Continue + {t('auth.register.continue')} {/* Helper text when invite is required but not yet entered */} @@ -586,9 +588,9 @@ export function RegisterPage() { )}

- Already have an account?{' '} + {t('auth.haveAccount')}{' '} - Log In + {t('auth.login.submit')}

@@ -596,14 +598,14 @@ export function RegisterPage() { ) : (
-

Make it yours

-

Personalize your profile, or skip for now

+

{t('auth.register.profile.title')}

+

{t('auth.register.profile.subtitle')}

{retryAfter > 0 && (
-

Too many attempts

-

Try again in {retryAfter}s

+

{t('auth.register.rateLimited')}

+

{t('auth.retryIn', { seconds: retryAfter })}

)} @@ -652,13 +654,13 @@ export function RegisterPage() { {/* Display Name */}
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 */}
{/* 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')}
@@ -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')}
@@ -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} diff --git a/packages/web/src/components/chat/SystemMessage.tsx b/packages/web/src/components/chat/SystemMessage.tsx index 44ceb63f..4d088949 100644 --- a/packages/web/src/components/chat/SystemMessage.tsx +++ b/packages/web/src/components/chat/SystemMessage.tsx @@ -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 | null; } -function resolveActorName(message: MessageWithUser, dm?: Pick | null): string { +function resolveActorName( + message: MessageWithUser, + dm: Pick | 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 = {}; 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 ?? ''; diff --git a/packages/web/src/components/layout/MobileDmsScreen.tsx b/packages/web/src/components/layout/MobileDmsScreen.tsx index 4006c997..0cc92354 100644 --- a/packages/web/src/components/layout/MobileDmsScreen.tsx +++ b/packages/web/src/components/layout/MobileDmsScreen.tsx @@ -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: , onClick: () => { @@ -251,7 +254,7 @@ export function MobileDmsScreen() {
{/* Header */}
-

Messages

+

{t('mobile.messages')}

diff --git a/packages/web/src/components/layout/MobileGroupDmInfo.tsx b/packages/web/src/components/layout/MobileGroupDmInfo.tsx index 45e48822..e17a5951 100644 --- a/packages/web/src/components/layout/MobileGroupDmInfo.tsx +++ b/packages/web/src/components/layout/MobileGroupDmInfo.tsx @@ -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); diff --git a/packages/web/src/components/layout/MobileShell.tsx b/packages/web/src/components/layout/MobileShell.tsx index 9a2546fb..7e97651c 100644 --- a/packages/web/src/components/layout/MobileShell.tsx +++ b/packages/web/src/components/layout/MobileShell.tsx @@ -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) => React.React 'settings-instance': () => , 'settings-instance-general': () => (
- } /> + } />
), 'settings-instance-registration': () => (
- } /> + } />
), 'settings-instance-federation': () => , 'settings-instance-streaming': () => (
- } /> + } />
), 'settings-instance-storage': () => (
- } /> + } />
), 'settings-instance-users': () => (
- } /> + } />
), diff --git a/packages/web/src/components/layout/MobileSpacesScreen.tsx b/packages/web/src/components/layout/MobileSpacesScreen.tsx index 3be63882..dad3006d 100644 --- a/packages/web/src/components/layout/MobileSpacesScreen.tsx +++ b/packages/web/src/components/layout/MobileSpacesScreen.tsx @@ -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: , 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: , 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: , 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: , 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: , 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: , @@ -462,7 +464,7 @@ export function MobileSpacesScreen() { { key: 'channel-settings', type: 'action', - label: 'Channel Settings', + label: t('mobile.channelSettings'), icon: , 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: , onClick: () => setDeleteChannelId(channelId), @@ -490,7 +492,7 @@ export function MobileSpacesScreen() { { key: 'category-settings', type: 'action', - label: 'Category Settings', + label: t('mobile.categorySettings'), icon: , 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: , 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 */}
{showChannelSkeleton ? ( -
+
{/* 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 && (
-

No channels yet.

+

{t('mobile.noChannels')}

)} @@ -926,7 +928,7 @@ export function MobileSpacesScreen() { - Create Space + {t('mobile.createSpace')}
@@ -955,17 +957,17 @@ export function MobileSpacesScreen() { {leaveConfirmSpaceId && ( { 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 && ( { 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 && ( { 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); }} diff --git a/packages/web/src/components/layout/MobileVoiceFullScreen.tsx b/packages/web/src/components/layout/MobileVoiceFullScreen.tsx index 2352dfd7..faf7e135 100644 --- a/packages/web/src/components/layout/MobileVoiceFullScreen.tsx +++ b/packages/web/src/components/layout/MobileVoiceFullScreen.tsx @@ -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() {