3 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 c4158ee245 docs: retire the fixes that shipped and were confirmed in use
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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:13:30 -03:00
devsyncwrld 61c68761df i18n: translate the channel and member sidebars
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
Clears the two remaining rounds owed under the fork's per-update translation
rule.

Covers channels, direct messages, the context menus, the inline voice settings
and the member list's loading and grouping labels.

The member grouping needed care: one function returns either a fixed label or
a role's own name, and role names are user data that must never be translated.
It now marks only the fixed cases and the component resolves them at render,
leaving role names untouched. The function is not a component, so it cannot
call the hook itself.
2026-09-01 17:03:48 -03:00
devsyncwrld c04b8b47eb i18n: translate the chat composer and messages
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
Catching up on the fork's rule that every update also translates one existing
system — three updates went by without one.

The composer's placeholder takes the channel as a parameter rather than being
concatenated, since Portuguese puts the channel elsewhere in the sentence than
English does.

The translation hook is bound to `tr` in these two files, not `t`: both already
use `t` for transfer records in inner scopes, which shadowed the function and
made it uncallable exactly where it was needed.
2026-09-01 16:59:50 -03:00
7 changed files with 149 additions and 47 deletions
+7 -5
View File
@@ -21,6 +21,10 @@ código e os commits seguem em inglês, como o resto do repositório.
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron | | Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui | | Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída | | Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações | | Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade | | Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
@@ -72,6 +76,9 @@ fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
- Configurações → Privacidade - Configurações → Privacidade
- Configurações → Conexões (nasceu bilíngue) - Configurações → Conexões (nasceu bilíngue)
- Seletor de GIF - Seletor de GIF
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
- Barra lateral: servidores, canais e menus de contexto
- Lista de membros (grupos por cargo, carregamento)
- Registro de auditoria e Estatísticas (nasceram bilíngues) - Registro de auditoria e Estatísticas (nasceram bilíngues)
### Fila sugerida de tradução ### Fila sugerida de tradução
@@ -83,11 +90,6 @@ modais de convite · configurações restantes · telas de erro
| Problema | Causa provável | Correção | | Problema | Causa provável | Correção |
|---|---|---| |---|---|---|
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | **Implementado (2026-09-01), não testado.** Trocado por `electron-native-screenshare`: captura com isolamento por processo — só a janela compartilhada (include) ou tudo menos este app (exclude). O PCM vai do processo principal ao renderer por IPC e vira faixa publicada no LiveKit. **Exige gerar instalador novo e reinstalar**, e só pode ser confirmado numa máquina Windows |
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
## App desktop — já existia (verificado 2026-08-31) ## App desktop — já existia (verificado 2026-08-31)
+5 -3
View File
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { MessageWithUser, Embed, User } from '@backspace/shared'; import type { MessageWithUser, Embed, User } from '@backspace/shared';
import { MarkdownRenderer } from './MarkdownRenderer'; import { MarkdownRenderer } from './MarkdownRenderer';
@@ -131,6 +132,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const editMessage = useChatStore((s) => s.editMessage); const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage); const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useSpaceStore((s) => s.members); const members = useSpaceStore((s) => s.members);
const tr = useT();
const openUserProfile = useUIStore((s) => s.openUserProfile); const openUserProfile = useUIStore((s) => s.openUserProfile);
const pending = isPendingMessage(message) ? message.__pending : null; const pending = isPendingMessage(message) ? message.__pending : null;
@@ -676,7 +678,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${ className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary' showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
title="Add reaction" title={tr('chat.message.addReaction')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
@@ -687,7 +689,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<button <button
onClick={() => setReplyTo(message)} onClick={() => setReplyTo(message)}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center" className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Reply" title={tr('chat.message.reply')}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" /> <path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
@@ -700,7 +702,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setIsEditing(true); setIsEditing(true);
}} }}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center" className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Edit" title={tr('chat.message.edit')}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
@@ -1,4 +1,5 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useT } from '../../i18n';
import { useChatStore } from '../../stores/chatStore'; import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore'; import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
@@ -94,6 +95,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Feature flags // Feature flags
const tr = useT();
const gifEnabled = useSettingsStore((s) => s.gifEnabled); const gifEnabled = useSettingsStore((s) => s.gifEnabled);
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES // Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
@@ -775,7 +777,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{chatReplyTo && ( {chatReplyTo && (
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]"> <div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate"> <div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
<span className="opacity-60">Replying to</span> <span className="opacity-60">{tr('chat.composer.replyingTo')}</span>
<span className="font-bold"> <span className="font-bold">
{chatReplyTo.user.displayName ?? chatReplyTo.user.username} {chatReplyTo.user.displayName ?? chatReplyTo.user.username}
</span> </span>
@@ -783,7 +785,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button <button
onClick={() => chatSetReplyTo(null)} onClick={() => chatSetReplyTo(null)}
className="text-txt-tertiary hover:text-txt-primary transition-colors" className="text-txt-tertiary hover:text-txt-primary transition-colors"
aria-label="Cancel reply" aria-label={tr('chat.composer.cancelReply')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" /> <path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
@@ -867,7 +869,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button <button
onClick={() => removeStagedTransfer(t.id)} onClick={() => removeStagedTransfer(t.id)}
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10" className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
aria-label="Remove attachment" aria-label={tr('chat.composer.removeAttachment')}
> >
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" /> <path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
@@ -886,8 +888,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file" title={tr('chat.composer.attach')}
aria-label="Attach file" aria-label={tr('chat.composer.attach')}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
@@ -915,14 +917,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={canAttachFiles ? handlePaste : undefined} onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`} placeholder={placeholder ?? tr('chat.composer.placeholder', {
channel: channelName.startsWith('@') ? channelName : `#${channelName}`,
})}
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin" className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1} rows={1}
/> />
{/* Active-upload indicator */} {/* Active-upload indicator */}
{anyActiveOrQueued && ( {anyActiveOrQueued && (
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading"> <div className="p-3 text-txt-tertiary" title={tr('chat.composer.uploadingEllipsis')} aria-label={tr('chat.composer.uploading')}>
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none"> <svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
@@ -934,7 +938,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{failedCount > 0 && ( {failedCount > 0 && (
<span <span
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0" className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
title="Remove or retry the failed attachment to send" title={tr('chat.composer.failedAttachment')}
> >
{failedCount} failed {failedCount} failed
</span> </span>
@@ -956,8 +960,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${ className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary' activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
title="GIF" title={tr('chat.composer.gif')}
aria-label="GIF picker" aria-label={tr('chat.composer.gifPicker')}
> >
{/* Outlined badge, not a filled block: the solid rectangle read as {/* Outlined badge, not a filled block: the solid rectangle read as
a plain square rather than a GIF picker. Letters reuse the a plain square rather than a GIF picker. Letters reuse the
@@ -977,8 +981,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${ className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary' activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
title="Emoji" title={tr('chat.composer.emoji')}
aria-label="Emoji picker" aria-label={tr('chat.composer.emojiPicker')}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
@@ -991,8 +995,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClick={() => void handleSubmit()} onClick={() => void handleSubmit()}
disabled={anyUnshippable} disabled={anyUnshippable}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50" className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message" aria-label={tr('chat.composer.sendMessage')}
title="Send" title={tr('chat.composer.send')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" /> <path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useT } from '../../i18n';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import type { Channel } from '@backspace/shared'; import type { Channel } from '@backspace/shared';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
@@ -26,6 +27,7 @@ import { useAudioDevices } from '../../hooks/useAudioDevices';
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives'; import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
export function ChannelSidebar() { export function ChannelSidebar() {
const tr = useT();
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
@@ -318,7 +320,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'create-channel', key: 'create-channel',
type: 'action', type: 'action',
label: 'Create Channel', label: tr('sidebar.createChannel'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" /> <path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
@@ -330,7 +332,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'create-category', key: 'create-category',
type: 'action', type: 'action',
label: 'Create Category', label: tr('sidebar.createCategory'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /> <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
@@ -343,7 +345,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'invite', key: 'invite',
type: 'action', type: 'action',
label: 'Invite People', label: tr('sidebar.invitePeople'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" /> <path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
@@ -355,7 +357,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'settings', key: 'settings',
type: 'action', type: 'action',
label: 'Space Settings', label: tr('sidebar.spaceSettings'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
@@ -373,7 +375,7 @@ export function ChannelSidebar() {
{ {
key: 'leave-group', key: 'leave-group',
type: 'action', type: 'action',
label: 'Leave Group', label: tr('sidebar.leaveGroup'),
danger: true, danger: true,
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -453,7 +455,7 @@ export function ChannelSidebar() {
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" /> <path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" /> <path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
</svg> </svg>
<span className="font-medium text-[16px]">Friends</span> <span className="font-medium text-[16px]">{tr('sidebar.friends')}</span>
</div> </div>
{/* Placeholder nav items */} {/* Placeholder nav items */}
@@ -463,7 +465,7 @@ export function ChannelSidebar() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg> </svg>
<span className="font-medium text-[16px]">Coming Soon</span> <span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
</div> </div>
<div <div
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50" className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
@@ -471,11 +473,11 @@ export function ChannelSidebar() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" /> <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" />
</svg> </svg>
<span className="font-medium text-[16px]">Coming Soon</span> <span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
</div> </div>
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group"> <div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">Direct Messages</span> <span className="text-[12px] font-bold text-txt-tertiary tracking-wider">{tr('sidebar.directMessages')}</span>
<button <button
onClick={() => openModal('newDm')} onClick={() => openModal('newDm')}
className="text-txt-tertiary hover:text-txt-primary transition-colors" className="text-txt-tertiary hover:text-txt-primary transition-colors"
@@ -584,7 +586,7 @@ export function ChannelSidebar() {
{/* Channels — dynamic category layout */} {/* Channels — dynamic category layout */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}> <div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}>
{showChannelSkeleton ? ( {showChannelSkeleton ? (
<div className="px-2 pt-3" role="status" aria-label="Loading channels"> <div className="px-2 pt-3" role="status" aria-label={tr('sidebar.loadingChannels')}>
{/* Category group 1 */} {/* Category group 1 */}
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" /> <div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
{Array.from({ length: 3 }, (_, i) => ( {Array.from({ length: 3 }, (_, i) => (
@@ -706,7 +708,7 @@ export function ChannelSidebar() {
{ {
key: 'category-settings', key: 'category-settings',
type: 'action', type: 'action',
label: 'Category Settings', label: tr('sidebar.categorySettings'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" />
@@ -717,7 +719,7 @@ export function ChannelSidebar() {
{ {
key: 'delete-category', key: 'delete-category',
type: 'action', type: 'action',
label: 'Delete Category', label: tr('sidebar.deleteCategory'),
danger: true, danger: true,
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -758,7 +760,7 @@ export function ChannelSidebar() {
/> />
))} ))}
{catChannels.length === 0 && ( {catChannels.length === 0 && (
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">No channels</div> <div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">{tr('sidebar.noChannels')}</div>
)} )}
</div> </div>
)} )}
@@ -777,7 +779,7 @@ export function ChannelSidebar() {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" /> <path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg> </svg>
<span className="text-[12px]">Create Channel</span> <span className="text-[12px]">{tr('sidebar.createChannel')}</span>
</button> </button>
)} )}
<button <button
@@ -787,7 +789,7 @@ export function ChannelSidebar() {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" /> <path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg> </svg>
<span className="text-[12px]">Create Category</span> <span className="text-[12px]">{tr('sidebar.createCategory')}</span>
</button> </button>
</div> </div>
)} )}
@@ -845,6 +847,7 @@ function UserAreaPanel({
onDeafenToggle: () => void; onDeafenToggle: () => void;
onSettingsClick: (tab?: string) => void; onSettingsClick: (tab?: string) => void;
}) { }) {
const tr = useT();
const [accountMenuOpen, setAccountMenuOpen] = useState(false); const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
@@ -959,7 +962,7 @@ function UserAreaPanel({
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-txt-primary text-left">Input Device</div> <div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.inputDevice')}</div>
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div> <div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
</div> </div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
@@ -1004,7 +1007,7 @@ function UserAreaPanel({
{/* Input Volume */} {/* Input Volume */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="text-[15px] font-semibold text-txt-primary mb-2">Input Volume</div> <div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.inputVolume')}</div>
<input <input
type="range" type="range"
min={0} min={0}
@@ -1039,7 +1042,7 @@ function UserAreaPanel({
onClick={() => onSettingsClick('voice')} onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span> <span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg> </svg>
@@ -1057,7 +1060,7 @@ function UserAreaPanel({
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-txt-primary text-left">Output Device</div> <div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.outputDevice')}</div>
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div> <div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
</div> </div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
@@ -1102,7 +1105,7 @@ function UserAreaPanel({
{/* Output Volume */} {/* Output Volume */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="text-[15px] font-semibold text-txt-primary mb-2">Output Volume</div> <div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.outputVolume')}</div>
<input <input
type="range" type="range"
min={0} min={0}
@@ -1125,7 +1128,7 @@ function UserAreaPanel({
onClick={() => onSettingsClick('voice')} onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span> <span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg> </svg>
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useT } from '../../i18n';
import type { MemberWithUser, Activity } from '@backspace/shared'; import type { MemberWithUser, Activity } from '@backspace/shared';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
@@ -21,7 +22,7 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
const ownerRole = member.roles?.find(r => r.position > 0); const ownerRole = member.roles?.find(r => r.position > 0);
return { return {
key: '__owner__', key: '__owner__',
label: 'OWNER', label: '__OWNER__',
color: ownerRole?.color ?? 'rgb(var(--accent-rose))', color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
position: Infinity, position: Infinity,
}; };
@@ -40,7 +41,10 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
// No explicit roles — just @everyone // No explicit roles — just @everyone
return { return {
key: '__online__', key: '__online__',
label: 'ONLINE', // Marcado em vez de traduzido aqui: esta funcao nao e um componente e nao
// pode usar o hook. Nomes de cargo passam adiante sem traducao — sao dados
// do usuario, nao interface.
label: '__ONLINE__',
color: undefined, color: undefined,
position: -1, position: -1,
}; };
@@ -111,6 +115,7 @@ export function MemberSidebar() {
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
const memberListOpen = useUIStore((s) => s.memberListOpen); const memberListOpen = useUIStore((s) => s.memberListOpen);
const tr = useT();
const openUserProfile = useUIStore((s) => s.openUserProfile); const openUserProfile = useUIStore((s) => s.openUserProfile);
const userActivities = useActivityStore((s) => s.userActivities); const userActivities = useActivityStore((s) => s.userActivities);
@@ -183,7 +188,7 @@ export function MemberSidebar() {
return ( return (
<div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard"> <div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
{showMemberSkeleton ? ( {showMemberSkeleton ? (
<div className="px-3 pt-4" role="status" aria-label="Loading members"> <div className="px-3 pt-4" role="status" aria-label={tr('sidebar.loadingMembers')}>
{/* Role group 1 */} {/* Role group 1 */}
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} /> <div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
{Array.from({ length: 2 }, (_, i) => ( {Array.from({ length: 2 }, (_, i) => (
@@ -207,7 +212,11 @@ export function MemberSidebar() {
{roleGroups.map(([key, group]) => ( {roleGroups.map(([key, group]) => (
<div key={key} className="mb-4"> <div key={key} className="mb-4">
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1"> <h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
{group.label} — {group.members.length} {group.label === '__ONLINE__'
? tr('sidebar.groupOnline')
: group.label === '__OWNER__'
? tr('sidebar.groupOwner')
: group.label} — {group.members.length}
</h3> </h3>
{group.members.map((m) => renderMember(m))} {group.members.map((m) => renderMember(m))}
</div> </div>
+41
View File
@@ -56,6 +56,47 @@ export const en = {
'accountMenu.copyId': 'Copy User ID', 'accountMenu.copyId': 'Copy User ID',
'accountMenu.copied': 'Copied', 'accountMenu.copied': 'Copied',
// Sidebar — spaces, channels and members
'sidebar.friends': 'Friends',
'sidebar.directMessages': 'Direct Messages',
'sidebar.noChannels': 'No channels',
'sidebar.comingSoon': 'Coming Soon',
'sidebar.loadingChannels': 'Loading channels',
'sidebar.loadingMembers': 'Loading members',
'sidebar.groupOnline': 'ONLINE',
'sidebar.groupOwner': 'OWNER',
'sidebar.createChannel': 'Create Channel',
'sidebar.createCategory': 'Create Category',
'sidebar.categorySettings': 'Category Settings',
'sidebar.deleteCategory': 'Delete Category',
'sidebar.invitePeople': 'Invite People',
'sidebar.spaceSettings': 'Space Settings',
'sidebar.leaveGroup': 'Leave Group',
'sidebar.voiceSettings': 'Voice Settings',
'sidebar.inputDevice': 'Input Device',
'sidebar.inputVolume': 'Input Volume',
'sidebar.outputDevice': 'Output Device',
'sidebar.outputVolume': 'Output Volume',
// Chat — composer and messages
'chat.composer.placeholder': 'Message {channel}',
'chat.composer.attach': 'Attach file',
'chat.composer.emoji': 'Emoji',
'chat.composer.gif': 'GIF',
'chat.composer.gifPicker': 'GIF picker',
'chat.composer.emojiPicker': 'Emoji picker',
'chat.composer.send': 'Send',
'chat.composer.sendMessage': 'Send message',
'chat.composer.uploading': 'Uploading',
'chat.composer.uploadingEllipsis': 'Uploading…',
'chat.composer.removeAttachment': 'Remove attachment',
'chat.composer.failedAttachment': 'Remove or retry the failed attachment to send',
'chat.composer.replyingTo': 'Replying to',
'chat.composer.cancelReply': 'Cancel reply',
'chat.message.addReaction': 'Add reaction',
'chat.message.edit': 'Edit',
'chat.message.reply': 'Reply',
// Statistics // Statistics
'stats.title': 'Statistics', 'stats.title': 'Statistics',
'stats.range.7': 'Last 7 days', 'stats.range.7': 'Last 7 days',
+41
View File
@@ -55,6 +55,47 @@ export const ptBR: Partial<Dictionary> = {
'accountMenu.copyId': 'Copiar ID do usuário', 'accountMenu.copyId': 'Copiar ID do usuário',
'accountMenu.copied': 'Copiado', 'accountMenu.copied': 'Copiado',
// Barra lateral — servidores, canais e membros
'sidebar.friends': 'Amigos',
'sidebar.directMessages': 'Mensagens diretas',
'sidebar.noChannels': 'Nenhum canal',
'sidebar.comingSoon': 'Em breve',
'sidebar.loadingChannels': 'Carregando canais',
'sidebar.loadingMembers': 'Carregando membros',
'sidebar.groupOnline': 'DISPONÍVEIS',
'sidebar.groupOwner': 'DONO',
'sidebar.createChannel': 'Criar canal',
'sidebar.createCategory': 'Criar categoria',
'sidebar.categorySettings': 'Configurações da categoria',
'sidebar.deleteCategory': 'Excluir categoria',
'sidebar.invitePeople': 'Convidar pessoas',
'sidebar.spaceSettings': 'Configurações do servidor',
'sidebar.leaveGroup': 'Sair do grupo',
'sidebar.voiceSettings': 'Configurações de voz',
'sidebar.inputDevice': 'Dispositivo de entrada',
'sidebar.inputVolume': 'Volume de entrada',
'sidebar.outputDevice': 'Dispositivo de saída',
'sidebar.outputVolume': 'Volume de saída',
// Chat — composer e mensagens
'chat.composer.placeholder': 'Conversar em {channel}',
'chat.composer.attach': 'Anexar arquivo',
'chat.composer.emoji': 'Emoji',
'chat.composer.gif': 'GIF',
'chat.composer.gifPicker': 'Seletor de GIF',
'chat.composer.emojiPicker': 'Seletor de emoji',
'chat.composer.send': 'Enviar',
'chat.composer.sendMessage': 'Enviar mensagem',
'chat.composer.uploading': 'Enviando',
'chat.composer.uploadingEllipsis': 'Enviando…',
'chat.composer.removeAttachment': 'Remover anexo',
'chat.composer.failedAttachment': 'Remova ou tente de novo o anexo que falhou para poder enviar',
'chat.composer.replyingTo': 'Respondendo a',
'chat.composer.cancelReply': 'Cancelar resposta',
'chat.message.addReaction': 'Adicionar reação',
'chat.message.edit': 'Editar',
'chat.message.reply': 'Responder',
// Estatísticas // Estatísticas
'stats.title': 'Estatísticas', 'stats.title': 'Estatísticas',
'stats.range.7': 'Últimos 7 dias', 'stats.range.7': 'Últimos 7 dias',