73 Commits
Author SHA1 Message Date
devsyncwrldandClaude Opus 5 c4158ee245 docs: retire the fixes that shipped and were confirmed in use
CI / Build & test (Node 20) (push) Waiting to run
CI / Build & test (Node 24) (push) Waiting to run
CI / Build & test (push) Blocked by required conditions
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
Security / Secret scan (gitleaks) (push) Waiting to run
Security / Dependency scan (OSV-Scanner) (push) Waiting to run
Security / IaC/config scan (Trivy) (push) Waiting to run
Security / License compliance scan (Trivy) (push) Waiting to run
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 17:13:30 -03:00
devsyncwrld 61c68761df i18n: translate the channel and member sidebars
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Clears the two remaining rounds owed under the fork's per-update translation
rule.

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

The member grouping needed care: one function returns either a fixed label or
a role's own name, and role names are user data that must never be translated.
It now marks only the fixed cases and the component resolves them at render,
leaving role names untouched. The function is not a component, so it cannot
call the hook itself.
2026-09-01 17:03:48 -03:00
devsyncwrld c04b8b47eb i18n: translate the chat composer and messages
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Catching up on the fork's rule that every update also translates one existing
system — three updates went by without one.

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

The translation hook is bound to `tr` in these two files, not `t`: both already
use `t` for transfer records in inner scopes, which shadowed the function and
made it uncallable exactly where it was needed.
2026-09-01 16:59:50 -03:00
devsyncwrld ce6bba0510 fix(ci): pulseaudio headers, and an ASCII release payload
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Linux got past pipewire and stopped on pulse/pulseaudio.h — the module
compiles both backends, so both sets of headers are needed.

Windows reached the upload and Gitea rejected the release creation with HTTP
422, 'invalid UTF-8 within /name': the JSON body carried accented text and the
Windows runner's shell mangles the encoding on the way out. The payload is now
ASCII, with a comment saying why, so it does not get 'improved' back into
Portuguese.
2026-09-01 16:04:01 -03:00
devsyncwrld 9ea399ded5 fix(ci): install pipewire headers, and make the Gitea upload diagnosable
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Two separate failures on the first run.

Linux could not compile the native module: it needs libpipewire-0.3-dev, which
its README states and the apt list omitted.

Windows built everything, native module verified, then died on the upload with
a bare JSONDecodeError. The cause was curl -sf, which discards the error body,
so a failed release creation surfaced as an empty pipe and no reason at all.
The step now captures status and body and prints them, treats a failed create
as possibly the other matrix job having just created it, and passes
target_commitish so the tag can be created.

Embedded python gave way to jq: a multi-line heredoc inside a YAML literal
block ends the block at the first unindented line, which is how the file became
invalid YAML in the first place.
2026-09-01 15:58:26 -03:00
devsyncwrld 9f7723d104 ci: build on GitHub, publish updates from Gitea
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
GitHub stays the build machine — it has the Windows runners the native audio
module needs — but the update feed moves to this fork's own Gitea.

The GitHub repository is private, and electron-updater against a private GitHub
repo needs a token inside the shipped app, which is a leaked token. Gitea
serves release assets to anyone, so the installer carries no credential. The
flow was verified end to end against the live instance before writing this:
create release, upload asset, download anonymously.

The release tag is fixed at 'latest' because electron-updater fetches
latest.yml before it knows which version exists, so the URL cannot carry a
version; CI replaces that release's assets each publish. electron-builder runs
with --publish never since it cannot upload to Gitea, but still emits the
latest.yml the updater reads.

Needs a GITEA_TOKEN secret on the GitHub repository.
2026-09-01 15:49:25 -03:00
devsyncwrld 5b84843217 fix(screenshare): make shared system audio audible
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Listeners had to run the participant at 200% with attenuation off. Two causes,
both on the publishing side.

WASAPI returns the process mix at a level well below what a microphone track
arrives at, so the track was quiet before it ever left the machine. A gain
stage now boosts it, followed by a limiter — a compressor with a high ratio and
fast attack — so the boost cannot clip loud passages.

And the track was published with LiveKit's defaults, which are tuned for
speech: mono, low bitrate, and DTX enabled, which stops transmitting during
silence. On game and music audio that reads as thin and clipped. It now
publishes with the music stereo preset, stereo forced, and DTX and RED off.

Lives in packages/web, so it reaches the desktop app through a normal deploy —
no new installer.
2026-09-01 15:36:55 -03:00
devsyncwrld bd8decb4b3 fix(ci): actually compile the native audio module, and prove it
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
The previous run went green and produced an installer with no system-audio
capture in it — the exact silent failure this module exists to prevent.

Two causes. electron-rebuild takes -w as one comma-separated list, not a
repeated flag; passing it twice made argv.w an array and the CLI threw
'argv.w.split is not a function', which also broke uiohook-napi's rebuild that
had been working. And pnpm 10 refuses to run a dependency's build script unless
it is listed in onlyBuiltDependencies, so node-gyp never ran for it at all —
the log said 'Ignored build scripts' and nothing else complained.

Neither surfaced because desktop's postinstall ends in , which
exists so contributors without build tools can install. That is reasonable
locally and dangerous in CI, so the workflow now asserts a compiled .node
exists and fails loudly when it does not, instead of trusting an exit code that
was designed to lie.
2026-09-01 15:13:26 -03:00
devsyncwrld 52f43d4d61 build: record electron-native-screenshare in the lockfile
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
The dependency was added to packages/desktop/package.json without regenerating
the lockfile, so CI's --frozen-lockfile refused the install — correctly: a
build that resolves fresh versions is a build that differs from what was
tested.
2026-09-01 15:06:59 -03:00
devsyncwrld 7eaba5e3f3 ci: build only the architectures this group runs
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Windows arm64 doubled the slowest job in the matrix — two Electron
distributions downloaded, native modules compiled twice — and nobody here runs
Windows on ARM. Linux arm64 went for the same reason.

Removing arm64 also retires the fpm step: electron-builder's bundled fpm is
x86_64-only and failed on the arm64 runner, which is the only reason a native
fpm was installed (ruby, ruby-dev, build-essential and a gem with native
extensions, on every run). On an x86_64 runner the bundled one works.

Matrix is now Windows x64 and Linux x64.
2026-09-01 14:02:54 -03:00
devsyncwrld 124db82a0c ci: drop the macOS build from the release matrix
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Nobody in this group runs macOS, and on a private repository the macOS runner
bills at 10x — it was the most expensive platform in the matrix, compiled on
every tag for zero users.

This edits a file inherited from upstream, so it will conflict on merges. That
is the accepted cost: the alternative is paying for a macOS build indefinitely.
2026-09-01 14:00:12 -03:00
devsyncwrld b4d001eb26 ci: add a fast Windows-only desktop build
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
release.yml builds four platforms and publishes a release, which is right for a
release and slow for 'give me an exe to test'. Wall time is set by the Windows
job, which compiles native modules and downloads Electron twice because it
targets x64 and arm64 together — and Windows-on-ARM is not something this group
runs.

This path builds one architecture, caches the Electron and electron-builder
downloads (~100MB re-fetched every run otherwise), and uploads the installer as
an artifact, so it needs neither a tag nor a version bump.

Kept as a separate file: release.yml comes from upstream and takes merges, so
editing it would conflict on every update.

Cost matters too on a private repository: macOS runners bill at 10x and Windows
at 2x, so the full matrix was paying for macOS builds on every tag.
2026-09-01 13:29:18 -03:00
devsyncwrld 60c51a1635 chore: version 1.1.0
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
First release built from the fork. electron-builder names artifacts and the
GitHub release from package.json rather than the git tag, so the two have to
agree or electron-updater compares mismatched versions and never offers the
update. v1.0.0 is already taken by upstream's tag in this history.
2026-09-01 13:15:13 -03:00
devsyncwrld d208b0c277 build(desktop): publish releases to syncwrld/resenhacord
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
2026-09-01 13:14:43 -03:00
devsyncwrld 2f836cd366 build(desktop): correct the fork's GitHub owner to syncwrld
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
2026-09-01 13:11:49 -03:00
devsyncwrld f7f2cf75ab build(desktop): point releases at the fork and rebuild the new native module
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
release.yml already builds and publishes desktop installers for every platform
on a v* tag, so no new workflow was needed — it needed adapting to the fork.

electron-builder published to upstream's repository, so releases (and with them
the electron-updater feed, which had no source at all) went nowhere useful.

More consequential: postinstall ran electron-rebuild against uiohook-napi only.
electron-native-screenshare would have been compiled for Node's ABI rather than
Electron's and failed to load at runtime — and since the loader degrades
quietly by design, the symptom would have been screen sharing with no sound and
no error, which is the exact bug this module exists to fix.

Documented in docs/build-desktop.md, including why the build has to run on
GitHub (Gitea Actions provides no hosted runners, and the module needs MSVC)
and why the Windows runner stays pinned to windows-2022.
2026-09-01 13:10:10 -03:00
devsyncwrld 3deed92dd9 feat(sounds): replace call and stream effects
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
The originals were all 1.14s — long enough to be intrusive for events that
fire whenever anyone joins, leaves, or watches a stream.

Synthesised rather than sourced: no third-party file, so no licensing question
in a public repository. The parameters are measured, not guessed — envelope,
spectral peaks and decay taken from two reference sounds the instance owner
supplied. The timbre is fundamental plus octave at near-equal strength
(1.00 / 0.85 / 0.10 / 0.02) decaying to 1/e in 0.19s, with no reverb.

Call join rises C4→G4, leave falls D4→G3, matching the references' intervals
and their 100ms spacing. The stream pair reuses those resolution notes as
single tones at ~55% the volume: they fire far more often during a broadcast,
so they have to sit under the call sounds rather than beside them.

Stream start is the one event that happens once per broadcast, so it can be a
chord — with a low thump and a short air layer that both die inside 150ms,
adding weight without length. Stream end mirrors it descending, quieter, and
without the air, since brightness reads as arrival.

Generators and the measured parameters are kept in tools/sfx so these can be
retuned without redoing the analysis. Total size drops from 150KB to 41KB.
2026-09-01 13:01:06 -03:00
devsyncwrld d525bbb8c5 feat(desktop): capture system audio with process isolation
Electron's audio: 'loopback' captures the whole output mix, this app's own
playback included — so everyone else's voices went back out inside the share
and each listener heard themselves. Not acoustic echo but a digital copy of
the output, which is why headphones never helped, and why shareAudio already
defaulted to off in the desktop app.

Electron offers no way to exclude our own audio: the docs allow only
'loopback' or 'loopbackWithMute', and the handler discards the renderer's
constraints (restrictOwnAudio never arrives). electron-native-screenshare does
it at the OS level — WASAPI process loopback on Windows — capturing only the
shared window when its pid resolves, and otherwise everything except us.

The module hands raw PCM to the main process, so it crosses IPC and is
scheduled onto a running cursor in Web Audio to become a MediaStreamTrack,
published as ScreenShareAudio. Loading is optional and failure degrades to a
silent share rather than blocking the app or the screen share.

The browser path is untouched: Chrome honours restrictOwnAudio and has no echo.

Verified by typecheck (web and Electron main) and the web suite. The audio path
itself cannot be exercised here — no Windows, no Electron, no audio device.
2026-09-01 00:56:34 -03:00
devsyncwrld 310e9b86be docs: diagnose the screen-share audio echo
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
2026-08-31 22:58:59 -03:00
devsyncwrld e291b5e411 docs: drop delivered rows from the pending sections
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
2026-08-31 22:40:44 -03:00
devsyncwrld c899253e52 fix: soundboard upload in Electron, and Spotify sync/disappearing/progress
Soundboard: naming a clip used window.prompt, which Electron does not
implement — it returned nothing, the flow aborted in silence, and adding a
sound worked in the browser while doing nothing at all in the desktop app.
Replaced with a two-step field inside the popover, identical in both.

Spotify, three separate defects behind the two symptoms reported:

Out of sync — a 20s poll stacked on the activity store's 5s debounce left
everyone else on the previous track for up to 25s. The next poll is now
scheduled just past the current track's end instead of on a fixed interval,
and a track change bypasses the debounce (it happens once every few minutes;
the debounce exists for chatty producers).

Vanishing — a paused track, and the silent gap Spotify reports between two
songs, both cleared the activity outright. Pausing is now carried as state
rather than absence, and an empty answer is tolerated for 25s before the
block comes down.

Progress bar — timestamps are computed with the server's clock and were drawn
against the viewer's, so any drift displaced the bar; and it kept advancing
after a pause until the next poll. The ready payload now carries server time
so each client can correct its own offset, and the bar freezes when paused.

Tray, native notifications and system audio in screen share were all found
already implemented and wired end to end; recorded in the roadmap rather than
built again.
2026-08-31 22:39:28 -03:00
devsyncwrld ff55d9d486 docs: plan desktop features and diagnose the Spotify sync bugs
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 14:02:21 -03:00
devsyncwrld e58021408c docs: record the Electron window.prompt soundboard bug
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 14:00:41 -03:00
devsyncwrld 2afe3453f0 docs: record four approved features not yet started
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:57:01 -03:00
devsyncwrld e89966435a fix(soundboard): read MANAGE_SPACE from the space bitfield
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The add-sound control was gated on channelPermissions, which carries the
per-channel bitfield; MANAGE_SPACE lives in the space-level one. The check
silently evaluated false for everybody, including owners, so the button never
rendered and there was no way to add a sound at all.

Also raise the clip cap to 2 MB — a 30-second clip at a high bitrate cleared
1 MB — and stop reporting every upload failure as 'too large', which sent
people to shrink a file that was not the problem.
2026-08-31 13:54:00 -03:00
devsyncwrld f5451e1b14 feat: soundboard, account menu, and call timer
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.

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

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

Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
2026-08-31 13:45:18 -03:00
devsyncwrld ef5545465d docs: mark GIF favourites, audit log and statistics delivered
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:30:26 -03:00
devsyncwrld 1830051732 feat(stats): voice-time and message leaderboards per space
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Voice stays get their own table rather than joining the audit log: that table
records points in time, a call is an interval, and pairing join/leave point
events would leave every query guessing at joins whose leave never arrived.

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

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

Only closed sessions count, so a figure does not move on every refresh. Bars
scale to the leader, not the total — with five people every share of a total
looks identical. Statistics are readable by any member, since they are the
group's own numbers; the audit log, which names who did what, stays on
MANAGE_SPACE.
2026-08-31 13:29:59 -03:00
devsyncwrld bbb190cbda feat(audit): append-only audit log for spaces
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one
event table rather than two logs that drift apart.

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

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

Actor is nullable with ON DELETE SET NULL: the event outlives the account, and
a log that vanished with its actor would be worthless. Reads are gated on
MANAGE_SPACE rather than a new permission bit, which would default to nobody
until every role was re-edited. Paging uses the snowflake id, stable even for
two events in the same millisecond, and an action this build does not know
still renders a row.
2026-08-31 13:22:52 -03:00
devsyncwrld fb662bfe12 feat(gif): favourites and category shortcuts
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
Favourites are stored server-side per user, so one made on the phone is there
on the desktop — the point of favouriting. The whole result is stored rather
than an id: the provider offers no lookup by id, so an id-only favourite could
not be rendered without re-finding it through search.

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

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

Server caps favourites per user and rejects non-http(s) URLs, which become
<img src> in everyone's picker.
2026-08-31 13:12:50 -03:00
devsyncwrld 0fc6abeb6e docs: mark Spotify delivered, drop resolved items
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 13:04:35 -03:00
devsyncwrld 75316b0882 feat(spotify): show the current track as an activity
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
OAuth Authorization Code flow, with tokens kept server-side: refreshing needs
the client secret, so the browser never holds a Spotify token — it asks this
instance what is playing and this instance calls Spotify.

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

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

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

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

Per the fork's language rule, the new UI ships in en and pt-BR, and this
round also translates the privacy panel.
2026-08-31 12:37:08 -03:00
devsyncwrld c022f2795f docs: record the i18n foundation and translation queue
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:25:03 -03:00
devsyncwrld 688a1335cb feat(i18n): language foundation with en and pt-BR
Nothing in the project was translatable — every string sat inline in English.

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

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

Translates the voice input panel (including the mic test shipped earlier
today) and the profile card as this round's system. Language options are
labelled in the active language, so a wrong pick can always be undone.
2026-08-31 12:24:50 -03:00
devsyncwrld 89e13441c8 docs: record the pt-BR language rule and new system ideas
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:18:33 -03:00
devsyncwrld 63afd2fc89 fix(profile): hoist the activity hook above the early return
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The activity selector sat below `if (!isOpen || !user) return null`. `user`
arrives asynchronously, so the hook ran on some renders and not others; React
counts hooks per render and tore the tree down with error #310 as soon as a
profile finished loading.

Move it above the guard and let the selector tolerate a null user. Typecheck
and the suite both passed with the bug in place — TypeScript cannot see hook
order and nothing renders this modal across the null-to-loaded transition.
2026-08-31 12:12:23 -03:00
devsyncwrld d80de49768 chore(caddy): serve the Gitea site alongside the app
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The git server runs as its own stack in /opt/gitea and is reached by container
name over the app's internal network, publishing no host port of its own. Only
the reverse-proxy entry belongs here, where the Caddyfile lives.
2026-08-31 12:07:29 -03:00
devsyncwrld b92a0d837e docs: update roadmap after mic test and activity work
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 12:05:18 -03:00
devsyncwrld d7da0ff203 feat(activity): show the current activity on the profile card
The activity pipeline was already complete end to end — Activity type, store,
WS broadcast, server validation, presence relay, and an ActivityCard used by
four list surfaces — but the profile card rendered none of it, which is the
'Listening to Spotify' block the design calls for.

Add ProfileActivity: richer than ActivityCard because the card has room for
artwork, track and artist, so it reads details/state/assets. All optional, so
it degrades to the bare name that today's process-based detector supplies.

Also scheme-check activity image assets server-side. activity.url was already
restricted to http(s) but assets.largeImage/smallImage were only length-checked
— an asymmetry that was harmless while nothing rendered them, and is not once
they become <img src>: a client could point them at a host it controls and
harvest the IP of everyone opening that profile.
2026-08-31 12:05:01 -03:00
devsyncwrld bfe62d7078 feat(voice): mic test with loopback in voice settings
The level meter only measured a stream a call had already opened, so settings
offered no way to check a mic before joining — the panel said as much.

Add startMicTest/stopMicTest on AudioManager: the processed input bus is
routed to the master output through a dedicated gain node, so the loopback can
be disconnected precisely. Settings had deliberately never opened the mic
itself; a mic test cannot honour that, so the test hands the mic back when it
stops.

Releasing needs two independent guards, because the user may join a call
mid-test: AudioManager only stops the exact stream it opened (identity check,
not a flag), and the caller must consent — the UI reads the call state, which
AudioManager cannot, as it does not import stores. Unmounting mid-test tears
the loopback down too.
2026-08-31 11:59:51 -03:00
devsyncwrld 37407a5ecd feat(voice): open the profile card from voice participants
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
The profile popout already existed and was reachable from eleven places —
messages, mentions, avatars, member list, DMs, activity panel — but no voice
surface opened it, so clicking someone during a call did nothing.

Wire it into the voice user rows (VoiceChannel's sidebar list) and the name
label on grid tiles, whose avatar was already a ProfileAvatar; the name beside
it not reacting read as the click failing.

Left mobile alone deliberately: MobileSpacesScreen already opens the profile
from its row wrapper, and MobileVoiceJoinSheet would layer a history-pushed
full-screen profile inside a bottom sheet, which cannot be verified here.
2026-08-31 11:49:57 -03:00
devsyncwrld cad3867027 docs: add fork roadmap
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
2026-08-31 11:45:02 -03:00
devsyncwrld 20526e1bc8 feat(gif): outlined GIF glyph, and GIF picker for the profile banner
The composer's GIF button drew a filled rounded rect with the letters knocked
out, which reads as a solid square rather than a picker. Invert it: stroked
outline with filled letters, reusing the original glyph paths scaled to centre.

Banners already accept absolute URLs on both ends (server isValidAssetUrl
allows http(s); the profile render branches on banner.startsWith('http')), so
the picker stores the remote URL directly with no upload path. Previews can now
hold either a blob: or an https: URL, so revoking is guarded — calling
revokeObjectURL on a remote URL is a silent no-op that would hide a mistake.
2026-08-31 11:44:40 -03:00
devsyncwrld c70b0095a9 feat(voice): jump to the call from the voice panel
The channel name under 'Voice Connected' was a plain div. Making it navigate
needed more than an onClick: voiceStore never recorded which space the call
was in, and spaceStore.channels only holds the space currently being viewed —
so after navigating away the call's channel was unresolvable, which is also
why the label degraded to a generic 'Voice Channel'.

Capture space and channel name at join time (the only moment they are
reliable) and use them for both the label and the jump. Covers space calls
and DM calls.
2026-08-31 11:40:45 -03:00
devsyncwrld 08db5374cb build: compile better-sqlite3 from source (no Node 20 prebuilt)
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Canceled after 0s
better-sqlite3@12.11.1 ships prebuilt binaries for ABI 127/137/141/147 only;
Node 20 is ABI 115, so prebuild-install falls back to node-gyp, which fails on
node:20-slim for lack of python3/make/g++.

Add the toolchain to the builder stage, and in the runtime stage install, use
and purge it inside a single layer so the final image ships no compiler.
2026-08-31 11:09:14 -03:00
TheZwiss 6c7cbee808 docs(pr-template): clarify the CLA checkbox is not the signature (#42)
The CLA Assistant only records a signature when the exact phrase is posted as a comment on the PR, so ticking the checkbox alone left the check red with no explanation (see #39). Spell the required comment out next to the checkbox, using the exact phrase from custom-pr-sign-comment in .github/workflows/cla.yml.
2026-08-25 16:36:24 +02:00
cnrd 8456b8f976 fix(web): stop the profile card re-anchoring to its own avatar (#39)
Avatar opened the profile popout whenever it received a user prop. Since user is how every avatar gets its gradient, colour and status dot, all 22 call sites became profile triggers by accident — including the picture inside the profile card itself, which re-anchored the card to that picture on every click and walked it across the screen (120px right, 36px down, until it pinned at the viewport clamp).

Avatar is now presentational. A new ProfileAvatar carries the open-the-profile behaviour at the five call sites that actually want it. The card's own picture escalates to the full profile modal instead of reopening the card.

The card also places itself off its measured size via the shared computeFloatingPosition engine, replacing six call sites that each hand-computed coordinates against a guessed 460px card height.

Closes #37
2026-08-25 15:51:04 +02:00
TheZwiss 747f1b9c5c ci(cla): store CLA signatures on a dedicated branch (#40)
The CLA Assistant appends each signature as a direct commit to the branch named in 'branch:'. That was main, which the 'Require CI on main' ruleset rejects ('Repository rule violations found'), so signatures were never recorded and the check stayed red however often a contributor signed — a deadlock for every outside contribution.

Point the store at the cla-signatures branch. The ruleset targets the default branch only, so the bot can append there without granting any actor a bypass on main. That branch is seeded with the existing signature and carries its own ruleset blocking deletion and non-fast-forward pushes.
2026-08-25 15:46:11 +02:00
TheZwiss 89467d6f93 fix(server): upgrade better-sqlite3 to 12.x for Node 24 compatibility (#41)
better-sqlite3 11.x removes its environment cleanup hook from Statement::~Statement() after the Node environment is torn down. Node 24.19.0 asserts on the null environment and aborts the worker, so vitest reported 'Worker exited unexpectedly' and exited non-zero on a fully green test run — failing the required check on every PR.

11.x also ships no prebuild for Node 24, so CI compiled it from source on every run. 12.x has prebuilds for that ABI and the V8 13.9 shims 11.x lacks. drizzle-orm declares better-sqlite3 >=7, so the major bump is in range.

Supersedes #26.
2026-08-25 15:41:43 +02:00
TheZwiss 8e13aaa057 Merge pull request #27 from TheZwiss/docs/security-design-record
docs: complete security design record on main (spec + Plan A plan)
2026-07-13 11:40:37 +02:00
Jannis Braun aa5052ba8a docs(security): add scanning/hardening design spec + Plan A implementation plan
Completes the design record on main: Plan B's plan and the federation spec were
already here; this adds the umbrella security spec (source of truth for the
remaining container/web/desktop/remediation workstreams) and Plan A's plan.
2026-07-13 11:36:23 +02:00
TheZwiss 97989fbfb4 Merge pull request #18 from TheZwiss/security/container-hardening
Container hardening: non-root + image scan (report-only)
2026-07-13 11:33:54 +02:00
Jannis Braun bf836e208b Merge main into security/container-hardening (bring up to date for merge) 2026-07-13 11:27:02 +02:00
TheZwiss 53a36cd11a Merge pull request #17 from TheZwiss/security/scanning-pipeline
Security scanning pipeline (report-only)
2026-07-13 11:26:27 +02:00
Jannis Braun d2d6ce9756 Merge main into security/scanning-pipeline (bring up to date for merge) 2026-07-13 11:20:45 +02:00
Jannis Braun 7d1895308d fix(docker): make SARIF upload non-blocking; correct seed-admin/build-stage/restore ownership docs
Final whole-branch review (opus) fixes:
- docker-publish.yml: upload-sarif was if:always() but not continue-on-error, so a
  Trivy SARIF-emit flake would fail the job and SKIP the multi-arch publish. Made it
  non-blocking so a scanner hiccup never blocks a release.
- deployment.md: seed-admin-rotated.txt is root-owned (written via docker exec, which
  bypasses the gosu drop) — reverted an over-correction. Corrected the canonical
  runtime-stage build description (no toolchain; non-root gosu). First-boot chown note.
- restore.sh: comment ownership root -> uid 1000.
2026-07-13 01:21:20 +02:00
Jannis Braun 0b3aa42a09 docs(docker): add maintainer release-gate pull-test note (per review) 2026-07-13 01:13:19 +02:00
Jannis Braun 6d81b63d86 docs(docker): document non-root runtime, data-volume migration, and image scan 2026-07-13 01:09:26 +02:00
Jannis Braun c4929a8b1b ci(docker): scan the amd64 image before publish; attach SBOM + provenance 2026-07-13 01:06:58 +02:00
Jannis Braun 0ec7ddba81 docs(docker): correct stale runtime-deps comment (no build toolchain anymore) 2026-07-13 01:04:13 +02:00
Jannis Braun 3100965c30 fix(docker): run container as non-root (gosu) and drop build toolchain from runtime 2026-07-13 00:58:07 +02:00
Jannis Braun 9d2eeb0963 docs(security): build native arm64 for local Docker verification (host is Apple Silicon; arm64 = Pi target) 2026-07-13 00:54:42 +02:00
Jannis Braun fd8659a964 docs(security): lighter targeted arm64 native-module check (avoid QEMU Vite build timeout) 2026-07-13 00:53:26 +02:00
Jannis Braun 7457146846 docs(security): fold Plan B review findings (proc/1 uid check, arm64 verify, cheap idempotent chown, provenance floor, doc corrections) 2026-07-13 00:52:13 +02:00
Jannis Braun 321428ba1f docs(security): Plan B implementation plan (container hardening + image scan) 2026-07-13 00:42:02 +02:00
Jannis Braun cf4172e81a fix(security): bump trivy-action v0.28.0 -> v0.36.0
v0.28.0's composite action referenced a nested aquasecurity/setup-trivy@v0.2.1
tag that no longer exists, so the action failed to RESOLVE during job setup
(before any step ran) — continue-on-error can't catch a resolution failure, so
both Trivy jobs went red on every run. v0.36.0 pins setup-trivy to a real SHA
(v0.2.6) and still supports scan-type/scan-ref/scanners/format/output.
2026-07-12 23:54:47 +02:00
Jannis Braun e2d09c0d52 fix(security): point OSV-Scanner at runnable subpath action; doc accuracy fixes
- OSV-Scanner ref was google/osv-scanner-action@<sha> (metadata-only root
  action, no runs:) -> subpath google/osv-scanner-action/osv-scanner-action
  which carries the docker action + scan-args input. Root ref would fail to
  load and redden the job on every run (caught in final whole-branch review).
- security-scanning.md: note gitleaks findings land in job log (not SARIF);
  add scorecard branch_protection_rule trigger; mark SBOM/provenance as not-
  yet-live. CLAUDE.md row: image scan is a later plan, not current.
2026-07-12 23:51:00 +02:00
Jannis Braun cb524675cc docs(security): document the scanning pipeline + maintainer checklist 2026-07-12 23:51:00 +02:00
Jannis Braun 4758ca46fa ci(security): SHA-pin all actions and add harden-runner (audit) 2026-07-12 23:51:00 +02:00
Jannis Braun 21d783e257 ci(security): add OpenSSF Scorecard workflow 2026-07-12 23:51:00 +02:00
Jannis Braun 3f75ff6e36 ci(security): add CodeQL SAST workflow (javascript-typescript) 2026-07-12 23:51:00 +02:00
Jannis Braun 74ae929ab4 ci(security): add report-only security scan workflow (gitleaks, OSV, Trivy) 2026-07-12 23:51:00 +02:00
Jannis Braun 807afba45a ci(security): add Dependabot config (npm + actions + docker) 2026-07-12 23:51:00 +02:00
129 changed files with 27618 additions and 339 deletions
+3 -1
View File
@@ -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
+44
View File
@@ -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
+97
View File
@@ -0,0 +1,97 @@
# Caminho rápido: só Windows x64, disparado à mão.
#
# O `release.yml` continua sendo o release de verdade (todas as plataformas,
# publica release e alimenta o electron-updater). Este aqui existe para quando
# você só quer um .exe para testar, sem marcar versão.
#
# Arquivo separado de propósito: o release.yml veio do upstream e recebe merges;
# mexer nele criaria conflito a cada atualização.
name: Build Windows (rápido)
on:
workflow_dispatch:
inputs:
arch:
description: Arquitetura
required: false
default: x64
type: choice
options: [x64, arm64]
permissions:
contents: read
jobs:
build:
# windows-2022, não windows-latest: a imagem latest traz o Visual Studio 18,
# que o node-gyp embutido no electron-rebuild não detecta ao compilar os
# módulos nativos. Mesma razão documentada no release.yml.
runs-on: windows-2022
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
# ~100MB de binários do Electron e das ferramentas do NSIS, baixados a
# cada execução sem isto. É o maior ganho depois de cortar o arm64.
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para que quem não tem
# ferramentas de build consiga instalar. No CI isso transforma falha em
# sucesso silencioso: o instalador sai sem o módulo nativo e o app
# compartilha tela sem som, sem erro nenhum. Então verifica-se o
# resultado em vez de confiar no código de saída.
- name: Verify native audio module compiled
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' 2>/dev/null | head -5)
if [ -z "$found" ]; then
echo "::error::electron-native-screenshare has no compiled .node — the installer would ship without system-audio capture"
find node_modules/.pnpm -maxdepth 1 -name 'electron-native-screenshare*' -printf '%p\n' 2>/dev/null || true
exit 1
fi
echo "OK:"; echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: sem release, sem precisar de tag nem bump de versão.
# O instalador sai como artefato desta execução.
- name: Build installer
working-directory: packages/desktop
run: pnpm exec electron-builder --win --${{ inputs.arch || 'x64' }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload installer
uses: actions/upload-artifact@v4
with:
name: backspace-windows-${{ inputs.arch || 'x64' }}
path: packages/desktop/dist-electron/*.exe
retention-days: 14
if-no-files-found: error
+8 -3
View File
@@ -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
+12 -4
View File
@@ -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.
+48
View File
@@ -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"
+4 -4
View File
@@ -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
+48 -6
View File
@@ -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
+183
View File
@@ -0,0 +1,183 @@
# Compila no GitHub, publica no Gitea.
#
# O GitHub entra só como máquina de build — é dele que vêm os runners Windows
# de que o módulo nativo de áudio precisa. A distribuição fica no Gitea, que
# serve os arquivos a qualquer um: assim o electron-updater não precisa de
# credencial embutida no app, o que aconteceria com um repositório privado no
# GitHub.
name: Publicar no Gitea
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
tag:
description: Tag a publicar (ex. v1.1.0)
required: true
type: string
permissions:
contents: read
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
- os: windows-2022
args: --win --x64
- os: ubuntu-latest
args: --linux --x64
runs-on: ${{ matrix.os }}
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Install Linux build dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y \
libx11-dev libxtst-dev libxt-dev \
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev \
libpipewire-0.3-dev libpulse-dev
- name: Setup pnpm
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0
with:
version: 10.34.3
- name: Setup Node.js
uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 20
cache: pnpm
- name: Cache Electron binaries
uses: actions/cache@v4
with:
path: |
~/.cache/electron
~/.cache/electron-builder
~\AppData\Local\electron\Cache
~\AppData\Local\electron-builder\Cache
key: electron-cache-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: electron-cache-${{ runner.os }}-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build shared package
run: pnpm --filter @backspace/shared build
# O postinstall termina em `|| console.warn` para quem não tem ferramentas
# de build. No CI isso esconde falha: o instalador sairia sem captura de
# áudio do sistema e ninguém saberia. Verifica-se o resultado.
- name: Verify native audio module compiled
if: runner.os == 'Windows'
shell: bash
run: |
found=$(find node_modules/.pnpm -path '*electron-native-screenshare*' -name '*.node' | head -5)
[ -n "$found" ] || { echo "::error::sem .node compilado — instalador sairia sem áudio do sistema"; exit 1; }
echo "$found"
- name: Compile desktop TypeScript
working-directory: packages/desktop
run: pnpm exec tsc
# --publish never: o electron-builder não sabe enviar para o Gitea. Ele
# gera os instaladores e o latest.yml (o índice que o app consulta), e o
# passo seguinte faz o upload.
- name: Build installers
working-directory: packages/desktop
run: pnpm exec electron-builder ${{ matrix.args }} --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: "false"
- name: Upload to Gitea release
shell: bash
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_API: https://git.resenha.website/api/v1/repos/devsyncwrld/backspace
TAG: latest
run: |
set -uo pipefail
if [ -z "${GITEA_TOKEN:-}" ]; then
echo "::error::segredo GITEA_TOKEN não configurado"
exit 1
fi
# Sem -f e imprimindo o corpo: a versão anterior usava `curl -sf`, que
# engole a resposta de erro, então uma falha aqui só aparecia como um
# JSONDecodeError sem dizer o motivo.
api() {
local method=$1 path=$2; shift 2
curl -s -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" "$GITEA_API$path" "$@"
}
body() { sed '$d' <<<"$1"; }
code() { tail -n1 <<<"$1"; }
# jq em vez de python embutido: um heredoc multilinha dentro de um
# bloco literal de YAML encerra o bloco na primeira linha sem recuo.
json_id() { jq -r '.id // empty'; }
find_release() {
local r; r=$(api GET "/releases/tags/$TAG")
[ "$(code "$r")" = "200" ] && body "$r" | json_id || echo ""
}
# O corpo do POST fica em ASCII de proposito: o shell do runner
# Windows corrompe UTF-8 na requisicao e o Gitea recusa com
# "invalid UTF-8 within /name" (HTTP 422).
ID=$(find_release)
if [ -z "$ID" ]; then
R=$(api POST "/releases" -H 'Content-Type: application/json' \
-d "{\"tag_name\":\"$TAG\",\"name\":\"Latest build\",\"target_commitish\":\"main\",\"body\":\"Installers published automatically by CI.\"}")
if [ "$(code "$R")" = "201" ]; then
ID=$(body "$R" | json_id)
echo "release criada: $ID"
else
echo "criação retornou HTTP $(code "$R"): $(body "$R")"
# O outro job da matriz pode tê-la criado no mesmo instante.
ID=$(find_release)
[ -n "$ID" ] || { echo "::error::não foi possível obter nem criar a release"; exit 1; }
echo "release encontrada após corrida: $ID"
fi
else
echo "release existente: $ID"
fi
# Remove só os anexos que esta plataforma vai repor, para os dois jobs
# não apagarem o trabalho um do outro.
R=$(api GET "/releases/$ID/assets")
if [ "$(code "$R")" = "200" ]; then
body "$R" | jq -r '.[] | "\(.id) \(.name)"' | while read -r aid aname; do
case "$aname" in
${{ runner.os == 'Windows' && '*.exe|latest.yml' || '*.AppImage|*.deb|latest-linux.yml' }})
echo "removendo anexo antigo: $aname"
api DELETE "/releases/$ID/assets/$aid" > /dev/null ;;
esac
done
fi
shopt -s nullglob
sent=0
for f in packages/desktop/dist-electron/*.exe \
packages/desktop/dist-electron/*.AppImage \
packages/desktop/dist-electron/*.deb \
packages/desktop/dist-electron/latest*.yml; do
name=$(basename "$f")
R=$(api POST "/releases/$ID/assets?name=$name" -F "attachment=@$f")
if [ "$(code "$R")" != "201" ]; then
echo "::error::falha ao enviar $name — HTTP $(code "$R"): $(body "$R")"
exit 1
fi
echo "enviado: $name"
sent=$((sent+1))
done
[ "$sent" -gt 0 ] || { echo "::error::o build não produziu instaladores"; exit 1; }
echo "$sent arquivo(s) publicados"
+20 -25
View File
@@ -14,28 +14,38 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
include: include:
# macOS — universal build (arm64 + x64) on Apple Silicon runner # Fork: só as plataformas que o grupo usa. Windows arm64 saiu porque
- os: macos-latest # dobrava o job mais lento (duas distribuições do Electron, módulos
args: --mac --arm64 --x64 # nativos compilados duas vezes) e ninguém roda Windows em ARM.
# Linux arm64 saiu pelo mesmo motivo. Com o arm64 fora, o passo que
# instalava o fpm nativo perdeu a razão de existir: ele só era
# necessário porque o fpm embutido no electron-builder é x86_64 e
# falhava no runner arm64.
# macOS removido neste fork: ninguém do grupo usa, e o runner macOS
# é cobrado a 10x num repositório privado — era a plataforma mais cara
# da matriz, compilada em toda tag para zero usuários.
# Windows — x64 + arm64 on x64 runner. # Windows — x64 + arm64 on x64 runner.
# Pinned to windows-2022 (VS 2022 / v17): the windows-latest image # Pinned to windows-2022 (VS 2022 / v17): the windows-latest image
# ships VS "18", which the node-gyp bundled with electron-rebuild # ships VS "18", which the node-gyp bundled with electron-rebuild
# cannot detect ("unknown version undefined") when compiling # cannot detect ("unknown version undefined") when compiling
# uiohook-napi in postinstall. # uiohook-napi in postinstall.
- os: windows-2022 - os: windows-2022
args: --win --x64 --arm64 args: --win --x64
# Linux — x64 on x64 runner # Linux — x64 on x64 runner
- os: ubuntu-latest - os: ubuntu-latest
args: --linux --x64 args: --linux --x64
# Linux — arm64 on arm64 runner
- os: ubuntu-24.04-arm
args: --linux --arm64
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
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'
@@ -53,24 +63,13 @@ jobs:
libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \ libxkbcommon-dev libxkbcommon-x11-dev libxkbfile-dev \
libxrandr-dev libxinerama-dev libx11-xcb-dev libxrandr-dev libxinerama-dev libx11-xcb-dev
- name: Install fpm for .deb packaging
if: runner.os == 'Linux'
# electron-builder bundles an x86_64-only fpm; on the arm64 runner it
# aborts the .deb target with "cannot execute binary file: Exec format
# error". Install fpm natively and set USE_SYSTEM_FPM (below) so both
# arches package their .deb with a host-native fpm. ruby-dev + make are
# needed for fpm's native gem dependencies.
run: |
sudo apt-get install -y ruby ruby-dev build-essential
sudo gem install --no-document fpm
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@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
@@ -91,7 +90,3 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
# Use the host-native fpm installed above instead of electron-builder's
# bundled x86_64 fpm (which can't run on the arm64 runner). No-op on
# macOS/Windows, which don't build .deb.
USE_SYSTEM_FPM: "true"
+48
View File
@@ -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
+123
View File
@@ -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
+1
View File
@@ -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 |
--- ---
+6
View File
@@ -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
View File
@@ -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"]
+18
View File
@@ -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 "$@"
+56
View File
@@ -0,0 +1,56 @@
# Gerar o instalador do app desktop
O workflow **`.github/workflows/release.yml`** (herdado do upstream) já compila
para macOS, Windows e Linux e publica os instaladores como release. Ele dispara
ao empurrar uma tag `v*`.
Não foi preciso escrever workflow novo — foi preciso **adaptá-lo ao fork**.
## Por que GitHub e não Gitea
O Gitea desta instância não tem Actions habilitado, e mesmo habilitado ele
**não oferece máquinas hospedadas**: seria preciso registrar um PC Windows
como runner e mantê-lo ligado. O GitHub fornece runners Windows prontos, que é
exatamente o que falta — o módulo nativo do compartilhamento de áudio usa
WASAPI e só compila no Windows, com o compilador da Microsoft.
O Gitea continua sendo o repositório principal. O GitHub entra apenas como
espelho para compilar.
## Passos (uma vez)
1. Criar um repositório no GitHub — o `electron-builder.yml` está apontado para
`syncwrld/resenhacord`. **Se o seu for outro nome, ajuste lá.**
2. Adicionar o espelho e empurrar:
```
cd /opt/backspace
git remote add github git@github.com:syncwrld/resenhacord.git
git push github main
```
3. Marcar uma versão e empurrar a tag — é ela que dispara a compilação:
```
git tag v1.0.1 && git push github v1.0.1
```
4. Os instaladores aparecem na aba Releases do GitHub em ~15 minutos.
## O que isso resolve de quebra
O `electron-updater` já estava instalado no projeto mas **sem feed** — o app
não se atualizava sozinho. Como o `publish` agora aponta para as releases do
fork, o app passa a encontrar versões novas por conta própria. Ninguém do grupo
precisa reinstalar na mão de novo.
## Adaptações feitas para o fork
| O quê | Por quê |
|---|---|
| `publish.owner` → `syncwrld` | Apontava para o repositório do upstream |
| `postinstall` reconstrói também `electron-native-screenshare` | Rodava `electron-rebuild` só no `uiohook-napi`. O módulo novo seria compilado para a ABI do Node em vez da do Electron e falharia ao carregar — e como o carregamento degrada em silêncio, o sintoma seria "compartilha sem som", sem erro visível |
| Nota sobre `asarUnpack` | O `uiohook-napi` tem `build/` excluído do asar porque distribui binários prontos. O módulo novo **não** distribui: `build/Release/*.node` é a única cópia e não pode ser excluída |
## Detalhe herdado que vale preservar
O runner do Windows está fixado em `windows-2022`, não `windows-latest`. O
comentário no workflow explica: a imagem `latest` traz o Visual Studio 18, que
o node-gyp embutido no `electron-rebuild` não detecta ("unknown version
undefined"). Não mude isso sem testar.
+144
View File
@@ -0,0 +1,144 @@
# 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 |
| Correções do Spotify (sincronia, sumiço, barra) | `c899253e` | Pausa virou estado; horário do servidor no `ready` corrige relógio |
| Soundboard no app desktop | `c899253e` | `window.prompt` não existe no Electron; campo inline no lugar |
| Áudio do sistema sem eco | `d525bbb8` `5b848432` | Módulo nativo com isolamento por processo + ganho e preset de música |
| Instaladores publicados pelo Gitea | `ce6bba05` | GitHub compila, Gitea distribui; atualização automática ligada |
| Fundação de i18n (en + pt-BR) | `688a1335` | `src/i18n/`; pt-BR é parcial e cai para inglês. Aba Idioma nas configurações |
| 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
- Chat: composer e mensagens (placeholder, anexos, responder, reações)
- Barra lateral: servidores, canais e menus de contexto
- Lista de membros (grupos por cargo, carregamento)
- Registro de auditoria e Estatísticas (nasceram bilíngues)
### Fila sugerida de tradução
Mensagens e composer · lista de membros · servidores e canais · amigos e DMs ·
modais de convite · configurações restantes · telas de erro
## Correções pendentes
| Problema | Causa provável | Correção |
|---|---|---|
## App desktop — já existia (verificado 2026-08-31)
As três pedidas já estão implementadas e ligadas de ponta a ponta. **Não
construir de novo.**
- **Bandeja** — `createTray()` em `desktop/src/main.ts`, chamada na inicialização;
fechar a janela esconde em vez de sair (`mainWindow.on('close')`).
- **Notificações nativas** — `showNotification()` no processo principal, canal
IPC `show-notification`, e o web já chama por `platform/notifications.ts`.
- **Áudio do sistema no compartilhamento** — `setDisplayMediaRequestHandler`
devolve `audio: 'loopback'`; existe caixa de seleção no `ScreenSharePicker`
ligada a `screenShareConfig.shareAudio`, que atravessa o IPC.
Se algum não se manifestar em uso, o trabalho é **depuração**, não construção.
## Aprovadas, a fazer depois (2026-08-31)
Escolhidas pelo dono, inspiradas no Discord. **Nenhuma iniciada.**
| Feature | Tamanho | Observação técnica |
|---|---|---|
| **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).
+39 -3
View File
@@ -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
+33
View File
@@ -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`).
+58
View File
@@ -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.)
+4 -3
View File
@@ -1,8 +1,8 @@
{ {
"name": "backspace", "name": "backspace",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"description": "Open, self-hosted communication platform text, voice, video, and federation", "description": "Open, self-hosted communication platform \u2014 text, voice, video, and federation",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"author": "Jannis Braun", "author": "Jannis Braun",
"homepage": "https://github.com/TheZwiss/backspace", "homepage": "https://github.com/TheZwiss/backspace",
@@ -32,8 +32,9 @@
"pnpm": { "pnpm": {
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"better-sqlite3", "better-sqlite3",
"esbuild",
"electron", "electron",
"electron-native-screenshare",
"esbuild",
"sharp" "sharp"
], ],
"patchedDependencies": { "patchedDependencies": {
+16 -3
View File
@@ -14,14 +14,27 @@ files:
- "!**/node_modules/uiohook-napi/build/**" - "!**/node_modules/uiohook-napi/build/**"
- "!**/node_modules/uiohook-napi/build.bak/**" - "!**/node_modules/uiohook-napi/build.bak/**"
- "!**/node_modules/uiohook-napi/bin/**" - "!**/node_modules/uiohook-napi/bin/**"
# electron-native-screenshare has no prebuilds — build/Release/*.node is the
# only copy, so it must NOT be excluded the way uiohook-napi's is. asarUnpack
# below takes it out of the archive so the loader can find it.
asarUnpack: asarUnpack:
- "**/*.node" - "**/*.node"
npmRebuild: false npmRebuild: false
afterPack: ./scripts/afterPack.js afterPack: ./scripts/afterPack.js
publish: publish:
- provider: github # Updates are served from this fork's own Gitea, not from GitHub.
owner: TheZwiss #
repo: backspace # GitHub is only the build machine — it has the Windows runners the native
# audio module needs. Its repository is private, and electron-updater against
# a private GitHub repo would need a token shipped inside the app, which is a
# leaked token. Gitea serves release assets to anyone, so no credential ends
# up in the installer.
#
# The tag is fixed at `latest` on purpose: electron-updater fetches
# latest.yml before it knows which version exists, so the URL cannot contain
# a version. CI replaces that release's assets on every publish.
- provider: generic
url: https://git.resenha.website/devsyncwrld/backspace/releases/download/latest/
protocols: protocols:
- name: Backspace - name: Backspace
schemes: schemes:
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "@backspace/desktop", "name": "@backspace/desktop",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"description": "Backspace", "description": "Backspace",
@@ -18,9 +18,10 @@
"clean": "rm -rf dist dist-electron", "clean": "rm -rf dist dist-electron",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"postinstall": "electron-rebuild -f -w uiohook-napi || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\"" "postinstall": "electron-rebuild -f -w uiohook-napi,electron-native-screenshare || node -e \"console.warn('[desktop] uiohook-napi native rebuild skipped - needs build tools (make, g++, python3). Only required to RUN the desktop app; the server, web client, and Docker image are unaffected.')\""
}, },
"dependencies": { "dependencies": {
"electron-native-screenshare": "^1.2.0",
"electron-updater": "^6.3.0", "electron-updater": "^6.3.0",
"uiohook-napi": "^1.5.5" "uiohook-napi": "^1.5.5"
}, },
+113 -1
View File
@@ -265,6 +265,102 @@ function applyLoginItemSettings(openAtLogin: boolean, startMinimized: boolean):
} }
} }
// ─── Native system-audio capture ────────────────────────────────────────────
//
// Electron's own `audio: 'loopback'` captures the whole output mix, which
// includes this app playing everyone else's voices — so those voices went back
// out inside the screen share and every listener heard themselves. Not acoustic
// echo: it is a digital copy of the output, so headphones never helped.
//
// This module captures with process-level isolation instead: only the shared
// window (include mode), or everything except this app (exclude mode).
interface NativeAudioMeta {
sampleRate: number;
channels: number;
bitsPerSample: number;
isFloat: boolean;
}
interface NativeScreenShareAudio {
startCapture(processId?: number, isIncludeMode?: boolean, onData?: (data: Buffer, meta: NativeAudioMeta) => void): boolean;
stopCapture(): boolean;
getPidFromWindowHandle(windowHandle: number): number;
isAvailable(): boolean;
getLoadError(): string | null;
}
let nativeAudio: NativeScreenShareAudio | null = null;
try {
// Required lazily and defensively: a native module that fails to load must
// degrade to sharing without audio, never stop the app from starting.
nativeAudio = require('electron-native-screenshare') as NativeScreenShareAudio;
if (!nativeAudio.isAvailable()) {
console.warn('[Main:ScreenShare] Native audio unavailable:', nativeAudio.getLoadError());
nativeAudio = null;
}
} catch (err) {
console.warn('[Main:ScreenShare] Native audio module missing:', err);
nativeAudio = null;
}
let nativeAudioActive = false;
/**
* Windows and Linux hand desktopCapturer ids of the form `window:<handle>:<n>`.
* Recovering the handle lets us capture only that window's audio, which is
* better than excluding ourselves: a game's sound goes out, the rest of the
* desktop does not.
*/
function windowHandleFromSourceId(sourceId: string): number | null {
const match = /^window:(\d+)/.exec(sourceId);
if (!match) return null;
const handle = Number(match[1]);
return Number.isFinite(handle) && handle > 0 ? handle : null;
}
function startNativeAudioCapture(sourceId: string): boolean {
if (!nativeAudio || nativeAudioActive) return false;
let targetPid = process.pid;
let includeMode = false;
const handle = windowHandleFromSourceId(sourceId);
if (handle !== null) {
const pid = nativeAudio.getPidFromWindowHandle(handle);
// pid 0 means the handle did not resolve; fall back to excluding ourselves
// rather than capturing nothing.
if (pid > 0) {
targetPid = pid;
includeMode = true;
}
}
try {
const started = nativeAudio.startCapture(targetPid, includeMode, (data, meta) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('native-audio-data', data, meta);
});
nativeAudioActive = started;
console.log('[Main:ScreenShare] Native audio', started ? 'started' : 'failed',
includeMode ? `(only pid ${targetPid})` : '(excluding self)');
return started;
} catch (err) {
console.error('[Main:ScreenShare] Native audio start failed:', err);
nativeAudioActive = false;
return false;
}
}
function stopNativeAudioCapture(): void {
if (!nativeAudio || !nativeAudioActive) return;
try {
nativeAudio.stopCapture();
} catch (err) {
console.warn('[Main:ScreenShare] Native audio stop failed:', err);
}
nativeAudioActive = false;
}
// ─── Tray Icon ────────────────────────────────────────────────────────────── // ─── Tray Icon ──────────────────────────────────────────────────────────────
function generateFallbackTrayIcon(): Electron.NativeImage { function generateFallbackTrayIcon(): Electron.NativeImage {
@@ -594,6 +690,10 @@ function registerIpcHandlers(): void {
ipcMain.handle('get-app-version', () => app.getVersion()); ipcMain.handle('get-app-version', () => app.getVersion());
// Screen share picker coordination (used by setDisplayMediaRequestHandler) // Screen share picker coordination (used by setDisplayMediaRequestHandler)
ipcMain.on('native-audio-stop', () => {
stopNativeAudioCapture();
});
ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => { ipcMain.on('screen-share-selected', (_event, _sourceId: string | null, _shareAudio?: boolean) => {
// Handled via ipcMain.once in the display media handler — this is just // Handled via ipcMain.once in the display media handler — this is just
// a safety net to prevent unhandled-message warnings // a safety net to prevent unhandled-message warnings
@@ -953,7 +1053,19 @@ if (!gotTheLock) {
// `PulseaudioLoopbackForScreenShare` feature flag we enable above. // `PulseaudioLoopbackForScreenShare` feature flag we enable above.
// Fails on PipeWire-only systems without pulse compat — the // Fails on PipeWire-only systems without pulse compat — the
// renderer catches that and toasts the user. // renderer catches that and toasts the user.
callback({ video: selected, ...(shareAudio ? { audio: 'loopback' } : {}) }); // Audio no longer rides on the Electron stream: `loopback` would put
// this app's own output (everyone else's voices) back into the share.
// The native module captures it separately, isolated by process, and
// the renderer turns it into the track LiveKit publishes.
if (shareAudio) {
const started = startNativeAudioCapture(sourceId);
if (!started) {
// Tell the renderer so it can say the share is going out silently,
// instead of the user assuming sound is included.
mainWindow?.webContents.send('native-audio-unavailable');
}
}
callback({ video: selected });
} catch (err) { } catch (err) {
console.error('[Main:ScreenShare] Handler error:', err); console.error('[Main:ScreenShare] Handler error:', err);
// @ts-ignore — deny the request without crashing // @ts-ignore — deny the request without crashing
+18
View File
@@ -65,6 +65,24 @@ contextBridge.exposeInMainWorld('backspace', {
onScreenShareSources: (callback: (sources: unknown[]) => void) => { onScreenShareSources: (callback: (sources: unknown[]) => void) => {
ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources)); ipcRenderer.on('screen-share-sources', (_event, sources) => callback(sources));
}, },
// Raw PCM from the native capture. Arrives ~50x/second; the renderer turns it
// into a MediaStreamTrack for LiveKit.
onNativeAudioData: (callback: (data: ArrayBuffer, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => void) => {
const listener = (_event: unknown, data: Uint8Array, meta: { sampleRate: number; channels: number; bitsPerSample: number; isFloat: boolean }) => {
// Copied out of the transferred buffer: reusing it across IPC messages
// would let a later chunk overwrite one still being read.
callback(data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) as ArrayBuffer, meta);
};
ipcRenderer.on('native-audio-data', listener);
return () => ipcRenderer.off('native-audio-data', listener);
},
onNativeAudioUnavailable: (callback: () => void) => {
const listener = () => callback();
ipcRenderer.on('native-audio-unavailable', listener);
return () => ipcRenderer.off('native-audio-unavailable', listener);
},
stopNativeAudio: () => ipcRenderer.send('native-audio-stop'),
selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => { selectScreenSource: (sourceId: string | null, shareAudio?: boolean) => {
ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true); ipcRenderer.send('screen-share-selected', sourceId, shareAudio ?? true);
}, },
@@ -0,0 +1,9 @@
CREATE TABLE `spotify_connections` (
`user_id` text PRIMARY KEY NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`expires_at` integer NOT NULL,
`spotify_user_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
@@ -0,0 +1,14 @@
CREATE TABLE `gif_favorites` (
`user_id` text NOT NULL,
`gif_id` text NOT NULL,
`title` text NOT NULL,
`preview_url` text NOT NULL,
`url` text NOT NULL,
`width` integer NOT NULL,
`height` integer NOT NULL,
`created_at` integer NOT NULL,
PRIMARY KEY(`user_id`, `gif_id`),
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_gif_favorites_user_id` ON `gif_favorites` (`user_id`);
@@ -0,0 +1,15 @@
CREATE TABLE `audit_events` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`actor_id` text,
`action` text NOT NULL,
`target_type` text,
`target_id` text,
`metadata` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`actor_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_audit_events_space_created` ON `audit_events` (`space_id`,`created_at`);--> statement-breakpoint
CREATE INDEX `idx_audit_events_actor` ON `audit_events` (`actor_id`);
@@ -0,0 +1,14 @@
CREATE TABLE `voice_sessions` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text,
`channel_id` text NOT NULL,
`user_id` text NOT NULL,
`started_at` integer NOT NULL,
`ended_at` integer,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_space_started` ON `voice_sessions` (`space_id`,`started_at`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_user` ON `voice_sessions` (`user_id`);--> statement-breakpoint
CREATE INDEX `idx_voice_sessions_ended` ON `voice_sessions` (`ended_at`);
@@ -0,0 +1,12 @@
CREATE TABLE `soundboard_sounds` (
`id` text PRIMARY KEY NOT NULL,
`space_id` text NOT NULL,
`name` text NOT NULL,
`filename` text NOT NULL,
`uploader_id` text,
`created_at` integer NOT NULL,
FOREIGN KEY (`space_id`) REFERENCES `spaces`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`uploader_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE set null
);
--> statement-breakpoint
CREATE INDEX `idx_soundboard_space` ON `soundboard_sounds` (`space_id`);
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
} }
] ]
} }
+1 -1
View File
@@ -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",
+4
View File
@@ -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'),
+96
View File
@@ -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),
}));
+13
View File
@@ -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);
+83
View File
@@ -0,0 +1,83 @@
import type { FastifyInstance } from 'fastify';
import { and, desc, eq, lt } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { hasPermission } from '../utils/permissions.js';
import { PermissionBits } from '@backspace/shared/src/permissions.js';
import { AUDIT_PAGE_SIZE, type AuditAction, type AuditEvent } from '@backspace/shared/src/audit.js';
export async function auditRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string }; Querystring: { before?: string; limit?: string } }>(
'/api/spaces/:id/audit-log',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Gated on MANAGE_SPACE rather than a new permission bit: the log names
// who did what to whom, which is administrator-shaped information, and a
// new bit would silently default to nobody until roles were re-edited.
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const limit = Math.min(Math.max(Number(request.query.limit) || AUDIT_PAGE_SIZE, 1), AUDIT_PAGE_SIZE);
const db = getDb();
// Ids are snowflakes, so ordering by id is chronological and gives a
// stable cursor even when two events land in the same millisecond.
const where = request.query.before
? and(eq(schema.auditEvents.spaceId, id), lt(schema.auditEvents.id, request.query.before))
: eq(schema.auditEvents.spaceId, id);
const rows = db.select({
id: schema.auditEvents.id,
spaceId: schema.auditEvents.spaceId,
action: schema.auditEvents.action,
targetType: schema.auditEvents.targetType,
targetId: schema.auditEvents.targetId,
metadata: schema.auditEvents.metadata,
createdAt: schema.auditEvents.createdAt,
actorId: schema.users.id,
actorUsername: schema.users.username,
actorDisplayName: schema.users.displayName,
actorAvatar: schema.users.avatar,
})
.from(schema.auditEvents)
.leftJoin(schema.users, eq(schema.auditEvents.actorId, schema.users.id))
.where(where)
.orderBy(desc(schema.auditEvents.id))
.limit(limit)
.all();
const events: AuditEvent[] = rows.map((r) => ({
id: r.id,
spaceId: r.spaceId,
action: r.action as AuditAction,
// Null when the account was deleted: the event stays, the actor does
// not — an audit log that vanished with its actor would be useless.
actor: r.actorId
? { id: r.actorId, username: r.actorUsername!, displayName: r.actorDisplayName, avatar: r.actorAvatar }
: null,
targetType: r.targetType,
targetId: r.targetId,
metadata: parseMetadata(r.metadata),
createdAt: r.createdAt,
}));
return reply.code(200).send({ events, hasMore: events.length === limit });
},
);
}
/** Metadata is written by us, but a malformed row must not break the whole page. */
function parseMetadata(raw: string | null): Record<string, unknown> | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as unknown;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
+28
View File
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm'; import { 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 });
}); });
+65 -1
View File
@@ -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();
});
} }
+85
View File
@@ -0,0 +1,85 @@
import type { FastifyInstance } from 'fastify';
import { eq } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { hasPermission, isMember } from '../utils/permissions.js';
import { PermissionBits } from '@backspace/shared/src/permissions.js';
import { generateSnowflake } from '../utils/snowflake.js';
/** A soundboard is a shortlist of gags, not a media library. */
const MAX_SOUNDS_PER_SPACE = 48;
const MAX_NAME_LENGTH = 32;
export async function soundboardRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string } }>(
'/api/spaces/:id/sounds',
{ preHandler: authenticate },
async (request, reply) => {
if (!isMember(request.params.id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const rows = getDb().select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.spaceId, request.params.id)).all();
return reply.code(200).send({ sounds: rows });
},
);
app.post<{ Params: { id: string }; Body: { name?: string; filename?: string } }>(
'/api/spaces/:id/sounds',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Adding is gated but playing is not: anyone in the call may press a
// button, only the people who run the space decide what the buttons are.
if (!hasPermission(request.userId, id, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
const name = (request.body?.name ?? '').trim().slice(0, MAX_NAME_LENGTH);
const filename = (request.body?.filename ?? '').trim();
if (!name || !filename) {
return reply.code(400).send({ error: 'name and filename are required', statusCode: 400 });
}
// The filename is a key into the upload directory, never a path.
if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) {
return reply.code(400).send({ error: 'Invalid filename', statusCode: 400 });
}
const db = getDb();
const count = db.select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.spaceId, id)).all().length;
if (count >= MAX_SOUNDS_PER_SPACE) {
return reply.code(409).send({ error: `At most ${MAX_SOUNDS_PER_SPACE} sounds`, statusCode: 409 });
}
const row = {
id: generateSnowflake(),
spaceId: id,
name,
filename,
uploaderId: request.userId,
createdAt: Date.now(),
};
db.insert(schema.soundboardSounds).values(row).run();
return reply.code(201).send(row);
},
);
app.delete<{ Params: { id: string } }>(
'/api/sounds/:id',
{ preHandler: authenticate },
async (request, reply) => {
const db = getDb();
const sound = db.select().from(schema.soundboardSounds)
.where(eq(schema.soundboardSounds.id, request.params.id)).get();
if (!sound) return reply.code(404).send({ error: 'Sound not found', statusCode: 404 });
if (!hasPermission(request.userId, sound.spaceId, PermissionBits.MANAGE_SPACE)) {
return reply.code(403).send({ error: 'Missing MANAGE_SPACE permission', statusCode: 403 });
}
db.delete(schema.soundboardSounds).where(eq(schema.soundboardSounds.id, request.params.id)).run();
return reply.code(204).send();
},
);
}
+48
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance } from 'fastify';
import { eq, and, inArray } from 'drizzle-orm'; import { 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);
}); });
+219
View File
@@ -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();
});
}
+94
View File
@@ -0,0 +1,94 @@
import type { FastifyInstance } from 'fastify';
import { and, eq, gte, sql, isNotNull, inArray } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { authenticate } from '../utils/auth.js';
import { isMember } from '../utils/permissions.js';
/** Windows the UI offers. Anything else is clamped into this range. */
const DEFAULT_DAYS = 30;
const MAX_DAYS = 365;
interface Leader {
userId: string;
username: string;
displayName: string | null;
avatar: string | null;
value: number;
}
export async function statsRoutes(app: FastifyInstance): Promise<void> {
app.get<{ Params: { id: string }; Querystring: { days?: string } }>(
'/api/spaces/:id/stats',
{ preHandler: authenticate },
async (request, reply) => {
const { id } = request.params;
// Any member may look: these are the group's own numbers, not moderation
// data. The audit log, which names who did what, stays admin-only.
if (!isMember(id, request.userId)) {
return reply.code(403).send({ error: 'Not a member of this space', statusCode: 403 });
}
const days = Math.min(Math.max(Number(request.query.days) || DEFAULT_DAYS, 1), MAX_DAYS);
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const db = getDb();
// Voice time. Only closed sessions count: an open one has no duration
// yet, and counting "now - startedAt" would make the numbers move every
// time the page is refreshed.
const voiceRows = db.select({
userId: schema.voiceSessions.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`sum(${schema.voiceSessions.endedAt} - ${schema.voiceSessions.startedAt})`,
})
.from(schema.voiceSessions)
.innerJoin(schema.users, eq(schema.voiceSessions.userId, schema.users.id))
.where(and(
eq(schema.voiceSessions.spaceId, id),
gte(schema.voiceSessions.startedAt, since),
isNotNull(schema.voiceSessions.endedAt),
))
.groupBy(schema.voiceSessions.userId)
.all() as Leader[];
// Messages. Scoped through the space's channels — the messages table has
// no space column.
const channelIds = db.select({ id: schema.channels.id })
.from(schema.channels)
.where(eq(schema.channels.spaceId, id))
.all()
.map((c) => c.id);
const messageRows = channelIds.length === 0 ? [] : db.select({
userId: schema.messages.userId,
username: schema.users.username,
displayName: schema.users.displayName,
avatar: schema.users.avatar,
value: sql<number>`count(*)`,
})
.from(schema.messages)
.innerJoin(schema.users, eq(schema.messages.userId, schema.users.id))
.where(and(
inArray(schema.messages.channelId, channelIds),
gte(schema.messages.createdAt, since),
))
.groupBy(schema.messages.userId)
.all() as Leader[];
const byValueDesc = (a: Leader, b: Leader) => b.value - a.value;
return reply.code(200).send({
days,
since,
voice: voiceRows.sort(byValueDesc),
messages: messageRows.sort(byValueDesc),
totals: {
voiceMs: voiceRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
messages: messageRows.reduce((sum, r) => sum + (r.value ?? 0), 0),
},
});
},
);
}
+36
View File
@@ -0,0 +1,36 @@
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
import type { AuditAction } from '@backspace/shared/src/audit.js';
interface RecordAuditEventInput {
spaceId: string;
actorId: string | null;
action: AuditAction;
targetType?: string | null;
targetId?: string | null;
metadata?: Record<string, unknown> | null;
}
/**
* Appends one entry to a space's audit log.
*
* Never throws: an audit write must not be able to fail the action it is
* describing. A moderator kicking someone must not see the kick fail because
* the log could not be written — the kick already happened.
*/
export function recordAuditEvent(input: RecordAuditEventInput): void {
try {
getDb().insert(schema.auditEvents).values({
id: generateSnowflake(),
spaceId: input.spaceId,
actorId: input.actorId,
action: input.action,
targetType: input.targetType ?? null,
targetId: input.targetId ?? null,
metadata: input.metadata ? JSON.stringify(input.metadata) : null,
createdAt: Date.now(),
}).run();
} catch (err) {
console.warn('[audit] failed to record event', input.action, err);
}
}
@@ -0,0 +1,65 @@
import { and, eq, isNull, sql } from 'drizzle-orm';
import { getDb, schema } from '../db/index.js';
import { generateSnowflake } from './snowflake.js';
/**
* Opens a session when someone joins a voice room.
*
* Never throws: statistics must not be able to break a call. Closes any
* dangling session for the same user first — the one-room-per-user invariant
* means a second open row would be a bookkeeping error, not two real calls.
*/
export function openVoiceSession(input: {
spaceId: string | null;
channelId: string;
userId: string;
}): void {
try {
const db = getDb();
closeVoiceSession(input.userId);
db.insert(schema.voiceSessions).values({
id: generateSnowflake(),
spaceId: input.spaceId,
channelId: input.channelId,
userId: input.userId,
startedAt: Date.now(),
endedAt: null,
}).run();
} catch (err) {
console.warn('[voice-sessions] failed to open session', err);
}
}
/** Closes the user's open session, if any. Never throws. */
export function closeVoiceSession(userId: string): void {
try {
getDb().update(schema.voiceSessions)
.set({ endedAt: Date.now() })
.where(and(eq(schema.voiceSessions.userId, userId), isNull(schema.voiceSessions.endedAt)))
.run();
} catch (err) {
console.warn('[voice-sessions] failed to close session', err);
}
}
/**
* Closes sessions left open by a crash or restart.
*
* Their real end time is unknowable. Ending them at `startedAt` — a zero-length
* session — discards that time rather than inventing it: crediting the gap
* would silently hand someone hours they never spent, and the numbers are the
* entire point of keeping this table.
*/
export function closeOrphanedVoiceSessions(): void {
try {
const result = getDb().update(schema.voiceSessions)
.set({ endedAt: sql`${schema.voiceSessions.startedAt}` })
.where(isNull(schema.voiceSessions.endedAt))
.run();
if (result.changes > 0) {
console.log(`[voice-sessions] closed ${result.changes} session(s) orphaned by a restart`);
}
} catch (err) {
console.warn('[voice-sessions] failed to close orphans', err);
}
}
+66 -3
View File
@@ -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);
+50 -2
View File
@@ -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,
})); }));
+3 -1
View File
@@ -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",
+44
View File
@@ -0,0 +1,44 @@
/**
* Audit action vocabulary, shared so the server writes and the client renders
* the same strings. Values are stored in the database, so renaming one
* rewrites history — add new actions instead.
*/
export const AUDIT_ACTIONS = [
'space.update',
'space.transfer_ownership',
'channel.create',
'channel.update',
'channel.delete',
'member.kick',
'member.leave',
'member.ban',
'member.unban',
'role.create',
'role.update',
'role.delete',
'invite.create',
'message.delete',
] as const;
export type AuditAction = (typeof AUDIT_ACTIONS)[number];
export interface AuditEventActor {
id: string;
username: string;
displayName: string | null;
avatar: string | null;
}
export interface AuditEvent {
id: string;
spaceId: string;
action: AuditAction;
actor: AuditEventActor | null;
targetType: string | null;
targetId: string | null;
/** Shape depends on `action`; used for display only. */
metadata: Record<string, unknown> | null;
createdAt: number;
}
export const AUDIT_PAGE_SIZE = 50;
+11 -3
View File
@@ -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 }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+83
View File
@@ -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);
+91
View File
@@ -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;
} }
+138 -23
View File
@@ -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
+8 -9
View File
@@ -1,9 +1,11 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'; import React, { useState, useRef, useEffect, useCallback } from 'react';
import { useT } from '../../i18n';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import type { MessageWithUser, Embed, User } from '@backspace/shared'; import type { MessageWithUser, Embed, User } from '@backspace/shared';
import { MarkdownRenderer } from './MarkdownRenderer'; import { MarkdownRenderer } from './MarkdownRenderer';
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';
@@ -130,6 +132,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
const editMessage = useChatStore((s) => s.editMessage); const editMessage = useChatStore((s) => s.editMessage);
const deleteMessage = useChatStore((s) => s.deleteMessage); const deleteMessage = useChatStore((s) => s.deleteMessage);
const members = useSpaceStore((s) => s.members); const members = useSpaceStore((s) => s.members);
const tr = useT();
const openUserProfile = useUIStore((s) => s.openUserProfile); const openUserProfile = useUIStore((s) => s.openUserProfile);
const pending = isPendingMessage(message) ? message.__pending : null; const pending = isPendingMessage(message) ? message.__pending : null;
@@ -266,11 +269,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 +416,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}
@@ -679,7 +678,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${ className={`p-1 hover:bg-interactive-hover rounded transition-colors text-[14px] leading-none ${
showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary' showReactionPicker ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
title="Add reaction" title={tr('chat.message.addReaction')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm1-13h-2v4H7v2h4v4h2v-4h4v-2h-4V7z" />
@@ -690,7 +689,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
<button <button
onClick={() => setReplyTo(message)} onClick={() => setReplyTo(message)}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center" className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Reply" title={tr('chat.message.reply')}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" /> <path d="M10 9V5L3 12L10 19V14.9C15 14.9 18.5 16.5 21 20C20 15 17 10 10 9Z" />
@@ -703,7 +702,7 @@ export function Message({ message, isCompact, isFirstInGroup, previousMessageId
setIsEditing(true); setIsEditing(true);
}} }}
className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center" className="px-2 h-full text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover transition-all flex items-center justify-center"
title="Edit" title={tr('chat.message.edit')}
> >
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
@@ -1,4 +1,5 @@
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react'; import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
import { useT } from '../../i18n';
import { useChatStore } from '../../stores/chatStore'; import { useChatStore } from '../../stores/chatStore';
import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore'; import { isDmChannel, getChannelOrigin, useSpaceStore } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
@@ -94,6 +95,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
// Feature flags // Feature flags
const tr = useT();
const gifEnabled = useSettingsStore((s) => s.gifEnabled); const gifEnabled = useSettingsStore((s) => s.gifEnabled);
// Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES // Permission gating: DM channels always allow sending; space channels check SEND_MESSAGES
@@ -775,7 +777,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{chatReplyTo && ( {chatReplyTo && (
<div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]"> <div className="bg-interactive-hover rounded-t-lg px-4 py-2 flex items-center justify-between border-b border-white/[0.06]">
<div className="flex items-center gap-1 text-[14px] text-txt-message truncate"> <div className="flex items-center gap-1 text-[14px] text-txt-message truncate">
<span className="opacity-60">Replying to</span> <span className="opacity-60">{tr('chat.composer.replyingTo')}</span>
<span className="font-bold"> <span className="font-bold">
{chatReplyTo.user.displayName ?? chatReplyTo.user.username} {chatReplyTo.user.displayName ?? chatReplyTo.user.username}
</span> </span>
@@ -783,7 +785,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button <button
onClick={() => chatSetReplyTo(null)} onClick={() => chatSetReplyTo(null)}
className="text-txt-tertiary hover:text-txt-primary transition-colors" className="text-txt-tertiary hover:text-txt-primary transition-colors"
aria-label="Cancel reply" aria-label={tr('chat.composer.cancelReply')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" /> <path d="M18.4 4L12 10.4L5.6 4L4 5.6L10.4 12L4 18.4L5.6 20L12 13.6L18.4 20L20 18.4L13.6 12L20 5.6L18.4 4Z" />
@@ -867,7 +869,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button <button
onClick={() => removeStagedTransfer(t.id)} onClick={() => removeStagedTransfer(t.id)}
className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10" className="absolute -top-2 -right-2 w-7 h-7 bg-accent-rose hover:bg-accent-rose/80 shadow-elevation-high rounded-lg flex items-center justify-center text-white transition-colors z-10"
aria-label="Remove attachment" aria-label={tr('chat.composer.removeAttachment')}
> >
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor">
<path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" /> <path d="M5 2a1 1 0 011-1h4a1 1 0 011 1v1h3a1 1 0 110 2h-.08L13 14a2 2 0 01-2 2H5a2 2 0 01-2-2L2.08 5H2a1 1 0 110-2h3V2zm2 0v1h2V2H7z" />
@@ -886,8 +888,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
<button <button
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0" className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] text-txt-tertiary hover:text-txt-secondary transition-colors flex-shrink-0"
title="Attach file" title={tr('chat.composer.attach')}
aria-label="Attach file" aria-label={tr('chat.composer.attach')}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm5 11h-4v4h-2v-4H7v-2h4V7h2v4h4v2z" />
@@ -915,14 +917,16 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onChange={handleChange} onChange={handleChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={canAttachFiles ? handlePaste : undefined} onPaste={canAttachFiles ? handlePaste : undefined}
placeholder={placeholder ?? `Message ${channelName.startsWith('@') ? channelName : `#${channelName}`}`} placeholder={placeholder ?? tr('chat.composer.placeholder', {
channel: channelName.startsWith('@') ? channelName : `#${channelName}`,
})}
className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin" className="input-embedded flex-1 py-[10px] px-1 resize-none text-[15px] leading-[1.375rem] max-h-[50vh] scrollbar-thin"
rows={1} rows={1}
/> />
{/* Active-upload indicator */} {/* Active-upload indicator */}
{anyActiveOrQueued && ( {anyActiveOrQueued && (
<div className="p-3 text-txt-tertiary" title="Uploading…" aria-label="Uploading"> <div className="p-3 text-txt-tertiary" title={tr('chat.composer.uploadingEllipsis')} aria-label={tr('chat.composer.uploading')}>
<svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none"> <svg className="w-5 h-5 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /> <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /> <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
@@ -934,7 +938,7 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
{failedCount > 0 && ( {failedCount > 0 && (
<span <span
className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0" className="text-[12px] font-medium text-accent-rose px-1 flex-shrink-0"
title="Remove or retry the failed attachment to send" title={tr('chat.composer.failedAttachment')}
> >
{failedCount} failed {failedCount} failed
</span> </span>
@@ -956,11 +960,17 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${ className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary' activePopover === 'gif' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
title="GIF" title={tr('chat.composer.gif')}
aria-label="GIF picker" aria-label={tr('chat.composer.gifPicker')}
> >
<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>
)} )}
@@ -971,8 +981,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${ className={`w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] transition-colors flex-shrink-0 ${
activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary' activePopover === 'emoji' ? 'text-accent-primary' : 'text-txt-tertiary hover:text-txt-secondary'
}`} }`}
title="Emoji" title={tr('chat.composer.emoji')}
aria-label="Emoji picker" aria-label={tr('chat.composer.emojiPicker')}
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm3.5-9c.83 0 1.5-.67 1.5-1.5S16.33 8 15.5 8 14 8.67 14 9.5s.67 1.5 1.5 1.5zm-7 0c.83 0 1.5-.67 1.5-1.5S9.33 8 8.5 8 7 8.67 7 9.5s.67 1.5 1.5 1.5zm3.5 6.5c2.33 0 4.31-1.46 5.11-3.5H6.89c.8 2.04 2.78 3.5 5.11 3.5z" />
@@ -985,8 +995,8 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
onClick={() => void handleSubmit()} onClick={() => void handleSubmit()}
disabled={anyUnshippable} disabled={anyUnshippable}
className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50" className="w-10 h-10 md:w-[34px] md:h-[34px] flex items-center justify-center rounded-[6px] bg-accent-primary hover:bg-accent-primary-hover text-white transition-all duration-150 flex-shrink-0 disabled:opacity-50"
aria-label="Send message" aria-label={tr('chat.composer.sendMessage')}
title="Send" title={tr('chat.composer.send')}
> >
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" /> <path d="M3.4 20.4l17.45-7.48a1 1 0 000-1.84L3.4 3.6a.993.993 0 00-1.39.91L2 9.12c0 .5.37.93.87.99L17 12 2.87 13.88c-.5.07-.87.5-.87 1l.01 4.61c0 .71.73 1.2 1.39.91z" />
@@ -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}
/> />
</> </>
)} )}
@@ -1,4 +1,5 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { useT } from '../../i18n';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import type { Channel } from '@backspace/shared'; import type { Channel } from '@backspace/shared';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
@@ -7,9 +8,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';
@@ -25,6 +27,7 @@ import { useAudioDevices } from '../../hooks/useAudioDevices';
import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives'; import { DropdownItem } from '../modals/settingsPanels/_shared/SettingsPickerPrimitives';
export function ChannelSidebar() { export function ChannelSidebar() {
const tr = useT();
const spaces = useSpaceStore((s) => s.spaces); const spaces = useSpaceStore((s) => s.spaces);
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
@@ -317,7 +320,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'create-channel', key: 'create-channel',
type: 'action', type: 'action',
label: 'Create Channel', label: tr('sidebar.createChannel'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" /> <path d="M2.5 12.5v-9l5-2v9l-5 2zm6-9v9l5-2v-9l-5 2z" opacity="0.5" />
@@ -329,7 +332,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'create-category', key: 'create-category',
type: 'action', type: 'action',
label: 'Create Category', label: tr('sidebar.createCategory'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /> <path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" />
@@ -342,7 +345,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'invite', key: 'invite',
type: 'action', type: 'action',
label: 'Invite People', label: tr('sidebar.invitePeople'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" /> <path d="M21 3H24V5H21V8H19V5H16V3H19V0H21V3ZM10 12C12.21 12 14 10.21 14 8C14 5.79 12.21 4 10 4C7.79 4 6 5.79 6 8C6 10.21 7.79 12 10 12ZM10 13C6.69 13 1 14.66 1 18V20H19V18C19 14.66 13.31 13 10 13Z" />
@@ -354,7 +357,7 @@ export function ChannelSidebar() {
items.push({ items.push({
key: 'settings', key: 'settings',
type: 'action', type: 'action',
label: 'Space Settings', label: tr('sidebar.spaceSettings'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1112 8.4a3.6 3.6 0 010 7.2z" />
@@ -372,7 +375,7 @@ export function ChannelSidebar() {
{ {
key: 'leave-group', key: 'leave-group',
type: 'action', type: 'action',
label: 'Leave Group', label: tr('sidebar.leaveGroup'),
danger: true, danger: true,
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -452,7 +455,7 @@ export function ChannelSidebar() {
<path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" /> <path d="M3 18a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-1c0-2.76-5.37-4-8-4s-8 1.24-8 4v1Z" />
<path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" /> <path d="M3.5 13.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z" opacity=".5" />
</svg> </svg>
<span className="font-medium text-[16px]">Friends</span> <span className="font-medium text-[16px]">{tr('sidebar.friends')}</span>
</div> </div>
{/* Placeholder nav items */} {/* Placeholder nav items */}
@@ -462,7 +465,7 @@ export function ChannelSidebar() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" /> <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z" />
</svg> </svg>
<span className="font-medium text-[16px]">Coming Soon</span> <span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
</div> </div>
<div <div
className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50" className="flex items-center gap-3 px-2 h-[42px] rounded-[6px] mb-[2px] text-txt-tertiary cursor-default opacity-50"
@@ -470,11 +473,11 @@ export function ChannelSidebar() {
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0"> <svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor" className="flex-shrink-0">
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" /> <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-7 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6z" />
</svg> </svg>
<span className="font-medium text-[16px]">Coming Soon</span> <span className="font-medium text-[16px]">{tr('sidebar.comingSoon')}</span>
</div> </div>
<div className="mt-[18px] px-2 mb-1 flex items-center justify-between group"> <div className="mt-[18px] px-2 mb-1 flex items-center justify-between group">
<span className="text-[12px] font-bold text-txt-tertiary tracking-wider">Direct Messages</span> <span className="text-[12px] font-bold text-txt-tertiary tracking-wider">{tr('sidebar.directMessages')}</span>
<button <button
onClick={() => openModal('newDm')} onClick={() => openModal('newDm')}
className="text-txt-tertiary hover:text-txt-primary transition-colors" className="text-txt-tertiary hover:text-txt-primary transition-colors"
@@ -583,7 +586,7 @@ export function ChannelSidebar() {
{/* Channels — dynamic category layout */} {/* Channels — dynamic category layout */}
<div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}> <div ref={scrollContainerRef} className="flex-1 overflow-y-auto pt-3 px-2 no-scrollbar" style={{ paddingBottom: floatingPanelHeight + 24 }} onDrop={containerHandlers.onDrop} onDragOver={containerHandlers.onDragOver} onContextMenu={handleSidebarContextMenu}>
{showChannelSkeleton ? ( {showChannelSkeleton ? (
<div className="px-2 pt-3" role="status" aria-label="Loading channels"> <div className="px-2 pt-3" role="status" aria-label={tr('sidebar.loadingChannels')}>
{/* Category group 1 */} {/* Category group 1 */}
<div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" /> <div className="skeleton skeleton-bar h-2 w-[45%] ml-2 mb-3" />
{Array.from({ length: 3 }, (_, i) => ( {Array.from({ length: 3 }, (_, i) => (
@@ -705,7 +708,7 @@ export function ChannelSidebar() {
{ {
key: 'category-settings', key: 'category-settings',
type: 'action', type: 'action',
label: 'Category Settings', label: tr('sidebar.categorySettings'),
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 00.12-.61l-1.92-3.32a.49.49 0 00-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 00-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.49.49 0 00-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58a.49.49 0 00-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1115.6 12 3.611 3.611 0 0112 15.6z" />
@@ -716,7 +719,7 @@ export function ChannelSidebar() {
{ {
key: 'delete-category', key: 'delete-category',
type: 'action', type: 'action',
label: 'Delete Category', label: tr('sidebar.deleteCategory'),
danger: true, danger: true,
icon: ( icon: (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"> <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
@@ -757,7 +760,7 @@ export function ChannelSidebar() {
/> />
))} ))}
{catChannels.length === 0 && ( {catChannels.length === 0 && (
<div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">No channels</div> <div className="px-2 py-2 text-[12px] text-txt-tertiary italic opacity-40">{tr('sidebar.noChannels')}</div>
)} )}
</div> </div>
)} )}
@@ -776,7 +779,7 @@ export function ChannelSidebar() {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" /> <path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg> </svg>
<span className="text-[12px]">Create Channel</span> <span className="text-[12px]">{tr('sidebar.createChannel')}</span>
</button> </button>
)} )}
<button <button
@@ -786,7 +789,7 @@ export function ChannelSidebar() {
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70"> <svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor" className="flex-shrink-0 opacity-70">
<path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" /> <path d="M8 2a.5.5 0 01.5.5v5h5a.5.5 0 010 1h-5v5a.5.5 0 01-1 0v-5h-5a.5.5 0 010-1h5v-5A.5.5 0 018 2z" />
</svg> </svg>
<span className="text-[12px]">Create Category</span> <span className="text-[12px]">{tr('sidebar.createCategory')}</span>
</button> </button>
</div> </div>
)} )}
@@ -844,6 +847,8 @@ function UserAreaPanel({
onDeafenToggle: () => void; onDeafenToggle: () => void;
onSettingsClick: (tab?: string) => void; onSettingsClick: (tab?: string) => void;
}) { }) {
const tr = useT();
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null); const [openPanel, setOpenPanel] = useState<'input' | 'output' | null>(null);
const inputDeviceId = useVoiceStore((s) => s.inputDeviceId); const inputDeviceId = useVoiceStore((s) => s.inputDeviceId);
const outputDeviceId = useVoiceStore((s) => s.outputDeviceId); const outputDeviceId = useVoiceStore((s) => s.outputDeviceId);
@@ -957,7 +962,7 @@ function UserAreaPanel({
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-txt-primary text-left">Input Device</div> <div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.inputDevice')}</div>
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div> <div className="text-[13px] text-txt-tertiary truncate text-left">{selectedInputLabel}</div>
</div> </div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
@@ -1002,7 +1007,7 @@ function UserAreaPanel({
{/* Input Volume */} {/* Input Volume */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="text-[15px] font-semibold text-txt-primary mb-2">Input Volume</div> <div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.inputVolume')}</div>
<input <input
type="range" type="range"
min={0} min={0}
@@ -1037,7 +1042,7 @@ function UserAreaPanel({
onClick={() => onSettingsClick('voice')} onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span> <span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg> </svg>
@@ -1055,7 +1060,7 @@ function UserAreaPanel({
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold text-txt-primary text-left">Output Device</div> <div className="text-[15px] font-semibold text-txt-primary text-left">{tr('sidebar.outputDevice')}</div>
<div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div> <div className="text-[13px] text-txt-tertiary truncate text-left">{selectedOutputLabel}</div>
</div> </div>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0 ml-2">
@@ -1100,7 +1105,7 @@ function UserAreaPanel({
{/* Output Volume */} {/* Output Volume */}
<div className="px-4 py-3"> <div className="px-4 py-3">
<div className="text-[15px] font-semibold text-txt-primary mb-2">Output Volume</div> <div className="text-[15px] font-semibold text-txt-primary mb-2">{tr('sidebar.outputVolume')}</div>
<input <input
type="range" type="range"
min={0} min={0}
@@ -1123,7 +1128,7 @@ function UserAreaPanel({
onClick={() => onSettingsClick('voice')} onClick={() => onSettingsClick('voice')}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors" className="w-full px-4 py-3 flex items-center justify-between hover:bg-interactive-hover transition-colors"
> >
<span className="text-[15px] font-semibold text-txt-primary">Voice Settings</span> <span className="text-[15px] font-semibold text-txt-primary">{tr('sidebar.voiceSettings')}</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary"> <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
<path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" /> <path d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.07.62-.07.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg> </svg>
@@ -1132,14 +1137,27 @@ function UserAreaPanel({
)} )}
{/* User area bar */} {/* User area bar */}
<div className="h-[52px] px-2 flex items-center select-none"> <div className="relative h-[52px] px-2 flex items-center select-none">
{/* Avatar + name */} {accountMenuOpen && (
<div className="p-1 hover:bg-interactive-hover rounded-[4px] flex items-center gap-2 flex-1 min-w-0 cursor-pointer transition-colors group"> <AccountMenu
<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') {
@@ -1,4 +1,5 @@
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useT } from '../../i18n';
import type { MemberWithUser, Activity } from '@backspace/shared'; import type { MemberWithUser, Activity } from '@backspace/shared';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
@@ -21,7 +22,7 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
const ownerRole = member.roles?.find(r => r.position > 0); const ownerRole = member.roles?.find(r => r.position > 0);
return { return {
key: '__owner__', key: '__owner__',
label: 'OWNER', label: '__OWNER__',
color: ownerRole?.color ?? 'rgb(var(--accent-rose))', color: ownerRole?.color ?? 'rgb(var(--accent-rose))',
position: Infinity, position: Infinity,
}; };
@@ -40,7 +41,10 @@ function getMemberGroup(member: MemberWithUser, ownerId: string | undefined) {
// No explicit roles — just @everyone // No explicit roles — just @everyone
return { return {
key: '__online__', key: '__online__',
label: 'ONLINE', // Marcado em vez de traduzido aqui: esta funcao nao e um componente e nao
// pode usar o hook. Nomes de cargo passam adiante sem traducao — sao dados
// do usuario, nao interface.
label: '__ONLINE__',
color: undefined, color: undefined,
position: -1, position: -1,
}; };
@@ -111,6 +115,7 @@ export function MemberSidebar() {
const currentSpaceId = useSpaceStore((s) => s.currentSpaceId); const currentSpaceId = useSpaceStore((s) => s.currentSpaceId);
const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId); const loadingSpaceId = useSpaceStore((s) => s.loadingSpaceId);
const memberListOpen = useUIStore((s) => s.memberListOpen); const memberListOpen = useUIStore((s) => s.memberListOpen);
const tr = useT();
const openUserProfile = useUIStore((s) => s.openUserProfile); const openUserProfile = useUIStore((s) => s.openUserProfile);
const userActivities = useActivityStore((s) => s.userActivities); const userActivities = useActivityStore((s) => s.userActivities);
@@ -157,11 +162,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) => {
@@ -187,7 +188,7 @@ export function MemberSidebar() {
return ( return (
<div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard"> <div className="w-60 bg-surface-members flex-shrink-0 overflow-y-auto select-none no-scrollbar hidden md:block border-l border-border-hard">
{showMemberSkeleton ? ( {showMemberSkeleton ? (
<div className="px-3 pt-4" role="status" aria-label="Loading members"> <div className="px-3 pt-4" role="status" aria-label={tr('sidebar.loadingMembers')}>
{/* Role group 1 */} {/* Role group 1 */}
<div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} /> <div className="skeleton skeleton-bar h-2 w-[40%] mb-3" style={{ animationDelay: '0s' }} />
{Array.from({ length: 2 }, (_, i) => ( {Array.from({ length: 2 }, (_, i) => (
@@ -211,7 +212,11 @@ export function MemberSidebar() {
{roleGroups.map(([key, group]) => ( {roleGroups.map(([key, group]) => (
<div key={key} className="mb-4"> <div key={key} className="mb-4">
<h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1"> <h3 className="text-[10.5px] font-bold text-txt-tertiary uppercase tracking-[0.06em] px-2 mb-1">
{group.label} {group.members.length} {group.label === '__ONLINE__'
? tr('sidebar.groupOnline')
: group.label === '__OWNER__'
? tr('sidebar.groupOwner')
: group.label} {group.members.length}
</h3> </h3>
{group.members.map((m) => renderMember(m))} {group.members.map((m) => renderMember(m))}
</div> </div>
@@ -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 &amp; 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 &amp; 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);
});
});
+2 -17
View File
@@ -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}

Some files were not shown because too many files have changed in this diff Show More