5 Commits
Author SHA1 Message Date
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
10 changed files with 269 additions and 33 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 (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
@@ -39,7 +48,9 @@ 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
# Runtime deps only: ffmpeg (media processing) + gosu (drop to non-root in the # 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 && \ RUN apt-get update && \
apt-get install -y --no-install-recommends ffmpeg gosu && \ apt-get install -y --no-install-recommends ffmpeg gosu && \
rm -rf /var/lib/apt/lists/* 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 (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/
+59
View File
@@ -0,0 +1,59 @@
# 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 |
| 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 |
|---|---|---|---|
| 10 | Teste de voz com loopback | Média | `AudioManager.playTestTone()` já existe; falta capturar o mic e devolver no monitor, com "Stop Testing" |
| 6 | Favoritar GIFs + categorias | Grande | Precisa de tabela, migração drizzle e API para sincronizar entre dispositivos, como no Discord |
| 8 | Atividade (Spotify etc.) | Grande | `activityStore` e `activityBridge` já existem, mas a detecção é via Electron; Spotify exige OAuth e presença via WebSocket |
| 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 |
## 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.
@@ -959,8 +959,14 @@ export function MessageInput({ channelId, channelName, placeholder }: MessageInp
title="GIF" title="GIF"
aria-label="GIF picker" aria-label="GIF picker"
> >
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> {/* Outlined badge, not a filled block: the solid rectangle read as
<path d="M2 5.5A2.5 2.5 0 0 1 4.5 3h15A2.5 2.5 0 0 1 22 5.5v13a2.5 2.5 0 0 1-2.5 2.5h-15A2.5 2.5 0 0 1 2 18.5v-13ZM5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" /> a plain square rather than a GIF picker. Letters reuse the
original glyph paths, scaled and centred inside the outline. */}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<rect x="3" y="6" width="18" height="12" rx="3" stroke="currentColor" strokeWidth="2" />
<g fill="currentColor" transform="translate(-1.77 -4.2) scale(1.35)">
<path d="M5.1 14V10h3.2v1.2H6.5v.6h1.6v1.1H6.5V14H5.1Zm4.5 0V10h1.4v4H9.6Zm2.5 0V10h3.2v1.2h-1.8v.5h1.6v1h-1.6V14h-1.4Z" />
</g>
</svg> </svg>
</button> </button>
)} )}
@@ -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"
@@ -199,6 +199,7 @@ export function VoiceChannel({ channelId, channelName, onClick, locked, canManag
<VoiceUserRow <VoiceUserRow
userId={member?.user.homeUserId ?? userId} userId={member?.user.homeUserId ?? userId}
displayName={displayName} displayName={displayName}
user={member?.user}
avatar={avatar} avatar={avatar}
avatarColor={avatarColor ?? undefined} avatarColor={avatarColor ?? undefined}
isMuted={isMuted} isMuted={isMuted}
@@ -1,5 +1,7 @@
import React, { useState, useRef } from 'react'; import React, { useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useChatStore } from '../../stores/chatStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { getActiveRoom } from '../../hooks/useLiveKit'; import { getActiveRoom } from '../../hooks/useLiveKit';
import { wsSend } from '../../hooks/useWebSocket'; import { wsSend } from '../../hooks/useWebSocket';
@@ -16,6 +18,10 @@ import { handleCameraAction } from '../../utils/voiceActions';
*/ */
export function VoiceControls() { export function VoiceControls() {
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId);
const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName);
const setCurrentChannel = useChatStore((s) => s.setCurrentChannel);
const navigate = useNavigate();
const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isCameraOn = useVoiceStore((s) => s.isCameraOn);
const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing);
const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled); const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled);
@@ -42,7 +48,20 @@ export function VoiceControls() {
if (!currentVoiceChannelId && !activeDmCall) return null; if (!currentVoiceChannelId && !activeDmCall) return null;
const channel = channels.find(c => c.id === currentVoiceChannelId); const channel = channels.find(c => c.id === currentVoiceChannelId);
const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel'); const channelName =
channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel');
// Jump back to where the call is happening. DM calls live under @me; space
// calls under the space captured at join time.
const callSpaceId = activeDmCall ? '@me' : currentVoiceSpaceId;
const callChannelId = activeDmCall ? activeDmCall.dmChannelId : currentVoiceChannelId;
const canGoToCall = Boolean(callChannelId && callSpaceId && !connectionError);
const handleGoToCall = () => {
if (!canGoToCall || !callChannelId) return;
setCurrentChannel(callChannelId);
navigate(`/channels/${callSpaceId}/${callChannelId}`);
};
const handleScreenShare = async () => { const handleScreenShare = async () => {
const room = getActiveRoom(); const room = getActiveRoom();
@@ -120,9 +139,19 @@ export function VoiceControls() {
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}> <div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'} {connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
</div> </div>
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]"> {canGoToCall ? (
{connectionError ? connectionError : channelName} <button
</div> onClick={handleGoToCall}
title="Go to call"
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
>
{channelName}
</button>
) : (
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
{connectionError ? connectionError : channelName}
</div>
)}
</div> </div>
<div className="flex items-center gap-0.5 flex-shrink-0"> <div className="flex items-center gap-0.5 flex-shrink-0">
@@ -4,6 +4,7 @@ import { useVoiceStore } from '../../stores/voiceStore';
import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore'; import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextMenuStore';
import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems'; import { buildVoiceModMenuItems, VolumeSliderItem } from './voiceMenuItems';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useUIStore } from '../../stores/uiStore';
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta'; import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit'; import { getActiveRoom, setCameraSubscription } from '../../hooks/useLiveKit';
import type { UserTile } from '../../hooks/useLiveKit'; import type { UserTile } from '../../hooks/useLiveKit';
@@ -34,6 +35,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const isLocal = participant.isLocal; const isLocal = participant.isLocal;
const avatarUserId = participant.homeUserId ?? participant.userId; const avatarUserId = participant.homeUserId ?? participant.userId;
const { displayName, avatar, user } = useVoiceParticipantMeta(participant); const { displayName, avatar, user } = useVoiceParticipantMeta(participant);
const openUserProfile = useUIStore((s) => s.openUserProfile);
// --- VIDEO & UI --- // --- VIDEO & UI ---
@@ -177,11 +179,25 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
<div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent"> <div className="absolute bottom-0 left-0 right-0 px-3 py-2 bg-gradient-to-t from-black/70 via-black/30 to-transparent">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<span {user ? (
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`} // The tile's avatar already opens the profile card; the name
> // sitting next to it did not, which read as the click failing.
{displayName} <button
</span> onClick={(e) => {
e.stopPropagation();
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
}}
className={`font-semibold text-white truncate text-left hover:underline ${large ? 'text-base' : 'text-[13px]'}`}
>
{displayName}
</button>
) : (
<span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
>
{displayName}
</span>
)}
{isLocal && ( {isLocal && (
<span className="text-[10px] text-white/40 font-medium"> <span className="text-[10px] text-white/40 font-medium">
(you) (you)
@@ -1,8 +1,18 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { User } from '@backspace/shared';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { ProfileAvatar } from '../ui/ProfileAvatar';
import { useUIStore } from '../../stores/uiStore';
export interface VoiceUserRowProps { export interface VoiceUserRowProps {
userId: string; userId: string;
displayName: string; displayName: string;
/**
* Full record for the participant, when it has resolved. Without it the row
* stays presentational rather than offering a click that opens nothing —
* the same contract ProfileAvatar documents.
*/
user?: User;
avatar: string | null; avatar: string | null;
avatarColor?: string; avatarColor?: string;
isMuted?: boolean; isMuted?: boolean;
@@ -22,6 +32,7 @@ export interface VoiceUserRowProps {
export function VoiceUserRow({ export function VoiceUserRow({
userId, userId,
displayName, displayName,
user,
avatar, avatar,
avatarColor, avatarColor,
isMuted, isMuted,
@@ -38,6 +49,15 @@ export function VoiceUserRow({
className = '', className = '',
}: VoiceUserRowProps) { }: VoiceUserRowProps) {
const avatarSize = size === 'compact' ? 20 : 24; const avatarSize = size === 'compact' ? 20 : 24;
const openUserProfile = useUIStore((s) => s.openUserProfile);
const openProfile = (e: ReactMouseEvent) => {
if (!user) return;
// The row sits inside a channel that has its own click target (join the
// call). Opening the profile is the more specific intent.
e.stopPropagation();
openUserProfile(user, e.currentTarget.getBoundingClientRect(), 'right');
};
// Mic icon priority: server muted/deafened/permission muted (amber) > self-muted (danger) // Mic icon priority: server muted/deafened/permission muted (amber) > self-muted (danger)
const showServerMicIcon = isServerMuted || isServerDeafened || isPermissionMuted; const showServerMicIcon = isServerMuted || isServerDeafened || isPermissionMuted;
@@ -49,17 +69,38 @@ export function VoiceUserRow({
return ( return (
<div className={`flex items-center gap-2 ${className}`}> <div className={`flex items-center gap-2 ${className}`}>
<Avatar {user ? (
src={avatar} <ProfileAvatar
name={displayName} src={avatar}
size={avatarSize} name={displayName}
userId={userId} size={avatarSize}
avatarColor={avatarColor} userId={userId}
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''} user={user}
/> avatarColor={avatarColor}
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0"> className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
{displayName} />
</span> ) : (
<Avatar
src={avatar}
name={displayName}
size={avatarSize}
userId={userId}
avatarColor={avatarColor}
className={isSpeaking ? 'rounded-full ring-2 ring-status-online' : ''}
/>
)}
{user ? (
<button
onClick={openProfile}
className="text-[13px] text-txt-secondary truncate flex-1 min-w-0 text-left hover:text-txt-primary hover:underline"
>
{displayName}
</button>
) : (
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">
{displayName}
</span>
)}
{/* Status badges */} {/* Status badges */}
<div className="flex items-center gap-1 flex-shrink-0"> <div className="flex items-center gap-1 flex-shrink-0">
{/* Server muted / space deafened / permission muted — amber mic with slash */} {/* Server muted / space deafened / permission muted — amber mic with slash */}
+17 -2
View File
@@ -17,6 +17,15 @@ export interface ScreenShareConfig {
interface VoiceState { interface VoiceState {
voiceUsers: Map<string, string[]>; // channelId → userIds voiceUsers: Map<string, string[]>; // channelId → userIds
currentVoiceChannelId: string | null; 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; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isCameraOn: boolean; isCameraOn: boolean;
@@ -85,7 +94,7 @@ interface VoiceState {
setVoiceUsers: (channelId: string, userIds: string[]) => void; setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void; addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void; removeVoiceUser: (channelId: string, userId: string) => void;
setCurrentVoiceChannel: (channelId: string | null) => void; setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void;
setParticipants: (participants: ParticipantInfo[]) => void; setParticipants: (participants: ParticipantInfo[]) => void;
setSpeakingParticipants: (ids: Set<string>) => void; setSpeakingParticipants: (ids: Set<string>) => void;
setConnectionError: (error: string | null) => void; setConnectionError: (error: string | null) => void;
@@ -158,6 +167,8 @@ export const useVoiceStore = create<VoiceState>()(
(set, get) => ({ (set, get) => ({
voiceUsers: new Map(), voiceUsers: new Map(),
currentVoiceChannelId: null, currentVoiceChannelId: null,
currentVoiceSpaceId: null,
currentVoiceChannelName: null,
isMuted: false, isMuted: false,
pttActive: false, pttActive: false,
isDeafened: false, isDeafened: false,
@@ -349,8 +360,10 @@ export const useVoiceStore = create<VoiceState>()(
}); });
}, },
setCurrentVoiceChannel: (channelId) => set({ setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({
currentVoiceChannelId: channelId, currentVoiceChannelId: channelId,
currentVoiceSpaceId: channelId ? spaceId : null,
currentVoiceChannelName: channelId ? channelName : null,
activeDmCall: null // Clear active DM call when joining a server channel activeDmCall: null // Clear active DM call when joining a server channel
}), }),
@@ -516,6 +529,8 @@ export const useVoiceStore = create<VoiceState>()(
voiceUsers: new Map(), voiceUsers: new Map(),
voiceUserStates: new Map(), voiceUserStates: new Map(),
currentVoiceChannelId: null, currentVoiceChannelId: null,
currentVoiceSpaceId: null,
currentVoiceChannelName: null,
participants: [], participants: [],
speakingParticipantIds: new Set(), speakingParticipantIds: new Set(),
speakingUserIds: new Set(), speakingUserIds: new Set(),
+9 -1
View File
@@ -152,7 +152,15 @@ export function joinVoiceChannel(
if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId); 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) // Optimistic: immediately show self in new channel (using origin-aware ID)
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId)); const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myNewId) addVoiceUser(channelId, myNewId); if (myNewId) addVoiceUser(channelId, myNewId);