From 688a1335cb532b9f013bfa3c99fd9aff957cac6f Mon Sep 17 00:00:00 2001 From: devsyncwrld Date: Mon, 31 Aug 2026 12:24:50 -0300 Subject: [PATCH] feat(i18n): language foundation with en and pt-BR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing in the project was translatable — every string sat inline in English. en.ts is the source dictionary and its type is derived from it, so a typo or a missing key fails typecheck instead of rendering the raw key at runtime. pt-BR.ts is deliberately Partial: translation proceeds one system per update and anything absent falls back to English, so a half-migrated interface is never broken, only partly English. Locale is persisted, guessed from the browser on first run, and kept in sync with through a subscription — persisted state rehydrates after first paint, so a one-off assignment would miss it. Translates the voice input panel (including the mic test shipped earlier today) and the profile card as this round's system. Language options are labelled in the active language, so a wrong pick can always be undone. --- .../components/modals/UserProfileModal.tsx | 8 +- .../src/components/modals/UserSettings.tsx | 30 ++++--- .../settingsPanels/AudioInputSection.tsx | 22 +++--- .../modals/settingsPanels/LanguagePanel.tsx | 48 ++++++++++++ .../web/src/components/ui/ProfileActivity.tsx | 17 ++-- packages/web/src/i18n/i18n.test.ts | 36 +++++++++ packages/web/src/i18n/index.ts | 78 +++++++++++++++++++ packages/web/src/i18n/locales/en.ts | 48 ++++++++++++ packages/web/src/i18n/locales/pt-BR.ts | 44 +++++++++++ 9 files changed, 298 insertions(+), 33 deletions(-) create mode 100644 packages/web/src/components/modals/settingsPanels/LanguagePanel.tsx create mode 100644 packages/web/src/i18n/i18n.test.ts create mode 100644 packages/web/src/i18n/index.ts create mode 100644 packages/web/src/i18n/locales/en.ts create mode 100644 packages/web/src/i18n/locales/pt-BR.ts diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 1d75f413..bc0c03fb 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { ProfileActivity } from '../ui/ProfileActivity'; +import { useT } from '../../i18n'; import { useActivityStore } from '../../stores/activityStore'; import { Username } from '../ui/Username'; import { useUIStore } from '../../stores/uiStore'; @@ -66,6 +67,7 @@ export function UserProfileModal() { const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest); const currentUser = useAuthStore((s) => s.user); + const t = useT(); const [user, setUser] = useState(null); const [userOrigin, setUserOrigin] = useState(''); const [activeTab, setActiveTab] = useState('about'); @@ -352,7 +354,7 @@ export function UserProfileModal() { {user.bio && (
- About Me + {t('profile.aboutMe')}
- Member Since + {t('profile.memberSince')}
{new Date(user.createdAt).toLocaleDateString(undefined, { @@ -519,7 +521,7 @@ export function UserProfileModal() { onClick={handleSendMessage} className="flex-1 py-2 rounded-lg text-[13px] font-medium text-white bg-accent-primary hover:bg-accent-primary/80 transition-colors" > - Send Message + {t('profile.sendMessage')} {friendship.state === 'none' && ( diff --git a/packages/web/src/components/modals/UserSettings.tsx b/packages/web/src/components/modals/UserSettings.tsx index bf01f916..b896a155 100644 --- a/packages/web/src/components/modals/UserSettings.tsx +++ b/packages/web/src/components/modals/UserSettings.tsx @@ -9,6 +9,8 @@ import { useAuthStore } from '../../stores/authStore'; import { AccountPanel } from './settingsPanels/AccountPanel'; import { VoicePanel } from './settingsPanels/VoicePanel'; import { PrivacyPanel } from './settingsPanels/PrivacyPanel'; +import { LanguagePanel } from './settingsPanels/LanguagePanel'; +import { useT } from '../../i18n'; import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel'; import { DesktopPanel } from './settingsPanels/DesktopPanel'; import { InstancePanel } from './settingsPanels/InstancePanel'; @@ -16,7 +18,7 @@ import { KeybindsPanel } from './settingsPanels/KeybindsPanel'; import { isElectron } from '../../platform/platform'; import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext'; -type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'desktop' | 'instance'; +type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'language' | 'desktop' | 'instance'; function SidebarSubLinks() { const ctx = useSettingsSectionsContext(); @@ -60,6 +62,7 @@ export function UserSettingsModal() { const user = useAuthStore((s) => s.user); const logout = useAuthStore((s) => s.logout); + const t = useT(); const [tab, setTab] = useState('account'); const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs'); // AGPL § 13: home-instance source offer. Fetched from the public info endpoint @@ -81,7 +84,7 @@ export function UserSettingsModal() { useEffect(() => { if (isOpen) { const requested = modalData.tab as SettingsTab | undefined; - if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'instance'].includes(requested)) { + if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'language', 'instance'].includes(requested)) { // Only allow instance tab for admins if (requested === 'instance' && !isAdmin) { setTab('account'); @@ -135,14 +138,15 @@ export function UserSettingsModal() { {/* Nav list */}
User Settings
- - - + + +
App Settings
- - + + + {isElectron() && } {isAdmin && ( @@ -192,14 +196,15 @@ export function UserSettingsModal() {
User Settings
- - - + + +
App Settings
- - + + + {isElectron() && } {isAdmin && ( @@ -249,6 +254,7 @@ export function UserSettingsModal() { {tab === 'privacy' && } {tab === 'connections' && } {tab === 'keybinds' && } + {tab === 'language' && } {tab === 'desktop' && } {tab === 'instance' && isAdmin && }
diff --git a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx index 7c6e106f..8c64d512 100644 --- a/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx +++ b/packages/web/src/components/modals/settingsPanels/AudioInputSection.tsx @@ -3,12 +3,14 @@ import { useVoiceStore } from '../../../stores/voiceStore'; import { AudioManager } from '../../../audio/AudioManager'; import { useAudioDevices } from '../../../hooks/useAudioDevices'; import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives'; +import { useT } from '../../../i18n'; export function AudioInputSection() { const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const setInputDevice = useVoiceStore((s) => s.setInputDevice); const inputVolume = useVoiceStore((s) => s.inputVolume); const setInputVolume = useVoiceStore((s) => s.setInputVolume); + const t = useT(); const { permState, inputs, inputLabels, requestPermission } = useAudioDevices(); const [listOpen, setListOpen] = useState(false); @@ -96,7 +98,7 @@ export function AudioInputSection() { setMicTestError(''); const ok = await am.startMicTest(); if (!ok) { - setMicTestError('Could not open the microphone. Check the device and its permission.'); + setMicTestError(t('settings.voice.micTest.failed')); return; } setMicTesting(true); @@ -125,7 +127,7 @@ export function AudioInputSection() { if (permState === 'unknown') { return ( - +
Checking microphone access…
); @@ -133,7 +135,7 @@ export function AudioInputSection() { if (permState === 'denied') { return ( - +
⚠ Microphone access denied
@@ -152,7 +154,7 @@ export function AudioInputSection() { if (permState === 'prompt') { return ( - +
Microphone permission needed to list and choose an input device. @@ -186,7 +188,7 @@ export function AudioInputSection() { const activeBars = Math.round(micLevel * micBars * (inputVolume / 100)); return ( - +
{micTesting - ? 'Playing your mic back to you — say something.' + ? t('settings.voice.micTest.playing') : isLiveKitConnected - ? 'The level meter is live while you are in a call.' - : 'Test your mic without joining a call.'} + ? t('settings.voice.micTest.inCall') + : t('settings.voice.micTest.idle')}
{micTestError && ( diff --git a/packages/web/src/components/modals/settingsPanels/LanguagePanel.tsx b/packages/web/src/components/modals/settingsPanels/LanguagePanel.tsx new file mode 100644 index 00000000..4b15b41b --- /dev/null +++ b/packages/web/src/components/modals/settingsPanels/LanguagePanel.tsx @@ -0,0 +1,48 @@ +import { SectionShell } from './_shared/SettingsPickerPrimitives'; +import { useLocaleStore, useT, LOCALES, type Locale } from '../../../i18n'; +import type { TranslationKey } from '../../../i18n'; + +const LOCALE_LABEL: Record = { + en: 'settings.language.en', + 'pt-BR': 'settings.language.ptBR', +}; + +/** + * Language picker. Each option is labelled in the active language rather than + * in its own — a reader who cannot find their way back out of a language they + * picked by mistake is the one failure this screen must not have. + */ +export function LanguagePanel() { + const t = useT(); + const locale = useLocaleStore((s) => s.locale); + const setLocale = useLocaleStore((s) => s.setLocale); + + return ( + +

+ {t('settings.language.description')} +

+
+ {LOCALES.map((option) => ( + + ))} +
+
+ ); +} diff --git a/packages/web/src/components/ui/ProfileActivity.tsx b/packages/web/src/components/ui/ProfileActivity.tsx index 0fac3121..1af571a5 100644 --- a/packages/web/src/components/ui/ProfileActivity.tsx +++ b/packages/web/src/components/ui/ProfileActivity.tsx @@ -1,17 +1,17 @@ import { useEffect, useState } from 'react'; import type { Activity } from '@backspace/shared'; import { getPrimaryActivity } from '@backspace/shared/src/activities.js'; +import { useT, type TranslationKey } from '../../i18n'; interface ProfileActivityProps { activities: Activity[]; } -const VERB: Record = { - playing: 'Playing', - listening: 'Listening to', - watching: 'Watching', - streaming: 'Streaming', - custom: '', +const VERB_KEY: Record, TranslationKey> = { + playing: 'profile.activity.playing', + listening: 'profile.activity.listening', + watching: 'profile.activity.watching', + streaming: 'profile.activity.streaming', }; function formatClock(ms: number): string { @@ -34,6 +34,7 @@ function formatClock(ms: number): string { * producer would fill in. */ export function ProfileActivity({ activities }: ProfileActivityProps) { + const t = useT(); const primary = getPrimaryActivity(activities); const start = primary?.timestamps?.start; const end = primary?.timestamps?.end; @@ -60,7 +61,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) { return (
- {VERB[primary.type]} {primary.name} + {t(VERB_KEY[primary.type])} {primary.name}
{artSrc && ( @@ -96,7 +97,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
) : start ? (
- {formatClock(elapsed)} elapsed + {t('profile.activity.elapsed', { time: formatClock(elapsed) })}
) : null}
diff --git a/packages/web/src/i18n/i18n.test.ts b/packages/web/src/i18n/i18n.test.ts new file mode 100644 index 00000000..f02bcbec --- /dev/null +++ b/packages/web/src/i18n/i18n.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from 'vitest'; +import { translate } from './index'; +import { en } from './locales/en'; +import { ptBR } from './locales/pt-BR'; + +describe('translate', () => { + it('returns the translation for the active locale', () => { + expect(translate('pt-BR', 'settings.tab.account')).toBe('Conta'); + }); + + it('falls back to English for a key the locale has not translated yet', () => { + // The whole migration strategy depends on this: pt-BR is deliberately + // partial, and an untranslated screen must read in English rather than + // break. + const untranslated = (Object.keys(en) as (keyof typeof en)[]).find((k) => !(k in ptBR)); + if (!untranslated) return; // pt-BR fully caught up — nothing to assert + expect(translate('pt-BR', untranslated)).toBe(en[untranslated]); + }); + + it('substitutes named parameters', () => { + expect(translate('en', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 elapsed'); + expect(translate('pt-BR', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 decorrido'); + }); + + it('leaves a placeholder alone when no value is supplied', () => { + expect(translate('en', 'profile.activity.elapsed')).toBe('{time} elapsed'); + }); + + it('keeps every pt-BR key present in the source dictionary', () => { + // Guards against a key being renamed in en.ts while pt-BR keeps the old + // one, which would silently fall back forever. + for (const key of Object.keys(ptBR)) { + expect(en).toHaveProperty(key); + } + }); +}); diff --git a/packages/web/src/i18n/index.ts b/packages/web/src/i18n/index.ts new file mode 100644 index 00000000..f10ce960 --- /dev/null +++ b/packages/web/src/i18n/index.ts @@ -0,0 +1,78 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { en, type TranslationKey } from './locales/en'; +import { ptBR } from './locales/pt-BR'; + +export const LOCALES = ['en', 'pt-BR'] as const; +export type Locale = (typeof LOCALES)[number]; + +const DICTIONARIES: Record>> = { + en, + 'pt-BR': ptBR, +}; + +/** + * First-run guess from the browser. Persisted afterwards, so an explicit + * choice always wins over the browser's setting on later visits. + */ +function detectLocale(): Locale { + if (typeof navigator === 'undefined') return 'en'; + return navigator.language?.toLowerCase().startsWith('pt') ? 'pt-BR' : 'en'; +} + +interface LocaleState { + locale: Locale; + setLocale: (locale: Locale) => void; +} + +export const useLocaleStore = create()( + persist( + (set) => ({ + locale: detectLocale(), + setLocale: (locale) => set({ locale }), + }), + { name: 'backspace-locale' }, + ), +); + +// Keep in sync: screen readers, spellcheck and hyphenation all read +// it, and persisted state rehydrates after the first paint — hence the +// subscription rather than a one-off assignment. +if (typeof document !== 'undefined') { + document.documentElement.lang = useLocaleStore.getState().locale; + useLocaleStore.subscribe((state) => { + document.documentElement.lang = state.locale; + }); +} + +/** + * Resolves a key, substituting `{name}` placeholders. + * + * Falls back to English, then to the key itself. The key is a deliberate last + * resort: it is ugly on screen, which makes a missing entry obvious in review + * instead of silently rendering an empty string. + */ +export function translate( + locale: Locale, + key: TranslationKey, + params?: Record, +): string { + const template = DICTIONARIES[locale]?.[key] ?? en[key] ?? key; + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (match, name: string) => + name in params ? String(params[name]) : match, + ); +} + +/** + * Subscribes the calling component to the active locale, so switching language + * re-renders it. Components that only need the string once (outside React) can + * call `translate` with `useLocaleStore.getState().locale` instead. + */ +export function useT() { + const locale = useLocaleStore((s) => s.locale); + return (key: TranslationKey, params?: Record) => + translate(locale, key, params); +} + +export type { TranslationKey }; diff --git a/packages/web/src/i18n/locales/en.ts b/packages/web/src/i18n/locales/en.ts new file mode 100644 index 00000000..8e73ea43 --- /dev/null +++ b/packages/web/src/i18n/locales/en.ts @@ -0,0 +1,48 @@ +/** + * Source dictionary. Every key the app can translate is declared here, and its + * type is derived from this object — a typo or a missing key fails typecheck + * rather than silently rendering the raw key at runtime. + * + * Keys are flat and dot-namespaced by system (`settings.voice.*`), so a + * translation pass can take one system at a time. + */ +export const en = { + // Settings — navigation + 'settings.tab.account': 'Account', + 'settings.tab.voice': 'Voice & Video', + 'settings.tab.privacy': 'Privacy', + 'settings.tab.connections': 'Connections', + 'settings.tab.keybinds': 'Keybinds', + 'settings.tab.desktop': 'Desktop', + 'settings.tab.instance': 'Instance', + 'settings.tab.language': 'Language', + + // Settings — language + 'settings.language.title': 'Language', + 'settings.language.description': 'Choose the language for the interface. Anything not yet translated stays in English.', + 'settings.language.en': 'English', + 'settings.language.ptBR': 'Portuguese (Brazil)', + + // Settings — voice: input + 'settings.voice.input.title': 'Input Device', + 'settings.voice.input.volume': 'Input Volume', + 'settings.voice.micTest.start': "Let's Check", + 'settings.voice.micTest.stop': 'Stop Testing', + 'settings.voice.micTest.playing': 'Playing your mic back to you — say something.', + 'settings.voice.micTest.inCall': 'The level meter is live while you are in a call.', + 'settings.voice.micTest.idle': 'Test your mic without joining a call.', + 'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.', + + // Profile card + 'profile.aboutMe': 'About Me', + 'profile.memberSince': 'Member Since', + 'profile.sendMessage': 'Send Message', + 'profile.activity.playing': 'Playing', + 'profile.activity.listening': 'Listening to', + 'profile.activity.watching': 'Watching', + 'profile.activity.streaming': 'Streaming', + 'profile.activity.elapsed': '{time} elapsed', +} as const; + +export type TranslationKey = keyof typeof en; +export type Dictionary = Record; diff --git a/packages/web/src/i18n/locales/pt-BR.ts b/packages/web/src/i18n/locales/pt-BR.ts new file mode 100644 index 00000000..80b2b409 --- /dev/null +++ b/packages/web/src/i18n/locales/pt-BR.ts @@ -0,0 +1,44 @@ +import type { Dictionary } from './en'; + +/** + * Partial on purpose. Translation happens one system per update, and anything + * absent here falls back to English — so a half-migrated interface is never + * broken, just partly in English. + */ +export const ptBR: Partial = { + // Configurações — navegação + 'settings.tab.account': 'Conta', + 'settings.tab.voice': 'Voz e Vídeo', + 'settings.tab.privacy': 'Privacidade', + 'settings.tab.connections': 'Conexões', + 'settings.tab.keybinds': 'Atalhos', + 'settings.tab.desktop': 'Desktop', + 'settings.tab.instance': 'Instância', + 'settings.tab.language': 'Idioma', + + // Configurações — idioma + 'settings.language.title': 'Idioma', + 'settings.language.description': 'Escolha o idioma da interface. O que ainda não foi traduzido continua em inglês.', + 'settings.language.en': 'Inglês', + 'settings.language.ptBR': 'Português (Brasil)', + + // Configurações — voz: entrada + 'settings.voice.input.title': 'Dispositivo de entrada', + 'settings.voice.input.volume': 'Volume de entrada', + 'settings.voice.micTest.start': 'Testar microfone', + 'settings.voice.micTest.stop': 'Parar teste', + 'settings.voice.micTest.playing': 'Devolvendo seu microfone para você — fale alguma coisa.', + 'settings.voice.micTest.inCall': 'O medidor fica ativo enquanto você está numa call.', + 'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.', + 'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.', + + // Cartão de perfil + 'profile.aboutMe': 'Sobre mim', + 'profile.memberSince': 'Membro desde', + 'profile.sendMessage': 'Enviar mensagem', + 'profile.activity.playing': 'Jogando', + 'profile.activity.listening': 'Ouvindo', + 'profile.activity.watching': 'Assistindo', + 'profile.activity.streaming': 'Transmitindo', + 'profile.activity.elapsed': '{time} decorrido', +};