feat(i18n): translate the five systems users actually see
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Ordered by what the group hits, not by string count.
- Chat system messages ("X added Y to the group") and their sidebar
preview twin. These are content, not chrome, so they sat in English in
the middle of conversations.
- Login, register and the invite landing page — the first screen anyone
invited to the instance sees.
- Voice — controls, connection info, the mobile join sheet, settings tab.
- Mobile screens.
- Settings navigation across space, user and instance, plus Discovery and
join requests.
Three call sites build strings outside a component and so read the locale
directly rather than through the hook: the sidebar preview formatter, the
connection-info track labels, and MobileShell's screen map — which holds
plain functions that are *called*, not rendered, where a hook would run
under whichever component invoked the map. Each says so at the definition;
all three re-render often enough that a language switch shows through
promptly.
Adds a test for a mistake made three times in this series: a file with an
exported component plus a private sub-component, where only the exported one
got the hook. tsc catches it, but only after a full build and without naming
the component. The test names it, and also flags the inverse — a hook placed
below an early return, which tsc accepts and React breaks at runtime (this
project shipped that once, as error #310). Verified by mutation: removing a
hook makes it fail and print the component.
Dictionary is at 856 keys, en and pt-BR at exact parity.
Verified: web build (tsc + vite), 492 web tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user