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.
This commit is contained in:
2026-08-31 11:49:57 -03:00
parent cad3867027
commit 37407a5ecd
4 changed files with 80 additions and 17 deletions
@@ -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}
@@ -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 */}