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
@@ -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',
};