Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c022f2795f | ||
|
|
688a1335cb | ||
|
|
89e13441c8 | ||
|
|
63afd2fc89 | ||
|
|
d80de49768 | ||
|
|
b92a0d837e | ||
|
|
d7da0ff203 | ||
|
|
bfe62d7078 | ||
|
|
37407a5ecd | ||
|
|
cad3867027 | ||
|
|
20526e1bc8 | ||
|
|
c70b0095a9 | ||
|
|
08db5374cb |
@@ -10,3 +10,9 @@
|
||||
# Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace"
|
||||
reverse_proxy backspace:3000
|
||||
}
|
||||
|
||||
# Gitea — servidor git próprio (stack em /opt/gitea). Alcançado pelo nome do
|
||||
# container na rede interna; o Gitea não publica porta nenhuma no host.
|
||||
{$GIT_DOMAIN:git.resenha.website} {
|
||||
reverse_proxy gitea:3000
|
||||
}
|
||||
|
||||
+22
-3
@@ -21,6 +21,15 @@ COPY packages/web/package.json packages/web/
|
||||
# Copy patches (referenced by pnpm-lock.yaml)
|
||||
COPY patches/ patches/
|
||||
|
||||
# better-sqlite3 publishes no prebuilt binary for Node 20 (ABI 115) — its
|
||||
# releases cover ABI 127/137/141/147 only — so prebuild-install falls back to
|
||||
# compiling with node-gyp, which needs python3/make/g++. node:20-slim ships
|
||||
# none of them. Builder stage only: the runtime stage copies the compiled
|
||||
# .node and stays slim.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install dependencies
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
@@ -39,7 +48,9 @@ FROM node:20-slim AS runtime
|
||||
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||
|
||||
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
|
||||
# entrypoint). No C toolchain — better-sqlite3 and sharp load prebuilt binaries.
|
||||
# entrypoint). sharp is N-API (ABI-independent) and loads a prebuilt binary;
|
||||
# better-sqlite3 no longer ships one for Node 20, so it is compiled below with
|
||||
# a toolchain that is purged in the same layer.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
@@ -58,8 +69,16 @@ COPY packages/web/package.json packages/web/
|
||||
# Copy patches (referenced by pnpm-lock.yaml)
|
||||
COPY patches/ patches/
|
||||
|
||||
# Install production dependencies only (tsx is in server dependencies)
|
||||
RUN pnpm install --prod --frozen-lockfile
|
||||
# Install production dependencies only (tsx is in server dependencies).
|
||||
# better-sqlite3 compiles from source here (no Node 20 prebuilt), so the C
|
||||
# toolchain is installed, used and purged inside this single layer — the final
|
||||
# image ships no compiler.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends python3 make g++ && \
|
||||
pnpm install --prod --frozen-lockfile && \
|
||||
apt-get purge -y python3 make g++ && \
|
||||
apt-get autoremove -y && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy shared source (needed at runtime since server imports types directly)
|
||||
COPY packages/shared/ packages/shared/
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Roadmap — fork Resenha
|
||||
|
||||
Plano de features próprias desta instância. Arquivo exclusivo do fork
|
||||
(nome com sufixo para não colidir com arquivos do upstream em merges).
|
||||
|
||||
Escrito em português por ser documento de planejamento do dono do fork; o
|
||||
código e os commits seguem em inglês, como o resto do repositório.
|
||||
|
||||
## Entregue
|
||||
|
||||
| Feature | Commit | Nota |
|
||||
|---|---|---|
|
||||
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
|
||||
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
|
||||
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
|
||||
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
|
||||
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
|
||||
| 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 |
|
||||
|
||||
## Já existia no código (verificado, não construir de novo)
|
||||
|
||||
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||
escalonados em 0/150/300ms.
|
||||
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
|
||||
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
|
||||
- **Preview de perfil ancorado** — `uiStore.openUserProfile(user, anchor, placement)`
|
||||
guarda `userProfilePopout`; popout posicionado no desktop, tela cheia no
|
||||
mobile. Já era aberto por mensagens, menções, avatares, lista de membros,
|
||||
DMs e painel de atividade. O que faltava era só a voz — agora ligado.
|
||||
Continua faltando o bloco de atividade do print do Discord, que é a #8.
|
||||
|
||||
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
|
||||
não implementação.
|
||||
|
||||
## Pendente — pedido original
|
||||
|
||||
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
||||
|
||||
| # | Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|---|
|
||||
| 6 | Favoritar GIFs + categorias | Grande | Precisa de tabela, migração drizzle e API para sincronizar entre dispositivos, como no Discord |
|
||||
| 8 | **Produtor** de atividade do Spotify | Grande | O consumo está pronto (`ProfileActivity` + pipeline completo). Falta algo que *gere* a atividade com faixa e artista — ver abaixo |
|
||||
| 9 | Registro de auditoria | Grande | Schema + ganchos em cada mutação do servidor + interface |
|
||||
|
||||
## Pendente — ideias aprovadas
|
||||
|
||||
| Feature | Tamanho | Observação técnica |
|
||||
|---|---|---|
|
||||
| Soundboard | Média | `AudioManager` já carrega e toca `.ogg` sob demanda; falta upload por espaço, permissão e disparo na sala LiveKit |
|
||||
| Estatísticas do grupo | Grande | Horas em call, quem mais falou, ranking. **Depende do mesmo registro de eventos da auditoria (#9)** |
|
||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
||||
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
|
||||
|
||||
## O que falta para o Spotify (#8)
|
||||
|
||||
O caminho de consumo está inteiro: tipo, store, WebSocket, validação no
|
||||
servidor, relay de presença e agora o bloco no perfil. **Falta um produtor.**
|
||||
|
||||
Três opções, com custos bem diferentes:
|
||||
|
||||
1. **Entrada no dicionário do detector** (`activityDetector.ts` lê um JSON de
|
||||
processos, e `listening` já é um tipo válido). Custo quase zero, mas dá
|
||||
apenas "Listening to Spotify" — sem faixa nem artista — e **só no app
|
||||
Electron**.
|
||||
2. **Ler o título da janela do Spotify** no processo main do Electron. O título
|
||||
é "Artista - Faixa", então preenche `details` e `state`. Ainda só desktop, e
|
||||
sem capa nem duração.
|
||||
3. **Spotify Web API com OAuth.** É a única que cobre quem usa pelo navegador —
|
||||
que é a maioria do grupo — e a única que traz capa e progresso.
|
||||
**Bloqueio:** exige registrar um app no dashboard do Spotify e obter
|
||||
client id/secret. Isso é ação sua; eu não consigo fazer.
|
||||
|
||||
|
||||
## Regra de idioma (a partir de 2026-08-31)
|
||||
|
||||
Funcionalidade nova sai com interface em **pt-BR**, e a cada update um sistema
|
||||
existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e
|
||||
commits seguem em inglês.
|
||||
|
||||
A fundação está pronta (`src/i18n/`). Para traduzir um sistema: adicione as
|
||||
chaves em `locales/en.ts`, traduza em `locales/pt-BR.ts` e troque as strings
|
||||
fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
||||
|
||||
### Sistemas já traduzidos
|
||||
|
||||
- Configurações → navegação e aba Idioma
|
||||
- Configurações → Voz (dispositivo de entrada, volume, teste de microfone)
|
||||
- Cartão de perfil (sobre mim, membro desde, enviar mensagem, atividade)
|
||||
|
||||
### Fila sugerida de tradução
|
||||
|
||||
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
|
||||
modais de convite · configurações restantes · telas de erro
|
||||
|
||||
## Ideias novas — a avaliar
|
||||
|
||||
Ordenadas por relação valor/custo para um servidor de grupo fechado.
|
||||
|
||||
| Sistema | Tamanho | Por que faz sentido aqui |
|
||||
|---|---|---|
|
||||
| **Fechar cadastro + convites** | Pequena | A instância está com `REGISTRATION_OPEN=true`: qualquer um cria conta. O `InviteModal` já existe — é trocar cadastro aberto por convite |
|
||||
| **Backup fora da VPS** | Pequena | Hoje app, banco, uploads, backups e as três cópias do repositório morrem no mesmo evento. Um envio periódico para fora resolve |
|
||||
| **Aniversários e lembretes** | Pequena | Alto retorno afetivo, custo baixo: campo de data + verificação diária + mensagem no canal |
|
||||
| **Perfis por servidor** | Média | Apelido e avatar diferentes por espaço, como no Discord. O modelo já tem membro por espaço |
|
||||
| **Eventos agendados com presença** | Média | "Sexta 21h" com confirmação. Encaixa nas notificações e no PWA já instalado |
|
||||
| **Notificações push de verdade** | Média | O `vite-plugin-pwa` e o service worker já estão lá; falta Web Push (VAPID) e o registro no servidor |
|
||||
| **Níveis e conquistas** | Média | Gamificação por tempo em call e mensagens. **Mesmo registro de eventos da auditoria e das estatísticas** — três features, um mecanismo |
|
||||
| **Clipes de call** | Grande | "Salvar os últimos 30 segundos" depois de alguém falar besteira. O LiveKit tem egress; exige buffer contínuo e armazenamento |
|
||||
| **Fila de música compartilhada** | Grande | Um participante-robô publicando faixa de áudio na sala LiveKit. É o que mais muda o uso de um servidor de amigos, e o mais caro |
|
||||
| **Autenticação em duas etapas** | Média | Só faz sentido depois de decidir o modelo de cadastro |
|
||||
|
||||
## Dependência que vale respeitar
|
||||
|
||||
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
|
||||
eventos append-only no servidor. Construir a auditoria primeiro e as
|
||||
estatísticas como leitura agregada dessa mesma tabela evita escrever dois
|
||||
sistemas de registro paralelos que divergem com o tempo.
|
||||
@@ -473,9 +473,16 @@ function validateActivities(raw: unknown): Activity[] | null {
|
||||
if (obj.assets && typeof obj.assets === 'object') {
|
||||
const aObj = obj.assets as Record<string, unknown>;
|
||||
const assets: ActivityAssets = {};
|
||||
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
|
||||
// Image assets are rendered as <img src> by clients, so they get the same
|
||||
// scheme check `url` above already has. Without it a client could point
|
||||
// them at a host it controls and harvest the IP of everyone who opens
|
||||
// that profile — and data: URIs would smuggle payloads through a field
|
||||
// only length-checked.
|
||||
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
|
||||
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
||||
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
|
||||
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
|
||||
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
||||
if (Object.keys(assets).length > 0) activity.assets = assets;
|
||||
}
|
||||
@@ -485,6 +492,10 @@ function validateActivities(raw: unknown): Activity[] | null {
|
||||
return validated;
|
||||
}
|
||||
|
||||
function isHttpUrl(value: string): boolean {
|
||||
return value.startsWith('https://') || value.startsWith('http://');
|
||||
}
|
||||
|
||||
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
||||
const status = event.status as string;
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ export class AudioManager {
|
||||
private rnnoiseReady = false;
|
||||
private keepAliveOscillator: OscillatorNode | null = null;
|
||||
|
||||
// Mic test (settings → Voice). See startMicTest().
|
||||
private micTestGain: GainNode | null = null;
|
||||
private micTestStream: MediaStream | null = null;
|
||||
|
||||
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
|
||||
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
|
||||
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
|
||||
@@ -567,6 +571,65 @@ export class AudioManager {
|
||||
osc.stop(now + 0.45);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mic test: routes the processed input bus to the speakers so the user hears
|
||||
* themselves, outside of any call.
|
||||
*
|
||||
* Settings deliberately never opened the mic on their own — the level meter
|
||||
* only measures a stream that a call had already established. A mic test
|
||||
* cannot honour that, so this is the one path that opens it, and
|
||||
* `stopMicTest` hands it back rather than leaving the mic indicator lit.
|
||||
*
|
||||
* Returns false when the mic could not be opened (denied, unplugged).
|
||||
*/
|
||||
async startMicTest(): Promise<boolean> {
|
||||
if (this.micTestGain) return true;
|
||||
const ctx = this.ensureContext();
|
||||
await this.resumeContext();
|
||||
|
||||
const hadStream = this.hasActiveStream();
|
||||
if (!hadStream) {
|
||||
const stream = await this.setInputDevice(this.currentInputDeviceId);
|
||||
if (!stream) return false;
|
||||
// Remember the exact stream we opened, so stopMicTest only ever stops
|
||||
// that one — never a stream something else established meanwhile.
|
||||
this.micTestStream = this.currentStream;
|
||||
}
|
||||
|
||||
this.micTestGain = ctx.createGain();
|
||||
this.inputGain!.connect(this.micTestGain);
|
||||
this.micTestGain.connect(this.getMasterOutput());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tears down the loopback.
|
||||
*
|
||||
* @param allowRelease Whether the mic may be handed back. Only the caller
|
||||
* knows whether a call has started since the test began — AudioManager
|
||||
* does not read stores — so releasing needs its consent as well as our own
|
||||
* record that this test is what opened the stream.
|
||||
*/
|
||||
stopMicTest(allowRelease: boolean): void {
|
||||
if (!this.micTestGain) return;
|
||||
try { this.inputGain?.disconnect(this.micTestGain); } catch { /* graph already torn down */ }
|
||||
try { this.micTestGain.disconnect(); } catch { /* already detached */ }
|
||||
this.micTestGain = null;
|
||||
|
||||
if (allowRelease && this.micTestStream && this.currentStream === this.micTestStream) {
|
||||
// Detach listeners before stopping (see `_setInputDeviceImpl`).
|
||||
const tracks = this.currentStream.getTracks();
|
||||
tracks.forEach(t => { t.onended = null; });
|
||||
tracks.forEach(t => t.stop());
|
||||
this.currentStream = null;
|
||||
}
|
||||
this.micTestStream = null;
|
||||
}
|
||||
|
||||
isMicTestActive(): boolean {
|
||||
return this.micTestGain !== null;
|
||||
}
|
||||
|
||||
getContext(): AudioContext | null {
|
||||
return this.ctx;
|
||||
}
|
||||
|
||||
@@ -959,8 +959,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
||||
title="GIF"
|
||||
aria-label="GIF picker"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||
a plain square rather than a GIF picker. Letters reuse the
|
||||
original glyph paths, scaled and centred inside the outline. */}
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="6" width="18" height="12" rx="3" stroke="currentColor" strokeWidth="2" />
|
||||
<g fill="currentColor" transform="translate(-1.77 -4.2) scale(1.35)">
|
||||
<path d="M5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||
</g>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,9 @@ import { useNavigate } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ProfileActivity } from '../ui/ProfileActivity';
|
||||
import { useT } from '../../i18n';
|
||||
import { useActivityStore } from '../../stores/activityStore';
|
||||
import { Username } from '../ui/Username';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||
@@ -64,6 +67,7 @@ export function UserProfileModal() {
|
||||
const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest);
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
|
||||
const t = useT();
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [userOrigin, setUserOrigin] = useState('');
|
||||
const [activeTab, setActiveTab] = useState<Tab>('about');
|
||||
@@ -142,13 +146,28 @@ export function UserProfileModal() {
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [isOpen, closeModal]);
|
||||
|
||||
// Keyed by home id, matching every other activity consumer (ActivityPanel,
|
||||
// MemberSidebar), so federated users resolve to the same record.
|
||||
//
|
||||
// Must sit ABOVE the early return: `user` is null on the first render and
|
||||
// arrives asynchronously, so a hook below it runs on some renders and not
|
||||
// others — React counts hooks per render and aborts the tree (#310).
|
||||
// The `?? []` stays OUTSIDE the selector; building it inside would hand
|
||||
// zustand a fresh array reference every render and spin.
|
||||
const activityList = useActivityStore((s) =>
|
||||
user ? s.userActivities.get(user.homeUserId ?? user.id) : undefined,
|
||||
);
|
||||
|
||||
if (!isOpen || !user) return null;
|
||||
|
||||
const activities = activityList ?? [];
|
||||
|
||||
const { baseName, domain } = parseFederatedUsername(user.username);
|
||||
const displayName = user.displayName ?? baseName;
|
||||
|
||||
// Banner — use correct API client for remote users
|
||||
const profileApi = getApiForOrigin(userOrigin);
|
||||
|
||||
const bannerSrc = user.banner
|
||||
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
|
||||
: null;
|
||||
@@ -335,7 +354,7 @@ export function UserProfileModal() {
|
||||
{user.bio && (
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
About Me
|
||||
{t('profile.aboutMe')}
|
||||
</span>
|
||||
<div className="text-[13px] text-txt-secondary mt-1 whitespace-pre-wrap break-words leading-relaxed [&_strong]:font-semibold [&_strong]:text-txt-primary [&_em]:italic [&_a]:text-accent-primary [&_a]:underline">
|
||||
<ReactMarkdown
|
||||
@@ -353,10 +372,13 @@ export function UserProfileModal() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current activity — the "Listening to Spotify" block */}
|
||||
<ProfileActivity activities={activities} />
|
||||
|
||||
{/* Member Since */}
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
Member Since
|
||||
{t('profile.memberSince')}
|
||||
</span>
|
||||
<div className="text-[13px] text-txt-secondary mt-1">
|
||||
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||
@@ -499,7 +521,7 @@ export function UserProfileModal() {
|
||||
onClick={handleSendMessage}
|
||||
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-white bg-accent-primary hover:bg-accent-primary/80 transition-colors"
|
||||
>
|
||||
Send Message
|
||||
{t('profile.sendMessage')}
|
||||
</button>
|
||||
|
||||
{friendship.state === 'none' && (
|
||||
|
||||
@@ -9,6 +9,8 @@ import { useAuthStore } from '../../stores/authStore';
|
||||
import { AccountPanel } from './settingsPanels/AccountPanel';
|
||||
import { VoicePanel } from './settingsPanels/VoicePanel';
|
||||
import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
|
||||
import { LanguagePanel } from './settingsPanels/LanguagePanel';
|
||||
import { useT } from '../../i18n';
|
||||
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
|
||||
import { DesktopPanel } from './settingsPanels/DesktopPanel';
|
||||
import { InstancePanel } from './settingsPanels/InstancePanel';
|
||||
@@ -16,7 +18,7 @@ import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
|
||||
import { isElectron } from '../../platform/platform';
|
||||
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext';
|
||||
|
||||
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'desktop' | 'instance';
|
||||
type SettingsTab = 'account' | 'voice' | 'privacy' | 'connections' | 'keybinds' | 'language' | 'desktop' | 'instance';
|
||||
|
||||
function SidebarSubLinks() {
|
||||
const ctx = useSettingsSectionsContext();
|
||||
@@ -60,6 +62,7 @@ export function UserSettingsModal() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
const t = useT();
|
||||
const [tab, setTab] = useState<SettingsTab>('account');
|
||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||
// AGPL § 13: home-instance source offer. Fetched from the public info endpoint
|
||||
@@ -81,7 +84,7 @@ export function UserSettingsModal() {
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const requested = modalData.tab as SettingsTab | undefined;
|
||||
if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'instance'].includes(requested)) {
|
||||
if (requested && ['account', 'voice', 'privacy', 'connections', 'keybinds', 'language', 'instance'].includes(requested)) {
|
||||
// Only allow instance tab for admins
|
||||
if (requested === 'instance' && !isAdmin) {
|
||||
setTab('account');
|
||||
@@ -135,14 +138,15 @@ export function UserSettingsModal() {
|
||||
{/* Nav list */}
|
||||
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice & Video</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
|
||||
|
||||
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
|
||||
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button>
|
||||
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
|
||||
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
|
||||
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
|
||||
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
|
||||
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
|
||||
|
||||
{isAdmin && (
|
||||
@@ -192,14 +196,15 @@ export function UserSettingsModal() {
|
||||
|
||||
<div className="glass-bubble rounded-lg p-2 space-y-0.5">
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">User Settings</div>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>Account</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice & Video</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</button>
|
||||
<button onClick={() => handleTabClick('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>{t('settings.tab.privacy')}</button>
|
||||
|
||||
<div className="border-t border-white/[0.04] my-2 mx-2" />
|
||||
<div className="text-[10px] font-semibold text-txt-tertiary uppercase tracking-wider px-3 py-1">App Settings</div>
|
||||
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>Connections</button>
|
||||
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</button>
|
||||
<button onClick={() => handleTabClick('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
|
||||
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>{t('settings.tab.keybinds')}</button>
|
||||
<button onClick={() => handleTabClick('language')} className={tabClass('language')}>{t('settings.tab.language')}</button>
|
||||
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
|
||||
|
||||
{isAdmin && (
|
||||
@@ -249,6 +254,7 @@ export function UserSettingsModal() {
|
||||
{tab === 'privacy' && <PrivacyPanel />}
|
||||
{tab === 'connections' && <ConnectionsPanel />}
|
||||
{tab === 'keybinds' && <KeybindsPanel />}
|
||||
{tab === 'language' && <LanguagePanel />}
|
||||
{tab === 'desktop' && <DesktopPanel />}
|
||||
{tab === 'instance' && isAdmin && <InstancePanel />}
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useInstanceStore } from '../../../stores/instanceStore';
|
||||
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||
import { Avatar } from '../../ui/Avatar';
|
||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||
import { GifPicker } from '../../chat/GifPicker';
|
||||
import { DeleteAccountModal } from '../DeleteAccountModal';
|
||||
import { api } from '../../../api/client';
|
||||
import { useTransferStore } from '../../../stores/transferStore';
|
||||
@@ -13,6 +14,16 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
|
||||
import { AVATAR_COLORS } from '@backspace/shared';
|
||||
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
||||
import type { FederationOpResult } from '../../../utils/federationOps';
|
||||
/**
|
||||
* Banner/avatar previews hold either a `blob:` object URL (local upload) or a
|
||||
* remote `https:` URL (GIF picker). Only the former owns memory that must be
|
||||
* released — calling revokeObjectURL on a remote URL is a silent no-op that
|
||||
* would quietly hide a mistake here.
|
||||
*/
|
||||
function releasePreview(url: string | null): void {
|
||||
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function AccountPanel() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||
@@ -37,6 +48,7 @@ export function AccountPanel() {
|
||||
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||
const [showBannerGif, setShowBannerGif] = useState(false);
|
||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const addToast = useUIStore((s) => s.addToast);
|
||||
@@ -54,7 +66,7 @@ export function AccountPanel() {
|
||||
setCustomHex(user.accentColor ?? '');
|
||||
// Reset upload state
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
setAvatarPreview(null);
|
||||
setAvatarFilename(null);
|
||||
setBannerPreview(null);
|
||||
@@ -202,7 +214,7 @@ export function AccountPanel() {
|
||||
};
|
||||
|
||||
const handleBannerCropComplete = async (blob: Blob) => {
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
const previewUrl = URL.createObjectURL(blob);
|
||||
setBannerPreview(previewUrl);
|
||||
setBannerCropSrc(null);
|
||||
@@ -227,8 +239,21 @@ export function AccountPanel() {
|
||||
setAvatarFilename('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Banners accept absolute URLs end to end: the server's isValidAssetUrl
|
||||
* allows http(s), and the profile render already branches on
|
||||
* `banner.startsWith('http')`. So a picked GIF needs no upload — the remote
|
||||
* URL is stored directly.
|
||||
*/
|
||||
const handleBannerGifSelect = (url: string) => {
|
||||
releasePreview(bannerPreview);
|
||||
setBannerPreview(url);
|
||||
setBannerFilename(url);
|
||||
setShowBannerGif(false);
|
||||
};
|
||||
|
||||
const handleRemoveBanner = () => {
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
setBannerPreview(null);
|
||||
setBannerFilename('');
|
||||
};
|
||||
@@ -300,7 +325,7 @@ export function AccountPanel() {
|
||||
setAvatarColorState(user.avatarColor ?? null);
|
||||
setCustomHex(user.accentColor ?? '');
|
||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
||||
releasePreview(bannerPreview);
|
||||
setAvatarPreview(null);
|
||||
setAvatarFilename(null);
|
||||
setBannerPreview(null);
|
||||
@@ -482,7 +507,7 @@ export function AccountPanel() {
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<div className="relative flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bannerInputRef.current?.click()}
|
||||
@@ -491,6 +516,23 @@ export function AccountPanel() {
|
||||
>
|
||||
Change Banner
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowBannerGif((v) => !v)}
|
||||
disabled={uploadingBanner}
|
||||
className="text-xs text-accent-primary hover:underline"
|
||||
>
|
||||
Choose GIF
|
||||
</button>
|
||||
{showBannerGif && (
|
||||
<>
|
||||
{/* Click-away layer, below the panel but above the page */}
|
||||
<div className="fixed inset-0 z-[290]" onClick={() => setShowBannerGif(false)} />
|
||||
<div className="absolute left-0 top-full mt-2 z-[300] glass rounded-xl overflow-hidden">
|
||||
<GifPicker onGifSelect={handleBannerGifSelect} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -3,12 +3,14 @@ import { useVoiceStore } from '../../../stores/voiceStore';
|
||||
import { AudioManager } from '../../../audio/AudioManager';
|
||||
import { useAudioDevices } from '../../../hooks/useAudioDevices';
|
||||
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
|
||||
import { useT } from '../../../i18n';
|
||||
|
||||
export function AudioInputSection() {
|
||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||
const setInputDevice = useVoiceStore((s) => s.setInputDevice);
|
||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||
const setInputVolume = useVoiceStore((s) => s.setInputVolume);
|
||||
const t = useT();
|
||||
const { permState, inputs, inputLabels, requestPermission } = useAudioDevices();
|
||||
|
||||
const [listOpen, setListOpen] = useState(false);
|
||||
@@ -20,6 +22,8 @@ export function AudioInputSection() {
|
||||
// then join voice and expect the meter / resolved-default hint to come
|
||||
// alive without reopening the panel.
|
||||
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
||||
const [micTesting, setMicTesting] = useState(false);
|
||||
const [micTestError, setMicTestError] = useState('');
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
|
||||
@@ -77,7 +81,39 @@ export function AudioInputSection() {
|
||||
stopped = true;
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
};
|
||||
}, [permState, audioCtxGen]);
|
||||
}, [permState, audioCtxGen, micTesting]);
|
||||
|
||||
// Subscribed (not a one-off getState) so the hint text below tracks the call
|
||||
// state live. The release decision itself reads getState() at the moment of
|
||||
// stopping, which is when it must be accurate.
|
||||
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||
|
||||
const toggleMicTest = async () => {
|
||||
const am = AudioManager.getInstance();
|
||||
if (micTesting) {
|
||||
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||
setMicTesting(false);
|
||||
return;
|
||||
}
|
||||
setMicTestError('');
|
||||
const ok = await am.startMicTest();
|
||||
if (!ok) {
|
||||
setMicTestError(t('settings.voice.micTest.failed'));
|
||||
return;
|
||||
}
|
||||
setMicTesting(true);
|
||||
};
|
||||
|
||||
// Leaving the panel mid-test must not leave the loopback running or the mic
|
||||
// held open.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const am = AudioManager.getInstance();
|
||||
if (am.isMicTestActive()) {
|
||||
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track the resolved upstream deviceId for the "Currently using: X" hint.
|
||||
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
|
||||
@@ -91,7 +127,7 @@ export function AudioInputSection() {
|
||||
|
||||
if (permState === 'unknown') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="text-sm text-txt-tertiary">Checking microphone access…</div>
|
||||
</SectionShell>
|
||||
);
|
||||
@@ -99,7 +135,7 @@ export function AudioInputSection() {
|
||||
|
||||
if (permState === 'denied') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-txt-primary">⚠ Microphone access denied</div>
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
@@ -118,7 +154,7 @@ export function AudioInputSection() {
|
||||
|
||||
if (permState === 'prompt') {
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="space-y-3">
|
||||
<div className="text-xs text-txt-tertiary">
|
||||
Microphone permission needed to list and choose an input device.
|
||||
@@ -152,7 +188,7 @@ export function AudioInputSection() {
|
||||
const activeBars = Math.round(micLevel * micBars * (inputVolume / 100));
|
||||
|
||||
return (
|
||||
<SectionShell title="Input Device">
|
||||
<SectionShell title={t('settings.voice.input.title')}>
|
||||
<div className="space-y-3">
|
||||
<div ref={dropdownRef}>
|
||||
<button
|
||||
@@ -189,7 +225,7 @@ export function AudioInputSection() {
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="text-[13px] font-medium text-txt-primary">Input Volume</div>
|
||||
<div className="text-[13px] font-medium text-txt-primary">{t('settings.voice.input.volume')}</div>
|
||||
<div className="text-xs text-txt-tertiary tabular-nums">{inputVolume}%</div>
|
||||
</div>
|
||||
<input
|
||||
@@ -213,9 +249,30 @@ export function AudioInputSection() {
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-xs text-txt-tertiary mt-1.5">
|
||||
The level meter activates once you join a voice channel.
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleMicTest()}
|
||||
disabled={permState !== 'granted'}
|
||||
className={`px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
micTesting
|
||||
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
|
||||
: 'bg-accent-primary text-white hover:brightness-110'
|
||||
}`}
|
||||
>
|
||||
{micTesting ? t('settings.voice.micTest.stop') : t('settings.voice.micTest.start')}
|
||||
</button>
|
||||
<span className="text-xs text-txt-tertiary">
|
||||
{micTesting
|
||||
? t('settings.voice.micTest.playing')
|
||||
: isLiveKitConnected
|
||||
? t('settings.voice.micTest.inCall')
|
||||
: t('settings.voice.micTest.idle')}
|
||||
</span>
|
||||
</div>
|
||||
{micTestError && (
|
||||
<div className="text-xs text-txt-danger mt-1.5">{micTestError}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</SectionShell>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { SectionShell } from './_shared/SettingsPickerPrimitives';
|
||||
import { useLocaleStore, useT, LOCALES, type Locale } from '../../../i18n';
|
||||
import type { TranslationKey } from '../../../i18n';
|
||||
|
||||
const LOCALE_LABEL: Record<Locale, TranslationKey> = {
|
||||
en: 'settings.language.en',
|
||||
'pt-BR': 'settings.language.ptBR',
|
||||
};
|
||||
|
||||
/**
|
||||
* Language picker. Each option is labelled in the active language rather than
|
||||
* in its own — a reader who cannot find their way back out of a language they
|
||||
* picked by mistake is the one failure this screen must not have.
|
||||
*/
|
||||
export function LanguagePanel() {
|
||||
const t = useT();
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
const setLocale = useLocaleStore((s) => s.setLocale);
|
||||
|
||||
return (
|
||||
<SectionShell title={t('settings.language.title')}>
|
||||
<p className="text-[13px] text-txt-tertiary mb-3">
|
||||
{t('settings.language.description')}
|
||||
</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{LOCALES.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
onClick={() => setLocale(option)}
|
||||
className={`flex items-center justify-between px-3 py-2 rounded-md text-[14px] text-left transition-colors ${
|
||||
option === locale
|
||||
? 'bg-interactive-selected text-txt-primary'
|
||||
: 'text-txt-secondary hover:bg-interactive-hover'
|
||||
}`}
|
||||
>
|
||||
<span>{t(LOCALE_LABEL[option])}</span>
|
||||
{option === locale && (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SectionShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { Activity } from '@backspace/shared';
|
||||
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||
import { useT, type TranslationKey } from '../../i18n';
|
||||
|
||||
interface ProfileActivityProps {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
const VERB_KEY: Record<Exclude<Activity['type'], 'custom'>, TranslationKey> = {
|
||||
playing: 'profile.activity.playing',
|
||||
listening: 'profile.activity.listening',
|
||||
watching: 'profile.activity.watching',
|
||||
streaming: 'profile.activity.streaming',
|
||||
};
|
||||
|
||||
function formatClock(ms: number): string {
|
||||
const total = Math.max(0, Math.floor(ms / 1000));
|
||||
const minutes = Math.floor(total / 60);
|
||||
const seconds = total % 60;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours > 0) return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity block on the profile card — the "Listening to Spotify" panel.
|
||||
*
|
||||
* Deliberately richer than `ActivityCard` (which renders name + elapsed for
|
||||
* compact list rows): here there is room for the artwork, the track and the
|
||||
* artist, so it reads `details`, `state` and `assets` too. Every one of those
|
||||
* is optional and the block degrades to just the name, which is all today's
|
||||
* process-based detector supplies — the extra fields are what a Spotify
|
||||
* producer would fill in.
|
||||
*/
|
||||
export function ProfileActivity({ activities }: ProfileActivityProps) {
|
||||
const t = useT();
|
||||
const primary = getPrimaryActivity(activities);
|
||||
const start = primary?.timestamps?.start;
|
||||
const end = primary?.timestamps?.end;
|
||||
|
||||
// Re-render once a second only while there is a clock to advance.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (!start) return;
|
||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => clearInterval(id);
|
||||
}, [start]);
|
||||
|
||||
if (!primary || primary.type === 'custom') return null;
|
||||
|
||||
const elapsed = start ? now - start : 0;
|
||||
const duration = start && end ? end - start : 0;
|
||||
const progress = duration > 0 ? Math.min(Math.max(elapsed / duration, 0), 1) : 0;
|
||||
|
||||
// The server restricts asset images to http(s); this mirrors that so a
|
||||
// record stored before that check cannot inject another scheme.
|
||||
const art = primary.assets?.largeImage;
|
||||
const artSrc = art && (art.startsWith('https://') || art.startsWith('http://')) ? art : null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||
{t(VERB_KEY[primary.type])} {primary.name}
|
||||
</span>
|
||||
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
|
||||
{artSrc && (
|
||||
<img
|
||||
src={artSrc}
|
||||
alt={primary.assets?.largeText ?? ''}
|
||||
className="w-[60px] h-[60px] rounded object-cover flex-shrink-0"
|
||||
referrerPolicy="no-referrer"
|
||||
loading="lazy"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
{primary.details && (
|
||||
<div className="text-[13px] font-semibold text-txt-primary truncate">
|
||||
{primary.details}
|
||||
</div>
|
||||
)}
|
||||
{primary.state && (
|
||||
<div className="text-[12px] text-txt-secondary truncate">{primary.state}</div>
|
||||
)}
|
||||
{duration > 0 ? (
|
||||
<div className="mt-2">
|
||||
<div className="h-[3px] rounded-full bg-interactive-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-txt-primary rounded-full"
|
||||
style={{ width: `${progress * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-txt-tertiary mt-1 tabular-nums">
|
||||
<span>{formatClock(elapsed)}</span>
|
||||
<span>{formatClock(duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : start ? (
|
||||
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
|
||||
{t('profile.activity.elapsed', { time: formatClock(elapsed) })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -199,6 +199,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
|
||||
<VoiceUserRow
|
||||
userId={member?.user.homeUserId ?? userId}
|
||||
displayName={displayName}
|
||||
user={member?.user}
|
||||
avatar={avatar}
|
||||
avatarColor={avatarColor ?? undefined}
|
||||
isMuted={isMuted}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
||||
import { wsSend } from '../../hooks/useWebSocket';
|
||||
@@ -16,6 +18,10 @@ import { handleCameraAction } from '../../utils/voiceActions';
|
||||
*/
|
||||
export function VoiceControls() {
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
|
||||
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||
const navigate = useNavigate();
|
||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||
@@ -42,7 +48,20 @@ export function VoiceControls() {
|
||||
if (!currentVoiceChannelId && !activeDmCall) return null;
|
||||
|
||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||
const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
|
||||
const channelName =
|
||||
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
|
||||
|
||||
// Jump back to where the call is happening. DM calls live under @me; space
|
||||
// calls under the space captured at join time.
|
||||
const callSpaceId = activeDmCall ? '@me' : currentVoiceSpaceId;
|
||||
const callChannelId = activeDmCall ? activeDmCall.dmChannelId : currentVoiceChannelId;
|
||||
const canGoToCall = Boolean(callChannelId && callSpaceId && !connectionError);
|
||||
|
||||
const handleGoToCall = () => {
|
||||
if (!canGoToCall || !callChannelId) return;
|
||||
setCurrentChannel(callChannelId);
|
||||
navigate(`/channels/${callSpaceId}/${callChannelId}`);
|
||||
};
|
||||
|
||||
const handleScreenShare = async () => {
|
||||
const room = getActiveRoom();
|
||||
@@ -120,9 +139,19 @@ export function VoiceControls() {
|
||||
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
||||
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
||||
</div>
|
||||
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
|
||||
{connectionError ? connectionError : channelName}
|
||||
</div>
|
||||
{canGoToCall ? (
|
||||
<button
|
||||
onClick={handleGoToCall}
|
||||
title="Go to call"
|
||||
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
|
||||
>
|
||||
{channelName}
|
||||
</button>
|
||||
) : (
|
||||
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
|
||||
{connectionError ? connectionError : channelName}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
|
||||
import { useSpaceStore } from '../../stores/spaceStore';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
||||
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
|
||||
import type { UserTile } from '../../hooks/useLiveKit';
|
||||
@@ -34,6 +35,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
const isLocal = participant.isLocal;
|
||||
const avatarUserId = participant.homeUserId ?? participant.userId;
|
||||
const { displayName, avatar, user } = useVoiceParticipantMeta(participant);
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
|
||||
// --- VIDEO & UI ---
|
||||
|
||||
@@ -177,11 +179,25 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
||||
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
{user ? (
|
||||
// The tile's avatar already opens the profile card; the name
|
||||
// sitting next to it did not, which read as the click failing.
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
|
||||
}}
|
||||
className={`font-semibold text-white truncate text-left hover:underline ${large ? 'text-base' : 'text-[13px]'}`}
|
||||
>
|
||||
{displayName}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{isLocal && (
|
||||
<span className="text-[10px] text-white/40 font-medium">
|
||||
(you)
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { User } from '@backspace/shared';
|
||||
import { Avatar } from '../ui/Avatar';
|
||||
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||
import { useUIStore } from '../../stores/uiStore';
|
||||
|
||||
export interface VoiceUserRowProps {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
/**
|
||||
* Full record for the participant, when it has resolved. Without it the row
|
||||
* stays presentational rather than offering a click that opens nothing —
|
||||
* the same contract ProfileAvatar documents.
|
||||
*/
|
||||
user?: User;
|
||||
avatar: string | null;
|
||||
avatarColor?: string;
|
||||
isMuted?: boolean;
|
||||
@@ -22,6 +32,7 @@ export interface VoiceUserRowProps {
|
||||
export function VoiceUserRow({
|
||||
userId,
|
||||
displayName,
|
||||
user,
|
||||
avatar,
|
||||
avatarColor,
|
||||
isMuted,
|
||||
@@ -38,6 +49,15 @@ export function VoiceUserRow({
|
||||
className = '',
|
||||
}: VoiceUserRowProps) {
|
||||
const avatarSize = size === 'compact' ? 20 : 24;
|
||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||
|
||||
const openProfile = (e: ReactMouseEvent) => {
|
||||
if (!user) return;
|
||||
// The row sits inside a channel that has its own click target (join the
|
||||
// call). Opening the profile is the more specific intent.
|
||||
e.stopPropagation();
|
||||
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
|
||||
};
|
||||
|
||||
// Mic icon priority: server muted/deafened/permission muted (amber) > self-muted (danger)
|
||||
const showServerMicIcon = isServerMuted || isServerDeafened || isPermissionMuted;
|
||||
@@ -49,17 +69,38 @@ export function VoiceUserRow({
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-2 ${className}`}>
|
||||
<Avatar
|
||||
src={avatar}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
userId={userId}
|
||||
avatarColor={avatarColor}
|
||||
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
|
||||
/>
|
||||
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">
|
||||
{displayName}
|
||||
</span>
|
||||
{user ? (
|
||||
<ProfileAvatar
|
||||
src={avatar}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
userId={userId}
|
||||
user={user}
|
||||
avatarColor={avatarColor}
|
||||
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
src={avatar}
|
||||
name={displayName}
|
||||
size={avatarSize}
|
||||
userId={userId}
|
||||
avatarColor={avatarColor}
|
||||
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
|
||||
/>
|
||||
)}
|
||||
{user ? (
|
||||
<button
|
||||
onClick={openProfile}
|
||||
className="text-[13px] text-txt-secondary truncate flex-1 min-w-0 text-left hover:text-txt-primary hover:underline"
|
||||
>
|
||||
{displayName}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">
|
||||
{displayName}
|
||||
</span>
|
||||
)}
|
||||
{/* Status badges */}
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{/* Server muted / space deafened / permission muted — amber mic with slash */}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { translate } from './index';
|
||||
import { en } from './locales/en';
|
||||
import { ptBR } from './locales/pt-BR';
|
||||
|
||||
describe('translate', () => {
|
||||
it('returns the translation for the active locale', () => {
|
||||
expect(translate('pt-BR', 'settings.tab.account')).toBe('Conta');
|
||||
});
|
||||
|
||||
it('falls back to English for a key the locale has not translated yet', () => {
|
||||
// The whole migration strategy depends on this: pt-BR is deliberately
|
||||
// partial, and an untranslated screen must read in English rather than
|
||||
// break.
|
||||
const untranslated = (Object.keys(en) as (keyof typeof en)[]).find((k) => !(k in ptBR));
|
||||
if (!untranslated) return; // pt-BR fully caught up — nothing to assert
|
||||
expect(translate('pt-BR', untranslated)).toBe(en[untranslated]);
|
||||
});
|
||||
|
||||
it('substitutes named parameters', () => {
|
||||
expect(translate('en', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 elapsed');
|
||||
expect(translate('pt-BR', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 decorrido');
|
||||
});
|
||||
|
||||
it('leaves a placeholder alone when no value is supplied', () => {
|
||||
expect(translate('en', 'profile.activity.elapsed')).toBe('{time} elapsed');
|
||||
});
|
||||
|
||||
it('keeps every pt-BR key present in the source dictionary', () => {
|
||||
// Guards against a key being renamed in en.ts while pt-BR keeps the old
|
||||
// one, which would silently fall back forever.
|
||||
for (const key of Object.keys(ptBR)) {
|
||||
expect(en).toHaveProperty(key);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { en, type TranslationKey } from './locales/en';
|
||||
import { ptBR } from './locales/pt-BR';
|
||||
|
||||
export const LOCALES = ['en', 'pt-BR'] as const;
|
||||
export type Locale = (typeof LOCALES)[number];
|
||||
|
||||
const DICTIONARIES: Record<Locale, Partial<Record<TranslationKey, string>>> = {
|
||||
en,
|
||||
'pt-BR': ptBR,
|
||||
};
|
||||
|
||||
/**
|
||||
* First-run guess from the browser. Persisted afterwards, so an explicit
|
||||
* choice always wins over the browser's setting on later visits.
|
||||
*/
|
||||
function detectLocale(): Locale {
|
||||
if (typeof navigator === 'undefined') return 'en';
|
||||
return navigator.language?.toLowerCase().startsWith('pt') ? 'pt-BR' : 'en';
|
||||
}
|
||||
|
||||
interface LocaleState {
|
||||
locale: Locale;
|
||||
setLocale: (locale: Locale) => void;
|
||||
}
|
||||
|
||||
export const useLocaleStore = create<LocaleState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
locale: detectLocale(),
|
||||
setLocale: (locale) => set({ locale }),
|
||||
}),
|
||||
{ name: 'backspace-locale' },
|
||||
),
|
||||
);
|
||||
|
||||
// Keep <html lang> in sync: screen readers, spellcheck and hyphenation all read
|
||||
// it, and persisted state rehydrates after the first paint — hence the
|
||||
// subscription rather than a one-off assignment.
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.lang = useLocaleStore.getState().locale;
|
||||
useLocaleStore.subscribe((state) => {
|
||||
document.documentElement.lang = state.locale;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a key, substituting `{name}` placeholders.
|
||||
*
|
||||
* Falls back to English, then to the key itself. The key is a deliberate last
|
||||
* resort: it is ugly on screen, which makes a missing entry obvious in review
|
||||
* instead of silently rendering an empty string.
|
||||
*/
|
||||
export function translate(
|
||||
locale: Locale,
|
||||
key: TranslationKey,
|
||||
params?: Record<string, string | number>,
|
||||
): string {
|
||||
const template = DICTIONARIES[locale]?.[key] ?? en[key] ?? key;
|
||||
if (!params) return template;
|
||||
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||
name in params ? String(params[name]) : match,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes the calling component to the active locale, so switching language
|
||||
* re-renders it. Components that only need the string once (outside React) can
|
||||
* call `translate` with `useLocaleStore.getState().locale` instead.
|
||||
*/
|
||||
export function useT() {
|
||||
const locale = useLocaleStore((s) => s.locale);
|
||||
return (key: TranslationKey, params?: Record<string, string | number>) =>
|
||||
translate(locale, key, params);
|
||||
}
|
||||
|
||||
export type { TranslationKey };
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Source dictionary. Every key the app can translate is declared here, and its
|
||||
* type is derived from this object — a typo or a missing key fails typecheck
|
||||
* rather than silently rendering the raw key at runtime.
|
||||
*
|
||||
* Keys are flat and dot-namespaced by system (`settings.voice.*`), so a
|
||||
* translation pass can take one system at a time.
|
||||
*/
|
||||
export const en = {
|
||||
// Settings — navigation
|
||||
'settings.tab.account': 'Account',
|
||||
'settings.tab.voice': 'Voice & Video',
|
||||
'settings.tab.privacy': 'Privacy',
|
||||
'settings.tab.connections': 'Connections',
|
||||
'settings.tab.keybinds': 'Keybinds',
|
||||
'settings.tab.desktop': 'Desktop',
|
||||
'settings.tab.instance': 'Instance',
|
||||
'settings.tab.language': 'Language',
|
||||
|
||||
// Settings — language
|
||||
'settings.language.title': 'Language',
|
||||
'settings.language.description': 'Choose the language for the interface. Anything not yet translated stays in English.',
|
||||
'settings.language.en': 'English',
|
||||
'settings.language.ptBR': 'Portuguese (Brazil)',
|
||||
|
||||
// Settings — voice: input
|
||||
'settings.voice.input.title': 'Input Device',
|
||||
'settings.voice.input.volume': 'Input Volume',
|
||||
'settings.voice.micTest.start': "Let's Check",
|
||||
'settings.voice.micTest.stop': 'Stop Testing',
|
||||
'settings.voice.micTest.playing': 'Playing your mic back to you — say something.',
|
||||
'settings.voice.micTest.inCall': 'The level meter is live while you are in a call.',
|
||||
'settings.voice.micTest.idle': 'Test your mic without joining a call.',
|
||||
'settings.voice.micTest.failed': 'Could not open the microphone. Check the device and its permission.',
|
||||
|
||||
// Profile card
|
||||
'profile.aboutMe': 'About Me',
|
||||
'profile.memberSince': 'Member Since',
|
||||
'profile.sendMessage': 'Send Message',
|
||||
'profile.activity.playing': 'Playing',
|
||||
'profile.activity.listening': 'Listening to',
|
||||
'profile.activity.watching': 'Watching',
|
||||
'profile.activity.streaming': 'Streaming',
|
||||
'profile.activity.elapsed': '{time} elapsed',
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof en;
|
||||
export type Dictionary = Record<TranslationKey, string>;
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { Dictionary } from './en';
|
||||
|
||||
/**
|
||||
* Partial on purpose. Translation happens one system per update, and anything
|
||||
* absent here falls back to English — so a half-migrated interface is never
|
||||
* broken, just partly in English.
|
||||
*/
|
||||
export const ptBR: Partial<Dictionary> = {
|
||||
// Configurações — navegação
|
||||
'settings.tab.account': 'Conta',
|
||||
'settings.tab.voice': 'Voz e Vídeo',
|
||||
'settings.tab.privacy': 'Privacidade',
|
||||
'settings.tab.connections': 'Conexões',
|
||||
'settings.tab.keybinds': 'Atalhos',
|
||||
'settings.tab.desktop': 'Desktop',
|
||||
'settings.tab.instance': 'Instância',
|
||||
'settings.tab.language': 'Idioma',
|
||||
|
||||
// Configurações — idioma
|
||||
'settings.language.title': 'Idioma',
|
||||
'settings.language.description': 'Escolha o idioma da interface. O que ainda não foi traduzido continua em inglês.',
|
||||
'settings.language.en': 'Inglês',
|
||||
'settings.language.ptBR': 'Português (Brasil)',
|
||||
|
||||
// Configurações — voz: entrada
|
||||
'settings.voice.input.title': 'Dispositivo de entrada',
|
||||
'settings.voice.input.volume': 'Volume de entrada',
|
||||
'settings.voice.micTest.start': 'Testar microfone',
|
||||
'settings.voice.micTest.stop': 'Parar teste',
|
||||
'settings.voice.micTest.playing': 'Devolvendo seu microfone para você — fale alguma coisa.',
|
||||
'settings.voice.micTest.inCall': 'O medidor fica ativo enquanto você está numa call.',
|
||||
'settings.voice.micTest.idle': 'Teste seu microfone sem entrar numa call.',
|
||||
'settings.voice.micTest.failed': 'Não foi possível abrir o microfone. Verifique o dispositivo e a permissão.',
|
||||
|
||||
// Cartão de perfil
|
||||
'profile.aboutMe': 'Sobre mim',
|
||||
'profile.memberSince': 'Membro desde',
|
||||
'profile.sendMessage': 'Enviar mensagem',
|
||||
'profile.activity.playing': 'Jogando',
|
||||
'profile.activity.listening': 'Ouvindo',
|
||||
'profile.activity.watching': 'Assistindo',
|
||||
'profile.activity.streaming': 'Transmitindo',
|
||||
'profile.activity.elapsed': '{time} decorrido',
|
||||
};
|
||||
@@ -17,6 +17,15 @@ export interface ScreenShareConfig {
|
||||
interface VoiceState {
|
||||
voiceUsers: Map<string, string[]>; // channelId → userIds
|
||||
currentVoiceChannelId: string | null;
|
||||
/**
|
||||
* Space and name of the channel the call is in, captured at join time.
|
||||
* `channels` in spaceStore only holds the space the user is *viewing*, so
|
||||
* once they navigate elsewhere the call's channel is no longer resolvable
|
||||
* from it — these keep the voice panel able to name and link to the call.
|
||||
* Transient: intentionally absent from `partialize`.
|
||||
*/
|
||||
currentVoiceSpaceId: string | null;
|
||||
currentVoiceChannelName: string | null;
|
||||
isMuted: boolean;
|
||||
isDeafened: boolean;
|
||||
isCameraOn: boolean;
|
||||
@@ -85,7 +94,7 @@ interface VoiceState {
|
||||
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
||||
addVoiceUser: (channelId: string, userId: string) => void;
|
||||
removeVoiceUser: (channelId: string, userId: string) => void;
|
||||
setCurrentVoiceChannel: (channelId: string | null) => void;
|
||||
setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
|
||||
setParticipants: (participants: ParticipantInfo[]) => void;
|
||||
setSpeakingParticipants: (ids: Set<string>) => void;
|
||||
setConnectionError: (error: string | null) => void;
|
||||
@@ -158,6 +167,8 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
(set, get) => ({
|
||||
voiceUsers: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
currentVoiceSpaceId: null,
|
||||
currentVoiceChannelName: null,
|
||||
isMuted: false,
|
||||
pttActive: false,
|
||||
isDeafened: false,
|
||||
@@ -349,8 +360,10 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
});
|
||||
},
|
||||
|
||||
setCurrentVoiceChannel: (channelId) => set({
|
||||
setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
|
||||
currentVoiceChannelId: channelId,
|
||||
currentVoiceSpaceId: channelId ? spaceId : null,
|
||||
currentVoiceChannelName: channelId ? channelName : null,
|
||||
activeDmCall: null // Clear active DM call when joining a server channel
|
||||
}),
|
||||
|
||||
@@ -516,6 +529,8 @@ export const useVoiceStore = create<VoiceState>()(
|
||||
voiceUsers: new Map(),
|
||||
voiceUserStates: new Map(),
|
||||
currentVoiceChannelId: null,
|
||||
currentVoiceSpaceId: null,
|
||||
currentVoiceChannelName: null,
|
||||
participants: [],
|
||||
speakingParticipantIds: new Set(),
|
||||
speakingUserIds: new Set(),
|
||||
|
||||
@@ -152,7 +152,15 @@ export function joinVoiceChannel(
|
||||
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
|
||||
}
|
||||
|
||||
setCurrentVoiceChannel(channelId);
|
||||
// Capture the space and name now: a voice channel can only be joined from
|
||||
// within its own space, but the user may navigate away afterwards — at which
|
||||
// point spaceStore.channels no longer resolves this channel.
|
||||
const spaceStore = useSpaceStore.getState();
|
||||
setCurrentVoiceChannel(
|
||||
channelId,
|
||||
spaceStore.currentSpaceId ?? null,
|
||||
spaceStore.channels.find((c) => c.id === channelId)?.name ?? null,
|
||||
);
|
||||
// Optimistic: immediately show self in new channel (using origin-aware ID)
|
||||
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
|
||||
if (myNewId) addVoiceUser(channelId, myNewId);
|
||||
|
||||
Reference in New Issue
Block a user