fix: perfect speaking ring circle and use real avatars/display names in voice tiles

Add flex to speaking ring wrapper divs in VoiceUser and PictureInPicture
to eliminate baseline descender space that made the ring oval. Extract
useVoiceParticipantMeta hook to resolve real display names and avatars
for voice participants instead of showing raw usernames with null avatars.
This commit is contained in:
Jannis Braun
2026-03-10 23:21:37 +01:00
parent a5c7bb6e9a
commit f82e721491
4 changed files with 55 additions and 8 deletions
@@ -347,7 +347,7 @@ export function PictureInPicture() {
) : ( ) : (
<div className="w-full h-full flex items-center justify-center bg-surface-channel"> <div className="w-full h-full flex items-center justify-center bg-surface-channel">
{displayParticipant ? ( {displayParticipant ? (
<div className="relative"> <div className="relative flex">
<Avatar <Avatar
name={displayParticipant.username} name={displayParticipant.username}
size={64} size={64}
@@ -3,6 +3,7 @@ import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
import { StreamContextMenu } from './StreamContextMenu'; import { StreamContextMenu } from './StreamContextMenu';
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit'; import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
interface StreamTileProps { interface StreamTileProps {
@@ -19,6 +20,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const isLocal = participant.isLocal; const isLocal = participant.isLocal;
const userId = participant.userId; const userId = participant.userId;
const avatarUserId = participant.homeUserId ?? userId; const avatarUserId = participant.homeUserId ?? userId;
const { displayName, avatar } = useVoiceParticipantMeta(participant);
const isWatching = watchingStreams.has(userId); const isWatching = watchingStreams.has(userId);
@@ -108,11 +110,11 @@ export function StreamTile({ tile, large }: StreamTileProps) {
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel"> <div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel">
<div className="relative"> <div className="relative">
<Avatar src={null} name={participant.username} size={large ? 80 : 48} userId={avatarUserId} /> <Avatar src={avatar} name={displayName} size={large ? 80 : 48} userId={avatarUserId} />
</div> </div>
<div className="text-center px-4"> <div className="text-center px-4">
<p className="text-txt-primary text-sm font-semibold"> <p className="text-txt-primary text-sm font-semibold">
{participant.username} is streaming {displayName} is streaming
</p> </p>
{!isLocal && ( {!isLocal && (
<button <button
@@ -154,7 +156,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
<span <span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`} className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
> >
{participant.username} {displayName}
</span> </span>
{isLocal && ( {isLocal && (
<span className="text-[10px] text-white/40 font-medium">(you)</span> <span className="text-[10px] text-white/40 font-medium">(you)</span>
@@ -3,6 +3,7 @@ import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { VoiceUserContextMenu } from './VoiceUserContextMenu'; import { VoiceUserContextMenu } from './VoiceUserContextMenu';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
import type { UserTile } from '../../hooks/useLiveKit'; import type { UserTile } from '../../hooks/useLiveKit';
interface VoiceUserProps { interface VoiceUserProps {
@@ -28,6 +29,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 } = useVoiceParticipantMeta(participant);
// --- VIDEO & UI --- // --- VIDEO & UI ---
@@ -90,10 +92,10 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
/> />
) : ( ) : (
<div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel"> <div className="w-full h-full flex flex-col items-center justify-center gap-3 bg-surface-channel">
<div className="relative"> <div className="relative flex">
<Avatar <Avatar
src={null} src={avatar}
name={participant.username} name={displayName}
size={large ? 100 : 64} size={large ? 100 : 64}
userId={avatarUserId} userId={avatarUserId}
/> />
@@ -110,7 +112,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
<span <span
className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`} className={`font-semibold text-white truncate ${large ? 'text-base' : 'text-[13px]'}`}
> >
{participant.username} {displayName}
</span> </span>
{isLocal && ( {isLocal && (
<span className="text-[10px] text-white/40 font-medium"> <span className="text-[10px] text-white/40 font-medium">
@@ -0,0 +1,43 @@
import { useMemo } from 'react';
import { useSpaceStore } from '../stores/spaceStore';
import { parseFederatedUsername } from '../utils/identity';
import type { ParticipantInfo } from './useLiveKit';
/**
* Resolves display metadata (displayName, avatar) for a voice participant
* by looking up member data from the space/DM stores.
*
* Reactive — re-renders when member data changes (e.g. user updates avatar mid-call).
*/
export function useVoiceParticipantMeta(participant: ParticipantInfo) {
const members = useSpaceStore((s) => s.members);
const dmChannels = useSpaceStore((s) => s.dmChannels);
return useMemo(() => {
// 1. Try space members (primary — covers space voice channels)
const member = members.find(m => m.userId === participant.userId);
if (member?.user) {
const { baseName } = parseFederatedUsername(member.user.username);
return {
displayName: member.user.displayName ?? baseName,
avatar: member.user.avatar ?? null,
};
}
// 2. Fallback to DM channel members (covers DM calls)
for (const dm of dmChannels) {
const dmMember = dm.members?.find(m => m.id === participant.userId);
if (dmMember) {
const { baseName } = parseFederatedUsername(dmMember.username);
return {
displayName: dmMember.displayName ?? baseName,
avatar: dmMember.avatar ?? null,
};
}
}
// 3. Final fallback — parse username from LiveKit identity
const { baseName } = parseFederatedUsername(participant.username);
return { displayName: baseName, avatar: null };
}, [members, dmChannels, participant.userId, participant.username]);
}