8 Commits
Author SHA1 Message Date
devsyncwrld b92a0d837e docs: update roadmap after mic test and activity work
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
2026-08-31 12: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
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 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
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 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)
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
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
15 changed files with 541 additions and 38 deletions
+22 -3
View File
@@ -21,6 +21,15 @@ COPY packages/web/package.json packages/web/
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ patches/
# better-sqlite3 publishes no prebuilt binary for Node 20 (ABI 115) — its
# releases cover ABI 127/137/141/147 only — so prebuild-install falls back to
# compiling with node-gyp, which needs python3/make/g++. node:20-slim ships
# none of them. Builder stage only: the runtime stage copies the compiled
# .node and stays slim.
RUN apt-get update && apt-get install -y --no-install-recommends \
python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
# Install dependencies
RUN pnpm install --frozen-lockfile
@@ -39,7 +48,9 @@ FROM node:20-slim AS runtime
RUN corepack enable && corepack prepare pnpm@10.34.3 --activate
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the
# entrypoint). No C toolchain — better-sqlite3 and sharp load prebuilt binaries.
# entrypoint). sharp is N-API (ABI-independent) and loads a prebuilt binary;
# better-sqlite3 no longer ships one for Node 20, so it is compiled below with
# a toolchain that is purged in the same layer.
RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg gosu && \
rm -rf /var/lib/apt/lists/*
@@ -58,8 +69,16 @@ COPY packages/web/package.json packages/web/
# Copy patches (referenced by pnpm-lock.yaml)
COPY patches/ patches/
# Install production dependencies only (tsx is in server dependencies)
RUN pnpm install --prod --frozen-lockfile
# Install production dependencies only (tsx is in server dependencies).
# better-sqlite3 compiles from source here (no Node 20 prebuilt), so the C
# toolchain is installed, used and purged inside this single layer — the final
# image ships no compiler.
RUN apt-get update && \
apt-get install -y --no-install-recommends python3 make g++ && \
pnpm install --prod --frozen-lockfile && \
apt-get purge -y python3 make g++ && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*
# Copy shared source (needed at runtime since server imports types directly)
COPY packages/shared/ packages/shared/
+79
View File
@@ -0,0 +1,79 @@
# 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 |
| Preview de perfil nos participantes da call | (ver git log) | O popout já existia e era aberto de 11 lugares; **nenhum era de voz**. Ligado nas linhas da lista de voz e no nome dos tiles da grade |
## Já existia no código (verificado, não construir de novo)
- **Animação de digitação** — `TypingIndicator.tsx`, três pontos `animate-bounce`
escalonados em 0/150/300ms.
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
mute, ringing. Os `.ogg` estão em `web/public/sounds/`.
- **Preview de perfil ancorado** — `uiStore.openUserProfile(user, anchor, placement)`
guarda `userProfilePopout`; popout posicionado no desktop, tela cheia no
mobile. Já era aberto por mensagens, menções, avatares, lista de membros,
DMs e painel de atividade. O que faltava era só a voz — agora ligado.
Continua faltando o bloco de atividade do print do Discord, que é a #8.
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
não implementação.
## Pendente — pedido original
Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
| # | Feature | Tamanho | Observação técnica |
|---|---|---|---|
| 6 | Favoritar GIFs + categorias | Grande | Precisa de tabela, migração drizzle e API para sincronizar entre dispositivos, como no Discord |
| 8 | **Produtor** de atividade do Spotify | Grande | O consumo está pronto (`ProfileActivity` + pipeline completo). Falta algo que *gere* a atividade com faixa e artista — ver abaixo |
| 9 | Registro de auditoria | Grande | Schema + ganchos em cada mutação do servidor + interface |
## Pendente — ideias aprovadas
| Feature | Tamanho | Observação técnica |
|---|---|---|
| Soundboard | Média | `AudioManager` já carrega e toca `.ogg` sob demanda; falta upload por espaço, permissão e disparo na sala LiveKit |
| Estatísticas do grupo | Grande | Horas em call, quem mais falou, ranking. **Depende do mesmo registro de eventos da auditoria (#9)** |
| Watch party | Grande | O screen share do LiveKit já existe; falta sincronizar posição de reprodução entre participantes |
| Emojis e stickers do grupo | Média | `UPLOAD_DIR` e o pipeline de upload já existem; falta tabela por espaço e resolução no render de mensagem |
## O que falta para o Spotify (#8)
O caminho de consumo está inteiro: tipo, store, WebSocket, validação no
servidor, relay de presença e agora o bloco no perfil. **Falta um produtor.**
Três opções, com custos bem diferentes:
1. **Entrada no dicionário do detector** (`activityDetector.ts` lê um JSON de
processos, e `listening` já é um tipo válido). Custo quase zero, mas dá
apenas "Listening to Spotify" — sem faixa nem artista — e **só no app
Electron**.
2. **Ler o título da janela do Spotify** no processo main do Electron. O título
é "Artista - Faixa", então preenche `details` e `state`. Ainda só desktop, e
sem capa nem duração.
3. **Spotify Web API com OAuth.** É a única que cobre quem usa pelo navegador —
que é a maioria do grupo — e a única que traz capa e progresso.
**Bloqueio:** exige registrar um app no dashboard do Spotify e obter
client id/secret. Isso é ação sua; eu não consigo fazer.
## 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.
+13 -2
View File
@@ -473,9 +473,16 @@ function validateActivities(raw: unknown): Activity[] | null {
if (obj.assets && typeof obj.assets === 'object') {
const aObj = obj.assets as Record<string, unknown>;
const assets: ActivityAssets = {};
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.largeImage = aObj.largeImage;
// Image assets are rendered as <img src> by clients, so they get the same
// scheme check `url` above already has. Without it a client could point
// them at a host it controls and harvest the IP of everyone who opens
// that profile — and data: URIs would smuggle payloads through a field
// only length-checked.
if (typeof aObj.largeImage === 'string' && aObj.largeImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
&& isHttpUrl(aObj.largeImage)) assets.largeImage = aObj.largeImage;
if (typeof aObj.largeText === 'string' && aObj.largeText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.largeText = aObj.largeText;
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH) assets.smallImage = aObj.smallImage;
if (typeof aObj.smallImage === 'string' && aObj.smallImage.length <= ACTIVITY_LIMITS.MAX_URL_LENGTH
&& isHttpUrl(aObj.smallImage)) assets.smallImage = aObj.smallImage;
if (typeof aObj.smallText === 'string' && aObj.smallText.length <= ACTIVITY_LIMITS.MAX_ASSET_TEXT_LENGTH) assets.smallText = aObj.smallText;
if (Object.keys(assets).length > 0) activity.assets = assets;
}
@@ -485,6 +492,10 @@ function validateActivities(raw: unknown): Activity[] | null {
return validated;
}
function isHttpUrl(value: string): boolean {
return value.startsWith('https://') || value.startsWith('http://');
}
function handlePresenceUpdate(event: Record<string, unknown>, userId: string): void {
const status = event.status as string;
+63
View File
@@ -32,6 +32,10 @@ export class AudioManager {
private rnnoiseReady = false;
private keepAliveOscillator: OscillatorNode | null = null;
// Mic test (settings → Voice). See startMicTest().
private micTestGain: GainNode | null = null;
private micTestStream: MediaStream | null = null;
// Cached `getUserMedia` denial. After a NotAllowedError, subsequent
// `setInputDevice` calls (e.g. `useLiveKit.syncMic` racing the user's
// tap on a denial prompt) re-throw the cached error WITHOUT issuing a
@@ -567,6 +571,65 @@ export class AudioManager {
osc.stop(now + 0.45);
}
/**
* Mic test: routes the processed input bus to the speakers so the user hears
* themselves, outside of any call.
*
* Settings deliberately never opened the mic on their own — the level meter
* only measures a stream that a call had already established. A mic test
* cannot honour that, so this is the one path that opens it, and
* `stopMicTest` hands it back rather than leaving the mic indicator lit.
*
* Returns false when the mic could not be opened (denied, unplugged).
*/
async startMicTest(): Promise<boolean> {
if (this.micTestGain) return true;
const ctx = this.ensureContext();
await this.resumeContext();
const hadStream = this.hasActiveStream();
if (!hadStream) {
const stream = await this.setInputDevice(this.currentInputDeviceId);
if (!stream) return false;
// Remember the exact stream we opened, so stopMicTest only ever stops
// that one — never a stream something else established meanwhile.
this.micTestStream = this.currentStream;
}
this.micTestGain = ctx.createGain();
this.inputGain!.connect(this.micTestGain);
this.micTestGain.connect(this.getMasterOutput());
return true;
}
/**
* Tears down the loopback.
*
* @param allowRelease Whether the mic may be handed back. Only the caller
* knows whether a call has started since the test began — AudioManager
* does not read stores — so releasing needs its consent as well as our own
* record that this test is what opened the stream.
*/
stopMicTest(allowRelease: boolean): void {
if (!this.micTestGain) return;
try { this.inputGain?.disconnect(this.micTestGain); } catch { /* graph already torn down */ }
try { this.micTestGain.disconnect(); } catch { /* already detached */ }
this.micTestGain = null;
if (allowRelease && this.micTestStream && this.currentStream === this.micTestStream) {
// Detach listeners before stopping (see `_setInputDeviceImpl`).
const tracks = this.currentStream.getTracks();
tracks.forEach(t => { t.onended = null; });
tracks.forEach(t => t.stop());
this.currentStream = null;
}
this.micTestStream = null;
}
isMicTestActive(): boolean {
return this.micTestGain !== null;
}
getContext(): AudioContext | null {
return this.ctx;
}
@@ -959,8 +959,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
title="GIF"
aria-label="GIF picker"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor">
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
{/* Outlined badge, not a filled block: the solid rectangle read as
a plain square rather than a GIF picker. Letters reuse the
original glyph paths, scaled and centred inside the outline. */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="3" stroke="currentColor" strokeWidth="2" />
<g fill="currentColor" transform="translate(-1.77 -4.2) scale(1.35)">
<path d="M5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
</g>
</svg>
</button>
)}
@@ -3,6 +3,8 @@ import { useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { ProfileActivity } from '../ui/ProfileActivity';
import { useActivityStore } from '../../stores/activityStore';
import { Username } from '../ui/Username';
import { useUIStore } from '../../stores/uiStore';
import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore';
@@ -149,6 +151,13 @@ export function UserProfileModal() {
// Banner — use correct API client for remote users
const profileApi = getApiForOrigin(userOrigin);
// Keyed by home id, matching every other activity consumer (ActivityPanel,
// MemberSidebar), so federated users resolve to the same record. The `?? []`
// stays OUTSIDE the selector: building it inside would hand zustand a fresh
// array reference every render and spin.
const activityList = useActivityStore((s) => s.userActivities.get(user.homeUserId ?? user.id));
const activities = activityList ?? [];
const bannerSrc = user.banner
? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner))
: null;
@@ -353,6 +362,9 @@ export function UserProfileModal() {
</div>
)}
{/* Current activity — the "Listening to Spotify" block */}
<ProfileActivity activities={activities} />
{/* Member Since */}
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
@@ -5,6 +5,7 @@ import { useInstanceStore } from '../../../stores/instanceStore';
import { useSpaceStore } from '../../../stores/spaceStore';
import { Avatar } from '../../ui/Avatar';
import { ImageCropModal } from '../../ui/ImageCropModal';
import { GifPicker } from '../../chat/GifPicker';
import { DeleteAccountModal } from '../DeleteAccountModal';
import { api } from '../../../api/client';
import { useTransferStore } from '../../../stores/transferStore';
@@ -13,6 +14,16 @@ import { getAvatarGradient, adjustColor, mutedGradient, AVATAR_GRADIENT_MAP, BAN
import { AVATAR_COLORS } from '@backspace/shared';
import type { User, UserStatus, AvatarColor } from '@backspace/shared';
import type { FederationOpResult } from '../../../utils/federationOps';
/**
* Banner/avatar previews hold either a `blob:` object URL (local upload) or a
* remote `https:` URL (GIF picker). Only the former owns memory that must be
* released — calling revokeObjectURL on a remote URL is a silent no-op that
* would quietly hide a mistake here.
*/
function releasePreview(url: string | null): void {
if (url && url.startsWith('blob:')) URL.revokeObjectURL(url);
}
export function AccountPanel() {
const user = useAuthStore((s) => s.user);
const updateProfile = useAuthStore((s) => s.updateProfile);
@@ -37,6 +48,7 @@ export function AccountPanel() {
const [bannerFilename, setBannerFilename] = useState<string | null>(null);
const [uploadingBanner, setUploadingBanner] = useState(false);
const [bannerCropSrc, setBannerCropSrc] = useState<string | null>(null);
const [showBannerGif, setShowBannerGif] = useState(false);
const bannerInputRef = useRef<HTMLInputElement>(null);
const addToast = useUIStore((s) => s.addToast);
@@ -54,7 +66,7 @@ export function AccountPanel() {
setCustomHex(user.accentColor ?? '');
// Reset upload state
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
releasePreview(bannerPreview);
setAvatarPreview(null);
setAvatarFilename(null);
setBannerPreview(null);
@@ -202,7 +214,7 @@ export function AccountPanel() {
};
const handleBannerCropComplete = async (blob: Blob) => {
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
releasePreview(bannerPreview);
const previewUrl = URL.createObjectURL(blob);
setBannerPreview(previewUrl);
setBannerCropSrc(null);
@@ -227,8 +239,21 @@ export function AccountPanel() {
setAvatarFilename('');
};
/**
* Banners accept absolute URLs end to end: the server's isValidAssetUrl
* allows http(s), and the profile render already branches on
* `banner.startsWith('http')`. So a picked GIF needs no upload — the remote
* URL is stored directly.
*/
const handleBannerGifSelect = (url: string) => {
releasePreview(bannerPreview);
setBannerPreview(url);
setBannerFilename(url);
setShowBannerGif(false);
};
const handleRemoveBanner = () => {
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
releasePreview(bannerPreview);
setBannerPreview(null);
setBannerFilename('');
};
@@ -300,7 +325,7 @@ export function AccountPanel() {
setAvatarColorState(user.avatarColor ?? null);
setCustomHex(user.accentColor ?? '');
if (avatarPreview) URL.revokeObjectURL(avatarPreview);
if (bannerPreview) URL.revokeObjectURL(bannerPreview);
releasePreview(bannerPreview);
setAvatarPreview(null);
setAvatarFilename(null);
setBannerPreview(null);
@@ -482,7 +507,7 @@ export function AccountPanel() {
</div>
)}
</button>
<div className="flex gap-2 mt-1">
<div className="relative flex gap-2 mt-1">
<button
type="button"
onClick={() => bannerInputRef.current?.click()}
@@ -491,6 +516,23 @@ export function AccountPanel() {
>
Change Banner
</button>
<button
type="button"
onClick={() => setShowBannerGif((v) => !v)}
disabled={uploadingBanner}
className="text-xs text-accent-primary hover:underline"
>
Choose GIF
</button>
{showBannerGif && (
<>
{/* Click-away layer, below the panel but above the page */}
<div className="fixed inset-0 z-[290]" onClick={() => setShowBannerGif(false)} />
<div className="absolute left-0 top-full mt-2 z-[300] glass rounded-xl overflow-hidden">
<GifPicker onGifSelect={handleBannerGifSelect} />
</div>
</>
)}
{(displayBannerSrc || user.banner) && bannerFilename !== '' && (
<button
type="button"
@@ -20,6 +20,8 @@ export function AudioInputSection() {
// then join voice and expect the meter / resolved-default hint to come
// alive without reopening the panel.
const [audioCtxGen, setAudioCtxGen] = useState(0);
const [micTesting, setMicTesting] = useState(false);
const [micTestError, setMicTestError] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const animFrameRef = useRef<number>(0);
@@ -77,7 +79,39 @@ export function AudioInputSection() {
stopped = true;
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
};
}, [permState, audioCtxGen]);
}, [permState, audioCtxGen, micTesting]);
// Subscribed (not a one-off getState) so the hint text below tracks the call
// state live. The release decision itself reads getState() at the moment of
// stopping, which is when it must be accurate.
const isLiveKitConnected = useVoiceStore((s) => s.isLiveKitConnected);
const toggleMicTest = async () => {
const am = AudioManager.getInstance();
if (micTesting) {
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
setMicTesting(false);
return;
}
setMicTestError('');
const ok = await am.startMicTest();
if (!ok) {
setMicTestError('Could not open the microphone. Check the device and its permission.');
return;
}
setMicTesting(true);
};
// Leaving the panel mid-test must not leave the loopback running or the mic
// held open.
useEffect(() => {
return () => {
const am = AudioManager.getInstance();
if (am.isMicTestActive()) {
am.stopMicTest(!useVoiceStore.getState().isLiveKitConnected);
}
};
}, []);
// Track the resolved upstream deviceId for the "Currently using: X" hint.
// Re-runs on `audioCtxGen` because the resolved-default ID is only known
@@ -213,9 +247,30 @@ export function AudioInputSection() {
/>
))}
</div>
<div className="text-xs text-txt-tertiary mt-1.5">
The level meter activates once you join a voice channel.
<div className="flex items-center gap-3 mt-3">
<button
type="button"
onClick={() => void toggleMicTest()}
disabled={permState !== 'granted'}
className={`px-3 py-1.5 rounded-md text-[13px] font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${
micTesting
? 'bg-interactive-muted text-txt-primary hover:brightness-110'
: 'bg-accent-primary text-white hover:brightness-110'
}`}
>
{micTesting ? 'Stop Testing' : "Let's Check"}
</button>
<span className="text-xs text-txt-tertiary">
{micTesting
? 'Playing your mic back to you — say something.'
: isLiveKitConnected
? 'The level meter is live while you are in a call.'
: 'Test your mic without joining a call.'}
</span>
</div>
{micTestError && (
<div className="text-xs text-txt-danger mt-1.5">{micTestError}</div>
)}
</div>
</div>
</SectionShell>
@@ -0,0 +1,106 @@
import { useEffect, useState } from 'react';
import type { Activity } from '@backspace/shared';
import { getPrimaryActivity } from '@backspace/shared/src/activities.js';
interface ProfileActivityProps {
activities: Activity[];
}
const VERB: Record<Activity['type'], string> = {
playing: 'Playing',
listening: 'Listening to',
watching: 'Watching',
streaming: 'Streaming',
custom: '',
};
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 primary = getPrimaryActivity(activities);
const start = primary?.timestamps?.start;
const end = primary?.timestamps?.end;
// Re-render once a second only while there is a clock to advance.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!start) return;
const id = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(id);
}, [start]);
if (!primary || primary.type === 'custom') return null;
const elapsed = start ? now - start : 0;
const duration = start && end ? end - start : 0;
const progress = duration > 0 ? Math.min(Math.max(elapsed / duration, 0), 1) : 0;
// The server restricts asset images to http(s); this mirrors that so a
// record stored before that check cannot inject another scheme.
const art = primary.assets?.largeImage;
const artSrc = art && (art.startsWith('https://') || art.startsWith('http://')) ? art : null;
return (
<div>
<span className="text-[11px] uppercase tracking-wide font-semibold text-txt-tertiary">
{VERB[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">
{formatClock(elapsed)} elapsed
</div>
) : null}
</div>
</div>
</div>
);
}
@@ -199,6 +199,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
<VoiceUserRow
userId={member?.user.homeUserId ?? userId}
displayName={displayName}
user={member?.user}
avatar={avatar}
avatarColor={avatarColor ?? undefined}
isMuted={isMuted}
@@ -1,5 +1,7 @@
import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVoiceStore } from '../../stores/voiceStore';
import { useChatStore } from '../../stores/chatStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket';
@@ -16,6 +18,10 @@ import { handleCameraAction } from '../../utils/voiceActions';
*/
export function VoiceControls() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const navigate = useNavigate();
const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
@@ -42,7 +48,20 @@ export function VoiceControls() {
if (!currentVoiceChannelId && !activeDmCall) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
const channelName =
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
// Jump back to where the call is happening. DM calls live under @me; space
// calls under the space captured at join time.
const callSpaceId = activeDmCall ? '@me' : currentVoiceSpaceId;
const callChannelId = activeDmCall ? activeDmCall.dmChannelId : currentVoiceChannelId;
const canGoToCall = Boolean(callChannelId && callSpaceId && !connectionError);
const handleGoToCall = () => {
if (!canGoToCall || !callChannelId) return;
setCurrentChannel(callChannelId);
navigate(`/channels/${callSpaceId}/${callChannelId}`);
};
const handleScreenShare = async () => {
const room = getActiveRoom();
@@ -120,9 +139,19 @@ export function VoiceControls() {
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
</div>
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
{connectionError ? connectionError : channelName}
</div>
{canGoToCall ? (
<button
onClick={handleGoToCall}
title="Go to call"
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
>
{channelName}
</button>
) : (
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
{connectionError ? connectionError : channelName}
</div>
)}
</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
@@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
import type { UserTile } from '../../hooks/useLiveKit';
@@ -34,6 +35,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const isLocal = participant.isLocal;
const avatarUserId = participant.homeUserId ?? participant.userId;
const { displayName, avatar, user } = useVoiceParticipantMeta(participant);
const openUserProfile = useUIStore((s) => s.openUserProfile);
// --- VIDEO & UI ---
@@ -177,11 +179,25 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0">
<span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{displayName}
</span>
{user ? (
// The tile's avatar already opens the profile card; the name
// sitting next to it did not, which read as the click failing.
<button
onClick={(e) => {
e.stopPropagation();
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
}}
className={`font-semibold text-white truncate text-left hover:underline ${large ? 'text-base' : 'text-[13px]'}`}
>
{displayName}
</button>
) : (
<span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{displayName}
</span>
)}
{isLocal && (
<span className="text-[10px] text-white/40 font-medium">
(you)
@@ -1,8 +1,18 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar';
import { ProfileAvatar } from '../ui/ProfileAvatar';
import { useUIStore } from '../../stores/uiStore';
export interface VoiceUserRowProps {
userId: string;
displayName: string;
/**
* Full record for the participant, when it has resolved. Without it the row
* stays presentational rather than offering a click that opens nothing —
* the same contract ProfileAvatar documents.
*/
user?: User;
avatar: string | null;
avatarColor?: string;
isMuted?: boolean;
@@ -22,6 +32,7 @@ export interface VoiceUserRowProps {
export function VoiceUserRow({
userId,
displayName,
user,
avatar,
avatarColor,
isMuted,
@@ -38,6 +49,15 @@ export function VoiceUserRow({
className = '',
}: VoiceUserRowProps) {
const avatarSize = size === 'compact' ? 20 : 24;
const openUserProfile = useUIStore((s) => s.openUserProfile);
const openProfile = (e: ReactMouseEvent) => {
if (!user) return;
// The row sits inside a channel that has its own click target (join the
// call). Opening the profile is the more specific intent.
e.stopPropagation();
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
};
// Mic icon priority: server muted/deafened/permission muted (amber) > self-muted (danger)
const showServerMicIcon = isServerMuted || isServerDeafened || isPermissionMuted;
@@ -49,17 +69,38 @@ export function VoiceUserRow({
return (
<div className={`flex items-center gap-2 ${className}`}>
<Avatar
src={avatar}
name={displayName}
size={avatarSize}
userId={userId}
avatarColor={avatarColor}
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
/>
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">
{displayName}
</span>
{user ? (
<ProfileAvatar
src={avatar}
name={displayName}
size={avatarSize}
userId={userId}
user={user}
avatarColor={avatarColor}
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
/>
) : (
<Avatar
src={avatar}
name={displayName}
size={avatarSize}
userId={userId}
avatarColor={avatarColor}
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
/>
)}
{user ? (
<button
onClick={openProfile}
className="text-[13px] text-txt-secondary truncate flex-1 min-w-0 text-left hover:text-txt-primary hover:underline"
>
{displayName}
</button>
) : (
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">
{displayName}
</span>
)}
{/* Status badges */}
<div className="flex items-center gap-1 flex-shrink-0">
{/* Server muted / space deafened / permission muted — amber mic with slash */}
+17 -2
View File
@@ -17,6 +17,15 @@ export interface ScreenShareConfig {
interface VoiceState {
voiceUsers: Map<string, string[]>; // channelId → userIds
currentVoiceChannelId: string | null;
/**
* Space and name of the channel the call is in, captured at join time.
* `channels` in spaceStore only holds the space the user is *viewing*, so
* once they navigate elsewhere the call's channel is no longer resolvable
* from it — these keep the voice panel able to name and link to the call.
* Transient: intentionally absent from `partialize`.
*/
currentVoiceSpaceId: string | null;
currentVoiceChannelName: string | null;
isMuted: boolean;
isDeafened: boolean;
isCameraOn: boolean;
@@ -85,7 +94,7 @@ interface VoiceState {
setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void;
setCurrentVoiceChannel: (channelId: string | null) => void;
setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
setParticipants: (participants: ParticipantInfo[]) => void;
setSpeakingParticipants: (ids: Set<string>) => void;
setConnectionError: (error: string | null) => void;
@@ -158,6 +167,8 @@ export const useVoiceStore = create<VoiceState>()(
(set, get) => ({
voiceUsers: new Map(),
currentVoiceChannelId: null,
currentVoiceSpaceId: null,
currentVoiceChannelName: null,
isMuted: false,
pttActive: false,
isDeafened: false,
@@ -349,8 +360,10 @@ export const useVoiceStore = create<VoiceState>()(
});
},
setCurrentVoiceChannel: (channelId) => set({
setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
currentVoiceChannelId: channelId,
currentVoiceSpaceId: channelId ? spaceId : null,
currentVoiceChannelName: channelId ? channelName : null,
activeDmCall: null // Clear active DM call when joining a server channel
}),
@@ -516,6 +529,8 @@ export const useVoiceStore = create<VoiceState>()(
voiceUsers: new Map(),
voiceUserStates: new Map(),
currentVoiceChannelId: null,
currentVoiceSpaceId: null,
currentVoiceChannelName: null,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
+9 -1
View File
@@ -152,7 +152,15 @@ export function joinVoiceChannel(
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId);
}
setCurrentVoiceChannel(channelId);
// Capture the space and name now: a voice channel can only be joined from
// within its own space, but the user may navigate away afterwards — at which
// point spaceStore.channels no longer resolves this channel.
const spaceStore = useSpaceStore.getState();
setCurrentVoiceChannel(
channelId,
spaceStore.currentSpaceId ?? null,
spaceStore.channels.find((c) => c.id === channelId)?.name ?? null,
);
// Optimistic: immediately show self in new channel (using origin-aware ID)
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myNewId) addVoiceUser(channelId, myNewId);