Compare commits
55
Commits
1e6c7c6042
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310e9b86be | ||
|
|
e291b5e411 | ||
|
|
c899253e52 | ||
|
|
ff55d9d486 | ||
|
|
e58021408c | ||
|
|
2afe3453f0 | ||
|
|
e89966435a | ||
|
|
f5451e1b14 | ||
|
|
ef5545465d | ||
|
|
1830051732 | ||
|
|
bbb190cbda | ||
|
|
fb662bfe12 | ||
|
|
0fc6abeb6e | ||
|
|
75316b0882 | ||
|
|
c022f2795f | ||
|
|
688a1335cb | ||
|
|
89e13441c8 | ||
|
|
63afd2fc89 | ||
|
|
d80de49768 | ||
|
|
b92a0d837e | ||
|
|
d7da0ff203 | ||
|
|
bfe62d7078 | ||
|
|
37407a5ecd | ||
|
|
cad3867027 | ||
|
|
20526e1bc8 | ||
|
|
c70b0095a9 | ||
|
|
08db5374cb | ||
|
|
6c7cbee808 | ||
|
|
8456b8f976 | ||
|
|
747f1b9c5c | ||
|
|
89467d6f93 | ||
|
|
8e13aaa057 | ||
|
|
aa5052ba8a | ||
|
|
97989fbfb4 | ||
|
|
bf836e208b | ||
|
|
53a36cd11a | ||
|
|
d2d6ce9756 | ||
|
|
7d1895308d | ||
|
|
0b3aa42a09 | ||
|
|
6d81b63d86 | ||
|
|
c4929a8b1b | ||
|
|
0ec7ddba81 | ||
|
|
3100965c30 | ||
|
|
9d2eeb0963 | ||
|
|
fd8659a964 | ||
|
|
7457146846 | ||
|
|
321428ba1f | ||
|
|
cf4172e81a | ||
|
|
e2d09c0d52 | ||
|
|
cb524675cc | ||
|
|
4758ca46fa | ||
|
|
21d783e257 | ||
|
|
3f75ff6e36 | ||
|
|
74ae929ab4 | ||
|
|
807afba45a |
@@ -21,7 +21,9 @@ Closes #
|
|||||||
- [ ] Tests pass where applicable (`pnpm test`)
|
- [ ] Tests pass where applicable (`pnpm test`)
|
||||||
- [ ] I updated the relevant `docs/systems/` spec if this changed schema, API routes, WebSocket events, the federation protocol, permissions, or the design system
|
- [ ] I updated the relevant `docs/systems/` spec if this changed schema, API routes, WebSocket events, the federation protocol, permissions, or the design system
|
||||||
- [ ] This change resolves the correct federated identity where it compares IDs, checks permissions, or talks to remote servers (no assumption of a single global user ID)
|
- [ ] This change resolves the correct federated identity where it compares IDs, checks permissions, or talks to remote servers (no assumption of a single global user ID)
|
||||||
- [ ] I have read and agree to the [CLA](../CLA.md)
|
- [ ] I have read and agree to the [CLA](../CLA.md) — ticking this box is not the
|
||||||
|
signature. After opening this PR, post a separate comment containing exactly:
|
||||||
|
`I have read the CLA Document and I hereby sign the CLA`
|
||||||
|
|
||||||
## Notes for reviewers
|
## Notes for reviewers
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
|
||||||
|
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
|
||||||
|
# - github-actions → action version bumps (feeds the SHA-pin comments)
|
||||||
|
# - docker → the Dockerfile base image (FROM node:20-slim)
|
||||||
|
#
|
||||||
|
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
|
||||||
|
# at the same "/" directory (a second docker entry would collide on
|
||||||
|
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
|
||||||
|
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
|
||||||
|
# are updated MANUALLY — see the maintainer checklist in
|
||||||
|
# docs/systems/security-scanning.md.
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
# One grouped PR for routine minor/patch bumps to cut PR noise.
|
||||||
|
npm-minor-patch:
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
ignore:
|
||||||
|
# uiohook-napi is pinned by an exact-version pnpm patch
|
||||||
|
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
|
||||||
|
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
|
||||||
|
# Docker stages until the patch is regenerated. Bump it by hand.
|
||||||
|
- dependency-name: uiohook-napi
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
@@ -31,15 +31,20 @@ jobs:
|
|||||||
node-version: [20, 24]
|
node-version: [20, 24]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v5
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10.34.3
|
version: 10.34.3
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|||||||
@@ -2,8 +2,16 @@ name: CLA Assistant
|
|||||||
|
|
||||||
# Requires every contributor to sign the project Contributor License Agreement
|
# Requires every contributor to sign the project Contributor License Agreement
|
||||||
# (CLA.md) before their pull request can be merged. Signatures are stored in
|
# (CLA.md) before their pull request can be merged. Signatures are stored in
|
||||||
# this repository at signatures/cla.json — no external service or database is
|
# this repository at signatures/cla.json on the `cla-signatures` branch — no
|
||||||
# used. A contributor signs by commenting the exact sentence configured below.
|
# external service or database is used. A contributor signs by commenting the
|
||||||
|
# exact sentence configured below.
|
||||||
|
#
|
||||||
|
# Why not `main`: the action appends each signature as a direct commit, which
|
||||||
|
# the "Require CI on main" ruleset rejects ("Repository rule violations found"),
|
||||||
|
# leaving the signature unrecorded and the check permanently red. Keeping the
|
||||||
|
# store on its own branch lets the bot append without granting any actor a
|
||||||
|
# bypass on main. That branch has its own ruleset blocking deletion and
|
||||||
|
# force-pushes, so the record cannot be rewritten.
|
||||||
|
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
@@ -29,7 +37,7 @@ jobs:
|
|||||||
(github.event.comment.body == 'recheck' ||
|
(github.event.comment.body == 'recheck' ||
|
||||||
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') ||
|
github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') ||
|
||||||
github.event_name == 'pull_request_target'
|
github.event_name == 'pull_request_target'
|
||||||
uses: contributor-assistant/github-action@v2.6.1
|
uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
with:
|
||||||
@@ -37,7 +45,7 @@ jobs:
|
|||||||
path-to-signatures: 'signatures/cla.json'
|
path-to-signatures: 'signatures/cla.json'
|
||||||
# The CLA document contributors are agreeing to.
|
# The CLA document contributors are agreeing to.
|
||||||
path-to-document: 'https://github.com/TheZwiss/backspace/blob/main/CLA.md'
|
path-to-document: 'https://github.com/TheZwiss/backspace/blob/main/CLA.md'
|
||||||
branch: 'main'
|
branch: 'cla-signatures'
|
||||||
# Accounts that never need to sign (maintainer + automation).
|
# Accounts that never need to sign (maintainer + automation).
|
||||||
allowlist: 'TheZwiss,dependabot[bot],github-actions[bot]'
|
allowlist: 'TheZwiss,dependabot[bot],github-actions[bot]'
|
||||||
# The exact phrase a contributor comments to sign.
|
# The exact phrase a contributor comments to sign.
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
# Static application security testing for all TS/JS. Uses build-mode: none — no
|
||||||
|
# compile needed, which sidesteps the monorepo/native-module build entirely.
|
||||||
|
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
|
||||||
|
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
|
||||||
|
# blocking is a repo setting (code-scanning merge protection), documented in the
|
||||||
|
# maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: codeql-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (javascript-typescript)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
languages: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
category: "/language:javascript-typescript"
|
||||||
@@ -24,10 +24,10 @@ jobs:
|
|||||||
name: github-pages
|
name: github-pages
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
- uses: actions/configure-pages@v6
|
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||||
- uses: actions/upload-pages-artifact@v5
|
- uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||||
with:
|
with:
|
||||||
path: ./site
|
path: ./site
|
||||||
- id: deployment
|
- id: deployment
|
||||||
uses: actions/deploy-pages@v5
|
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||||
|
|||||||
@@ -27,13 +27,14 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
# The runtime image bakes the git commit for the AGPL-3.0 § 13 source
|
# The runtime image bakes the git commit for the AGPL-3.0 § 13 source
|
||||||
# offer (config.commit → GET /api/instance/info). The .git dir is not in
|
# offer (config.commit → GET /api/instance/info). The .git dir is not in
|
||||||
@@ -44,13 +45,13 @@ jobs:
|
|||||||
run: echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
run: echo "commit=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||||
|
|
||||||
- name: Log in to GitHub Container Registry
|
- name: Log in to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -58,7 +59,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Derive image tags and labels
|
- name: Derive image tags and labels
|
||||||
id: docker_meta
|
id: docker_meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||||
with:
|
with:
|
||||||
# github.repository is "TheZwiss/backspace"; metadata-action lowercases
|
# github.repository is "TheZwiss/backspace"; metadata-action lowercases
|
||||||
# it → ghcr.io/thezwiss/backspace (GHCR requires lowercase).
|
# it → ghcr.io/thezwiss/backspace (GHCR requires lowercase).
|
||||||
@@ -76,8 +77,47 @@ jobs:
|
|||||||
org.opencontainers.image.licenses=AGPL-3.0-only
|
org.opencontainers.image.licenses=AGPL-3.0-only
|
||||||
org.opencontainers.image.revision=${{ github.sha }}
|
org.opencontainers.image.revision=${{ github.sha }}
|
||||||
|
|
||||||
|
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||||
|
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||||
|
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||||
|
# single-arch build first; the multi-arch push below reuses these layers
|
||||||
|
# from the buildx cache, so this is cheap.
|
||||||
|
- name: Build amd64 image for scanning
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: backspace:scan
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Trivy image scan (report-only)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: image
|
||||||
|
image-ref: backspace:scan
|
||||||
|
ignore-unfixed: true
|
||||||
|
format: sarif
|
||||||
|
output: trivy-image.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Upload Trivy image SARIF
|
||||||
|
if: always()
|
||||||
|
continue-on-error: true # a scanner/SARIF-emit flake must never skip the publish below
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-image.sarif
|
||||||
|
category: trivy-image
|
||||||
|
|
||||||
|
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||||
|
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||||
- name: Build and push (linux/amd64, linux/arm64)
|
- name: Build and push (linux/amd64, linux/arm64)
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm64
|
platforms: linux/amd64,linux/arm64
|
||||||
@@ -86,6 +126,8 @@ jobs:
|
|||||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
build-args: |
|
build-args: |
|
||||||
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
sbom: true
|
||||||
|
provenance: true
|
||||||
# Cache multi-arch layers across runs via the GitHub Actions cache to
|
# Cache multi-arch layers across runs via the GitHub Actions cache to
|
||||||
# keep the ~1.6 GB build from re-running cold every release.
|
# keep the ~1.6 GB build from re-running cold every release.
|
||||||
cache-from: type=gha
|
cache-from: type=gha
|
||||||
|
|||||||
@@ -34,8 +34,14 @@ jobs:
|
|||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
|
||||||
- name: Install Linux build dependencies
|
- name: Install Linux build dependencies
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
@@ -65,12 +71,12 @@ jobs:
|
|||||||
sudo gem install --no-document fpm
|
sudo gem install --no-document fpm
|
||||||
|
|
||||||
- name: Setup pnpm
|
- name: Setup pnpm
|
||||||
uses: pnpm/action-setup@v5
|
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
|
||||||
with:
|
with:
|
||||||
version: 10.34.3
|
version: 10.34.3
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 20
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
name: OpenSSF Scorecard
|
||||||
|
|
||||||
|
# Scores the repo's security posture (branch protection, pinned deps, token
|
||||||
|
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
|
||||||
|
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
|
||||||
|
# see the maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
branch_protection_rule:
|
||||||
|
schedule:
|
||||||
|
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # upload SARIF
|
||||||
|
id-token: write # publish_results OIDC attestation
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Run Scorecard
|
||||||
|
uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
publish_results: true
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||||
|
with:
|
||||||
|
name: SARIF file
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
- name: Upload SARIF to code scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
name: Security
|
||||||
|
|
||||||
|
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
|
||||||
|
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
|
||||||
|
# is flipped on in Plan E after the remediation pass.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitleaks:
|
||||||
|
name: Secret scan (gitleaks)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout (full history)
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
|
||||||
|
- name: Run gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@ff98106e4c7b2bc287b24eaf42907196329070c7 # v2.3.9
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
osv-scanner:
|
||||||
|
name: Dependency scan (OSV-Scanner)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Run OSV-Scanner
|
||||||
|
uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-args: |-
|
||||||
|
--lockfile=./pnpm-lock.yaml
|
||||||
|
--format=sarif
|
||||||
|
--output=osv-results.sarif
|
||||||
|
- name: Upload OSV SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: osv-results.sarif
|
||||||
|
category: osv-scanner
|
||||||
|
|
||||||
|
trivy-config:
|
||||||
|
name: IaC/config scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Trivy config scan (Dockerfile + docker-compose)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: config
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-config.sarif
|
||||||
|
- name: Upload Trivy config SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-config.sarif
|
||||||
|
category: trivy-config
|
||||||
|
|
||||||
|
trivy-license:
|
||||||
|
name: License compliance scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
|
||||||
|
- name: Trivy license scan
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
scanners: license
|
||||||
|
format: sarif
|
||||||
|
output: trivy-license.sarif
|
||||||
|
- name: Upload Trivy license SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-license.sarif
|
||||||
|
category: trivy-license
|
||||||
@@ -160,6 +160,7 @@ Before modifying any subsystem, read its spec from `docs/systems/`. After making
|
|||||||
| [message-list.md](docs/systems/message-list.md) | Auto-scroll model, position memory (session-only), embed renderer dimension contract, known limitations | Touching MessageList.tsx, scroll behavior, embed renderers, position restore |
|
| [message-list.md](docs/systems/message-list.md) | Auto-scroll model, position memory (session-only), embed renderer dimension contract, known limitations | Touching MessageList.tsx, scroll behavior, embed renderers, position restore |
|
||||||
| [deployment.md](docs/systems/deployment.md) | Hosting pipeline: Docker/Caddy build, admin bootstrap, DB backup/restore, image pinning, env vars | Any deploy, backup/restore, or hosting change |
|
| [deployment.md](docs/systems/deployment.md) | Hosting pipeline: Docker/Caddy build, admin bootstrap, DB backup/restore, image pinning, env vars | Any deploy, backup/restore, or hosting change |
|
||||||
| [activity-presence.md](docs/systems/activity-presence.md) | Presence states, rich activities, activity types/priorities, broadcast pipeline, visibility control, ActivityCard/Panel | Presence, rich activities, activity display, status management |
|
| [activity-presence.md](docs/systems/activity-presence.md) | Presence states, rich activities, activity types/priorities, broadcast pipeline, visibility control, ActivityCard/Panel | Presence, rich activities, activity display, status management |
|
||||||
|
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/license; image scan in a later plan), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
+30
-4
@@ -21,6 +21,15 @@ COPY packages/web/package.json packages/web/
|
|||||||
# Copy patches (referenced by pnpm-lock.yaml)
|
# Copy patches (referenced by pnpm-lock.yaml)
|
||||||
COPY patches/ patches/
|
COPY patches/ patches/
|
||||||
|
|
||||||
|
# better-sqlite3 publishes no prebuilt binary for Node 20 (ABI 115) — its
|
||||||
|
# releases cover ABI 127/137/141/147 only — so prebuild-install falls back to
|
||||||
|
# compiling with node-gyp, which needs python3/make/g++. node:20-slim ships
|
||||||
|
# none of them. Builder stage only: the runtime stage copies the compiled
|
||||||
|
# .node and stays slim.
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
python3 make g++ \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
@@ -38,9 +47,12 @@ FROM node:20-slim AS runtime
|
|||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
|
||||||
|
|
||||||
# Install build dependencies for better-sqlite3 native module
|
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
|
||||||
|
# entrypoint). sharp is N-API (ABI-independent) and loads a prebuilt binary;
|
||||||
|
# better-sqlite3 no longer ships one for Node 20, so it is compiled below with
|
||||||
|
# a toolchain that is purged in the same layer.
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -57,8 +69,16 @@ COPY packages/web/package.json packages/web/
|
|||||||
# Copy patches (referenced by pnpm-lock.yaml)
|
# Copy patches (referenced by pnpm-lock.yaml)
|
||||||
COPY patches/ patches/
|
COPY patches/ patches/
|
||||||
|
|
||||||
# Install production dependencies only (tsx is in server dependencies)
|
# Install production dependencies only (tsx is in server dependencies).
|
||||||
RUN pnpm install --prod --frozen-lockfile
|
# better-sqlite3 compiles from source here (no Node 20 prebuilt), so the C
|
||||||
|
# toolchain is installed, used and purged inside this single layer — the final
|
||||||
|
# image ships no compiler.
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends python3 make g++ && \
|
||||||
|
pnpm install --prod --frozen-lockfile && \
|
||||||
|
apt-get purge -y python3 make g++ && \
|
||||||
|
apt-get autoremove -y && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Copy shared source (needed at runtime since server imports types directly)
|
# Copy shared source (needed at runtime since server imports types directly)
|
||||||
COPY packages/shared/ packages/shared/
|
COPY packages/shared/ packages/shared/
|
||||||
@@ -72,6 +92,11 @@ COPY --from=builder /app/packages/web/dist packages/web/dist
|
|||||||
# Create data directories
|
# Create data directories
|
||||||
RUN mkdir -p /app/data/uploads
|
RUN mkdir -p /app/data/uploads
|
||||||
|
|
||||||
|
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||||
|
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Set environment defaults
|
# Set environment defaults
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
@@ -94,4 +119,5 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=5 \
|
|||||||
|
|
||||||
# Run the server using tsx from the server package directory
|
# Run the server using tsx from the server package directory
|
||||||
WORKDIR /app/packages/server
|
WORKDIR /app/packages/server
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||||
|
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||||
|
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||||
|
# the bind mount was created by a different uid.
|
||||||
|
#
|
||||||
|
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||||
|
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||||
|
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||||
|
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||||
|
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||||
|
# (the old root container booted fine on such mounts).
|
||||||
|
set -e
|
||||||
|
mkdir -p /app/data/uploads
|
||||||
|
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||||
|
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||||
|
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||||
|
exec gosu node "$@"
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Roadmap — fork Resenha
|
||||||
|
|
||||||
|
Plano de features próprias desta instância. Arquivo exclusivo do fork
|
||||||
|
(nome com sufixo para não colidir com arquivos do upstream em merges).
|
||||||
|
|
||||||
|
Escrito em português por ser documento de planejamento do dono do fork; o
|
||||||
|
código e os commits seguem em inglês, como o resto do repositório.
|
||||||
|
|
||||||
|
## Entregue
|
||||||
|
|
||||||
|
| Feature | Commit | Nota |
|
||||||
|
|---|---|---|
|
||||||
|
| Ir para a call clicando no nome do canal | `c70b0095` | Exigiu o `voiceStore` passar a guardar o espaço da call — antes ele não sabia onde a call estava assim que o usuário navegava para outro servidor |
|
||||||
|
| Botão de GIF redesenhado | `20526e1b` | Contorno vazado com letras cheias, no lugar do bloco sólido |
|
||||||
|
| Explorador de GIF no banner | `20526e1b` | Sem upload: banner já aceita URL absoluta no cliente e no servidor |
|
||||||
|
| Teste de microfone com retorno | `bfe62d70` | `AudioManager.startMicTest/stopMicTest`; devolve o microfone ao parar, com duas travas independentes |
|
||||||
|
| Bloco de atividade no perfil | `d7da0ff2` | `ProfileActivity`; inclui correção de validação de assets no servidor |
|
||||||
|
| Favoritar GIFs + categorias (#6) | `fb662bfe` | Favoritos no servidor, por conta; guarda o resultado inteiro porque o provedor não busca por id |
|
||||||
|
| Registro de auditoria (#9) | `bbb190cb` | Tabela append-only genérica; 8 ganchos; sair ≠ ser expulso |
|
||||||
|
| Estatísticas do grupo | `18300517` | Tabela própria de sessões de voz; ganchos no ponto único de entrada/saída |
|
||||||
|
| Spotify por OAuth (atividade com capa e progresso) | `75316b08` | Token só no servidor; `state` assinado; atividades por fonte para não brigar com o detector do Electron |
|
||||||
|
| Menu do próprio nome | `f5451e1b` | Perfil, status e copiar ID. Sem Clips/trocar conta — não existem aqui |
|
||||||
|
| Cronômetro da call | `f5451e1b` | `startedAt` do servidor; zera sozinho porque sala vazia é destruída |
|
||||||
|
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
|
||||||
|
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
|
||||||
|
|
||||||
|
## Já existia no código (verificado, não construir de novo)
|
||||||
|
|
||||||
|
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
|
||||||
|
escalonados em 0/150/300ms.
|
||||||
|
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||||
|
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
|
||||||
|
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
|
||||||
|
- **Preview de perfil ancorado** — `uiStore.openUserProfile(user, anchor, placement)`
|
||||||
|
guarda `userProfilePopout`; popout posicionado no desktop, tela cheia no
|
||||||
|
mobile. Já era aberto por mensagens, menções, avatares, lista de membros,
|
||||||
|
DMs e painel de atividade. O que faltava era só a voz — agora ligado.
|
||||||
|
Continua faltando o bloco de atividade do print do Discord, que é a #8.
|
||||||
|
|
||||||
|
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
|
||||||
|
não implementação.
|
||||||
|
|
||||||
|
## Pendente — pedido original
|
||||||
|
|
||||||
|
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
||||||
|
|
||||||
|
| # | Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|---|
|
||||||
|
|
||||||
|
## Pendente — ideias aprovadas
|
||||||
|
|
||||||
|
| Feature | Tamanho | Observação técnica |
|
||||||
|
|---|---|---|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
## Regra de idioma (a partir de 2026-08-31)
|
||||||
|
|
||||||
|
Funcionalidade nova sai com interface em **pt-BR**, e a cada update um sistema
|
||||||
|
existente é traduzido. Os dois idiomas **coexistem**. Código, comentários e
|
||||||
|
commits seguem em inglês.
|
||||||
|
|
||||||
|
A fundação está pronta (`src/i18n/`). Para traduzir um sistema: adicione as
|
||||||
|
chaves em `locales/en.ts`, traduza em `locales/pt-BR.ts` e troque as strings
|
||||||
|
fixas por `t('chave')`. O que faltar cai para o inglês sozinho.
|
||||||
|
|
||||||
|
### Sistemas já traduzidos
|
||||||
|
|
||||||
|
- Configurações → navegação e aba Idioma
|
||||||
|
- Configurações → Voz (dispositivo de entrada, volume, teste de microfone)
|
||||||
|
- Cartão de perfil (sobre mim, membro desde, enviar mensagem, atividade)
|
||||||
|
- Configurações → Privacidade
|
||||||
|
- Configurações → Conexões (nasceu bilíngue)
|
||||||
|
- Seletor de GIF
|
||||||
|
- Registro de auditoria e Estatísticas (nasceram bilíngues)
|
||||||
|
|
||||||
|
### Fila sugerida de tradução
|
||||||
|
|
||||||
|
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
|
||||||
|
modais de convite · configurações restantes · telas de erro
|
||||||
|
|
||||||
|
## Correções pendentes
|
||||||
|
|
||||||
|
| Problema | Causa provável | Correção |
|
||||||
|
|---|---|---|
|
||||||
|
| **Enviar som ao soundboard não funciona no app desktop** (funciona no navegador) | `SoundboardPopover` pede o nome do som com `window.prompt`, que o **Electron não implementa** — não abre nada e devolve vazio, então o fluxo aborta em silêncio, sem erro. `window.prompt` aparece em exatamente um lugar no projeto: esse. O resto do código já o evitava | Trocar por um campo de texto dentro do próprio popover (ou um modal reutilizável). Some o prompt e passa a funcionar igual nos dois. **Vale criar o modal de entrada genérico**, já que não existe nenhum e outras features vão precisar |
|
||||||
|
| **Eco absurdo ao compartilhar tela com som** (app desktop) | O `loopback` do Electron captura a mistura de saída do sistema **inteiro**, que inclui o próprio Backspace tocando a voz dos outros. Essa voz volta para eles dentro da transmissão, com atraso. Não é eco acústico — é digital, então **fone não resolve**. As constraints do web (`restrictOwnAudio`) nunca chegam: o handler ignora o `_request` e monta o stream a partir do enum. É também por isso que `shareAudio` já vinha desligado por padrão no app | Sem solução limpa no Electron: nem `loopback` nem `loopbackWithMute` excluem o áudio do próprio app. Opções reais na seção abaixo |
|
||||||
|
| **Bloco do Spotify dessincronizado** (mostra a faixa errada por um tempo) | Soma de duas esperas: a consulta roda a cada **20s** (`useSpotifyActivity`) e o envio pelo WebSocket ainda passa por um **debounce de 5s** no `activityStore`. Na pior hipótese os outros veem a música anterior por ~25s | Consultar de novo perto do fim da faixa (a duração é conhecida) em vez de só por intervalo fixo, e encurtar o debounce para esta fonte |
|
||||||
|
| **Bloco do Spotify some sozinho** | Três caminhos apagam a atividade inteira: faixa **pausada** (`is_playing: false` devolve `null`), o **vão entre faixas** (o Spotify responde `204`) e qualquer falha transitória. Some e volta = a piscada que você viu | Manter a última faixa conhecida por alguns segundos antes de apagar, e enviar um estado *pausado* explícito em vez de sumir com o bloco |
|
||||||
|
| **Barra de progresso errada / andando pausada** | Duas causas independentes: (1) o progresso é derivado de carimbos calculados com o relógio do **servidor** e desenhado contra o relógio de **quem olha** — se os relógios divergem, a barra fica deslocada; (2) a barra continua avançando localmente depois que a pessoa pausa, até a próxima consulta | Enviar o horário do servidor junto no payload para o cliente corrigir a diferença, e congelar a barra quando o estado for pausado |
|
||||||
|
|
||||||
|
|
||||||
|
## App desktop — já existia (verificado 2026-08-31)
|
||||||
|
|
||||||
|
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 |
|
||||||
|
|---|---|---|
|
||||||
|
| **Fixar mensagens no canal** | Pequena | Tabela de fixadas por canal + rota de listagem; permissão natural é `MANAGE_MESSAGES` |
|
||||||
|
| **Busca com filtros** | Média | `SearchPopover` já existe; falta filtrar por autor, canal e presença de anexo — e índice no banco, senão fica lento quando o histórico crescer |
|
||||||
|
| **Resposta rápida de voz (soundboard por atalho)** | Pequena | O soundboard já está pronto; falta só marcar favoritos e ligar a atalhos de teclado, sem abrir painel. Cuidado: o limite de repetição do servidor continua valendo e é o que impede virar metralhadora |
|
||||||
|
| **Cargos com ícone e exibição separada** | Média | Cargos e cores já existem; falta ícone, o campo "exibir separadamente" e o agrupamento na lista de membros |
|
||||||
|
|
||||||
|
|
||||||
|
## Ideias novas — a avaliar
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
**Auditoria (#9) e Estatísticas compartilham o mesmo mecanismo**: uma tabela de
|
||||||
|
eventos append-only no servidor. Construir a auditoria primeiro e as
|
||||||
|
estatísticas como leitura agregada dessa mesma tabela evita escrever dois
|
||||||
|
sistemas de registro paralelos que divergem com o tempo.
|
||||||
@@ -0,0 +1,645 @@
|
|||||||
|
# Plan A — Scanning Pipeline & Supply-Chain Hardening (report-only) Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Stand up the full automated security-scanning pipeline (Dependabot, CodeQL SAST, secret scanning, dependency CVEs, IaC/license scanning, OpenSSF Scorecard) plus supply-chain hardening (SHA-pinned actions, harden-runner, least-privilege permissions) on GitHub Actions — all **report-only/advisory**, so the PR that adds it stays green and mergeable.
|
||||||
|
|
||||||
|
**Architecture:** Four new files under `.github/` (one Dependabot config + three workflows) each with a single scan responsibility, plus a hardening sweep across the four existing workflows. Every scanner uploads SARIF to the GitHub Security tab and is non-blocking in this plan; enforcement (fail-the-build) is flipped on in a later plan (Plan E) after the remediation pass. This is the foundation the rest of the initiative builds on and, on its own, answers the "no security scanning" objection with visibly-running scanners.
|
||||||
|
|
||||||
|
**Tech Stack:** GitHub Actions (YAML), GitHub Dependabot, CodeQL (`javascript-typescript`, build-mode `none`), gitleaks, OSV-Scanner (reads `pnpm-lock.yaml` v9), Aqua Trivy (config + license), OpenSSF Scorecard, StepSecurity harden-runner. Local validators: `actionlint`, `pinact` (SHA-pinning).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **Report-only in this plan.** Every scanner must be non-blocking (`continue-on-error: true` at step level, or advisory SARIF upload). Enforcement is flipped on in Plan E — do NOT make any scanner fail the build here. Each non-blocking step carries a comment: `# report-only; enforcement flipped on in Plan E`.
|
||||||
|
- **SHA-pin every action.** All `uses:` refs across ALL workflows (new and existing) pin to a full 40-char commit SHA with a trailing `# vX.Y.Z` version comment. No `@v5`/`@main` tag refs may remain after Task 5.
|
||||||
|
- **harden-runner is Linux-only.** `step-security/harden-runner` runs only on Ubuntu runners. In any matrix that includes macOS/Windows (i.e. `release.yml`), guard it with `if: runner.os == 'Linux'`.
|
||||||
|
- **`egress-policy: audit`** for every harden-runner step (never `block` in this plan — multi-arch buildx/QEMU/gha-cache make many egress calls).
|
||||||
|
- **Commit identity:** the repo's local git config already uses `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>` — use a plain `git commit`. NEVER override author/committer email with `-c user.email=...`, and never commit as `alxtrading94@gmail.com`.
|
||||||
|
- **No new runtime dependencies.** This plan touches only `.github/` and docs; it must not modify `package.json` dependency lists or any application/runtime code.
|
||||||
|
- **Node 20 / pnpm 10.34.3** are the project's pinned toolchain — any workflow that installs deps mirrors `ci.yml` (`pnpm/action-setup` @ 10.34.3, `actions/setup-node` node 20).
|
||||||
|
- **Branch:** all work lands on `security-scanning-hardening` (already checked out).
|
||||||
|
- **Action versions:** the YAML below uses each action's current major tag. If an action's latest major differs at implementation time, check its README and adjust the tag — then Task 5 pins whatever tag you used to its SHA. A wrong tag surfaces as an `actionlint` error or a red PR check; fix and re-run.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Dependabot config + local validators
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/dependabot.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: nothing (first task).
|
||||||
|
- Produces: `.github/dependabot.yml` — the Dependabot v2 config later documented by Task 6. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Install the local validators**
|
||||||
|
|
||||||
|
`actionlint` validates workflow YAML; `pinact` will SHA-pin actions in Task 5. On the macOS dev host:
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
brew install actionlint pinact
|
||||||
|
actionlint --version && pinact --version
|
||||||
|
```
|
||||||
|
Expected: both print a version. (Fallbacks if Homebrew lacks them: `go install github.com/rhysd/actionlint/cmd/actionlint@latest` and `go install github.com/suzuki-shunsuke/pinact/cmd/pinact@latest`, or run actionlint via Docker `docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Write `.github/dependabot.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Dependabot keeps dependencies and CI actions patched. Three ecosystems:
|
||||||
|
# - npm → the pnpm workspace (Dependabot reads pnpm-lock.yaml v9)
|
||||||
|
# - github-actions → action version bumps (feeds the SHA-pin comments)
|
||||||
|
# - docker → the Dockerfile base image (FROM node:20-slim)
|
||||||
|
#
|
||||||
|
# NOTE (intentional): there is NO docker entry for docker-compose.yml. It sits
|
||||||
|
# at the same "/" directory (a second docker entry would collide on
|
||||||
|
# ecosystem+directory), and Dependabot's docker ecosystem parses Dockerfiles,
|
||||||
|
# not `image:` refs in compose. The pinned caddy / livekit-server compose images
|
||||||
|
# are updated MANUALLY — see the maintainer checklist in
|
||||||
|
# docs/systems/security-scanning.md.
|
||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: npm
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
groups:
|
||||||
|
# One grouped PR for routine minor/patch bumps to cut PR noise.
|
||||||
|
npm-minor-patch:
|
||||||
|
update-types:
|
||||||
|
- minor
|
||||||
|
- patch
|
||||||
|
ignore:
|
||||||
|
# uiohook-napi is pinned by an exact-version pnpm patch
|
||||||
|
# (patches/uiohook-napi@1.5.5.patch). A bump makes the patch path stop
|
||||||
|
# matching, breaking `pnpm install --frozen-lockfile` in CI and both
|
||||||
|
# Docker stages until the patch is regenerated. Bump it by hand.
|
||||||
|
- dependency-name: uiohook-napi
|
||||||
|
|
||||||
|
- package-ecosystem: github-actions
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
groups:
|
||||||
|
github-actions:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
- package-ecosystem: docker
|
||||||
|
directory: /
|
||||||
|
schedule:
|
||||||
|
interval: weekly
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Validate YAML syntax**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
python3 -c "import yaml,sys; yaml.safe_load(open('.github/dependabot.yml')); print('dependabot.yml: valid YAML')"
|
||||||
|
```
|
||||||
|
Expected: `dependabot.yml: valid YAML` (no traceback). (The full schema is validated by GitHub after push — Task 7 confirms it in the repo's Insights → Dependency graph → Dependabot.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/dependabot.yml
|
||||||
|
git commit -m "ci(security): add Dependabot config (npm + actions + docker)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `security.yml` — secret, dependency, IaC & license scanning (report-only)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/security.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `pnpm-lock.yaml` (OSV lockfile scan), repo tree (gitleaks history, Trivy config/license).
|
||||||
|
- Produces: workflow `Security` with jobs `gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`; each uploads a SARIF category (`gitleaks`, `osv-scanner`, `trivy-config`, `trivy-license`). Task 6 documents these; Task 5 pins their actions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `.github/workflows/security.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Security
|
||||||
|
|
||||||
|
# Report-only in this plan: every scanner is non-blocking and uploads SARIF to
|
||||||
|
# the Security tab. Enforcement (fail on fixable HIGH/CRITICAL, block on secrets)
|
||||||
|
# is flipped on in Plan E after the remediation pass.
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '32 5 * * 1' # weekly Monday 05:32 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: security-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
gitleaks:
|
||||||
|
name: Secret scan (gitleaks)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout (full history)
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # gitleaks scans the whole git history, not just the diff
|
||||||
|
- name: Run gitleaks
|
||||||
|
uses: gitleaks/gitleaks-action@v2
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
osv-scanner:
|
||||||
|
name: Dependency scan (OSV-Scanner)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Run OSV-Scanner
|
||||||
|
uses: google/osv-scanner-action@v2
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-args: |-
|
||||||
|
--lockfile=./pnpm-lock.yaml
|
||||||
|
--format=sarif
|
||||||
|
--output=osv-results.sarif
|
||||||
|
- name: Upload OSV SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: osv-results.sarif
|
||||||
|
category: osv-scanner
|
||||||
|
|
||||||
|
trivy-config:
|
||||||
|
name: IaC/config scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Trivy config scan (Dockerfile + docker-compose)
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: config
|
||||||
|
scan-ref: .
|
||||||
|
format: sarif
|
||||||
|
output: trivy-config.sarif
|
||||||
|
- name: Upload Trivy config SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-config.sarif
|
||||||
|
category: trivy-config
|
||||||
|
|
||||||
|
trivy-license:
|
||||||
|
name: License compliance scan (Trivy)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Trivy license scan
|
||||||
|
uses: aquasecurity/trivy-action@0.28.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: fs
|
||||||
|
scan-ref: .
|
||||||
|
scanners: license
|
||||||
|
format: sarif
|
||||||
|
output: trivy-license.sarif
|
||||||
|
- name: Upload Trivy license SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-license.sarif
|
||||||
|
category: trivy-license
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate with actionlint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/security.yml
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0). If actionlint flags an unknown input for an action, check that action's README and correct it. (Note: actionlint does not fetch action inputs, so most such errors are shellcheck/expression issues — fix those.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/security.yml
|
||||||
|
git commit -m "ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: `codeql.yml` — CodeQL SAST (report-only)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/codeql.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: repo TypeScript/JavaScript source (analyzed with `build-mode: none`).
|
||||||
|
- Produces: workflow `CodeQL` with job `analyze`, category `/language:javascript-typescript`. Findings land in the Security tab. Task 6 documents it; Task 5 pins its actions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `.github/workflows/codeql.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: CodeQL
|
||||||
|
|
||||||
|
# Static application security testing for all TS/JS. Uses build-mode: none — no
|
||||||
|
# compile needed, which sidesteps the monorepo/native-module build entirely.
|
||||||
|
# Default (code-scanning) query suite; security-extended is deferred (triage tax).
|
||||||
|
# CodeQL uploads alerts to the Security tab but does NOT fail the PR by itself —
|
||||||
|
# blocking is a repo setting (code-scanning merge protection), documented in the
|
||||||
|
# maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
schedule:
|
||||||
|
- cron: '27 3 * * 1' # weekly Monday 03:27 UTC
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: codeql-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analyze:
|
||||||
|
name: Analyze (javascript-typescript)
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
security-events: write # upload SARIF to code scanning
|
||||||
|
actions: read
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Initialize CodeQL
|
||||||
|
uses: github/codeql-action/init@v3
|
||||||
|
with:
|
||||||
|
languages: javascript-typescript
|
||||||
|
build-mode: none
|
||||||
|
- name: Perform CodeQL analysis
|
||||||
|
uses: github/codeql-action/analyze@v3
|
||||||
|
with:
|
||||||
|
category: "/language:javascript-typescript"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate with actionlint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/codeql.yml
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/codeql.yml
|
||||||
|
git commit -m "ci(security): add CodeQL SAST workflow (javascript-typescript)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: `scorecard.yml` — OpenSSF Scorecard (report-only)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `.github/workflows/scorecard.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the whole repo + workflow metadata (Scorecard evaluates repo posture).
|
||||||
|
- Produces: workflow `OpenSSF Scorecard` with job `analysis`; publishes results (feeds the public badge added in Plan E) and uploads SARIF. Task 5 pins its actions.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `.github/workflows/scorecard.yml`**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: OpenSSF Scorecard
|
||||||
|
|
||||||
|
# Scores the repo's security posture (branch protection, pinned deps, token
|
||||||
|
# permissions, etc.) and publishes to the OpenSSF public API so a badge can be
|
||||||
|
# shown (badge is added in Plan E). REQUIRES the canonical repo to be PUBLIC —
|
||||||
|
# see the maintainer checklist in docs/systems/security-scanning.md.
|
||||||
|
|
||||||
|
on:
|
||||||
|
branch_protection_rule:
|
||||||
|
schedule:
|
||||||
|
- cron: '18 4 * * 2' # weekly Tuesday 04:18 UTC
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions: read-all
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
analysis:
|
||||||
|
name: Scorecard analysis
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
security-events: write # upload SARIF
|
||||||
|
id-token: write # publish_results OIDC attestation
|
||||||
|
steps:
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- name: Run Scorecard
|
||||||
|
uses: ossf/scorecard-action@v2
|
||||||
|
with:
|
||||||
|
results_file: results.sarif
|
||||||
|
results_format: sarif
|
||||||
|
publish_results: true
|
||||||
|
- name: Upload artifact
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: SARIF file
|
||||||
|
path: results.sarif
|
||||||
|
retention-days: 5
|
||||||
|
- name: Upload SARIF to code scanning
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: results.sarif
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate with actionlint**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/scorecard.yml
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/scorecard.yml
|
||||||
|
git commit -m "ci(security): add OpenSSF Scorecard workflow"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Harden existing workflows + SHA-pin every action
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/ci.yml` (add harden-runner step)
|
||||||
|
- Modify: `.github/workflows/release.yml` (add Linux-guarded harden-runner step)
|
||||||
|
- Modify: `.github/workflows/security.yml`, `codeql.yml`, `scorecard.yml`, `ci.yml`, `release.yml`, `cla.yml`, `deploy-pages.yml`, `docker-publish.yml` (SHA-pin all `uses:`)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: all workflow files from Tasks 2-4 plus the four pre-existing ones.
|
||||||
|
- Produces: every `uses:` pinned to `@<40-char-sha> # vX.Y.Z`; harden-runner (audit) on the two build workflows. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add harden-runner to `ci.yml`**
|
||||||
|
|
||||||
|
In `.github/workflows/ci.yml`, insert as the FIRST step of the `build-and-test` job (before `Checkout`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Harden the runner
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add Linux-guarded harden-runner to `release.yml`**
|
||||||
|
|
||||||
|
In `.github/workflows/release.yml`, insert as the FIRST step of the `build` matrix job (before `Checkout`). It MUST be guarded — the matrix includes macOS and Windows, where harden-runner does not run:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Harden the runner
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
uses: step-security/harden-runner@v2
|
||||||
|
with:
|
||||||
|
egress-policy: audit
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: SHA-pin every action across all workflows**
|
||||||
|
|
||||||
|
Run `pinact` from the repo root — it rewrites each `uses: owner/repo@vX` to `uses: owner/repo@<sha> # vX` in place:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pinact run
|
||||||
|
```
|
||||||
|
|
||||||
|
Manual fallback (if `pinact` is unavailable) — resolve each tag to its commit SHA with `gh` and edit by hand. `repos/{repo}/commits/{ref}` dereferences both lightweight and annotated tags to the commit:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example for one action; repeat for every distinct uses: ref.
|
||||||
|
gh api repos/actions/checkout/commits/v5 --jq '.sha'
|
||||||
|
# → paste as: uses: actions/checkout@<sha> # v5
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Verify no unpinned action refs remain**
|
||||||
|
|
||||||
|
Run (flags any `uses:` ref NOT pinned to a 40-hex-char SHA — catches both `@v5` and non-`v` semver tags like Trivy's `@0.28.0`):
|
||||||
|
```bash
|
||||||
|
grep -rnE 'uses: +[^ ]+@' .github/workflows/ | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED REFS FOUND (fix above)" || echo "All actions pinned to SHA"
|
||||||
|
```
|
||||||
|
Expected: `All actions pinned to SHA` (the second grep exits non-zero when nothing is unpinned, so the `||` branch prints). A properly pinned line contains `@<40-hex> # vX.Y.Z` and is filtered out; any surviving line is an unpinned ref to fix.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Re-validate all workflows**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint
|
||||||
|
```
|
||||||
|
Expected: no output (exit 0) — actionlint scans every file in `.github/workflows/`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/
|
||||||
|
git commit -m "ci(security): SHA-pin all actions and add harden-runner (audit)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Document the pipeline
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/systems/security-scanning.md`
|
||||||
|
- Modify: `CLAUDE.md` (add a subsystem-table row)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the workflows/config from Tasks 1-5 (documents them).
|
||||||
|
- Produces: the `security-scanning.md` spec + maintainer checklist referenced by every workflow comment; a CLAUDE.md table row. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `docs/systems/security-scanning.md`**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Security Scanning & Supply-Chain Assurance
|
||||||
|
|
||||||
|
Automated, continuous scanning wired into GitHub Actions. This document is the
|
||||||
|
reference for what runs, where results go, and the one-time settings a maintainer
|
||||||
|
must enable. **Current state: report-only** — scanners surface findings in the
|
||||||
|
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
|
||||||
|
later change once the remediation pass has cleared the backlog.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
| File | Purpose | Trigger | Result |
|
||||||
|
|------|---------|---------|--------|
|
||||||
|
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
|
||||||
|
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly | Security tab + public badge |
|
||||||
|
|
||||||
|
## Tiered policy (target, enforced in a later change)
|
||||||
|
|
||||||
|
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
|
||||||
|
disallowed license.
|
||||||
|
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
|
||||||
|
medium/low; Scorecard.
|
||||||
|
|
||||||
|
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
|
||||||
|
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
|
||||||
|
— see the checklist below.
|
||||||
|
|
||||||
|
## Supply-chain hardening
|
||||||
|
|
||||||
|
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
|
||||||
|
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
||||||
|
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
||||||
|
- Least-privilege `permissions:` per workflow/job.
|
||||||
|
- SBOM + SLSA provenance are attached to the published container image (added with
|
||||||
|
the image-scan work).
|
||||||
|
|
||||||
|
## Maintainer checklist (one-time GitHub settings — NOT code)
|
||||||
|
|
||||||
|
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
|
||||||
|
the CodeQL free tier).
|
||||||
|
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
|
||||||
|
security updates**.
|
||||||
|
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
|
||||||
|
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
|
||||||
|
so high-severity alerts block PRs (the code-level gates do the rest).
|
||||||
|
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
|
||||||
|
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
|
||||||
|
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
|
||||||
|
releases ship. (Renovate, which parses compose, is an optional future
|
||||||
|
alternative.)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the CLAUDE.md subsystem-table row**
|
||||||
|
|
||||||
|
In `CLAUDE.md`, inside the "Subsystem Documentation" table (the block of `| File | Contents | Read when... |` rows), add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| [security-scanning.md](docs/systems/security-scanning.md) | CI security pipeline: Dependabot, CodeQL SAST, gitleaks, OSV-Scanner, Trivy (config/image/license), OpenSSF Scorecard, SHA-pinning, harden-runner, tiered enforcement policy, maintainer settings checklist | Any CI security work, adding/changing scanners, enabling enforcement, supply-chain hardening |
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the doc links resolve**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
test -f docs/systems/security-scanning.md && grep -q 'security-scanning.md' CLAUDE.md && echo "doc + CLAUDE.md row present"
|
||||||
|
```
|
||||||
|
Expected: `doc + CLAUDE.md row present`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/systems/security-scanning.md CLAUDE.md
|
||||||
|
git commit -m "docs(security): document the scanning pipeline + maintainer checklist"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Open the PR and verify the pipeline runs
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: everything from Tasks 1-6, pushed to GitHub.
|
||||||
|
- Produces: a PR with all scanners running green/advisory — the acceptance gate for Plan A.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Push the branch**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
git push -u origin security-scanning-hardening
|
||||||
|
```
|
||||||
|
Expected: branch pushed; GitHub prints a PR-create URL.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Open a PR**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
gh pr create --fill --base main --head security-scanning-hardening \
|
||||||
|
--title "Security scanning pipeline (report-only)" \
|
||||||
|
--body "Adds Dependabot, CodeQL, gitleaks, OSV-Scanner, Trivy (config/license), OpenSSF Scorecard, SHA-pinned actions, and harden-runner. All scanners are report-only; enforcement is flipped on in a later change. See docs/systems/security-scanning.md."
|
||||||
|
```
|
||||||
|
Expected: prints the PR URL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Watch the checks**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
gh pr checks --watch
|
||||||
|
```
|
||||||
|
Expected: `CI / Build & test` passes; `CodeQL`, `Security` (gitleaks/osv/trivy jobs), and `OpenSSF Scorecard` all complete. Because every scanner is `continue-on-error`/advisory, **no scanner may report a failing (red) required check** — a scanner surfacing findings is fine, but the job itself should not fail the PR. If a job fails for a non-finding reason (bad action input, missing permission), fix the workflow and push.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm SARIF + Dependabot registration**
|
||||||
|
|
||||||
|
Verify in the GitHub UI (or note as maintainer follow-up if Actions/security features aren't enabled yet):
|
||||||
|
- Security → Code scanning: alerts appear under categories `codeql`, `osv-scanner`, `trivy-config`, `trivy-license`, and Scorecard.
|
||||||
|
- Insights → Dependency graph → Dependabot: the three ecosystems (npm, github-actions, docker) are listed as configured.
|
||||||
|
|
||||||
|
Run (CLI cross-check of code-scanning analyses, if the repo is public with Actions enabled):
|
||||||
|
```bash
|
||||||
|
gh api repos/:owner/:repo/code-scanning/analyses --jq '[.[].category] | unique' 2>/dev/null || echo "code-scanning API not available yet (enable in Settings)"
|
||||||
|
```
|
||||||
|
Expected: a list including the scanner categories, or the fallback message (then it's a maintainer-settings follow-up, not a plan defect).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Record verification outcome**
|
||||||
|
|
||||||
|
No commit. Note in the PR description (or a comment) which checks passed and any settings follow-ups (from the Task 6 maintainer checklist) still pending. Plan A is complete when the PR is green with all four scanners running advisory.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage (WS1 + supply-chain):** Dependabot (Task 1) ✓; CodeQL (Task 3) ✓; gitleaks + OSV + Trivy config + Trivy license (Task 2) ✓; Scorecard (Task 4) ✓; SHA-pinning + harden-runner (audit) + least-priv permissions across all workflows (Task 5) ✓; docs + maintainer checklist + CLAUDE.md row (Task 6) ✓; report-only sequencing honored throughout (Global Constraints + per-step comments) ✓.
|
||||||
|
- **Deferred by design (other plans, not gaps):** container image scan + SBOM/provenance + docker-publish restructure → Plan B; helmet/CSP/CORS + DAST → Plan C; Electron hardening → Plan D; remediation of findings + enforcement flip + README badges → Plan E. Task 5's SHA-pin sweep does include `docker-publish.yml` (harmless; Plan B re-pins as it restructures).
|
||||||
|
- **Enforcement stays OFF here** — every scanner is `continue-on-error`/advisory; no `fail-on`/severity gate is set in this plan. The maintainer-settings toggles (CodeQL merge protection, push protection, branch protection) are documented, not enabled in code.
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
# Plan B — Container Hardening & Real Image Scanning Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Harden the published container image — run it as a non-root user, slim its runtime attack surface, and scan the actual amd64 image for OS/library CVEs before publishing — without breaking existing self-hosters or the multi-arch (amd64+arm64) GHCR publish.
|
||||||
|
|
||||||
|
**Architecture:** Two edits to the runtime layer (`Dockerfile` + a new `docker-entrypoint.sh`) plus a restructure of `docker-publish.yml` so a single-arch amd64 image is built and Trivy-scanned before the multi-arch push. The image scan is **report-only** here (matching Plan A's sequencing); Plan E flips it to blocking. SBOM + SLSA provenance are attached at push time.
|
||||||
|
|
||||||
|
**Tech Stack:** Docker multi-stage build (`node:20-slim`), Docker Buildx + QEMU, GitHub Actions, Aqua Trivy (image + SARIF), `gosu` for privilege drop, better-sqlite3 (prebuilt binary), tsx (runtime TS loader).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- **This plan builds on Plan A's branch** (`security/scanning-pipeline`); the workflows here are already SHA-pinned. Work branch: `security/container-hardening`.
|
||||||
|
- **Do not break existing self-hosters.** The `./data:/app/data` bind mount (`docker-compose.yml:31`) is host-owned; the container must still read/write it after `docker pull` + restart. The non-root switch is handled by an entrypoint that chowns `/app/data` **as root** then drops to the `node` user via `gosu` — so there is **no static `USER` line** (a static `USER` would run the entrypoint unprivileged and make the chown impossible).
|
||||||
|
- **Keep `ffmpeg`** (real runtime dependency) and **keep `tsx`** (the `CMD` runs TS via `tsx/esm`). Only `python3 make g++` may leave the runtime stage.
|
||||||
|
- **better-sqlite3 must still load.** It is expected to install via its prebuilt binary on `node:20-slim` (glibc) for both amd64 and arm64. If a task's build shows it compiling (needs the toolchain), use the documented fallback (keep the toolchain, OR copy the built module from the builder stage) and report it — do not ship a broken image.
|
||||||
|
- **Image scan is report-only in this plan** (`exit-code: '0'` + `continue-on-error: true`, comment `# report-only; enforcement flipped on in Plan E`). Do NOT make it fail the publish here.
|
||||||
|
- **trivy-action pinned to `ed142fd0673e97e23eac54620cfb913e5ce36c25` (# v0.36.0)** — v0.28.0's nested `setup-trivy@v0.2.1` ref is broken (see memory `ci-security-action-gotchas`). SHA-pin any other new action with a `# vX.Y.Z` comment.
|
||||||
|
- **Both build paths must keep working:** the GHCR prebuilt-image pull (`docker-compose.yml` `image:`) AND the from-source `docker compose up --build` fallback.
|
||||||
|
- **Commit identity:** plain `git commit` (local config = `Jannis Braun <151788261+TheZwiss@users.noreply.github.com>`). NEVER `-c user.email`; never the alxtrading94 email.
|
||||||
|
- **Node 20 / pnpm 10.34.3** are the pinned toolchain.
|
||||||
|
- **Docker daemon must be running** for Tasks 1 and 2 verification (`docker build` / `buildx --load`). If it is not up, STOP and report — do not mark a Dockerfile task done without a real build+boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Harden the runtime image (non-root via gosu, drop build toolchain)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docker-entrypoint.sh`
|
||||||
|
- Modify: `Dockerfile` (runtime stage, lines 37-97)
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the existing builder stage output (`/app/packages/web/dist`).
|
||||||
|
- Produces: an image that runs `node --import tsx/esm src/index.ts` as the non-root `node` user (uid 1000) with a writable `/app/data`. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the entrypoint script**
|
||||||
|
|
||||||
|
Create `docker-entrypoint.sh` at the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
# Runs as root: make the (bind-mounted, host-owned) data dir writable by the
|
||||||
|
# non-root `node` user, then drop privileges via gosu and exec the CMD. This
|
||||||
|
# lets the container run as uid 1000 while still owning ./data on hosts where
|
||||||
|
# the bind mount was created by a different uid.
|
||||||
|
#
|
||||||
|
# - Idempotent AND cheap: only chown entries not already node-owned, so after
|
||||||
|
# the first boot this is near-instant. A plain `chown -R` over a large
|
||||||
|
# uploads/ tree on slow Pi/SD storage would delay startup on EVERY restart.
|
||||||
|
# - Non-fatal: on a bind mount that rejects chown (some CIFS/NFS backings),
|
||||||
|
# warn and continue rather than crash-looping under `restart: unless-stopped`
|
||||||
|
# (the old root container booted fine on such mounts).
|
||||||
|
set -e
|
||||||
|
mkdir -p /app/data/uploads
|
||||||
|
chown node:node /app/data /app/data/uploads 2>/dev/null || true
|
||||||
|
find /app/data ! -user node -exec chown node:node {} + 2>/dev/null || \
|
||||||
|
echo "docker-entrypoint: warning: could not chown /app/data; continuing (ensure it is writable by uid 1000)"
|
||||||
|
exec gosu node "$@"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Modify the runtime stage's apt-get line**
|
||||||
|
|
||||||
|
In `Dockerfile`, replace the runtime-stage package install (currently line 42-44):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends python3 make g++ ffmpeg && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
with (drop the C toolchain; keep ffmpeg; add gosu for the privilege drop):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ffmpeg gosu && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the entrypoint + keep the CMD**
|
||||||
|
|
||||||
|
In `Dockerfile`, immediately AFTER the `RUN mkdir -p /app/data/uploads` line (currently line 73) add the entrypoint copy:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Non-root hardening: copy the privilege-dropping entrypoint. It chowns the
|
||||||
|
# data volume as root, then execs the CMD as the unprivileged `node` user.
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Then, at the END of the file, replace the final two lines (currently line 96-97):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
WORKDIR /app/packages/server
|
||||||
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
with (add the ENTRYPOINT between WORKDIR and CMD; do NOT add a `USER` line):
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
WORKDIR /app/packages/server
|
||||||
|
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||||
|
CMD ["node", "--import", "tsx/esm", "src/index.ts"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the native (arm64) image + confirm neither arch needs the toolchain**
|
||||||
|
|
||||||
|
The runtime stage runs its own `pnpm install --prod` (Dockerfile:61), so better-sqlite3/sharp install **per arch**. This host is Apple Silicon (arm64), which is ALSO the Raspberry Pi's arch — the main self-host target — so build the real image **natively for arm64** (fast; a `--platform linux/amd64` build here would emulate the whole Vite build via Rosetta and likely time out). This build both produces the image for the Step 5 boot test AND exercises the arm64 runtime install:
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 --load -t backspace:hardening-test --build-arg BACKSPACE_COMMIT=test .
|
||||||
|
```
|
||||||
|
Watch the better-sqlite3 output: it must use a prebuilt binary (`prebuild-install`), NOT `node-gyp`/compilation. This build may take a few minutes (pnpm install + Vite) — give it an ample timeout or run it in the background so it isn't killed mid-build.
|
||||||
|
|
||||||
|
Then verify the OTHER arch (amd64) toolchain drop with a lightweight, emulated native-module check (no Vite build, so it's quick even under emulation) on `node:20-slim` amd64, which — like the hardened runtime stage — has no `python3/make/g++`:
|
||||||
|
```bash
|
||||||
|
BSQL=$(grep -A1 'better-sqlite3@' pnpm-lock.yaml | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); echo "testing better-sqlite3@$BSQL"
|
||||||
|
docker run --rm --platform linux/amd64 node:20-slim sh -c "
|
||||||
|
cd /tmp && npm init -y >/dev/null 2>&1 &&
|
||||||
|
npm install --no-audit --no-fund better-sqlite3@$BSQL sharp@0.33.5 2>&1 | grep -iE 'prebuild-install|prebuilt|node-gyp|gyp ERR|rebuild' | head -20;
|
||||||
|
node -e \"require('better-sqlite3')(':memory:').close(); require('sharp'); console.log('amd64 native modules OK (no toolchain)')\"
|
||||||
|
"
|
||||||
|
```
|
||||||
|
Expected: the native arm64 image builds (better-sqlite3 prebuilt), AND the amd64 test prints `amd64 native modules OK (no toolchain)`. If EITHER arch tries to compile, or the amd64 test errors, STOP — apply the fallback (re-add the toolchain to the runtime apt-get line, OR build the module in the builder stage and `COPY --from=builder`) and report which arch failed, which fallback you used, and why. Do not proceed on a one-arch pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Boot the container and verify non-root + data volume + DB**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
mkdir -p /tmp/bkspace-data
|
||||||
|
docker run -d --name bkspace-htest -e JWT_SECRET=testsecret_at_least_32_chars_long_xx -p 3999:3000 -v /tmp/bkspace-data:/app/data backspace:hardening-test
|
||||||
|
sleep 12
|
||||||
|
echo "--- health ---"; curl -fsS http://localhost:3999/api/health && echo " OK"
|
||||||
|
# IMPORTANT: check PID 1 (the actual server), NOT `docker exec ... id`. `docker exec`
|
||||||
|
# spawns a NEW process as the image's configured USER (root, since there is no USER
|
||||||
|
# line), so `exec ... id` prints uid=0 even when the gosu drop worked. /proc/1/status
|
||||||
|
# is the real server process's identity.
|
||||||
|
echo "--- server (PID 1) runs as node/uid 1000, not root ---"; docker exec bkspace-htest sh -c "grep '^Uid:' /proc/1/status"
|
||||||
|
echo "--- data dir written + owned by node ---"; docker exec bkspace-htest sh -c 'ls -ld /app/data /app/data/uploads'
|
||||||
|
echo "--- better-sqlite3 loaded (DB file exists) ---"; docker exec bkspace-htest sh -c 'ls -la /app/data/*.db 2>/dev/null || echo NO_DB'
|
||||||
|
echo "--- sharp (native, toolchain-sensitive) loads ---"; docker exec bkspace-htest node -e "require('sharp'); console.log('sharp OK')"
|
||||||
|
echo "--- boot logs clean (no EACCES / permission errors from running non-root) ---"; docker logs bkspace-htest 2>&1 | grep -iE 'EACCES|permission denied|EPERM' && echo "PERMISSION ERRORS FOUND" || echo "logs clean"
|
||||||
|
```
|
||||||
|
Expected: `/api/health` returns ok; `Uid:` line shows `1000 1000 1000 1000` (server runs non-root); `/app/data` + `/app/data/uploads` exist and are `node`-owned; a `.db` file was created (better-sqlite3 loaded and wrote); `sharp OK` prints (the OTHER native module survived the toolchain drop); logs show no permission errors. If any fails, fix before proceeding.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Tear down the test container**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
docker rm -f bkspace-htest; rm -rf /tmp/bkspace-data
|
||||||
|
docker rmi backspace:hardening-test 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docker-entrypoint.sh Dockerfile
|
||||||
|
git commit -m "fix(docker): run container as non-root (gosu) and drop build toolchain from runtime"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Scan the image before publishing (restructure docker-publish.yml)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.github/workflows/docker-publish.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the hardened `Dockerfile` from Task 1.
|
||||||
|
- Produces: a publish workflow that builds amd64 → Trivy-scans it (report-only) → pushes multi-arch with SBOM + provenance. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Restructure the build/scan/push steps**
|
||||||
|
|
||||||
|
In `.github/workflows/docker-publish.yml`, add `security-events: write` to the top-level `permissions` block (it currently has `contents: read` + `packages: write`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
security-events: write
|
||||||
|
```
|
||||||
|
|
||||||
|
Then replace the single `Build and push (linux/amd64, linux/arm64)` step (currently lines 79-92) with the build → scan → push sequence:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Build a single-arch amd64 image and LOAD it into the runner's docker
|
||||||
|
# daemon so Trivy can scan the exact artifact before anything is published.
|
||||||
|
# A multi-arch manifest cannot be --load'ed, so scanning must happen on a
|
||||||
|
# single-arch build first; the multi-arch push below reuses these layers
|
||||||
|
# from the buildx cache, so this is cheap.
|
||||||
|
- name: Build amd64 image for scanning
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64
|
||||||
|
load: true
|
||||||
|
push: false
|
||||||
|
tags: backspace:scan
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
|
- name: Trivy image scan (report-only)
|
||||||
|
uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0
|
||||||
|
continue-on-error: true # report-only; enforcement flipped on in Plan E
|
||||||
|
with:
|
||||||
|
scan-type: image
|
||||||
|
image-ref: backspace:scan
|
||||||
|
ignore-unfixed: true
|
||||||
|
format: sarif
|
||||||
|
output: trivy-image.sarif
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
|
||||||
|
- name: Upload Trivy image SARIF
|
||||||
|
if: always()
|
||||||
|
uses: github/codeql-action/upload-sarif@02c5e83432fe5497fd85b873b6c9f16a8578e1d9 # v3.37.0
|
||||||
|
with:
|
||||||
|
sarif_file: trivy-image.sarif
|
||||||
|
category: trivy-image
|
||||||
|
|
||||||
|
# Publish the multi-arch image. Reuses the amd64 layers built above via the
|
||||||
|
# gha cache. Attaches an SBOM and SLSA provenance attestation to the image.
|
||||||
|
- name: Build and push (linux/amd64, linux/arm64)
|
||||||
|
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||||
|
build-args: |
|
||||||
|
BACKSPACE_COMMIT=${{ steps.meta_commit.outputs.commit }}
|
||||||
|
sbom: true
|
||||||
|
provenance: true
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Validate the workflow**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
actionlint .github/workflows/docker-publish.yml
|
||||||
|
```
|
||||||
|
Expected: exit 0, no output.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Confirm all actions still SHA-pinned**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -rnE 'uses: +[^ ]+@' .github/workflows/docker-publish.yml | grep -vE '@[0-9a-f]{40}' && echo "UNPINNED FOUND" || echo "All actions pinned to SHA"
|
||||||
|
```
|
||||||
|
Expected: `All actions pinned to SHA`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Locally reproduce the build→load→scan path**
|
||||||
|
|
||||||
|
This proves the new build→load→scan logic works without publishing anything (requires Docker daemon + local Trivy: `brew install trivy` if absent). Build native (arm64) here to avoid emulation — the scan mechanism is arch-independent; CI scans the amd64 image natively on GitHub's runners:
|
||||||
|
```bash
|
||||||
|
docker buildx build --platform linux/arm64 --load -t backspace:scan --build-arg BACKSPACE_COMMIT=test .
|
||||||
|
trivy image --severity HIGH,CRITICAL --ignore-unfixed backspace:scan | tail -25
|
||||||
|
docker rmi backspace:scan
|
||||||
|
```
|
||||||
|
Expected: the image builds + loads, and Trivy scans it and prints a summary (findings are fine — the scan is report-only; we just need it to RUN). Note accurately in the report: only the **amd64** image is Trivy-scanned; the published **arm64** image ships unscanned (acceptable for this plan). The multi-arch push + SBOM/provenance path cannot be exercised without publishing — it is verified by review + a maintainer `workflow_dispatch` run; the amd64 layers are gha-cache reused on the push, but the **arm64 layers build cold** there (so the push is not "free").
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .github/workflows/docker-publish.yml
|
||||||
|
git commit -m "ci(docker): scan the amd64 image before publish; attach SBOM + provenance"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Document the container hardening
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/systems/deployment.md`
|
||||||
|
- Modify: `docs/systems/security-scanning.md`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: the changes from Tasks 1-2.
|
||||||
|
- Produces: an upgrade/migration note + an updated pipeline reference. No code symbols.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a container-hardening + migration note to deployment.md AND correct now-false ownership statements**
|
||||||
|
|
||||||
|
Read `docs/systems/deployment.md` first to match its structure. Then:
|
||||||
|
|
||||||
|
(a) Add a subsection (place it near the Docker/image content) with this content:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Container hardening (non-root)
|
||||||
|
|
||||||
|
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||||
|
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||||
|
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||||
|
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||||
|
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||||
|
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||||
|
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||||
|
|
||||||
|
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||||
|
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||||
|
amd64 image is scanned; the arm64 image is published unscanned.
|
||||||
|
|
||||||
|
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||||
|
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||||
|
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||||
|
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||||
|
|
||||||
|
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||||
|
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||||
|
is expected and idempotent. If you previously accessed `./data` on the host as a
|
||||||
|
different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||||
|
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||||
|
now uid-1000-owned files.
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Correct the two statements that this change makes false (the data dir is no
|
||||||
|
longer root-owned):
|
||||||
|
- The seed-admin line (around `deployment.md:170`): change
|
||||||
|
`writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount)`
|
||||||
|
→ `... (mode `0600`, owned by the container's runtime user uid 1000 via the bind-mount)`.
|
||||||
|
- The Restore intro (around `deployment.md:252`): change
|
||||||
|
`Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount`
|
||||||
|
→ `... is **container-owned (uid 1000)** via the bind-mount`. (The throwaway
|
||||||
|
root `alpine` container still performs the swap — root can rewrite uid-1000
|
||||||
|
files — so the mechanism description after it stays correct.)
|
||||||
|
|
||||||
|
**Maintainer release-gate (record it, do not action it here):** before the first
|
||||||
|
`v*` tag that ships this image, do a real `docker compose pull && docker compose
|
||||||
|
up -d` on both an amd64 host and the arm64 Pi to confirm the attestation-bearing
|
||||||
|
image pulls on the actual deployment Docker versions.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Reflect the image scan in security-scanning.md**
|
||||||
|
|
||||||
|
In `docs/systems/security-scanning.md`, update the supply-chain line about SBOM/provenance (currently "**will be** attached ... not yet live") to reflect that image scanning + SBOM + provenance now exist in `docker-publish.yml` (report-only image scan; SBOM + provenance attached at push). Add `docker-publish.yml` to the workflow table with trigger "tag push / manual" and result "image scan (report-only) + SBOM + provenance".
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the docs reference reality**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
grep -q 'non-root' docs/systems/deployment.md && grep -q 'provenance' docs/systems/security-scanning.md && echo "docs updated"
|
||||||
|
```
|
||||||
|
Expected: `docs updated`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/systems/deployment.md docs/systems/security-scanning.md
|
||||||
|
git commit -m "docs(docker): document non-root runtime, data-volume migration, and image scan"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage (WS2):** non-root USER via gosu (Task 1) ✓; slim runtime / drop toolchain with prebuilt-binary verification + fallback (Task 1) ✓; keep ffmpeg + tsx (Task 1 / constraints) ✓; bind-mount chown migration (Task 1 entrypoint + Task 3 doc) ✓; restructure to single-arch load → scan → multi-arch push (Task 2) ✓; SBOM + provenance (Task 2) ✓; image scan report-only, flips in Plan E (Task 2 + constraints) ✓.
|
||||||
|
- **Deferred by design:** flipping the image scan to blocking → Plan E. Desktop, web/CSP/CORS → Plans C/D.
|
||||||
|
- **Risk-managed:** the toolchain removal is verified by a real build that must show better-sqlite3 using a prebuilt binary; a fallback is defined if it compiles. The non-root switch is verified by asserting `uid=1000` at runtime and a successful `./data` write + DB creation. Multi-arch push + SBOM/provenance is review-plus-workflow_dispatch verified (cannot be exercised without publishing).
|
||||||
|
- **Enforcement stays OFF** — image scan is `continue-on-error` + `severity`-limited to HIGH/CRITICAL for the report; no build-failing gate added here.
|
||||||
|
- **Adversarial pre-execution review folded in:** the non-root verification now checks `/proc/1/status` (not `docker exec … id`, which spawns a new root process and would false-fail); BOTH arches are built to verify the toolchain drop (arm64 is the release-hard-fail path); the entrypoint chown is idempotent-cheap + non-fatal (Pi/CIFS safety); a `sharp` native-load smoke check + EACCES log scan were added; now-false `deployment.md` ownership statements are corrected; and an SBOM/provenance min-Docker floor + a maintainer pull-test release-gate are documented (attestation-bearing images can trip very old Docker on the `pull` path).
|
||||||
@@ -0,0 +1,428 @@
|
|||||||
|
# Security Scanning & Hardening Initiative — Design
|
||||||
|
|
||||||
|
**Date:** 2026-07-10
|
||||||
|
**Status:** Approved (design); pending implementation plan
|
||||||
|
**Author:** Lead Developer (Backspace)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Motivation
|
||||||
|
|
||||||
|
A prospective self-hoster declined to run Backspace with the objection:
|
||||||
|
|
||||||
|
> "Security testing: You've made a web app. I am not installing a new webapp that
|
||||||
|
> is expected to touch the internet without some level of security scanning."
|
||||||
|
|
||||||
|
The objection is valid. Investigation of the current state shows Backspace has
|
||||||
|
solid security **engineering** but no security **assurance infrastructure**:
|
||||||
|
|
||||||
|
**Already present (good):**
|
||||||
|
- `SECURITY.md` with a private vulnerability-disclosure policy.
|
||||||
|
- Real defensive code: SSRF protection (`packages/server/src/utils/ssrf.ts` — DNS
|
||||||
|
resolution, private-IP blocking, per-redirect-hop re-validation), HMAC-signed
|
||||||
|
federation with replay-nonce prevention, sliding-window rate limiters, JWT +
|
||||||
|
bcrypt, input validation.
|
||||||
|
- Good secrets hygiene: `.deploy.local` untracked; thorough `.gitignore`
|
||||||
|
(`.env*`, `*.pem`, `*.key`, `data/`, `*.db`).
|
||||||
|
- CI (`ci.yml`) running typecheck + build + full test suite.
|
||||||
|
|
||||||
|
**Absent (the gap):**
|
||||||
|
- No SAST, no dependency/CVE scanning, no secret scanning, no container image
|
||||||
|
scanning, no supply-chain hardening, no Dependabot.
|
||||||
|
- No visible, verifiable evidence a stranger can audit before trusting the app.
|
||||||
|
- **No browser-facing hardening** the objection actually cares about: no security
|
||||||
|
response headers (`@fastify/helmet` absent; bare `Caddyfile`), CORS reflects any
|
||||||
|
origin with credentials, unsigned desktop autoupdate, no Electron fuses/asar
|
||||||
|
integrity, no license compliance gate for a dual-licensed (AGPL + commercial)
|
||||||
|
project.
|
||||||
|
|
||||||
|
**Root cause (per the No-Band-Aids principle):** the fix is not a one-off scan. It
|
||||||
|
is a permanent, automated, and *visible* scanning pipeline wired into CI/CD, plus
|
||||||
|
remediation of the browser/desktop hardening gaps that continuous scanning would
|
||||||
|
be embarrassing to leave open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Goals & Non-Goals
|
||||||
|
|
||||||
|
### Goals
|
||||||
|
1. Continuous, automated scanning on every change: SAST, dependency CVEs, secrets,
|
||||||
|
container image, license compliance, supply-chain posture.
|
||||||
|
2. Tiered enforcement: high-confidence, fixable issues **block merge**; the rest are
|
||||||
|
advisory in the GitHub Security tab. Never wall off merges on unfixable upstream
|
||||||
|
CVEs.
|
||||||
|
3. Close the browser-facing and desktop-facing hardening gaps (headers, CORS,
|
||||||
|
Electron integrity).
|
||||||
|
4. Publish verifiable evidence: badges, an OpenSSF Scorecard, SBOM/provenance, and
|
||||||
|
documentation a stranger can read without repo access.
|
||||||
|
5. Land the whole thing without leaving CI spuriously red: scanners report-only →
|
||||||
|
remediate → flip enforcement.
|
||||||
|
|
||||||
|
### Non-Goals (explicitly out of scope for this initiative)
|
||||||
|
- **npm provenance / package signing** — every workspace is `"private": true`;
|
||||||
|
nothing is published to npm. N/A.
|
||||||
|
- **Purchasing desktop code-signing certificates** — a procurement action (Apple
|
||||||
|
Developer ~$99/yr, a Windows code-signing cert) that cannot be done in code. We
|
||||||
|
implement the *code-level* Electron hardening and *document* the signing steps
|
||||||
|
and certs to buy; we do not fake signing.
|
||||||
|
- **Full fuzzing harness for federation input** — valuable but multi-week; deferred.
|
||||||
|
A handful of targeted negative/property tests on `validateExternalUrl` and S2S
|
||||||
|
JSON parsing is in scope; a standing fuzz harness is not.
|
||||||
|
- **TLS/cipher configuration** — Caddy already auto-provisions HTTPS with modern
|
||||||
|
defaults; we add security *headers*, not a TLS overhaul.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tool Selection & Rationale
|
||||||
|
|
||||||
|
Where a "GitHub-native" option and a "committed-workflow" option overlap, we prefer
|
||||||
|
**committed workflow files** — a self-hoster auditing the repo can read a `.yml`
|
||||||
|
file; they cannot read repo settings. Settings-only toggles are documented as
|
||||||
|
required manual steps, never claimed as code.
|
||||||
|
|
||||||
|
| Scan class | Choice | Rationale |
|
||||||
|
|---|---|---|
|
||||||
|
| SAST | **CodeQL** (committed advanced workflow, default `security` suite to start) | Free for public repos, best TS/JS coverage, `none` build mode sidesteps monorepo/native-module build complexity. `security-extended` deferred to avoid a day-one triage tax. |
|
||||||
|
| Dependency CVEs | **OSV-Scanner** (blocking CI gate) **+ Dependabot** (auto-upgrade PRs) | OSV-Scanner parses `pnpm-lock.yaml` v9 directly and can fail the build; Dependabot alerts are advisory-only. Two distinct roles, no overlap. **Trivy is NOT used for dependency CVEs** (avoids double-noise). |
|
||||||
|
| Secrets | **gitleaks** (committed, full history + PR diff) **+** documented native push-protection | gitleaks is the verifiable, blocking, history-aware gate; native push-protection is the complementary pre-commit net for the future. |
|
||||||
|
| Container image | **Trivy** (image scan, blocking) | SARIF output, `ignore-unfixed: true` for tiered policy, scans the exact GHCR image users pull. |
|
||||||
|
| IaC/config | **Trivy config** (Dockerfile, docker-compose) | Note: Trivy does **not** lint the `Caddyfile`; the reverse-proxy hardening is done by hand (§6.3). |
|
||||||
|
| License compliance | **Trivy `--scanners license`** with an allowlist | Dual-licensed AGPL + commercial → a copyleft-incompatible transitive dep is a legal defect. Reuses the Trivy we already run. |
|
||||||
|
| Supply chain | **SHA-pinned actions + harden-runner (audit) + SBOM + SLSA provenance + OpenSSF Scorecard** | Answers "can I trust the build?" and produces a public Scorecard badge. |
|
||||||
|
| Dynamic (DAST) | **ZAP baseline** against an ephemeral `docker compose up` (advisory) | Catches missing headers + CORS reflection continuously; the one dynamic check for a "webapp exposed to the internet." |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Architecture — Component Layout
|
||||||
|
|
||||||
|
Each workflow file has one clear purpose (mirrors the codebase's module-boundary
|
||||||
|
principle).
|
||||||
|
|
||||||
|
```
|
||||||
|
.github/
|
||||||
|
dependabot.yml NEW — pnpm(npm) + github-actions + docker(Dockerfile only)
|
||||||
|
workflows/
|
||||||
|
codeql.yml NEW — CodeQL SAST (PR + push main + weekly)
|
||||||
|
security.yml NEW — gitleaks + OSV-Scanner + Trivy config + Trivy license
|
||||||
|
scorecard.yml NEW — OpenSSF Scorecard (push main + weekly) → Security tab + badge
|
||||||
|
dast.yml NEW — ZAP baseline vs ephemeral compose stack (advisory)
|
||||||
|
docker-publish.yml EDIT — restructure for real image scanning + SBOM + provenance
|
||||||
|
ci.yml EDIT — harden-runner (audit), tighten permissions
|
||||||
|
release.yml EDIT — harden-runner (audit), tighten permissions
|
||||||
|
cla.yml EDIT
|
||||||
|
deploy-pages.yml EDIT
|
||||||
|
```
|
||||||
|
|
||||||
|
**SHA-pinning applies to EVERY workflow** — the four edited above, `docker-publish.yml`,
|
||||||
|
and all four new ones (`codeql`/`security`/`scorecard`/`dast`). Pin every `uses:` to a
|
||||||
|
full commit SHA with a trailing `# vX.Y.Z` comment. (OpenSSF Scorecard's
|
||||||
|
Pinned-Dependencies check and tag-move attack resistance both require this repo-wide.)
|
||||||
|
|
||||||
|
```
|
||||||
|
Dockerfile EDIT — non-root USER, slim runtime, copy pruned node_modules
|
||||||
|
Caddyfile EDIT — security response headers
|
||||||
|
packages/server/src/index.ts EDIT — @fastify/helmet + CSP; tighten CORS
|
||||||
|
packages/server/package.json EDIT — add @fastify/helmet
|
||||||
|
packages/web/index.html EDIT — CSP meta (defense in depth for static shell)
|
||||||
|
packages/desktop/src/main.ts EDIT — will-navigate deny handler
|
||||||
|
packages/desktop/electron-builder.yml EDIT — @electron/fuses / asar integrity
|
||||||
|
packages/desktop/package.json EDIT — add @electron/fuses
|
||||||
|
README.md EDIT — badges + "Security & supply chain" section
|
||||||
|
SECURITY.md EDIT — "Security testing & assurance" section
|
||||||
|
docs/systems/security-scanning.md NEW — full pipeline spec
|
||||||
|
docs/systems/desktop-security.md NEW — Electron hardening + signing procurement
|
||||||
|
CLAUDE.md EDIT — add subsystem-table rows
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Policy Engine (tiered enforcement)
|
||||||
|
|
||||||
|
| Finding | Action |
|
||||||
|
|---|---|
|
||||||
|
| gitleaks secret hit | **Block** (always) |
|
||||||
|
| OSV-Scanner — fixable HIGH/CRITICAL | **Block** |
|
||||||
|
| Trivy image — fixable HIGH/CRITICAL (`ignore-unfixed: true`) | **Block** |
|
||||||
|
| Trivy license — disallowed license | **Block** |
|
||||||
|
| CodeQL — any alert | Advisory (SARIF → Security tab) |
|
||||||
|
| OSV/Trivy — unfixable, or medium/low | Advisory (SARIF → Security tab) |
|
||||||
|
| ZAP baseline (DAST) | Advisory (report artifact) |
|
||||||
|
| Scorecard | Advisory (score badge + Security tab) |
|
||||||
|
|
||||||
|
**Enforcement honesty — two mechanisms, kept separate:**
|
||||||
|
- **Code-enforced (auditable in the `.yml`):** OSV-Scanner, Trivy, and gitleaks
|
||||||
|
block via workflow exit codes.
|
||||||
|
- **Settings-enforced (documented one-time toggles, NOT claimed as code):** CodeQL
|
||||||
|
merge-blocking (code-scanning merge protection), Dependabot alerts, native
|
||||||
|
secret-scanning + push protection, and branch protection "require status checks."
|
||||||
|
These live in `docs/systems/security-scanning.md` as a maintainer checklist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Workstreams (bounded, independently reviewable)
|
||||||
|
|
||||||
|
Sequencing rule: **WS1 scanners land report-only → WS5 remediation → flip WS1/WS2
|
||||||
|
enforcement to blocking.** WS3/WS4 are otherwise independent and can land in
|
||||||
|
parallel. Two cross-workstream dependencies to respect: **(a)** WS3 (CSP/CORS
|
||||||
|
validation) and WS6's DAST job share the same **two-instance + LiveKit ephemeral
|
||||||
|
test rig** — build it once, reuse it; **(b)** WS6's badges + maintainer checklist
|
||||||
|
document state produced by WS1/WS2/WS5, so its final copy is written *last* (the
|
||||||
|
workflow files can be scaffolded earlier).
|
||||||
|
|
||||||
|
### WS1 — Scanning & supply-chain pipeline (report-only first)
|
||||||
|
- `.github/dependabot.yml`:
|
||||||
|
- `package-ecosystem: npm` at `/` (Dependabot handles pnpm workspaces), weekly,
|
||||||
|
grouped minor/patch.
|
||||||
|
- **`ignore` `uiohook-napi`** — it is pinned by an exact-version patch
|
||||||
|
(`patches/uiohook-napi@1.5.5.patch`); an unmatched bump breaks
|
||||||
|
`pnpm install --frozen-lockfile` in CI and both Docker stages. Also treat
|
||||||
|
`onlyBuiltDependencies` (`better-sqlite3`, `esbuild`, `electron`, `sharp`)
|
||||||
|
bumps with care (grouped, expect native-rebuild churn).
|
||||||
|
- `package-ecosystem: github-actions` at `/`.
|
||||||
|
- `package-ecosystem: docker` at `/` — tracks the **Dockerfile `FROM`** only.
|
||||||
|
**No compose entry:** `docker-compose.yml` sits at the same `/` directory (a
|
||||||
|
second docker entry there would collide on ecosystem+directory), and Dependabot's
|
||||||
|
docker ecosystem parses Dockerfiles, **not** `image:` refs in compose. The pinned
|
||||||
|
`caddy:2.11.1-alpine` / `livekit/livekit-server:v1.9.11` compose images are
|
||||||
|
therefore updated **manually** — added as a line item to the maintainer checklist
|
||||||
|
in `docs/systems/security-scanning.md`. (Renovate, which does parse compose, is
|
||||||
|
noted there as an optional future alternative.)
|
||||||
|
- `codeql.yml`: languages `javascript-typescript`, default `security` queries,
|
||||||
|
triggers PR + push `main` + weekly cron. SARIF uploaded.
|
||||||
|
- `security.yml`:
|
||||||
|
- **gitleaks** — full history + PR diff, SARIF, **block** on hit.
|
||||||
|
- **OSV-Scanner** — reads `pnpm-lock.yaml`; report-only initially, then block on
|
||||||
|
fixable HIGH/CRITICAL after WS5.
|
||||||
|
- **Trivy config** — Dockerfile + docker-compose misconfig, SARIF, advisory.
|
||||||
|
- **Trivy license** — `--scanners license` against the dependency tree with an
|
||||||
|
allowlist (permissive + AGPL-compatible); block on disallowed.
|
||||||
|
- `scorecard.yml`: `ossf/scorecard-action`, push `main` + weekly, publish results +
|
||||||
|
badge.
|
||||||
|
- Harden **all** workflows (new and existing, incl. `docker-publish.yml`): pin every
|
||||||
|
`uses:` to a full commit SHA (retain a `# vX.Y.Z` comment); add
|
||||||
|
`step-security/harden-runner` in **`egress-policy: audit`** (not block — multi-arch
|
||||||
|
buildx + QEMU + gha cache make many egress calls); tighten job-level `permissions`
|
||||||
|
to least privilege.
|
||||||
|
|
||||||
|
### WS2 — Container hardening & real image scanning
|
||||||
|
- **Restructure `docker-publish.yml`** (the current single multi-arch `build-push`
|
||||||
|
cannot be scanned before publish):
|
||||||
|
1. Build **single-arch `linux/amd64`** with `load: true`.
|
||||||
|
2. **Trivy image scan** (`ignore-unfixed: true`, block on fixable HIGH/CRITICAL),
|
||||||
|
SARIF uploaded.
|
||||||
|
3. On pass, the multi-arch (`amd64,arm64`) `build-push` with `push: true`,
|
||||||
|
`sbom: true`, `provenance: true`. (Buildx cache makes the second build cheap.)
|
||||||
|
- Trivy authenticates to GHCR with the same `GITHUB_TOKEN` used for login (the
|
||||||
|
package may be private until manually flipped public).
|
||||||
|
- **Dockerfile hardening:**
|
||||||
|
- **Prune mechanics (precise):** the builder runs a *full* `pnpm install
|
||||||
|
--frozen-lockfile` (Dockerfile:25) whose `node_modules` is a symlinked `.pnpm`
|
||||||
|
virtual store — a plain `COPY --from=builder node_modules` is **not**
|
||||||
|
self-contained. Use `pnpm --filter @backspace/server deploy --prod
|
||||||
|
/app/deploy` in the builder to produce a dereferenced/hoisted prod tree, then
|
||||||
|
`COPY --from=builder /app/deploy` into the runtime stage. This replaces the
|
||||||
|
runtime stage's own `pnpm install --prod`, letting `python3 make g++` be dropped
|
||||||
|
from runtime. **Keep `ffmpeg`** (real runtime dep) and **keep `tsx`** as a prod
|
||||||
|
dependency (the CMD runs TS via `tsx/esm`). Verify `better-sqlite3`'s prebuilt
|
||||||
|
binary and `tsx` are present in the copied tree for **both** target arches.
|
||||||
|
- **Non-root + bind-mount chown (reconciled — the two are mutually exclusive if
|
||||||
|
done naively):** `docker-compose.yml:31` bind-mounts host-owned `./data:/app/data`.
|
||||||
|
Chowning it requires **root**, so we do **not** hard-set a `USER` line (that would
|
||||||
|
run the entrypoint as non-root and make the chown impossible). Instead: install
|
||||||
|
`gosu` (or `su-exec`), add an `ENTRYPOINT` that (a) idempotently `chown`s
|
||||||
|
`/app/data` to a fixed non-root UID, then (b) `exec gosu <uid> "$@"` to drop
|
||||||
|
privileges — so the process runs non-root while the volume stays writable. The
|
||||||
|
`ENTRYPOINT` must `exec "$@"` to preserve the existing `WORKDIR
|
||||||
|
/app/packages/server` + `CMD ["node","--import","tsx/esm","src/index.ts"]`
|
||||||
|
(Dockerfile:96-97). Ship a documented upgrade note; must not break existing
|
||||||
|
self-hosters on `docker pull` + restart.
|
||||||
|
|
||||||
|
### WS3 — Web/server hardening
|
||||||
|
|
||||||
|
**Reality check (from review):** this app renders *arbitrary user-supplied content*
|
||||||
|
and is *federated*, so a restrictive `img-src`/`media-src`/`connect-src` is
|
||||||
|
infeasible. A CSP here realistically constrains `script-src` / `object-src` /
|
||||||
|
`base-uri` / `frame-ancestors` / `form-action` (the XSS/clickjacking-relevant
|
||||||
|
directives) and stays permissive on content origins. Concretely:
|
||||||
|
- **`img-src` / `media-src` must be broad** (`https: data: blob:`): link-embed OG
|
||||||
|
images (`VideoEmbed.tsx`, `RichEmbed.tsx`) come from *any* linked site, and GIF
|
||||||
|
previews load directly from Klipy's CDN (`routes/gif.ts` returns `file.url`
|
||||||
|
unproxied — the CDN host differs from `api.klipy.com`).
|
||||||
|
- **`connect-src` must include the LiveKit `wss://` origin, which is operator
|
||||||
|
config** (`routes/livekit.ts` returns `config.livekit.url` = `LIVEKIT_URL`
|
||||||
|
verbatim) — so the **CSP must be generated at runtime from config**, not a static
|
||||||
|
string. Federation (`getApiForOrigin` in `exploreStore`/`socialStore`/`spaceStore`)
|
||||||
|
fetches/opens WS to peers discovered at runtime → `connect-src` must also allow
|
||||||
|
`https: wss:` (peers aren't enumerable at build time).
|
||||||
|
- **`frame-src` needs an explicit provider allowlist** — YouTube, Vimeo, Spotify
|
||||||
|
embed origins — or the embed iframes break (default `frame-src 'self'` blocks them).
|
||||||
|
|
||||||
|
Steps:
|
||||||
|
- Add `@fastify/helmet`. Build the CSP **dynamically** from `config.livekit.url` +
|
||||||
|
the embed-provider list; ship it **report-only first**, validate against real flows
|
||||||
|
(chat, **cross-instance federation**, embed render, upload, and a **real voice
|
||||||
|
join**) with zero violations, then flip to enforcing.
|
||||||
|
- `packages/web/index.html`: CSP `<meta>` (defense-in-depth) — script/object/base
|
||||||
|
directives only; do not duplicate the dynamic connect/img rules there.
|
||||||
|
- `Caddyfile`: `Strict-Transport-Security`, `X-Content-Type-Options: nosniff`,
|
||||||
|
`Referrer-Policy`, and clickjacking protection via CSP `frame-ancestors` (prefer
|
||||||
|
over `X-Frame-Options`). **Ownership split (documented to avoid conflicts):** Caddy
|
||||||
|
owns HSTS + nosniff + Referrer-Policy; the app (helmet) owns the CSP. Don't set CSP
|
||||||
|
in two places.
|
||||||
|
- **CORS (`packages/server/src/index.ts:46-48`)** — replace `origin: true` with a
|
||||||
|
**dynamic `origin` callback backed by the live federation-peer registry**, NOT a
|
||||||
|
static `DOMAIN`-derived list. Two breakages a static list would cause, both must be
|
||||||
|
handled:
|
||||||
|
- **Federated browser uploads:** browsers make cross-origin tus POST/HEAD/PATCH/
|
||||||
|
DELETE to peer `/api/files/*` (see the existing CORS-block comment at
|
||||||
|
`index.ts:50-64`); peers are DB-backed and added after boot → the callback must
|
||||||
|
consult the live registry, not a boot-time snapshot.
|
||||||
|
- **Desktop instance picker:** `packages/desktop/resources/instance-picker.html`
|
||||||
|
does a renderer `fetch('<url>/api/instance/info')` from a `file://` document
|
||||||
|
(Origin `null`). Keep `/api/instance/info` **CORS-open** (or move that probe to a
|
||||||
|
main-process fetch) so the picker doesn't report instances as unreachable.
|
||||||
|
- **Federation note:** S2S endpoints authenticate by HMAC and receive no browser
|
||||||
|
`Origin`; verify they are unaffected by the two-instance federation integration
|
||||||
|
suite.
|
||||||
|
- **Rollout (phased, like the scanners):** CSP report-only → observe → enforce; CORS
|
||||||
|
gets a **"log-and-allow" observation phase** (log rejected origins without blocking)
|
||||||
|
before switching to reject. **Test-rig dependency:** validating CSP + CORS here
|
||||||
|
needs the **two-instance + LiveKit** harness (shared with the DAST env, §WS6/G2),
|
||||||
|
which is heavier than a single-instance boot — call this out when scheduling.
|
||||||
|
|
||||||
|
### WS4 — Desktop/Electron hardening
|
||||||
|
- **Fuses without breaking the existing hook:** `electron-builder.yml:20` already
|
||||||
|
declares `afterPack: ./scripts/afterPack.js` (it strips host-compiled
|
||||||
|
`uiohook-napi` artifacts + cross-platform prebuilds), and electron-builder allows
|
||||||
|
**only one** `afterPack`. So do **not** add a second hook. Prefer electron-builder's
|
||||||
|
top-level **`electronFuses:`** config key (cleanest, no collision); if a fuse isn't
|
||||||
|
expressible there, call `@electron/fuses` `flipFuses()` **inside** the existing
|
||||||
|
`scripts/afterPack.js`. Fuses: disable `RunAsNode` + `EnableNodeCliInspectArguments`,
|
||||||
|
enable `OnlyLoadAppFromAsar`. **Asar-integrity caveat:** it interacts with the
|
||||||
|
existing `asarUnpack: **/*.node` (lines 17-18) and the afterPack that mutates
|
||||||
|
`app.asar.unpacked` — integrity hashes must be computed *after* those mutations, and
|
||||||
|
because builds are unsigned (`release.yml:93`) macOS integrity **enforcement** is
|
||||||
|
limited; document this in `desktop-security.md` rather than over-claiming.
|
||||||
|
- **`will-navigate` deny handler** in `main.ts`: block foreign top-level navigations
|
||||||
|
while allowing the initial `https://` instance load and the `file://` picker.
|
||||||
|
Clarification (mechanism): the app is client-routed (history API →
|
||||||
|
`did-navigate-in-page`), cross-instance switching uses main-process `loadURL`, and
|
||||||
|
`/join/*` deep-links are handled by `setWindowOpenHandler` (`main.ts:454`) — none of
|
||||||
|
these are `will-navigate`, so the deny handler is safe and `setWindowOpenHandler`
|
||||||
|
stays untouched.
|
||||||
|
- `docs/systems/desktop-security.md`: document the current webPreferences posture
|
||||||
|
(contextIsolation on, nodeIntegration off, sandbox on — `main.ts:356-360`), the
|
||||||
|
fuses/asar posture and its unsigned-macOS limits, and — because `release.yml:93`
|
||||||
|
sets `CSC_IDENTITY_AUTO_DISCOVERY: false` (unsigned) — the exact signing +
|
||||||
|
notarization steps and certificates to procure. Flag unsigned autoupdate as a known
|
||||||
|
gap until signing is wired up.
|
||||||
|
|
||||||
|
### WS5 — Remediation (after WS1 lands report-only)
|
||||||
|
- Run OSV-Scanner + Trivy + CodeQL; triage. Fix real HIGH/CRITICAL: direct upgrades,
|
||||||
|
`pnpm.overrides` for transitive pins where no direct upgrade exists, code fixes for
|
||||||
|
true-positive SAST findings. Dismiss false positives **with written justification**
|
||||||
|
(`.trivyignore` / inline).
|
||||||
|
- **SSRF hardening (fix, then test — not just test):** the string-prefix
|
||||||
|
`isPrivateIp` (`utils/ssrf.ts:3-16`) is genuinely bypassable — `::ffff:127.0.0.1`
|
||||||
|
matches no branch and returns `false` (SSRF to loopback via an attacker AAAA
|
||||||
|
record), and there is no `100.64.0.0/10` (CGNAT) or `::` handling. **Harden
|
||||||
|
`isPrivateIp`**: normalize IPv4-mapped IPv6, reject CGNAT and `::`/unspecified, and
|
||||||
|
normalize decimal/octal/hex hostname encodings — *then* add the negative/property
|
||||||
|
tests for `validateExternalUrl` covering those vectors. The residual DNS-rebind
|
||||||
|
TOCTOU is already documented (`ssrf.ts:58-61`) and stays out of scope (noted, not
|
||||||
|
fixed).
|
||||||
|
- **Then flip WS1/WS2 enforcement to blocking.**
|
||||||
|
|
||||||
|
### WS6 — Visible evidence, DAST & docs
|
||||||
|
- **`dast.yml` (ZAP baseline):** stands up an ephemeral instance and runs ZAP
|
||||||
|
baseline (advisory). **CI env override required** — the production compose won't
|
||||||
|
come up unmodified: Caddy uses `{$DOMAIN}` + ACME auto-HTTPS (hangs in CI without
|
||||||
|
public DNS), `backspace` requires `JWT_SECRET`, livekit is profile-gated. Use a CI
|
||||||
|
compose override that sets a test `JWT_SECRET`/`DOMAIN` and **points ZAP directly at
|
||||||
|
the `backspace` container `:3000`, bypassing Caddy** (or Caddy `internal`/local
|
||||||
|
TLS). This is the same two-instance-capable rig WS3 needs for CSP/CORS validation.
|
||||||
|
- README: CodeQL, OpenSSF Scorecard, and security-policy badges; a "Security &
|
||||||
|
supply chain" section describing what runs on every change and where results are
|
||||||
|
published.
|
||||||
|
- SECURITY.md: add a "Security testing & assurance" section enumerating the pipeline.
|
||||||
|
- `docs/systems/security-scanning.md`: full spec of every workflow, the tiered
|
||||||
|
policy, and the maintainer settings checklist (§5) — including the **repo-must-be-
|
||||||
|
public precondition** (Scorecard `publish_results` + badge and CodeQL free tier both
|
||||||
|
require a public canonical repo) and the manual `caddy`/`livekit` compose-image
|
||||||
|
update reminder (from WS1/F1).
|
||||||
|
- CLAUDE.md: add subsystem-table rows for `security-scanning.md` and
|
||||||
|
`desktop-security.md` (required by the Documentation Rule — this is structural CI
|
||||||
|
and architecture).
|
||||||
|
- **Finalize WS6 last:** badges + the maintainer checklist document state that only
|
||||||
|
exists once WS1/WS2/WS5 land, so write the final copy after those are green (the
|
||||||
|
workflow *files* can be scaffolded earlier).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Testing Strategy
|
||||||
|
|
||||||
|
- **`actionlint`** on every new/edited workflow.
|
||||||
|
- **Real PR-branch run** watching each check go green (or advisory) as intended.
|
||||||
|
- **Canary proof of blocking:** on a throwaway branch, introduce a fake secret and a
|
||||||
|
known-vulnerable dependency; confirm gitleaks and OSV-Scanner actually **fail** the
|
||||||
|
build; revert.
|
||||||
|
- **WS2:** `docker build` locally for amd64 + container boots + `/api/health`
|
||||||
|
responds, before and after the Dockerfile changes; confirm the process runs
|
||||||
|
**non-root** (via gosu step-down) yet still writes the host-owned `./data` bind
|
||||||
|
mount; confirm `tsx` + `better-sqlite3` prebuilt are present in the pruned tree and
|
||||||
|
the arm64 image still builds; confirm existing self-hosters survive `pull` + restart.
|
||||||
|
- **WS3 (needs the two-instance + LiveKit rig):** security headers present
|
||||||
|
(curl/DevTools); **zero CSP violations** across chat, **cross-instance federation**,
|
||||||
|
embed render (YouTube/Vimeo/Spotify + generic OG image), GIF, upload, and a **real
|
||||||
|
voice join**; CORS callback permits the app origin **and dynamically-registered
|
||||||
|
peers** (federated upload), keeps `/api/instance/info` open to the `file://` picker
|
||||||
|
(Origin `null`), and rejects an unknown origin; two-instance federation S2S suite
|
||||||
|
still green.
|
||||||
|
- **WS4:** desktop app boots with fuses/asar-integrity applied and the existing
|
||||||
|
`afterPack` native-module cleanup intact; `will-navigate` blocks a foreign top-level
|
||||||
|
URL while the initial instance load, the `file://` picker, and `/join/*` deep-links
|
||||||
|
(via `setWindowOpenHandler`) still work.
|
||||||
|
- **DAST:** ZAP baseline runs against the CI compose override (bypassing Caddy) and
|
||||||
|
produces a report artifact.
|
||||||
|
- **Full suite** (`pnpm -r test`) green throughout; existing federation/voice suites
|
||||||
|
unaffected.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Enforcement day-one paints CI permanently red (May-2024 lockfile has fixable highs) | Report-only → remediate (WS5) → flip blocking. |
|
||||||
|
| Multi-arch image "scan" is theater / arm64 unscanned | WS2 restructure: single-arch load+scan → then multi-arch push. |
|
||||||
|
| Non-root USER breaks `./data` bind-mount for existing self-hosters | Run entrypoint as root → chown → `exec gosu <uid>` step-down (no static `USER`); documented upgrade note; tested before/after. |
|
||||||
|
| Dependabot breaks CI via `uiohook-napi` patch / native rebuilds | `ignore` the patched dep; group `onlyBuiltDependencies`. |
|
||||||
|
| CSP too strict for a federated, arbitrary-content app | CSP built **dynamically** from `config.livekit.url` + peer registry; `img/media/connect` permissive; constrain only script/object/base/frame-ancestors; report-only → enforce. |
|
||||||
|
| CORS allowlist breaks federated uploads + desktop `file://` picker | Dynamic `origin` callback backed by the **live peer registry**; keep `/api/instance/info` CORS-open; "log-and-allow" phase before rejecting. |
|
||||||
|
| Electron fuses overwrite the existing `afterPack` (native-module cleanup) | Use top-level `electronFuses:` key or call `flipFuses()` inside the existing `scripts/afterPack.js`; compute asar-integrity hashes after afterPack mutations. |
|
||||||
|
| pnpm symlinked `.pnpm` store makes a plain `node_modules` copy non-self-contained | Use `pnpm --filter @backspace/server deploy --prod`; verify `tsx` + `better-sqlite3` prebuilt land per-arch; keep `ffmpeg`; boot test. |
|
||||||
|
| DAST/compose won't come up in CI (ACME/DOMAIN/JWT_SECRET) | CI compose override with test env; point ZAP at `backspace:3000`, bypass Caddy. |
|
||||||
|
| harden-runner block mode false-positives the Docker build | Start in `audit`; graduate to block only on lightweight jobs. |
|
||||||
|
| Scorecard badge / CodeQL free tier assume a public repo | Documented as an explicit precondition in the maintainer checklist. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Definition of Done
|
||||||
|
|
||||||
|
- All new workflows present, `actionlint`-clean, and green on a real PR.
|
||||||
|
- Blocking gates proven by canary (secret + vuln), then reverted.
|
||||||
|
- Security tab populated (CodeQL, Scorecard, advisory Trivy/OSV) with no open
|
||||||
|
fixable HIGH/CRITICAL after WS5.
|
||||||
|
- helmet + CSP + Caddyfile headers live with no CSP violations in normal use; CORS
|
||||||
|
allowlisted; federation suite green.
|
||||||
|
- Electron fuses + `will-navigate` live; desktop boots and deep-links work; existing
|
||||||
|
`afterPack` native-module cleanup intact.
|
||||||
|
- `isPrivateIp` hardened (IPv4-mapped IPv6 / CGNAT / `::` / alt-encodings) with
|
||||||
|
passing negative tests.
|
||||||
|
- Container image scanned before publish; SBOM + provenance attached; Dockerfile
|
||||||
|
runs non-root (gosu step-down) with a working data volume.
|
||||||
|
- README badges + Security section; SECURITY.md expanded;
|
||||||
|
`docs/systems/security-scanning.md` + `docs/systems/desktop-security.md` written;
|
||||||
|
CLAUDE.md subsystem table updated.
|
||||||
|
- Maintainer settings checklist documented (CodeQL merge protection, Dependabot
|
||||||
|
alerts, push protection, branch protection).
|
||||||
@@ -62,7 +62,7 @@ One installer, three modes, recorded as `DEPLOY_MODE` in `.env`. `install.sh` au
|
|||||||
`Dockerfile` has two stages:
|
`Dockerfile` has two stages:
|
||||||
|
|
||||||
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
1. **`builder`** (`node:20-slim`) — enables pnpm via corepack, installs the full workspace with `pnpm install --frozen-lockfile`, copies `shared`/`server`/`web` source, and runs `pnpm --filter @backspace/web build` to produce the static frontend (`packages/web/dist`).
|
||||||
2. **`runtime`** (`node:20-slim`) — installs the native toolchain for `better-sqlite3` plus `ffmpeg` (`python3 make g++ ffmpeg`), installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and starts the server with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
2. **`runtime`** (`node:20-slim`) — installs `ffmpeg` (media) + `gosu` (privilege drop) only — **no C toolchain**, since `better-sqlite3`/`sharp` load prebuilt binaries — installs production-only deps with `pnpm install --prod --frozen-lockfile` (`tsx` is a server runtime dependency), copies `shared` + `server` source and the prebuilt `web/dist`, creates `/app/data/uploads`, and runs the server **as the non-root `node` user** via `docker-entrypoint.sh` (which chowns `/app/data` as root, then `exec gosu node`) with `node --import tsx/esm src/index.ts` from `/app/packages/server`.
|
||||||
|
|
||||||
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
The server is run through `tsx` (no separate transpile step); TypeScript is executed directly at runtime.
|
||||||
|
|
||||||
@@ -73,6 +73,42 @@ The server is run through `tsx` (no separate transpile step); TypeScript is exec
|
|||||||
|
|
||||||
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
|
Empty/unset → `config.commit` is `null` (local dev, tarball install, or git unavailable). The source URL itself is `config.sourceCodeUrl` (env `BACKSPACE_SOURCE_URL`, default upstream) — operators running a modified build MUST set it to their fork.
|
||||||
|
|
||||||
|
### Container hardening (non-root)
|
||||||
|
|
||||||
|
The runtime image runs as the unprivileged `node` user (uid 1000), not root. On
|
||||||
|
container start, `docker-entrypoint.sh` runs as root only long enough to `chown`
|
||||||
|
the `./data` bind mount to `node` (only entries not already node-owned, so it is
|
||||||
|
near-instant after the first boot), then drops privileges via `gosu` and execs the
|
||||||
|
server. The build toolchain (`python3`/`make`/`g++`) is not installed in the
|
||||||
|
runtime stage — `better-sqlite3` and `sharp` load from prebuilt binaries — which
|
||||||
|
shrinks the runtime attack surface. `ffmpeg` remains (a real runtime dependency).
|
||||||
|
|
||||||
|
The published image carries an SBOM and SLSA provenance attestation, and the
|
||||||
|
amd64 image is scanned by Trivy before publish (report-only). Note: only the
|
||||||
|
amd64 image is scanned; the arm64 image is published unscanned.
|
||||||
|
|
||||||
|
**Minimum Docker version:** the attestation-bearing multi-arch image requires a
|
||||||
|
reasonably modern Docker to `pull` cleanly (Docker Engine 24+ recommended).
|
||||||
|
Very old daemons (≤ 20.10) may mishandle the `unknown/unknown` attestation
|
||||||
|
manifests. New installs via `install.sh` (get.docker.com) are fine.
|
||||||
|
|
||||||
|
**Upgrade note for existing self-hosters:** on the first start of the hardened
|
||||||
|
image, the contents of your host `./data` directory are chowned to uid 1000. This
|
||||||
|
is expected and idempotent. On an instance with a large `uploads/` tree on slow
|
||||||
|
storage (e.g. a Pi on SD), the **first** restart after upgrade may take noticeably
|
||||||
|
longer as this one-time chown runs before the server starts; subsequent boots only
|
||||||
|
touch not-yet-node-owned entries and are near-instant. If you previously accessed
|
||||||
|
`./data` on the host as a different user, adjust host-side access accordingly. `./restore.sh` continues to
|
||||||
|
work — it swaps files inside a throwaway root container, and root can rewrite the
|
||||||
|
now uid-1000-owned files.
|
||||||
|
|
||||||
|
**Release-gate (maintainer):** before the first `v*` tag that ships this image,
|
||||||
|
do a real `docker compose pull && docker compose up -d` on both an amd64 host and
|
||||||
|
the arm64 Pi to confirm the attestation-bearing image pulls cleanly on the actual
|
||||||
|
deployment Docker versions, and that the container boots non-root with a writable
|
||||||
|
`./data` on real Linux (the macOS Docker Desktop bind-mount ownership display is
|
||||||
|
not representative of Linux behaviour).
|
||||||
|
|
||||||
### Run: `docker compose up -d --build`
|
### Run: `docker compose up -d --build`
|
||||||
|
|
||||||
`docker-compose.yml` defines:
|
`docker-compose.yml` defines:
|
||||||
@@ -167,7 +203,7 @@ Behavior (`packages/server/src/scripts/remediate-seed-admin.ts`):
|
|||||||
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
- **Targets only the local seed admin** — `username = 'admin'` with `home_instance IS NULL` and `is_admin = 1`. Replicated/federated users are never touched.
|
||||||
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
- **Rotates only `admin123`.** It verifies the current hash against `admin123`; if the password has already been changed, it is a **no-op** ("nothing to do"). It is fully idempotent — safe to run repeatedly.
|
||||||
- **Never deletes** the account (the default-space ownership constraint above).
|
- **Never deletes** the account (the default-space ownership constraint above).
|
||||||
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, root-owned via the bind-mount). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`.**
|
- On rotation it generates a 24-character random password, updates the hash, prints the new password to stdout, **and** writes it to `data/seed-admin-rotated.txt` (mode `0600`, **root-owned** — the script runs via `docker exec`, which bypasses the entrypoint's gosu drop and runs as root, so this file is uid 0 until the next container restart re-chowns it). **Store the password somewhere safe, then delete `data/seed-admin-rotated.txt`** (a non-root host user may need `sudo`).
|
||||||
|
|
||||||
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
> **Note — sessions are not invalidated.** Rotation changes the stored password hash only; it does **not** revoke existing JWTs. An already-logged-in admin session survives until the token expires (`JWT_EXPIRES_IN`, default 30 days). Rotation closes off *future* logins with the old password; it does not eject a currently active session. If you must terminate live sessions immediately, rotate `JWT_SECRET` (which invalidates **all** tokens instance-wide) and restart.
|
||||||
|
|
||||||
@@ -250,7 +286,7 @@ They do **not** protect against **hardware loss** (disk failure, the box being d
|
|||||||
|
|
||||||
## 4. Restore
|
## 4. Restore
|
||||||
|
|
||||||
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (root)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
Restores are driven by `./restore.sh` from the host. Because `data/` (including `backspace.db` and `data/backups/`) is **container-owned (uid 1000)** via the bind-mount, the host user cannot rewrite those files directly — so the actual swap runs inside a throwaway root `alpine` container that mounts `data/`.
|
||||||
|
|
||||||
### List snapshots
|
### List snapshots
|
||||||
|
|
||||||
|
|||||||
@@ -199,6 +199,39 @@ interface AvatarStackProps {
|
|||||||
|
|
||||||
**Hooks-in-loop safety:** each rendered slot is its own `<AvatarTile>` component so `useCanonicalUserView` is called exactly once per slot, never inside a variable-length `.map()`.
|
**Hooks-in-loop safety:** each rendered slot is its own `<AvatarTile>` component so `useCanonicalUserView` is called exactly once per slot, never inside a variable-length `.map()`.
|
||||||
|
|
||||||
|
### Avatar vs ProfileAvatar
|
||||||
|
|
||||||
|
Two components, one deliberate split:
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|---|---|
|
||||||
|
| `Avatar` (`ui/Avatar.tsx`) | Purely presentational. Takes `user` for the gradient, avatar colour, `homeUserId` and status dot. Clicking it does nothing unless the caller passes `onClick`. |
|
||||||
|
| `ProfileAvatar` (`ui/ProfileAvatar.tsx`) | `Avatar` plus the profile card. Opens `UserProfilePopout` anchored to its own box, stops propagation so it wins over an enclosing row handler, and stays inert while `user` is undefined. |
|
||||||
|
|
||||||
|
**Rule:** an avatar is only a profile trigger when it is a `ProfileAvatar`. Never re-add an implicit "open the profile if a `user` prop is present" branch to `Avatar` — passing `user` is how *every* avatar gets its colour, so that branch silently turns the picture inside the profile card, the settings preview, the avatar-upload button and every row in a modal into a trigger. It also made the card re-anchor to its own picture and walk across the screen on repeated clicks (issue #37).
|
||||||
|
|
||||||
|
Use `ProfileAvatar` when the avatar is the primary way to reach that person's profile and nothing else owns the click. Use `Avatar` when an enclosing row, button or list item already handles clicks, or when the avatar depicts the surface it already sits on.
|
||||||
|
|
||||||
|
**Escalation chain.** Clicking a face always moves one step deeper, never sideways and never nowhere:
|
||||||
|
|
||||||
|
| Surface | Picture click |
|
||||||
|
|---|---|
|
||||||
|
| Member tile / row / message author | Opens the preview card (`UserProfilePopout`) |
|
||||||
|
| Preview card | Opens the full profile modal (`UserProfileModal`) and closes the card |
|
||||||
|
| Full profile modal | Nothing — this is the terminus |
|
||||||
|
|
||||||
|
The middle step matters: an inert picture on the preview card is a dead end that forces the user down to the *View Full Profile* link. What it must never do is reopen the card itself — that is the drift bug from issue #37.
|
||||||
|
|
||||||
|
### Floating placement
|
||||||
|
|
||||||
|
Every floating surface places itself with `computeFloatingPosition` (`hooks/useFloatingPosition.ts`): preferred side → flip when it would overflow → clamp into the viewport, with an 8px viewport padding.
|
||||||
|
|
||||||
|
- Components with a live anchor element use the `useFloatingPosition` hook (tooltips, mention/search popovers, voice popovers).
|
||||||
|
- Components opened from a store keep the anchor's **rect** instead of an element — `uiStore.openUserProfile(user, anchor, placement)` stores `AnchorRect` + `Placement`, and `UserProfilePopout` measures itself and places off that. `pointAnchor(x, y)` builds a zero-size rect for the rare caller with no anchor element.
|
||||||
|
- `align: 'start'` lines the surface's leading edge up with the anchor; the default centres it on the anchor.
|
||||||
|
|
||||||
|
**Callers never compute coordinates.** A surface that is handed a finished `{ top, left }` cannot account for its own measured size, and any caller-side constant (an assumed card height, a hardcoded sidebar width) drifts the moment the content or the layout changes.
|
||||||
|
|
||||||
**Tile geometry contract.** Each `AvatarTile` renders at `size × size` with a 2px border (`box-sizing: border-box` from Tailwind preflight), so its content area is `(size − 4) × (size − 4)`. The inner `Avatar` is sized to that content area (`size − 2 · TILE_BORDER_WIDTH`) and centered geometrically on the tile via `flex items-center justify-center`, **not** by inline-flow placement. Both corrections are required: sizing the Avatar to the outer dimensions overflows the padding box and gets clipped off-center (visible disc remains centered, but the avatar's contents — image crop, initials gradient + letter — anchor at the padding-edge top-left and visibly drift toward the lower-right of the visible disc); relying on `Avatar`'s `inline-flex` placement makes the Avatar drift vertically by whatever the inherited `line-height` adds, independent of border. `TILE_BORDER_WIDTH` is exported from `AvatarStack.tsx` as the single source of truth for the `border-2` width and must be updated in lockstep with any future change to that class.
|
**Tile geometry contract.** Each `AvatarTile` renders at `size × size` with a 2px border (`box-sizing: border-box` from Tailwind preflight), so its content area is `(size − 4) × (size − 4)`. The inner `Avatar` is sized to that content area (`size − 2 · TILE_BORDER_WIDTH`) and centered geometrically on the tile via `flex items-center justify-center`, **not** by inline-flow placement. Both corrections are required: sizing the Avatar to the outer dimensions overflows the padding box and gets clipped off-center (visible disc remains centered, but the avatar's contents — image crop, initials gradient + letter — anchor at the padding-edge top-left and visibly drift toward the lower-right of the visible disc); relying on `Avatar`'s `inline-flex` placement makes the Avatar drift vertically by whatever the inherited `line-height` adds, independent of border. `TILE_BORDER_WIDTH` is exported from `AvatarStack.tsx` as the single source of truth for the `border-2` width and must be updated in lockstep with any future change to that class.
|
||||||
|
|
||||||
**Border tiers:** the surface tier the stack sits on determines the tile border color (so the tiles cleanly separate from the panel they overlap). `channel` → `border-surface-channel` (sidebar); `chat` → `border-surface-chat` (chat area / welcome header / chat header); `modal` → `border-surface-elevated` (modal hero, mobile info-screen hero — there is no `surface-modal` token in `tailwind.config.js`).
|
**Border tiers:** the surface tier the stack sits on determines the tile border color (so the tiles cleanly separate from the panel they overlap). `channel` → `border-surface-channel` (sidebar); `chat` → `border-surface-chat` (chat area / welcome header / chat header); `modal` → `border-surface-elevated` (modal hero, mobile info-screen hero — there is no `surface-modal` token in `tailwind.config.js`).
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# Security Scanning & Supply-Chain Assurance
|
||||||
|
|
||||||
|
Automated, continuous scanning wired into GitHub Actions. This document is the
|
||||||
|
reference for what runs, where results go, and the one-time settings a maintainer
|
||||||
|
must enable. **Current state: report-only** — scanners surface findings in the
|
||||||
|
Security tab but do not block merges yet. Enforcement (blocking) is turned on in a
|
||||||
|
later change once the remediation pass has cleared the backlog.
|
||||||
|
|
||||||
|
## Workflows
|
||||||
|
|
||||||
|
| File | Purpose | Trigger | Result |
|
||||||
|
|------|---------|---------|--------|
|
||||||
|
| `.github/dependabot.yml` | Dependency + action + base-image update PRs | weekly | PRs |
|
||||||
|
| `.github/workflows/codeql.yml` | CodeQL SAST (`javascript-typescript`, build-mode none) | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/security.yml` | gitleaks (secrets, full history), OSV-Scanner (deps), Trivy config (IaC), Trivy license | PR + push main + weekly | Security tab |
|
||||||
|
| `.github/workflows/scorecard.yml` | OpenSSF Scorecard (repo posture) | push main + weekly + on branch-protection change | Security tab + public badge |
|
||||||
|
| `.github/workflows/docker-publish.yml` | Image scan (Trivy) + SBOM + provenance for the published container | tag push / manual | image scan (report-only) + SBOM + provenance |
|
||||||
|
|
||||||
|
> **gitleaks findings** surface in the workflow's job log and PR summary — the
|
||||||
|
> `gitleaks` job does not upload SARIF, so secret hits do **not** appear under
|
||||||
|
> Security → Code scanning (unlike the OSV / Trivy / CodeQL / Scorecard jobs).
|
||||||
|
|
||||||
|
## Tiered policy (target, enforced in a later change)
|
||||||
|
|
||||||
|
- **Always block:** gitleaks secret hit; OSV/Trivy fixable HIGH/CRITICAL; Trivy
|
||||||
|
disallowed license.
|
||||||
|
- **Advisory (SARIF → Security tab):** CodeQL alerts; OSV/Trivy unfixable or
|
||||||
|
medium/low; Scorecard.
|
||||||
|
|
||||||
|
Code-level gates (OSV, Trivy, gitleaks) block via workflow exit codes. CodeQL
|
||||||
|
merge-blocking, Dependabot alerts, and native secret-scanning are GitHub *settings*
|
||||||
|
— see the checklist below.
|
||||||
|
|
||||||
|
## Supply-chain hardening
|
||||||
|
|
||||||
|
- Every action is pinned to a full commit SHA (`# vX.Y.Z` comment) — resists
|
||||||
|
tag-move attacks and satisfies Scorecard's Pinned-Dependencies check.
|
||||||
|
- `step-security/harden-runner` (egress-policy `audit`) on Linux jobs.
|
||||||
|
- Least-privilege `permissions:` per workflow/job.
|
||||||
|
- SBOM + SLSA provenance are attached to the published container image at push
|
||||||
|
(`.github/workflows/docker-publish.yml`), alongside a report-only Trivy scan
|
||||||
|
of the amd64 image (the arm64 image ships unscanned; enforcement is turned
|
||||||
|
on in a later plan).
|
||||||
|
|
||||||
|
## Maintainer checklist (one-time GitHub settings — NOT code)
|
||||||
|
|
||||||
|
- [ ] Repository must be **public** (required for the Scorecard badge/publish and
|
||||||
|
the CodeQL free tier).
|
||||||
|
- [ ] Settings → Code security: enable **Dependabot alerts** and **Dependabot
|
||||||
|
security updates**.
|
||||||
|
- [ ] Settings → Code security: enable **Secret scanning** + **Push protection**.
|
||||||
|
- [ ] Settings → Code security: enable **CodeQL / code-scanning merge protection**
|
||||||
|
so high-severity alerts block PRs (the code-level gates do the rest).
|
||||||
|
- [ ] Branch protection on `main`: require the CI + security status checks to pass.
|
||||||
|
- [ ] **Manual image bumps:** Dependabot does not track `docker-compose.yml`
|
||||||
|
`image:` pins — update `caddy` and `livekit/livekit-server` by hand when new
|
||||||
|
releases ship. (Renovate, which parses compose, is an optional future
|
||||||
|
alternative.)
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
CREATE TABLE `spotify_connections` (
|
||||||
|
`user_id` text PRIMARY KEY NOT NULL,
|
||||||
|
`access_token` text NOT NULL,
|
||||||
|
`refresh_token` text NOT NULL,
|
||||||
|
`expires_at` integer NOT NULL,
|
||||||
|
`spotify_user_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `gif_favorites` (
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`gif_id` text NOT NULL,
|
||||||
|
`title` text NOT NULL,
|
||||||
|
`preview_url` text NOT NULL,
|
||||||
|
`url` text NOT NULL,
|
||||||
|
`width` integer NOT NULL,
|
||||||
|
`height` integer NOT NULL,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
PRIMARY KEY(`user_id`, `gif_id`),
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_gif_favorites_user_id` ON `gif_favorites` (`user_id`);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `audit_events` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`actor_id` text,
|
||||||
|
`action` text NOT NULL,
|
||||||
|
`target_type` text,
|
||||||
|
`target_id` text,
|
||||||
|
`metadata` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`actor_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_audit_events_space_created` ON `audit_events` (`space_id`,`created_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_audit_events_actor` ON `audit_events` (`actor_id`);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
CREATE TABLE `voice_sessions` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text,
|
||||||
|
`channel_id` text NOT NULL,
|
||||||
|
`user_id` text NOT NULL,
|
||||||
|
`started_at` integer NOT NULL,
|
||||||
|
`ended_at` integer,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_voice_sessions_space_started` ON `voice_sessions` (`space_id`,`started_at`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_voice_sessions_user` ON `voice_sessions` (`user_id`);--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_voice_sessions_ended` ON `voice_sessions` (`ended_at`);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
CREATE TABLE `soundboard_sounds` (
|
||||||
|
`id` text PRIMARY KEY NOT NULL,
|
||||||
|
`space_id` text NOT NULL,
|
||||||
|
`name` text NOT NULL,
|
||||||
|
`filename` text NOT NULL,
|
||||||
|
`uploader_id` text,
|
||||||
|
`created_at` integer NOT NULL,
|
||||||
|
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE INDEX `idx_soundboard_space` ON `soundboard_sounds` (`space_id`);
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -78,6 +78,41 @@
|
|||||||
"when": 1783035334526,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
"@tus/file-store": "^1.5.1",
|
"@tus/file-store": "^1.5.1",
|
||||||
"@tus/server": "^1.10.2",
|
"@tus/server": "^1.10.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"better-sqlite3": "^11.3.0",
|
"better-sqlite3": "^12.11.1",
|
||||||
"cheerio": "^1.0.0",
|
"cheerio": "^1.0.0",
|
||||||
"dotenv": "^16.4.5",
|
"dotenv": "^16.4.5",
|
||||||
"drizzle-orm": "^0.33.0",
|
"drizzle-orm": "^0.33.0",
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -549,3 +549,99 @@ 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),
|
||||||
|
}));
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ 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 { 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 +114,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 +135,10 @@ 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(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 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +474,23 @@ 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 = {};
|
||||||
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
|
// Image assets are rendered as <img src> by clients, so they get the same
|
||||||
|
// scheme check `url` above already has. Without it a client could point
|
||||||
|
// them at a host it controls and harvest the IP of everyone who opens
|
||||||
|
// that profile — and data: URIs would smuggle payloads through a field
|
||||||
|
// only length-checked.
|
||||||
|
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||||
|
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
|
||||||
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
|
||||||
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
|
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
|
||||||
|
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
|
||||||
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
|
||||||
if (Object.keys(assets).length > 0) activity.assets = assets;
|
if (Object.keys(assets).length > 0) activity.assets = assets;
|
||||||
}
|
}
|
||||||
@@ -485,6 +500,10 @@ function validateActivities(raw: unknown): Activity[] | null {
|
|||||||
return validated;
|
return validated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isHttpUrl(value: string): boolean {
|
||||||
|
return value.startsWith('https://') || value.startsWith('http://');
|
||||||
|
}
|
||||||
|
|
||||||
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
|
||||||
const status = event.status as string;
|
const status = event.status as string;
|
||||||
|
|
||||||
@@ -713,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)
|
||||||
@@ -783,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;
|
||||||
@@ -369,6 +369,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 +409,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 +433,14 @@ 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: '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 +459,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 }
|
||||||
|
|||||||
@@ -71,7 +71,34 @@ 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 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 +306,30 @@ 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 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 +739,35 @@ export class BackspaceApiClient {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.soundboard = {
|
||||||
|
list: (spaceId: string) => request<{ sounds: SoundboardSound[] }>('GET', `/spaces/${spaceId}/sounds`),
|
||||||
|
add: (spaceId: string, name: string, filename: string) =>
|
||||||
|
request<SoundboardSound>('POST', `/spaces/${spaceId}/sounds`, { name, filename }),
|
||||||
|
remove: (soundId: string) => request<void>('DELETE', `/sounds/${soundId}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.stats = {
|
||||||
|
space: (spaceId: string, days: number) =>
|
||||||
|
request<SpaceStats>('GET', `/spaces/${spaceId}/stats?days=${days}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.audit = {
|
||||||
|
log: (spaceId: string, before?: string) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (before) params.set('before', before);
|
||||||
|
const qs = params.toString();
|
||||||
|
return request<{ events: AuditEvent[]; hasMore: boolean }>(
|
||||||
|
'GET', `/spaces/${spaceId}/audit-log${qs ? `?${qs}` : ''}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.spotify = {
|
||||||
|
status: () => request<{ configured: boolean; connected: boolean }>('GET', '/connections/spotify/status'),
|
||||||
|
authorizeUrl: () => request<{ url: string }>('GET', '/connections/spotify/authorize'),
|
||||||
|
nowPlaying: () => request<{ activity: Activity | null; connected: boolean; 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 +775,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);
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export class AudioManager {
|
|||||||
private rnnoiseReady = false;
|
private rnnoiseReady = false;
|
||||||
private keepAliveOscillator: OscillatorNode | null = null;
|
private keepAliveOscillator: OscillatorNode | null = null;
|
||||||
|
|
||||||
|
// Mic test (settings → Voice). See startMicTest().
|
||||||
|
private micTestGain: GainNode | null = null;
|
||||||
|
private micTestStream: MediaStream | null = null;
|
||||||
|
|
||||||
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
|
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
|
||||||
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
|
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
|
||||||
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
|
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
|
||||||
@@ -181,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);
|
||||||
@@ -567,6 +599,65 @@ export class AudioManager {
|
|||||||
osc.stop(now + 0.45);
|
osc.stop(now + 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mic test: routes the processed input bus to the speakers so the user hears
|
||||||
|
* themselves, outside of any call.
|
||||||
|
*
|
||||||
|
* Settings deliberately never opened the mic on their own — the level meter
|
||||||
|
* only measures a stream that a call had already established. A mic test
|
||||||
|
* cannot honour that, so this is the one path that opens it, and
|
||||||
|
* `stopMicTest` hands it back rather than leaving the mic indicator lit.
|
||||||
|
*
|
||||||
|
* Returns false when the mic could not be opened (denied, unplugged).
|
||||||
|
*/
|
||||||
|
async startMicTest(): Promise<boolean> {
|
||||||
|
if (this.micTestGain) return true;
|
||||||
|
const ctx = this.ensureContext();
|
||||||
|
await this.resumeContext();
|
||||||
|
|
||||||
|
const hadStream = this.hasActiveStream();
|
||||||
|
if (!hadStream) {
|
||||||
|
const stream = await this.setInputDevice(this.currentInputDeviceId);
|
||||||
|
if (!stream) return false;
|
||||||
|
// Remember the exact stream we opened, so stopMicTest only ever stops
|
||||||
|
// that one — never a stream something else established meanwhile.
|
||||||
|
this.micTestStream = this.currentStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.micTestGain = ctx.createGain();
|
||||||
|
this.inputGain!.connect(this.micTestGain);
|
||||||
|
this.micTestGain.connect(this.getMasterOutput());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tears down the loopback.
|
||||||
|
*
|
||||||
|
* @param allowRelease Whether the mic may be handed back. Only the caller
|
||||||
|
* knows whether a call has started since the test began — AudioManager
|
||||||
|
* does not read stores — so releasing needs its consent as well as our own
|
||||||
|
* record that this test is what opened the stream.
|
||||||
|
*/
|
||||||
|
stopMicTest(allowRelease: boolean): void {
|
||||||
|
if (!this.micTestGain) return;
|
||||||
|
try { this.inputGain?.disconnect(this.micTestGain); } catch { /* graph already torn down */ }
|
||||||
|
try { this.micTestGain.disconnect(); } catch { /* already detached */ }
|
||||||
|
this.micTestGain = null;
|
||||||
|
|
||||||
|
if (allowRelease && this.micTestStream && this.currentStream === this.micTestStream) {
|
||||||
|
// Detach listeners before stopping (see `_setInputDeviceImpl`).
|
||||||
|
const tracks = this.currentStream.getTracks();
|
||||||
|
tracks.forEach(t => { t.onended = null; });
|
||||||
|
tracks.forEach(t => t.stop());
|
||||||
|
this.currentStream = null;
|
||||||
|
}
|
||||||
|
this.micTestStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
isMicTestActive(): boolean {
|
||||||
|
return this.micTestGain !== null;
|
||||||
|
}
|
||||||
|
|
||||||
getContext(): AudioContext | null {
|
getContext(): AudioContext | null {
|
||||||
return this.ctx;
|
return this.ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,29 +213,52 @@ 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) => {
|
||||||
<button
|
const isFavorite = favoriteIds.has(gif.id);
|
||||||
key={gif.id}
|
return (
|
||||||
onClick={() => onGifSelect(gif.url)}
|
// The star cannot live inside the tile button — a button inside
|
||||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all break-inside-avoid"
|
// 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">
|
||||||
<img
|
<button
|
||||||
src={gif.previewUrl}
|
onClick={() => onGifSelect(gif.url)}
|
||||||
alt={gif.title}
|
className="w-full rounded-lg overflow-hidden hover:ring-2 hover:ring-accent-primary transition-all block"
|
||||||
className="w-full object-cover rounded-lg"
|
>
|
||||||
loading="lazy"
|
<img
|
||||||
style={{
|
src={gif.previewUrl}
|
||||||
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
|
alt={gif.title}
|
||||||
}}
|
className="w-full object-cover rounded-lg"
|
||||||
/>
|
loading="lazy"
|
||||||
</button>
|
style={{
|
||||||
))}
|
aspectRatio: gif.width && gif.height ? `${gif.width}/${gif.height}` : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => void toggleFavorite(gif, e)}
|
||||||
|
title={isFavorite ? t('gif.favorite.remove') : t('gif.favorite.add')}
|
||||||
|
aria-label={isFavorite ? t('gif.favorite.remove') : t('gif.favorite.add')}
|
||||||
|
className={`absolute top-1.5 right-1.5 w-7 h-7 rounded-full flex items-center justify-center bg-black/55 backdrop-blur-sm transition-opacity ${
|
||||||
|
isFavorite ? 'opacity-100 text-accent-amber' : 'opacity-0 group-hover:opacity-100 focus:opacity-100 text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill={isFavorite ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="m12 17.27 6.18 3.73-1.64-7.03L22 9.24l-7.19-.61L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{loadingMore && (
|
{loadingMore && (
|
||||||
|
|||||||
@@ -43,11 +43,7 @@ export const MentionBadge = React.memo(function MentionBadge({ userId }: Mention
|
|||||||
const handleClick = (e: React.MouseEvent) => {
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
if (!member || !memberUser) return;
|
if (!member || !memberUser) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
openUserProfile(memberUser, e.currentTarget.getBoundingClientRect());
|
||||||
openUserProfile(memberUser, {
|
|
||||||
top: Math.min(rect.top, window.innerHeight - 450),
|
|
||||||
left: rect.right + 8,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build inline styles: role-colored text with tinted background
|
// Build inline styles: role-colored text with tinted background
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { MessageWithUser, Embed, User } from '@backspace/shared';
|
|||||||
import { MarkdownRenderer } from './MarkdownRenderer';
|
import { MarkdownRenderer } from './MarkdownRenderer';
|
||||||
import { MentionBadge } from './MentionBadge';
|
import { MentionBadge } from './MentionBadge';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||||
import { useContextMenuStore } from '../../stores/contextMenuStore';
|
import { useContextMenuStore } from '../../stores/contextMenuStore';
|
||||||
import { buildMessageMenuItems } from './messageMenuItems';
|
import { buildMessageMenuItems } from './messageMenuItems';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
@@ -266,11 +267,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
const handleUsernameClick = (e: React.MouseEvent) => {
|
const handleUsernameClick = (e: React.MouseEvent) => {
|
||||||
if (!message.user) return;
|
if (!message.user) return;
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
openUserProfile(message.user, e.currentTarget.getBoundingClientRect());
|
||||||
openUserProfile(message.user, {
|
|
||||||
top: Math.min(rect.top, window.innerHeight - 450),
|
|
||||||
left: rect.right + 16,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
const handleContextMenu = (e: React.MouseEvent) => {
|
||||||
@@ -417,7 +414,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
|
|||||||
<div className="w-10 flex-shrink-0 flex items-start justify-start">
|
<div className="w-10 flex-shrink-0 flex items-start justify-start">
|
||||||
{isFirstInGroup || message.replyTo ? (
|
{isFirstInGroup || message.replyTo ? (
|
||||||
<div className="mt-0.5">
|
<div className="mt-0.5">
|
||||||
<Avatar
|
<ProfileAvatar
|
||||||
src={displayIdentity.avatar}
|
src={displayIdentity.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={40}
|
size={40}
|
||||||
|
|||||||
@@ -959,8 +959,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
|
|||||||
title="GIF"
|
title="GIF"
|
||||||
aria-label="GIF picker"
|
aria-label="GIF picker"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
|
{/* Outlined badge, not a filled block: the solid rectangle read as
|
||||||
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
a plain square rather than a GIF picker. Letters reuse the
|
||||||
|
original glyph paths, scaled and centred inside the outline. */}
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3" y="6" width="18" height="12" rx="3" stroke="currentColor" strokeWidth="2" />
|
||||||
|
<g fill="currentColor" transform="translate(-1.77 -4.2) scale(1.35)">
|
||||||
|
<path d="M5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
type PendingBubble,
|
type PendingBubble,
|
||||||
} from '../../stores/pendingMessageStore';
|
} from '../../stores/pendingMessageStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||||
import { AvatarStack } from '../ui/AvatarStack';
|
import { AvatarStack } from '../ui/AvatarStack';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
import { hasPermissionBit, PermissionBits } from '../../utils/permissions';
|
||||||
@@ -832,11 +833,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
|
|||||||
|
|
||||||
const handleOwnerClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
const handleOwnerClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||||
if (!ownerMember) return;
|
if (!ownerMember) return;
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
openUserProfile(ownerMember, e.currentTarget.getBoundingClientRect(), 'bottom');
|
||||||
openUserProfile(ownerMember, {
|
|
||||||
top: Math.min(rect.bottom + 8, window.innerHeight - 450),
|
|
||||||
left: rect.left,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -896,7 +893,7 @@ function WelcomeHeader({ channelId }: { channelId: string }) {
|
|||||||
return (
|
return (
|
||||||
<div className="px-4 pt-8 pb-4">
|
<div className="px-4 pt-8 pb-4">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<Avatar src={otherUser?.avatar} name={displayName} size={80} user={otherUser ?? undefined} />
|
<ProfileAvatar src={otherUser?.avatar} name={displayName} size={80} user={otherUser ?? undefined} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-[32px] leading-10 font-bold text-txt-primary">{displayName}</h3>
|
<h3 className="text-[32px] leading-10 font-bold text-txt-primary">{displayName}</h3>
|
||||||
<p className="text-txt-secondary text-[14px] mt-1">
|
<p className="text-txt-secondary text-[14px] mt-1">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -104,7 +104,6 @@ export function ActivityPanel() {
|
|||||||
|
|
||||||
const handleFriendClick = (e: React.MouseEvent, friend: Friend) => {
|
const handleFriendClick = (e: React.MouseEvent, friend: Friend) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
openUserProfile(
|
openUserProfile(
|
||||||
{
|
{
|
||||||
id: friend.id,
|
id: friend.id,
|
||||||
@@ -123,10 +122,8 @@ export function ActivityPanel() {
|
|||||||
isAdmin: false,
|
isAdmin: false,
|
||||||
replicatedInstances: [],
|
replicatedInstances: [],
|
||||||
},
|
},
|
||||||
{
|
e.currentTarget.getBoundingClientRect(),
|
||||||
top: Math.min(rect.top, window.innerHeight - 450),
|
'left',
|
||||||
left: rect.left - 316,
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,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 +216,7 @@ 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();
|
||||||
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);
|
||||||
@@ -463,7 +465,7 @@ export function AppLayout() {
|
|||||||
<UpdateToast />
|
<UpdateToast />
|
||||||
|
|
||||||
{/* User Profile Popout */}
|
{/* User Profile Popout */}
|
||||||
{userProfilePopout.user && userProfilePopout.position && (
|
{userProfilePopout.user && userProfilePopout.anchor && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[145]"
|
className="fixed inset-0 z-[145]"
|
||||||
@@ -472,7 +474,8 @@ export function AppLayout() {
|
|||||||
<UserProfilePopout
|
<UserProfilePopout
|
||||||
user={userProfilePopout.user}
|
user={userProfilePopout.user}
|
||||||
onClose={closeUserProfile}
|
onClose={closeUserProfile}
|
||||||
position={userProfilePopout.position}
|
anchor={userProfilePopout.anchor}
|
||||||
|
placement={userProfilePopout.placement}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ 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 { Avatar } from '../ui/Avatar';
|
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||||
import { Mascot } from '../ui/Mascot';
|
import { Mascot } from '../ui/Mascot';
|
||||||
import { wsSend } from '../../hooks/useWebSocket';
|
import { wsSend } from '../../hooks/useWebSocket';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
@@ -844,6 +845,7 @@ function UserAreaPanel({
|
|||||||
onDeafenToggle: () => void;
|
onDeafenToggle: () => void;
|
||||||
onSettingsClick: (tab?: string) => void;
|
onSettingsClick: (tab?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
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);
|
||||||
@@ -1132,14 +1134,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
|
||||||
<Avatar src={user.avatar} name={user.displayName ?? user.username} size={34} status={user.status as any} user={user} />
|
onClose={() => setAccountMenuOpen(false)}
|
||||||
<div className="flex-1 min-w-0">
|
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} />
|
||||||
|
<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 */}
|
||||||
|
|||||||
@@ -285,45 +285,17 @@ describe('DmMemberRow — profile popout anchoring', () => {
|
|||||||
await user.click(profileBtn);
|
await user.click(profileBtn);
|
||||||
|
|
||||||
expect(openUserProfileMock).toHaveBeenCalledTimes(1);
|
expect(openUserProfileMock).toHaveBeenCalledTimes(1);
|
||||||
|
// The row hands over its rect and the side it wants; the card works out its
|
||||||
|
// own coordinates once it knows how tall it is (see UserProfilePopout).
|
||||||
expect(openUserProfileMock).toHaveBeenCalledWith(
|
expect(openUserProfileMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ id: member.id }),
|
expect.objectContaining({ id: member.id }),
|
||||||
// Math.min(200, 800 - 450) = 200; left = 1500 - 316 = 1184.
|
rect,
|
||||||
{ top: 200, left: 1184 },
|
'left',
|
||||||
);
|
);
|
||||||
// The row no longer routes 'profile' through onMenuAction.
|
// The row no longer routes 'profile' through onMenuAction.
|
||||||
expect(onMenuAction).not.toHaveBeenCalled();
|
expect(onMenuAction).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('clamps top to (innerHeight - 450) when the row sits near the bottom of the viewport', async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const { container } = renderRow();
|
|
||||||
const row = container.querySelector('[data-dm-member-row]') as HTMLElement;
|
|
||||||
|
|
||||||
const rect: DOMRect = {
|
|
||||||
top: 700,
|
|
||||||
left: 1500,
|
|
||||||
right: 1740,
|
|
||||||
bottom: 740,
|
|
||||||
width: 240,
|
|
||||||
height: 40,
|
|
||||||
x: 1500,
|
|
||||||
y: 700,
|
|
||||||
toJSON: () => ({}),
|
|
||||||
} as DOMRect;
|
|
||||||
row.getBoundingClientRect = () => rect;
|
|
||||||
|
|
||||||
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
|
|
||||||
|
|
||||||
openMenuByContextMenu(row);
|
|
||||||
await user.click(await screen.findByText('View Profile'));
|
|
||||||
|
|
||||||
// Math.min(700, 800 - 450 = 350) → top clamped to 350.
|
|
||||||
expect(openUserProfileMock).toHaveBeenCalledWith(
|
|
||||||
expect.anything(),
|
|
||||||
{ top: 350, left: 1184 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('falls back to onMenuAction("profile", ...) when the row has no bounding rect', async () => {
|
it('falls back to onMenuAction("profile", ...) when the row has no bounding rect', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const { container, onMenuAction, member } = renderRow();
|
const { container, onMenuAction, member } = renderRow();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||||
import { Username } from '../ui/Username';
|
import { Username } from '../ui/Username';
|
||||||
import { Tooltip } from '../ui/Tooltip';
|
import { Tooltip } from '../ui/Tooltip';
|
||||||
import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
import { parseFederatedUsername, isFederationGlobeApplicable } from '../../utils/identity';
|
||||||
@@ -104,14 +104,11 @@ export function DmMemberRow({
|
|||||||
label: 'View Profile',
|
label: 'View Profile',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
// Anchor the popout to this row's bounding rect — matches the
|
// Anchor the popout to this row's bounding rect — matches the
|
||||||
// MemberSidebar pattern (see MemberSidebar.tsx:158-165). On mobile
|
// MemberSidebar pattern (see MemberSidebar.tsx). On mobile the anchor
|
||||||
// the position arg is ignored by the store (full-screen push).
|
// is ignored by the store (full-screen push).
|
||||||
const rect = rowRef.current?.getBoundingClientRect();
|
const rect = rowRef.current?.getBoundingClientRect();
|
||||||
if (rect) {
|
if (rect) {
|
||||||
useUIStore.getState().openUserProfile(canonical, {
|
useUIStore.getState().openUserProfile(canonical, rect, 'left');
|
||||||
top: Math.min(rect.top, window.innerHeight - 450),
|
|
||||||
left: rect.left - 316,
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
// Fallback: defer to the consumer if we can't compute a rect
|
// Fallback: defer to the consumer if we can't compute a rect
|
||||||
// (shouldn't happen in practice, but keeps the contract intact).
|
// (shouldn't happen in practice, but keeps the contract intact).
|
||||||
@@ -183,12 +180,13 @@ export function DmMemberRow({
|
|||||||
className="group flex items-center gap-2.5 px-2 py-1.5 rounded-[6px] hover:bg-interactive-hover transition-colors select-none"
|
className="group flex items-center gap-2.5 px-2 py-1.5 rounded-[6px] hover:bg-interactive-hover transition-colors select-none"
|
||||||
>
|
>
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<Avatar
|
<ProfileAvatar
|
||||||
src={canonical.avatar}
|
src={canonical.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={32}
|
size={32}
|
||||||
status={isOffline ? null : canonical.status}
|
status={isOffline ? null : canonical.status}
|
||||||
user={canonical}
|
user={canonical}
|
||||||
|
placement="left"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
|||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
import { ConfirmDialog } from '../ui/ConfirmDialog';
|
||||||
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
import { DmMemberRow, type DmMemberRowAction } from './DmMemberRow';
|
||||||
|
import { pointAnchor } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Right-side roster for group DMs. Mirrors `MemberSidebar`'s layout language
|
* Right-side roster for group DMs. Mirrors `MemberSidebar`'s layout language
|
||||||
@@ -93,7 +94,7 @@ export function DmRosterPanel() {
|
|||||||
// MemberSidebar pattern). This branch only fires on the unlikely
|
// MemberSidebar pattern). This branch only fires on the unlikely
|
||||||
// fallback path where the row couldn't compute its bounding rect —
|
// fallback path where the row couldn't compute its bounding rect —
|
||||||
// in that case, anchor to the top-left of the roster column.
|
// in that case, anchor to the top-left of the roster column.
|
||||||
openUserProfile(member, { top: 100, left: 100 });
|
openUserProfile(member, pointAnchor(100, 100));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'kick') {
|
if (action === 'kick') {
|
||||||
|
|||||||
@@ -157,11 +157,7 @@ export function MemberSidebar() {
|
|||||||
|
|
||||||
const handleMemberClick = (e: React.MouseEvent, user: MemberWithUser['user']) => {
|
const handleMemberClick = (e: React.MouseEvent, user: MemberWithUser['user']) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'left');
|
||||||
openUserProfile(user, {
|
|
||||||
top: Math.min(rect.top, window.innerHeight - 450),
|
|
||||||
left: rect.left - 316,
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderMember = (member: MemberWithUser, isOffline = false) => {
|
const renderMember = (member: MemberWithUser, isOffline = false) => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { api } from '../../api/client';
|
|||||||
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
import { isSelf, parseFederatedUsername } from '../../utils/identity';
|
||||||
import { AvatarStack } from '../ui/AvatarStack';
|
import { AvatarStack } from '../ui/AvatarStack';
|
||||||
import { DmMemberRow, type DmMemberRowAction } from '../layout/DmMemberRow';
|
import { DmMemberRow, type DmMemberRowAction } from '../layout/DmMemberRow';
|
||||||
|
import { pointAnchor } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
const MAX_NAME_LENGTH = 50;
|
const MAX_NAME_LENGTH = 50;
|
||||||
const MAX_GROUP_MEMBERS = 10;
|
const MAX_GROUP_MEMBERS = 10;
|
||||||
@@ -263,7 +264,7 @@ export function GroupDmSettings() {
|
|||||||
// Fallback path — DmMemberRow normally opens the profile itself via
|
// Fallback path — DmMemberRow normally opens the profile itself via
|
||||||
// its own bounding rect. If we reach this branch, just route to a
|
// its own bounding rect. If we reach this branch, just route to a
|
||||||
// top-left anchor (matches DmRosterPanel's fallback).
|
// top-left anchor (matches DmRosterPanel's fallback).
|
||||||
useUIStore.getState().openUserProfile(member, { top: 100, left: 100 });
|
useUIStore.getState().openUserProfile(member, pointAnchor(100, 100));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (action === 'kick') {
|
if (action === 'kick') {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ 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 { 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 +269,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'>('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 +335,10 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -366,6 +374,10 @@ 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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -392,6 +404,8 @@ 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} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { useNavigate } from 'react-router-dom';
|
|||||||
import ReactMarkdown from 'react-markdown';
|
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 { useT } from '../../i18n';
|
||||||
|
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';
|
||||||
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
|
||||||
@@ -64,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');
|
||||||
@@ -142,13 +146,28 @@ 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);
|
||||||
|
|
||||||
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))
|
||||||
: null;
|
: null;
|
||||||
@@ -335,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
|
||||||
@@ -353,10 +372,13 @@ export function UserProfileModal() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Current activity — the "Listening to Spotify" block */}
|
||||||
|
<ProfileActivity activities={activities} />
|
||||||
|
|
||||||
{/* 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, {
|
||||||
@@ -499,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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useInstanceStore } from '../../../stores/instanceStore';
|
|||||||
import { useSpaceStore } from '../../../stores/spaceStore';
|
import { useSpaceStore } from '../../../stores/spaceStore';
|
||||||
import { Avatar } from '../../ui/Avatar';
|
import { Avatar } from '../../ui/Avatar';
|
||||||
import { ImageCropModal } from '../../ui/ImageCropModal';
|
import { ImageCropModal } from '../../ui/ImageCropModal';
|
||||||
|
import { GifPicker } from '../../chat/GifPicker';
|
||||||
import { DeleteAccountModal } from '../DeleteAccountModal';
|
import { DeleteAccountModal } from '../DeleteAccountModal';
|
||||||
import { api } from '../../../api/client';
|
import { api } from '../../../api/client';
|
||||||
import { useTransferStore } from '../../../stores/transferStore';
|
import { useTransferStore } from '../../../stores/transferStore';
|
||||||
@@ -13,6 +14,16 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
|
|||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
|
||||||
import type { FederationOpResult } from '../../../utils/federationOps';
|
import type { FederationOpResult } from '../../../utils/federationOps';
|
||||||
|
/**
|
||||||
|
* Banner/avatar previews hold either a `blob:` object URL (local upload) or a
|
||||||
|
* remote `https:` URL (GIF picker). Only the former owns memory that must be
|
||||||
|
* released — calling revokeObjectURL on a remote URL is a silent no-op that
|
||||||
|
* would quietly hide a mistake here.
|
||||||
|
*/
|
||||||
|
function releasePreview(url: string | null): void {
|
||||||
|
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
export function AccountPanel() {
|
export function AccountPanel() {
|
||||||
const user = useAuthStore((s) => s.user);
|
const user = useAuthStore((s) => s.user);
|
||||||
const updateProfile = useAuthStore((s) => s.updateProfile);
|
const updateProfile = useAuthStore((s) => s.updateProfile);
|
||||||
@@ -37,6 +48,7 @@ export function AccountPanel() {
|
|||||||
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
|
||||||
const [uploadingBanner, setUploadingBanner] = useState(false);
|
const [uploadingBanner, setUploadingBanner] = useState(false);
|
||||||
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
|
||||||
|
const [showBannerGif, setShowBannerGif] = useState(false);
|
||||||
const bannerInputRef = useRef<HTMLInputElement>(null);
|
const bannerInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const addToast = useUIStore((s) => s.addToast);
|
const addToast = useUIStore((s) => s.addToast);
|
||||||
@@ -54,7 +66,7 @@ export function AccountPanel() {
|
|||||||
setCustomHex(user.accentColor ?? '');
|
setCustomHex(user.accentColor ?? '');
|
||||||
// Reset upload state
|
// Reset upload state
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
setAvatarFilename(null);
|
setAvatarFilename(null);
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
@@ -202,7 +214,7 @@ export function AccountPanel() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBannerCropComplete = async (blob: Blob) => {
|
const handleBannerCropComplete = async (blob: Blob) => {
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
const previewUrl = URL.createObjectURL(blob);
|
const previewUrl = URL.createObjectURL(blob);
|
||||||
setBannerPreview(previewUrl);
|
setBannerPreview(previewUrl);
|
||||||
setBannerCropSrc(null);
|
setBannerCropSrc(null);
|
||||||
@@ -227,8 +239,21 @@ export function AccountPanel() {
|
|||||||
setAvatarFilename('');
|
setAvatarFilename('');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Banners accept absolute URLs end to end: the server's isValidAssetUrl
|
||||||
|
* allows http(s), and the profile render already branches on
|
||||||
|
* `banner.startsWith('http')`. So a picked GIF needs no upload — the remote
|
||||||
|
* URL is stored directly.
|
||||||
|
*/
|
||||||
|
const handleBannerGifSelect = (url: string) => {
|
||||||
|
releasePreview(bannerPreview);
|
||||||
|
setBannerPreview(url);
|
||||||
|
setBannerFilename(url);
|
||||||
|
setShowBannerGif(false);
|
||||||
|
};
|
||||||
|
|
||||||
const handleRemoveBanner = () => {
|
const handleRemoveBanner = () => {
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
setBannerFilename('');
|
setBannerFilename('');
|
||||||
};
|
};
|
||||||
@@ -300,7 +325,7 @@ export function AccountPanel() {
|
|||||||
setAvatarColorState(user.avatarColor ?? null);
|
setAvatarColorState(user.avatarColor ?? null);
|
||||||
setCustomHex(user.accentColor ?? '');
|
setCustomHex(user.accentColor ?? '');
|
||||||
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
|
||||||
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
|
releasePreview(bannerPreview);
|
||||||
setAvatarPreview(null);
|
setAvatarPreview(null);
|
||||||
setAvatarFilename(null);
|
setAvatarFilename(null);
|
||||||
setBannerPreview(null);
|
setBannerPreview(null);
|
||||||
@@ -482,7 +507,7 @@ export function AccountPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex gap-2 mt-1">
|
<div className="relative flex gap-2 mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => bannerInputRef.current?.click()}
|
onClick={() => bannerInputRef.current?.click()}
|
||||||
@@ -491,6 +516,23 @@ export function AccountPanel() {
|
|||||||
>
|
>
|
||||||
Change Banner
|
Change Banner
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowBannerGif((v) => !v)}
|
||||||
|
disabled={uploadingBanner}
|
||||||
|
className="text-xs text-accent-primary hover:underline"
|
||||||
|
>
|
||||||
|
Choose GIF
|
||||||
|
</button>
|
||||||
|
{showBannerGif && (
|
||||||
|
<>
|
||||||
|
{/* Click-away layer, below the panel but above the page */}
|
||||||
|
<div className="fixed inset-0 z-[290]" onClick={() => setShowBannerGif(false)} />
|
||||||
|
<div className="absolute left-0 top-full mt-2 z-[300] glass rounded-xl overflow-hidden">
|
||||||
|
<GifPicker onGifSelect={handleBannerGifSelect} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
|
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -20,6 +22,8 @@ export function AudioInputSection() {
|
|||||||
// then join voice and expect the meter / resolved-default hint to come
|
// then join voice and expect the meter / resolved-default hint to come
|
||||||
// alive without reopening the panel.
|
// alive without reopening the panel.
|
||||||
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
const [audioCtxGen, setAudioCtxGen] = useState(0);
|
||||||
|
const [micTesting, setMicTesting] = useState(false);
|
||||||
|
const [micTestError, setMicTestError] = useState('');
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
const animFrameRef = useRef<number>(0);
|
const animFrameRef = useRef<number>(0);
|
||||||
|
|
||||||
@@ -77,7 +81,39 @@ export function AudioInputSection() {
|
|||||||
stopped = true;
|
stopped = true;
|
||||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||||
};
|
};
|
||||||
}, [permState, audioCtxGen]);
|
}, [permState, audioCtxGen, micTesting]);
|
||||||
|
|
||||||
|
// Subscribed (not a one-off getState) so the hint text below tracks the call
|
||||||
|
// state live. The release decision itself reads getState() at the moment of
|
||||||
|
// stopping, which is when it must be accurate.
|
||||||
|
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
|
||||||
|
|
||||||
|
const toggleMicTest = async () => {
|
||||||
|
const am = AudioManager.getInstance();
|
||||||
|
if (micTesting) {
|
||||||
|
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||||
|
setMicTesting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMicTestError('');
|
||||||
|
const ok = await am.startMicTest();
|
||||||
|
if (!ok) {
|
||||||
|
setMicTestError(t('settings.voice.micTest.failed'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setMicTesting(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Leaving the panel mid-test must not leave the loopback running or the mic
|
||||||
|
// held open.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
const am = AudioManager.getInstance();
|
||||||
|
if (am.isMicTestActive()) {
|
||||||
|
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Track the resolved upstream deviceId for the "Currently using: X" hint.
|
// Track the resolved upstream deviceId for the "Currently using: X" hint.
|
||||||
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
|
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
|
||||||
@@ -91,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>
|
||||||
);
|
);
|
||||||
@@ -99,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">
|
||||||
@@ -118,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.
|
||||||
@@ -152,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
|
||||||
@@ -189,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
|
||||||
@@ -213,9 +249,30 @@ export function AudioInputSection() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-txt-tertiary mt-1.5">
|
<div className="flex items-center gap-3 mt-3">
|
||||||
The level meter activates once you join a voice channel.
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void toggleMicTest()}
|
||||||
|
disabled={permState !== 'granted'}
|
||||||
|
className={`px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||||
|
micTesting
|
||||||
|
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
|
||||||
|
: 'bg-accent-primary text-white hover:brightness-110'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{micTesting ? t('settings.voice.micTest.stop') : t('settings.voice.micTest.start')}
|
||||||
|
</button>
|
||||||
|
<span className="text-xs text-txt-tertiary">
|
||||||
|
{micTesting
|
||||||
|
? t('settings.voice.micTest.playing')
|
||||||
|
: isLiveKitConnected
|
||||||
|
? t('settings.voice.micTest.inCall')
|
||||||
|
: t('settings.voice.micTest.idle')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{micTestError && (
|
||||||
|
<div className="text-xs text-txt-danger mt-1.5">{micTestError}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionShell>
|
</SectionShell>
|
||||||
|
|||||||
@@ -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,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,63 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { User } from '@backspace/shared';
|
||||||
|
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
|
function makeUser(): User {
|
||||||
|
return {
|
||||||
|
id: 'u-1',
|
||||||
|
username: 'ada',
|
||||||
|
displayName: 'Ada',
|
||||||
|
avatar: null,
|
||||||
|
banner: null,
|
||||||
|
accentColor: null,
|
||||||
|
avatarColor: null,
|
||||||
|
bio: null,
|
||||||
|
status: 'online',
|
||||||
|
customStatus: null,
|
||||||
|
isAdmin: false,
|
||||||
|
createdAt: 0,
|
||||||
|
homeInstance: null,
|
||||||
|
homeUserId: null,
|
||||||
|
replicatedInstances: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Avatar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useUIStore.setState({
|
||||||
|
isMobile: false,
|
||||||
|
userProfilePopout: { user: null, anchor: null, placement: 'right' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is presentational: a `user` prop alone does not make it a profile trigger', async () => {
|
||||||
|
// `user` carries identity for the gradient, colour and status dot. Passing it
|
||||||
|
// must not silently turn the avatar into a popout trigger — otherwise every
|
||||||
|
// avatar inside a modal, settings preview or the profile card itself opens a
|
||||||
|
// second profile card on top of the surface it lives in (issue #37).
|
||||||
|
const { container } = render(<Avatar src={null} name="Ada" size={40} user={makeUser()} />);
|
||||||
|
|
||||||
|
await userEvent.click(container.querySelector('[data-avatar]')!);
|
||||||
|
|
||||||
|
expect(useUIStore.getState().userProfilePopout.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is not focusable or clickable-looking without a handler', () => {
|
||||||
|
const { container } = render(<Avatar src={null} name="Ada" size={40} user={makeUser()} />);
|
||||||
|
|
||||||
|
expect(container.querySelector('[data-avatar]')!.className).not.toContain('cursor-pointer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs an explicit onClick handler', async () => {
|
||||||
|
let clicks = 0;
|
||||||
|
const { container } = render(<Avatar src={null} name="Ada" size={40} user={makeUser()} onClick={() => { clicks++; }} />);
|
||||||
|
|
||||||
|
await userEvent.click(container.querySelector('[data-avatar]')!);
|
||||||
|
|
||||||
|
expect(clicks).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
|
||||||
import { getAvatarGradient } from '../../utils/gradients';
|
import { getAvatarGradient } from '../../utils/gradients';
|
||||||
|
|
||||||
interface AvatarProps {
|
interface AvatarProps {
|
||||||
@@ -56,7 +55,6 @@ function getDotMetrics(avatarSize: number, ringWidth: number = 0) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring, avatarColor }: AvatarProps) {
|
export function Avatar({ src, name, size = 40, status, className = '', onClick, user, userId, ring, avatarColor }: AvatarProps) {
|
||||||
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
|
||||||
const initials = name.charAt(0).toUpperCase();
|
const initials = name.charAt(0).toUpperCase();
|
||||||
const fontPx = Math.round(size * 0.4);
|
const fontPx = Math.round(size * 0.4);
|
||||||
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, avatarColor ?? user?.avatarColor);
|
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name, avatarColor ?? user?.avatarColor);
|
||||||
@@ -64,19 +62,6 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
|||||||
const ringWidth = ring?.width ?? 0;
|
const ringWidth = ring?.width ?? 0;
|
||||||
const outerSize = size + ringWidth * 2;
|
const outerSize = size + ringWidth * 2;
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent) => {
|
|
||||||
if (onClick) {
|
|
||||||
onClick(e);
|
|
||||||
} else if (user) {
|
|
||||||
e.stopPropagation();
|
|
||||||
const rect = e.currentTarget.getBoundingClientRect();
|
|
||||||
openUserProfile(user, {
|
|
||||||
top: Math.min(rect.top, window.innerHeight - 450),
|
|
||||||
left: rect.right + 16,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Only compute mask when status dot is visible
|
// Only compute mask when status dot is visible
|
||||||
const cutoutMask = status ? buildCutoutMask(size, ringWidth) : undefined;
|
const cutoutMask = status ? buildCutoutMask(size, ringWidth) : undefined;
|
||||||
const maskStyle: React.CSSProperties | undefined = cutoutMask
|
const maskStyle: React.CSSProperties | undefined = cutoutMask
|
||||||
@@ -88,9 +73,9 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-avatar
|
data-avatar
|
||||||
className={`relative inline-flex flex-shrink-0 ${(onClick || user) ? 'cursor-pointer' : ''} ${className}`}
|
className={`relative inline-flex flex-shrink-0 ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||||
style={{ width: outerSize, height: outerSize }}
|
style={{ width: outerSize, height: outerSize }}
|
||||||
onClick={handleClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
{/* Inner masked circle — ring background + avatar content */}
|
{/* Inner masked circle — ring background + avatar content */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import type { Activity } from '@backspace/shared';
|
||||||
|
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
|
||||||
|
import { useT, type TranslationKey } from '../../i18n';
|
||||||
|
import { serverNow } from '../../utils/serverTime';
|
||||||
|
|
||||||
|
interface ProfileActivityProps {
|
||||||
|
activities: Activity[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERB_KEY: Record<Exclude<Activity['type'], 'custom'>, TranslationKey> = {
|
||||||
|
playing: 'profile.activity.playing',
|
||||||
|
listening: 'profile.activity.listening',
|
||||||
|
watching: 'profile.activity.watching',
|
||||||
|
streaming: 'profile.activity.streaming',
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatClock(ms: number): string {
|
||||||
|
const total = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const minutes = Math.floor(total / 60);
|
||||||
|
const seconds = total % 60;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours > 0) return `${hours}:${String(minutes % 60).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The activity block on the profile card — the "Listening to Spotify" panel.
|
||||||
|
*
|
||||||
|
* Deliberately richer than `ActivityCard` (which renders name + elapsed for
|
||||||
|
* compact list rows): here there is room for the artwork, the track and the
|
||||||
|
* artist, so it reads `details`, `state` and `assets` too. Every one of those
|
||||||
|
* is optional and the block degrades to just the name, which is all today's
|
||||||
|
* process-based detector supplies — the extra fields are what a Spotify
|
||||||
|
* producer would fill in.
|
||||||
|
*/
|
||||||
|
export function ProfileActivity({ activities }: ProfileActivityProps) {
|
||||||
|
const t = useT();
|
||||||
|
const primary = getPrimaryActivity(activities);
|
||||||
|
const start = primary?.timestamps?.start;
|
||||||
|
const end = primary?.timestamps?.end;
|
||||||
|
|
||||||
|
const paused = primary?.paused === true;
|
||||||
|
|
||||||
|
// 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(() => {
|
||||||
|
if (!start || paused) return;
|
||||||
|
const id = setInterval(() => setNow(serverNow()), 1000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [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;
|
||||||
|
|
||||||
|
const elapsed = start ? now - start : 0;
|
||||||
|
const duration = start && end ? end - start : 0;
|
||||||
|
const progress = duration > 0 ? Math.min(Math.max(elapsed / duration, 0), 1) : 0;
|
||||||
|
|
||||||
|
// The server restricts asset images to http(s); this mirrors that so a
|
||||||
|
// record stored before that check cannot inject another scheme.
|
||||||
|
const art = primary.assets?.largeImage;
|
||||||
|
const artSrc = art && (art.startsWith('https://') || art.startsWith('http://')) ? art : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
|
||||||
|
{t(VERB_KEY[primary.type])} {primary.name}
|
||||||
|
</span>
|
||||||
|
<div className="mt-2 flex gap-3 rounded-lg bg-surface-elevated/40 p-2.5">
|
||||||
|
{artSrc && (
|
||||||
|
<img
|
||||||
|
src={artSrc}
|
||||||
|
alt={primary.assets?.largeText ?? ''}
|
||||||
|
className="w-[60px] h-[60px] rounded object-cover flex-shrink-0"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{primary.details && (
|
||||||
|
<div className="text-[13px] font-semibold text-txt-primary truncate">
|
||||||
|
{primary.details}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{primary.state && (
|
||||||
|
<div className="text-[12px] text-txt-secondary truncate">{primary.state}</div>
|
||||||
|
)}
|
||||||
|
{duration > 0 ? (
|
||||||
|
<div className="mt-2">
|
||||||
|
<div className="h-[3px] rounded-full bg-interactive-muted overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-txt-primary rounded-full"
|
||||||
|
style={{ width: `${progress * 100}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-[10px] text-txt-tertiary mt-1 tabular-nums">
|
||||||
|
<span>{formatClock(elapsed)}</span>
|
||||||
|
<span>{formatClock(duration)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : start ? (
|
||||||
|
<div className="text-[11px] text-txt-tertiary mt-1 tabular-nums">
|
||||||
|
{t('profile.activity.elapsed', { time: formatClock(elapsed) })}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import type { User } from '@backspace/shared';
|
||||||
|
|
||||||
|
import { ProfileAvatar } from './ProfileAvatar';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
|
function makeUser(): User {
|
||||||
|
return {
|
||||||
|
id: 'u-1',
|
||||||
|
username: 'ada',
|
||||||
|
displayName: 'Ada',
|
||||||
|
avatar: null,
|
||||||
|
banner: null,
|
||||||
|
accentColor: null,
|
||||||
|
avatarColor: null,
|
||||||
|
bio: null,
|
||||||
|
status: 'online',
|
||||||
|
customStatus: null,
|
||||||
|
isAdmin: false,
|
||||||
|
createdAt: 0,
|
||||||
|
homeInstance: null,
|
||||||
|
homeUserId: null,
|
||||||
|
replicatedInstances: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubRect(el: Element, rect: Partial<DOMRect>) {
|
||||||
|
vi.spyOn(el, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
top: 0, left: 0, right: 0, bottom: 0, width: 0, height: 0, x: 0, y: 0,
|
||||||
|
toJSON: () => ({}), ...rect,
|
||||||
|
} as DOMRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProfileAvatar', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useUIStore.setState({
|
||||||
|
isMobile: false,
|
||||||
|
userProfilePopout: { user: null, anchor: null, placement: 'right' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens the profile popout anchored to its own box', async () => {
|
||||||
|
const { container } = render(<ProfileAvatar user={makeUser()} name="Ada" size={40} />);
|
||||||
|
const el = container.querySelector('[data-avatar]')!;
|
||||||
|
stubRect(el, { top: 200, left: 100, right: 140, bottom: 240, width: 40, height: 40 });
|
||||||
|
|
||||||
|
await userEvent.click(el);
|
||||||
|
|
||||||
|
const popout = useUIStore.getState().userProfilePopout;
|
||||||
|
expect(popout.user).toMatchObject({ id: 'u-1' });
|
||||||
|
expect(popout.anchor).toMatchObject({ top: 200, left: 100, right: 140, bottom: 240 });
|
||||||
|
expect(popout.placement).toBe('right');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours an explicit placement so callers do not hand-roll offsets', async () => {
|
||||||
|
const { container } = render(<ProfileAvatar user={makeUser()} name="Ada" size={40} placement="left" />);
|
||||||
|
const el = container.querySelector('[data-avatar]')!;
|
||||||
|
stubRect(el, { top: 10, left: 900, right: 940, bottom: 50, width: 40, height: 40 });
|
||||||
|
|
||||||
|
await userEvent.click(el);
|
||||||
|
|
||||||
|
expect(useUIStore.getState().userProfilePopout.placement).toBe('left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('degrades to a plain avatar when the user behind it is unknown', async () => {
|
||||||
|
// Voice tiles and DM intros render before the user record has resolved.
|
||||||
|
const { container } = render(<ProfileAvatar user={undefined} name="?" size={40} />);
|
||||||
|
const el = container.querySelector('[data-avatar]')!;
|
||||||
|
|
||||||
|
await userEvent.click(el);
|
||||||
|
|
||||||
|
expect(useUIStore.getState().userProfilePopout.user).toBeNull();
|
||||||
|
expect(el.className).not.toContain('cursor-pointer');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops the click from reaching an enclosing row handler', async () => {
|
||||||
|
let rowClicks = 0;
|
||||||
|
const { container } = render(
|
||||||
|
<div onClick={() => { rowClicks++; }}>
|
||||||
|
<ProfileAvatar user={makeUser()} name="Ada" size={40} />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
const el = container.querySelector('[data-avatar]')!;
|
||||||
|
stubRect(el, { top: 0, left: 0, right: 40, bottom: 40, width: 40, height: 40 });
|
||||||
|
|
||||||
|
await userEvent.click(el);
|
||||||
|
|
||||||
|
expect(rowClicks).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import type { User } from '@backspace/shared';
|
||||||
|
import { Avatar } from './Avatar';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
import type { Placement } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
|
type AvatarProps = React.ComponentProps<typeof Avatar>;
|
||||||
|
|
||||||
|
interface ProfileAvatarProps extends Omit<AvatarProps, 'onClick' | 'user'> {
|
||||||
|
/** Undefined while the user record is still resolving — the avatar then stays
|
||||||
|
* presentational rather than offering a click that opens nothing. */
|
||||||
|
user?: User;
|
||||||
|
/** Preferred side for the card; it flips automatically when there's no room. */
|
||||||
|
placement?: Placement;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An avatar that opens the profile card for the user it depicts.
|
||||||
|
*
|
||||||
|
* This is deliberately a separate component from `Avatar`: `Avatar` takes a
|
||||||
|
* `user` for the gradient, colour and status dot, and plenty of avatars carry
|
||||||
|
* one without being a profile trigger — the picture inside the profile card
|
||||||
|
* itself, the settings preview, rows inside modals. Folding the behaviour into
|
||||||
|
* `Avatar` made every one of those a trigger by accident, which is what let the
|
||||||
|
* profile card re-anchor to its own picture and walk across the screen.
|
||||||
|
*/
|
||||||
|
export function ProfileAvatar({ user, placement = 'right', ...avatarProps }: ProfileAvatarProps) {
|
||||||
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
|
const handleClick = user
|
||||||
|
? (e: React.MouseEvent) => {
|
||||||
|
// Rows that hold an avatar usually have their own click target (open the
|
||||||
|
// DM, select the member). Opening the profile is the more specific intent.
|
||||||
|
e.stopPropagation();
|
||||||
|
openUserProfile(user, e.currentTarget.getBoundingClientRect(), placement);
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return <Avatar {...avatarProps} user={user} onClick={handleClick} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import type { User } from '@backspace/shared';
|
||||||
|
|
||||||
|
// The popout reaches into the space store (origin routing), the API client and
|
||||||
|
// the federated-mutuals loader. None of that is under test here — stub it so the
|
||||||
|
// test exercises the card's own click and placement behaviour.
|
||||||
|
vi.mock('../../stores/spaceStore', () => ({
|
||||||
|
useSpaceStore: Object.assign(
|
||||||
|
(selector: (s: Record<string, unknown>) => unknown) =>
|
||||||
|
selector({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }),
|
||||||
|
{ getState: () => ({ addDmChannel: vi.fn(), findExistingDmForUser: vi.fn() }) },
|
||||||
|
),
|
||||||
|
getApiForOrigin: () => ({ uploads: { url: (k: string) => `/uploads/${k}` } }),
|
||||||
|
resolveUserOrigin: () => 'local',
|
||||||
|
}));
|
||||||
|
vi.mock('../../api/client', () => ({ api: { dm: { create: vi.fn() } } }));
|
||||||
|
vi.mock('../../utils/mutuals', () => ({
|
||||||
|
loadFederatedMutuals: vi.fn().mockResolvedValue({ mutualFriends: [], mutualSpaces: [] }),
|
||||||
|
}));
|
||||||
|
vi.mock('../../utils/userViewLookup', () => ({ useCanonicalUserView: (u: User) => u }));
|
||||||
|
|
||||||
|
import { UserProfilePopout } from './UserProfilePopout';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
|
const CARD_W = 340;
|
||||||
|
const CARD_H = 420;
|
||||||
|
|
||||||
|
function makeUser(): User {
|
||||||
|
return {
|
||||||
|
id: 'u-1', username: 'ada', displayName: 'Ada', avatar: null, banner: null,
|
||||||
|
accentColor: null, avatarColor: null, bio: null, status: 'online',
|
||||||
|
customStatus: null, isAdmin: false, createdAt: 0, homeInstance: null,
|
||||||
|
homeUserId: null, replicatedInstances: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function anchorAt(left: number, top: number, size = 40) {
|
||||||
|
return { top, left, right: left + size, bottom: top + size, width: size, height: size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function setViewport(width: number, height: number) {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: height, configurable: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(anchor: ReturnType<typeof anchorAt>, placement?: 'left' | 'right') {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<UserProfilePopout user={makeUser()} onClose={() => {}} anchor={anchor} placement={placement} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UserProfilePopout', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setViewport(1920, 1080);
|
||||||
|
useUIStore.setState({
|
||||||
|
isMobile: false,
|
||||||
|
activeModal: null,
|
||||||
|
modalData: {},
|
||||||
|
userProfilePopout: { user: null, anchor: null, placement: 'right' },
|
||||||
|
});
|
||||||
|
// jsdom has no layout: give every element the card's real measured size so
|
||||||
|
// the popout can place itself off its own dimensions.
|
||||||
|
vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({
|
||||||
|
top: 0, left: 0, right: CARD_W, bottom: CARD_H, width: CARD_W, height: CARD_H,
|
||||||
|
x: 0, y: 0, toJSON: () => ({}),
|
||||||
|
} as DOMRect);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not reopen itself when its own picture is clicked (issue #37)', async () => {
|
||||||
|
const { container } = renderCard(anchorAt(300, 200));
|
||||||
|
|
||||||
|
const avatar = container.querySelector('[data-avatar]')!;
|
||||||
|
await userEvent.click(avatar);
|
||||||
|
await userEvent.click(avatar);
|
||||||
|
|
||||||
|
expect(useUIStore.getState().userProfilePopout.user).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escalates to the full profile when the card's picture is clicked", async () => {
|
||||||
|
// The picture is the obvious thing to click for "show me more about this
|
||||||
|
// person". Doing nothing there is a dead end — the only way forward would be
|
||||||
|
// the View Full Profile link.
|
||||||
|
let closed = false;
|
||||||
|
const { container } = render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<UserProfilePopout user={makeUser()} onClose={() => { closed = true; }} anchor={anchorAt(300, 200)} />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await userEvent.click(container.querySelector('[data-avatar]')!);
|
||||||
|
|
||||||
|
expect(useUIStore.getState().activeModal).toBe('userProfile');
|
||||||
|
expect(useUIStore.getState().modalData).toMatchObject({ userId: 'u-1' });
|
||||||
|
expect(closed).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sits beside its anchor', () => {
|
||||||
|
const { container } = renderCard(anchorAt(300, 200));
|
||||||
|
|
||||||
|
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
|
||||||
|
expect(parseFloat(card.style.left)).toBe(340 + 8); // anchor.right + offset
|
||||||
|
expect(parseFloat(card.style.top)).toBe(200); // top-aligned with its anchor
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips to the other side instead of running off the right edge', () => {
|
||||||
|
setViewport(1000, 800);
|
||||||
|
const { container } = renderCard(anchorAt(900, 100));
|
||||||
|
|
||||||
|
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
|
||||||
|
const left = parseFloat(card.style.left);
|
||||||
|
expect(left).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(left + CARD_W).toBeLessThanOrEqual(1000 - 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a tall card on screen when anchored near the bottom', () => {
|
||||||
|
setViewport(1280, 700);
|
||||||
|
const { container } = renderCard(anchorAt(200, 660));
|
||||||
|
|
||||||
|
const card = container.querySelector('[data-user-profile-popout]') as HTMLElement;
|
||||||
|
const top = parseFloat(card.style.top);
|
||||||
|
expect(top).toBeGreaterThanOrEqual(8);
|
||||||
|
expect(top + CARD_H).toBeLessThanOrEqual(700 - 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
@@ -11,14 +11,20 @@ import { getAvatarGradient, adjustColor, mutedGradient } from '../../utils/gradi
|
|||||||
import { parseFederatedUsername } from '../../utils/identity';
|
import { parseFederatedUsername } from '../../utils/identity';
|
||||||
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
import { useCanonicalUserView } from '../../utils/userViewLookup';
|
||||||
import { loadFederatedMutuals } from '../../utils/mutuals';
|
import { loadFederatedMutuals } from '../../utils/mutuals';
|
||||||
|
import { computeFloatingPosition, type AnchorRect, type Placement } from '../../hooks/useFloatingPosition';
|
||||||
|
|
||||||
|
/** Gap between the card and the element it was opened from. */
|
||||||
|
const ANCHOR_OFFSET = 8;
|
||||||
|
|
||||||
interface UserProfilePopoutProps {
|
interface UserProfilePopoutProps {
|
||||||
user: User;
|
user: User;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
position?: { top: number; left: number };
|
/** Rect of the element the card was opened from. */
|
||||||
|
anchor: AnchorRect;
|
||||||
|
placement?: Placement;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserProfilePopout({ user: propUser, onClose, position }: UserProfilePopoutProps) {
|
export function UserProfilePopout({ user: propUser, onClose, anchor, placement = 'right' }: UserProfilePopoutProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
@@ -43,12 +49,38 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
|
|||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, [user.id, user.homeUserId]);
|
}, [user.id, user.homeUserId]);
|
||||||
|
|
||||||
const top = position
|
// Placed off the card's *measured* size rather than a guessed height: the card
|
||||||
? Math.min(Math.max(8, position.top), window.innerHeight - 460)
|
// grows with the bio, the custom status and the mutuals row, so any constant
|
||||||
: undefined;
|
// here would cut tall cards off at the bottom of the viewport.
|
||||||
const left = position
|
const cardRef = useRef<HTMLDivElement>(null);
|
||||||
? Math.min(Math.max(8, position.left), window.innerWidth - 356)
|
const [placed, setPlaced] = useState<{ top: number; left: number } | null>(null);
|
||||||
: undefined;
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const card = cardRef.current;
|
||||||
|
if (!card) return;
|
||||||
|
|
||||||
|
const place = () => {
|
||||||
|
const { width, height } = card.getBoundingClientRect();
|
||||||
|
// 'start': the card's top edge lines up with the row it came from, the
|
||||||
|
// way it always has — centring a tall card on a 32px avatar would drag it
|
||||||
|
// up over unrelated content.
|
||||||
|
const next = computeFloatingPosition(anchor, width, height, placement, ANCHOR_OFFSET, 'start');
|
||||||
|
setPlaced((prev) =>
|
||||||
|
prev && prev.top === next.top && prev.left === next.left
|
||||||
|
? prev
|
||||||
|
: { top: next.top, left: next.left },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
place();
|
||||||
|
const observer = new ResizeObserver(place);
|
||||||
|
observer.observe(card);
|
||||||
|
window.addEventListener('resize', place);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener('resize', place);
|
||||||
|
};
|
||||||
|
}, [anchor, placement]);
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -78,6 +110,11 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
|
|||||||
openModal('userProfile', { userId: user.id, user, origin });
|
openModal('userProfile', { userId: user.id, user, origin });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAvatarClick = (event: React.MouseEvent) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
handleViewFullProfile();
|
||||||
|
};
|
||||||
|
|
||||||
// Banner display
|
// Banner display
|
||||||
const bannerSrc = user.banner
|
const bannerSrc = user.banner
|
||||||
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
|
? (user.banner.startsWith('http') || user.banner.startsWith('/') ? user.banner : userApi.uploads.url(user.banner))
|
||||||
@@ -89,12 +126,17 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
|
|||||||
return mutedGradient(g.from, g.to);
|
return mutedGradient(g.from, g.to);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
// Parked off-screen for the one layout pass before the card knows how tall it
|
||||||
|
// is; `useLayoutEffect` places it before the browser paints, so it never
|
||||||
|
// renders visibly in the wrong spot.
|
||||||
|
const cardStyle = placed ?? { top: -9999, left: -9999 };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={cardRef}
|
||||||
|
data-user-profile-popout
|
||||||
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
|
className="fixed z-[200] w-[340px] rounded-[12px] overflow-hidden animate-fade-in select-none glass-modal"
|
||||||
style={position
|
style={cardStyle}
|
||||||
? { top, left }
|
|
||||||
: { top: '50%', left: '50%', transform: 'translate(-50%, -50%)' }}
|
|
||||||
>
|
>
|
||||||
{/* Banner */}
|
{/* Banner */}
|
||||||
<div
|
<div
|
||||||
@@ -108,6 +150,9 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-4 pb-4 relative">
|
<div className="px-4 pb-4 relative">
|
||||||
{/* Avatar */}
|
{/* Avatar */}
|
||||||
|
{/* The picture escalates to the full profile — the card is a preview, and
|
||||||
|
clicking the face is the obvious way to ask for the whole thing. It
|
||||||
|
deliberately does NOT reopen the card (see issue #37). */}
|
||||||
<Avatar
|
<Avatar
|
||||||
src={user.avatar}
|
src={user.avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
@@ -115,6 +160,7 @@ export function UserProfilePopout({ user: propUser, onClose, position }: UserPro
|
|||||||
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
status={user.status as 'online' | 'idle' | 'dnd' | 'offline' | null}
|
||||||
userId={user.homeUserId ?? user.id}
|
userId={user.homeUserId ?? user.id}
|
||||||
user={user}
|
user={user}
|
||||||
|
onClick={handleAvatarClick}
|
||||||
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
|
ring={{ width: 4, color: 'rgba(20,20,26,0.85)' }}
|
||||||
className="mt-[-44px] mb-3"
|
className="mt-[-44px] mb-3"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -199,6 +209,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
|
|||||||
<VoiceUserRow
|
<VoiceUserRow
|
||||||
userId={member?.user.homeUserId ?? userId}
|
userId={member?.user.homeUserId ?? userId}
|
||||||
displayName={displayName}
|
displayName={displayName}
|
||||||
|
user={member?.user}
|
||||||
avatar={avatar}
|
avatar={avatar}
|
||||||
avatarColor={avatarColor ?? undefined}
|
avatarColor={avatarColor ?? undefined}
|
||||||
isMuted={isMuted}
|
isMuted={isMuted}
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import React, { useState, useRef } from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
|
import { useChatStore } from '../../stores/chatStore';
|
||||||
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
|
||||||
import { getActiveRoom } from '../../hooks/useLiveKit';
|
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';
|
||||||
@@ -16,6 +19,11 @@ import { handleCameraAction } from '../../utils/voiceActions';
|
|||||||
*/
|
*/
|
||||||
export function VoiceControls() {
|
export function VoiceControls() {
|
||||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||||
|
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
|
||||||
|
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
|
||||||
|
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [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);
|
||||||
@@ -31,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;
|
||||||
@@ -42,7 +54,20 @@ export function VoiceControls() {
|
|||||||
if (!currentVoiceChannelId && !activeDmCall) return null;
|
if (!currentVoiceChannelId && !activeDmCall) return null;
|
||||||
|
|
||||||
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
const channel = channels.find(c => c.id === currentVoiceChannelId);
|
||||||
const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
|
const channelName =
|
||||||
|
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
|
||||||
|
|
||||||
|
// Jump back to where the call is happening. DM calls live under @me; space
|
||||||
|
// calls under the space captured at join time.
|
||||||
|
const callSpaceId = activeDmCall ? '@me' : currentVoiceSpaceId;
|
||||||
|
const callChannelId = activeDmCall ? activeDmCall.dmChannelId : currentVoiceChannelId;
|
||||||
|
const canGoToCall = Boolean(callChannelId && callSpaceId && !connectionError);
|
||||||
|
|
||||||
|
const handleGoToCall = () => {
|
||||||
|
if (!canGoToCall || !callChannelId) return;
|
||||||
|
setCurrentChannel(callChannelId);
|
||||||
|
navigate(`/channels/${callSpaceId}/${callChannelId}`);
|
||||||
|
};
|
||||||
|
|
||||||
const handleScreenShare = async () => {
|
const handleScreenShare = async () => {
|
||||||
const room = getActiveRoom();
|
const room = getActiveRoom();
|
||||||
@@ -100,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
|
||||||
@@ -120,9 +158,19 @@ export function VoiceControls() {
|
|||||||
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
|
||||||
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
|
{canGoToCall ? (
|
||||||
{connectionError ? connectionError : channelName}
|
<button
|
||||||
</div>
|
onClick={handleGoToCall}
|
||||||
|
title="Go to call"
|
||||||
|
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{channelName}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
|
||||||
|
{connectionError ? connectionError : channelName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||||
@@ -187,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}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||||
import { useVoiceStore } from '../../stores/voiceStore';
|
import { useVoiceStore } from '../../stores/voiceStore';
|
||||||
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
|
||||||
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
|
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
||||||
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
|
||||||
import type { UserTile } from '../../hooks/useLiveKit';
|
import type { UserTile } from '../../hooks/useLiveKit';
|
||||||
@@ -34,6 +35,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
const isLocal = participant.isLocal;
|
const isLocal = participant.isLocal;
|
||||||
const avatarUserId = participant.homeUserId ?? participant.userId;
|
const avatarUserId = participant.homeUserId ?? participant.userId;
|
||||||
const { displayName, avatar, user } = useVoiceParticipantMeta(participant);
|
const { displayName, avatar, user } = useVoiceParticipantMeta(participant);
|
||||||
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
// --- VIDEO & UI ---
|
// --- VIDEO & UI ---
|
||||||
|
|
||||||
@@ -160,7 +162,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
) : (
|
) : (
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel">
|
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel">
|
||||||
<div className="relative flex">
|
<div className="relative flex">
|
||||||
<Avatar
|
<ProfileAvatar
|
||||||
src={avatar}
|
src={avatar}
|
||||||
name={displayName}
|
name={displayName}
|
||||||
size={large ? 100 : 64}
|
size={large ? 100 : 64}
|
||||||
@@ -177,11 +179,25 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
|
|||||||
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
|
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
<span
|
{user ? (
|
||||||
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
|
// The tile's avatar already opens the profile card; the name
|
||||||
>
|
// sitting next to it did not, which read as the click failing.
|
||||||
{displayName}
|
<button
|
||||||
</span>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
|
||||||
|
}}
|
||||||
|
className={`font-semibold text-white truncate text-left hover:underline ${large ? 'text-base' : 'text-[13px]'}`}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{isLocal && (
|
{isLocal && (
|
||||||
<span className="text-[10px] text-white/40 font-medium">
|
<span className="text-[10px] text-white/40 font-medium">
|
||||||
(you)
|
(you)
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||||
|
import type { User } from '@backspace/shared';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
|
import { ProfileAvatar } from '../ui/ProfileAvatar';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
|
|
||||||
export interface VoiceUserRowProps {
|
export interface VoiceUserRowProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
/**
|
||||||
|
* Full record for the participant, when it has resolved. Without it the row
|
||||||
|
* stays presentational rather than offering a click that opens nothing —
|
||||||
|
* the same contract ProfileAvatar documents.
|
||||||
|
*/
|
||||||
|
user?: User;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
avatarColor?: string;
|
avatarColor?: string;
|
||||||
isMuted?: boolean;
|
isMuted?: boolean;
|
||||||
@@ -22,6 +32,7 @@ export interface VoiceUserRowProps {
|
|||||||
export function VoiceUserRow({
|
export function VoiceUserRow({
|
||||||
userId,
|
userId,
|
||||||
displayName,
|
displayName,
|
||||||
|
user,
|
||||||
avatar,
|
avatar,
|
||||||
avatarColor,
|
avatarColor,
|
||||||
isMuted,
|
isMuted,
|
||||||
@@ -38,6 +49,15 @@ export function VoiceUserRow({
|
|||||||
className = '',
|
className = '',
|
||||||
}: VoiceUserRowProps) {
|
}: VoiceUserRowProps) {
|
||||||
const avatarSize = size === 'compact' ? 20 : 24;
|
const avatarSize = size === 'compact' ? 20 : 24;
|
||||||
|
const openUserProfile = useUIStore((s) => s.openUserProfile);
|
||||||
|
|
||||||
|
const openProfile = (e: ReactMouseEvent) => {
|
||||||
|
if (!user) return;
|
||||||
|
// The row sits inside a channel that has its own click target (join the
|
||||||
|
// call). Opening the profile is the more specific intent.
|
||||||
|
e.stopPropagation();
|
||||||
|
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
|
||||||
|
};
|
||||||
|
|
||||||
// Mic icon priority: server muted/deafened/permission muted (amber) > self-muted (danger)
|
// Mic icon priority: server muted/deafened/permission muted (amber) > self-muted (danger)
|
||||||
const showServerMicIcon = isServerMuted || isServerDeafened || isPermissionMuted;
|
const showServerMicIcon = isServerMuted || isServerDeafened || isPermissionMuted;
|
||||||
@@ -49,17 +69,38 @@ export function VoiceUserRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center gap-2 ${className}`}>
|
<div className={`flex items-center gap-2 ${className}`}>
|
||||||
<Avatar
|
{user ? (
|
||||||
src={avatar}
|
<ProfileAvatar
|
||||||
name={displayName}
|
src={avatar}
|
||||||
size={avatarSize}
|
name={displayName}
|
||||||
userId={userId}
|
size={avatarSize}
|
||||||
avatarColor={avatarColor}
|
userId={userId}
|
||||||
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
|
user={user}
|
||||||
/>
|
avatarColor={avatarColor}
|
||||||
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">
|
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
|
||||||
{displayName}
|
/>
|
||||||
</span>
|
) : (
|
||||||
|
<Avatar
|
||||||
|
src={avatar}
|
||||||
|
name={displayName}
|
||||||
|
size={avatarSize}
|
||||||
|
userId={userId}
|
||||||
|
avatarColor={avatarColor}
|
||||||
|
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{user ? (
|
||||||
|
<button
|
||||||
|
onClick={openProfile}
|
||||||
|
className="text-[13px] text-txt-secondary truncate flex-1 min-w-0 text-left hover:text-txt-primary hover:underline"
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{/* Status badges */}
|
{/* Status badges */}
|
||||||
<div className="flex items-center gap-1 flex-shrink-0">
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
{/* Server muted / space deafened / permission muted — amber mic with slash */}
|
{/* Server muted / space deafened / permission muted — amber mic with slash */}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { computeFloatingPosition } from './useFloatingPosition';
|
||||||
|
|
||||||
|
function rect(left: number, top: number, w = 40, h = 40): DOMRect {
|
||||||
|
return { left, top, right: left + w, bottom: top + h, width: w, height: h, x: left, y: top, toJSON: () => ({}) } as DOMRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('computeFloatingPosition', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { value: 1000, configurable: true });
|
||||||
|
Object.defineProperty(window, 'innerHeight', { value: 800, configurable: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('places a right-anchored surface just past the anchor', () => {
|
||||||
|
expect(computeFloatingPosition(rect(100, 100), 340, 420, 'right', 8)).toMatchObject({
|
||||||
|
left: 148,
|
||||||
|
actualPlacement: 'right',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('flips left when the surface would overflow the right edge', () => {
|
||||||
|
const pos = computeFloatingPosition(rect(900, 100), 340, 420, 'right', 8);
|
||||||
|
expect(pos.actualPlacement).toBe('left');
|
||||||
|
expect(pos.left).toBe(900 - 340 - 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aligns to the anchor's leading edge when asked, instead of centring on it", () => {
|
||||||
|
// The profile card lines its top edge up with the row it was opened from;
|
||||||
|
// centring a 420px card on a 32px avatar would drag it far up the screen.
|
||||||
|
const pos = computeFloatingPosition(rect(100, 300), 340, 420, 'right', 8, 'start');
|
||||||
|
expect(pos.top).toBe(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('centres on the anchor by default', () => {
|
||||||
|
const pos = computeFloatingPosition(rect(100, 300), 340, 420, 'right', 8);
|
||||||
|
expect(pos.top).toBe(300 + 20 - 210);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clamps a surface taller than the space below its anchor', () => {
|
||||||
|
const pos = computeFloatingPosition(rect(100, 700), 340, 420, 'right', 8);
|
||||||
|
expect(pos.top + 420).toBeLessThanOrEqual(800 - 8);
|
||||||
|
expect(pos.top).toBeGreaterThanOrEqual(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,23 @@
|
|||||||
import { type RefObject, type CSSProperties, useState, useLayoutEffect, useCallback } from 'react';
|
import { type RefObject, type CSSProperties, useState, useLayoutEffect, useCallback } from 'react';
|
||||||
|
|
||||||
type Placement = 'top' | 'bottom' | 'left' | 'right';
|
export type Placement = 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
/** Cross-axis alignment: centred on the anchor, or flush with its leading edge. */
|
||||||
|
export type Alignment = 'center' | 'start';
|
||||||
|
|
||||||
|
/** The subset of DOMRect placement needs — lets callers pass a stored rect. */
|
||||||
|
export interface AnchorRect {
|
||||||
|
top: number;
|
||||||
|
left: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A zero-size anchor at a viewport point, for callers with no anchor element. */
|
||||||
|
export function pointAnchor(x: number, y: number): AnchorRect {
|
||||||
|
return { top: y, left: x, right: x, bottom: y, width: 0, height: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
interface UseFloatingPositionOptions {
|
interface UseFloatingPositionOptions {
|
||||||
placement: Placement;
|
placement: Placement;
|
||||||
@@ -22,12 +39,22 @@ const oppositePlacement: Record<Placement, Placement> = {
|
|||||||
right: 'left',
|
right: 'left',
|
||||||
};
|
};
|
||||||
|
|
||||||
function computePosition(
|
/**
|
||||||
anchorRect: DOMRect,
|
* Places a floating surface next to an anchor: preferred side first, flipping to
|
||||||
|
* the opposite side when it would overflow, then clamping into the viewport.
|
||||||
|
*
|
||||||
|
* Exported because not every floating surface has a live anchor element. The
|
||||||
|
* profile popout, for instance, is opened from a store and keeps only the
|
||||||
|
* anchor's rect — the row it came from may have re-rendered or scrolled away by
|
||||||
|
* the time the card mounts.
|
||||||
|
*/
|
||||||
|
export function computeFloatingPosition(
|
||||||
|
anchorRect: AnchorRect,
|
||||||
floatingWidth: number,
|
floatingWidth: number,
|
||||||
floatingHeight: number,
|
floatingHeight: number,
|
||||||
placement: Placement,
|
placement: Placement,
|
||||||
offset: number,
|
offset: number,
|
||||||
|
align: Alignment = 'center',
|
||||||
): { top: number; left: number; actualPlacement: Placement } {
|
): { top: number; left: number; actualPlacement: Placement } {
|
||||||
const vw = window.innerWidth;
|
const vw = window.innerWidth;
|
||||||
const vh = window.innerHeight;
|
const vh = window.innerHeight;
|
||||||
@@ -37,17 +64,24 @@ function computePosition(
|
|||||||
let actual = placement;
|
let actual = placement;
|
||||||
|
|
||||||
// Compute initial position on primary axis
|
// Compute initial position on primary axis
|
||||||
|
const crossLeft = align === 'start'
|
||||||
|
? anchorRect.left
|
||||||
|
: anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
|
||||||
|
const crossTop = align === 'start'
|
||||||
|
? anchorRect.top
|
||||||
|
: anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
|
||||||
|
|
||||||
if (placement === 'top') {
|
if (placement === 'top') {
|
||||||
top = anchorRect.top - floatingHeight - offset;
|
top = anchorRect.top - floatingHeight - offset;
|
||||||
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
|
left = crossLeft;
|
||||||
} else if (placement === 'bottom') {
|
} else if (placement === 'bottom') {
|
||||||
top = anchorRect.bottom + offset;
|
top = anchorRect.bottom + offset;
|
||||||
left = anchorRect.left + anchorRect.width / 2 - floatingWidth / 2;
|
left = crossLeft;
|
||||||
} else if (placement === 'left') {
|
} else if (placement === 'left') {
|
||||||
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
|
top = crossTop;
|
||||||
left = anchorRect.left - floatingWidth - offset;
|
left = anchorRect.left - floatingWidth - offset;
|
||||||
} else {
|
} else {
|
||||||
top = anchorRect.top + anchorRect.height / 2 - floatingHeight / 2;
|
top = crossTop;
|
||||||
left = anchorRect.right + offset;
|
left = anchorRect.right + offset;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +147,7 @@ export function useFloatingPosition(
|
|||||||
const anchorRect = anchor.getBoundingClientRect();
|
const anchorRect = anchor.getBoundingClientRect();
|
||||||
const floatingRect = floating.getBoundingClientRect();
|
const floatingRect = floating.getBoundingClientRect();
|
||||||
|
|
||||||
const pos = computePosition(
|
const pos = computeFloatingPosition(
|
||||||
anchorRect,
|
anchorRect,
|
||||||
floatingRect.width,
|
floatingRect.width,
|
||||||
floatingRect.height,
|
floatingRect.height,
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -574,14 +586,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,141 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
|
||||||
|
// 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,137 @@
|
|||||||
|
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',
|
||||||
|
|
||||||
|
// 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(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { persist, createJSONStorage } from 'zustand/middleware';
|
import { persist, createJSONStorage } from 'zustand/middleware';
|
||||||
import type { User } from '@backspace/shared';
|
import type { User } from '@backspace/shared';
|
||||||
|
import type { AnchorRect, Placement } from '../hooks/useFloatingPosition';
|
||||||
|
|
||||||
type ModalType =
|
type ModalType =
|
||||||
| 'createSpace'
|
| 'createSpace'
|
||||||
@@ -40,7 +41,11 @@ interface UIState {
|
|||||||
imagePreviewUrl: string | null;
|
imagePreviewUrl: string | null;
|
||||||
userProfilePopout: {
|
userProfilePopout: {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
position: { top: number; left: number } | null;
|
/** Rect of the element the card was opened from. The card places itself off
|
||||||
|
* this rect once it knows its own measured size — callers never compute
|
||||||
|
* coordinates, so no surface can drift by re-anchoring to itself. */
|
||||||
|
anchor: AnchorRect | null;
|
||||||
|
placement: Placement;
|
||||||
};
|
};
|
||||||
toasts: Toast[];
|
toasts: Toast[];
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
@@ -51,7 +56,7 @@ interface UIState {
|
|||||||
setShowDms: (show: boolean) => void;
|
setShowDms: (show: boolean) => void;
|
||||||
openImagePreview: (url: string) => void;
|
openImagePreview: (url: string) => void;
|
||||||
closeImagePreview: () => void;
|
closeImagePreview: () => void;
|
||||||
openUserProfile: (user: User, position: { top: number; left: number }) => void;
|
openUserProfile: (user: User, anchor: AnchorRect, placement?: Placement) => void;
|
||||||
closeUserProfile: () => void;
|
closeUserProfile: () => void;
|
||||||
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
|
addToast: (message: string, type?: 'info' | 'warning' | 'success', duration?: number) => void;
|
||||||
removeToast: (id: string) => void;
|
removeToast: (id: string) => void;
|
||||||
@@ -93,7 +98,8 @@ export const useUIStore = create<UIState>()(
|
|||||||
imagePreviewUrl: null,
|
imagePreviewUrl: null,
|
||||||
userProfilePopout: {
|
userProfilePopout: {
|
||||||
user: null,
|
user: null,
|
||||||
position: null,
|
anchor: null,
|
||||||
|
placement: 'right',
|
||||||
},
|
},
|
||||||
toasts: [],
|
toasts: [],
|
||||||
|
|
||||||
@@ -120,7 +126,7 @@ export const useUIStore = create<UIState>()(
|
|||||||
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
openImagePreview: (url) => set({ activeModal: 'imagePreview', imagePreviewUrl: url }),
|
||||||
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
closeImagePreview: () => set({ activeModal: null, imagePreviewUrl: null }),
|
||||||
|
|
||||||
openUserProfile: (user, position) => {
|
openUserProfile: (user, anchor, placement = 'right') => {
|
||||||
if (get().isMobile) {
|
if (get().isMobile) {
|
||||||
// On mobile, push a full-screen user profile instead of a positioned popout
|
// On mobile, push a full-screen user profile instead of a positioned popout
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
@@ -128,11 +134,11 @@ export const useUIStore = create<UIState>()(
|
|||||||
}));
|
}));
|
||||||
history.pushState({ mobileScreen: 'user-profile' }, '');
|
history.pushState({ mobileScreen: 'user-profile' }, '');
|
||||||
} else {
|
} else {
|
||||||
set({ userProfilePopout: { user, position } });
|
set({ userProfilePopout: { user, anchor, placement } });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
closeUserProfile: () => set({
|
closeUserProfile: () => set({
|
||||||
userProfilePopout: { user: null, position: null }
|
userProfilePopout: { user: null, anchor: null, placement: 'right' }
|
||||||
}),
|
}),
|
||||||
|
|
||||||
addToast: (message, type = 'info', duration = 5000) => {
|
addToast: (message, type = 'info', duration = 5000) => {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user