fix: resolve federated avatar color to home instance identity

Federated users get a different snowflake ID on remote instances,
causing avatar gradients to mismatch their home identity. Apply
resolveDisplayIdentity/isSelf resolution in VoiceUser, StreamTile,
VoiceChannel sidebar, and MemberSidebar so the current user's avatar
color is consistent across all views.
This commit is contained in:
Jannis Braun
2026-03-04 04:27:35 +01:00
parent 2d6cdd0a3c
commit 9432f81ba3
4 changed files with 17 additions and 4 deletions
@@ -2,6 +2,8 @@ import React, { useMemo } from 'react';
import type { MemberWithUser } from '@backspace/shared'; import type { MemberWithUser } from '@backspace/shared';
import { useServerStore } from '../../stores/serverStore'; import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { resolveDisplayIdentity } from '../../utils/identity';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username'; import { Username } from '../ui/Username';
@@ -46,6 +48,7 @@ export function MemberSidebar() {
const currentServerId = useServerStore((s) => s.currentServerId); const currentServerId = useServerStore((s) => s.currentServerId);
const memberListOpen = useUIStore((s) => s.memberListOpen); const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile); const openUserProfile = useUIStore((s) => s.openUserProfile);
const authUser = useAuthStore((s) => s.user);
const server = servers.find(s => s.id === currentServerId); const server = servers.find(s => s.id === currentServerId);
const ownerId = server?.ownerId; const ownerId = server?.ownerId;
@@ -97,6 +100,7 @@ export function MemberSidebar() {
const renderMember = (member: MemberWithUser, isOffline = false) => { const renderMember = (member: MemberWithUser, isOffline = false) => {
const displayName = member.user.displayName ?? member.user.username; const displayName = member.user.displayName ?? member.user.username;
const colorStyle = isOffline ? undefined : getMemberColor(member); const colorStyle = isOffline ? undefined : getMemberColor(member);
const resolvedUser = resolveDisplayIdentity(member.user, authUser ?? null);
return ( return (
<div <div
key={member.userId} key={member.userId}
@@ -110,6 +114,7 @@ export function MemberSidebar() {
status={isOffline ? 'offline' : member.user.status} status={isOffline ? 'offline' : member.user.status}
className={isOffline ? 'opacity-60' : undefined} className={isOffline ? 'opacity-60' : undefined}
user={member.user} user={member.user}
userId={resolvedUser.id}
/> />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<Username <Username
@@ -1,6 +1,7 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare'; import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
@@ -23,6 +24,8 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const { participant } = tile; const { participant } = tile;
const isLocal = participant.isLocal; const isLocal = participant.isLocal;
const userId = participant.userId; const userId = participant.userId;
const homeUser = useAuthStore((s) => s.user);
const avatarUserId = isLocal ? (homeUser?.id ?? userId) : userId;
const isWatching = watchingStreams.has(userId); const isWatching = watchingStreams.has(userId);
const streamVolume = streamVolumes.get(userId) ?? 100; const streamVolume = streamVolumes.get(userId) ?? 100;
@@ -146,7 +149,7 @@ 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={participant.userId} /> <Avatar src={null} name={participant.username} 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">
@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { isSelf } from '../../utils/identity';
const EMPTY_VOICE_USERS: string[] = []; const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore'; import { useServerStore } from '../../stores/serverStore';
@@ -19,7 +20,8 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
const localIsDeafened = useVoiceStore((s) => s.isDeafened); const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted); const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates); const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const currentUserId = useAuthStore((s) => s.user?.id); const authUser = useAuthStore((s) => s.user);
const currentUserId = authUser?.id;
const members = useServerStore((s) => s.members); const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId; const isActive = currentVoiceChannel === channelId;
@@ -73,7 +75,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
name={displayName} name={displayName}
size={24} size={24}
status={status} status={status}
userId={userId} userId={(authUser && member?.user && isSelf(member.user, authUser)) ? authUser.id : userId}
/> />
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span> <span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */} {/* Status badges */}
@@ -1,6 +1,7 @@
import React, { useRef, useEffect, useState, useCallback } from 'react'; import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
import type { UserTile } from '../../hooks/useLiveKit'; import type { UserTile } from '../../hooks/useLiveKit';
interface VoiceUserProps { interface VoiceUserProps {
@@ -20,6 +21,8 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const perUserVolume = participantVolumes.get(participant.userId) ?? 100; const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal; const isLocal = participant.isLocal;
const homeUser = useAuthStore((s) => s.user);
const avatarUserId = isLocal ? (homeUser?.id ?? participant.userId) : participant.userId;
// --- VIDEO & UI --- // --- VIDEO & UI ---
@@ -95,7 +98,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
src={null} src={null}
name={participant.username} name={participant.username}
size={large ? 100 : 64} size={large ? 100 : 64}
userId={participant.userId} userId={avatarUserId}
/> />
{isSpeaking && ( {isSpeaking && (
<div className="absolute -inset-1.5 rounded-full ring-[3px] ring-status-online animate-pulse" /> <div className="absolute -inset-1.5 rounded-full ring-[3px] ring-status-online animate-pulse" />