13 Commits
Author SHA1 Message Date
devsyncwrld ff55d9d486 docs: plan desktop features and diagnose the Spotify sync bugs
CI / Build & test (Node 20) (push) Waiting to run
CI / Build & test (Node 24) (push) Waiting to run
CI / Build & test (push) Blocked by required conditions
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
Security / Secret scan (gitleaks) (push) Waiting to run
Security / Dependency scan (OSV-Scanner) (push) Waiting to run
Security / IaC/config scan (Trivy) (push) Waiting to run
Security / License compliance scan (Trivy) (push) Waiting to run
2026-08-31 14:02:21 -03:00
devsyncwrld e58021408c docs: record the Electron window.prompt soundboard bug
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
2026-08-31 14:00:41 -03:00
devsyncwrld 2afe3453f0 docs: record four approved features not yet started
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
2026-08-31 13:57:01 -03:00
devsyncwrld e89966435a fix(soundboard): read MANAGE_SPACE from the space bitfield
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
The add-sound control was gated on channelPermissions, which carries the
per-channel bitfield; MANAGE_SPACE lives in the space-level one. The check
silently evaluated false for everybody, including owners, so the button never
rendered and there was no way to add a sound at all.

Also raise the clip cap to 2 MB — a 30-second clip at a high bitrate cleared
1 MB — and stop reporting every upload failure as 'too large', which sent
people to shrink a file that was not the problem.
2026-08-31 13:54:00 -03:00
devsyncwrld f5451e1b14 feat: soundboard, account menu, and call timer
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.

Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter
broadcasts a space room to the whole space, which is right for the presence
the sidebar shows and wrong for anything audible. The cooldown is enforced
server-side — a client-side one only slows down people not trying to abuse it,
and a soundboard is the easiest thing here to turn into a weapon. Playing is
open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE.

Account menu: the name in the user bar had cursor-pointer and no handler, so
the interface was already promising a click that did nothing. Offers profile,
status and copy-id — not the Clips or account switching the reference design
shows, which would be dead UI here.

Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
2026-08-31 13:45:18 -03:00
devsyncwrld ef5545465d docs: mark GIF favourites, audit log and statistics delivered
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
2026-08-31 13:30:26 -03:00
devsyncwrld 1830051732 feat(stats): voice-time and message leaderboards per space
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Voice stays get their own table rather than joining the audit log: that table
records points in time, a call is an interval, and pairing join/leave point
events would leave every query guessing at joins whose leave never arrived.

Sessions are opened and closed inside joinRoom/leaveCurrentRoom rather than at
the seven call sites that reach them, so no path can be missed, and
destroyRoom closes them too — it bypasses leaveCurrentRoom and would otherwise
leak open rows.

A restart leaves sessions open with an unknowable end time. They are closed at
startedAt, discarding that time rather than inventing it: crediting the gap
would hand someone hours they never spent, and the numbers are the point.
Mirrors the existing users.status sweep on boot.

Only closed sessions count, so a figure does not move on every refresh. Bars
scale to the leader, not the total — with five people every share of a total
looks identical. Statistics are readable by any member, since they are the
group's own numbers; the audit log, which names who did what, stays on
MANAGE_SPACE.
2026-08-31 13:29:59 -03:00
devsyncwrld bbb190cbda feat(audit): append-only audit log for spaces
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.

The table is deliberately generic (action + target + JSON metadata) so a new
action needs no migration. Writes never throw: a kick must not fail because
its log entry could not be written, since the kick already happened.

Leaving is recorded as a different action from being removed. The same route
serves both, and a log that conflates them misleads exactly when it matters.

Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
2026-08-31 13:22:52 -03:00
devsyncwrld fb662bfe12 feat(gif): favourites and category shortcuts
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
Favourites are stored server-side per user, so one made on the phone is there
on the desktop — the point of favouriting. The whole result is stored rather
than an id: the provider offers no lookup by id, so an id-only favourite could
not be rendered without re-finding it through search.

Category chips translate their label but not their query, which goes to a
provider that indexes in English.

The star sits beside the tile button rather than inside it: a button within a
button is invalid and swallows the click. Toggling is optimistic and reverts
on failure, and favourites skip both the loading skeleton and the infinite
scroll, which belong to provider-backed browsing only.

Server caps favourites per user and rejects non-http(s) URLs, which become
<img src> in everyone's picker.
2026-08-31 13:12:50 -03:00
devsyncwrld 0fc6abeb6e docs: mark Spotify delivered, drop resolved items
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
2026-08-31 13:04:35 -03:00
devsyncwrld 75316b0882 feat(spotify): show the current track as an activity
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.

The callback arrives as a plain browser redirect with no Authorization header,
so the OAuth state carries the user id signed with the instance secret and is
compared in constant time; without that, anyone could bind their Spotify
account to another user.

Activities are now tracked per producer. pushActivities replaced the whole
list, so the desktop game detector and Spotify would erase each other — losing
exactly the case this is for, a game and Spotify at once.

Polling backs off when the tab is hidden and keeps the last known track on a
network error rather than reporting 'stopped listening'. A rejected refresh
token (access revoked on Spotify's side) drops the row so the UI stops
claiming a live connection.

Scope is read-only: user-read-currently-playing and user-read-playback-state.

Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
2026-08-31 12:37:08 -03:00
devsyncwrld c022f2795f docs: record the i18n foundation and translation queue
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
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
2026-08-31 12:25:03 -03:00
devsyncwrld 688a1335cb feat(i18n): language foundation with en and pt-BR
Nothing in the project was translatable — every string sat inline in English.

en.ts is the source dictionary and its type is derived from it, so a typo or a
missing key fails typecheck instead of rendering the raw key at runtime.
pt-BR.ts is deliberately Partial: translation proceeds one system per update
and anything absent falls back to English, so a half-migrated interface is
never broken, only partly English.

Locale is persisted, guessed from the browser on first run, and kept in sync
with <html lang> through a subscription — persisted state rehydrates after
first paint, so a one-off assignment would miss it.

Translates the voice input panel (including the mic test shipped earlier
today) and the profile card as this round's system. Language options are
labelled in the active language, so a wrong pick can always be undone.
2026-08-31 12:24:50 -03:00
59 changed files with 23339 additions and 110 deletions
+59 -28
View File
@@ -15,6 +15,13 @@ código e os commits seguem em inglês, como o resto do repositório.
| 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 |
| Favoritar GIFs + categorias (#6) | `fb662bfe` | Favoritos no servidor, por conta; guarda o resultado inteiro porque o provedor não busca por id |
| Registro de auditoria (#9) | `bbb190cb` | Tabela append-only genérica; 8 ganchos; sair ≠ ser expulso |
| Estatísticas do grupo | `18300517` | Tabela própria de sessões de voz; ganchos no ponto único de entrada/saída |
| 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 |
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
| 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)
@@ -39,9 +46,6 @@ 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
@@ -52,35 +56,64 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| 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.
**Pré-requisito não óbvio:** o projeto não tem sistema de i18n algum — as
strings estão fixas em inglês dentro dos componentes. Traduzir "um sistema por
update" só é possível depois da fundação abaixo.
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)
- Configurações → Privacidade
- Configurações → Conexões (nasceu bilíngue)
- Seletor de GIF
- Registro de auditoria e Estatísticas (nasceram bilíngues)
### 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
## Correções pendentes
| 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 |
| **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 |
## Aprovadas para o app desktop (2026-08-31)
Só entram aqui coisas que o navegador **não consegue** fazer — o resto seria
trabalho dobrado sem ganho. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica |
|---|---|---|
| **Áudio do sistema no compartilhamento de tela** | Média | Hoje o som do jogo/vídeo não vai junto com a tela. O Electron captura áudio do sistema; o navegador não. **É pré-requisito da watch party** — sem isso, assistir junto é assistir mudo. No Linux depende do servidor de áudio (PipeWire/PulseAudio), então vale confirmar o alvo antes |
| **Bandeja do sistema** | Pequena | Fechar minimiza em vez de sair; ícone com menu de mudo/silenciar e sair de verdade. Cuidado clássico: sem um "sair" explícito no menu, a pessoa não consegue fechar o app |
| **Notificações nativas do sistema** | Pequena | Mais confiáveis que as do navegador e funcionam com a janela minimizada. Já existe um `NotificationController` no web; a parte desktop é rotear pelo processo principal |
## Aprovadas, a fazer depois (2026-08-31)
Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica |
|---|---|---|
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
## Ideias novas — a avaliar
@@ -88,8 +121,6 @@ Ordenadas por relação valor/custo para um servidor de grupo fechado.
| Sistema | Tamanho | Por que faz sentido aqui |
|---|---|---|
| **Fundação de i18n** | Média | Bloqueia a regra de idioma acima. Dicionário por idioma + hook de tradução + seletor nas configurações; migração componente a componente, um por update |
| **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 |
@@ -0,0 +1,9 @@
CREATE TABLE `spotify_connections` (
`user_id` text PRIMARY KEY NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`expires_at` integer NOT NULL,
`spotify_user_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,14 @@
CREATE TABLE `gif_favorites` (
`user_id` text NOT NULL,
`gif_id` text NOT NULL,
`title` text NOT NULL,
`preview_url` text NOT NULL,
`url` text NOT NULL,
`width` integer NOT NULL,
`height` integer NOT NULL,
`created_at` integer NOT NULL,
PRIMARY KEY(`user_id`, `gif_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_gif_favorites_user_id` ON `gif_favorites` (`user_id`);
@@ -0,0 +1,15 @@
CREATE TABLE `audit_events` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`actor_id` text,
`action` text NOT NULL,
`target_type` text,
`target_id` text,
`metadata` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`actor_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_audit_events_space_created` ON `audit_events` (`space_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_audit_events_actor` ON `audit_events` (`actor_id`);
@@ -0,0 +1,14 @@
CREATE TABLE `voice_sessions` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text,
`channel_id` text NOT NULL,
`user_id` text NOT NULL,
`started_at` integer NOT NULL,
`ended_at` integer,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_space_started` ON `voice_sessions` (`space_id`,`started_at`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_user` ON `voice_sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_ended` ON `voice_sessions` (`ended_at`);
@@ -0,0 +1,12 @@
CREATE TABLE `soundboard_sounds` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`name` text NOT NULL,
`filename` text NOT NULL,
`uploader_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_soundboard_space` ON `soundboard_sounds` (`space_id`);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -78,6 +78,41 @@
"when": 1783035334526,
"tag": "0010_broken_blazing_skull",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1788190305853,
"tag": "0011_lethal_bruce_banner",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1788192490711,
"tag": "0012_sour_pixie",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1788193162813,
"tag": "0013_fancy_betty_brant",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1788193659704,
"tag": "0014_mean_killer_shrike",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1788194631751,
"tag": "0015_young_human_fly",
"breakpoints": true
}
]
}
+4
View File
@@ -76,6 +76,10 @@ export const config = {
sourceCodeUrl,
commit,
spotify: {
clientId: envOptional('SPOTIFY_CLIENT_ID'),
clientSecret: envOptional('SPOTIFY_CLIENT_SECRET'),
},
livekit: {
url: envOptional('LIVEKIT_URL'),
apiKey: envOptional('LIVEKIT_API_KEY'),
+96
View File
@@ -549,3 +549,99 @@ export const inviteRedemptions = sqliteTable('invite_redemptions', {
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
}));
/**
* Spotify tokens, one row per user.
*
* Kept server-side on purpose: refreshing requires the client secret, so the
* browser never holds a Spotify token at all — it asks this server what is
* playing and this server talks to Spotify.
*/
export const spotifyConnections = sqliteTable('spotify_connections', {
userId: text('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
accessToken: text('access_token').notNull(),
refreshToken: text('refresh_token').notNull(),
// Epoch millis at which accessToken stops working.
expiresAt: integer('expires_at').notNull(),
spotifyUserId: text('spotify_user_id'),
createdAt: integer('created_at').notNull(),
});
/**
* Favourited GIFs, one row per user per GIF.
*
* Stores the whole result rather than an id: the provider offers no lookup by
* id, so a favourites tab that only kept ids could not render without
* re-searching for something the user may never find again.
*/
export const gifFavorites = sqliteTable('gif_favorites', {
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
gifId: text('gif_id').notNull(),
title: text('title').notNull(),
previewUrl: text('preview_url').notNull(),
url: text('url').notNull(),
width: integer('width').notNull(),
height: integer('height').notNull(),
createdAt: integer('created_at').notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.gifId] }),
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
}));
/**
* Append-only record of who changed what in a space.
*
* Deliberately generic (action + target + JSON metadata) rather than a column
* per event type: new actions must not require a migration. Statistics read
* this same table — two features, one mechanism, instead of two logs that
* drift apart.
*/
export const auditEvents = sqliteTable('audit_events', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
actorId: text('actor_id').references(() => users.id, { onDelete: 'set null' }),
action: text('action').notNull(),
targetType: text('target_type'),
targetId: text('target_id'),
// JSON blob; shape depends on `action`. Never trusted for permissions.
metadata: text('metadata'),
createdAt: integer('created_at').notNull(),
}, (table) => ({
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
actorIdx: index('idx_audit_events_actor').on(table.actorId),
}));
/**
* One row per stay in a voice room, closed when the user leaves.
*
* Separate from `auditEvents` on purpose: that table records points in time,
* while a call is an interval. Storing joins and leaves as separate point
* events would make every statistics query pair rows by hand and guess at
* joins whose leave never arrived (a crash, a restart).
*
* `endedAt` null means still connected. `spaceId` is null for DM calls.
*/
export const voiceSessions = sqliteTable('voice_sessions', {
id: text('id').primaryKey(),
spaceId: text('space_id').references(() => spaces.id, { onDelete: 'cascade' }),
channelId: text('channel_id').notNull(),
userId: text('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
startedAt: integer('started_at').notNull(),
endedAt: integer('ended_at'),
}, (table) => ({
spaceIdx: index('idx_voice_sessions_space_started').on(table.spaceId, table.startedAt),
userIdx: index('idx_voice_sessions_user').on(table.userId),
openIdx: index('idx_voice_sessions_ended').on(table.endedAt),
}));
/** Soundboard clips, per space. The file lives in the normal upload dir. */
export const soundboardSounds = sqliteTable('soundboard_sounds', {
id: text('id').primaryKey(),
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
filename: text('filename').notNull(),
uploaderId: text('uploader_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: integer('created_at').notNull(),
}, (table) => ({
spaceIdx: index('idx_soundboard_space').on(table.spaceId),
}));
+13
View File
@@ -15,6 +15,11 @@ import { uploadRoutes } from './routes/uploads.js';
import { filesRoutes } from './routes/files.js';
import { dmRoutes } from './routes/dm.js';
import { livekitRoutes } from './routes/livekit.js';
import { spotifyRoutes } from './routes/spotify.js';
import { auditRoutes } from './routes/audit.js';
import { statsRoutes } from './routes/stats.js';
import { soundboardRoutes } from './routes/soundboard.js';
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
import { socialRoutes } from './routes/social.js';
import { settingsRoutes } from './routes/settings.js';
import { utilRoutes } from './routes/utils.js';
@@ -109,6 +114,10 @@ async function main(): Promise<void> {
// Initialize database
getDb();
// A restart leaves voice sessions open with no way to know when they really
// ended. Sweep them before anything can read the statistics.
closeOrphanedVoiceSessions();
// Reset orphaned `users.status` rows for locally-homed users. The previous
// process's in-memory disconnect timers are gone, so any non-offline row
// is stale by construction. Replicated (federated) rows are skipped — their
@@ -126,6 +135,10 @@ async function main(): Promise<void> {
await app.register(filesRoutes);
await app.register(dmRoutes);
await app.register(livekitRoutes);
await app.register(spotifyRoutes);
await app.register(auditRoutes);
await app.register(statsRoutes);
await app.register(soundboardRoutes);
await app.register(socialRoutes);
await app.register(settingsRoutes);
await app.register(utilRoutes);
+83
View File
@@ -0,0 +1,83 @@
import type { FastifyInstance } from 'fastify';
import { and, desc, eq, lt } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { hasPermission } from '../utils/permissions.js';
import { PermissionBits } from '@backspace/shared/src/permissions.js';
import { AUDIT_PAGE_SIZE, type AuditAction, type AuditEvent } from '@backspace/shared/src/audit.js';
export async function auditRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string }; Querystring: { before?: string; limit?: string } }>(
'/api/spaces/:id/audit-log',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Gated on MANAGE_SPACE rather than a new permission bit: the log names
// who did what to whom, which is administrator-shaped information, and a
// new bit would silently default to nobody until roles were re-edited.
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const limit = Math.min(Math.max(Number(request.query.limit) || AUDIT_PAGE_SIZE, 1), AUDIT_PAGE_SIZE);
const db = getDb();
// Ids are snowflakes, so ordering by id is chronological and gives a
// stable cursor even when two events land in the same millisecond.
const where = request.query.before
? and(eq(schema.auditEvents.spaceId, id), lt(schema.auditEvents.id, request.query.before))
: eq(schema.auditEvents.spaceId, id);
const rows = db.select({
id: schema.auditEvents.id,
spaceId: schema.auditEvents.spaceId,
action: schema.auditEvents.action,
targetType: schema.auditEvents.targetType,
targetId: schema.auditEvents.targetId,
metadata: schema.auditEvents.metadata,
createdAt: schema.auditEvents.createdAt,
actorId: schema.users.id,
actorUsername: schema.users.username,
actorDisplayName: schema.users.displayName,
actorAvatar: schema.users.avatar,
})
.from(schema.auditEvents)
.leftJoin(schema.users, eq(schema.auditEvents.actorId, schema.users.id))
.where(where)
.orderBy(desc(schema.auditEvents.id))
.limit(limit)
.all();
const events: AuditEvent[] = rows.map((r) => ({
id: r.id,
spaceId: r.spaceId,
action: r.action as AuditAction,
// Null when the account was deleted: the event stays, the actor does
// not — an audit log that vanished with its actor would be useless.
actor: r.actorId
? { id: r.actorId, username: r.actorUsername!, displayName: r.actorDisplayName, avatar: r.actorAvatar }
: null,
targetType: r.targetType,
targetId: r.targetId,
metadata: parseMetadata(r.metadata),
createdAt: r.createdAt,
}));
return reply.code(200).send({ events, hasMore: events.length === limit });
},
);
}
/** Metadata is written by us, but a malformed row must not break the whole page. */
function parseMetadata(raw: string | null): Record<string, unknown> | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
+28
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { recordAuditEvent } from '../utils/auditLog.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
import { permissionsToString } from '@backspace/shared/src/permissions.js';
@@ -257,6 +258,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
// Return the channel with the creator's computed permissions (same shape as
// the channel_created WS event) so the client can render it immediately
// without waiting for the broadcast to round-trip.
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'channel.create',
targetType: 'channel',
targetId: channelId,
metadata: { name: channelData.name, type: channelData.type },
});
const creatorPerms = computePermissions(request.userId, id, channelId);
return reply.code(201).send({
...channelData,
@@ -346,6 +356,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
});
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.update',
targetType: 'channel',
targetId: id,
metadata: { name: channelData.name },
});
return reply.code(200).send(channelData);
});
@@ -418,6 +437,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
connectionManager.sendToUser(uid, deleteEvent);
}
recordAuditEvent({
spaceId,
actorId: request.userId,
action: 'channel.delete',
targetType: 'channel',
targetId: id,
metadata: { name: channel.name },
});
return reply.code(200).send({ success: true });
});
+65 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { eq, and, desc } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import type { GifResult } from '@backspace/shared';
@@ -178,4 +178,68 @@ export async function gifRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ results: [], next: '' });
}
});
// ─── Favourites ──────────────────────────────────────────────────────────
// Kept per user and synced server-side so a favourite made on the phone is
// there on the desktop, which is the whole point of favouriting.
/** Cap per user: a favourites tab is a shortlist, not an archive. */
const MAX_FAVORITES = 200;
app.get('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
const rows = db.select().from(schema.gifFavorites)
.where(eq(schema.gifFavorites.userId, request.userId))
.orderBy(desc(schema.gifFavorites.createdAt))
.all();
const results: GifResult[] = rows.map((r) => ({
id: r.gifId, title: r.title, previewUrl: r.previewUrl,
url: r.url, width: r.width, height: r.height,
}));
return reply.code(200).send({ results });
});
app.post<{ Body: GifResult }>('/api/gif/favorites', { preHandler: authenticate }, async (request, reply) => {
const { id, title, previewUrl, url, width, height } = request.body ?? ({} as GifResult);
if (!id || typeof id !== 'string' || !previewUrl || !url) {
return reply.code(400).send({ error: 'id, previewUrl and url are required', statusCode: 400 });
}
// Only http(s): these become <img src> for everyone who opens the picker.
for (const candidate of [previewUrl, url]) {
if (!/^https?:\/\//.test(candidate)) {
return reply.code(400).send({ error: 'previewUrl and url must be http(s)', statusCode: 400 });
}
}
const db = getDb();
const count = db.select().from(schema.gifFavorites)
.where(eq(schema.gifFavorites.userId, request.userId)).all().length;
const existing = db.select().from(schema.gifFavorites)
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, id))).get();
if (!existing && count >= MAX_FAVORITES) {
return reply.code(409).send({ error: `At most ${MAX_FAVORITES} favourites`, statusCode: 409 });
}
db.insert(schema.gifFavorites).values({
userId: request.userId,
gifId: id,
title: typeof title === 'string' ? title.slice(0, 200) : '',
previewUrl,
url,
width: Number.isFinite(width) ? width : 0,
height: Number.isFinite(height) ? height : 0,
createdAt: Date.now(),
}).onConflictDoNothing().run();
return reply.code(204).send();
});
app.delete<{ Params: { id: string } }>('/api/gif/favorites/:id', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
db.delete(schema.gifFavorites)
.where(and(eq(schema.gifFavorites.userId, request.userId), eq(schema.gifFavorites.gifId, request.params.id)))
.run();
return reply.code(204).send();
});
}
+85
View File
@@ -0,0 +1,85 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { hasPermission, isMember } from '../utils/permissions.js';
import { PermissionBits } from '@backspace/shared/src/permissions.js';
import { generateSnowflake } from '../utils/snowflake.js';
/** A soundboard is a shortlist of gags, not a media library. */
const MAX_SOUNDS_PER_SPACE = 48;
const MAX_NAME_LENGTH = 32;
export async function soundboardRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string } }>(
'/api/spaces/:id/sounds',
{ preHandler: authenticate },
async (request, reply) => {
if (!isMember(request.params.id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const rows = getDb().select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.spaceId, request.params.id)).all();
return reply.code(200).send({ sounds: rows });
},
);
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
'/api/spaces/:id/sounds',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Adding is gated but playing is not: anyone in the call may press a
// button, only the people who run the space decide what the buttons are.
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const name = (request.body?.name ?? '').trim().slice(0, MAX_NAME_LENGTH);
const filename = (request.body?.filename ?? '').trim();
if (!name || !filename) {
return reply.code(400).send({ error: 'name and filename are required', statusCode: 400 });
}
// The filename is a key into the upload directory, never a path.
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
}
const db = getDb();
const count = db.select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.spaceId, id)).all().length;
if (count >= MAX_SOUNDS_PER_SPACE) {
return reply.code(409).send({ error: `At most ${MAX_SOUNDS_PER_SPACE} sounds`, statusCode: 409 });
}
const row = {
id: generateSnowflake(),
spaceId: id,
name,
filename,
uploaderId: request.userId,
createdAt: Date.now(),
};
db.insert(schema.soundboardSounds).values(row).run();
return reply.code(201).send(row);
},
);
app.delete<{ Params: { id: string } }>(
'/api/sounds/:id',
{ preHandler: authenticate },
async (request, reply) => {
const db = getDb();
const sound = db.select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.id, request.params.id)).get();
if (!sound) return reply.code(404).send({ error: 'Sound not found', statusCode: 404 });
if (!hasPermission(request.userId, sound.spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
db.delete(schema.soundboardSounds).where(eq(schema.soundboardSounds.id, request.params.id)).run();
return reply.code(204).send();
},
);
}
+48
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm';
import { getDb, getRawDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { recordAuditEvent } from '../utils/auditLog.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
@@ -489,6 +490,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
space: spaceData,
});
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'space.update',
targetType: 'space',
targetId: id,
metadata: { fields: Object.keys(updates) },
});
return reply.code(200).send(spaceData);
});
@@ -1005,6 +1015,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
userId: uid,
});
recordAuditEvent({
spaceId: id,
actorId: request.userId,
// Leaving on your own is not the same event as being removed by someone
// else, and a log that conflates the two misleads exactly when it matters.
action: request.userId === uid ? 'member.leave' : 'member.kick',
targetType: 'user',
targetId: uid,
});
return reply.code(200).send({ success: true });
});
@@ -1068,6 +1088,17 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
}
checkVoicePermissions(id);
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'role.create',
targetType: 'role',
// roleId is the value just inserted; `role` is a read-back the compiler
// cannot prove returned a row.
targetId: roleId,
metadata: { name: role?.name ?? null },
});
return reply.code(201).send(role);
});
@@ -1125,6 +1156,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
}
checkVoicePermissions(id);
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'role.update',
targetType: 'role',
targetId: roleId,
metadata: { name: updated?.name ?? null },
});
return reply.code(200).send(updated);
});
@@ -1247,6 +1287,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
space: spaceData,
});
recordAuditEvent({
spaceId: id,
actorId: request.userId,
action: 'space.transfer_ownership',
targetType: 'user',
targetId: newOwnerId,
});
return reply.code(200).send(spaceData);
});
+212
View File
@@ -0,0 +1,212 @@
import type { FastifyInstance } from 'fastify';
import crypto from 'crypto';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { config } from '../config.js';
import { getOurOrigin } from '../utils/federationAuth.js';
import type { Activity } from '@backspace/shared';
const SPOTIFY_AUTH = 'https://accounts.spotify.com/authorize';
const SPOTIFY_TOKEN = 'https://accounts.spotify.com/api/token';
const SPOTIFY_NOW_PLAYING = 'https://api.spotify.com/v1/me/player/currently-playing';
// Read-only: enough to see the current track, nothing that can control playback
// or read the library.
const SCOPES = 'user-read-currently-playing user-read-playback-state';
/** Refresh this many ms before expiry, so a request never races the deadline. */
const REFRESH_MARGIN_MS = 60_000;
function redirectUri(): string {
return `${getOurOrigin()}/api/connections/spotify/callback`;
}
function isConfigured(): boolean {
return Boolean(config.spotify.clientId && config.spotify.clientSecret);
}
/**
* OAuth `state`, signed with the instance's JWT secret.
*
* The callback arrives as a browser redirect, which carries no Authorization
* header — so the state has to say who started the flow, and be tamper-proof
* or anyone could bind their Spotify account to someone else's user.
*/
function signState(userId: string): string {
const payload = Buffer.from(JSON.stringify({ userId, exp: Date.now() + 10 * 60_000 })).toString('base64url');
const sig = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
return `${payload}.${sig}`;
}
function verifyState(state: string): string | null {
const [payload, sig] = state.split('.');
if (!payload || !sig) return null;
const expected = crypto.createHmac('sha256', config.jwtSecret).update(payload).digest('base64url');
const a = Buffer.from(sig);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
try {
const data = JSON.parse(Buffer.from(payload, 'base64url').toString()) as { userId: string; exp: number };
if (!data.userId || typeof data.exp !== 'number' || data.exp < Date.now()) return null;
return data.userId;
} catch {
return null;
}
}
function basicAuthHeader(): string {
return 'Basic ' + Buffer.from(`${config.spotify.clientId}:${config.spotify.clientSecret}`).toString('base64');
}
/**
* Returns a usable access token, refreshing it first when it is about to
* expire. Returns null when the connection is gone or Spotify rejected the
* refresh token — the caller then treats the user as disconnected.
*/
async function getAccessToken(userId: string): Promise<string | null> {
const db = getDb();
const row = db.select().from(schema.spotifyConnections)
.where(eq(schema.spotifyConnections.userId, userId)).get();
if (!row) return null;
if (row.expiresAt - REFRESH_MARGIN_MS > Date.now()) return row.accessToken;
const res = await fetch(SPOTIFY_TOKEN, {
method: 'POST',
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: row.refreshToken }),
});
if (!res.ok) {
// A refresh token is rejected when the user revoked access on Spotify's
// side. Drop the row so the UI stops claiming a live connection.
if (res.status === 400 || res.status === 401) {
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, userId)).run();
}
return null;
}
const body = await res.json() as { access_token: string; expires_in: number; refresh_token?: string };
db.update(schema.spotifyConnections).set({
accessToken: body.access_token,
// Spotify only returns a new refresh token sometimes; keep the old one otherwise.
refreshToken: body.refresh_token ?? row.refreshToken,
expiresAt: Date.now() + body.expires_in * 1000,
}).where(eq(schema.spotifyConnections.userId, userId)).run();
return body.access_token;
}
interface SpotifyTrack {
is_playing: boolean;
progress_ms: number | null;
item: {
name: string;
duration_ms: number;
artists: { name: string }[];
album: { name: string; images: { url: string }[] };
external_urls?: { spotify?: string };
} | null;
}
/** Maps Spotify's payload onto the Activity shape the profile card renders. */
function toActivity(track: SpotifyTrack): Activity | null {
if (!track.is_playing || !track.item) return null;
const now = Date.now();
const progress = track.progress_ms ?? 0;
return {
type: 'listening',
name: 'Spotify',
details: track.item.name,
state: track.item.artists.map((a) => a.name).join(', '),
timestamps: { start: now - progress, end: now - progress + track.item.duration_ms },
assets: {
largeImage: track.item.album.images[0]?.url,
largeText: track.item.album.name,
},
url: track.item.external_urls?.spotify,
};
}
export async function spotifyRoutes(app: FastifyInstance): Promise<void> {
app.get('/api/connections/spotify/status', { preHandler: authenticate }, async (request, reply) => {
if (!isConfigured()) return reply.code(200).send({ configured: false, connected: false });
const db = getDb();
const row = db.select({ userId: schema.spotifyConnections.userId })
.from(schema.spotifyConnections)
.where(eq(schema.spotifyConnections.userId, request.userId)).get();
return reply.code(200).send({ configured: true, connected: Boolean(row) });
});
// Returns the URL rather than redirecting: the caller is fetch(), which would
// follow a 302 to Spotify instead of navigating the window there.
app.get('/api/connections/spotify/authorize', { preHandler: authenticate }, async (request, reply) => {
if (!isConfigured()) {
return reply.code(503).send({ error: 'Spotify is not configured on this instance', statusCode: 503 });
}
const params = new URLSearchParams({
client_id: config.spotify.clientId!,
response_type: 'code',
redirect_uri: redirectUri(),
scope: SCOPES,
state: signState(request.userId),
});
return reply.code(200).send({ url: `${SPOTIFY_AUTH}?${params}` });
});
app.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
'/api/connections/spotify/callback',
async (request, reply) => {
const { code, state, error } = request.query;
const settingsUrl = `${getOurOrigin()}/channels/@me?settings=connections`;
if (error || !code || !state) return reply.redirect(`${settingsUrl}&spotify=denied`);
const userId = verifyState(state);
if (!userId) return reply.redirect(`${settingsUrl}&spotify=invalid_state`);
const res = await fetch(SPOTIFY_TOKEN, {
method: 'POST',
headers: { Authorization: basicAuthHeader(), 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri() }),
});
if (!res.ok) return reply.redirect(`${settingsUrl}&spotify=exchange_failed`);
const body = await res.json() as { access_token: string; refresh_token: string; expires_in: number };
const db = getDb();
const row = {
userId,
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + body.expires_in * 1000,
spotifyUserId: null,
createdAt: Date.now(),
};
db.insert(schema.spotifyConnections).values(row)
.onConflictDoUpdate({ target: schema.spotifyConnections.userId, set: row }).run();
return reply.redirect(`${settingsUrl}&spotify=connected`);
},
);
app.get('/api/connections/spotify/now-playing', { preHandler: authenticate }, async (request, reply) => {
const token = await getAccessToken(request.userId);
if (!token) return reply.code(200).send({ activity: null, connected: false });
const res = await fetch(SPOTIFY_NOW_PLAYING, { headers: { Authorization: `Bearer ${token}` } });
// 204 means "nothing playing"; anything else non-OK is a transient problem
// and must not be reported as a lost connection.
if (res.status === 204) return reply.code(200).send({ activity: null, connected: true });
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401 });
const track = await res.json() as SpotifyTrack;
return reply.code(200).send({ activity: toActivity(track), connected: true });
});
app.delete('/api/connections/spotify', { preHandler: authenticate }, async (request, reply) => {
const db = getDb();
db.delete(schema.spotifyConnections).where(eq(schema.spotifyConnections.userId, request.userId)).run();
return reply.code(204).send();
});
}
+94
View File
@@ -0,0 +1,94 @@
import type { FastifyInstance } from 'fastify';
import { and, eq, gte, sql, isNotNull, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { isMember } from '../utils/permissions.js';
/** Windows the UI offers. Anything else is clamped into this range. */
const DEFAULT_DAYS = 30;
const MAX_DAYS = 365;
interface Leader {
userId: string;
username: string;
displayName: string | null;
avatar: string | null;
value: number;
}
export async function statsRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string }; Querystring: { days?: string } }>(
'/api/spaces/:id/stats',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Any member may look: these are the group's own numbers, not moderation
// data. The audit log, which names who did what, stays admin-only.
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const days = Math.min(Math.max(Number(request.query.days) || DEFAULT_DAYS, 1), MAX_DAYS);
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDb();
// Voice time. Only closed sessions count: an open one has no duration
// yet, and counting "now - startedAt" would make the numbers move every
// time the page is refreshed.
const voiceRows = db.select({
userId: schema.voiceSessions.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`sum(${schema.voiceSessions.endedAt} - ${schema.voiceSessions.startedAt})`,
})
.from(schema.voiceSessions)
.innerJoin(schema.users, eq(schema.voiceSessions.userId, schema.users.id))
.where(and(
eq(schema.voiceSessions.spaceId, id),
gte(schema.voiceSessions.startedAt, since),
isNotNull(schema.voiceSessions.endedAt),
))
.groupBy(schema.voiceSessions.userId)
.all() as Leader[];
// Messages. Scoped through the space's channels — the messages table has
// no space column.
const channelIds = db.select({ id: schema.channels.id })
.from(schema.channels)
.where(eq(schema.channels.spaceId, id))
.all()
.map((c) => c.id);
const messageRows = channelIds.length === 0 ? [] : db.select({
userId: schema.messages.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`count(*)`,
})
.from(schema.messages)
.innerJoin(schema.users, eq(schema.messages.userId, schema.users.id))
.where(and(
inArray(schema.messages.channelId, channelIds),
gte(schema.messages.createdAt, since),
))
.groupBy(schema.messages.userId)
.all() as Leader[];
const byValueDesc = (a: Leader, b: Leader) => b.value - a.value;
return reply.code(200).send({
days,
since,
voice: voiceRows.sort(byValueDesc),
messages: messageRows.sort(byValueDesc),
totals: {
voiceMs: voiceRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
messages: messageRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
},
});
},
);
}
+36
View File
@@ -0,0 +1,36 @@
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
import type { AuditAction } from '@backspace/shared/src/audit.js';
interface RecordAuditEventInput {
spaceId: string;
actorId: string | null;
action: AuditAction;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
/**
* Appends one entry to a space's audit log.
*
* Never throws: an audit write must not be able to fail the action it is
* describing. A moderator kicking someone must not see the kick fail because
* the log could not be written — the kick already happened.
*/
export function recordAuditEvent(input: RecordAuditEventInput): void {
try {
getDb().insert(schema.auditEvents).values({
id: generateSnowflake(),
spaceId: input.spaceId,
actorId: input.actorId,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
createdAt: Date.now(),
}).run();
} catch (err) {
console.warn('[audit] failed to record event', input.action, err);
}
}
@@ -0,0 +1,65 @@
import { and, eq, isNull, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
/**
* Opens a session when someone joins a voice room.
*
* Never throws: statistics must not be able to break a call. Closes any
* dangling session for the same user first — the one-room-per-user invariant
* means a second open row would be a bookkeeping error, not two real calls.
*/
export function openVoiceSession(input: {
spaceId: string | null;
channelId: string;
userId: string;
}): void {
try {
const db = getDb();
closeVoiceSession(input.userId);
db.insert(schema.voiceSessions).values({
id: generateSnowflake(),
spaceId: input.spaceId,
channelId: input.channelId,
userId: input.userId,
startedAt: Date.now(),
endedAt: null,
}).run();
} catch (err) {
console.warn('[voice-sessions] failed to open session', err);
}
}
/** Closes the user's open session, if any. Never throws. */
export function closeVoiceSession(userId: string): void {
try {
getDb().update(schema.voiceSessions)
.set({ endedAt: Date.now() })
.where(and(eq(schema.voiceSessions.userId, userId), isNull(schema.voiceSessions.endedAt)))
.run();
} catch (err) {
console.warn('[voice-sessions] failed to close session', err);
}
}
/**
* Closes sessions left open by a crash or restart.
*
* Their real end time is unknowable. Ending them at `startedAt` — a zero-length
* session — discards that time rather than inventing it: crediting the gap
* would silently hand someone hours they never spent, and the numbers are the
* entire point of keeping this table.
*/
export function closeOrphanedVoiceSessions(): void {
try {
const result = getDb().update(schema.voiceSessions)
.set({ endedAt: sql`${schema.voiceSessions.startedAt}` })
.where(isNull(schema.voiceSessions.endedAt))
.run();
if (result.changes > 0) {
console.log(`[voice-sessions] closed ${result.changes} session(s) orphaned by a restart`);
}
} catch (err) {
console.warn('[voice-sessions] failed to close orphans', err);
}
}
+49 -1
View File
@@ -168,6 +168,10 @@ export function handleClientEvent(
case 'voice_join':
handleVoiceJoin(event, userId, ws);
break;
case 'soundboard_play':
handleSoundboardPlay(event, userId);
break;
case 'voice_leave':
handleVoiceLeave(userId);
break;
@@ -724,12 +728,14 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
// Join room
connectionManager.joinRoom(channelId, userId);
// Broadcast join
// Broadcast join. Carries when this occupancy began so late joiners show the
// call's real elapsed time rather than counting from their own arrival.
connectionManager.sendToRoom(channelId, {
type: 'voice_state_update',
channelId,
userId,
action: 'join',
startedAt: connectionManager.getRoomStartedAt(channelId) ?? undefined,
});
// Also broadcast current voice status if it exists (persisted during moves)
@@ -794,6 +800,48 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
}
}
/**
* Minimum gap between one person's soundboard triggers.
*
* Enforced on the server: a client-side cooldown only slows down people who
* are not trying to abuse it, and a soundboard is the easiest thing in a chat
* app to turn into a weapon.
*/
const SOUNDBOARD_COOLDOWN_MS = 2000;
const lastSoundboardPlay = new Map<string, number>();
function handleSoundboardPlay(event: Record<string, unknown>, userId: string): void {
const soundId = event.soundId;
if (typeof soundId !== 'string' || !soundId) return;
const now = Date.now();
const last = lastSoundboardPlay.get(userId) ?? 0;
if (now - last < SOUNDBOARD_COOLDOWN_MS) return;
// Must be in a voice room: a soundboard is something you press while in a
// call, not a way to make noise in a call you are not part of.
const userRoom = connectionManager.getUserRoom(userId);
if (!userRoom || userRoom.room.roomType !== 'space') return;
const sound = getDb().select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.id, soundId)).get();
if (!sound) return;
// And the sound must belong to the space whose call they are in.
const meta = userRoom.room.metadata as SpaceRoomMeta;
if (sound.spaceId !== meta.spaceId) return;
lastSoundboardPlay.set(userId, now);
connectionManager.sendToRoomParticipants(userRoom.roomId, {
type: 'soundboard_played',
soundId: sound.id,
userId,
name: sound.name,
filename: sound.filename,
});
}
function handleVoiceLeave(userId: string): void {
connectionManager.clearVoiceWs(userId);
const left = connectionManager.leaveCurrentRoom(userId);
+46 -1
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from 'fastify';
import type { WebSocket } from 'ws';
import { verifyJwt } from '../utils/auth.js';
import { openVoiceSession, closeVoiceSession } from '../utils/voiceSessions.js';
import { getDb, schema } from '../db/index.js';
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
import { handleClientEvent } from './events.js';
@@ -414,6 +415,7 @@ class ConnectionManager {
type: 'space_voice_state',
spaceId,
voiceStates: snapshot.voiceStates,
voiceRoomStarts: snapshot.voiceRoomStarts,
voiceUserStates: snapshot.voiceUserStates,
spaceVoiceStates: snapshot.spaceVoiceStates,
});
@@ -441,11 +443,13 @@ class ConnectionManager {
*/
buildSpaceVoiceState(spaceId: string, userId: string): {
voiceStates: Record<string, string[]>;
voiceRoomStarts: Record<string, number>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
} {
const db = getDb();
const voiceStates: Record<string, string[]> = {};
const voiceRoomStarts: Record<string, number> = {};
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
@@ -462,6 +466,8 @@ class ConnectionManager {
if (participants.size > 0) {
const ids = Array.from(participants);
voiceStates[ch.id] = ids;
const startedAt = this.getRoomStartedAt(ch.id);
if (startedAt !== null) voiceRoomStarts[ch.id] = startedAt;
for (const uid of ids) {
const status = this.getVoiceUserStatus(uid);
if (status) voiceUserStates[uid] = status;
@@ -499,7 +505,7 @@ class ConnectionManager {
}
}
return { voiceStates, voiceUserStates, spaceVoiceStates };
return { voiceStates, voiceRoomStarts, voiceUserStates, spaceVoiceStates };
}
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
@@ -714,6 +720,16 @@ class ConnectionManager {
room.participants.add(userId);
this.userToRoom.set(userId, roomId);
// Recorded here rather than at the seven call sites that lead into voice:
// every path — join, move, DM call, reconnect — funnels through this
// method, so hooking it cannot miss one.
openVoiceSession({
spaceId: room.roomType === 'space' ? (room.metadata as SpaceRoomMeta).spaceId : null,
channelId: roomId,
userId,
});
return room;
}
@@ -746,6 +762,8 @@ class ConnectionManager {
const room = this.leaveRoom(roomId, userId);
if (!room) return null;
closeVoiceSession(userId);
return { roomId, room };
}
@@ -757,6 +775,9 @@ class ConnectionManager {
const displaced: string[] = [];
for (const userId of room.participants) {
this.userToRoom.delete(userId);
// Destroying a room bypasses leaveCurrentRoom, so these sessions would
// otherwise stay open until the next restart swept them away.
closeVoiceSession(userId);
displaced.push(userId);
}
@@ -960,6 +981,30 @@ class ConnectionManager {
}
}
/**
* When the current occupancy of a room began. Null when nobody is in it —
* empty space rooms are destroyed, which is what makes the call timer reset
* once the last person leaves.
*/
getRoomStartedAt(roomId: string): number | null {
return this.voiceRooms.get(roomId)?.startedAt ?? null;
}
/**
* Send only to the people actually inside a room.
*
* Distinct from `sendToRoom`, which fans a space room out to the whole
* space — right for presence updates the sidebar shows, wrong for anything
* audible: a soundboard clip must reach the call, not everyone online.
*/
sendToRoomParticipants(roomId: string, event: ServerEvent): void {
const room = this.voiceRooms.get(roomId);
if (!room) return;
for (const userId of room.participants) {
this.sendToUser(userId, event);
}
}
/** Send to all connections of all online users. */
sendToAll(event: ServerEvent, excludeUserId?: string): void {
const message = JSON.stringify(event);
+3 -1
View File
@@ -14,7 +14,9 @@
"./src/activities": "./src/activities.ts",
"./src/activities.js": "./src/activities.ts",
"./src/constants": "./src/constants.ts",
"./src/constants.js": "./src/constants.ts"
"./src/constants.js": "./src/constants.ts",
"./src/audit": "./src/audit.ts",
"./src/audit.js": "./src/audit.ts"
},
"scripts": {
"build": "tsc",
+44
View File
@@ -0,0 +1,44 @@
/**
* Audit action vocabulary, shared so the server writes and the client renders
* the same strings. Values are stored in the database, so renaming one
* rewrites history — add new actions instead.
*/
export const AUDIT_ACTIONS = [
'space.update',
'space.transfer_ownership',
'channel.create',
'channel.update',
'channel.delete',
'member.kick',
'member.leave',
'member.ban',
'member.unban',
'role.create',
'role.update',
'role.delete',
'invite.create',
'message.delete',
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
export interface AuditEventActor {
id: string;
username: string;
displayName: string | null;
avatar: string | null;
}
export interface AuditEvent {
id: string;
spaceId: string;
action: AuditAction;
actor: AuditEventActor | null;
targetType: string | null;
targetId: string | null;
/** Shape depends on `action`; used for display only. */
metadata: Record<string, unknown> | null;
createdAt: number;
}
export const AUDIT_PAGE_SIZE = 50;
+5 -3
View File
@@ -403,6 +403,7 @@ export type ClientEvent =
| { type: 'typing_start'; channelId: string }
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
| { type: 'voice_join'; channelId: string }
| { type: 'soundboard_play'; soundId: string }
| { type: 'voice_leave' }
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
| { type: 'dm_typing_start'; dmChannelId: string }
@@ -426,13 +427,14 @@ export type ClientEvent =
// Server → Client Events
export type ServerEvent =
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[]; awaitingApprovalPeerOrigins?: string[]; activePeerOrigins?: string[]; pendingApprovalCount?: number }
| { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string }
| { type: 'typing'; channelId: string; userId: string; username: string }
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave' }
| { type: 'voice_state_update'; channelId: string; userId: string; action: 'join' | 'leave'; startedAt?: number }
| { type: 'soundboard_played'; soundId: string; userId: string; name: string; filename: string }
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
| { type: 'member_left'; spaceId: string; userId: string }
| { type: 'dm_message_created'; message: DmMessageWithUser }
@@ -451,7 +453,7 @@ export type ServerEvent =
| { type: 'dm_call_ended'; dmChannelId: string }
| { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] }
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
| { type: 'space_voice_state'; spaceId: string; voiceStates: Record<string, string[]>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
| { type: 'space_voice_state'; spaceId: string; voiceStates: Record<string, string[]>; voiceRoomStarts?: Record<string, number>; voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> }
| { type: 'dm_channel_created'; dmChannel: DmChannel }
| { type: 'dm_channel_closed'; dmChannelId: string }
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
+83
View File
@@ -71,7 +71,34 @@ import type {
AttachProofResponse,
ReattachRequest,
ReattachResponse,
Activity,
} from '@backspace/shared';
import type { AuditEvent } from '@backspace/shared/src/audit.js';
export interface SoundboardSound {
id: string;
spaceId: string;
name: string;
filename: string;
uploaderId: string | null;
createdAt: number;
}
export interface StatsLeader {
userId: string;
username: string;
displayName: string | null;
avatar: string | null;
value: number;
}
export interface SpaceStats {
days: number;
since: number;
voice: StatsLeader[];
messages: StatsLeader[];
totals: { voiceMs: number; messages: number };
}
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
@@ -279,6 +306,30 @@ export class BackspaceApiClient {
trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
search: (q: string, limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
enabled: () => Promise<{ enabled: boolean }>;
favorites: () => Promise<{ results: GifResult[] }>;
addFavorite: (gif: GifResult) => Promise<void>;
removeFavorite: (id: string) => Promise<void>;
};
readonly soundboard: {
list: (spaceId: string) => Promise<{ sounds: SoundboardSound[] }>;
add: (spaceId: string, name: string, filename: string) => Promise<SoundboardSound>;
remove: (soundId: string) => Promise<void>;
};
readonly stats: {
space: (spaceId: string, days: number) => Promise<SpaceStats>;
};
readonly audit: {
log: (spaceId: string, before?: string) => Promise<{ events: AuditEvent[]; hasMore: boolean }>;
};
readonly spotify: {
status: () => Promise<{ configured: boolean; connected: boolean }>;
authorizeUrl: () => Promise<{ url: string }>;
nowPlaying: () => Promise<{ activity: Activity | null; connected: boolean }>;
disconnect: () => Promise<void>;
};
readonly federation: {
@@ -688,6 +739,35 @@ export class BackspaceApiClient {
},
};
this.soundboard = {
list: (spaceId: string) => request<{ sounds: SoundboardSound[] }>('GET', `/spaces/${spaceId}/sounds`),
add: (spaceId: string, name: string, filename: string) =>
request<SoundboardSound>('POST', `/spaces/${spaceId}/sounds`, { name, filename }),
remove: (soundId: string) => request<void>('DELETE', `/sounds/${soundId}`),
};
this.stats = {
space: (spaceId: string, days: number) =>
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
};
this.audit = {
log: (spaceId: string, before?: string) => {
const params = new URLSearchParams();
if (before) params.set('before', before);
const qs = params.toString();
return request<{ events: AuditEvent[]; hasMore: boolean }>(
'GET', `/spaces/${spaceId}/audit-log${qs ? `?${qs}` : ''}`);
},
};
this.spotify = {
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
nowPlaying: () => request<{ activity: Activity | null; connected: boolean }>('GET', '/connections/spotify/now-playing'),
disconnect: () => request<void>('DELETE', '/connections/spotify'),
};
this.gif = {
trending: (limit = 30, pos?: string) => {
const params = new URLSearchParams();
@@ -695,6 +775,9 @@ export class BackspaceApiClient {
if (pos) params.set('pos', pos);
return request<{ results: GifResult[]; next: string }>('GET', `/gif/trending?${params}`);
},
favorites: () => request<{ results: GifResult[] }>('GET', '/gif/favorites'),
addFavorite: (gif: GifResult) => request<void>('POST', '/gif/favorites', gif),
removeFavorite: (id: string) => request<void>('DELETE', `/gif/favorites/${encodeURIComponent(id)}`),
search: (q: string, limit = 30, pos?: string) => {
const params = new URLSearchParams();
params.set('q', q);
+28
View File
@@ -185,6 +185,34 @@ export class AudioManager {
}
}
/**
* Plays a sound from an arbitrary URL (soundboard clips, which live in the
* upload directory rather than /sounds). Cached by URL like the built-in
* effects, so repeats do not re-download.
*/
async playUrl(url: string, options: { volume?: number } = {}): Promise<void> {
try {
const ctx = this.ensureContext();
await this.resumeContext();
let buffer = this.soundBuffers.get(url);
if (!buffer) {
const response = await fetch(url);
if (!response.ok) return;
buffer = await ctx.decodeAudioData(await response.arrayBuffer());
this.soundBuffers.set(url, buffer);
}
const source = ctx.createBufferSource();
source.buffer = buffer;
const gain = ctx.createGain();
gain.gain.value = options.volume ?? 1;
source.connect(gain);
gain.connect(this.getMasterOutput());
source.start(0);
} catch (err) {
console.warn('[AudioManager] playUrl failed', err);
}
}
async playSound(name: string, options: { loop?: boolean; volume?: number } = {}): Promise<AudioBufferSourceNode | null> {
await this.resumeContext();
const buffer = await this.loadSound(name);
+138 -23
View File
@@ -1,6 +1,21 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { api } from '../../api/client';
import type { GifResult } from '@backspace/shared';
import { useT, type TranslationKey } from '../../i18n';
/**
* Category shortcuts. The label is translated but the query is not: it is sent
* to the provider, which indexes in English — a translated query would return
* nothing.
*/
const CATEGORIES: { key: TranslationKey; query: string }[] = [
{ key: 'gif.category.hello', query: 'hello' },
{ key: 'gif.category.lol', query: 'lol' },
{ key: 'gif.category.love', query: 'love' },
{ key: 'gif.category.birthday', query: 'happy birthday' },
{ key: 'gif.category.dance', query: 'dance' },
{ key: 'gif.category.facepalm', query: 'facepalm' },
];
interface GifPickerProps {
onGifSelect: (url: string) => void;
@@ -12,6 +27,10 @@ interface GifPickerProps {
}
export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
const t = useT();
const [showFavorites, setShowFavorites] = useState(false);
const [favorites, setFavorites] = useState<GifResult[]>([]);
const [favoriteIds, setFavoriteIds] = useState<Set<string>>(new Set());
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState<GifResult[]>([]);
@@ -21,6 +40,46 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
// Favourites load once and are kept in memory: the picker is opened and
// closed constantly, and re-fetching on every open would be visible.
useEffect(() => {
let cancelled = false;
api.gif.favorites()
.then(({ results }) => {
if (cancelled) return;
setFavorites(results);
setFavoriteIds(new Set(results.map((g) => g.id)));
})
.catch(() => { /* favourites are an enhancement; browsing still works */ });
return () => { cancelled = true; };
}, []);
const toggleFavorite = async (gif: GifResult, e: React.MouseEvent) => {
// The tile behind this button inserts the GIF into the message.
e.stopPropagation();
const isFavorite = favoriteIds.has(gif.id);
// Optimistic: the star must feel instant. Reverted below if the call fails.
setFavoriteIds((prev) => {
const next = new Set(prev);
if (isFavorite) next.delete(gif.id); else next.add(gif.id);
return next;
});
setFavorites((prev) => (isFavorite ? prev.filter((g) => g.id !== gif.id) : [gif, ...prev]));
try {
if (isFavorite) await api.gif.removeFavorite(gif.id);
else await api.gif.addFavorite(gif);
} catch {
setFavoriteIds((prev) => {
const next = new Set(prev);
if (isFavorite) next.add(gif.id); else next.delete(gif.id);
return next;
});
setFavorites((prev) => (isFavorite ? [gif, ...prev] : prev.filter((g) => g.id !== gif.id)));
}
};
// Debounce search query
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -60,6 +119,8 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
// Infinite scroll
const handleScroll = useCallback(() => {
const el = scrollRef.current;
// Favourites are a complete local list — nothing to page through.
if (showFavorites) return;
if (!el || loadingMore || !nextPos) return;
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
setLoadingMore(true);
@@ -76,13 +137,16 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
};
fetchMore();
}
}, [loadingMore, nextPos, debouncedQuery]);
}, [loadingMore, nextPos, debouncedQuery, showFavorites]);
// Prevent keyboard events from bubbling
const handleKeyDown = (e: React.KeyboardEvent) => {
e.stopPropagation();
};
// Favourites are a local list; browsing results come from the provider.
const shown = showFavorites ? favorites : results;
// Mobile: fill parent (sheet sets width + max-height). Desktop: fixed dims
// matching the legacy popover footprint.
const rootClass = mobile
@@ -97,7 +161,7 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search GIFs"
placeholder={t('gif.search')}
className="input-search w-full"
// Auto-focus only on desktop. On mobile this would force the OS
// keyboard up the moment the sheet opens, hiding most of the grid.
@@ -105,13 +169,41 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
/>
</div>
{/* Category shortcuts */}
<div className="flex gap-1.5 px-3 pb-2 overflow-x-auto no-scrollbar shrink-0">
<button
type="button"
onClick={() => setShowFavorites((v) => !v)}
className={`px-2.5 py-1 rounded-full text-[12px] font-medium whitespace-nowrap transition-colors flex items-center gap-1 ${
showFavorites
? 'bg-accent-primary text-white'
: 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
}`}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
{t('gif.tab.favorites')}
</button>
{CATEGORIES.map((category) => (
<button
key={category.query}
type="button"
onClick={() => { setShowFavorites(false); setQuery(category.query); }}
className="px-2.5 py-1 rounded-full text-[12px] font-medium whitespace-nowrap bg-surface-elevated text-txt-secondary hover:text-txt-primary transition-colors"
>
{t(category.key)}
</button>
))}
</div>
{/* Results grid */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
onScroll={handleScroll}
>
{loading ? (
{loading && !showFavorites ? (
<div className="grid grid-cols-2 gap-1.5 p-1">
{Array.from({ length: 6 }).map((_, i) => (
<div
@@ -121,29 +213,52 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
/>
))}
</div>
) : results.length === 0 ? (
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
{debouncedQuery.trim() ? 'No GIFs found' : 'No trending GIFs'}
) : shown.length === 0 ? (
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm text-center px-4">
{showFavorites
? t('gif.empty.favorites')
: debouncedQuery.trim()
? t('gif.empty.search')
: t('gif.empty.trending')}
</div>
) : (
<div className="columns-2 gap-1.5 p-1">
{results.map((gif) => (
<button
key={gif.id}
onClick={() => onGifSelect(gif.url)}
className="w-full mb-1.5 rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all break-inside-avoid"
>
<img
src={gif.previewUrl}
alt={gif.title}
className="w-full object-cover rounded-lg"
loading="lazy"
style={{
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
}}
/>
</button>
))}
{shown.map((gif) => {
const isFavorite = favoriteIds.has(gif.id);
return (
// The star cannot live inside the tile button — a button inside
// a button is invalid and swallows the click. Siblings instead.
<div key={gif.id} className="relative group w-full mb-1.5 break-inside-avoid">
<button
onClick={() => onGifSelect(gif.url)}
className="w-full rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all block"
>
<img
src={gif.previewUrl}
alt={gif.title}
className="w-full object-cover rounded-lg"
loading="lazy"
style={{
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
}}
/>
</button>
<button
type="button"
onClick={(e) => void toggleFavorite(gif, e)}
title={isFavorite ? t('gif.favorite.remove') : t('gif.favorite.add')}
aria-label={isFavorite ? t('gif.favorite.remove') : t('gif.favorite.add')}
className={`absolute top-1.5 right-1.5 w-7 h-7 rounded-full flex items-center justify-center bg-black/55 backdrop-blur-sm transition-opacity ${
isFavorite ? 'opacity-100 text-accent-amber' : 'opacity-0 group-hover:opacity-100 focus:opacity-100 text-white'
}`}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill={isFavorite ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
<path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
</svg>
</button>
</div>
);
})}
</div>
)}
{loadingMore && (
@@ -0,0 +1,120 @@
import { useEffect, useRef, useState } from 'react';
import type { UserStatus } from '@backspace/shared';
import { useAuthStore } from '../../stores/authStore';
import { useT, type TranslationKey } from '../../i18n';
interface AccountMenuProps {
onClose: () => void;
onEditProfile: () => void;
}
const STATUSES: { value: UserStatus; key: TranslationKey; dot: string }[] = [
{ value: 'online', key: 'accountMenu.status.online', dot: 'bg-status-online' },
{ value: 'idle', key: 'accountMenu.status.idle', dot: 'bg-status-idle' },
{ value: 'dnd', key: 'accountMenu.status.dnd', dot: 'bg-status-dnd' },
// 'offline' chosen deliberately is what other clients call invisible.
{ value: 'offline', key: 'accountMenu.status.offline', dot: 'bg-txt-tertiary' },
];
export function AccountMenu({ onClose, onEditProfile }: AccountMenuProps) {
const t = useT();
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handlePointer = (e: MouseEvent | TouchEvent) => {
if (!menuRef.current?.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); onClose(); }
};
// touchstart alongside mousedown: iOS Safari does not reliably synthesise
// mousedown from a tap, matching what the other popovers here do.
document.addEventListener('mousedown', handlePointer);
document.addEventListener('touchstart', handlePointer);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('touchstart', handlePointer);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
if (!user) return null;
const handleStatus = async (status: UserStatus) => {
if (status === (user.status ?? 'online')) return onClose();
try {
await updateProfile({ status });
} finally {
onClose();
}
};
const handleCopyId = async () => {
try {
await navigator.clipboard.writeText(user.id);
setCopied(true);
// Left open on purpose: the confirmation is the only feedback, and
// closing immediately would hide it.
setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard is unavailable over plain http or without permission.
}
};
return (
<div
ref={menuRef}
role="menu"
className="absolute bottom-full left-2 right-2 mb-2 z-[200] glass rounded-xl overflow-hidden py-1.5 shadow-xl"
>
<button
role="menuitem"
onClick={() => { onEditProfile(); onClose(); }}
className="w-full px-3 py-2 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25ZM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83Z" />
</svg>
{t('accountMenu.editProfile')}
</button>
<div className="h-px bg-white/[0.06] my-1.5 mx-2" />
<div className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-wider text-txt-tertiary">
{t('accountMenu.status')}
</div>
{STATUSES.map((option) => (
<button
key={option.value}
role="menuitemradio"
aria-checked={(user.status ?? 'online') === option.value}
onClick={() => void handleStatus(option.value)}
className="w-full px-3 py-1.5 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
>
<span className={`w-2.5 h-2.5 rounded-full ${option.dot}`} />
<span className="flex-1 text-left">{t(option.key)}</span>
{(user.status ?? 'online') === option.value && (
<svg width="14" height="14" 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 className="h-px bg-white/[0.06] my-1.5 mx-2" />
<button
role="menuitem"
onClick={() => void handleCopyId()}
className="w-full px-3 py-2 flex items-center gap-2.5 text-[13.5px] text-txt-secondary hover:bg-interactive-hover hover:text-txt-primary transition-colors"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1Zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm0 16H8V7h11v14Z" />
</svg>
{copied ? t('accountMenu.copied') : t('accountMenu.copyId')}
</button>
</div>
);
}
@@ -22,6 +22,7 @@ import { UserProfileModal } from '../modals/UserProfileModal';
import { IncomingCallModal } from '../voice/IncomingCallModal';
import { PictureInPicture } from '../voice/PictureInPicture';
import { SoundController } from '../voice/SoundController';
import { useSpotifyActivity } from '../../hooks/useSpotifyActivity';
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
import { NotificationController } from '../NotificationController';
import { UserProfilePopout } from '../ui/UserProfilePopout';
@@ -215,6 +216,7 @@ export function AppLayout() {
const showBootSkeleton = useDelayedLoading(isLoading);
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
useSpotifyActivity();
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const loadMessages = useChatStore((s) => s.loadMessages);
const setIsMobile = useUIStore((s) => s.setIsMobile);
@@ -7,6 +7,7 @@ import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { VoiceChannel } from '../voice/VoiceChannel';
import { AccountMenu } from './AccountMenu';
import { VoiceControls } from '../voice/VoiceControls';
import { useVoiceStore } from '../../stores/voiceStore';
import { ProfileAvatar } from '../ui/ProfileAvatar';
@@ -844,6 +845,7 @@ function UserAreaPanel({
onDeafenToggle: () => void;
onSettingsClick: (tab?: string) => void;
}) {
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
@@ -1132,14 +1134,27 @@ function UserAreaPanel({
)}
{/* User area bar */}
<div className="h-[52px] px-2 flex items-center select-none">
{/* Avatar + name */}
<div className="p-1 hover:bg-interactive-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group">
<div className="relative h-[52px] px-2 flex items-center select-none">
{accountMenuOpen && (
<AccountMenu
onClose={() => setAccountMenuOpen(false)}
onEditProfile={() => onSettingsClick('account')}
/>
)}
{/* Avatar + name. The avatar opens the profile card (ProfileAvatar);
the name opens the account menu — which is what the cursor here has
been promising all along without anything happening. */}
<div className="p-1 hover:bg-interactive-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 transition-colors group">
<ProfileAvatar src={user.avatar} name={user.displayName ?? user.username} size={34} status={user.status} user={user} />
<div className="flex-1 min-w-0">
<button
onClick={() => setAccountMenuOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={accountMenuOpen}
className="flex-1 min-w-0 text-left cursor-pointer"
>
<div className="text-[13.5px] font-semibold text-txt-primary truncate leading-tight">{user.displayName ?? user.username}</div>
<div className="text-[11px] text-txt-tertiary truncate leading-tight group-hover:text-txt-secondary">@{user.username}</div>
</div>
</button>
</div>
{/* Controls */}
@@ -7,6 +7,9 @@ import { Avatar } from '../ui/Avatar';
import { api } from '../../api/client';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
import { useT } from '../../i18n';
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
import { BansPanel } from './spaceSettingsPanels/BansPanel';
@@ -266,7 +269,8 @@ export function SpaceSettingsModal() {
const spaces = useSpaceStore((s) => s.spaces);
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans'>('overview');
const t = useT();
const [tab, setTab] = useState<'overview' | 'discovery' | 'members' | 'roles' | 'bans' | 'audit' | 'stats'>('overview');
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
const isOpen = activeModal === 'spaceSettings';
@@ -331,6 +335,10 @@ export function SpaceSettingsModal() {
{canBanMembers && (
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
)}
{canManageSpace && (
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
</div>
</div>
@@ -366,6 +374,10 @@ export function SpaceSettingsModal() {
{canBanMembers && (
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
)}
{canManageSpace && (
<button onClick={() => handleTabClick('audit')} className={tabClass('audit')}>{t('audit.title')}</button>
)}
<button onClick={() => handleTabClick('stats')} className={tabClass('stats')}>{t('stats.title')}</button>
</div>
</div>
)}
@@ -392,6 +404,8 @@ export function SpaceSettingsModal() {
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
</div>
</div>
)}
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { ProfileActivity } from '../ui/ProfileActivity';
import { useT } from '../../i18n';
import { useActivityStore } from '../../stores/activityStore';
import { Username } from '../ui/Username';
import { useUIStore } from '../../stores/uiStore';
@@ -66,6 +67,7 @@ export function UserProfileModal() {
const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest);
const currentUser = useAuthStore((s) => s.user);
const t = useT();
const [user, setUser] = useState<User | null>(null);
const [userOrigin, setUserOrigin] = useState('');
const [activeTab, setActiveTab] = useState<Tab>('about');
@@ -352,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
@@ -376,7 +378,7 @@ export function UserProfileModal() {
{/* 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, {
@@ -519,7 +521,7 @@ export function UserProfileModal() {
onClick={handleSendMessage}
className="flex-1 py-2 rounded-lg text-[13px] font-medium text-white bg-accent-primary hover:bg-accent-primary/80 transition-colors"
>
Send Message
{t('profile.sendMessage')}
</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 &amp; 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 &amp; 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>
@@ -3,12 +3,14 @@ import { useVoiceStore } from '../../../stores/voiceStore';
import { AudioManager } from '../../../audio/AudioManager';
import { useAudioDevices } from '../../../hooks/useAudioDevices';
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
import { useT } from '../../../i18n';
export function AudioInputSection() {
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const setInputDevice = useVoiceStore((s) => s.setInputDevice);
const inputVolume = useVoiceStore((s) => s.inputVolume);
const setInputVolume = useVoiceStore((s) => s.setInputVolume);
const t = useT();
const { permState, inputs, inputLabels, requestPermission } = useAudioDevices();
const [listOpen, setListOpen] = useState(false);
@@ -96,7 +98,7 @@ export function AudioInputSection() {
setMicTestError('');
const ok = await am.startMicTest();
if (!ok) {
setMicTestError('Could not open the microphone. Check the device and its permission.');
setMicTestError(t('settings.voice.micTest.failed'));
return;
}
setMicTesting(true);
@@ -125,7 +127,7 @@ export function AudioInputSection() {
if (permState === 'unknown') {
return (
<SectionShell title="Input Device">
<SectionShell title={t('settings.voice.input.title')}>
<div className="text-sm text-txt-tertiary">Checking microphone access</div>
</SectionShell>
);
@@ -133,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">
@@ -152,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.
@@ -186,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
@@ -223,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
@@ -258,14 +260,14 @@ export function AudioInputSection() {
: 'bg-accent-primary text-white hover:brightness-110'
}`}
>
{micTesting ? 'Stop Testing' : "Let's Check"}
{micTesting ? t('settings.voice.micTest.stop') : t('settings.voice.micTest.start')}
</button>
<span className="text-xs text-txt-tertiary">
{micTesting
? 'Playing your mic back to you — say something.'
? t('settings.voice.micTest.playing')
: isLiveKitConnected
? 'The level meter is live while you are in a call.'
: 'Test your mic without joining a call.'}
? t('settings.voice.micTest.inCall')
: t('settings.voice.micTest.idle')}
</span>
</div>
{micTestError && (
@@ -1,10 +1,111 @@
import { ConnectedInstances } from '../ConnectedInstances';
import { useEffect, useState } from 'react';
import { api } from '../../../api/client';
import { useT, type TranslationKey } from '../../../i18n';
/** Errors the OAuth callback can hand back in the URL. */
const CALLBACK_ERRORS = ['denied', 'invalid_state', 'exchange_failed'] as const;
type CallbackError = (typeof CALLBACK_ERRORS)[number];
function readCallbackResult(): CallbackError | 'connected' | null {
if (typeof window === 'undefined') return null;
const value = new URLSearchParams(window.location.search).get('spotify');
if (value === 'connected') return 'connected';
return CALLBACK_ERRORS.includes(value as CallbackError) ? (value as CallbackError) : null;
}
export function ConnectionsPanel() {
const t = useT();
const [configured, setConfigured] = useState(true);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
const [callbackError, setCallbackError] = useState<CallbackError | null>(null);
useEffect(() => {
const result = readCallbackResult();
if (result && result !== 'connected') setCallbackError(result);
// Drop the parameter so a refresh does not replay the old outcome.
if (result && typeof window !== 'undefined') {
const url = new URL(window.location.href);
url.searchParams.delete('spotify');
window.history.replaceState({}, '', url.toString());
}
}, []);
useEffect(() => {
let cancelled = false;
api.spotify.status()
.then((s) => { if (!cancelled) { setConfigured(s.configured); setConnected(s.connected); } })
.catch(() => { /* leave the panel in its default state */ });
return () => { cancelled = true; };
}, []);
const handleConnect = async () => {
setBusy(true);
try {
const { url } = await api.spotify.authorizeUrl();
window.location.href = url;
} catch {
setBusy(false);
}
};
const handleDisconnect = async () => {
setBusy(true);
try {
await api.spotify.disconnect();
setConnected(false);
} finally {
setBusy(false);
}
};
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Connections</h2>
<ConnectedInstances />
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('connections.title')}</h2>
<div className="rounded-lg bg-surface-elevated/40 p-4">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<div className="flex items-center gap-2">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-accent-mint flex-shrink-0" aria-hidden="true">
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm4.586 14.424a.623.623 0 0 1-.857.207c-2.348-1.435-5.304-1.76-8.785-.964a.623.623 0 1 1-.277-1.215c3.809-.871 7.077-.496 9.712 1.115a.623.623 0 0 1 .207.857Zm1.223-2.722a.78.78 0 0 1-1.072.257c-2.687-1.652-6.785-2.131-9.965-1.166a.78.78 0 1 1-.452-1.492c3.632-1.102 8.147-.568 11.232 1.329a.78.78 0 0 1 .257 1.072Zm.105-2.835c-3.223-1.914-8.54-2.09-11.617-1.156a.935.935 0 1 1-.542-1.79c3.532-1.072 9.404-.865 13.115 1.338a.935.935 0 0 1-.956 1.608Z" />
</svg>
<span className="text-[15px] font-semibold text-txt-primary">Spotify</span>
{connected && (
<span className="text-[11px] px-1.5 py-0.5 rounded bg-status-online/15 text-status-online font-medium">
{t('connections.spotify.connected')}
</span>
)}
</div>
<p className="text-[13px] text-txt-secondary mt-1">{t('connections.spotify.description')}</p>
<p className="text-[12px] text-txt-tertiary mt-1">{t('connections.spotify.hint')}</p>
</div>
{configured && (
<button
type="button"
onClick={() => void (connected ? handleDisconnect() : handleConnect())}
disabled={busy}
className={`px-3 py-1.5 rounded-md text-[13px] font-medium flex-shrink-0 transition-colors disabled:opacity-50 ${
connected
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
: 'bg-accent-primary text-white hover:brightness-110'
}`}
>
{connected ? t('connections.spotify.disconnect') : t('connections.spotify.connect')}
</button>
)}
</div>
{!configured && (
<p className="text-[12px] text-txt-tertiary mt-3">{t('connections.spotify.notConfigured')}</p>
)}
{callbackError && (
<p className="text-[12px] text-txt-danger mt-3">
{t(`connections.spotify.error.${callbackError}` as TranslationKey)}
</p>
)}
</div>
</div>
);
}
@@ -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>
);
}
@@ -3,8 +3,10 @@ import { useAuthStore } from '../../../stores/authStore';
import { useActivityStore } from '../../../stores/activityStore';
import { api } from '../../../api/client';
import { Toggle } from '../../ui/Toggle';
import { useT } from '../../../i18n';
export function PrivacyPanel() {
const t = useT();
const user = useAuthStore((s) => s.user);
const setUser = useAuthStore((s) => s.setUser);
const showActivity = useActivityStore((s) => s.showActivity);
@@ -31,7 +33,7 @@ export function PrivacyPanel() {
return (
<div className="space-y-5">
<h2 className="text-lg font-semibold text-txt-primary mb-6">Privacy</h2>
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('privacy.title')}</h2>
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Discovery
@@ -39,9 +41,9 @@ export function PrivacyPanel() {
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="flex items-center justify-between py-1">
<div className="flex-1 mr-4">
<div className="text-sm text-txt-primary">Allow others to find my profile</div>
<div className="text-sm text-txt-primary">{t('privacy.discoverable.label')}</div>
<div className="text-xs text-txt-tertiary mt-0.5">
When enabled, your profile appears in Discover People. Others can always add you by exact username.
{t('privacy.discoverable.description')}
</div>
</div>
<Toggle enabled={discoverable} onChange={handleToggle} />
@@ -60,9 +62,9 @@ export function PrivacyPanel() {
<div className="rounded-lg bg-white/[0.03] border border-white/[0.04] p-3.5">
<div className="flex items-center justify-between py-1">
<div className="flex-1 mr-4">
<div className="text-sm text-txt-primary">Share Activity Status</div>
<div className="text-sm text-txt-primary">{t('privacy.activity.label')}</div>
<div className="text-xs text-txt-tertiary mt-0.5">
Allow others to see what you're up to, like games you're playing or music you're listening to.
{t('privacy.activity.description')}
</div>
</div>
<Toggle
@@ -0,0 +1,132 @@
import { useEffect, useState, useCallback } from 'react';
import type { AuditEvent } from '@backspace/shared/src/audit.js';
import { api } from '../../../api/client';
import { Avatar } from '../../ui/Avatar';
import { useT, type TranslationKey } from '../../../i18n';
interface AuditLogPanelProps {
spaceId: string;
}
/** Actions carry a `{name}` only when the metadata supplies one. */
function actionKey(action: string): TranslationKey {
const key = `audit.action.${action}` as TranslationKey;
return key;
}
const KNOWN_ACTIONS = new Set([
'space.update', 'space.transfer_ownership',
'channel.create', 'channel.update', 'channel.delete',
'member.kick', 'member.leave', 'member.ban', 'member.unban',
'role.create', 'role.update', 'role.delete',
'invite.create', 'message.delete',
]);
function formatTimestamp(ms: number, locale: string): string {
return new Date(ms).toLocaleString(locale, {
day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit',
});
}
export function AuditLogPanel({ spaceId }: AuditLogPanelProps) {
const t = useT();
const [events, setEvents] = useState<AuditEvent[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const load = useCallback(async (before?: string) => {
const page = await api.audit.log(spaceId, before);
setEvents((prev) => (before ? [...prev, ...page.events] : page.events));
setHasMore(page.hasMore);
}, [spaceId]);
useEffect(() => {
let cancelled = false;
setLoading(true);
load()
.catch(() => { /* an empty log reads the same as an unreachable one here */ })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [load]);
const handleLoadMore = async () => {
const last = events[events.length - 1];
if (!last) return;
setLoadingMore(true);
try {
await load(last.id);
} finally {
setLoadingMore(false);
}
};
if (loading) {
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('audit.title')}</h2>
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-12 rounded-lg bg-surface-elevated animate-pulse" />
))}
</div>
</div>
);
}
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('audit.title')}</h2>
{events.length === 0 ? (
<p className="text-[13px] text-txt-tertiary">{t('audit.empty')}</p>
) : (
<>
<ul className="space-y-1">
{events.map((event) => {
const actorName = event.actor
? (event.actor.displayName ?? event.actor.username)
: t('audit.unknownActor');
const name = typeof event.metadata?.name === 'string' ? event.metadata.name : '';
// An action this build does not know about still gets a row: the
// log is a record, and hiding entries would defeat its purpose.
const key = KNOWN_ACTIONS.has(event.action) ? actionKey(event.action) : 'audit.action.unknown';
return (
<li key={event.id} className="flex items-center gap-3 py-2 px-2 rounded-lg hover:bg-interactive-hover">
<Avatar
src={event.actor?.avatar ?? null}
name={actorName}
size={28}
userId={event.actor?.id}
/>
<div className="min-w-0 flex-1">
<div className="text-[13px] text-txt-secondary truncate">
{t(key, { actor: actorName, name })}
</div>
</div>
<time
dateTime={new Date(event.createdAt).toISOString()}
className="text-[11px] text-txt-tertiary flex-shrink-0 tabular-nums"
>
{formatTimestamp(event.createdAt, document.documentElement.lang || 'en')}
</time>
</li>
);
})}
</ul>
{hasMore && (
<button
type="button"
onClick={() => void handleLoadMore()}
disabled={loadingMore}
className="mt-4 px-3 py-1.5 rounded-md text-[13px] font-medium bg-surface-elevated text-txt-secondary hover:text-txt-primary transition-colors disabled:opacity-50"
>
{t('audit.loadMore')}
</button>
)}
</>
)}
</div>
);
}
@@ -0,0 +1,142 @@
import { useEffect, useState } from 'react';
import { api, type SpaceStats, type StatsLeader } from '../../../api/client';
import { Avatar } from '../../ui/Avatar';
import { useT, type TranslationKey } from '../../../i18n';
interface StatsPanelProps {
spaceId: string;
}
const RANGES: { days: number; key: TranslationKey }[] = [
{ days: 7, key: 'stats.range.7' },
{ days: 30, key: 'stats.range.30' },
{ days: 365, key: 'stats.range.365' },
];
function Leaderboard({
title,
rows,
format,
}: {
title: string;
rows: StatsLeader[];
format: (value: number) => string;
}) {
// The bar is relative to the leader, not to the total: with five people the
// share of a total is tiny and every bar looks the same.
const max = rows.length > 0 ? Math.max(...rows.map((r) => r.value)) : 0;
return (
<div>
<h3 className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary mb-2">{title}</h3>
<ul className="space-y-1.5">
{rows.map((row) => (
<li key={row.userId} className="flex items-center gap-3">
<Avatar src={row.avatar} name={row.displayName ?? row.username} size={26} userId={row.userId} />
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-[13px] text-txt-secondary truncate">
{row.displayName ?? row.username}
</span>
<span className="text-[12px] text-txt-tertiary tabular-nums flex-shrink-0">
{format(row.value)}
</span>
</div>
<div className="h-[3px] rounded-full bg-interactive-muted mt-1 overflow-hidden">
<div
className="h-full bg-accent-primary rounded-full"
style={{ width: max > 0 ? `${(row.value / max) * 100}%` : '0%' }}
/>
</div>
</div>
</li>
))}
</ul>
</div>
);
}
export function StatsPanel({ spaceId }: StatsPanelProps) {
const t = useT();
const [days, setDays] = useState(30);
const [stats, setStats] = useState<SpaceStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
setLoading(true);
api.stats.space(spaceId, days)
.then((data) => { if (!cancelled) setStats(data); })
.catch(() => { if (!cancelled) setStats(null); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [spaceId, days]);
const formatDuration = (ms: number) => {
const minutes = Math.round(ms / 60000);
const hours = Math.floor(minutes / 60);
return hours > 0
? t('stats.hours', { hours, minutes: minutes % 60 })
: t('stats.minutes', { minutes });
};
const isEmpty = !stats || (stats.voice.length === 0 && stats.messages.length === 0);
return (
<div className="max-w-2xl">
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('stats.title')}</h2>
<div className="flex gap-1.5 mb-5">
{RANGES.map((range) => (
<button
key={range.days}
type="button"
onClick={() => setDays(range.days)}
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
days === range.days
? 'bg-accent-primary text-white'
: 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
}`}
>
{t(range.key)}
</button>
))}
</div>
{loading ? (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-10 rounded-lg bg-surface-elevated animate-pulse" />
))}
</div>
) : isEmpty ? (
<p className="text-[13px] text-txt-tertiary">{t('stats.empty')}</p>
) : (
<div className="space-y-6">
{stats.voice.length > 0 && (
<div>
<Leaderboard title={t('stats.voice.title')} rows={stats.voice} format={formatDuration} />
<p className="text-[11px] text-txt-tertiary mt-2">
{t('stats.total.voice', { value: formatDuration(stats.totals.voiceMs) })}
</p>
</div>
)}
{stats.messages.length > 0 && (
<div>
<Leaderboard
title={t('stats.messages.title')}
rows={stats.messages}
format={(value) => String(value)}
/>
<p className="text-[11px] text-txt-tertiary mt-2">
{t('stats.total.messages', { value: stats.totals.messages })}
</p>
</div>
)}
</div>
)}
<p className="text-[11px] text-txt-tertiary mt-6">{t('stats.note')}</p>
</div>
);
}
@@ -1,17 +1,17 @@
import { useEffect, useState } from 'react';
import type { Activity } from '@backspace/shared';
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
import { useT, type TranslationKey } from '../../i18n';
interface ProfileActivityProps {
activities: Activity[];
}
const VERB: Record<Activity['type'], string> = {
playing: 'Playing',
listening: 'Listening to',
watching: 'Watching',
streaming: 'Streaming',
custom: '',
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 {
@@ -34,6 +34,7 @@ function formatClock(ms: number): string {
* producer would fill in.
*/
export function ProfileActivity({ activities }: ProfileActivityProps) {
const t = useT();
const primary = getPrimaryActivity(activities);
const start = primary?.timestamps?.start;
const end = primary?.timestamps?.end;
@@ -60,7 +61,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
return (
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
{VERB[primary.type]} {primary.name}
{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 && (
@@ -96,7 +97,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
</div>
) : start ? (
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
{formatClock(elapsed)} elapsed
{t('profile.activity.elapsed', { time: formatClock(elapsed) })}
</div>
) : null}
</div>
@@ -0,0 +1,38 @@
import { useEffect, useState } from 'react';
interface CallTimerProps {
startedAt: number;
className?: string;
}
function format(elapsedMs: number): string {
const total = Math.max(0, Math.floor(elapsedMs / 1000));
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
const seconds = total % 60;
const pad = (n: number) => String(n).padStart(2, '0');
return hours > 0 ? `${hours}:${pad(minutes)}:${pad(seconds)}` : `${minutes}:${pad(seconds)}`;
}
/**
* How long the current call has been running.
*
* `startedAt` comes from the server, so everyone sees the same figure and a
* late joiner sees the call's age rather than their own. The server destroys an
* empty room, so the next call starts from zero on its own.
*/
export function CallTimer({ startedAt, className = '' }: CallTimerProps) {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
// Aligned to the next whole second so the digits do not visibly stutter.
const timeout = setTimeout(() => setNow(Date.now()), 1000 - (Date.now() % 1000));
return () => clearTimeout(timeout);
}, [now]);
return (
<span className={`tabular-nums ${className}`} title={new Date(startedAt).toLocaleTimeString()}>
{format(now - startedAt)}
</span>
);
}
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from 'react';
import { api, type SoundboardSound } from '../../api/client';
import { wsSend } from '../../hooks/useWebSocket';
import { useTransferStore } from '../../stores/transferStore';
import { waitForTransferAttachment } from '../../utils/waitForTransfer';
import { useT } from '../../i18n';
interface SoundboardPopoverProps {
spaceId: string;
canManage: boolean;
onClose: () => void;
}
/** Clips are short gags; anything larger is a music file in disguise. */
const MAX_SOUND_BYTES = 2 * 1024 * 1024;
export function SoundboardPopover({ spaceId, canManage, onClose }: SoundboardPopoverProps) {
const t = useT();
const [sounds, setSounds] = useState<SoundboardSound[]>([]);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState('');
const fileRef = useRef<HTMLInputElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
let cancelled = false;
api.soundboard.list(spaceId)
.then(({ sounds: list }) => { if (!cancelled) setSounds(list); })
.catch(() => { /* an empty board is the honest fallback */ });
return () => { cancelled = true; };
}, [spaceId]);
useEffect(() => {
const handlePointer = (e: MouseEvent | TouchEvent) => {
if (!panelRef.current?.contains(e.target as Node)) onClose();
};
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
document.addEventListener('mousedown', handlePointer);
document.addEventListener('touchstart', handlePointer);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('mousedown', handlePointer);
document.removeEventListener('touchstart', handlePointer);
document.removeEventListener('keydown', handleKey);
};
}, [onClose]);
// Fire and forget: the server echoes the clip back to everyone in the call,
// this client included, so the presser hears exactly what the others hear —
// including the server's refusal when the cooldown is still running.
const play = (soundId: string) => wsSend({ type: 'soundboard_play', soundId });
const handleFile = async (file: File) => {
setError('');
if (file.size > MAX_SOUND_BYTES) {
setError(t('soundboard.tooLarge'));
return;
}
const name = window.prompt(t('soundboard.namePrompt'), file.name.replace(/\.[^.]+$/, ''));
if (!name) return;
setUploading(true);
try {
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
const { filename } = await waitForTransferAttachment(tid);
const created = await api.soundboard.add(spaceId, name, filename);
setSounds((prev) => [...prev, created]);
} catch {
// Distinct from the size check above: reporting every failure as "too
// large" sends people to shrink a file that was never the problem.
setError(t('soundboard.uploadFailed'));
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = '';
}
};
const handleRemove = async (soundId: string) => {
const previous = sounds;
setSounds((prev) => prev.filter((s) => s.id !== soundId));
try {
await api.soundboard.remove(soundId);
} catch {
setSounds(previous);
}
};
return (
<div
ref={panelRef}
className="absolute bottom-full left-2 right-2 mb-2 z-[200] glass rounded-xl overflow-hidden p-3 shadow-xl"
>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold uppercase tracking-wider text-txt-tertiary">
{t('soundboard.title')}
</span>
{canManage && (
<>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="text-[11px] text-accent-primary hover:underline disabled:opacity-50"
>
{uploading ? t('soundboard.adding') : t('soundboard.add')}
</button>
<input
ref={fileRef}
type="file"
accept="audio/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void handleFile(file);
}}
/>
</>
)}
</div>
{error && <div className="text-[11px] text-txt-danger mb-2">{error}</div>}
{sounds.length === 0 ? (
<p className="text-[12px] text-txt-tertiary py-2">{t('soundboard.empty')}</p>
) : (
<div className="grid grid-cols-3 gap-1.5 max-h-[220px] overflow-y-auto scrollbar-thin">
{sounds.map((sound) => (
<div key={sound.id} className="relative group">
<button
type="button"
onClick={() => play(sound.id)}
className="w-full px-2 py-2.5 rounded-lg bg-surface-elevated text-txt-secondary hover:text-txt-primary hover:brightness-125 transition-all text-[11px] font-medium truncate"
title={sound.name}
>
{sound.name}
</button>
{canManage && (
<button
type="button"
onClick={() => void handleRemove(sound.id)}
title={t('soundboard.remove')}
aria-label={t('soundboard.remove')}
className="absolute -top-1 -right-1 w-4 h-4 rounded-full bg-accent-rose text-white text-[10px] leading-none opacity-0 group-hover:opacity-100 transition-opacity"
>
×
</button>
)}
</div>
))}
</div>
)}
</div>
);
}
@@ -1,5 +1,6 @@
import React, { useCallback, useMemo } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { CallTimer } from './CallTimer';
import { useSpaceStore } from '../../stores/spaceStore';
import { useAuthStore } from '../../stores/authStore';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
@@ -33,6 +34,9 @@ interface VoiceChannelProps {
/** Wrapper component for the volume slider so it can use hooks (useState). */
export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, voiceUserHandlers, dropZone }: VoiceChannelProps) {
// Present only while someone is in the channel; the server drops the room
// when it empties, which is what makes the next call start from zero.
const callStartedAt = useVoiceStore((s) => s.voiceRoomStarts.get(channelId));
const serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
const participants = useVoiceStore((s) => s.participants);
@@ -141,6 +145,12 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
</svg>
)}
<span className="truncate text-[15px] font-medium flex-1 text-left">{channelName}</span>
{callStartedAt !== undefined && (
<CallTimer
startedAt={callStartedAt}
className="flex-shrink-0 text-[11px] text-txt-tertiary font-medium"
/>
)}
{canManage && (
<svg
width="16"
@@ -7,6 +7,7 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
import { SoundboardPopover } from './SoundboardPopover';
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
import { broadcastVoiceStatus } from '../../utils/voice';
@@ -22,6 +23,7 @@ export function VoiceControls() {
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const navigate = useNavigate();
const [showSoundboard, setShowSoundboard] = useState(false);
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
@@ -37,6 +39,10 @@ export function VoiceControls() {
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined);
// MANAGE_SPACE lives in the space-level bitfield, not the per-channel one —
// reading it from channelPermissions silently yielded false for everyone.
const spacePerms = useSpaceStore((s) => currentVoiceSpaceId ? s.spacePermissions.get(currentVoiceSpaceId) : undefined);
const canManageSpace = hasPermissionBit(spacePerms, PermissionBits.MANAGE_SPACE);
// In DM calls, all permissions are granted; in space channels, check SPEAK and STREAM
const isDmCall = !!activeDmCall;
@@ -119,6 +125,19 @@ export function VoiceControls() {
return (
<>
{/* Zero-height anchor: the component returns a fragment, so without a
positioned ancestor the popover would resolve against whatever
happened to be relative further up the sidebar. */}
<div className="relative">
{showSoundboard && currentVoiceSpaceId && (
<SoundboardPopover
spaceId={currentVoiceSpaceId}
canManage={canManageSpace}
onClose={() => setShowSoundboard(false)}
/>
)}
</div>
{/* Row 1: Signal icon + status text + disconnect */}
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
<button
@@ -216,6 +235,23 @@ export function VoiceControls() {
</button>
)}
{/* Soundboard — space calls only: clips belong to a space. */}
{currentVoiceSpaceId && (
<button
onClick={() => setShowSoundboard((v) => !v)}
className={`${btnBase} ${
showSoundboard
? 'bg-surface-base text-accent-primary hover:bg-surface-channel'
: btnDefaultStyle
}`}
title="Soundboard"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6Z" />
</svg>
</button>
)}
{/* Video Quality */}
<button
ref={qualityBtnRef}
@@ -0,0 +1,60 @@
import { useEffect } from 'react';
import { api } from '../api/client';
import { useActivityStore } from '../stores/activityStore';
/** While a track is playing. Short enough that a track change shows up quickly. */
const POLL_CONNECTED_MS = 20_000;
/** While the account is not linked — cheap heartbeat that notices a new link. */
const POLL_IDLE_MS = 60_000;
/**
* Publishes what the user is listening to on Spotify as an activity.
*
* The browser never sees a Spotify token: it asks this instance, which holds
* the credentials and talks to Spotify. Reported under its own source so it
* coexists with the desktop game detector instead of replacing it.
*/
export function useSpotifyActivity(): void {
const showActivity = useActivityStore((s) => s.showActivity);
useEffect(() => {
const setSource = useActivityStore.getState().setSourceActivities;
// The privacy toggle governs this like any other activity source.
if (!showActivity) {
setSource('spotify', []);
return;
}
let cancelled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const tick = async () => {
let delay = POLL_IDLE_MS;
try {
// Polling a hidden tab burns Spotify's rate limit for a screen nobody
// is looking at; the next visible tick catches up.
if (typeof document === 'undefined' || !document.hidden) {
const { activity, connected } = await api.spotify.nowPlaying();
if (cancelled) return;
setSource('spotify', activity ? [activity] : []);
delay = connected ? POLL_CONNECTED_MS : POLL_IDLE_MS;
} else {
delay = POLL_CONNECTED_MS;
}
} catch {
// Network hiccup or a logged-out session: keep the last known state and
// retry, rather than reporting "stopped listening" on a transient error.
}
if (!cancelled) timer = setTimeout(() => void tick(), delay);
};
void tick();
return () => {
cancelled = true;
if (timer) clearTimeout(timer);
setSource('spotify', []);
};
}, [showActivity]);
}
+27 -1
View File
@@ -1,4 +1,7 @@
import React, { useEffect, useRef } from 'react';
import { AudioManager } from '../audio/AudioManager';
import { getSfxVolume } from '../utils/sfx';
import { api } from '../api/client';
import { useAuthStore } from '../stores/authStore';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore';
import { useChatStore } from '../stores/chatStore';
@@ -285,6 +288,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
setVoiceUsers(channelId, userIds);
}
}
if (event.voiceRoomStarts) {
const vs = useVoiceStore.getState();
for (const [channelId, startedAt] of Object.entries(event.voiceRoomStarts)) {
vs.setVoiceRoomStart(channelId, startedAt);
}
}
// Initialize activity data from ready payload
if (event.userActivities) {
useActivityStore.getState().initActivities(event.userActivities);
@@ -574,14 +583,31 @@ function handleEvent(origin: string, event: ServerEvent): void {
break;
}
case 'voice_state_update':
case 'voice_state_update': {
const vs = useVoiceStore.getState();
if (event.action === 'join') {
addVoiceUser(event.channelId, event.userId);
if (event.startedAt) vs.setVoiceRoomStart(event.channelId, event.startedAt);
} else {
removeVoiceUser(event.channelId, event.userId);
clearVoiceUserStatus(event.userId);
// The server destroys an empty space room, so its clock is gone; drop
// ours too or the next call would show the previous one's elapsed time.
const remaining = useVoiceStore.getState().voiceUsers.get(event.channelId);
if (!remaining || remaining.length === 0) vs.clearVoiceRoomStart(event.channelId);
}
break;
}
case 'soundboard_played':
// Played locally by every client in the call rather than mixed into the
// presser's microphone: no upstream bandwidth, no LiveKit track, and the
// clip stays crisp instead of going through voice processing.
void AudioManager.getInstance().playUrl(
api.uploads.url(event.filename),
{ volume: getSfxVolume() },
);
break;
case 'voice_status_update':
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
+36
View File
@@ -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);
}
});
});
+78
View File
@@ -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 };
+139
View File
@@ -0,0 +1,139 @@
/**
* 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.',
// Soundboard
'soundboard.title': 'Soundboard',
'soundboard.empty': 'No sounds yet.',
'soundboard.add': 'Add sound',
'soundboard.adding': 'Uploading...',
'soundboard.remove': 'Remove',
'soundboard.namePrompt': 'Name for this sound',
'soundboard.joinFirst': 'Join a voice channel to use the soundboard.',
'soundboard.tooLarge': 'Sound must be under 2 MB and a few seconds long.',
'soundboard.uploadFailed': 'Could not upload that file. Try a different one.',
// Account menu (own name in the user bar)
'accountMenu.editProfile': 'Edit Profile',
'accountMenu.status': 'Status',
'accountMenu.status.online': 'Online',
'accountMenu.status.idle': 'Idle',
'accountMenu.status.dnd': 'Do Not Disturb',
'accountMenu.status.offline': 'Invisible',
'accountMenu.copyId': 'Copy User ID',
'accountMenu.copied': 'Copied',
// Statistics
'stats.title': 'Statistics',
'stats.range.7': 'Last 7 days',
'stats.range.30': 'Last 30 days',
'stats.range.365': 'Last year',
'stats.voice.title': 'Time in voice',
'stats.messages.title': 'Messages sent',
'stats.empty': 'Nothing recorded in this period yet.',
'stats.total.voice': '{value} total',
'stats.total.messages': '{value} messages in total',
'stats.hours': '{hours}h {minutes}m',
'stats.minutes': '{minutes}m',
'stats.note': 'Counting started when this feature was installed — earlier activity is not included.',
// Audit log
'audit.title': 'Audit Log',
'audit.empty': 'Nothing recorded yet. Changes to the server show up here.',
'audit.loadMore': 'Load more',
'audit.unknownActor': 'Deleted account',
'audit.action.space.update': '{actor} updated the server settings',
'audit.action.space.transfer_ownership': '{actor} transferred ownership of the server',
'audit.action.channel.create': '{actor} created the channel {name}',
'audit.action.channel.update': '{actor} updated the channel {name}',
'audit.action.channel.delete': '{actor} deleted the channel {name}',
'audit.action.member.kick': '{actor} removed a member',
'audit.action.member.leave': '{actor} left the server',
'audit.action.member.ban': '{actor} banned a member',
'audit.action.member.unban': '{actor} unbanned a member',
'audit.action.role.create': '{actor} created the role {name}',
'audit.action.role.update': '{actor} updated the role {name}',
'audit.action.role.delete': '{actor} deleted a role',
'audit.action.invite.create': '{actor} created an invite',
'audit.action.message.delete': '{actor} deleted a message',
'audit.action.unknown': '{actor} performed an action',
// GIF picker
'gif.search': 'Search GIFs',
'gif.tab.favorites': 'Favorites',
'gif.tab.trending': 'Trending',
'gif.empty.search': 'No GIFs found',
'gif.empty.trending': 'No trending GIFs',
'gif.empty.favorites': 'No favorites yet — tap the star on any GIF.',
'gif.favorite.add': 'Add to favorites',
'gif.favorite.remove': 'Remove from favorites',
'gif.category.hello': 'hello',
'gif.category.lol': 'lol',
'gif.category.love': 'love',
'gif.category.birthday': 'happy birthday',
'gif.category.dance': 'dance',
'gif.category.facepalm': 'facepalm',
// Settings — privacy
'privacy.title': 'Privacy',
'privacy.discoverable.label': 'Allow others to find my profile',
'privacy.discoverable.description': 'When enabled, your profile appears in Discover People. Others can always add you by exact username.',
'privacy.activity.label': 'Share Activity Status',
'privacy.activity.description': "Allow others to see what you're up to, like games you're playing or music you're listening to.",
// Settings — connections
'connections.title': 'Connections',
'connections.spotify.description': 'Show what you are listening to on your profile.',
'connections.spotify.connect': 'Connect Spotify',
'connections.spotify.disconnect': 'Disconnect',
'connections.spotify.connected': 'Connected',
'connections.spotify.notConfigured': 'This instance has no Spotify credentials configured.',
'connections.spotify.hint': 'Only what is playing is read — playback cannot be controlled.',
'connections.spotify.error.denied': 'Authorisation was cancelled on Spotify.',
'connections.spotify.error.invalid_state': 'The authorisation link expired. Try again.',
'connections.spotify.error.exchange_failed': 'Spotify refused the authorisation. Try again.',
// 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>;
+135
View File
@@ -0,0 +1,135 @@
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.',
// Soundboard
'soundboard.title': 'Soundboard',
'soundboard.empty': 'Nenhum som ainda.',
'soundboard.add': 'Adicionar som',
'soundboard.adding': 'Enviando...',
'soundboard.remove': 'Remover',
'soundboard.namePrompt': 'Nome deste som',
'soundboard.joinFirst': 'Entre num canal de voz para usar o soundboard.',
'soundboard.tooLarge': 'O som precisa ter menos de 2 MB e poucos segundos.',
'soundboard.uploadFailed': 'Não foi possível enviar esse arquivo. Tente outro.',
// Menu da conta (próprio nome na barra de usuário)
'accountMenu.editProfile': 'Editar perfil',
'accountMenu.status': 'Status',
'accountMenu.status.online': 'Disponível',
'accountMenu.status.idle': 'Ausente',
'accountMenu.status.dnd': 'Não perturbe',
'accountMenu.status.offline': 'Invisível',
'accountMenu.copyId': 'Copiar ID do usuário',
'accountMenu.copied': 'Copiado',
// Estatísticas
'stats.title': 'Estatísticas',
'stats.range.7': 'Últimos 7 dias',
'stats.range.30': 'Últimos 30 dias',
'stats.range.365': 'Último ano',
'stats.voice.title': 'Tempo em call',
'stats.messages.title': 'Mensagens enviadas',
'stats.empty': 'Nada registrado neste período ainda.',
'stats.total.voice': '{value} no total',
'stats.total.messages': '{value} mensagens no total',
'stats.hours': '{hours}h {minutes}min',
'stats.minutes': '{minutes}min',
'stats.note': 'A contagem começou quando esta funcionalidade foi instalada — atividade anterior não entra.',
// Registro de auditoria
'audit.title': 'Registro de auditoria',
'audit.empty': 'Nada registrado ainda. Mudanças no servidor aparecem aqui.',
'audit.loadMore': 'Carregar mais',
'audit.unknownActor': 'Conta excluída',
'audit.action.space.update': '{actor} alterou as configurações do servidor',
'audit.action.space.transfer_ownership': '{actor} transferiu a propriedade do servidor',
'audit.action.channel.create': '{actor} criou o canal {name}',
'audit.action.channel.update': '{actor} alterou o canal {name}',
'audit.action.channel.delete': '{actor} excluiu o canal {name}',
'audit.action.member.kick': '{actor} removeu um membro',
'audit.action.member.leave': '{actor} saiu do servidor',
'audit.action.member.ban': '{actor} baniu um membro',
'audit.action.member.unban': '{actor} removeu o banimento de um membro',
'audit.action.role.create': '{actor} criou o cargo {name}',
'audit.action.role.update': '{actor} alterou o cargo {name}',
'audit.action.role.delete': '{actor} excluiu um cargo',
'audit.action.invite.create': '{actor} criou um convite',
'audit.action.message.delete': '{actor} excluiu uma mensagem',
'audit.action.unknown': '{actor} realizou uma ação',
// Seletor de GIF
'gif.search': 'Buscar GIFs',
'gif.tab.favorites': 'Favoritos',
'gif.tab.trending': 'Em alta',
'gif.empty.search': 'Nenhum GIF encontrado',
'gif.empty.trending': 'Nenhum GIF em alta',
'gif.empty.favorites': 'Nenhum favorito ainda — toque na estrela de um GIF.',
'gif.favorite.add': 'Adicionar aos favoritos',
'gif.favorite.remove': 'Remover dos favoritos',
'gif.category.hello': 'oi',
'gif.category.lol': 'risada',
'gif.category.love': 'amor',
'gif.category.birthday': 'feliz aniversário',
'gif.category.dance': 'dança',
'gif.category.facepalm': 'vergonha alheia',
// Configurações — privacidade
'privacy.title': 'Privacidade',
'privacy.discoverable.label': 'Permitir que me encontrem',
'privacy.discoverable.description': 'Quando ativado, seu perfil aparece em Descobrir Pessoas. Qualquer um sempre pode te adicionar pelo nome de usuário exato.',
'privacy.activity.label': 'Compartilhar atividade',
'privacy.activity.description': 'Permite que os outros vejam o que você está fazendo, como jogos que está jogando ou música que está ouvindo.',
// Configurações — conexões
'connections.title': 'Conexões',
'connections.spotify.description': 'Mostre no seu perfil o que você está ouvindo.',
'connections.spotify.connect': 'Conectar Spotify',
'connections.spotify.disconnect': 'Desconectar',
'connections.spotify.connected': 'Conectado',
'connections.spotify.notConfigured': 'Esta instância não tem credenciais do Spotify configuradas.',
'connections.spotify.hint': 'Só é lido o que está tocando — não é possível controlar a reprodução.',
'connections.spotify.error.denied': 'A autorização foi cancelada no Spotify.',
'connections.spotify.error.invalid_state': 'O link de autorização expirou. Tente de novo.',
'connections.spotify.error.exchange_failed': 'O Spotify recusou a autorização. Tente de novo.',
// 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',
};
+3 -3
View File
@@ -10,16 +10,16 @@ export function initActivityBridge(): void {
// Subscribe to future activity changes from main process
unsubscribe = window.backspace.onActivityDetected((activity) => {
if (activity) {
useActivityStore.getState().pushActivities([activity as Activity]);
useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
} else {
useActivityStore.getState().pushActivities([]);
useActivityStore.getState().setSourceActivities('desktop', []);
}
});
// Request current state (handles instance-switch: game was already running)
window.backspace.getCurrentActivity?.().then((activity: unknown) => {
if (activity) {
useActivityStore.getState().pushActivities([activity as Activity]);
useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
}
}).catch(() => {});
}
+20
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand';
import type { Activity } from '@backspace/shared';
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
import { wsSendAll } from '../hooks/useWebSocket';
let pushTimer: ReturnType<typeof setTimeout> | null = null;
@@ -14,9 +15,18 @@ interface ActivityState {
initActivities: (activityMap: Record<string, Activity[]>) => void;
setShowActivity: (show: boolean) => void;
pushActivities: (activities: Activity[]) => void;
setSourceActivities: (source: string, activities: Activity[]) => void;
reset: () => void;
}
/**
* Activities kept per producer. The desktop process detector and Spotify report
* independently of each other, and a plain replace would let whichever spoke
* last erase the other — losing precisely the case this exists for: a game and
* Spotify at the same time.
*/
const bySource = new Map<string, Activity[]>();
export const useActivityStore = create<ActivityState>((set, get) => ({
userActivities: new Map(),
showActivity: true,
@@ -75,8 +85,18 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
}, 5000);
},
setSourceActivities: (source, activities) => {
if (activities.length === 0) bySource.delete(source);
else bySource.set(source, activities);
const merged = Array.from(bySource.values())
.flat()
.slice(0, ACTIVITY_LIMITS.MAX_ACTIVITIES_PER_USER);
get().pushActivities(merged);
},
reset: () => {
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
bySource.clear();
set({ userActivities: new Map(), showActivity: true, myActivities: null });
},
}));
+26
View File
@@ -16,6 +16,12 @@ export interface ScreenShareConfig {
interface VoiceState {
voiceUsers: Map<string, string[]>; // channelId → userIds
/**
* When each occupied voice channel's current call began, from the server.
* The client cannot derive this: someone joining an hour in must see the
* call's elapsed time, not their own.
*/
voiceRoomStarts: Map<string, number>;
currentVoiceChannelId: string | null;
/**
* Space and name of the channel the call is in, captured at join time.
@@ -94,6 +100,8 @@ interface VoiceState {
setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void;
setVoiceRoomStart: (channelId: string, startedAt: number) => void;
clearVoiceRoomStart: (channelId: string) => void;
setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
setParticipants: (participants: ParticipantInfo[]) => void;
setSpeakingParticipants: (ids: Set<string>) => void;
@@ -166,6 +174,7 @@ export const useVoiceStore = create<VoiceState>()(
persist(
(set, get) => ({
voiceUsers: new Map(),
voiceRoomStarts: new Map(),
currentVoiceChannelId: null,
currentVoiceSpaceId: null,
currentVoiceChannelName: null,
@@ -360,6 +369,22 @@ export const useVoiceStore = create<VoiceState>()(
});
},
setVoiceRoomStart: (channelId, startedAt) => set((state) => {
// First value wins: re-broadcasts on later joins carry the same start,
// but a stray newer one must not restart a running clock.
if (state.voiceRoomStarts.get(channelId) === startedAt) return {};
const next = new Map(state.voiceRoomStarts);
next.set(channelId, startedAt);
return { voiceRoomStarts: next };
}),
clearVoiceRoomStart: (channelId) => set((state) => {
if (!state.voiceRoomStarts.has(channelId)) return {};
const next = new Map(state.voiceRoomStarts);
next.delete(channelId);
return { voiceRoomStarts: next };
}),
setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
currentVoiceChannelId: channelId,
currentVoiceSpaceId: channelId ? spaceId : null,
@@ -527,6 +552,7 @@ export const useVoiceStore = create<VoiceState>()(
// Connection state
hwOverdrive: false,
voiceUsers: new Map(),
voiceRoomStarts: new Map(),
voiceUserStates: new Map(),
currentVoiceChannelId: null,
currentVoiceSpaceId: null,
+7
View File
@@ -9,6 +9,7 @@ import { useVoiceStore } from '../stores/voiceStore';
export interface SpaceVoiceStateSnapshot {
spaceId: string;
voiceStates: Record<string, string[]>;
voiceRoomStarts?: Record<string, number>;
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
}
@@ -33,6 +34,12 @@ export function applySpaceVoiceState(snapshot: SpaceVoiceStateSnapshot): void {
for (const [channelId, userIds] of Object.entries(snapshot.voiceStates)) {
setVoiceUsers(channelId, userIds);
}
if (snapshot.voiceRoomStarts) {
const { setVoiceRoomStart } = useVoiceStore.getState();
for (const [channelId, startedAt] of Object.entries(snapshot.voiceRoomStarts)) {
setVoiceRoomStart(channelId, startedAt);
}
}
for (const [userId, status] of Object.entries(snapshot.voiceUserStates)) {
setVoiceUserStatus(userId, status.isMuted, status.isDeafened, status.isCameraOn, status.isScreenSharing);
}