Compare commits
46
Commits
b92a0d837e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
293378293d | ||
|
|
c5abb6fd64 | ||
|
|
1fb61377b9 | ||
|
|
1a1067bbb6 | ||
|
|
71d2109b2f | ||
|
|
7f08384372 | ||
|
|
7d003021d1 | ||
|
|
50a8f12c77 | ||
|
|
4ceb5cd66a | ||
|
|
c4158ee245 | ||
|
|
61c68761df | ||
|
|
c04b8b47eb | ||
|
|
ce6bba0510 | ||
|
|
9ea399ded5 | ||
|
|
9f7723d104 | ||
|
|
5b84843217 | ||
|
|
bd8decb4b3 | ||
|
|
52f43d4d61 | ||
|
|
7eaba5e3f3 | ||
|
|
124db82a0c | ||
|
|
b4d001eb26 | ||
|
|
60c51a1635 | ||
|
|
d208b0c277 | ||
|
|
2f836cd366 | ||
|
|
f7f2cf75ab | ||
|
|
3deed92dd9 | ||
|
|
d525bbb8c5 | ||
|
|
310e9b86be | ||
|
|
e291b5e411 | ||
|
|
c899253e52 | ||
|
|
ff55d9d486 | ||
|
|
e58021408c | ||
|
|
2afe3453f0 | ||
|
|
e89966435a | ||
|
|
f5451e1b14 | ||
|
|
ef5545465d | ||
|
|
1830051732 | ||
|
|
bbb190cbda | ||
|
|
fb662bfe12 | ||
|
|
0fc6abeb6e | ||
|
|
75316b0882 | ||
|
|
c022f2795f | ||
|
|
688a1335cb | ||
|
|
89e13441c8 | ||
|
|
63afd2fc89 | ||
|
|
d80de49768 |
@@ -0,0 +1,97 @@
|
|||||||
|
# Caminho rápido: só Windows x64, disparado à mão.
|
||||||
|
#
|
||||||
|
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
|
||||||
|
# publica release e alimenta o electron-updater). Este aqui existe para quando
|
||||||
|
# você só quer um .exe para testar, sem marcar versão.
|
||||||
|
#
|
||||||
|
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
|
||||||
|
# mexer nele criaria conflito a cada atualização.
|
||||||
|
name: Build Windows (rápido)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
arch:
|
||||||
|
description: Arquitetura
|
||||||
|
required: false
|
||||||
|
default: x64
|
||||||
|
type: choice
|
||||||
|
options: [x64, arm64]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
|
||||||
|
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
|
||||||
|
# módulos nativos. Mesma razão documentada no release.yml.
|
||||||
|
runs-on: windows-2022
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
|
with:
|
||||||
|
version: 10.34.3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
|
||||||
|
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
|
||||||
|
- name: Cache Electron binaries
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~\AppData\Local\electron\Cache
|
||||||
|
~\AppData\Local\electron-builder\Cache
|
||||||
|
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: electron-cache-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @backspace/shared build
|
||||||
|
|
||||||
|
# O postinstall termina em `|| console.warn` para que quem não tem
|
||||||
|
# ferramentas de build consiga instalar. No CI isso transforma falha em
|
||||||
|
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
|
||||||
|
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
|
||||||
|
# resultado em vez de confiar no código de saída.
|
||||||
|
- name: Verify native audio module compiled
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
|
||||||
|
if [ -z "$found" ]; then
|
||||||
|
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
|
||||||
|
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "OK:"; echo "$found"
|
||||||
|
|
||||||
|
- name: Compile desktop TypeScript
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec tsc
|
||||||
|
|
||||||
|
# --publish never: sem release, sem precisar de tag nem bump de versão.
|
||||||
|
# O instalador sai como artefato desta execução.
|
||||||
|
- name: Build installer
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
- name: Upload installer
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backspace-windows-${{ inputs.arch || 'x64' }}
|
||||||
|
path: packages/desktop/dist-electron/*.exe
|
||||||
|
retention-days: 14
|
||||||
|
if-no-files-found: error
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
# Compila no GitHub, publica no Gitea.
|
||||||
|
#
|
||||||
|
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
|
||||||
|
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
|
||||||
|
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
|
||||||
|
# credencial embutida no app, o que aconteceria com um repositório privado no
|
||||||
|
# GitHub.
|
||||||
|
name: Publicar no Gitea
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ['v*']
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: Tag a publicar (ex. v1.1.0)
|
||||||
|
required: true
|
||||||
|
type: string
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- os: windows-2022
|
||||||
|
args: --win --x64
|
||||||
|
- os: ubuntu-latest
|
||||||
|
args: --linux --x64
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
|
- name: Install Linux build dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libx11-dev libxtst-dev libxt-dev \
|
||||||
|
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
||||||
|
libxrandr-dev libxinerama-dev libx11-xcb-dev \
|
||||||
|
libpipewire-0.3-dev libpulse-dev
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
|
with:
|
||||||
|
version: 10.34.3
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Cache Electron binaries
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cache/electron
|
||||||
|
~/.cache/electron-builder
|
||||||
|
~\AppData\Local\electron\Cache
|
||||||
|
~\AppData\Local\electron-builder\Cache
|
||||||
|
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||||
|
restore-keys: electron-cache-${{ runner.os }}-
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build shared package
|
||||||
|
run: pnpm --filter @backspace/shared build
|
||||||
|
|
||||||
|
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
|
||||||
|
# de build. No CI isso esconde falha: o instalador sairia sem captura de
|
||||||
|
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
|
||||||
|
- name: Verify native audio module compiled
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
|
||||||
|
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
|
||||||
|
echo "$found"
|
||||||
|
|
||||||
|
- name: Compile desktop TypeScript
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec tsc
|
||||||
|
|
||||||
|
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
|
||||||
|
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
|
||||||
|
# passo seguinte faz o upload.
|
||||||
|
- name: Build installers
|
||||||
|
working-directory: packages/desktop
|
||||||
|
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
|
||||||
|
env:
|
||||||
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
|
|
||||||
|
- name: Upload to Gitea release
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
|
||||||
|
TAG: latest
|
||||||
|
run: |
|
||||||
|
set -uo pipefail
|
||||||
|
if [ -z "${GITEA_TOKEN:-}" ]; then
|
||||||
|
echo "::error::segredo GITEA_TOKEN não configurado"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
|
||||||
|
# engole a resposta de erro, então uma falha aqui só aparecia como um
|
||||||
|
# JSONDecodeError sem dizer o motivo.
|
||||||
|
api() {
|
||||||
|
local method=$1 path=$2; shift 2
|
||||||
|
curl -s -w '\n%{http_code}' -X "$method" \
|
||||||
|
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
|
||||||
|
}
|
||||||
|
body() { sed '$d' <<<"$1"; }
|
||||||
|
code() { tail -n1 <<<"$1"; }
|
||||||
|
# jq em vez de python embutido: um heredoc multilinha dentro de um
|
||||||
|
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
|
||||||
|
json_id() { jq -r '.id // empty'; }
|
||||||
|
|
||||||
|
find_release() {
|
||||||
|
local r; r=$(api GET "/releases/tags/$TAG")
|
||||||
|
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
|
||||||
|
}
|
||||||
|
|
||||||
|
# O corpo do POST fica em ASCII de proposito: o shell do runner
|
||||||
|
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
|
||||||
|
# "invalid UTF-8 within /name" (HTTP 422).
|
||||||
|
ID=$(find_release)
|
||||||
|
if [ -z "$ID" ]; then
|
||||||
|
R=$(api POST "/releases" -H 'Content-Type: application/json' \
|
||||||
|
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
|
||||||
|
if [ "$(code "$R")" = "201" ]; then
|
||||||
|
ID=$(body "$R" | json_id)
|
||||||
|
echo "release criada: $ID"
|
||||||
|
else
|
||||||
|
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
|
||||||
|
# O outro job da matriz pode tê-la criado no mesmo instante.
|
||||||
|
ID=$(find_release)
|
||||||
|
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
|
||||||
|
echo "release encontrada após corrida: $ID"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "release existente: $ID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
|
||||||
|
# não apagarem o trabalho um do outro.
|
||||||
|
#
|
||||||
|
# Padrão montado em bash puro: a versão anterior interpolava uma
|
||||||
|
# expressão do Actions dentro de um `case`, e quando ela não casou o
|
||||||
|
# laço passou em silêncio — a release ficou com dois latest.yml e o
|
||||||
|
# updater serviu o antigo, dizendo que a versão nova não existia.
|
||||||
|
R=$(api GET "/releases/$ID/assets")
|
||||||
|
if [ "$(code "$R")" != "200" ]; then
|
||||||
|
echo "::error::não foi possível listar os anexos — HTTP $(code "$R"): $(body "$R")"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Filtro dentro do jq, comparando o nome direto. A versão anterior
|
||||||
|
# montava um regex e o casava contra "<id> <nome>", onde o `^` de
|
||||||
|
# `^latest\.yml$` ancorava no início da linha — depois do id — e nunca
|
||||||
|
# podia casar. O laço então só removia os .exe e deixava um latest.yml
|
||||||
|
# duplicado, que é o arquivo que decide se há atualização.
|
||||||
|
OLD=$(body "$R" | jq -r --arg os "${RUNNER_OS:-}" '
|
||||||
|
.[]
|
||||||
|
| select(
|
||||||
|
if $os == "Windows"
|
||||||
|
then (.name | endswith(".exe")) or .name == "latest.yml"
|
||||||
|
else (.name | endswith(".AppImage")) or (.name | endswith(".deb")) or .name == "latest-linux.yml"
|
||||||
|
end
|
||||||
|
)
|
||||||
|
| "\(.id) \(.name)"')
|
||||||
|
echo "anexos desta plataforma já na release: $(printf '%s' "$OLD" | grep -c . || true)"
|
||||||
|
if [ -n "$OLD" ]; then
|
||||||
|
printf '%s\n' "$OLD" | while read -r aid aname; do
|
||||||
|
[ -n "$aid" ] || continue
|
||||||
|
echo "removendo anexo antigo: $aname"
|
||||||
|
D=$(api DELETE "/releases/$ID/assets/$aid")
|
||||||
|
[ "$(code "$D")" = "204" ] || echo "::warning::falha ao remover $aname — HTTP $(code "$D")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
shopt -s nullglob
|
||||||
|
sent=0
|
||||||
|
for f in packages/desktop/dist-electron/*.exe \
|
||||||
|
packages/desktop/dist-electron/*.AppImage \
|
||||||
|
packages/desktop/dist-electron/*.deb \
|
||||||
|
packages/desktop/dist-electron/latest*.yml; do
|
||||||
|
name=$(basename "$f")
|
||||||
|
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
|
||||||
|
if [ "$(code "$R")" != "201" ]; then
|
||||||
|
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "enviado: $name"
|
||||||
|
sent=$((sent+1))
|
||||||
|
done
|
||||||
|
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
|
||||||
|
echo "$sent arquivo(s) publicados"
|
||||||
|
|
||||||
|
# O updater busca latest.yml pelo nome. Duas cópias com o mesmo nome
|
||||||
|
# fazem o Gitea servir a mais antiga, e a atualização deixa de ser
|
||||||
|
# oferecida — sem erro em lugar nenhum. Falha aqui em vez de publicar
|
||||||
|
# uma release que parece boa e não atualiza.
|
||||||
|
if [ "${RUNNER_OS:-}" = "Windows" ]; then
|
||||||
|
R=$(api GET "/releases/$ID/assets")
|
||||||
|
DUP=$(body "$R" | jq -r '[.[] | select(.name == "latest.yml")] | length')
|
||||||
|
if [ "$DUP" != "1" ]; then
|
||||||
|
echo "::error::a release tem $DUP cópias de latest.yml — o updater serviria a errada"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "latest.yml: 1 cópia, como esperado"
|
||||||
|
fi
|
||||||
@@ -14,22 +14,26 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
# macOS — universal build (arm64 + x64) on Apple Silicon runner
|
# Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
|
||||||
- os: macos-latest
|
# dobrava o job mais lento (duas distribuições do Electron, módulos
|
||||||
args: --mac --arm64 --x64
|
# nativos compilados duas vezes) e ninguém roda Windows em ARM.
|
||||||
|
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
|
||||||
|
# instalava o fpm nativo perdeu a razão de existir: ele só era
|
||||||
|
# necessário porque o fpm embutido no electron-builder é x86_64 e
|
||||||
|
# falhava no runner arm64.
|
||||||
|
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
|
||||||
|
# é cobrado a 10x num repositório privado — era a plataforma mais cara
|
||||||
|
# da matriz, compilada em toda tag para zero usuários.
|
||||||
# Windows — x64 + arm64 on x64 runner.
|
# Windows — x64 + arm64 on x64 runner.
|
||||||
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
|
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
|
||||||
# ships VS "18", which the node-gyp bundled with electron-rebuild
|
# ships VS "18", which the node-gyp bundled with electron-rebuild
|
||||||
# cannot detect ("unknown version undefined") when compiling
|
# cannot detect ("unknown version undefined") when compiling
|
||||||
# uiohook-napi in postinstall.
|
# uiohook-napi in postinstall.
|
||||||
- os: windows-2022
|
- os: windows-2022
|
||||||
args: --win --x64 --arm64
|
args: --win --x64
|
||||||
# Linux — x64 on x64 runner
|
# Linux — x64 on x64 runner
|
||||||
- os: ubuntu-latest
|
- os: ubuntu-latest
|
||||||
args: --linux --x64
|
args: --linux --x64
|
||||||
# Linux — arm64 on arm64 runner
|
|
||||||
- os: ubuntu-24.04-arm
|
|
||||||
args: --linux --arm64
|
|
||||||
|
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
@@ -59,17 +63,6 @@ jobs:
|
|||||||
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
|
||||||
libxrandr-dev libxinerama-dev libx11-xcb-dev
|
libxrandr-dev libxinerama-dev libx11-xcb-dev
|
||||||
|
|
||||||
- name: Install fpm for .deb packaging
|
|
||||||
if: runner.os == 'Linux'
|
|
||||||
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
|
|
||||||
# aborts the .deb target with "cannot execute binary file: Exec format
|
|
||||||
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
|
|
||||||
# arches package their .deb with a host-native fpm. ruby-dev + make are
|
|
||||||
# needed for fpm's native gem dependencies.
|
|
||||||
run: |
|
|
||||||
sudo apt-get install -y ruby ruby-dev build-essential
|
|
||||||
sudo gem install --no-document fpm
|
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
@@ -97,7 +90,3 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||||
# Use the host-native fpm installed above instead of electron-builder's
|
|
||||||
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
|
|
||||||
# macOS/Windows, which don't build .deb.
|
|
||||||
USE_SYSTEM_FPM: "true"
|
|
||||||
|
|||||||
@@ -10,3 +10,9 @@
|
|||||||
# Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace"
|
# Backspace API, WebSocket, and frontend — Docker DNS resolves "backspace"
|
||||||
reverse_proxy backspace:3000
|
reverse_proxy backspace:3000
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Gitea — servidor git próprio (stack em /opt/gitea). Alcançado pelo nome do
|
||||||
|
# container na rede interna; o Gitea não publica porta nenhuma no host.
|
||||||
|
{$GIT_DOMAIN:git.resenha.website} {
|
||||||
|
reverse_proxy gitea:3000
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Gerar o instalador do app desktop
|
||||||
|
|
||||||
|
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
|
||||||
|
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
|
||||||
|
ao empurrar uma tag `v*`.
|
||||||
|
|
||||||
|
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
|
||||||
|
|
||||||
|
## Por que GitHub e não Gitea
|
||||||
|
|
||||||
|
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
|
||||||
|
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
|
||||||
|
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
|
||||||
|
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
|
||||||
|
WASAPI e só compila no Windows, com o compilador da Microsoft.
|
||||||
|
|
||||||
|
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
|
||||||
|
espelho para compilar.
|
||||||
|
|
||||||
|
## Passos (uma vez)
|
||||||
|
|
||||||
|
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
|
||||||
|
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
|
||||||
|
2. Adicionar o espelho e empurrar:
|
||||||
|
```
|
||||||
|
cd /opt/backspace
|
||||||
|
git remote add github git@github.com:syncwrld/resenhacord.git
|
||||||
|
git push github main
|
||||||
|
```
|
||||||
|
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
|
||||||
|
```
|
||||||
|
git tag v1.0.1 && git push github v1.0.1
|
||||||
|
```
|
||||||
|
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
|
||||||
|
|
||||||
|
## O que isso resolve de quebra
|
||||||
|
|
||||||
|
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
|
||||||
|
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
|
||||||
|
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
|
||||||
|
precisa reinstalar na mão de novo.
|
||||||
|
|
||||||
|
## Adaptações feitas para o fork
|
||||||
|
|
||||||
|
| O quê | Por quê |
|
||||||
|
|---|---|
|
||||||
|
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
|
||||||
|
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
|
||||||
|
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
|
||||||
|
|
||||||
|
## Detalhe herdado que vale preservar
|
||||||
|
|
||||||
|
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
|
||||||
|
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
|
||||||
|
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
|
||||||
|
undefined"). Não mude isso sem testar.
|
||||||
+91
-21
@@ -15,10 +15,28 @@ 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
|
||||||
|
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
|
||||||
|
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
|
||||||
|
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
|
||||||
|
| Fixar mensagens no canal | `4ceb5cd6` | Estado na própria mensagem; painel no cabeçalho; limite de 50 |
|
||||||
|
| Busca: filtros embutidos e tradução | `50a8f12c` | Os filtros já existiam; faltava `de:fulano` e descoberta |
|
||||||
|
| Emojis e figurinhas do servidor | `7d003021` | `:nome:` no markdown sem plugin; figurinha como mensagem inteira |
|
||||||
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
## Já existia no código (verificado, não construir de novo)
|
## Já existia no código (verificado, não construir de novo)
|
||||||
|
|
||||||
|
- **Busca com filtros** — servidor (`q`, `from`, `has`, `before`, `after`),
|
||||||
|
client de API e painel no `SearchPopover` já existiam completos. Só faltava
|
||||||
|
tradução, sintaxe embutida (`de:fulano`) e descoberta.
|
||||||
|
|
||||||
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||||
escalonados em 0/150/300ms.
|
escalonados em 0/150/300ms.
|
||||||
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||||
@@ -39,37 +57,89 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
|
|
||||||
| # | Feature | Tamanho | Observação técnica |
|
| # | 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
|
## Pendente — ideias aprovadas
|
||||||
|
|
||||||
| Feature | Tamanho | Observação técnica |
|
| Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Soundboard | Média | `AudioManager` já carrega e toca `.ogg` sob demanda; falta upload por espaço, permissão e disparo na sala LiveKit |
|
|
||||||
| Estatísticas do grupo | Grande | Horas em call, quem mais falou, ranking. **Depende do mesmo registro de eventos da auditoria (#9)** |
|
|
||||||
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
|
| 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)
|
## Regra de idioma (a partir de 2026-08-31)
|
||||||
|
|
||||||
O caminho de consumo está inteiro: tipo, store, WebSocket, validação no
|
Funcionalidade nova sai com interface em **pt-BR**, e a cada update um sistema
|
||||||
servidor, relay de presença e agora o bloco no perfil. **Falta um produtor.**
|
existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e
|
||||||
|
commits seguem em inglês.
|
||||||
|
|
||||||
Três opções, com custos bem diferentes:
|
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.
|
||||||
|
|
||||||
1. **Entrada no dicionário do detector** (`activityDetector.ts` lê um JSON de
|
### Sistemas já traduzidos
|
||||||
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
|
- Configurações → navegação e aba Idioma
|
||||||
Electron**.
|
- Configurações → Voz (dispositivo de entrada, volume, teste de microfone)
|
||||||
2. **Ler o título da janela do Spotify** no processo main do Electron. O título
|
- Cartão de perfil (sobre mim, membro desde, enviar mensagem, atividade)
|
||||||
é "Artista - Faixa", então preenche `details` e `state`. Ainda só desktop, e
|
- Configurações → Privacidade
|
||||||
sem capa nem duração.
|
- Configurações → Conexões (nasceu bilíngue)
|
||||||
3. **Spotify Web API com OAuth.** É a única que cobre quem usa pelo navegador —
|
- Seletor de GIF
|
||||||
que é a maioria do grupo — e a única que traz capa e progresso.
|
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
|
||||||
**Bloqueio:** exige registrar um app no dashboard do Spotify e obter
|
- Barra lateral: servidores, canais e menus de contexto
|
||||||
client id/secret. Isso é ação sua; eu não consigo fazer.
|
- Lista de membros (grupos por cargo, carregamento)
|
||||||
|
- Busca (rótulos, filtros e sintaxe embutida)
|
||||||
|
- 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 |
|
||||||
|
|---|---|---|
|
||||||
|
|
||||||
|
|
||||||
|
## App desktop — já existia (verificado 2026-08-31)
|
||||||
|
|
||||||
|
As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
|
||||||
|
construir de novo.**
|
||||||
|
|
||||||
|
- **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
|
||||||
|
fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
|
||||||
|
- **Notificações nativas** — `showNotification()` no processo principal, canal
|
||||||
|
IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
|
||||||
|
- **Áudio do sistema no compartilhamento** — `setDisplayMediaRequestHandler`
|
||||||
|
devolve `audio: 'loopback'`; existe caixa de seleção no `ScreenSharePicker`
|
||||||
|
ligada a `screenShareConfig.shareAudio`, que atravessa o IPC.
|
||||||
|
|
||||||
|
Se algum não se manifestar em uso, o trabalho é **depuração**, não construção.
|
||||||
|
|
||||||
|
|
||||||
|
## Aprovadas, a fazer depois (2026-08-31)
|
||||||
|
|
||||||
|
Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
|
||||||
|
|
||||||
|
| Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|
|
||||||
|
| **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
|
||||||
|
|
||||||
|
Ordenadas por relação valor/custo para um servidor de grupo fechado.
|
||||||
|
|
||||||
|
| Sistema | Tamanho | Por que faz sentido aqui |
|
||||||
|
|---|---|---|
|
||||||
|
| **Backup fora da VPS** | Pequena | Hoje app, banco, uploads, backups e as três cópias do repositório morrem no mesmo evento. Um envio periódico para fora resolve |
|
||||||
|
| **Aniversários e lembretes** | Pequena | Alto retorno afetivo, custo baixo: campo de data + verificação diária + mensagem no canal |
|
||||||
|
| **Perfis por servidor** | Média | Apelido e avatar diferentes por espaço, como no Discord. O modelo já tem membro por espaço |
|
||||||
|
| **Eventos agendados com presença** | Média | "Sexta 21h" com confirmação. Encaixa nas notificações e no PWA já instalado |
|
||||||
|
| **Notificações push de verdade** | Média | O `vite-plugin-pwa` e o service worker já estão lá; falta Web Push (VAPID) e o registro no servidor |
|
||||||
|
| **Níveis e conquistas** | Média | Gamificação por tempo em call e mensagens. **Mesmo registro de eventos da auditoria e das estatísticas** — três features, um mecanismo |
|
||||||
|
| **Clipes de call** | Grande | "Salvar os últimos 30 segundos" depois de alguém falar besteira. O LiveKit tem egress; exige buffer contínuo e armazenamento |
|
||||||
|
| **Fila de música compartilhada** | Grande | Um participante-robô publicando faixa de áudio na sala LiveKit. É o que mais muda o uso de um servidor de amigos, e o mais caro |
|
||||||
|
| **Autenticação em duas etapas** | Média | Só faz sentido depois de decidir o modelo de cadastro |
|
||||||
|
|
||||||
## Dependência que vale respeitar
|
## Dependência que vale respeitar
|
||||||
|
|
||||||
|
|||||||
+4
-3
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "backspace",
|
"name": "backspace",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Open, self-hosted communication platform — text, voice, video, and federation",
|
"description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"author": "Jannis Braun",
|
"author": "Jannis Braun",
|
||||||
"homepage": "https://github.com/TheZwiss/backspace",
|
"homepage": "https://github.com/TheZwiss/backspace",
|
||||||
@@ -32,8 +32,9 @@
|
|||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"better-sqlite3",
|
"better-sqlite3",
|
||||||
"esbuild",
|
|
||||||
"electron",
|
"electron",
|
||||||
|
"electron-native-screenshare",
|
||||||
|
"esbuild",
|
||||||
"sharp"
|
"sharp"
|
||||||
],
|
],
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
|
|||||||
@@ -14,14 +14,27 @@ files:
|
|||||||
- "!**/node_modules/uiohook-napi/build/**"
|
- "!**/node_modules/uiohook-napi/build/**"
|
||||||
- "!**/node_modules/uiohook-napi/build.bak/**"
|
- "!**/node_modules/uiohook-napi/build.bak/**"
|
||||||
- "!**/node_modules/uiohook-napi/bin/**"
|
- "!**/node_modules/uiohook-napi/bin/**"
|
||||||
|
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
|
||||||
|
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
|
||||||
|
# below takes it out of the archive so the loader can find it.
|
||||||
asarUnpack:
|
asarUnpack:
|
||||||
- "**/*.node"
|
- "**/*.node"
|
||||||
npmRebuild: false
|
npmRebuild: false
|
||||||
afterPack: ./scripts/afterPack.js
|
afterPack: ./scripts/afterPack.js
|
||||||
publish:
|
publish:
|
||||||
- provider: github
|
# Updates are served from this fork's own Gitea, not from GitHub.
|
||||||
owner: TheZwiss
|
#
|
||||||
repo: backspace
|
# GitHub is only the build machine — it has the Windows runners the native
|
||||||
|
# audio module needs. Its repository is private, and electron-updater against
|
||||||
|
# a private GitHub repo would need a token shipped inside the app, which is a
|
||||||
|
# leaked token. Gitea serves release assets to anyone, so no credential ends
|
||||||
|
# up in the installer.
|
||||||
|
#
|
||||||
|
# The tag is fixed at `latest` on purpose: electron-updater fetches
|
||||||
|
# latest.yml before it knows which version exists, so the URL cannot contain
|
||||||
|
# a version. CI replaces that release's assets on every publish.
|
||||||
|
- provider: generic
|
||||||
|
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
|
||||||
protocols:
|
protocols:
|
||||||
- name: Backspace
|
- name: Backspace
|
||||||
schemes:
|
schemes:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@backspace/desktop",
|
"name": "@backspace/desktop",
|
||||||
"version": "1.0.0",
|
"version": "1.3.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"description": "Backspace",
|
"description": "Backspace",
|
||||||
@@ -18,9 +18,10 @@
|
|||||||
"clean": "rm -rf dist dist-electron",
|
"clean": "rm -rf dist dist-electron",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"postinstall": "electron-rebuild -f -w uiohook-napi || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
|
"postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"electron-native-screenshare": "^1.2.0",
|
||||||
"electron-updater": "^6.3.0",
|
"electron-updater": "^6.3.0",
|
||||||
"uiohook-napi": "^1.5.5"
|
"uiohook-napi": "^1.5.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -265,6 +265,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Native system-audio capture ────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Electron's own `audio: 'loopback'` captures the whole output mix, which
|
||||||
|
// includes this app playing everyone else's voices — so those voices went back
|
||||||
|
// out inside the screen share and every listener heard themselves. Not acoustic
|
||||||
|
// echo: it is a digital copy of the output, so headphones never helped.
|
||||||
|
//
|
||||||
|
// This module captures with process-level isolation instead: only the shared
|
||||||
|
// window (include mode), or everything except this app (exclude mode).
|
||||||
|
interface NativeAudioMeta {
|
||||||
|
sampleRate: number;
|
||||||
|
channels: number;
|
||||||
|
bitsPerSample: number;
|
||||||
|
isFloat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeScreenShareAudio {
|
||||||
|
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
|
||||||
|
stopCapture(): boolean;
|
||||||
|
getPidFromWindowHandle(windowHandle: number): number;
|
||||||
|
isAvailable(): boolean;
|
||||||
|
getLoadError(): string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nativeAudio: NativeScreenShareAudio | null = null;
|
||||||
|
try {
|
||||||
|
// Required lazily and defensively: a native module that fails to load must
|
||||||
|
// degrade to sharing without audio, never stop the app from starting.
|
||||||
|
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
|
||||||
|
if (!nativeAudio.isAvailable()) {
|
||||||
|
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
|
||||||
|
nativeAudio = null;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Main:ScreenShare] Native audio module missing:', err);
|
||||||
|
nativeAudio = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let nativeAudioActive = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
|
||||||
|
* Recovering the handle lets us capture only that window's audio, which is
|
||||||
|
* better than excluding ourselves: a game's sound goes out, the rest of the
|
||||||
|
* desktop does not.
|
||||||
|
*/
|
||||||
|
function windowHandleFromSourceId(sourceId: string): number | null {
|
||||||
|
const match = /^window:(\d+)/.exec(sourceId);
|
||||||
|
if (!match) return null;
|
||||||
|
const handle = Number(match[1]);
|
||||||
|
return Number.isFinite(handle) && handle > 0 ? handle : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startNativeAudioCapture(sourceId: string): boolean {
|
||||||
|
if (!nativeAudio || nativeAudioActive) return false;
|
||||||
|
|
||||||
|
let targetPid = process.pid;
|
||||||
|
let includeMode = false;
|
||||||
|
|
||||||
|
const handle = windowHandleFromSourceId(sourceId);
|
||||||
|
if (handle !== null) {
|
||||||
|
const pid = nativeAudio.getPidFromWindowHandle(handle);
|
||||||
|
// pid 0 means the handle did not resolve; fall back to excluding ourselves
|
||||||
|
// rather than capturing nothing.
|
||||||
|
if (pid > 0) {
|
||||||
|
targetPid = pid;
|
||||||
|
includeMode = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
|
||||||
|
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||||
|
mainWindow.webContents.send('native-audio-data', data, meta);
|
||||||
|
});
|
||||||
|
nativeAudioActive = started;
|
||||||
|
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
|
||||||
|
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
|
||||||
|
return started;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Main:ScreenShare] Native audio start failed:', err);
|
||||||
|
nativeAudioActive = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopNativeAudioCapture(): void {
|
||||||
|
if (!nativeAudio || !nativeAudioActive) return;
|
||||||
|
try {
|
||||||
|
nativeAudio.stopCapture();
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
|
||||||
|
}
|
||||||
|
nativeAudioActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Tray Icon ──────────────────────────────────────────────────────────────
|
// ─── Tray Icon ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function generateFallbackTrayIcon(): Electron.NativeImage {
|
function generateFallbackTrayIcon(): Electron.NativeImage {
|
||||||
@@ -492,7 +588,9 @@ function createTray(): void {
|
|||||||
|
|
||||||
function showNotification(title: string, body: string, onClick?: () => void): void {
|
function showNotification(title: string, body: string, onClick?: () => void): void {
|
||||||
if (!Notification.isSupported()) return;
|
if (!Notification.isSupported()) return;
|
||||||
const notification = new Notification({ title, body, silent: false });
|
// silent: o som do sistema é o que mais incomoda numa notificação de chat, e
|
||||||
|
// o app toca o seu próprio efeito — que combina com os demais sons dele.
|
||||||
|
const notification = new Notification({ title, body, silent: true });
|
||||||
notification.on('click', onClick ?? (() => {
|
notification.on('click', onClick ?? (() => {
|
||||||
mainWindow?.show();
|
mainWindow?.show();
|
||||||
mainWindow?.focus();
|
mainWindow?.focus();
|
||||||
@@ -594,6 +692,10 @@ function registerIpcHandlers(): void {
|
|||||||
ipcMain.handle('get-app-version', () => app.getVersion());
|
ipcMain.handle('get-app-version', () => app.getVersion());
|
||||||
|
|
||||||
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
|
// Screen share picker coordination (used by setDisplayMediaRequestHandler)
|
||||||
|
ipcMain.on('native-audio-stop', () => {
|
||||||
|
stopNativeAudioCapture();
|
||||||
|
});
|
||||||
|
|
||||||
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
|
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
|
||||||
// Handled via ipcMain.once in the display media handler — this is just
|
// Handled via ipcMain.once in the display media handler — this is just
|
||||||
// a safety net to prevent unhandled-message warnings
|
// a safety net to prevent unhandled-message warnings
|
||||||
@@ -953,7 +1055,19 @@ if (!gotTheLock) {
|
|||||||
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
|
// `PulseaudioLoopbackForScreenShare` feature flag we enable above.
|
||||||
// Fails on PipeWire-only systems without pulse compat — the
|
// Fails on PipeWire-only systems without pulse compat — the
|
||||||
// renderer catches that and toasts the user.
|
// renderer catches that and toasts the user.
|
||||||
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) });
|
// Audio no longer rides on the Electron stream: `loopback` would put
|
||||||
|
// this app's own output (everyone else's voices) back into the share.
|
||||||
|
// The native module captures it separately, isolated by process, and
|
||||||
|
// the renderer turns it into the track LiveKit publishes.
|
||||||
|
if (shareAudio) {
|
||||||
|
const started = startNativeAudioCapture(sourceId);
|
||||||
|
if (!started) {
|
||||||
|
// Tell the renderer so it can say the share is going out silently,
|
||||||
|
// instead of the user assuming sound is included.
|
||||||
|
mainWindow?.webContents.send('native-audio-unavailable');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
callback({ video: selected });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Main:ScreenShare] Handler error:', err);
|
console.error('[Main:ScreenShare] Handler error:', err);
|
||||||
// @ts-ignore — deny the request without crashing
|
// @ts-ignore — deny the request without crashing
|
||||||
|
|||||||
@@ -65,6 +65,24 @@ contextBridge.exposeInMainWorld('backspace', {
|
|||||||
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
|
onScreenShareSources: (callback: (sources: unknown[]) => void) => {
|
||||||
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
|
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
|
||||||
},
|
},
|
||||||
|
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
|
||||||
|
// into a MediaStreamTrack for LiveKit.
|
||||||
|
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
|
||||||
|
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
|
||||||
|
// Copied out of the transferred buffer: reusing it across IPC messages
|
||||||
|
// would let a later chunk overwrite one still being read.
|
||||||
|
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
|
||||||
|
};
|
||||||
|
ipcRenderer.on('native-audio-data', listener);
|
||||||
|
return () => ipcRenderer.off('native-audio-data', listener);
|
||||||
|
},
|
||||||
|
onNativeAudioUnavailable: (callback: () => void) => {
|
||||||
|
const listener = () => callback();
|
||||||
|
ipcRenderer.on('native-audio-unavailable', listener);
|
||||||
|
return () => ipcRenderer.off('native-audio-unavailable', listener);
|
||||||
|
},
|
||||||
|
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
|
||||||
|
|
||||||
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
|
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
|
||||||
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
|
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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`);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE `messages` ADD `pinned_at` integer;--> statement-breakpoint
|
||||||
|
ALTER TABLE `messages` ADD `pinned_by` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_messages_channel_pinned` ON `messages` (`channel_id`,`pinned_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_messages_channel_user_created` ON `messages` (`channel_id`,`user_id`,`created_at`);
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
CREATE TABLE `space_emojis` (
|
||||||
|
`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 TABLE `space_stickers` (
|
||||||
|
`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
|
||||||
|
ALTER TABLE `messages` ADD `sticker_id` text;--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_emojis_space` ON `space_emojis` (`space_id`);--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `idx_space_emojis_space_name` ON `space_emojis` (`space_id`,`name`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_space_stickers_space` ON `space_stickers` (`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
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,55 @@
|
|||||||
"when": 1783035334526,
|
"when": 1783035334526,
|
||||||
"tag": "0010_broken_blazing_skull",
|
"tag": "0010_broken_blazing_skull",
|
||||||
"breakpoints": true
|
"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
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295165743,
|
||||||
|
"tag": "0016_lyrical_freak",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 17,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1788295936825,
|
||||||
|
"tag": "0017_purple_wildside",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -76,6 +76,10 @@ export const config = {
|
|||||||
sourceCodeUrl,
|
sourceCodeUrl,
|
||||||
commit,
|
commit,
|
||||||
|
|
||||||
|
spotify: {
|
||||||
|
clientId: envOptional('SPOTIFY_CLIENT_ID'),
|
||||||
|
clientSecret: envOptional('SPOTIFY_CLIENT_SECRET'),
|
||||||
|
},
|
||||||
livekit: {
|
livekit: {
|
||||||
url: envOptional('LIVEKIT_URL'),
|
url: envOptional('LIVEKIT_URL'),
|
||||||
apiKey: envOptional('LIVEKIT_API_KEY'),
|
apiKey: envOptional('LIVEKIT_API_KEY'),
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ export const messages = sqliteTable('messages', {
|
|||||||
replyToId: text('reply_to_id'),
|
replyToId: text('reply_to_id'),
|
||||||
content: text('content'),
|
content: text('content'),
|
||||||
editedAt: integer('edited_at'),
|
editedAt: integer('edited_at'),
|
||||||
|
// Pinned state lives on the message rather than in a join table: a message is
|
||||||
|
// pinned in exactly one channel — its own — so a separate table would only
|
||||||
|
// add a join to every pin lookup.
|
||||||
|
pinnedAt: integer('pinned_at'),
|
||||||
|
pinnedBy: text('pinned_by'),
|
||||||
|
// Figurinha enviada como mensagem. `ON DELETE set null` de propósito: apagar
|
||||||
|
// a figurinha do servidor não pode apagar o histórico de quem a usou.
|
||||||
|
stickerId: text('sticker_id'),
|
||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
}, (table) => ({
|
}, (table) => ({
|
||||||
replyToFk: foreignKey({
|
replyToFk: foreignKey({
|
||||||
@@ -89,6 +97,10 @@ export const messages = sqliteTable('messages', {
|
|||||||
}).onDelete('set null'),
|
}).onDelete('set null'),
|
||||||
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
channelIdx: index('idx_messages_channel_id').on(table.channelId),
|
||||||
userIdx: index('idx_messages_user_id').on(table.userId),
|
userIdx: index('idx_messages_user_id').on(table.userId),
|
||||||
|
// Listing a channel's pins, newest first, without scanning its history.
|
||||||
|
pinnedIdx: index('idx_messages_channel_pinned').on(table.channelId, table.pinnedAt),
|
||||||
|
// Filtered search: author within a channel, ordered by recency.
|
||||||
|
searchIdx: index('idx_messages_channel_user_created').on(table.channelId, table.userId, table.createdAt),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const attachments = sqliteTable('attachments', {
|
export const attachments = sqliteTable('attachments', {
|
||||||
@@ -549,3 +561,133 @@ export const inviteRedemptions = sqliteTable('invite_redemptions', {
|
|||||||
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
|
inviteIdx: index('idx_invite_redemptions_invite_id').on(table.inviteId),
|
||||||
userIdx: index('idx_invite_redemptions_user_id').on(table.userId),
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis próprios de um espaço, referenciados por `:nome:` nas mensagens.
|
||||||
|
*
|
||||||
|
* O nome é único por espaço: `:trollface:` tem de resolver para uma imagem só,
|
||||||
|
* senão o render vira loteria. Espaços diferentes podem repetir o nome.
|
||||||
|
*/
|
||||||
|
export const spaceEmojis = sqliteTable('space_emojis', {
|
||||||
|
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_space_emojis_space').on(table.spaceId),
|
||||||
|
nameUnique: uniqueIndex('idx_space_emojis_space_name').on(table.spaceId, table.name),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Figurinhas do espaço. Tabela separada dos emojis de propósito: emoji entra
|
||||||
|
* no meio do texto e figurinha ocupa a mensagem inteira — tamanhos, limites e
|
||||||
|
* caminho de render são diferentes.
|
||||||
|
*/
|
||||||
|
export const spaceStickers = sqliteTable('space_stickers', {
|
||||||
|
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_space_stickers_space').on(table.spaceId),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import { uploadRoutes } from './routes/uploads.js';
|
|||||||
import { filesRoutes } from './routes/files.js';
|
import { filesRoutes } from './routes/files.js';
|
||||||
import { dmRoutes } from './routes/dm.js';
|
import { dmRoutes } from './routes/dm.js';
|
||||||
import { livekitRoutes } from './routes/livekit.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 { expressionRoutes } from './routes/expressions.js';
|
||||||
|
import { closeOrphanedVoiceSessions } from './utils/voiceSessions.js';
|
||||||
import { socialRoutes } from './routes/social.js';
|
import { socialRoutes } from './routes/social.js';
|
||||||
import { settingsRoutes } from './routes/settings.js';
|
import { settingsRoutes } from './routes/settings.js';
|
||||||
import { utilRoutes } from './routes/utils.js';
|
import { utilRoutes } from './routes/utils.js';
|
||||||
@@ -109,6 +115,10 @@ async function main(): Promise<void> {
|
|||||||
// Initialize database
|
// Initialize database
|
||||||
getDb();
|
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
|
// Reset orphaned `users.status` rows for locally-homed users. The previous
|
||||||
// process's in-memory disconnect timers are gone, so any non-offline row
|
// process's in-memory disconnect timers are gone, so any non-offline row
|
||||||
// is stale by construction. Replicated (federated) rows are skipped — their
|
// is stale by construction. Replicated (federated) rows are skipped — their
|
||||||
@@ -126,6 +136,11 @@ async function main(): Promise<void> {
|
|||||||
await app.register(filesRoutes);
|
await app.register(filesRoutes);
|
||||||
await app.register(dmRoutes);
|
await app.register(dmRoutes);
|
||||||
await app.register(livekitRoutes);
|
await app.register(livekitRoutes);
|
||||||
|
await app.register(spotifyRoutes);
|
||||||
|
await app.register(auditRoutes);
|
||||||
|
await app.register(statsRoutes);
|
||||||
|
await app.register(soundboardRoutes);
|
||||||
|
await app.register(expressionRoutes);
|
||||||
await app.register(socialRoutes);
|
await app.register(socialRoutes);
|
||||||
await app.register(settingsRoutes);
|
await app.register(settingsRoutes);
|
||||||
await app.register(utilRoutes);
|
await app.register(utilRoutes);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { recordAuditEvent } from '../utils/auditLog.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
import { isMember, hasPermission, getChannelSpaceId, PermissionBits, computePermissions } from '../utils/permissions.js';
|
||||||
import { permissionsToString } from '@backspace/shared/src/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
|
// Return the channel with the creator's computed permissions (same shape as
|
||||||
// the channel_created WS event) so the client can render it immediately
|
// the channel_created WS event) so the client can render it immediately
|
||||||
// without waiting for the broadcast to round-trip.
|
// 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);
|
const creatorPerms = computePermissions(request.userId, id, channelId);
|
||||||
return reply.code(201).send({
|
return reply.code(201).send({
|
||||||
...channelData,
|
...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);
|
return reply.code(200).send(channelData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -418,6 +437,15 @@ export async function channelRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
connectionManager.sendToUser(uid, deleteEvent);
|
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 });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
|
import { and, 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';
|
||||||
|
|
||||||
|
const MAX_EMOJIS_PER_SPACE = 100;
|
||||||
|
const MAX_STICKERS_PER_SPACE = 50;
|
||||||
|
const MAX_NAME_LENGTH = 32;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nomes viram `:nome:` no texto, então só letras, números e sublinhado —
|
||||||
|
* espaço ou dois-pontos dentro do nome tornariam a referência impossível de
|
||||||
|
* delimitar.
|
||||||
|
*/
|
||||||
|
const NAME_PATTERN = /^[a-z0-9_]{2,32}$/;
|
||||||
|
|
||||||
|
function normalizeName(raw: unknown): string | null {
|
||||||
|
if (typeof raw !== 'string') return null;
|
||||||
|
const name = raw.trim().toLowerCase().replace(/\s+/g, '_').slice(0, MAX_NAME_LENGTH);
|
||||||
|
return NAME_PATTERN.test(name) ? name : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** O nome do arquivo é chave no diretório de uploads, nunca um caminho. */
|
||||||
|
function validFilename(raw: unknown): raw is string {
|
||||||
|
return typeof raw === 'string' && raw.length > 0
|
||||||
|
&& !raw.includes('/') && !raw.includes('\\') && !raw.includes('..');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expressionRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
// ─── Emojis ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/emojis', { 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.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ emojis: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/emojis', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
const name = normalizeName(request.body?.name);
|
||||||
|
if (!name) {
|
||||||
|
return reply.code(400).send({ error: 'Name must be 2-32 chars: letters, numbers, underscore', statusCode: 400 });
|
||||||
|
}
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_EMOJIS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_EMOJIS_PER_SPACE} emojis`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(and(eq(schema.spaceEmojis.spaceId, id), eq(schema.spaceEmojis.name, name))).get();
|
||||||
|
if (existing) {
|
||||||
|
return reply.code(409).send({ error: `:${name}: already exists in this space`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceEmojis).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/emojis/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const emoji = db.select().from(schema.spaceEmojis)
|
||||||
|
.where(eq(schema.spaceEmojis.id, request.params.id)).get();
|
||||||
|
if (!emoji) return reply.code(404).send({ error: 'Emoji not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, emoji.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceEmojis).where(eq(schema.spaceEmojis.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Stickers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/spaces/:id/stickers', { 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.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, request.params.id)).all();
|
||||||
|
return reply.code(200).send({ stickers: rows });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
|
||||||
|
'/api/spaces/:id/stickers', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
// Figurinha é escolhida numa grade, não digitada, então o nome é rótulo
|
||||||
|
// e aceita acento e espaço — ao contrário do emoji.
|
||||||
|
const name = typeof request.body?.name === 'string'
|
||||||
|
? request.body.name.trim().slice(0, MAX_NAME_LENGTH) : '';
|
||||||
|
if (!name) return reply.code(400).send({ error: 'Name is required', statusCode: 400 });
|
||||||
|
if (!validFilename(request.body?.filename)) {
|
||||||
|
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const count = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.spaceId, id)).all().length;
|
||||||
|
if (count >= MAX_STICKERS_PER_SPACE) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_STICKERS_PER_SPACE} stickers`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = {
|
||||||
|
id: generateSnowflake(),
|
||||||
|
spaceId: id,
|
||||||
|
name,
|
||||||
|
filename: request.body!.filename!,
|
||||||
|
uploaderId: request.userId,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
db.insert(schema.spaceStickers).values(row).run();
|
||||||
|
return reply.code(201).send(row);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/stickers/:id', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const sticker = db.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, request.params.id)).get();
|
||||||
|
if (!sticker) return reply.code(404).send({ error: 'Sticker not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, sticker.spaceId, PermissionBits.MANAGE_SPACE)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
db.delete(schema.spaceStickers).where(eq(schema.spaceStickers.id, request.params.id)).run();
|
||||||
|
return reply.code(204).send();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
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 { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import type { GifResult } from '@backspace/shared';
|
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: '' });
|
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();
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { eq, and, desc, lt, inArray } from 'drizzle-orm';
|
import { eq, and, desc, lt, inArray, isNotNull } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
@@ -145,6 +145,11 @@ export function buildMessageWithUser(
|
|||||||
content: message.content,
|
content: message.content,
|
||||||
editedAt: message.editedAt,
|
editedAt: message.editedAt,
|
||||||
createdAt: message.createdAt,
|
createdAt: message.createdAt,
|
||||||
|
// Carried on every message so the client can show the pin marker without a
|
||||||
|
// second request, and so the pins panel and the timeline agree.
|
||||||
|
pinnedAt: message.pinnedAt,
|
||||||
|
pinnedBy: message.pinnedBy,
|
||||||
|
stickerId: message.stickerId,
|
||||||
user: sanitizeUser(user),
|
user: sanitizeUser(user),
|
||||||
attachments: attachmentRows.map(a => ({
|
attachments: attachmentRows.map(a => ({
|
||||||
id: a.id,
|
id: a.id,
|
||||||
@@ -271,7 +276,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { id } = request.params;
|
const { id } = request.params;
|
||||||
const { content, attachments: attachmentIds, replyToId } = request.body;
|
const { content, attachments: attachmentIds, replyToId, stickerId } = request.body;
|
||||||
|
|
||||||
const spaceId = getChannelSpaceId(id);
|
const spaceId = getChannelSpaceId(id);
|
||||||
if (!spaceId) {
|
if (!spaceId) {
|
||||||
@@ -287,18 +292,35 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
return reply.code(403).send({ error: 'Missing ATTACH_FILES permission', statusCode: 403 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const db0 = getDb();
|
||||||
|
const db = db0;
|
||||||
|
|
||||||
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
const hasContent = content && typeof content === 'string' && content.trim().length > 0;
|
||||||
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
const hasAttachments = attachmentIds && attachmentIds.length > 0;
|
||||||
|
|
||||||
if (!hasContent && !hasAttachments) {
|
// Figurinha vale como conteúdo: a mensagem é a figurinha.
|
||||||
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
|
let sticker = null;
|
||||||
|
if (stickerId) {
|
||||||
|
sticker = db0.select().from(schema.spaceStickers)
|
||||||
|
.where(eq(schema.spaceStickers.id, stickerId)).get() ?? null;
|
||||||
|
if (!sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker not found', statusCode: 400 });
|
||||||
|
}
|
||||||
|
// Só figurinhas do próprio espaço: aceitar de outro vazaria imagem entre
|
||||||
|
// servidores que não têm relação nenhuma.
|
||||||
|
if (sticker.spaceId !== spaceId) {
|
||||||
|
return reply.code(400).send({ error: 'Sticker belongs to another space', statusCode: 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasContent && !hasAttachments && !sticker) {
|
||||||
|
return reply.code(400).send({ error: 'Message must have content, attachments or a sticker', statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
if (content && content.length > MAX_MESSAGE_LENGTH) {
|
||||||
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const messageId = generateSnowflake();
|
const messageId = generateSnowflake();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
@@ -323,6 +345,7 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
channelId: id,
|
channelId: id,
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
replyToId: replyToId || null,
|
replyToId: replyToId || null,
|
||||||
|
stickerId: sticker ? sticker.id : null,
|
||||||
content: content?.trim() || null,
|
content: content?.trim() || null,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
}).run();
|
}).run();
|
||||||
@@ -500,4 +523,120 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
|
|
||||||
return reply.code(200).send({ success: true });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Pins ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assembles rows into the shape clients expect. Same batching the channel
|
||||||
|
* listing uses — kept in one place so pins and history cannot drift apart in
|
||||||
|
* what they include.
|
||||||
|
*/
|
||||||
|
function assembleMessages(rows: (typeof schema.messages.$inferSelect)[]): MessageWithUser[] {
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const db = getDb();
|
||||||
|
const userIds = [...new Set(rows.map(m => m.userId))];
|
||||||
|
const userMap = new Map(
|
||||||
|
db.select().from(schema.users).where(inArray(schema.users.id, userIds)).all().map(u => [u.id, u]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messageIds = rows.map(m => m.id);
|
||||||
|
const attachmentMap = new Map<string, (typeof schema.attachments.$inferSelect)[]>();
|
||||||
|
for (const att of db.select().from(schema.attachments)
|
||||||
|
.where(inArray(schema.attachments.messageId, messageIds)).all()) {
|
||||||
|
const mid = att.messageId ?? '';
|
||||||
|
if (!attachmentMap.has(mid)) attachmentMap.set(mid, []);
|
||||||
|
attachmentMap.get(mid)!.push(att);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reactionsMap = fetchReactionsForMessages(messageIds);
|
||||||
|
const embedMap = fetchEmbedsForMessages(messageIds);
|
||||||
|
const replyToMap = fetchReplyToMessages(rows);
|
||||||
|
|
||||||
|
return rows.map(m => {
|
||||||
|
const user = userMap.get(m.userId);
|
||||||
|
if (!user) return null;
|
||||||
|
const replyTo = m.replyToId ? (replyToMap.get(m.replyToId) ?? null) : null;
|
||||||
|
return buildMessageWithUser(m, user, attachmentMap.get(m.id) ?? [],
|
||||||
|
reactionsMap.get(m.id) ?? [], replyTo, embedMap.get(m.id) ?? []);
|
||||||
|
}).filter((m): m is MessageWithUser => m !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discord caps a channel at 50; the same ceiling keeps the panel usable. */
|
||||||
|
const MAX_PINS_PER_CHANNEL = 50;
|
||||||
|
|
||||||
|
app.get<{ Params: { id: string } }>('/api/channels/:id/pins', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const { id } = request.params;
|
||||||
|
const spaceId = getChannelSpaceId(id);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.VIEW_CHANNEL, id)) {
|
||||||
|
return reply.code(403).send({ error: 'Cannot view this channel', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const db = getDb();
|
||||||
|
const rows = db.select()
|
||||||
|
.from(schema.messages)
|
||||||
|
.where(and(eq(schema.messages.channelId, id), isNotNull(schema.messages.pinnedAt)))
|
||||||
|
.orderBy(desc(schema.messages.pinnedAt))
|
||||||
|
.all();
|
||||||
|
|
||||||
|
return reply.code(200).send({ messages: assembleMessages(rows) });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||||
|
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||||
|
|
||||||
|
const spaceId = getChannelSpaceId(message.channelId);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
if (message.pinnedAt) return reply.code(200).send({ success: true });
|
||||||
|
|
||||||
|
const count = db.select({ id: schema.messages.id })
|
||||||
|
.from(schema.messages)
|
||||||
|
.where(and(eq(schema.messages.channelId, message.channelId), isNotNull(schema.messages.pinnedAt)))
|
||||||
|
.all().length;
|
||||||
|
if (count >= MAX_PINS_PER_CHANNEL) {
|
||||||
|
return reply.code(409).send({ error: `At most ${MAX_PINS_PER_CHANNEL} pinned messages per channel`, statusCode: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.messages)
|
||||||
|
.set({ pinnedAt: Date.now(), pinnedBy: request.userId })
|
||||||
|
.where(eq(schema.messages.id, message.id)).run();
|
||||||
|
|
||||||
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||||
|
type: 'message_pinned',
|
||||||
|
channelId: message.channelId,
|
||||||
|
messageId: message.id,
|
||||||
|
pinned: true,
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete<{ Params: { id: string } }>('/api/messages/:id/pin', { preHandler: authenticate }, async (request, reply) => {
|
||||||
|
const db = getDb();
|
||||||
|
const message = db.select().from(schema.messages).where(eq(schema.messages.id, request.params.id)).get();
|
||||||
|
if (!message) return reply.code(404).send({ error: 'Message not found', statusCode: 404 });
|
||||||
|
|
||||||
|
const spaceId = getChannelSpaceId(message.channelId);
|
||||||
|
if (!spaceId) return reply.code(404).send({ error: 'Channel not found', statusCode: 404 });
|
||||||
|
if (!hasPermission(request.userId, spaceId, PermissionBits.MANAGE_MESSAGES, message.channelId)) {
|
||||||
|
return reply.code(403).send({ error: 'Missing MANAGE_MESSAGES permission', statusCode: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
db.update(schema.messages)
|
||||||
|
.set({ pinnedAt: null, pinnedBy: null })
|
||||||
|
.where(eq(schema.messages.id, message.id)).run();
|
||||||
|
|
||||||
|
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||||
|
type: 'message_pinned',
|
||||||
|
channelId: message.channelId,
|
||||||
|
messageId: message.id,
|
||||||
|
pinned: false,
|
||||||
|
});
|
||||||
|
return reply.code(200).send({ success: true });
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify';
|
|||||||
import { eq, and, inArray } from 'drizzle-orm';
|
import { eq, and, inArray } from 'drizzle-orm';
|
||||||
import { getDb, getRawDb, schema } from '../db/index.js';
|
import { getDb, getRawDb, schema } from '../db/index.js';
|
||||||
import { authenticate } from '../utils/auth.js';
|
import { authenticate } from '../utils/auth.js';
|
||||||
|
import { recordAuditEvent } from '../utils/auditLog.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.js';
|
import { generateSnowflake } from '../utils/snowflake.js';
|
||||||
import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.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';
|
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,
|
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);
|
return reply.code(200).send(spaceData);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1005,6 +1015,16 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
userId: uid,
|
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 });
|
return reply.code(200).send({ success: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1068,6 +1088,17 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
checkVoicePermissions(id);
|
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);
|
return reply.code(201).send(role);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1125,6 +1156,15 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
}
|
}
|
||||||
checkVoicePermissions(id);
|
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);
|
return reply.code(200).send(updated);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1247,6 +1287,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
space: spaceData,
|
space: spaceData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
recordAuditEvent({
|
||||||
|
spaceId: id,
|
||||||
|
actorId: request.userId,
|
||||||
|
action: 'space.transfer_ownership',
|
||||||
|
targetType: 'user',
|
||||||
|
targetId: newOwnerId,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(spaceData);
|
return reply.code(200).send(spaceData);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
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.
|
||||||
|
*
|
||||||
|
* A paused track is still reported, marked `paused`. Returning null for it made
|
||||||
|
* the block disappear on every pause — and, together with the silent gap
|
||||||
|
* between two songs, produced the flicker of it vanishing and coming back.
|
||||||
|
*/
|
||||||
|
function toActivity(track: SpotifyTrack): Activity | null {
|
||||||
|
if (!track.item) return null;
|
||||||
|
const now = Date.now();
|
||||||
|
const progress = track.progress_ms ?? 0;
|
||||||
|
return {
|
||||||
|
type: 'listening',
|
||||||
|
name: 'Spotify',
|
||||||
|
paused: !track.is_playing,
|
||||||
|
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, serverTime: Date.now() });
|
||||||
|
|
||||||
|
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, serverTime: Date.now() });
|
||||||
|
if (!res.ok) return reply.code(200).send({ activity: null, connected: res.status !== 401, serverTime: Date.now() });
|
||||||
|
|
||||||
|
const track = await res.json() as SpotifyTrack;
|
||||||
|
return reply.code(200).send({ activity: toActivity(track), connected: true, serverTime: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -168,6 +168,10 @@ export function handleClientEvent(
|
|||||||
case 'voice_join':
|
case 'voice_join':
|
||||||
handleVoiceJoin(event, userId, ws);
|
handleVoiceJoin(event, userId, ws);
|
||||||
break;
|
break;
|
||||||
|
case 'soundboard_play':
|
||||||
|
handleSoundboardPlay(event, userId);
|
||||||
|
break;
|
||||||
|
|
||||||
case 'voice_leave':
|
case 'voice_leave':
|
||||||
handleVoiceLeave(userId);
|
handleVoiceLeave(userId);
|
||||||
break;
|
break;
|
||||||
@@ -470,6 +474,10 @@ function validateActivities(raw: unknown): Activity[] | null {
|
|||||||
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
|
if (ts.start !== undefined || ts.end !== undefined) activity.timestamps = ts;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Preserved through validation: without it the paused flag is stripped on
|
||||||
|
// its way to everyone else, and the block resumes ticking on their screens.
|
||||||
|
if (obj.paused === true) activity.paused = true;
|
||||||
|
|
||||||
if (obj.assets && typeof obj.assets === 'object') {
|
if (obj.assets && typeof obj.assets === 'object') {
|
||||||
const aObj = obj.assets as Record<string, unknown>;
|
const aObj = obj.assets as Record<string, unknown>;
|
||||||
const assets: ActivityAssets = {};
|
const assets: ActivityAssets = {};
|
||||||
@@ -724,12 +732,14 @@ function handleVoiceJoin(event: Record<string, unknown>, userId: string, ws: Web
|
|||||||
// Join room
|
// Join room
|
||||||
connectionManager.joinRoom(channelId, userId);
|
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, {
|
connectionManager.sendToRoom(channelId, {
|
||||||
type: 'voice_state_update',
|
type: 'voice_state_update',
|
||||||
channelId,
|
channelId,
|
||||||
userId,
|
userId,
|
||||||
action: 'join',
|
action: 'join',
|
||||||
|
startedAt: connectionManager.getRoomStartedAt(channelId) ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Also broadcast current voice status if it exists (persisted during moves)
|
// Also broadcast current voice status if it exists (persisted during moves)
|
||||||
@@ -794,6 +804,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 {
|
function handleVoiceLeave(userId: string): void {
|
||||||
connectionManager.clearVoiceWs(userId);
|
connectionManager.clearVoiceWs(userId);
|
||||||
const left = connectionManager.leaveCurrentRoom(userId);
|
const left = connectionManager.leaveCurrentRoom(userId);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import type { WebSocket } from 'ws';
|
import type { WebSocket } from 'ws';
|
||||||
import { verifyJwt } from '../utils/auth.js';
|
import { verifyJwt } from '../utils/auth.js';
|
||||||
|
import { openVoiceSession, closeVoiceSession } from '../utils/voiceSessions.js';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
|
import { eq, and, or, inArray, isNull, desc, sql } from 'drizzle-orm';
|
||||||
import { handleClientEvent } from './events.js';
|
import { handleClientEvent } from './events.js';
|
||||||
@@ -414,6 +415,7 @@ class ConnectionManager {
|
|||||||
type: 'space_voice_state',
|
type: 'space_voice_state',
|
||||||
spaceId,
|
spaceId,
|
||||||
voiceStates: snapshot.voiceStates,
|
voiceStates: snapshot.voiceStates,
|
||||||
|
voiceRoomStarts: snapshot.voiceRoomStarts,
|
||||||
voiceUserStates: snapshot.voiceUserStates,
|
voiceUserStates: snapshot.voiceUserStates,
|
||||||
spaceVoiceStates: snapshot.spaceVoiceStates,
|
spaceVoiceStates: snapshot.spaceVoiceStates,
|
||||||
});
|
});
|
||||||
@@ -441,11 +443,13 @@ class ConnectionManager {
|
|||||||
*/
|
*/
|
||||||
buildSpaceVoiceState(spaceId: string, userId: string): {
|
buildSpaceVoiceState(spaceId: string, userId: string): {
|
||||||
voiceStates: Record<string, string[]>;
|
voiceStates: Record<string, string[]>;
|
||||||
|
voiceRoomStarts: Record<string, number>;
|
||||||
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>;
|
||||||
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }>;
|
||||||
} {
|
} {
|
||||||
const db = getDb();
|
const db = getDb();
|
||||||
const voiceStates: Record<string, string[]> = {};
|
const voiceStates: Record<string, string[]> = {};
|
||||||
|
const voiceRoomStarts: Record<string, number> = {};
|
||||||
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
|
const voiceUserStates: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }> = {};
|
||||||
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
|
const spaceVoiceStates: Record<string, { spaceMuted: boolean; spaceDeafened: boolean; permissionMuted: boolean }> = {};
|
||||||
|
|
||||||
@@ -462,6 +466,8 @@ class ConnectionManager {
|
|||||||
if (participants.size > 0) {
|
if (participants.size > 0) {
|
||||||
const ids = Array.from(participants);
|
const ids = Array.from(participants);
|
||||||
voiceStates[ch.id] = ids;
|
voiceStates[ch.id] = ids;
|
||||||
|
const startedAt = this.getRoomStartedAt(ch.id);
|
||||||
|
if (startedAt !== null) voiceRoomStarts[ch.id] = startedAt;
|
||||||
for (const uid of ids) {
|
for (const uid of ids) {
|
||||||
const status = this.getVoiceUserStatus(uid);
|
const status = this.getVoiceUserStatus(uid);
|
||||||
if (status) voiceUserStates[uid] = status;
|
if (status) voiceUserStates[uid] = status;
|
||||||
@@ -499,7 +505,7 @@ class ConnectionManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { voiceStates, voiceUserStates, spaceVoiceStates };
|
return { voiceStates, voiceRoomStarts, voiceUserStates, spaceVoiceStates };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
|
// ─── Unified VoiceRoom API ─────────────────────────────────────────────────
|
||||||
@@ -714,6 +720,16 @@ class ConnectionManager {
|
|||||||
|
|
||||||
room.participants.add(userId);
|
room.participants.add(userId);
|
||||||
this.userToRoom.set(userId, roomId);
|
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;
|
return room;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,6 +762,8 @@ class ConnectionManager {
|
|||||||
const room = this.leaveRoom(roomId, userId);
|
const room = this.leaveRoom(roomId, userId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
|
closeVoiceSession(userId);
|
||||||
|
|
||||||
return { roomId, room };
|
return { roomId, room };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +775,9 @@ class ConnectionManager {
|
|||||||
const displaced: string[] = [];
|
const displaced: string[] = [];
|
||||||
for (const userId of room.participants) {
|
for (const userId of room.participants) {
|
||||||
this.userToRoom.delete(userId);
|
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);
|
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. */
|
/** Send to all connections of all online users. */
|
||||||
sendToAll(event: ServerEvent, excludeUserId?: string): void {
|
sendToAll(event: ServerEvent, excludeUserId?: string): void {
|
||||||
const message = JSON.stringify(event);
|
const message = JSON.stringify(event);
|
||||||
@@ -1073,7 +1118,7 @@ class ConnectionManager {
|
|||||||
if (connections.size === 0) return;
|
if (connections.size === 0) return;
|
||||||
|
|
||||||
const readyData = buildReadyPayload(userId);
|
const readyData = buildReadyPayload(userId);
|
||||||
const message = JSON.stringify({ type: 'ready', ...readyData });
|
const message = JSON.stringify({ type: 'ready', serverTime: Date.now(), ...readyData });
|
||||||
for (const ws of connections) {
|
for (const ws of connections) {
|
||||||
if (ws.readyState === 1) {
|
if (ws.readyState === 1) {
|
||||||
ws.send(message);
|
ws.send(message);
|
||||||
@@ -1747,6 +1792,9 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
|||||||
const readyData = buildReadyPayload(userId);
|
const readyData = buildReadyPayload(userId);
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'ready',
|
type: 'ready',
|
||||||
|
// Lets each client measure its own offset from this server, so activity
|
||||||
|
// timestamps computed here render correctly on a machine whose clock drifts.
|
||||||
|
serverTime: Date.now(),
|
||||||
...readyData,
|
...readyData,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
"./src/activities": "./src/activities.ts",
|
"./src/activities": "./src/activities.ts",
|
||||||
"./src/activities.js": "./src/activities.ts",
|
"./src/activities.js": "./src/activities.ts",
|
||||||
"./src/constants": "./src/constants.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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -212,6 +212,11 @@ export interface Message {
|
|||||||
type?: 'user' | 'system';
|
type?: 'user' | 'system';
|
||||||
editedAt: number | null;
|
editedAt: number | null;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
|
/** Epoch millis when pinned; null when not pinned. */
|
||||||
|
pinnedAt?: number | null;
|
||||||
|
pinnedBy?: string | null;
|
||||||
|
/** Figurinha enviada como mensagem; o conteúdo fica vazio nesse caso. */
|
||||||
|
stickerId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageWithUser extends Message {
|
export interface MessageWithUser extends Message {
|
||||||
@@ -369,6 +374,12 @@ export interface ActivityAssets {
|
|||||||
export interface Activity {
|
export interface Activity {
|
||||||
type: ActivityType;
|
type: ActivityType;
|
||||||
name: string;
|
name: string;
|
||||||
|
/**
|
||||||
|
* Playback is paused. Kept as a state rather than dropping the activity:
|
||||||
|
* pausing a track used to remove the block entirely, so it vanished and
|
||||||
|
* reappeared on every pause and every gap between songs.
|
||||||
|
*/
|
||||||
|
paused?: boolean;
|
||||||
details?: string;
|
details?: string;
|
||||||
state?: string;
|
state?: string;
|
||||||
timestamps?: ActivityTimestamps;
|
timestamps?: ActivityTimestamps;
|
||||||
@@ -403,6 +414,7 @@ export type ClientEvent =
|
|||||||
| { type: 'typing_start'; channelId: string }
|
| { type: 'typing_start'; channelId: string }
|
||||||
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
| { type: 'presence_update'; status: 'online' | 'idle' | 'dnd' }
|
||||||
| { type: 'voice_join'; channelId: string }
|
| { type: 'voice_join'; channelId: string }
|
||||||
|
| { type: 'soundboard_play'; soundId: string }
|
||||||
| { type: 'voice_leave' }
|
| { type: 'voice_leave' }
|
||||||
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
|
| { type: 'dm_message_create'; dmChannelId: string; content?: string; attachments?: string[]; replyToId?: string }
|
||||||
| { type: 'dm_typing_start'; dmChannelId: string }
|
| { type: 'dm_typing_start'; dmChannelId: string }
|
||||||
@@ -426,13 +438,15 @@ export type ClientEvent =
|
|||||||
|
|
||||||
// Server → Client Events
|
// Server → Client Events
|
||||||
export type ServerEvent =
|
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'; serverTime?: number; 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_created'; message: MessageWithUser }
|
||||||
| { type: 'message_updated'; message: MessageWithUser }
|
| { type: 'message_updated'; message: MessageWithUser }
|
||||||
| { type: 'message_deleted'; messageId: string; channelId: string }
|
| { type: 'message_deleted'; messageId: string; channelId: string }
|
||||||
| { type: 'typing'; channelId: string; userId: string; username: string }
|
| { type: 'typing'; channelId: string; userId: string; username: string }
|
||||||
| { type: 'presence_update'; userId: string; status: string; activities?: Activity[] }
|
| { 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: 'message_pinned'; channelId: string; messageId: string; pinned: boolean }
|
||||||
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
| { type: 'member_joined'; spaceId: string; member: MemberWithUser }
|
||||||
| { type: 'member_left'; spaceId: string; userId: string }
|
| { type: 'member_left'; spaceId: string; userId: string }
|
||||||
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
| { type: 'dm_message_created'; message: DmMessageWithUser }
|
||||||
@@ -451,7 +465,7 @@ export type ServerEvent =
|
|||||||
| { type: 'dm_call_ended'; dmChannelId: string }
|
| { type: 'dm_call_ended'; dmChannelId: string }
|
||||||
| { type: 'dm_call_undeliverable'; dmChannelId: string | null; federatedCallId: string; terminal: boolean; phase: DmCallPhase; failures: DmCallUndeliverableFailure[] }
|
| { 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: '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_created'; dmChannel: DmChannel }
|
||||||
| { type: 'dm_channel_closed'; dmChannelId: string }
|
| { type: 'dm_channel_closed'; dmChannelId: string }
|
||||||
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
|
| { type: 'dm_channel_updated'; dmChannelId: string; name: string | null; icon: string | null }
|
||||||
@@ -587,6 +601,8 @@ export interface CreateMessageRequest {
|
|||||||
content: string;
|
content: string;
|
||||||
attachments?: string[];
|
attachments?: string[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
|
/** Envio de figurinha; nesse caso `content` fica vazio. */
|
||||||
|
stickerId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMessageRequest {
|
export interface UpdateMessageRequest {
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -71,7 +71,45 @@ import type {
|
|||||||
AttachProofResponse,
|
AttachProofResponse,
|
||||||
ReattachRequest,
|
ReattachRequest,
|
||||||
ReattachResponse,
|
ReattachResponse,
|
||||||
|
Activity,
|
||||||
} from '@backspace/shared';
|
} from '@backspace/shared';
|
||||||
|
import type { AuditEvent } from '@backspace/shared/src/audit.js';
|
||||||
|
|
||||||
|
export interface SpaceEmoji {
|
||||||
|
id: string;
|
||||||
|
spaceId: string;
|
||||||
|
name: string;
|
||||||
|
filename: string;
|
||||||
|
uploaderId: string | null;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SpaceSticker = SpaceEmoji;
|
||||||
|
|
||||||
|
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';
|
import { getApiForOrigin, getOwnerInstanceForDm } from '../utils/crossStoreResolvers';
|
||||||
|
|
||||||
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
export type { FederationPeer, FederationOrphanedAccount, FederationResetEvent, FederationResetEventsResponse, ApprovalRequest, PeeringSubscription, PeeringNotification };
|
||||||
@@ -279,6 +317,45 @@ export class BackspaceApiClient {
|
|||||||
trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
|
trending: (limit?: number, pos?: string) => Promise<{ results: GifResult[]; next: string }>;
|
||||||
search: (q: string, 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 }>;
|
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 expressions: {
|
||||||
|
emojis: (spaceId: string) => Promise<{ emojis: SpaceEmoji[] }>;
|
||||||
|
addEmoji: (spaceId: string, name: string, filename: string) => Promise<SpaceEmoji>;
|
||||||
|
removeEmoji: (id: string) => Promise<void>;
|
||||||
|
stickers: (spaceId: string) => Promise<{ stickers: SpaceSticker[] }>;
|
||||||
|
addSticker: (spaceId: string, name: string, filename: string) => Promise<SpaceSticker>;
|
||||||
|
removeSticker: (id: string) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
readonly pins: {
|
||||||
|
list: (channelId: string) => Promise<{ messages: MessageWithUser[] }>;
|
||||||
|
pin: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
|
unpin: (messageId: string) => Promise<{ success: boolean }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
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; serverTime?: number }>;
|
||||||
|
disconnect: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
readonly federation: {
|
readonly federation: {
|
||||||
@@ -688,6 +765,50 @@ 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.expressions = {
|
||||||
|
emojis: (spaceId) => request<{ emojis: SpaceEmoji[] }>('GET', `/spaces/${spaceId}/emojis`),
|
||||||
|
addEmoji: (spaceId, name, filename) => request<SpaceEmoji>('POST', `/spaces/${spaceId}/emojis`, { name, filename }),
|
||||||
|
removeEmoji: (id) => request<void>('DELETE', `/emojis/${id}`),
|
||||||
|
stickers: (spaceId) => request<{ stickers: SpaceSticker[] }>('GET', `/spaces/${spaceId}/stickers`),
|
||||||
|
addSticker: (spaceId, name, filename) => request<SpaceSticker>('POST', `/spaces/${spaceId}/stickers`, { name, filename }),
|
||||||
|
removeSticker: (id) => request<void>('DELETE', `/stickers/${id}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.pins = {
|
||||||
|
list: (channelId: string) => request<{ messages: MessageWithUser[] }>('GET', `/channels/${channelId}/pins`),
|
||||||
|
pin: (messageId: string) => request<{ success: boolean }>('PUT', `/messages/${messageId}/pin`),
|
||||||
|
unpin: (messageId: string) => request<{ success: boolean }>('DELETE', `/messages/${messageId}/pin`),
|
||||||
|
};
|
||||||
|
|
||||||
|
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; serverTime?: number }>('GET', '/connections/spotify/now-playing'),
|
||||||
|
disconnect: () => request<void>('DELETE', '/connections/spotify'),
|
||||||
|
};
|
||||||
|
|
||||||
this.gif = {
|
this.gif = {
|
||||||
trending: (limit = 30, pos?: string) => {
|
trending: (limit = 30, pos?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -695,6 +816,9 @@ export class BackspaceApiClient {
|
|||||||
if (pos) params.set('pos', pos);
|
if (pos) params.set('pos', pos);
|
||||||
return request<{ results: GifResult[]; next: string }>('GET', `/gif/trending?${params}`);
|
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) => {
|
search: (q: string, limit = 30, pos?: string) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.set('q', q);
|
params.set('q', q);
|
||||||
|
|||||||
@@ -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> {
|
async playSound(name: string, options: { loop?: boolean; volume?: number } = {}): Promise<AudioBufferSourceNode | null> {
|
||||||
await this.resumeContext();
|
await this.resumeContext();
|
||||||
const buffer = await this.loadSound(name);
|
const buffer = await this.loadSound(name);
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useInAppNotificationStore } from '../stores/inAppNotificationStore';
|
||||||
|
import { AudioManager } from '../audio/AudioManager';
|
||||||
|
import { getSfxVolume } from '../utils/sfx';
|
||||||
|
import { translate, useLocaleStore } from '../i18n';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
import { useVoiceStore } from '../stores/voiceStore';
|
import { useVoiceStore } from '../stores/voiceStore';
|
||||||
import { useAuthStore } from '../stores/authStore';
|
import { useAuthStore } from '../stores/authStore';
|
||||||
@@ -51,19 +55,39 @@ export function NotificationController() {
|
|||||||
|
|
||||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||||
if (isInitialMount.current) return;
|
if (isInitialMount.current) return;
|
||||||
if (windowFocused.current) return;
|
// Antes daqui só havia aviso com a janela fora de foco. Agora a janela em
|
||||||
|
// foco também avisa, mas dentro do app e apenas para outro canal — avisar
|
||||||
|
// sobre a conversa que a pessoa está lendo seria ruído.
|
||||||
|
const focused = windowFocused.current;
|
||||||
|
const currentChannel = state.currentChannelId;
|
||||||
|
|
||||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||||
for (const { message } of newEvents) {
|
for (const { message } of newEvents) {
|
||||||
if (message.userId !== currentUser?.id) {
|
if (message.userId !== currentUser?.id) {
|
||||||
|
if (focused && message.channelId === currentChannel) break;
|
||||||
|
|
||||||
const displayName = message.user?.displayName || message.user?.username || 'Someone';
|
const displayName = message.user?.displayName || message.user?.username || 'Someone';
|
||||||
const body = message.content
|
const body = message.content
|
||||||
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
|
? message.content.replace(/[*_~`>#\-\[\]]/g, '').slice(0, 100)
|
||||||
: 'Sent an attachment';
|
: translate(useLocaleStore.getState().locale, 'notify.attachment');
|
||||||
sendNotification(displayName, body, {
|
|
||||||
|
useInAppNotificationStore.getState().push({
|
||||||
|
title: displayName,
|
||||||
|
body,
|
||||||
|
avatar: message.user?.avatar ?? null,
|
||||||
|
userId: message.user?.id,
|
||||||
channelId: message.channelId,
|
channelId: message.channelId,
|
||||||
});
|
});
|
||||||
|
// Efeito próprio, no lugar do som do sistema. O balão nativo agora
|
||||||
|
// é silencioso, então este é o único som que toca.
|
||||||
|
void AudioManager.getInstance().playSound('notification', { volume: getSfxVolume() });
|
||||||
|
|
||||||
|
// O balão do sistema só faz sentido quando a janela não está à
|
||||||
|
// vista: com ela em foco, o aviso dentro do app já cumpre o papel.
|
||||||
|
if (!focused) {
|
||||||
|
sendNotification(displayName, body, { channelId: message.channelId });
|
||||||
|
}
|
||||||
break; // one notification per batch
|
break; // one notification per batch
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
|
import { api } from '../../api/client';
|
||||||
import Picker from '@emoji-mart/react';
|
import Picker from '@emoji-mart/react';
|
||||||
import data from '@emoji-mart/data';
|
import data from '@emoji-mart/data';
|
||||||
|
|
||||||
@@ -16,6 +19,24 @@ interface EmojiPickerProps {
|
|||||||
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
|
export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps) {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Emojis próprios do servidor entram como categoria extra do emoji-mart.
|
||||||
|
// Sem espaço atual (DM) a lista fica vazia e a categoria não aparece.
|
||||||
|
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const spaceEmojis = useExpressionStore((s) => (spaceId ? s.emojisBySpace.get(spaceId) : undefined));
|
||||||
|
const customCategories = useMemo(() => {
|
||||||
|
if (!spaceEmojis?.length) return [];
|
||||||
|
return [{
|
||||||
|
id: 'space',
|
||||||
|
name: 'Servidor',
|
||||||
|
emojis: spaceEmojis.map((e) => ({
|
||||||
|
id: e.name,
|
||||||
|
name: e.name,
|
||||||
|
keywords: [e.name],
|
||||||
|
skins: [{ src: api.uploads.url(e.filename) }],
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
}, [spaceEmojis]);
|
||||||
|
|
||||||
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
|
// Prevent keyboard events from bubbling out (e.g. Enter submitting the chat input)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = containerRef.current;
|
const el = containerRef.current;
|
||||||
@@ -45,6 +66,7 @@ export function EmojiPicker({ onEmojiSelect, mobile = false }: EmojiPickerProps)
|
|||||||
<div ref={containerRef} className={wrapperClass}>
|
<div ref={containerRef} className={wrapperClass}>
|
||||||
<Picker
|
<Picker
|
||||||
data={data}
|
data={data}
|
||||||
|
custom={customCategories}
|
||||||
onEmojiSelect={onEmojiSelect}
|
onEmojiSelect={onEmojiSelect}
|
||||||
theme="dark"
|
theme="dark"
|
||||||
set="native"
|
set="native"
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { GifResult } from '@backspace/shared';
|
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 {
|
interface GifPickerProps {
|
||||||
onGifSelect: (url: string) => void;
|
onGifSelect: (url: string) => void;
|
||||||
@@ -12,6 +27,10 @@ interface GifPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function GifPicker({ onGifSelect, mobile = false }: 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 [query, setQuery] = useState('');
|
||||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||||
const [results, setResults] = useState<GifResult[]>([]);
|
const [results, setResults] = useState<GifResult[]>([]);
|
||||||
@@ -21,6 +40,46 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
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
|
// Debounce search query
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
@@ -60,6 +119,8 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
// Infinite scroll
|
// Infinite scroll
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
|
// Favourites are a complete local list — nothing to page through.
|
||||||
|
if (showFavorites) return;
|
||||||
if (!el || loadingMore || !nextPos) return;
|
if (!el || loadingMore || !nextPos) return;
|
||||||
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
@@ -76,13 +137,16 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
};
|
};
|
||||||
fetchMore();
|
fetchMore();
|
||||||
}
|
}
|
||||||
}, [loadingMore, nextPos, debouncedQuery]);
|
}, [loadingMore, nextPos, debouncedQuery, showFavorites]);
|
||||||
|
|
||||||
// Prevent keyboard events from bubbling
|
// Prevent keyboard events from bubbling
|
||||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
e.stopPropagation();
|
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
|
// Mobile: fill parent (sheet sets width + max-height). Desktop: fixed dims
|
||||||
// matching the legacy popover footprint.
|
// matching the legacy popover footprint.
|
||||||
const rootClass = mobile
|
const rootClass = mobile
|
||||||
@@ -97,7 +161,7 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search GIFs"
|
placeholder={t('gif.search')}
|
||||||
className="input-search w-full"
|
className="input-search w-full"
|
||||||
// Auto-focus only on desktop. On mobile this would force the OS
|
// Auto-focus only on desktop. On mobile this would force the OS
|
||||||
// keyboard up the moment the sheet opens, hiding most of the grid.
|
// keyboard up the moment the sheet opens, hiding most of the grid.
|
||||||
@@ -105,13 +169,41 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 */}
|
{/* Results grid */}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
|
className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-1"
|
||||||
onScroll={handleScroll}
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading && !showFavorites ? (
|
||||||
<div className="grid grid-cols-2 gap-1.5 p-1">
|
<div className="grid grid-cols-2 gap-1.5 p-1">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<div
|
||||||
@@ -121,17 +213,25 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : results.length === 0 ? (
|
) : shown.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm">
|
<div className="flex items-center justify-center h-full text-txt-tertiary text-sm text-center px-4">
|
||||||
{debouncedQuery.trim() ? 'No GIFs found' : 'No trending GIFs'}
|
{showFavorites
|
||||||
|
? t('gif.empty.favorites')
|
||||||
|
: debouncedQuery.trim()
|
||||||
|
? t('gif.empty.search')
|
||||||
|
: t('gif.empty.trending')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="columns-2 gap-1.5 p-1">
|
<div className="columns-2 gap-1.5 p-1">
|
||||||
{results.map((gif) => (
|
{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
|
<button
|
||||||
key={gif.id}
|
|
||||||
onClick={() => onGifSelect(gif.url)}
|
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"
|
className="w-full rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all block"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={gif.previewUrl}
|
src={gif.previewUrl}
|
||||||
@@ -143,7 +243,22 @@ export function GifPicker({ onGifSelect, mobile = false }: GifPickerProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</button>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
import React, { useRef, useEffect, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { EmojiPicker } from './EmojiPicker';
|
import { EmojiPicker } from './EmojiPicker';
|
||||||
import { GifPicker } from './GifPicker';
|
import { GifPicker } from './GifPicker';
|
||||||
|
import { StickerPicker } from './StickerPicker';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useDragToClose } from '../../hooks/useDragToClose';
|
import { useDragToClose } from '../../hooks/useDragToClose';
|
||||||
|
|
||||||
export type InputPopoverTab = 'emoji' | 'gif';
|
export type InputPopoverTab = 'emoji' | 'gif' | 'sticker';
|
||||||
|
|
||||||
interface InputPopoverProps {
|
interface InputPopoverProps {
|
||||||
activeTab: InputPopoverTab;
|
activeTab: InputPopoverTab;
|
||||||
@@ -15,6 +17,8 @@ interface InputPopoverProps {
|
|||||||
anchorRef: React.RefObject<HTMLElement | null>;
|
anchorRef: React.RefObject<HTMLElement | null>;
|
||||||
gifEnabled: boolean;
|
gifEnabled: boolean;
|
||||||
onTabChange: (tab: InputPopoverTab) => void;
|
onTabChange: (tab: InputPopoverTab) => void;
|
||||||
|
onStickerSelect: (stickerId: string) => void;
|
||||||
|
hasStickers?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SharedTabProps {
|
interface SharedTabProps {
|
||||||
@@ -53,6 +57,7 @@ function DesktopPopover({
|
|||||||
onClose,
|
onClose,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onGifSelect,
|
onGifSelect,
|
||||||
|
onStickerSelect,
|
||||||
anchorRef,
|
anchorRef,
|
||||||
gifEnabled,
|
gifEnabled,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -139,6 +144,7 @@ function DesktopPopover({
|
|||||||
<div className="flex-1 min-h-0 overflow-hidden">
|
<div className="flex-1 min-h-0 overflow-hidden">
|
||||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} />}
|
||||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} />}
|
||||||
|
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>,
|
</div>,
|
||||||
@@ -155,6 +161,7 @@ function MobileSheet({
|
|||||||
onClose,
|
onClose,
|
||||||
onEmojiSelect,
|
onEmojiSelect,
|
||||||
onGifSelect,
|
onGifSelect,
|
||||||
|
onStickerSelect,
|
||||||
gifEnabled,
|
gifEnabled,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
availableTabs,
|
availableTabs,
|
||||||
@@ -221,6 +228,7 @@ function MobileSheet({
|
|||||||
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
<div className="flex-1 min-h-0 overflow-hidden flex flex-col">
|
||||||
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
{activeTab === 'emoji' && <EmojiPicker onEmojiSelect={onEmojiSelect} mobile />}
|
||||||
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
{activeTab === 'gif' && gifEnabled && <GifPicker onGifSelect={onGifSelect} mobile />}
|
||||||
|
{activeTab === 'sticker' && <StickerPicker onStickerSelect={onStickerSelect} mobile />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>,
|
||||||
@@ -231,12 +239,17 @@ function MobileSheet({
|
|||||||
export function InputPopover(props: InputPopoverProps) {
|
export function InputPopover(props: InputPopoverProps) {
|
||||||
const isMobile = useUIStore((s) => s.isMobile);
|
const isMobile = useUIStore((s) => s.isMobile);
|
||||||
|
|
||||||
|
const t = useT();
|
||||||
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
const availableTabs: { key: InputPopoverTab; label: string }[] = [
|
||||||
{ key: 'emoji', label: 'Emoji' },
|
{ key: 'emoji', label: 'Emoji' },
|
||||||
];
|
];
|
||||||
if (props.gifEnabled) {
|
if (props.gifEnabled) {
|
||||||
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
availableTabs.splice(0, 0, { key: 'gif', label: 'GIF' });
|
||||||
}
|
}
|
||||||
|
// Figurinha só existe dentro de um servidor; em DM a aba não aparece.
|
||||||
|
if (props.hasStickers) {
|
||||||
|
availableTabs.push({ key: 'sticker', label: t('expressions.stickers') });
|
||||||
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
if (isMobile) {
|
||||||
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
return <MobileSheet {...props} availableTabs={availableTabs} />;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { isCustomEmojiAlt } from '../../utils/customEmoji';
|
||||||
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import { Highlight, themes } from 'prism-react-renderer';
|
import { Highlight, themes } from 'prism-react-renderer';
|
||||||
@@ -186,7 +187,22 @@ function buildComponents(): Components {
|
|||||||
hr: () => <hr className="border-border-soft my-2" />,
|
hr: () => <hr className="border-border-soft my-2" />,
|
||||||
|
|
||||||
// Images (in markdown content — not attachments)
|
// Images (in markdown content — not attachments)
|
||||||
img: ({ src, alt }) => (
|
img: ({ src, alt }) => {
|
||||||
|
// Emoji próprio do espaço chega como imagem markdown com alt `:nome:`.
|
||||||
|
// Renderiza em tamanho de texto e inline, para caber no meio da frase em
|
||||||
|
// vez de virar um bloco como uma imagem comum.
|
||||||
|
if (isCustomEmojiAlt(alt ?? undefined)) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt ?? ''}
|
||||||
|
title={alt ?? ''}
|
||||||
|
className="inline-block align-text-bottom w-[22px] h-[22px] object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
<div className="mt-1 max-w-[400px]">
|
<div className="mt-1 max-w-[400px]">
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -197,7 +213,8 @@ function buildComponents(): Components {
|
|||||||
crossOrigin="anonymous"
|
crossOrigin="anonymous"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
),
|
);
|
||||||
|
},
|
||||||
|
|
||||||
// Tables (GFM)
|
// Tables (GFM)
|
||||||
table: ({ children }) => (
|
table: ({ children }) => (
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
|
import { renderCustomEmojis } from '../../utils/customEmoji';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
||||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
@@ -131,6 +135,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
const editMessage = useChatStore((s) => s.editMessage);
|
const editMessage = useChatStore((s) => s.editMessage);
|
||||||
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
const deleteMessage = useChatStore((s) => s.deleteMessage);
|
||||||
const members = useSpaceStore((s) => s.members);
|
const members = useSpaceStore((s) => s.members);
|
||||||
|
const tr = useT();
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
const pending = isPendingMessage(message) ? message.__pending : null;
|
const pending = isPendingMessage(message) ? message.__pending : null;
|
||||||
@@ -230,6 +235,26 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
return () => clearTimeout(confirmDeleteTimeout.current);
|
return () => clearTimeout(confirmDeleteTimeout.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Emojis próprios do espaço são resolvidos antes do markdown; sem espaço
|
||||||
|
// (DM, por exemplo) o texto passa intacto.
|
||||||
|
const emojiSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const emojiLookup = useExpressionStore((s) => s.emojiByName);
|
||||||
|
const contentWithEmojis = useMemo(() => {
|
||||||
|
if (!message.content || !emojiSpaceId) return message.content ?? '';
|
||||||
|
return renderCustomEmojis(
|
||||||
|
message.content,
|
||||||
|
(name) => emojiLookup(emojiSpaceId, name),
|
||||||
|
(filename) => api.uploads.url(filename),
|
||||||
|
);
|
||||||
|
}, [message.content, emojiSpaceId, emojiLookup]);
|
||||||
|
|
||||||
|
const stickerId = (message as MessageWithUser).stickerId;
|
||||||
|
const sticker = useExpressionStore((s) =>
|
||||||
|
stickerId && emojiSpaceId
|
||||||
|
? s.stickersBySpace.get(emojiSpaceId)?.find((k) => k.id === stickerId)
|
||||||
|
: undefined,
|
||||||
|
);
|
||||||
|
|
||||||
const isGifOnly = isGifOnlyMessage(message.content);
|
const isGifOnly = isGifOnlyMessage(message.content);
|
||||||
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
const imageEmbedSourceUrl = isGifOnly ? null : getImageEmbedSourceUrl(message.content, message.embeds || []);
|
||||||
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
// sourceUrl: the original URL for context menu Copy/Open Link actions
|
||||||
@@ -329,6 +354,15 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
setEditContent(message.content ?? '');
|
setEditContent(message.content ?? '');
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
},
|
},
|
||||||
|
isPinned: Boolean((message as MessageWithUser).pinnedAt),
|
||||||
|
labels: { pin: tr('pins.pin'), unpin: tr('pins.unpin') },
|
||||||
|
onTogglePin: () => {
|
||||||
|
const pinned = Boolean((message as MessageWithUser).pinnedAt);
|
||||||
|
// Sem atualização otimista: o servidor recusa acima do limite do canal,
|
||||||
|
// e mostrar como fixada antes da confirmação mentiria nesse caso.
|
||||||
|
void (pinned ? api.pins.unpin(message.id) : api.pins.pin(message.id))
|
||||||
|
.catch(() => { /* o servidor rejeitou; o estado permanece o que era */ });
|
||||||
|
},
|
||||||
onDelete: () => deleteMessage(message.id, channelKey),
|
onDelete: () => deleteMessage(message.id, channelKey),
|
||||||
onReaction: (emoji: string) => toggleReaction(emoji),
|
onReaction: (emoji: string) => toggleReaction(emoji),
|
||||||
onOpenEmojiPicker: () => {
|
onOpenEmojiPicker: () => {
|
||||||
@@ -516,7 +550,19 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
<>
|
<>
|
||||||
{message.content && (
|
{message.content && (
|
||||||
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
<div className="text-txt-message text-[15px] leading-[1.5] break-words whitespace-pre-wrap selection:bg-accent-primary/30">
|
||||||
<MarkdownRenderer content={message.content} />
|
{sticker ? (
|
||||||
|
// Figurinha ocupa a mensagem inteira, sem moldura de
|
||||||
|
// anexo: é o conteúdo, não um arquivo acompanhando texto.
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(sticker.filename)}
|
||||||
|
alt={sticker.name}
|
||||||
|
title={sticker.name}
|
||||||
|
className="w-[160px] h-[160px] object-contain rounded-lg"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MarkdownRenderer content={contentWithEmojis} />
|
||||||
|
)}
|
||||||
{message.editedAt && (
|
{message.editedAt && (
|
||||||
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
<span className="text-[10px] text-txt-tertiary ml-1 select-none font-medium">(edited)</span>
|
||||||
)}
|
)}
|
||||||
@@ -676,7 +722,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
|
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
|
||||||
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="Add reaction"
|
title={tr('chat.message.addReaction')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
|
||||||
@@ -687,7 +733,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
<button
|
<button
|
||||||
onClick={() => setReplyTo(message)}
|
onClick={() => setReplyTo(message)}
|
||||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||||
title="Reply"
|
title={tr('chat.message.reply')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
|
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
|
||||||
@@ -700,7 +746,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
}}
|
}}
|
||||||
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
|
||||||
title="Edit"
|
title={tr('chat.message.edit')}
|
||||||
>
|
>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
@@ -94,6 +95,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
// Feature flags
|
// Feature flags
|
||||||
|
const tr = useT();
|
||||||
|
// Figurinhas pertencem a um servidor; em DM não há o que oferecer.
|
||||||
|
const stickerSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
|
||||||
|
const handleStickerSelect = (stickerId: string) => {
|
||||||
|
setActivePopover(null);
|
||||||
|
// Enviada de imediato: a figurinha é a mensagem inteira, então não faz
|
||||||
|
// sentido acumulá-la no campo de texto esperando um Enter.
|
||||||
|
void sendMessage(channelId, '', undefined, stickerId);
|
||||||
|
};
|
||||||
|
|
||||||
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
const gifEnabled = useSettingsStore((s) => s.gifEnabled);
|
||||||
|
|
||||||
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
|
||||||
@@ -529,21 +541,25 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiSelect = useCallback(
|
const handleEmojiSelect = useCallback(
|
||||||
(emoji: { native: string }) => {
|
(emoji: { native?: string; id?: string }) => {
|
||||||
|
// Emoji próprio não tem `native`: entra no texto como `:nome:`, que é
|
||||||
|
// o que o render resolve depois para a imagem.
|
||||||
|
const inserted = emoji.native ?? (emoji.id ? `:${emoji.id}:` : '');
|
||||||
|
if (!inserted) return;
|
||||||
const textarea = textareaRef.current;
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) {
|
if (!textarea) {
|
||||||
setDraft(channelId, draftText + emoji.native);
|
setDraft(channelId, draftText + inserted);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const start = textarea.selectionStart;
|
const start = textarea.selectionStart;
|
||||||
const end = textarea.selectionEnd;
|
const end = textarea.selectionEnd;
|
||||||
const before = draftText.slice(0, start);
|
const before = draftText.slice(0, start);
|
||||||
const after = draftText.slice(end);
|
const after = draftText.slice(end);
|
||||||
const newContent = before + emoji.native + after;
|
const newContent = before + inserted + after;
|
||||||
setDraft(channelId, newContent);
|
setDraft(channelId, newContent);
|
||||||
|
|
||||||
// Restore cursor position after the emoji
|
// Restore cursor position after the emoji
|
||||||
const newCursorPos = start + emoji.native.length;
|
const newCursorPos = start + inserted.length;
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
textarea.focus();
|
textarea.focus();
|
||||||
textarea.selectionStart = newCursorPos;
|
textarea.selectionStart = newCursorPos;
|
||||||
@@ -766,6 +782,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
onClose={() => setActivePopover(null)}
|
onClose={() => setActivePopover(null)}
|
||||||
onEmojiSelect={handleEmojiSelect}
|
onEmojiSelect={handleEmojiSelect}
|
||||||
onGifSelect={handleGifSelect}
|
onGifSelect={handleGifSelect}
|
||||||
|
onStickerSelect={handleStickerSelect}
|
||||||
|
hasStickers={Boolean(stickerSpaceId)}
|
||||||
anchorRef={popoverAnchorRef}
|
anchorRef={popoverAnchorRef}
|
||||||
gifEnabled={gifEnabled}
|
gifEnabled={gifEnabled}
|
||||||
onTabChange={setActivePopover}
|
onTabChange={setActivePopover}
|
||||||
@@ -775,7 +793,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
{chatReplyTo && (
|
{chatReplyTo && (
|
||||||
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
|
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
|
||||||
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
|
||||||
<span className="opacity-60">Replying to</span>
|
<span className="opacity-60">{tr('chat.composer.replyingTo')}</span>
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
{chatReplyTo.user.displayName ?? chatReplyTo.user.username}
|
||||||
</span>
|
</span>
|
||||||
@@ -783,7 +801,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
<button
|
<button
|
||||||
onClick={() => chatSetReplyTo(null)}
|
onClick={() => chatSetReplyTo(null)}
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
aria-label="Cancel reply"
|
aria-label={tr('chat.composer.cancelReply')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
|
||||||
@@ -867,7 +885,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeStagedTransfer(t.id)}
|
onClick={() => removeStagedTransfer(t.id)}
|
||||||
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
|
||||||
aria-label="Remove attachment"
|
aria-label={tr('chat.composer.removeAttachment')}
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
|
||||||
@@ -886,8 +904,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
|
||||||
title="Attach file"
|
title={tr('chat.composer.attach')}
|
||||||
aria-label="Attach file"
|
aria-label={tr('chat.composer.attach')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
|
||||||
@@ -915,14 +933,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onPaste={canAttachFiles ? handlePaste : undefined}
|
onPaste={canAttachFiles ? handlePaste : undefined}
|
||||||
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`}
|
placeholder={placeholder ?? tr('chat.composer.placeholder', {
|
||||||
|
channel: channelName.startsWith('@') ? channelName : `#${channelName}`,
|
||||||
|
})}
|
||||||
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
|
||||||
rows={1}
|
rows={1}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Active-upload indicator */}
|
{/* Active-upload indicator */}
|
||||||
{anyActiveOrQueued && (
|
{anyActiveOrQueued && (
|
||||||
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading">
|
<div className="p-3 text-txt-tertiary" title={tr('chat.composer.uploadingEllipsis')} aria-label={tr('chat.composer.uploading')}>
|
||||||
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||||
@@ -934,7 +954,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
{failedCount > 0 && (
|
{failedCount > 0 && (
|
||||||
<span
|
<span
|
||||||
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
|
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
|
||||||
title="Remove or retry the failed attachment to send"
|
title={tr('chat.composer.failedAttachment')}
|
||||||
>
|
>
|
||||||
{failedCount} failed
|
{failedCount} failed
|
||||||
</span>
|
</span>
|
||||||
@@ -956,8 +976,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
||||||
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="GIF"
|
title={tr('chat.composer.gif')}
|
||||||
aria-label="GIF picker"
|
aria-label={tr('chat.composer.gifPicker')}
|
||||||
>
|
>
|
||||||
{/* Outlined badge, not a filled block: the solid rectangle read as
|
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||||
a plain square rather than a GIF picker. Letters reuse the
|
a plain square rather than a GIF picker. Letters reuse the
|
||||||
@@ -977,8 +997,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
|
||||||
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
|
||||||
}`}
|
}`}
|
||||||
title="Emoji"
|
title={tr('chat.composer.emoji')}
|
||||||
aria-label="Emoji picker"
|
aria-label={tr('chat.composer.emojiPicker')}
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
|
||||||
@@ -991,8 +1011,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
onClick={() => void handleSubmit()}
|
onClick={() => void handleSubmit()}
|
||||||
disabled={anyUnshippable}
|
disabled={anyUnshippable}
|
||||||
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
|
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
|
||||||
aria-label="Send message"
|
aria-label={tr('chat.composer.sendMessage')}
|
||||||
title="Send"
|
title={tr('chat.composer.send')}
|
||||||
>
|
>
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
|
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import type { MessageWithUser } from '@backspace/shared';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
|
interface PinsPopoverProps {
|
||||||
|
channelId: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onJumpToMessage: (messageId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PinsPopover({ channelId, onClose, onJumpToMessage }: PinsPopoverProps) {
|
||||||
|
const t = useT();
|
||||||
|
const [messages, setMessages] = useState<MessageWithUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
setLoading(true);
|
||||||
|
api.pins.list(channelId)
|
||||||
|
.then(({ messages: list }) => { if (!cancelled) setMessages(list); })
|
||||||
|
.catch(() => { if (!cancelled) setMessages([]); })
|
||||||
|
.finally(() => { if (!cancelled) setLoading(false); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [channelId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onPointer = (e: MouseEvent | TouchEvent) => {
|
||||||
|
if (!panelRef.current?.contains(e.target as Node)) onClose();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
||||||
|
// touchstart junto de mousedown: o iOS Safari não sintetiza mousedown de
|
||||||
|
// toque de forma confiável, como os outros popovers deste projeto tratam.
|
||||||
|
document.addEventListener('mousedown', onPointer);
|
||||||
|
document.addEventListener('touchstart', onPointer);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onPointer);
|
||||||
|
document.removeEventListener('touchstart', onPointer);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={panelRef}
|
||||||
|
className="absolute right-0 top-full mt-2 z-[300] w-[380px] max-h-[460px] glass rounded-xl overflow-hidden flex flex-col shadow-xl"
|
||||||
|
>
|
||||||
|
<div className="px-4 py-3 border-b border-border-soft shrink-0">
|
||||||
|
<span className="text-[13px] font-semibold text-txt-primary">{t('pins.title')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin p-2">
|
||||||
|
{loading ? (
|
||||||
|
<div className="space-y-2 p-1">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-14 rounded-lg bg-surface-elevated animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-txt-tertiary p-3">{t('pins.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{messages.map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => { onJumpToMessage(m.id); onClose(); }}
|
||||||
|
className="w-full text-left flex gap-2.5 p-2 rounded-lg hover:bg-interactive-hover transition-colors"
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
src={m.user.avatar}
|
||||||
|
name={m.user.displayName ?? m.user.username}
|
||||||
|
size={28}
|
||||||
|
userId={m.user.id}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[12px] font-semibold text-txt-primary truncate">
|
||||||
|
{m.user.displayName ?? m.user.username}
|
||||||
|
</div>
|
||||||
|
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">
|
||||||
|
{m.content || ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
import { parseSearchQuery } from '../../utils/searchQuery';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
import { useFloatingPosition } from '../../hooks/useFloatingPosition';
|
||||||
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin, getApiForOrigin } from '../../stores/spaceStore';
|
||||||
@@ -103,6 +105,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const t = useT();
|
||||||
const [fromFilter, setFromFilter] = useState('');
|
const [fromFilter, setFromFilter] = useState('');
|
||||||
const [hasFilter, setHasFilter] = useState('');
|
const [hasFilter, setHasFilter] = useState('');
|
||||||
const [beforeFilter, setBeforeFilter] = useState('');
|
const [beforeFilter, setBeforeFilter] = useState('');
|
||||||
@@ -153,8 +156,16 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
const doSearch = useCallback(async (searchOffset = 0) => {
|
const doSearch = useCallback(async (searchOffset = 0) => {
|
||||||
const trimmed = query.trim();
|
// Filtros digitados na consulta (`de:fulano`) valem sobre os do painel:
|
||||||
if (!trimmed && !fromFilter && !hasFilter && !beforeFilter && !afterFilter) {
|
// quem acabou de escrever está expressando a intenção mais recente.
|
||||||
|
const parsed = parseSearchQuery(query);
|
||||||
|
const trimmed = parsed.text;
|
||||||
|
const effFrom = parsed.from ?? fromFilter;
|
||||||
|
const effHas = parsed.has ?? hasFilter;
|
||||||
|
const effBefore = parsed.before ?? beforeFilter;
|
||||||
|
const effAfter = parsed.after ?? afterFilter;
|
||||||
|
|
||||||
|
if (!trimmed && !effFrom && !effHas && !effBefore && !effAfter) {
|
||||||
setResults([]);
|
setResults([]);
|
||||||
setTotalCount(0);
|
setTotalCount(0);
|
||||||
return;
|
return;
|
||||||
@@ -166,10 +177,10 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
const client = getApiForOrigin(origin);
|
const client = getApiForOrigin(origin);
|
||||||
const params = {
|
const params = {
|
||||||
q: trimmed || undefined,
|
q: trimmed || undefined,
|
||||||
from: fromFilter || undefined,
|
from: effFrom || undefined,
|
||||||
has: hasFilter || undefined,
|
has: effHas || undefined,
|
||||||
before: beforeFilter || undefined,
|
before: effBefore || undefined,
|
||||||
after: afterFilter || undefined,
|
after: effAfter || undefined,
|
||||||
offset: searchOffset,
|
offset: searchOffset,
|
||||||
limit: 25,
|
limit: 25,
|
||||||
};
|
};
|
||||||
@@ -224,7 +235,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
type="text"
|
type="text"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search messages..."
|
placeholder={t('search.placeholder')}
|
||||||
className="input-embedded flex-1 text-[14px]"
|
className="input-embedded flex-1 text-[14px]"
|
||||||
/>
|
/>
|
||||||
{query && (
|
{query && (
|
||||||
@@ -257,30 +268,30 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
{showFilters && (
|
{showFilters && (
|
||||||
<div className="mt-2 grid grid-cols-2 gap-2">
|
<div className="mt-2 grid grid-cols-2 gap-2">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">From</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.from')}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={fromFilter}
|
value={fromFilter}
|
||||||
onChange={(e) => setFromFilter(e.target.value)}
|
onChange={(e) => setFromFilter(e.target.value)}
|
||||||
placeholder="username"
|
placeholder={t('search.fromPlaceholder')}
|
||||||
className="input-search w-full px-2 py-1 text-[13px]"
|
className="input-search w-full px-2 py-1 text-[13px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Has</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.has')}</label>
|
||||||
<select
|
<select
|
||||||
value={hasFilter}
|
value={hasFilter}
|
||||||
onChange={(e) => setHasFilter(e.target.value)}
|
onChange={(e) => setHasFilter(e.target.value)}
|
||||||
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
|
className="input-search w-full px-2 py-1 text-[13px] appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
<option value="">Any</option>
|
<option value="">{t('search.hasAny')}</option>
|
||||||
<option value="file">File</option>
|
<option value="file">{t('search.hasFile')}</option>
|
||||||
<option value="image">Image</option>
|
<option value="image">{t('search.hasImage')}</option>
|
||||||
<option value="link">Link</option>
|
<option value="link">{t('search.hasLink')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">Before</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.before')}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={beforeFilter}
|
value={beforeFilter}
|
||||||
@@ -289,7 +300,7 @@ export function SearchPopover({ open, onClose, anchorRef, channelId, isDm, onJum
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">After</label>
|
<label className="text-[11px] text-txt-tertiary font-medium mb-1 block">{t('search.after')}</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={afterFilter}
|
value={afterFilter}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { api } from '../../api/client';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
|
interface StickerPickerProps {
|
||||||
|
onStickerSelect: (stickerId: string) => void;
|
||||||
|
mobile?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StickerPicker({ onStickerSelect, mobile = false }: StickerPickerProps) {
|
||||||
|
const t = useT();
|
||||||
|
const spaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const stickers = useExpressionStore((s) => (spaceId ? s.stickersBySpace.get(spaceId) : undefined)) ?? [];
|
||||||
|
const load = useExpressionStore((s) => s.load);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (spaceId) void load(spaceId);
|
||||||
|
}, [spaceId, load]);
|
||||||
|
|
||||||
|
// Mesmas dimensões do seletor de GIF, para as abas do popover não pularem de
|
||||||
|
// tamanho ao alternar.
|
||||||
|
const rootClass = mobile
|
||||||
|
? 'flex flex-col flex-1 min-h-0 w-full'
|
||||||
|
: 'flex flex-col h-[390px] w-[390px]';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClass}>
|
||||||
|
<div className="px-3 pt-3 pb-2 shrink-0">
|
||||||
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-txt-tertiary">
|
||||||
|
{t('expressions.pickerTitle')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto scrollbar-thin px-2 pb-2">
|
||||||
|
{stickers.length === 0 ? (
|
||||||
|
<p className="text-[12px] text-txt-tertiary p-3">{t('expressions.pickerEmpty')}</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{stickers.map((sticker) => (
|
||||||
|
<button
|
||||||
|
key={sticker.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onStickerSelect(sticker.id)}
|
||||||
|
title={sticker.name}
|
||||||
|
className="aspect-square rounded-lg bg-surface-elevated hover:brightness-125 transition-all flex items-center justify-center p-2"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(sticker.filename)}
|
||||||
|
alt={sticker.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ interface MessageMenuParams {
|
|||||||
canAddReactions: boolean;
|
canAddReactions: boolean;
|
||||||
canSendMessages: boolean;
|
canSendMessages: boolean;
|
||||||
canManageMessages: boolean;
|
canManageMessages: boolean;
|
||||||
|
isPinned: boolean;
|
||||||
|
onTogglePin: () => void;
|
||||||
|
/** Rótulos traduzidos: este módulo não é um componente e não pode usar o hook. */
|
||||||
|
labels: { pin: string; unpin: string };
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
@@ -42,6 +46,9 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
|||||||
canAddReactions,
|
canAddReactions,
|
||||||
canSendMessages,
|
canSendMessages,
|
||||||
canManageMessages,
|
canManageMessages,
|
||||||
|
isPinned,
|
||||||
|
onTogglePin,
|
||||||
|
labels,
|
||||||
onReply,
|
onReply,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -306,6 +313,21 @@ export function buildMessageMenuItems(params: MessageMenuParams): ContextMenuIte
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Pin / Unpin (moderator) ─────────────────────────────────────────────
|
||||||
|
if (canManageMessages) {
|
||||||
|
items.push({
|
||||||
|
key: 'pin',
|
||||||
|
type: 'action',
|
||||||
|
label: isPinned ? labels.unpin : labels.pin,
|
||||||
|
icon: (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
onClick: onTogglePin,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ── Delete Message (author or moderator) ────────────────────────────────
|
// ── Delete Message (author or moderator) ────────────────────────────────
|
||||||
const canDelete = isAuthor || canManageMessages;
|
const canDelete = isAuthor || canManageMessages;
|
||||||
if (canDelete) {
|
if (canDelete) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
|
import { InAppNotifications } from '../ui/InAppNotifications';
|
||||||
|
import { UpdateBanner } from '../ui/UpdateBanner';
|
||||||
|
import { useExpressionStore } from '../../stores/expressionStore';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { SpaceSidebar } from './SpaceSidebar';
|
import { SpaceSidebar } from './SpaceSidebar';
|
||||||
import { ChannelSidebar } from './ChannelSidebar';
|
import { ChannelSidebar } from './ChannelSidebar';
|
||||||
@@ -22,6 +25,7 @@ import { UserProfileModal } from '../modals/UserProfileModal';
|
|||||||
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
import { IncomingCallModal } from '../voice/IncomingCallModal';
|
||||||
import { PictureInPicture } from '../voice/PictureInPicture';
|
import { PictureInPicture } from '../voice/PictureInPicture';
|
||||||
import { SoundController } from '../voice/SoundController';
|
import { SoundController } from '../voice/SoundController';
|
||||||
|
import { useSpotifyActivity } from '../../hooks/useSpotifyActivity';
|
||||||
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
import { GlobalAudioRenderer } from '../voice/GlobalAudioRenderer';
|
||||||
import { NotificationController } from '../NotificationController';
|
import { NotificationController } from '../NotificationController';
|
||||||
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
import { UserProfilePopout } from '../ui/UserProfilePopout';
|
||||||
@@ -215,6 +219,16 @@ export function AppLayout() {
|
|||||||
const showBootSkeleton = useDelayedLoading(isLoading);
|
const showBootSkeleton = useDelayedLoading(isLoading);
|
||||||
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
const setCurrentSpace = useSpaceStore((s) => s.setCurrentSpace);
|
||||||
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
const loadSpaceDetail = useSpaceStore((s) => s.loadSpaceDetail);
|
||||||
|
useSpotifyActivity();
|
||||||
|
|
||||||
|
// Emojis e figurinhas do espaço atual, carregados uma vez por espaço: o
|
||||||
|
// render de mensagem consulta o mapa a cada `:nome:`, e buscar por mensagem
|
||||||
|
// viraria uma cascata de requisições.
|
||||||
|
const expressionSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
|
const loadExpressions = useExpressionStore((s) => s.load);
|
||||||
|
useEffect(() => {
|
||||||
|
if (expressionSpaceId) void loadExpressions(expressionSpaceId);
|
||||||
|
}, [expressionSpaceId, loadExpressions]);
|
||||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
const loadMessages = useChatStore((s) => s.loadMessages);
|
const loadMessages = useChatStore((s) => s.loadMessages);
|
||||||
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
const setIsMobile = useUIStore((s) => s.setIsMobile);
|
||||||
@@ -416,6 +430,8 @@ export function AppLayout() {
|
|||||||
(e.g. immediately after Join, or after popping voice-full back to
|
(e.g. immediately after Join, or after popping voice-full back to
|
||||||
the root). */}
|
the root). */}
|
||||||
<SoundController />
|
<SoundController />
|
||||||
|
<UpdateBanner />
|
||||||
|
<InAppNotifications />
|
||||||
<GlobalAudioRenderer />
|
<GlobalAudioRenderer />
|
||||||
<NotificationController />
|
<NotificationController />
|
||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import type { Channel } from '@backspace/shared';
|
import type { Channel } from '@backspace/shared';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
@@ -7,6 +8,7 @@ import { useUIStore } from '../../stores/uiStore';
|
|||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
import { VoiceChannel } from '../voice/VoiceChannel';
|
import { VoiceChannel } from '../voice/VoiceChannel';
|
||||||
|
import { AccountMenu } from './AccountMenu';
|
||||||
import { VoiceControls } from '../voice/VoiceControls';
|
import { VoiceControls } from '../voice/VoiceControls';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||||
@@ -25,6 +27,7 @@ import { useAudioDevices } from '../../hooks/useAudioDevices';
|
|||||||
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
|
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
|
||||||
|
|
||||||
export function ChannelSidebar() {
|
export function ChannelSidebar() {
|
||||||
|
const tr = useT();
|
||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
||||||
@@ -317,7 +320,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'create-channel',
|
key: 'create-channel',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Create Channel',
|
label: tr('sidebar.createChannel'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
|
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
|
||||||
@@ -329,7 +332,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'create-category',
|
key: 'create-category',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Create Category',
|
label: tr('sidebar.createCategory'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
|
||||||
@@ -342,7 +345,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'invite',
|
key: 'invite',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Invite People',
|
label: tr('sidebar.invitePeople'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
|
||||||
@@ -354,7 +357,7 @@ export function ChannelSidebar() {
|
|||||||
items.push({
|
items.push({
|
||||||
key: 'settings',
|
key: 'settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Space Settings',
|
label: tr('sidebar.spaceSettings'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
|
||||||
@@ -372,7 +375,7 @@ export function ChannelSidebar() {
|
|||||||
{
|
{
|
||||||
key: 'leave-group',
|
key: 'leave-group',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Leave Group',
|
label: tr('sidebar.leaveGroup'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -452,7 +455,7 @@ export function ChannelSidebar() {
|
|||||||
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
|
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
|
||||||
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
|
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Friends</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.friends')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Placeholder nav items */}
|
{/* Placeholder nav items */}
|
||||||
@@ -462,7 +465,7 @@ export function ChannelSidebar() {
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Coming Soon</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
|
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
|
||||||
@@ -470,11 +473,11 @@ export function ChannelSidebar() {
|
|||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
|
||||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" />
|
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="font-medium text-[16px]">Coming Soon</span>
|
<span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
|
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
|
||||||
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">Direct Messages</span>
|
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">{tr('sidebar.directMessages')}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => openModal('newDm')}
|
onClick={() => openModal('newDm')}
|
||||||
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
className="text-txt-tertiary hover:text-txt-primary transition-colors"
|
||||||
@@ -583,7 +586,7 @@ export function ChannelSidebar() {
|
|||||||
{/* Channels — dynamic category layout */}
|
{/* Channels — dynamic category layout */}
|
||||||
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}>
|
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}>
|
||||||
{showChannelSkeleton ? (
|
{showChannelSkeleton ? (
|
||||||
<div className="px-2 pt-3" role="status" aria-label="Loading channels">
|
<div className="px-2 pt-3" role="status" aria-label={tr('sidebar.loadingChannels')}>
|
||||||
{/* Category group 1 */}
|
{/* Category group 1 */}
|
||||||
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
|
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
|
||||||
{Array.from({ length: 3 }, (_, i) => (
|
{Array.from({ length: 3 }, (_, i) => (
|
||||||
@@ -705,7 +708,7 @@ export function ChannelSidebar() {
|
|||||||
{
|
{
|
||||||
key: 'category-settings',
|
key: 'category-settings',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Category Settings',
|
label: tr('sidebar.categorySettings'),
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" />
|
||||||
@@ -716,7 +719,7 @@ export function ChannelSidebar() {
|
|||||||
{
|
{
|
||||||
key: 'delete-category',
|
key: 'delete-category',
|
||||||
type: 'action',
|
type: 'action',
|
||||||
label: 'Delete Category',
|
label: tr('sidebar.deleteCategory'),
|
||||||
danger: true,
|
danger: true,
|
||||||
icon: (
|
icon: (
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -757,7 +760,7 @@ export function ChannelSidebar() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{catChannels.length === 0 && (
|
{catChannels.length === 0 && (
|
||||||
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">No channels</div>
|
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">{tr('sidebar.noChannels')}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -776,7 +779,7 @@ export function ChannelSidebar() {
|
|||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
||||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-[12px]">Create Channel</span>
|
<span className="text-[12px]">{tr('sidebar.createChannel')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -786,7 +789,7 @@ export function ChannelSidebar() {
|
|||||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
|
||||||
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-[12px]">Create Category</span>
|
<span className="text-[12px]">{tr('sidebar.createCategory')}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -844,6 +847,8 @@ function UserAreaPanel({
|
|||||||
onDeafenToggle: () => void;
|
onDeafenToggle: () => void;
|
||||||
onSettingsClick: (tab?: string) => void;
|
onSettingsClick: (tab?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const tr = useT();
|
||||||
|
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||||
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
|
||||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||||
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
|
||||||
@@ -957,7 +962,7 @@ function UserAreaPanel({
|
|||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary text-left">Input Device</div>
|
<div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.inputDevice')}</div>
|
||||||
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
|
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
||||||
@@ -1002,7 +1007,7 @@ function UserAreaPanel({
|
|||||||
|
|
||||||
{/* Input Volume */}
|
{/* Input Volume */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary mb-2">Input Volume</div>
|
<div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.inputVolume')}</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -1037,7 +1042,7 @@ function UserAreaPanel({
|
|||||||
onClick={() => onSettingsClick('voice')}
|
onClick={() => onSettingsClick('voice')}
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
|
<span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1055,7 +1060,7 @@ function UserAreaPanel({
|
|||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary text-left">Output Device</div>
|
<div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.outputDevice')}</div>
|
||||||
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
|
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
|
||||||
</div>
|
</div>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
|
||||||
@@ -1100,7 +1105,7 @@ function UserAreaPanel({
|
|||||||
|
|
||||||
{/* Output Volume */}
|
{/* Output Volume */}
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className="text-[15px] font-semibold text-txt-primary mb-2">Output Volume</div>
|
<div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.outputVolume')}</div>
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -1123,7 +1128,7 @@ function UserAreaPanel({
|
|||||||
onClick={() => onSettingsClick('voice')}
|
onClick={() => onSettingsClick('voice')}
|
||||||
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
|
||||||
>
|
>
|
||||||
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span>
|
<span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -1132,14 +1137,27 @@ function UserAreaPanel({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* User area bar */}
|
{/* User area bar */}
|
||||||
<div className="h-[52px] px-2 flex items-center select-none">
|
<div className="relative h-[52px] px-2 flex items-center select-none">
|
||||||
{/* Avatar + name */}
|
{accountMenuOpen && (
|
||||||
<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">
|
<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} />
|
<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-[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 className="text-[11px] text-txt-tertiary truncate leading-tight group-hover:text-txt-secondary">@{user.username}</div>
|
||||||
</div>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useChatStore } from '../../stores/chatStore';
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
@@ -25,6 +26,7 @@ import type { User } from '@backspace/shared';
|
|||||||
import { Tooltip } from '../ui/Tooltip';
|
import { Tooltip } from '../ui/Tooltip';
|
||||||
import { joinVoiceChannel } from '../../utils/voice';
|
import { joinVoiceChannel } from '../../utils/voice';
|
||||||
import { SearchPopover } from '../chat/SearchPopover';
|
import { SearchPopover } from '../chat/SearchPopover';
|
||||||
|
import { PinsPopover } from '../chat/PinsPopover';
|
||||||
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
|
import { isDmChannel, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
|
|
||||||
export function MainContent() {
|
export function MainContent() {
|
||||||
@@ -50,7 +52,9 @@ export function MainContent() {
|
|||||||
|
|
||||||
const voiceContainerRef = useRef<HTMLDivElement>(null);
|
const voiceContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const searchButtonRef = useRef<HTMLButtonElement>(null);
|
const searchButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const t = useT();
|
||||||
const [searchOpen, setSearchOpen] = useState(false);
|
const [searchOpen, setSearchOpen] = useState(false);
|
||||||
|
const [pinsOpen, setPinsOpen] = useState(false);
|
||||||
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
|
const [jumpToMessageId, setJumpToMessageId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Resolve the DM header's "first other" member through the canonical view
|
// Resolve the DM header's "first other" member through the canonical view
|
||||||
@@ -505,6 +509,24 @@ export function MainContent() {
|
|||||||
</button>
|
</button>
|
||||||
<TransferIndicator />
|
<TransferIndicator />
|
||||||
<div className="w-[1px] h-5 bg-border-soft mx-1" />
|
<div className="w-[1px] h-5 bg-border-soft mx-1" />
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setPinsOpen((v) => !v)}
|
||||||
|
className={`w-8 h-8 flex items-center justify-center transition-colors rounded-[6px] ${pinsOpen ? 'text-txt-primary bg-interactive-active' : 'text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover'}`}
|
||||||
|
title={t('pins.title')}
|
||||||
|
>
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M16 3v2h-1v6l2 2v2h-5v6l-1 1-1-1v-6H5v-2l2-2V5H6V3h10z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{pinsOpen && currentChannelId && (
|
||||||
|
<PinsPopover
|
||||||
|
channelId={currentChannelId}
|
||||||
|
onClose={() => setPinsOpen(false)}
|
||||||
|
onJumpToMessage={(id) => setJumpToMessageId(id)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<MemberListToggleButton />
|
<MemberListToggleButton />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import type { MemberWithUser, Activity } from '@backspace/shared';
|
import type { MemberWithUser, Activity } from '@backspace/shared';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -21,7 +22,7 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
|
|||||||
const ownerRole = member.roles?.find(r => r.position > 0);
|
const ownerRole = member.roles?.find(r => r.position > 0);
|
||||||
return {
|
return {
|
||||||
key: '__owner__',
|
key: '__owner__',
|
||||||
label: 'OWNER',
|
label: '__OWNER__',
|
||||||
color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
|
color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
|
||||||
position: Infinity,
|
position: Infinity,
|
||||||
};
|
};
|
||||||
@@ -40,7 +41,10 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
|
|||||||
// No explicit roles — just @everyone
|
// No explicit roles — just @everyone
|
||||||
return {
|
return {
|
||||||
key: '__online__',
|
key: '__online__',
|
||||||
label: 'ONLINE',
|
// Marcado em vez de traduzido aqui: esta funcao nao e um componente e nao
|
||||||
|
// pode usar o hook. Nomes de cargo passam adiante sem traducao — sao dados
|
||||||
|
// do usuario, nao interface.
|
||||||
|
label: '__ONLINE__',
|
||||||
color: undefined,
|
color: undefined,
|
||||||
position: -1,
|
position: -1,
|
||||||
};
|
};
|
||||||
@@ -111,6 +115,7 @@ export function MemberSidebar() {
|
|||||||
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
|
||||||
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
|
||||||
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
const memberListOpen = useUIStore((s) => s.memberListOpen);
|
||||||
|
const tr = useT();
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
const userActivities = useActivityStore((s) => s.userActivities);
|
const userActivities = useActivityStore((s) => s.userActivities);
|
||||||
|
|
||||||
@@ -183,7 +188,7 @@ export function MemberSidebar() {
|
|||||||
return (
|
return (
|
||||||
<div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
|
<div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
|
||||||
{showMemberSkeleton ? (
|
{showMemberSkeleton ? (
|
||||||
<div className="px-3 pt-4" role="status" aria-label="Loading members">
|
<div className="px-3 pt-4" role="status" aria-label={tr('sidebar.loadingMembers')}>
|
||||||
{/* Role group 1 */}
|
{/* Role group 1 */}
|
||||||
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
|
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
|
||||||
{Array.from({ length: 2 }, (_, i) => (
|
{Array.from({ length: 2 }, (_, i) => (
|
||||||
@@ -207,7 +212,11 @@ export function MemberSidebar() {
|
|||||||
{roleGroups.map(([key, group]) => (
|
{roleGroups.map(([key, group]) => (
|
||||||
<div key={key} className="mb-4">
|
<div key={key} className="mb-4">
|
||||||
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
|
||||||
{group.label} — {group.members.length}
|
{group.label === '__ONLINE__'
|
||||||
|
? tr('sidebar.groupOnline')
|
||||||
|
: group.label === '__OWNER__'
|
||||||
|
? tr('sidebar.groupOwner')
|
||||||
|
: group.label} — {group.members.length}
|
||||||
</h3>
|
</h3>
|
||||||
{group.members.map((m) => renderMember(m))}
|
{group.members.map((m) => renderMember(m))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import { Avatar } from '../ui/Avatar';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
import { OverviewPanel } from './spaceSettingsPanels/OverviewPanel';
|
||||||
|
import { AuditLogPanel } from './spaceSettingsPanels/AuditLogPanel';
|
||||||
|
import { StatsPanel } from './spaceSettingsPanels/StatsPanel';
|
||||||
|
import { ExpressionsPanel } from './spaceSettingsPanels/ExpressionsPanel';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
import { MembersPanel } from './spaceSettingsPanels/MembersPanel';
|
||||||
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
import { RolesPanel } from './spaceSettingsPanels/RolesPanel';
|
||||||
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
import { BansPanel } from './spaceSettingsPanels/BansPanel';
|
||||||
@@ -266,7 +270,8 @@ export function SpaceSettingsModal() {
|
|||||||
const spaces = useSpaceStore((s) => s.spaces);
|
const spaces = useSpaceStore((s) => s.spaces);
|
||||||
const spacePermissions = useSpaceStore((s) => s.spacePermissions);
|
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' | 'expressions'>('overview');
|
||||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||||
|
|
||||||
const isOpen = activeModal === 'spaceSettings';
|
const isOpen = activeModal === 'spaceSettings';
|
||||||
@@ -331,6 +336,13 @@ export function SpaceSettingsModal() {
|
|||||||
{canBanMembers && (
|
{canBanMembers && (
|
||||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
<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>
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,6 +378,13 @@ export function SpaceSettingsModal() {
|
|||||||
{canBanMembers && (
|
{canBanMembers && (
|
||||||
<button onClick={() => handleTabClick('bans')} className={tabClass('bans')}>Bans</button>
|
<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>
|
||||||
|
{canManageSpace && (
|
||||||
|
<button onClick={() => handleTabClick('expressions')} className={tabClass('expressions')}>{t('expressions.title')}</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -392,6 +411,9 @@ export function SpaceSettingsModal() {
|
|||||||
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
{tab === 'members' && <MembersPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
{tab === 'roles' && canManageRoles && <RolesPanel spaceId={currentSpaceId} />}
|
||||||
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
{tab === 'bans' && canBanMembers && <BansPanel spaceId={currentSpaceId} />}
|
||||||
|
{tab === 'audit' && canManageSpace && <AuditLogPanel spaceId={currentSpaceId} />}
|
||||||
|
{tab === 'stats' && <StatsPanel spaceId={currentSpaceId} />}
|
||||||
|
{tab === 'expressions' && canManageSpace && <ExpressionsPanel spaceId={currentSpaceId} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { ProfileActivity } from '../ui/ProfileActivity';
|
import { ProfileActivity } from '../ui/ProfileActivity';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { useActivityStore } from '../../stores/activityStore';
|
import { useActivityStore } from '../../stores/activityStore';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -66,6 +67,7 @@ export function UserProfileModal() {
|
|||||||
const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest);
|
const cancelFriendRequest = useSocialStore((s) => s.cancelFriendRequest);
|
||||||
const currentUser = useAuthStore((s) => s.user);
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
|
||||||
|
const t = useT();
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [userOrigin, setUserOrigin] = useState('');
|
const [userOrigin, setUserOrigin] = useState('');
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('about');
|
const [activeTab, setActiveTab] = useState<Tab>('about');
|
||||||
@@ -144,19 +146,27 @@ export function UserProfileModal() {
|
|||||||
return () => document.removeEventListener('keydown', handleKey);
|
return () => document.removeEventListener('keydown', handleKey);
|
||||||
}, [isOpen, closeModal]);
|
}, [isOpen, closeModal]);
|
||||||
|
|
||||||
|
// Keyed by home id, matching every other activity consumer (ActivityPanel,
|
||||||
|
// MemberSidebar), so federated users resolve to the same record.
|
||||||
|
//
|
||||||
|
// Must sit ABOVE the early return: `user` is null on the first render and
|
||||||
|
// arrives asynchronously, so a hook below it runs on some renders and not
|
||||||
|
// others — React counts hooks per render and aborts the tree (#310).
|
||||||
|
// The `?? []` stays OUTSIDE the selector; building it inside would hand
|
||||||
|
// zustand a fresh array reference every render and spin.
|
||||||
|
const activityList = useActivityStore((s) =>
|
||||||
|
user ? s.userActivities.get(user.homeUserId ?? user.id) : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
if (!isOpen || !user) return null;
|
if (!isOpen || !user) return null;
|
||||||
|
|
||||||
|
const activities = activityList ?? [];
|
||||||
|
|
||||||
const { baseName, domain } = parseFederatedUsername(user.username);
|
const { baseName, domain } = parseFederatedUsername(user.username);
|
||||||
const displayName = user.displayName ?? baseName;
|
const displayName = user.displayName ?? baseName;
|
||||||
|
|
||||||
// Banner — use correct API client for remote users
|
// Banner — use correct API client for remote users
|
||||||
const profileApi = getApiForOrigin(userOrigin);
|
const profileApi = getApiForOrigin(userOrigin);
|
||||||
// Keyed by home id, matching every other activity consumer (ActivityPanel,
|
|
||||||
// MemberSidebar), so federated users resolve to the same record. The `?? []`
|
|
||||||
// stays OUTSIDE the selector: building it inside would hand zustand a fresh
|
|
||||||
// array reference every render and spin.
|
|
||||||
const activityList = useActivityStore((s) => s.userActivities.get(user.homeUserId ?? user.id));
|
|
||||||
const activities = activityList ?? [];
|
|
||||||
|
|
||||||
const bannerSrc = user.banner
|
const bannerSrc = user.banner
|
||||||
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
|
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
|
||||||
@@ -344,7 +354,7 @@ export function UserProfileModal() {
|
|||||||
{user.bio && (
|
{user.bio && (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
About Me
|
{t('profile.aboutMe')}
|
||||||
</span>
|
</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">
|
<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
|
<ReactMarkdown
|
||||||
@@ -368,7 +378,7 @@ export function UserProfileModal() {
|
|||||||
{/* Member Since */}
|
{/* Member Since */}
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
Member Since
|
{t('profile.memberSince')}
|
||||||
</span>
|
</span>
|
||||||
<div className="text-[13px] text-txt-secondary mt-1">
|
<div className="text-[13px] text-txt-secondary mt-1">
|
||||||
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
{new Date(user.createdAt).toLocaleDateString(undefined, {
|
||||||
@@ -511,7 +521,7 @@ export function UserProfileModal() {
|
|||||||
onClick={handleSendMessage}
|
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"
|
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>
|
</button>
|
||||||
|
|
||||||
{friendship.state === 'none' && (
|
{friendship.state === 'none' && (
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { AccountPanel } from './settingsPanels/AccountPanel';
|
import { AccountPanel } from './settingsPanels/AccountPanel';
|
||||||
import { VoicePanel } from './settingsPanels/VoicePanel';
|
import { VoicePanel } from './settingsPanels/VoicePanel';
|
||||||
import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
|
import { PrivacyPanel } from './settingsPanels/PrivacyPanel';
|
||||||
|
import { LanguagePanel } from './settingsPanels/LanguagePanel';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
|
import { ConnectionsPanel } from './settingsPanels/ConnectionsPanel';
|
||||||
import { DesktopPanel } from './settingsPanels/DesktopPanel';
|
import { DesktopPanel } from './settingsPanels/DesktopPanel';
|
||||||
import { InstancePanel } from './settingsPanels/InstancePanel';
|
import { InstancePanel } from './settingsPanels/InstancePanel';
|
||||||
@@ -16,7 +18,7 @@ import { KeybindsPanel } from './settingsPanels/KeybindsPanel';
|
|||||||
import { isElectron } from '../../platform/platform';
|
import { isElectron } from '../../platform/platform';
|
||||||
import { SettingsSectionsProvider, useSettingsSectionsContext } from './SettingsSectionsContext';
|
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() {
|
function SidebarSubLinks() {
|
||||||
const ctx = useSettingsSectionsContext();
|
const ctx = useSettingsSectionsContext();
|
||||||
@@ -60,6 +62,7 @@ export function UserSettingsModal() {
|
|||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const logout = useAuthStore((s) => s.logout);
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|
||||||
|
const t = useT();
|
||||||
const [tab, setTab] = useState<SettingsTab>('account');
|
const [tab, setTab] = useState<SettingsTab>('account');
|
||||||
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
const [mobileView, setMobileView] = useState<'tabs' | 'content'>('tabs');
|
||||||
// AGPL § 13: home-instance source offer. Fetched from the public info endpoint
|
// AGPL § 13: home-instance source offer. Fetched from the public info endpoint
|
||||||
@@ -81,7 +84,7 @@ export function UserSettingsModal() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
const requested = modalData.tab as SettingsTab | undefined;
|
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
|
// Only allow instance tab for admins
|
||||||
if (requested === 'instance' && !isAdmin) {
|
if (requested === 'instance' && !isAdmin) {
|
||||||
setTab('account');
|
setTab('account');
|
||||||
@@ -135,14 +138,15 @@ export function UserSettingsModal() {
|
|||||||
{/* Nav list */}
|
{/* Nav list */}
|
||||||
<div className="glass-bubble rounded-lg p-2 flex-1 flex flex-col">
|
<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>
|
<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('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice & Video</button>
|
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
||||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</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="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>
|
<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('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
|
||||||
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</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>}
|
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -192,14 +196,15 @@ export function UserSettingsModal() {
|
|||||||
|
|
||||||
<div className="glass-bubble rounded-lg p-2 space-y-0.5">
|
<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>
|
<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('account')} className={tabClass('account')}>{t('settings.tab.account')}</button>
|
||||||
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>Voice & Video</button>
|
<button onClick={() => handleTabClick('voice')} className={tabClass('voice')}>{t('settings.tab.voice')}</button>
|
||||||
<button onClick={() => handleTabClick('privacy')} className={tabClass('privacy')}>Privacy</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="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>
|
<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('connections')} className={tabClass('connections')}>{t('settings.tab.connections')}</button>
|
||||||
<button onClick={() => handleTabClick('keybinds')} className={tabClass('keybinds')}>Keybinds</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>}
|
{isElectron() && <button onClick={() => handleTabClick('desktop')} className={tabClass('desktop')}>Desktop</button>}
|
||||||
|
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
@@ -249,6 +254,7 @@ export function UserSettingsModal() {
|
|||||||
{tab === 'privacy' && <PrivacyPanel />}
|
{tab === 'privacy' && <PrivacyPanel />}
|
||||||
{tab === 'connections' && <ConnectionsPanel />}
|
{tab === 'connections' && <ConnectionsPanel />}
|
||||||
{tab === 'keybinds' && <KeybindsPanel />}
|
{tab === 'keybinds' && <KeybindsPanel />}
|
||||||
|
{tab === 'language' && <LanguagePanel />}
|
||||||
{tab === 'desktop' && <DesktopPanel />}
|
{tab === 'desktop' && <DesktopPanel />}
|
||||||
{tab === 'instance' && isAdmin && <InstancePanel />}
|
{tab === 'instance' && isAdmin && <InstancePanel />}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { useVoiceStore } from '../../../stores/voiceStore';
|
|||||||
import { AudioManager } from '../../../audio/AudioManager';
|
import { AudioManager } from '../../../audio/AudioManager';
|
||||||
import { useAudioDevices } from '../../../hooks/useAudioDevices';
|
import { useAudioDevices } from '../../../hooks/useAudioDevices';
|
||||||
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
|
import { SectionShell, DropdownItem } from './_shared/SettingsPickerPrimitives';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
export function AudioInputSection() {
|
export function AudioInputSection() {
|
||||||
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
|
||||||
const setInputDevice = useVoiceStore((s) => s.setInputDevice);
|
const setInputDevice = useVoiceStore((s) => s.setInputDevice);
|
||||||
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
const inputVolume = useVoiceStore((s) => s.inputVolume);
|
||||||
const setInputVolume = useVoiceStore((s) => s.setInputVolume);
|
const setInputVolume = useVoiceStore((s) => s.setInputVolume);
|
||||||
|
const t = useT();
|
||||||
const { permState, inputs, inputLabels, requestPermission } = useAudioDevices();
|
const { permState, inputs, inputLabels, requestPermission } = useAudioDevices();
|
||||||
|
|
||||||
const [listOpen, setListOpen] = useState(false);
|
const [listOpen, setListOpen] = useState(false);
|
||||||
@@ -96,7 +98,7 @@ export function AudioInputSection() {
|
|||||||
setMicTestError('');
|
setMicTestError('');
|
||||||
const ok = await am.startMicTest();
|
const ok = await am.startMicTest();
|
||||||
if (!ok) {
|
if (!ok) {
|
||||||
setMicTestError('Could not open the microphone. Check the device and its permission.');
|
setMicTestError(t('settings.voice.micTest.failed'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMicTesting(true);
|
setMicTesting(true);
|
||||||
@@ -125,7 +127,7 @@ export function AudioInputSection() {
|
|||||||
|
|
||||||
if (permState === 'unknown') {
|
if (permState === 'unknown') {
|
||||||
return (
|
return (
|
||||||
<SectionShell title="Input Device">
|
<SectionShell title={t('settings.voice.input.title')}>
|
||||||
<div className="text-sm text-txt-tertiary">Checking microphone access…</div>
|
<div className="text-sm text-txt-tertiary">Checking microphone access…</div>
|
||||||
</SectionShell>
|
</SectionShell>
|
||||||
);
|
);
|
||||||
@@ -133,7 +135,7 @@ export function AudioInputSection() {
|
|||||||
|
|
||||||
if (permState === 'denied') {
|
if (permState === 'denied') {
|
||||||
return (
|
return (
|
||||||
<SectionShell title="Input Device">
|
<SectionShell title={t('settings.voice.input.title')}>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-sm text-txt-primary">⚠ Microphone access denied</div>
|
<div className="text-sm text-txt-primary">⚠ Microphone access denied</div>
|
||||||
<div className="text-xs text-txt-tertiary">
|
<div className="text-xs text-txt-tertiary">
|
||||||
@@ -152,7 +154,7 @@ export function AudioInputSection() {
|
|||||||
|
|
||||||
if (permState === 'prompt') {
|
if (permState === 'prompt') {
|
||||||
return (
|
return (
|
||||||
<SectionShell title="Input Device">
|
<SectionShell title={t('settings.voice.input.title')}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="text-xs text-txt-tertiary">
|
<div className="text-xs text-txt-tertiary">
|
||||||
Microphone permission needed to list and choose an input device.
|
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));
|
const activeBars = Math.round(micLevel * micBars * (inputVolume / 100));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionShell title="Input Device">
|
<SectionShell title={t('settings.voice.input.title')}>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div ref={dropdownRef}>
|
<div ref={dropdownRef}>
|
||||||
<button
|
<button
|
||||||
@@ -223,7 +225,7 @@ export function AudioInputSection() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<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 className="text-xs text-txt-tertiary tabular-nums">{inputVolume}%</div>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -258,14 +260,14 @@ export function AudioInputSection() {
|
|||||||
: 'bg-accent-primary text-white hover:brightness-110'
|
: '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>
|
</button>
|
||||||
<span className="text-xs text-txt-tertiary">
|
<span className="text-xs text-txt-tertiary">
|
||||||
{micTesting
|
{micTesting
|
||||||
? 'Playing your mic back to you — say something.'
|
? t('settings.voice.micTest.playing')
|
||||||
: isLiveKitConnected
|
: isLiveKitConnected
|
||||||
? 'The level meter is live while you are in a call.'
|
? t('settings.voice.micTest.inCall')
|
||||||
: 'Test your mic without joining a call.'}
|
: t('settings.voice.micTest.idle')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{micTestError && (
|
{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() {
|
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 (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="max-w-2xl">
|
||||||
<h2 className="text-lg font-semibold text-txt-primary mb-6">Connections</h2>
|
<h2 className="text-lg font-semibold text-txt-primary mb-6">{t('connections.title')}</h2>
|
||||||
<ConnectedInstances />
|
|
||||||
|
<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>
|
</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 { useActivityStore } from '../../../stores/activityStore';
|
||||||
import { api } from '../../../api/client';
|
import { api } from '../../../api/client';
|
||||||
import { Toggle } from '../../ui/Toggle';
|
import { Toggle } from '../../ui/Toggle';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
export function PrivacyPanel() {
|
export function PrivacyPanel() {
|
||||||
|
const t = useT();
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const setUser = useAuthStore((s) => s.setUser);
|
const setUser = useAuthStore((s) => s.setUser);
|
||||||
const showActivity = useActivityStore((s) => s.showActivity);
|
const showActivity = useActivityStore((s) => s.showActivity);
|
||||||
@@ -31,7 +33,7 @@ export function PrivacyPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<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>
|
||||||
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
|
||||||
Discovery
|
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="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 items-center justify-between py-1">
|
||||||
<div className="flex-1 mr-4">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Toggle enabled={discoverable} onChange={handleToggle} />
|
<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="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 items-center justify-between py-1">
|
||||||
<div className="flex-1 mr-4">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Toggle
|
<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,218 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { api, type SpaceEmoji } from '../../../api/client';
|
||||||
|
import { useExpressionStore } from '../../../stores/expressionStore';
|
||||||
|
import { useTransferStore } from '../../../stores/transferStore';
|
||||||
|
import { waitForTransferAttachment } from '../../../utils/waitForTransfer';
|
||||||
|
import { useT } from '../../../i18n';
|
||||||
|
|
||||||
|
interface ExpressionsPanelProps {
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emoji e figurinha são carregados a cada mensagem: têm de ser leves. */
|
||||||
|
const MAX_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
type Kind = 'emoji' | 'sticker';
|
||||||
|
|
||||||
|
export function ExpressionsPanel({ spaceId }: ExpressionsPanelProps) {
|
||||||
|
const t = useT();
|
||||||
|
const emojis = useExpressionStore((s) => s.emojisBySpace.get(spaceId)) ?? [];
|
||||||
|
const stickers = useExpressionStore((s) => s.stickersBySpace.get(spaceId)) ?? [];
|
||||||
|
const setEmojis = useExpressionStore((s) => s.setEmojis);
|
||||||
|
const setStickers = useExpressionStore((s) => s.setStickers);
|
||||||
|
|
||||||
|
const [kind, setKind] = useState<Kind>('emoji');
|
||||||
|
const [pending, setPending] = useState<File | null>(null);
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
Promise.all([api.expressions.emojis(spaceId), api.expressions.stickers(spaceId)])
|
||||||
|
.then(([e, s]) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setEmojis(spaceId, e.emojis);
|
||||||
|
setStickers(spaceId, s.stickers);
|
||||||
|
})
|
||||||
|
.catch(() => { /* lista vazia é o fallback honesto */ });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [spaceId, setEmojis, setStickers]);
|
||||||
|
|
||||||
|
const pickFile = (file: File) => {
|
||||||
|
setError('');
|
||||||
|
if (file.size > MAX_BYTES) {
|
||||||
|
setError(t('expressions.tooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPending(file);
|
||||||
|
// Sugere o nome do arquivo já no formato aceito, para o caso comum não
|
||||||
|
// exigir digitação nenhuma.
|
||||||
|
const base = file.name.replace(/\.[^.]+$/, '');
|
||||||
|
setName(kind === 'emoji' ? base.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 32) : base.slice(0, 32));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
setPending(null);
|
||||||
|
setName('');
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirm = async () => {
|
||||||
|
const file = pending;
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!file || !trimmed) return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const tid = await useTransferStore.getState().startUpload(file, { tray: false });
|
||||||
|
const { filename } = await waitForTransferAttachment(tid);
|
||||||
|
if (kind === 'emoji') {
|
||||||
|
const created = await api.expressions.addEmoji(spaceId, trimmed, filename);
|
||||||
|
setEmojis(spaceId, [...emojis, created]);
|
||||||
|
} else {
|
||||||
|
const created = await api.expressions.addSticker(spaceId, trimmed, filename);
|
||||||
|
setStickers(spaceId, [...stickers, created]);
|
||||||
|
}
|
||||||
|
cancel();
|
||||||
|
} catch (err) {
|
||||||
|
// 409 é nome repetido — mensagem específica, porque a ação corretiva é
|
||||||
|
// outra: mudar o nome, não trocar a imagem.
|
||||||
|
const conflict = (err as { statusCode?: number } | undefined)?.statusCode === 409;
|
||||||
|
setError(conflict ? t('expressions.nameTaken') : t('expressions.uploadFailed'));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (item: SpaceEmoji) => {
|
||||||
|
const isEmoji = kind === 'emoji';
|
||||||
|
const previous = isEmoji ? emojis : stickers;
|
||||||
|
const next = previous.filter((x) => x.id !== item.id);
|
||||||
|
if (isEmoji) setEmojis(spaceId, next); else setStickers(spaceId, next);
|
||||||
|
try {
|
||||||
|
await (isEmoji ? api.expressions.removeEmoji(item.id) : api.expressions.removeSticker(item.id));
|
||||||
|
} catch {
|
||||||
|
if (isEmoji) setEmojis(spaceId, previous); else setStickers(spaceId, previous);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const items = kind === 'emoji' ? emojis : stickers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h2 className="text-lg font-semibold text-txt-primary mb-4">{t('expressions.title')}</h2>
|
||||||
|
|
||||||
|
<div className="flex gap-1.5 mb-5">
|
||||||
|
{(['emoji', 'sticker'] as Kind[]).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setKind(k); cancel(); }}
|
||||||
|
className={`px-2.5 py-1 rounded-full text-[12px] font-medium transition-colors ${
|
||||||
|
kind === k ? 'bg-accent-primary text-white' : 'bg-surface-elevated text-txt-secondary hover:text-txt-primary'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{k === 'emoji' ? t('expressions.emojis') : t('expressions.stickers')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fileRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-3 py-1.5 rounded-md text-[13px] font-medium bg-accent-primary text-white hover:brightness-110 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{kind === 'emoji' ? t('expressions.addEmoji') : t('expressions.addSticker')}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={fileRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => { const f = e.target.files?.[0]; if (f) pickFile(f); }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-[12px] text-txt-danger mt-2">{error}</p>}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<div className="mt-3 p-3 rounded-lg bg-surface-elevated/60">
|
||||||
|
<label className="block text-[11px] text-txt-tertiary mb-1">{t('expressions.namePrompt')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter' && name.trim()) void confirm();
|
||||||
|
if (e.key === 'Escape') cancel();
|
||||||
|
}}
|
||||||
|
className="input-search w-full mb-1"
|
||||||
|
/>
|
||||||
|
{kind === 'emoji' && (
|
||||||
|
<p className="text-[11px] text-txt-tertiary mb-2">{t('expressions.nameHintEmoji')}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confirm()}
|
||||||
|
disabled={busy || !name.trim()}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-accent-primary text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{busy ? t('expressions.uploading') : t('expressions.confirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancel}
|
||||||
|
disabled={busy}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('expressions.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-5">
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-txt-tertiary">
|
||||||
|
{kind === 'emoji' ? t('expressions.emptyEmojis') : t('expressions.emptyStickers')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-6 gap-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="relative group">
|
||||||
|
<div className="aspect-square rounded-lg bg-surface-elevated flex items-center justify-center p-1.5">
|
||||||
|
<img
|
||||||
|
src={api.uploads.url(item.filename)}
|
||||||
|
alt={item.name}
|
||||||
|
title={kind === 'emoji' ? `:${item.name}:` : item.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-txt-tertiary truncate text-center mt-0.5">
|
||||||
|
{kind === 'emoji' ? `:${item.name}:` : item.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void remove(item)}
|
||||||
|
title={t('expressions.remove')}
|
||||||
|
aria-label={t('expressions.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>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useInAppNotificationStore, type InAppNotification } from '../../stores/inAppNotificationStore';
|
||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
|
||||||
|
/** Tempo na tela antes de sumir sozinha. */
|
||||||
|
const AUTO_DISMISS_MS = 6000;
|
||||||
|
|
||||||
|
function NotificationCard({ item }: { item: InAppNotification }) {
|
||||||
|
const dismiss = useInAppNotificationStore((s) => s.dismiss);
|
||||||
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => dismiss(item.id), AUTO_DISMISS_MS);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [item.id, dismiss]);
|
||||||
|
|
||||||
|
const open = () => {
|
||||||
|
if (item.channelId) {
|
||||||
|
setCurrentChannel(item.channelId);
|
||||||
|
navigate(`/channels/${item.spaceId || '@me'}/${item.channelId}`);
|
||||||
|
}
|
||||||
|
dismiss(item.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="glass rounded-xl shadow-xl w-[300px] overflow-hidden animate-slide-up">
|
||||||
|
<button onClick={open} className="w-full text-left flex gap-2.5 p-3 hover:bg-interactive-hover transition-colors">
|
||||||
|
<Avatar src={item.avatar} name={item.title} size={32} userId={item.userId} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-[13px] font-semibold text-txt-primary truncate">{item.title}</div>
|
||||||
|
<div className="text-[12px] text-txt-secondary line-clamp-2 break-words">{item.body}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Avisos dentro da própria janela, no lugar do balão do sistema.
|
||||||
|
*
|
||||||
|
* O balão do Windows traz o som do sistema junto e não combina com o resto do
|
||||||
|
* app. Aqui o efeito sonoro é o mesmo dos outros sons do Backspace, e clicar
|
||||||
|
* leva direto ao canal.
|
||||||
|
*/
|
||||||
|
export function InAppNotifications() {
|
||||||
|
const items = useInAppNotificationStore((s) => s.items);
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed top-4 right-4 z-[400] flex flex-col gap-2 pointer-events-none">
|
||||||
|
{items.map((item) => (
|
||||||
|
<div key={item.id} className="pointer-events-auto">
|
||||||
|
<NotificationCard item={item} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { Activity } from '@backspace/shared';
|
import type { Activity } from '@backspace/shared';
|
||||||
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||||
|
import { useT, type TranslationKey } from '../../i18n';
|
||||||
|
import { serverNow } from '../../utils/serverTime';
|
||||||
|
|
||||||
interface ProfileActivityProps {
|
interface ProfileActivityProps {
|
||||||
activities: Activity[];
|
activities: Activity[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const VERB: Record<Activity['type'], string> = {
|
const VERB_KEY: Record<Exclude<Activity['type'], 'custom'>, TranslationKey> = {
|
||||||
playing: 'Playing',
|
playing: 'profile.activity.playing',
|
||||||
listening: 'Listening to',
|
listening: 'profile.activity.listening',
|
||||||
watching: 'Watching',
|
watching: 'profile.activity.watching',
|
||||||
streaming: 'Streaming',
|
streaming: 'profile.activity.streaming',
|
||||||
custom: '',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatClock(ms: number): string {
|
function formatClock(ms: number): string {
|
||||||
@@ -34,17 +35,26 @@ function formatClock(ms: number): string {
|
|||||||
* producer would fill in.
|
* producer would fill in.
|
||||||
*/
|
*/
|
||||||
export function ProfileActivity({ activities }: ProfileActivityProps) {
|
export function ProfileActivity({ activities }: ProfileActivityProps) {
|
||||||
|
const t = useT();
|
||||||
const primary = getPrimaryActivity(activities);
|
const primary = getPrimaryActivity(activities);
|
||||||
const start = primary?.timestamps?.start;
|
const start = primary?.timestamps?.start;
|
||||||
const end = primary?.timestamps?.end;
|
const end = primary?.timestamps?.end;
|
||||||
|
|
||||||
// Re-render once a second only while there is a clock to advance.
|
const paused = primary?.paused === true;
|
||||||
const [now, setNow] = useState(() => Date.now());
|
|
||||||
|
// Ticks only while something is actually advancing: a paused track kept
|
||||||
|
// counting until the next poll, so the bar walked past where the listener
|
||||||
|
// had stopped.
|
||||||
|
const [now, setNow] = useState(() => serverNow());
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!start) return;
|
if (!start || paused) return;
|
||||||
const id = setInterval(() => setNow(Date.now()), 1000);
|
const id = setInterval(() => setNow(serverNow()), 1000);
|
||||||
return () => clearInterval(id);
|
return () => clearInterval(id);
|
||||||
}, [start]);
|
}, [start, paused]);
|
||||||
|
|
||||||
|
// Recompute once when playback resumes or the track changes, so the frozen
|
||||||
|
// value is not what gets drawn.
|
||||||
|
useEffect(() => { setNow(serverNow()); }, [start, paused]);
|
||||||
|
|
||||||
if (!primary || primary.type === 'custom') return null;
|
if (!primary || primary.type === 'custom') return null;
|
||||||
|
|
||||||
@@ -60,7 +70,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
<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>
|
</span>
|
||||||
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
|
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
|
||||||
{artSrc && (
|
{artSrc && (
|
||||||
@@ -96,7 +106,7 @@ export function ProfileActivity({ activities }: ProfileActivityProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : start ? (
|
) : start ? (
|
||||||
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
|
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
|
||||||
{formatClock(elapsed)} elapsed
|
{t('profile.activity.elapsed', { time: formatClock(elapsed) })}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { isElectron } from '../../platform/platform';
|
||||||
|
import { useT } from '../../i18n';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aviso de que uma atualização já baixou e só falta reiniciar.
|
||||||
|
*
|
||||||
|
* O processo principal já mostrava uma notificação do sistema, que some sozinha
|
||||||
|
* e passa despercebida se a pessoa não estiver olhando. Este aviso fica na
|
||||||
|
* janela até ser atendido ou dispensado.
|
||||||
|
*/
|
||||||
|
export function UpdateBanner() {
|
||||||
|
const t = useT();
|
||||||
|
const [version, setVersion] = useState<string | null>(null);
|
||||||
|
const [dismissed, setDismissed] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isElectron() || !window.backspace?.onUpdateDownloaded) return;
|
||||||
|
window.backspace.onUpdateDownloaded((info) => {
|
||||||
|
setVersion(info.version);
|
||||||
|
// Uma atualização nova reabre o aviso mesmo se a anterior foi dispensada:
|
||||||
|
// dispensar significa "agora não", não "nunca mais".
|
||||||
|
setDismissed(false);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!version || dismissed) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-[400] max-w-[320px] glass rounded-xl shadow-xl p-3.5 animate-slide-up">
|
||||||
|
<div className="text-[13px] font-semibold text-txt-primary">{t('update.ready')}</div>
|
||||||
|
<p className="text-[12px] text-txt-secondary mt-0.5">
|
||||||
|
{t('update.readyVersion', { version })}
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.backspace?.installUpdate?.()}
|
||||||
|
className="px-3 py-1.5 rounded-md text-[12px] font-medium bg-accent-primary text-white hover:brightness-110"
|
||||||
|
>
|
||||||
|
{t('update.restart')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setDismissed(true)}
|
||||||
|
className="px-3 py-1.5 rounded-md text-[12px] font-medium bg-interactive-muted text-txt-secondary hover:text-txt-primary"
|
||||||
|
>
|
||||||
|
{t('update.later')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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,213 @@
|
|||||||
|
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('');
|
||||||
|
// Two-step add: pick the file, then name it in a field right here. The first
|
||||||
|
// version asked with window.prompt, which Electron does not implement — it
|
||||||
|
// returned nothing and the flow aborted in silence, so adding a sound worked
|
||||||
|
// in the browser and did nothing at all in the desktop app.
|
||||||
|
const [pendingFile, setPendingFile] = useState<File | null>(null);
|
||||||
|
const [pendingName, setPendingName] = 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 pickFile = (file: File) => {
|
||||||
|
setError('');
|
||||||
|
if (file.size > MAX_SOUND_BYTES) {
|
||||||
|
setError(t('soundboard.tooLarge'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingFile(file);
|
||||||
|
setPendingName(file.name.replace(/\.[^.]+$/, '').slice(0, 32));
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelPending = () => {
|
||||||
|
setPendingFile(null);
|
||||||
|
setPendingName('');
|
||||||
|
if (fileRef.current) fileRef.current.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmPending = async () => {
|
||||||
|
const file = pendingFile;
|
||||||
|
const name = pendingName.trim();
|
||||||
|
if (!file || !name) return;
|
||||||
|
|
||||||
|
setUploading(true);
|
||||||
|
setError('');
|
||||||
|
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]);
|
||||||
|
cancelPending();
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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) pickFile(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="text-[11px] text-txt-danger mb-2">{error}</div>}
|
||||||
|
|
||||||
|
{pendingFile && (
|
||||||
|
<div className="mb-2 p-2 rounded-lg bg-surface-elevated/60">
|
||||||
|
<label className="block text-[11px] text-txt-tertiary mb-1">
|
||||||
|
{t('soundboard.namePrompt')}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pendingName}
|
||||||
|
maxLength={32}
|
||||||
|
autoFocus
|
||||||
|
onChange={(e) => setPendingName(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Scoped here so Enter does not reach the composer behind the popover.
|
||||||
|
e.stopPropagation();
|
||||||
|
if (e.key === 'Enter' && pendingName.trim()) void confirmPending();
|
||||||
|
if (e.key === 'Escape') cancelPending();
|
||||||
|
}}
|
||||||
|
className="input-search w-full mb-2"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void confirmPending()}
|
||||||
|
disabled={uploading || !pendingName.trim()}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-accent-primary text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{uploading ? t('soundboard.adding') : t('soundboard.confirm')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelPending}
|
||||||
|
disabled={uploading}
|
||||||
|
className="px-2.5 py-1 rounded-md text-[11px] font-medium bg-interactive-muted text-txt-secondary disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{t('soundboard.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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 React, { useCallback, useMemo } from 'react';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { CallTimer } from './CallTimer';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
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). */
|
/** Wrapper component for the volume slider so it can use hooks (useState). */
|
||||||
export function VoiceChannel({ channelId, channelName, onClick, locked, canManage, onSettingsClick, voiceUserHandlers, dropZone }: VoiceChannelProps) {
|
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 serverVoiceUsers = useVoiceStore((s) => s.voiceUsers.get(channelId)) ?? EMPTY_VOICE_USERS;
|
||||||
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannel = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
const participants = useVoiceStore((s) => s.participants);
|
const participants = useVoiceStore((s) => s.participants);
|
||||||
@@ -141,6 +145,12 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
|
|||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
<span className="truncate text-[15px] font-medium flex-1 text-left">{channelName}</span>
|
<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 && (
|
{canManage && (
|
||||||
<svg
|
<svg
|
||||||
width="16"
|
width="16"
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getActiveRoom } from '../../hooks/useLiveKit';
|
|||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
import { ConnectionInfoPopover } from './ConnectionInfoPopover';
|
||||||
|
import { SoundboardPopover } from './SoundboardPopover';
|
||||||
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
import { startScreenShare, stopScreenShare } from '../../utils/screenShare';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
import { broadcastVoiceStatus } from '../../utils/voice';
|
import { broadcastVoiceStatus } from '../../utils/voice';
|
||||||
@@ -22,6 +23,7 @@ export function VoiceControls() {
|
|||||||
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
||||||
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [showSoundboard, setShowSoundboard] = useState(false);
|
||||||
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
|
||||||
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
|
||||||
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
|
||||||
@@ -37,6 +39,10 @@ export function VoiceControls() {
|
|||||||
|
|
||||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||||
const channelPerms = useSpaceStore((s) => currentVoiceChannelId ? s.channelPermissions.get(currentVoiceChannelId) : undefined);
|
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
|
// In DM calls, all permissions are granted; in space channels, check SPEAK and STREAM
|
||||||
const isDmCall = !!activeDmCall;
|
const isDmCall = !!activeDmCall;
|
||||||
@@ -119,6 +125,19 @@ export function VoiceControls() {
|
|||||||
|
|
||||||
return (
|
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 */}
|
{/* Row 1: Signal icon + status text + disconnect */}
|
||||||
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
|
<div className="relative flex items-center gap-2 px-3 pt-3 pb-1">
|
||||||
<button
|
<button
|
||||||
@@ -216,6 +235,23 @@ export function VoiceControls() {
|
|||||||
</button>
|
</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 */}
|
{/* Video Quality */}
|
||||||
<button
|
<button
|
||||||
ref={qualityBtnRef}
|
ref={qualityBtnRef}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import type { Activity } from '@backspace/shared';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { useActivityStore } from '../stores/activityStore';
|
||||||
|
import { setServerTime } from '../utils/serverTime';
|
||||||
|
|
||||||
|
/** Ceiling between checks while a track is playing. */
|
||||||
|
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;
|
||||||
|
/** Never hammer the API, however close the track end looks. */
|
||||||
|
const MIN_POLL_MS = 4_000;
|
||||||
|
/**
|
||||||
|
* How long a silent answer is tolerated before the block is taken down.
|
||||||
|
*
|
||||||
|
* Spotify reports "nothing playing" in the gap between two songs, so clearing
|
||||||
|
* on the first empty answer made the block vanish and reappear between every
|
||||||
|
* track.
|
||||||
|
*/
|
||||||
|
const EMPTY_GRACE_MS = 25_000;
|
||||||
|
|
||||||
|
function trackKey(activity: Activity | null): string {
|
||||||
|
return activity ? `${activity.details ?? ''}|${activity.state ?? ''}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
const lastKeyRef = useRef('');
|
||||||
|
const emptySinceRef = useRef(0);
|
||||||
|
|
||||||
|
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, serverTime } = await api.spotify.nowPlaying();
|
||||||
|
if (cancelled) return;
|
||||||
|
if (serverTime) setServerTime(serverTime);
|
||||||
|
|
||||||
|
if (activity) {
|
||||||
|
emptySinceRef.current = 0;
|
||||||
|
const key = trackKey(activity);
|
||||||
|
// A new track goes out at once; progress-only updates can wait for
|
||||||
|
// the debounce, which is what it is there for.
|
||||||
|
const immediate = key !== lastKeyRef.current;
|
||||||
|
lastKeyRef.current = key;
|
||||||
|
setSource('spotify', [activity], { immediate });
|
||||||
|
|
||||||
|
// Check back just after this track should end, rather than landing
|
||||||
|
// mid-song and showing everyone the previous one for another
|
||||||
|
// twenty seconds.
|
||||||
|
const end = activity.timestamps?.end;
|
||||||
|
const remaining = end ? end - Date.now() + 1_000 : POLL_CONNECTED_MS;
|
||||||
|
delay = Math.max(MIN_POLL_MS, Math.min(POLL_CONNECTED_MS, remaining));
|
||||||
|
} else if (connected) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!emptySinceRef.current) emptySinceRef.current = now;
|
||||||
|
if (now - emptySinceRef.current >= EMPTY_GRACE_MS) {
|
||||||
|
lastKeyRef.current = '';
|
||||||
|
setSource('spotify', []);
|
||||||
|
}
|
||||||
|
delay = MIN_POLL_MS;
|
||||||
|
} else {
|
||||||
|
lastKeyRef.current = '';
|
||||||
|
emptySinceRef.current = 0;
|
||||||
|
setSource('spotify', []);
|
||||||
|
delay = 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.
|
||||||
|
delay = POLL_CONNECTED_MS;
|
||||||
|
}
|
||||||
|
if (!cancelled) timer = setTimeout(() => void tick(), delay);
|
||||||
|
};
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
setSource('spotify', []);
|
||||||
|
};
|
||||||
|
}, [showActivity]);
|
||||||
|
}
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
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 { useAuthStore } from '../stores/authStore';
|
||||||
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin, setMyUserIdForOrigin, resolveDmChannelId } from '../stores/spaceStore';
|
||||||
import { useChatStore } from '../stores/chatStore';
|
import { useChatStore } from '../stores/chatStore';
|
||||||
@@ -9,6 +12,7 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo, Activity, User } from '@
|
|||||||
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
import { resolveAssetUrl, normalizeUserAssets, normalizeMessageAssets } from '../utils/assetUrls';
|
||||||
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
|
import { broadcastVoiceStatus, broadcastDeafenViaLiveKit } from '../utils/voice';
|
||||||
import { applySpaceVoiceState } from '../utils/voiceStateSync';
|
import { applySpaceVoiceState } from '../utils/voiceStateSync';
|
||||||
|
import { setServerTime } from '../utils/serverTime';
|
||||||
import { sortDmChannels } from '../utils/dmSorting';
|
import { sortDmChannels } from '../utils/dmSorting';
|
||||||
import { registerSelfId } from '../utils/identity';
|
import { registerSelfId } from '../utils/identity';
|
||||||
import { getActiveRoom } from './useLiveKit';
|
import { getActiveRoom } from './useLiveKit';
|
||||||
@@ -278,6 +282,8 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (event.serverTime) setServerTime(event.serverTime);
|
||||||
|
|
||||||
// Clear voice state only for the reconnecting origin before repopulating
|
// Clear voice state only for the reconnecting origin before repopulating
|
||||||
clearVoiceUsersForOrigin(origin);
|
clearVoiceUsersForOrigin(origin);
|
||||||
if (event.voiceStates) {
|
if (event.voiceStates) {
|
||||||
@@ -285,6 +291,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
setVoiceUsers(channelId, userIds);
|
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
|
// Initialize activity data from ready payload
|
||||||
if (event.userActivities) {
|
if (event.userActivities) {
|
||||||
useActivityStore.getState().initActivities(event.userActivities);
|
useActivityStore.getState().initActivities(event.userActivities);
|
||||||
@@ -507,6 +519,23 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'message_pinned': {
|
||||||
|
// Atualiza a mensagem já carregada em vez de recarregar o canal: fixar é
|
||||||
|
// uma mudança de um campo, e recarregar jogaria fora a posição de leitura.
|
||||||
|
// updateMessage chaveia por message.channelId, então basta achar a
|
||||||
|
// mensagem na lista daquele canal.
|
||||||
|
const cs = useChatStore.getState();
|
||||||
|
const list = cs.messages.get(event.channelId);
|
||||||
|
const found = list?.find((m) => m.id === event.messageId);
|
||||||
|
if (found) {
|
||||||
|
cs.updateMessage({
|
||||||
|
...found,
|
||||||
|
pinnedAt: event.pinned ? Date.now() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'message_updated':
|
case 'message_updated':
|
||||||
if (!isHome) {
|
if (!isHome) {
|
||||||
normalizeMessageAssets(event.message, origin);
|
normalizeMessageAssets(event.message, origin);
|
||||||
@@ -574,14 +603,31 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'voice_state_update':
|
case 'voice_state_update': {
|
||||||
|
const vs = useVoiceStore.getState();
|
||||||
if (event.action === 'join') {
|
if (event.action === 'join') {
|
||||||
addVoiceUser(event.channelId, event.userId);
|
addVoiceUser(event.channelId, event.userId);
|
||||||
|
if (event.startedAt) vs.setVoiceRoomStart(event.channelId, event.startedAt);
|
||||||
} else {
|
} else {
|
||||||
removeVoiceUser(event.channelId, event.userId);
|
removeVoiceUser(event.channelId, event.userId);
|
||||||
clearVoiceUserStatus(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;
|
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':
|
case 'voice_status_update':
|
||||||
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
|
setVoiceUserStatus(event.userId, event.isMuted, event.isDeafened, event.isCameraOn, event.isScreenSharing);
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { translate } from './index';
|
||||||
|
import { en } from './locales/en';
|
||||||
|
import { ptBR } from './locales/pt-BR';
|
||||||
|
|
||||||
|
describe('translate', () => {
|
||||||
|
it('returns the translation for the active locale', () => {
|
||||||
|
expect(translate('pt-BR', 'settings.tab.account')).toBe('Conta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to English for a key the locale has not translated yet', () => {
|
||||||
|
// The whole migration strategy depends on this: pt-BR is deliberately
|
||||||
|
// partial, and an untranslated screen must read in English rather than
|
||||||
|
// break.
|
||||||
|
const untranslated = (Object.keys(en) as (keyof typeof en)[]).find((k) => !(k in ptBR));
|
||||||
|
if (!untranslated) return; // pt-BR fully caught up — nothing to assert
|
||||||
|
expect(translate('pt-BR', untranslated)).toBe(en[untranslated]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('substitutes named parameters', () => {
|
||||||
|
expect(translate('en', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 elapsed');
|
||||||
|
expect(translate('pt-BR', 'profile.activity.elapsed', { time: '3:20' })).toBe('3:20 decorrido');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a placeholder alone when no value is supplied', () => {
|
||||||
|
expect(translate('en', 'profile.activity.elapsed')).toBe('{time} elapsed');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps every pt-BR key present in the source dictionary', () => {
|
||||||
|
// Guards against a key being renamed in en.ts while pt-BR keeps the old
|
||||||
|
// one, which would silently fall back forever.
|
||||||
|
for (const key of Object.keys(ptBR)) {
|
||||||
|
expect(en).toHaveProperty(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { persist } from 'zustand/middleware';
|
||||||
|
import { en, type TranslationKey } from './locales/en';
|
||||||
|
import { ptBR } from './locales/pt-BR';
|
||||||
|
|
||||||
|
export const LOCALES = ['en', 'pt-BR'] as const;
|
||||||
|
export type Locale = (typeof LOCALES)[number];
|
||||||
|
|
||||||
|
const DICTIONARIES: Record<Locale, Partial<Record<TranslationKey, string>>> = {
|
||||||
|
en,
|
||||||
|
'pt-BR': ptBR,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* First-run guess from the browser. Persisted afterwards, so an explicit
|
||||||
|
* choice always wins over the browser's setting on later visits.
|
||||||
|
*/
|
||||||
|
function detectLocale(): Locale {
|
||||||
|
if (typeof navigator === 'undefined') return 'en';
|
||||||
|
return navigator.language?.toLowerCase().startsWith('pt') ? 'pt-BR' : 'en';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LocaleState {
|
||||||
|
locale: Locale;
|
||||||
|
setLocale: (locale: Locale) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useLocaleStore = create<LocaleState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
locale: detectLocale(),
|
||||||
|
setLocale: (locale) => set({ locale }),
|
||||||
|
}),
|
||||||
|
{ name: 'backspace-locale' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep <html lang> in sync: screen readers, spellcheck and hyphenation all read
|
||||||
|
// it, and persisted state rehydrates after the first paint — hence the
|
||||||
|
// subscription rather than a one-off assignment.
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.documentElement.lang = useLocaleStore.getState().locale;
|
||||||
|
useLocaleStore.subscribe((state) => {
|
||||||
|
document.documentElement.lang = state.locale;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a key, substituting `{name}` placeholders.
|
||||||
|
*
|
||||||
|
* Falls back to English, then to the key itself. The key is a deliberate last
|
||||||
|
* resort: it is ugly on screen, which makes a missing entry obvious in review
|
||||||
|
* instead of silently rendering an empty string.
|
||||||
|
*/
|
||||||
|
export function translate(
|
||||||
|
locale: Locale,
|
||||||
|
key: TranslationKey,
|
||||||
|
params?: Record<string, string | number>,
|
||||||
|
): string {
|
||||||
|
const template = DICTIONARIES[locale]?.[key] ?? en[key] ?? key;
|
||||||
|
if (!params) return template;
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, name: string) =>
|
||||||
|
name in params ? String(params[name]) : match,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes the calling component to the active locale, so switching language
|
||||||
|
* re-renders it. Components that only need the string once (outside React) can
|
||||||
|
* call `translate` with `useLocaleStore.getState().locale` instead.
|
||||||
|
*/
|
||||||
|
export function useT() {
|
||||||
|
const locale = useLocaleStore((s) => s.locale);
|
||||||
|
return (key: TranslationKey, params?: Record<string, string | number>) =>
|
||||||
|
translate(locale, key, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { TranslationKey };
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
/**
|
||||||
|
* 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.confirm': 'Add',
|
||||||
|
'soundboard.cancel': 'Cancel',
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// In-app notifications
|
||||||
|
'notify.attachment': 'Sent an attachment',
|
||||||
|
'notify.jump': 'Open',
|
||||||
|
|
||||||
|
// Desktop update
|
||||||
|
'update.ready': 'Update ready',
|
||||||
|
'update.readyVersion': 'Version {version} is ready to install.',
|
||||||
|
'update.restart': 'Restart to update',
|
||||||
|
'update.later': 'Later',
|
||||||
|
|
||||||
|
// Custom emojis and stickers
|
||||||
|
'expressions.title': 'Emojis & Stickers',
|
||||||
|
'expressions.emojis': 'Emojis',
|
||||||
|
'expressions.stickers': 'Stickers',
|
||||||
|
'expressions.addEmoji': 'Add emoji',
|
||||||
|
'expressions.addSticker': 'Add sticker',
|
||||||
|
'expressions.emptyEmojis': 'No custom emojis yet.',
|
||||||
|
'expressions.emptyStickers': 'No stickers yet.',
|
||||||
|
'expressions.namePrompt': 'Name',
|
||||||
|
'expressions.nameHintEmoji': 'Letters, numbers and underscore. Used as :name: in messages.',
|
||||||
|
'expressions.confirm': 'Add',
|
||||||
|
'expressions.cancel': 'Cancel',
|
||||||
|
'expressions.remove': 'Remove',
|
||||||
|
'expressions.tooLarge': 'Image must be under 512 KB.',
|
||||||
|
'expressions.uploadFailed': 'Could not add that image. Try another one.',
|
||||||
|
'expressions.nameTaken': 'That name is already in use in this server.',
|
||||||
|
'expressions.uploading': 'Uploading…',
|
||||||
|
'expressions.pickerTitle': 'Stickers',
|
||||||
|
'expressions.pickerEmpty': 'This server has no stickers yet.',
|
||||||
|
|
||||||
|
// Search
|
||||||
|
'search.placeholder': 'Search messages…',
|
||||||
|
'search.filters': 'Filters',
|
||||||
|
'search.from': 'From',
|
||||||
|
'search.fromPlaceholder': 'username',
|
||||||
|
'search.has': 'Has',
|
||||||
|
'search.hasAny': 'Any',
|
||||||
|
'search.hasFile': 'File',
|
||||||
|
'search.hasImage': 'Image',
|
||||||
|
'search.hasLink': 'Link',
|
||||||
|
'search.before': 'Before',
|
||||||
|
'search.after': 'After',
|
||||||
|
'search.hint': 'Tip: type from:name, has:image, before:2026-01-31 straight into the search box.',
|
||||||
|
|
||||||
|
// Pinned messages
|
||||||
|
'pins.pin': 'Pin Message',
|
||||||
|
'pins.unpin': 'Unpin Message',
|
||||||
|
'pins.title': 'Pinned Messages',
|
||||||
|
'pins.empty': 'No pinned messages in this channel yet.',
|
||||||
|
'pins.limit': 'This channel has reached the pin limit.',
|
||||||
|
|
||||||
|
// Sidebar — spaces, channels and members
|
||||||
|
'sidebar.friends': 'Friends',
|
||||||
|
'sidebar.directMessages': 'Direct Messages',
|
||||||
|
'sidebar.noChannels': 'No channels',
|
||||||
|
'sidebar.comingSoon': 'Coming Soon',
|
||||||
|
'sidebar.loadingChannels': 'Loading channels',
|
||||||
|
'sidebar.loadingMembers': 'Loading members',
|
||||||
|
'sidebar.groupOnline': 'ONLINE',
|
||||||
|
'sidebar.groupOwner': 'OWNER',
|
||||||
|
'sidebar.createChannel': 'Create Channel',
|
||||||
|
'sidebar.createCategory': 'Create Category',
|
||||||
|
'sidebar.categorySettings': 'Category Settings',
|
||||||
|
'sidebar.deleteCategory': 'Delete Category',
|
||||||
|
'sidebar.invitePeople': 'Invite People',
|
||||||
|
'sidebar.spaceSettings': 'Space Settings',
|
||||||
|
'sidebar.leaveGroup': 'Leave Group',
|
||||||
|
'sidebar.voiceSettings': 'Voice Settings',
|
||||||
|
'sidebar.inputDevice': 'Input Device',
|
||||||
|
'sidebar.inputVolume': 'Input Volume',
|
||||||
|
'sidebar.outputDevice': 'Output Device',
|
||||||
|
'sidebar.outputVolume': 'Output Volume',
|
||||||
|
|
||||||
|
// Chat — composer and messages
|
||||||
|
'chat.composer.placeholder': 'Message {channel}',
|
||||||
|
'chat.composer.attach': 'Attach file',
|
||||||
|
'chat.composer.emoji': 'Emoji',
|
||||||
|
'chat.composer.gif': 'GIF',
|
||||||
|
'chat.composer.gifPicker': 'GIF picker',
|
||||||
|
'chat.composer.emojiPicker': 'Emoji picker',
|
||||||
|
'chat.composer.send': 'Send',
|
||||||
|
'chat.composer.sendMessage': 'Send message',
|
||||||
|
'chat.composer.uploading': 'Uploading',
|
||||||
|
'chat.composer.uploadingEllipsis': 'Uploading…',
|
||||||
|
'chat.composer.removeAttachment': 'Remove attachment',
|
||||||
|
'chat.composer.failedAttachment': 'Remove or retry the failed attachment to send',
|
||||||
|
'chat.composer.replyingTo': 'Replying to',
|
||||||
|
'chat.composer.cancelReply': 'Cancel reply',
|
||||||
|
'chat.message.addReaction': 'Add reaction',
|
||||||
|
'chat.message.edit': 'Edit',
|
||||||
|
'chat.message.reply': 'Reply',
|
||||||
|
|
||||||
|
// Statistics
|
||||||
|
'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>;
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
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.confirm': 'Adicionar',
|
||||||
|
'soundboard.cancel': 'Cancelar',
|
||||||
|
'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',
|
||||||
|
|
||||||
|
// Notificações no app
|
||||||
|
'notify.attachment': 'Enviou um anexo',
|
||||||
|
'notify.jump': 'Abrir',
|
||||||
|
|
||||||
|
// Atualização do app
|
||||||
|
'update.ready': 'Atualização pronta',
|
||||||
|
'update.readyVersion': 'A versão {version} está pronta para instalar.',
|
||||||
|
'update.restart': 'Reiniciar para atualizar',
|
||||||
|
'update.later': 'Depois',
|
||||||
|
|
||||||
|
// Emojis e figurinhas
|
||||||
|
'expressions.title': 'Emojis e figurinhas',
|
||||||
|
'expressions.emojis': 'Emojis',
|
||||||
|
'expressions.stickers': 'Figurinhas',
|
||||||
|
'expressions.addEmoji': 'Adicionar emoji',
|
||||||
|
'expressions.addSticker': 'Adicionar figurinha',
|
||||||
|
'expressions.emptyEmojis': 'Nenhum emoji próprio ainda.',
|
||||||
|
'expressions.emptyStickers': 'Nenhuma figurinha ainda.',
|
||||||
|
'expressions.namePrompt': 'Nome',
|
||||||
|
'expressions.nameHintEmoji': 'Letras, números e sublinhado. Usado como :nome: nas mensagens.',
|
||||||
|
'expressions.confirm': 'Adicionar',
|
||||||
|
'expressions.cancel': 'Cancelar',
|
||||||
|
'expressions.remove': 'Remover',
|
||||||
|
'expressions.tooLarge': 'A imagem precisa ter menos de 512 KB.',
|
||||||
|
'expressions.uploadFailed': 'Não foi possível adicionar essa imagem. Tente outra.',
|
||||||
|
'expressions.nameTaken': 'Esse nome já está em uso neste servidor.',
|
||||||
|
'expressions.uploading': 'Enviando…',
|
||||||
|
'expressions.pickerTitle': 'Figurinhas',
|
||||||
|
'expressions.pickerEmpty': 'Este servidor ainda não tem figurinhas.',
|
||||||
|
|
||||||
|
// Busca
|
||||||
|
'search.placeholder': 'Buscar mensagens…',
|
||||||
|
'search.filters': 'Filtros',
|
||||||
|
'search.from': 'De',
|
||||||
|
'search.fromPlaceholder': 'nome de usuário',
|
||||||
|
'search.has': 'Contém',
|
||||||
|
'search.hasAny': 'Qualquer',
|
||||||
|
'search.hasFile': 'Arquivo',
|
||||||
|
'search.hasImage': 'Imagem',
|
||||||
|
'search.hasLink': 'Link',
|
||||||
|
'search.before': 'Antes de',
|
||||||
|
'search.after': 'Depois de',
|
||||||
|
'search.hint': 'Dica: digite de:nome, contém:imagem, antes:2026-01-31 direto no campo de busca.',
|
||||||
|
|
||||||
|
// Mensagens fixadas
|
||||||
|
'pins.pin': 'Fixar mensagem',
|
||||||
|
'pins.unpin': 'Desafixar mensagem',
|
||||||
|
'pins.title': 'Mensagens fixadas',
|
||||||
|
'pins.empty': 'Nenhuma mensagem fixada neste canal ainda.',
|
||||||
|
'pins.limit': 'Este canal atingiu o limite de mensagens fixadas.',
|
||||||
|
|
||||||
|
// Barra lateral — servidores, canais e membros
|
||||||
|
'sidebar.friends': 'Amigos',
|
||||||
|
'sidebar.directMessages': 'Mensagens diretas',
|
||||||
|
'sidebar.noChannels': 'Nenhum canal',
|
||||||
|
'sidebar.comingSoon': 'Em breve',
|
||||||
|
'sidebar.loadingChannels': 'Carregando canais',
|
||||||
|
'sidebar.loadingMembers': 'Carregando membros',
|
||||||
|
'sidebar.groupOnline': 'DISPONÍVEIS',
|
||||||
|
'sidebar.groupOwner': 'DONO',
|
||||||
|
'sidebar.createChannel': 'Criar canal',
|
||||||
|
'sidebar.createCategory': 'Criar categoria',
|
||||||
|
'sidebar.categorySettings': 'Configurações da categoria',
|
||||||
|
'sidebar.deleteCategory': 'Excluir categoria',
|
||||||
|
'sidebar.invitePeople': 'Convidar pessoas',
|
||||||
|
'sidebar.spaceSettings': 'Configurações do servidor',
|
||||||
|
'sidebar.leaveGroup': 'Sair do grupo',
|
||||||
|
'sidebar.voiceSettings': 'Configurações de voz',
|
||||||
|
'sidebar.inputDevice': 'Dispositivo de entrada',
|
||||||
|
'sidebar.inputVolume': 'Volume de entrada',
|
||||||
|
'sidebar.outputDevice': 'Dispositivo de saída',
|
||||||
|
'sidebar.outputVolume': 'Volume de saída',
|
||||||
|
|
||||||
|
// Chat — composer e mensagens
|
||||||
|
'chat.composer.placeholder': 'Conversar em {channel}',
|
||||||
|
'chat.composer.attach': 'Anexar arquivo',
|
||||||
|
'chat.composer.emoji': 'Emoji',
|
||||||
|
'chat.composer.gif': 'GIF',
|
||||||
|
'chat.composer.gifPicker': 'Seletor de GIF',
|
||||||
|
'chat.composer.emojiPicker': 'Seletor de emoji',
|
||||||
|
'chat.composer.send': 'Enviar',
|
||||||
|
'chat.composer.sendMessage': 'Enviar mensagem',
|
||||||
|
'chat.composer.uploading': 'Enviando',
|
||||||
|
'chat.composer.uploadingEllipsis': 'Enviando…',
|
||||||
|
'chat.composer.removeAttachment': 'Remover anexo',
|
||||||
|
'chat.composer.failedAttachment': 'Remova ou tente de novo o anexo que falhou para poder enviar',
|
||||||
|
'chat.composer.replyingTo': 'Respondendo a',
|
||||||
|
'chat.composer.cancelReply': 'Cancelar resposta',
|
||||||
|
'chat.message.addReaction': 'Adicionar reação',
|
||||||
|
'chat.message.edit': 'Editar',
|
||||||
|
'chat.message.reply': 'Responder',
|
||||||
|
|
||||||
|
// Estatísticas
|
||||||
|
'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',
|
||||||
|
};
|
||||||
@@ -10,16 +10,16 @@ export function initActivityBridge(): void {
|
|||||||
// Subscribe to future activity changes from main process
|
// Subscribe to future activity changes from main process
|
||||||
unsubscribe = window.backspace.onActivityDetected((activity) => {
|
unsubscribe = window.backspace.onActivityDetected((activity) => {
|
||||||
if (activity) {
|
if (activity) {
|
||||||
useActivityStore.getState().pushActivities([activity as Activity]);
|
useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
|
||||||
} else {
|
} else {
|
||||||
useActivityStore.getState().pushActivities([]);
|
useActivityStore.getState().setSourceActivities('desktop', []);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request current state (handles instance-switch: game was already running)
|
// Request current state (handles instance-switch: game was already running)
|
||||||
window.backspace.getCurrentActivity?.().then((activity: unknown) => {
|
window.backspace.getCurrentActivity?.().then((activity: unknown) => {
|
||||||
if (activity) {
|
if (activity) {
|
||||||
useActivityStore.getState().pushActivities([activity as Activity]);
|
useActivityStore.getState().setSourceActivities('desktop', [activity as Activity]);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -61,6 +61,11 @@ interface BackspaceElectronAPI {
|
|||||||
// Screen share picker coordination
|
// Screen share picker coordination
|
||||||
onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void;
|
onScreenShareSources: (callback: (sources: ElectronScreenSource[]) => void) => void;
|
||||||
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void;
|
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => void;
|
||||||
|
onNativeAudioData?: (
|
||||||
|
callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void,
|
||||||
|
) => () => void;
|
||||||
|
onNativeAudioUnavailable?: (callback: () => void) => () => void;
|
||||||
|
stopNativeAudio?: () => void;
|
||||||
|
|
||||||
// Instance URL management
|
// Instance URL management
|
||||||
getInstanceUrl: () => Promise<string | null>;
|
getInstanceUrl: () => Promise<string | null>;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Activity } from '@backspace/shared';
|
import type { Activity } from '@backspace/shared';
|
||||||
|
import { ACTIVITY_LIMITS } from '@backspace/shared/src/activities.js';
|
||||||
import { wsSendAll } from '../hooks/useWebSocket';
|
import { wsSendAll } from '../hooks/useWebSocket';
|
||||||
|
|
||||||
let pushTimer: ReturnType<typeof setTimeout> | null = null;
|
let pushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
@@ -14,9 +15,18 @@ interface ActivityState {
|
|||||||
initActivities: (activityMap: Record<string, Activity[]>) => void;
|
initActivities: (activityMap: Record<string, Activity[]>) => void;
|
||||||
setShowActivity: (show: boolean) => void;
|
setShowActivity: (show: boolean) => void;
|
||||||
pushActivities: (activities: Activity[]) => void;
|
pushActivities: (activities: Activity[]) => void;
|
||||||
|
setSourceActivities: (source: string, activities: Activity[], opts?: { immediate?: boolean }) => void;
|
||||||
reset: () => 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) => ({
|
export const useActivityStore = create<ActivityState>((set, get) => ({
|
||||||
userActivities: new Map(),
|
userActivities: new Map(),
|
||||||
showActivity: true,
|
showActivity: true,
|
||||||
@@ -75,8 +85,30 @@ export const useActivityStore = create<ActivityState>((set, get) => ({
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setSourceActivities: (source, activities, opts) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
if (!opts?.immediate) {
|
||||||
|
get().pushActivities(merged);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skips the 5s debounce. That delay exists to coalesce a chatty producer,
|
||||||
|
// but a track change happens once every few minutes and stacking it on top
|
||||||
|
// of the poll interval is what made everyone else see the previous song.
|
||||||
|
if (!get().showActivity) return;
|
||||||
|
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
||||||
|
set({ myActivities: merged });
|
||||||
|
wsSendAll({ type: 'activity_update', activities: merged });
|
||||||
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
if (pushTimer) { clearTimeout(pushTimer); pushTimer = null; }
|
||||||
|
bySource.clear();
|
||||||
set({ userActivities: new Map(), showActivity: true, myActivities: null });
|
set({ userActivities: new Map(), showActivity: true, myActivities: null });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ interface ChatState {
|
|||||||
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
loadMessages: (channelId: string, force?: boolean) => Promise<void>;
|
||||||
clearAllMessages: () => void;
|
clearAllMessages: () => void;
|
||||||
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
loadMoreMessages: (channelId: string) => Promise<boolean>;
|
||||||
sendMessage: (channelId: string, content: string, attachmentIds?: string[]) => Promise<void>;
|
sendMessage: (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => Promise<void>;
|
||||||
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
editMessage: (messageId: string, content: string, channelId: string) => Promise<void>;
|
||||||
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
deleteMessage: (messageId: string, channelId: string) => Promise<void>;
|
||||||
addMessage: (channelId: string, message: MessageWithUser) => void;
|
addMessage: (channelId: string, message: MessageWithUser) => void;
|
||||||
@@ -319,7 +319,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
sendMessage: async (channelId: string, content: string, attachmentIds?: string[]) => {
|
sendMessage: async (channelId: string, content: string, attachmentIds?: string[], stickerId?: string) => {
|
||||||
const replyToId = get().replyTo?.id;
|
const replyToId = get().replyTo?.id;
|
||||||
const isDm = isDmChannel(channelId);
|
const isDm = isDmChannel(channelId);
|
||||||
const currentUser = useAuthStore.getState().user;
|
const currentUser = useAuthStore.getState().user;
|
||||||
@@ -367,7 +367,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (isDm) {
|
if (isDm) {
|
||||||
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
await client.dm.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
||||||
} else {
|
} else {
|
||||||
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId });
|
await client.channels.sendMessage(channelId, { content, attachments: attachmentIds, replyToId, stickerId });
|
||||||
}
|
}
|
||||||
// Real message will arrive via WebSocket and replace the temp one
|
// Real message will arrive via WebSocket and replace the temp one
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { api, type SpaceEmoji, type SpaceSticker } from '../api/client';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emojis e figurinhas por espaço.
|
||||||
|
*
|
||||||
|
* Carregados uma vez por espaço e mantidos em memória: o render de mensagem
|
||||||
|
* consulta o mapa a cada `:nome:` encontrado, e uma busca por requisição
|
||||||
|
* tornaria cada mensagem uma cascata de chamadas.
|
||||||
|
*/
|
||||||
|
interface ExpressionState {
|
||||||
|
emojisBySpace: Map<string, SpaceEmoji[]>;
|
||||||
|
stickersBySpace: Map<string, SpaceSticker[]>;
|
||||||
|
loaded: Set<string>;
|
||||||
|
load: (spaceId: string) => Promise<void>;
|
||||||
|
emojiByName: (spaceId: string, name: string) => SpaceEmoji | undefined;
|
||||||
|
setEmojis: (spaceId: string, emojis: SpaceEmoji[]) => void;
|
||||||
|
setStickers: (spaceId: string, stickers: SpaceSticker[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useExpressionStore = create<ExpressionState>((set, get) => ({
|
||||||
|
emojisBySpace: new Map(),
|
||||||
|
stickersBySpace: new Map(),
|
||||||
|
loaded: new Set(),
|
||||||
|
|
||||||
|
load: async (spaceId) => {
|
||||||
|
if (!spaceId || get().loaded.has(spaceId)) return;
|
||||||
|
// Marcado antes da resposta: entrar num espaço dispara vários renders, e
|
||||||
|
// sem isso a mesma busca sairia várias vezes em paralelo.
|
||||||
|
set((s) => ({ loaded: new Set(s.loaded).add(spaceId) }));
|
||||||
|
try {
|
||||||
|
const [{ emojis }, { stickers }] = await Promise.all([
|
||||||
|
api.expressions.emojis(spaceId),
|
||||||
|
api.expressions.stickers(spaceId),
|
||||||
|
]);
|
||||||
|
set((s) => ({
|
||||||
|
emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis),
|
||||||
|
stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers),
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Sem emojis próprios a conversa segue: `:nome:` fica como texto.
|
||||||
|
set((s) => {
|
||||||
|
const loaded = new Set(s.loaded); loaded.delete(spaceId);
|
||||||
|
return { loaded };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
emojiByName: (spaceId, name) =>
|
||||||
|
get().emojisBySpace.get(spaceId)?.find((e) => e.name === name),
|
||||||
|
|
||||||
|
setEmojis: (spaceId, emojis) =>
|
||||||
|
set((s) => ({ emojisBySpace: new Map(s.emojisBySpace).set(spaceId, emojis) })),
|
||||||
|
|
||||||
|
setStickers: (spaceId, stickers) =>
|
||||||
|
set((s) => ({ stickersBySpace: new Map(s.stickersBySpace).set(spaceId, stickers) })),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
export interface InAppNotification {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
avatar: string | null;
|
||||||
|
userId?: string;
|
||||||
|
channelId?: string;
|
||||||
|
spaceId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
items: InAppNotification[];
|
||||||
|
push: (n: Omit<InAppNotification, 'id'>) => void;
|
||||||
|
dismiss: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poucas de cada vez: uma pilha longa cobre a conversa em vez de avisar. */
|
||||||
|
const MAX_VISIBLE = 3;
|
||||||
|
|
||||||
|
export const useInAppNotificationStore = create<State>((set) => ({
|
||||||
|
items: [],
|
||||||
|
push: (n) =>
|
||||||
|
set((s) => ({
|
||||||
|
items: [...s.items, { ...n, id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` }]
|
||||||
|
.slice(-MAX_VISIBLE),
|
||||||
|
})),
|
||||||
|
dismiss: (id) => set((s) => ({ items: s.items.filter((i) => i.id !== id) })),
|
||||||
|
}));
|
||||||
@@ -16,6 +16,12 @@ export interface ScreenShareConfig {
|
|||||||
|
|
||||||
interface VoiceState {
|
interface VoiceState {
|
||||||
voiceUsers: Map<string, string[]>; // channelId → userIds
|
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;
|
currentVoiceChannelId: string | null;
|
||||||
/**
|
/**
|
||||||
* Space and name of the channel the call is in, captured at join time.
|
* 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;
|
setVoiceUsers: (channelId: string, userIds: string[]) => void;
|
||||||
addVoiceUser: (channelId: string, userId: string) => void;
|
addVoiceUser: (channelId: string, userId: string) => void;
|
||||||
removeVoiceUser: (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;
|
setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
|
||||||
setParticipants: (participants: ParticipantInfo[]) => void;
|
setParticipants: (participants: ParticipantInfo[]) => void;
|
||||||
setSpeakingParticipants: (ids: Set<string>) => void;
|
setSpeakingParticipants: (ids: Set<string>) => void;
|
||||||
@@ -166,6 +174,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
persist(
|
persist(
|
||||||
(set, get) => ({
|
(set, get) => ({
|
||||||
voiceUsers: new Map(),
|
voiceUsers: new Map(),
|
||||||
|
voiceRoomStarts: new Map(),
|
||||||
currentVoiceChannelId: null,
|
currentVoiceChannelId: null,
|
||||||
currentVoiceSpaceId: null,
|
currentVoiceSpaceId: null,
|
||||||
currentVoiceChannelName: 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({
|
setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
|
||||||
currentVoiceChannelId: channelId,
|
currentVoiceChannelId: channelId,
|
||||||
currentVoiceSpaceId: channelId ? spaceId : null,
|
currentVoiceSpaceId: channelId ? spaceId : null,
|
||||||
@@ -527,6 +552,7 @@ export const useVoiceStore = create<VoiceState>()(
|
|||||||
// Connection state
|
// Connection state
|
||||||
hwOverdrive: false,
|
hwOverdrive: false,
|
||||||
voiceUsers: new Map(),
|
voiceUsers: new Map(),
|
||||||
|
voiceRoomStarts: new Map(),
|
||||||
voiceUserStates: new Map(),
|
voiceUserStates: new Map(),
|
||||||
currentVoiceChannelId: null,
|
currentVoiceChannelId: null,
|
||||||
currentVoiceSpaceId: null,
|
currentVoiceSpaceId: null,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user