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
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.
This commit is contained in:
@@ -13,6 +13,7 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
| 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 |
|
| 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 |
|
| 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 |
|
| 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)
|
## Já existia no código (verificado, não construir de novo)
|
||||||
|
|
||||||
@@ -21,6 +22,11 @@ código e os commits seguem em inglês, como o resto do repositório.
|
|||||||
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
- **Sons de call/stream** — `SoundController.tsx`, montado no `AppLayout`:
|
||||||
stream started/ended, alguém entra/sai da tela, entra/sai da call, câmera,
|
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/`.
|
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**,
|
Se qualquer um dos dois não se manifestar em uso, o trabalho é **depuração**,
|
||||||
não implementação.
|
não implementação.
|
||||||
@@ -31,7 +37,6 @@ Ordem sugerida: as pequenas primeiro, as grandes uma de cada vez.
|
|||||||
|
|
||||||
| # | Feature | Tamanho | Observação técnica |
|
| # | Feature | Tamanho | Observação técnica |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 7 | Preview de perfil ao clicar no nome | Média | Os dados já vêm do `UserProfileModal`; é transformar em popover ancorado |
|
|
||||||
| 10 | Teste de voz com loopback | Média | `AudioManager.playTestTone()` já existe; falta capturar o mic e devolver no monitor, com "Stop Testing" |
|
| 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 |
|
| 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 |
|
| 8 | Atividade (Spotify etc.) | Grande | `activityStore` e `activityBridge` já existem, mas a detecção é via Electron; Spotify exige OAuth e presença via WebSocket |
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user