fix: persist homeUserId for federated users to ensure consistent avatar colors

Store the original home snowflake ID (homeUserId) during federation replication
so that avatar gradient colors resolve identically across instances. Previously,
replicated users got new snowflake IDs on each instance, causing different
gradient colors. Now Avatar, UserProfilePopout, VoiceUser, StreamTile, and
VoiceChannel all resolve through homeUserId when available. Includes backfill
logic for existing federated users missing the field.
This commit is contained in:
Jannis Braun
2026-03-04 13:45:25 +01:00
parent 9432f81ba3
commit c7c3331dbf
15 changed files with 47 additions and 22 deletions
@@ -62,6 +62,7 @@ const makeRequest = (overrides: Partial<FriendRequest> = {}): FriendRequest => (
isAdmin: false,
createdAt: Date.now(),
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
},
...overrides,
@@ -218,6 +219,7 @@ describe('FriendsPage', () => {
isAdmin: false,
createdAt: Date.now(),
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
},
});
@@ -265,6 +267,7 @@ describe('FriendsPage', () => {
isAdmin: false,
createdAt: Date.now(),
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
},
});
@@ -307,6 +310,7 @@ describe('FriendsPage', () => {
isAdmin: false,
createdAt: Date.now(),
homeInstance: null,
homeUserId: null,
replicatedInstances: [],
},
});
@@ -2,8 +2,6 @@ import React, { useMemo } from 'react';
import type { MemberWithUser } from '@backspace/shared';
import { useServerStore } from '../../stores/serverStore';
import { useUIStore } from '../../stores/uiStore';
import { useAuthStore } from '../../stores/authStore';
import { resolveDisplayIdentity } from '../../utils/identity';
import { Avatar } from '../ui/Avatar';
import { Username } from '../ui/Username';
@@ -48,7 +46,6 @@ export function MemberSidebar() {
const currentServerId = useServerStore((s) => s.currentServerId);
const memberListOpen = useUIStore((s) => s.memberListOpen);
const openUserProfile = useUIStore((s) => s.openUserProfile);
const authUser = useAuthStore((s) => s.user);
const server = servers.find(s => s.id === currentServerId);
const ownerId = server?.ownerId;
@@ -100,7 +97,6 @@ export function MemberSidebar() {
const renderMember = (member: MemberWithUser, isOffline = false) => {
const displayName = member.user.displayName ?? member.user.username;
const colorStyle = isOffline ? undefined : getMemberColor(member);
const resolvedUser = resolveDisplayIdentity(member.user, authUser ?? null);
return (
<div
key={member.userId}
@@ -114,7 +110,6 @@ export function MemberSidebar() {
status={isOffline ? 'offline' : member.user.status}
className={isOffline ? 'opacity-60' : undefined}
user={member.user}
userId={resolvedUser.id}
/>
<div className="flex-1 min-w-0">
<Username
+1 -1
View File
@@ -48,7 +48,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
const initials = name.charAt(0).toUpperCase();
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18;
const gradient = getAvatarGradient(userId ?? user?.id, name);
const gradient = getAvatarGradient(userId ?? user?.homeUserId ?? user?.id, name);
const handleClick = (e: React.MouseEvent) => {
if (onClick) {
@@ -55,7 +55,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
<div
className="h-[48px] rounded-t-[12px]"
style={{
background: getAvatarGradient(user.id, displayName).gradient,
background: getAvatarGradient(user.homeUserId ?? user.id, displayName).gradient,
opacity: 0.6,
}}
/>
@@ -1,7 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
@@ -24,8 +23,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
const { participant } = tile;
const isLocal = participant.isLocal;
const userId = participant.userId;
const homeUser = useAuthStore((s) => s.user);
const avatarUserId = isLocal ? (homeUser?.id ?? userId) : userId;
const avatarUserId = participant.homeUserId ?? userId;
const isWatching = watchingStreams.has(userId);
const streamVolume = streamVolumes.get(userId) ?? 100;
@@ -1,7 +1,5 @@
import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
import { isSelf } from '../../utils/identity';
const EMPTY_VOICE_USERS: string[] = [];
import { useServerStore } from '../../stores/serverStore';
@@ -20,8 +18,11 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
const localIsDeafened = useVoiceStore((s) => s.isDeafened);
const localIsMuted = useVoiceStore((s) => s.isMuted);
const voiceUserStates = useVoiceStore((s) => s.voiceUserStates);
const authUser = useAuthStore((s) => s.user);
const currentUserId = authUser?.id;
const currentUserId = useVoiceStore((s) => {
// Derive from participants — avoids unnecessary authStore dependency
const local = s.participants.find(p => p.isLocal);
return local?.userId ?? null;
});
const members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId;
@@ -75,7 +76,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
name={displayName}
size={24}
status={status}
userId={(authUser && member?.user && isSelf(member.user, authUser)) ? authUser.id : userId}
userId={member?.user.homeUserId ?? userId}
/>
<span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */}
@@ -1,7 +1,6 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Avatar } from '../ui/Avatar';
import { useVoiceStore } from '../../stores/voiceStore';
import { useAuthStore } from '../../stores/authStore';
import type { UserTile } from '../../hooks/useLiveKit';
interface VoiceUserProps {
@@ -21,8 +20,7 @@ export function VoiceUser({ tile, large }: VoiceUserProps) {
const perUserVolume = participantVolumes.get(participant.userId) ?? 100;
const isLocal = participant.isLocal;
const homeUser = useAuthStore((s) => s.user);
const avatarUserId = isLocal ? (homeUser?.id ?? participant.userId) : participant.userId;
const avatarUserId = participant.homeUserId ?? participant.userId;
// --- VIDEO & UI ---
+5 -1
View File
@@ -12,7 +12,7 @@ import {
LocalAudioTrack,
LocalTrackPublication,
} from 'livekit-client';
import { getApiForOrigin, getChannelOrigin } from '../stores/serverStore';
import { getApiForOrigin, getChannelOrigin, useServerStore } from '../stores/serverStore';
import { useVoiceStore } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager';
import { SpeakingDetector } from '../audio/SpeakingDetector';
@@ -37,6 +37,7 @@ export interface ParticipantInfo {
identity: string;
userId: string;
username: string;
homeUserId: string | null;
isMuted: boolean;
isDeafened: boolean;
isCameraOn: boolean;
@@ -152,6 +153,8 @@ export function useLiveKit() {
const processParticipant = (p: Participant, isLocal: boolean) => {
if (!p.identity) return;
const { userId, username } = parseIdentity(p.identity);
const memberMatch = useServerStore.getState().members.find(m => m.userId === userId);
const homeUserId = memberMatch?.user.homeUserId ?? null;
let audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null;
let screenTrack: MediaStreamTrack | null = null;
@@ -194,6 +197,7 @@ export function useLiveKit() {
identity: p.identity,
userId,
username,
homeUserId,
isMuted: isPartMuted,
isDeafened: isPartDeafened,
isCameraOn: !!videoTrack,
+10
View File
@@ -150,6 +150,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
password,
displayName: displayName || currentUser.displayName || undefined,
homeInstance,
homeUserId: currentUser.id,
});
} catch (err) {
const message = (err as Error).message;
@@ -162,6 +163,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
password,
displayName: displayName || currentUser.displayName || undefined,
homeInstance,
homeUserId: currentUser.id,
});
} catch (err2) {
const msg2 = (err2 as Error).message;
@@ -380,6 +382,14 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Non-critical — keep cached label
}
// Backfill homeUserId if missing (existing federated users before this field existed)
if (user.homeInstance && !user.homeUserId) {
const homeUser = useAuthStore.getState().user;
if (homeUser) {
client.users.update({ homeUserId: homeUser.id }).catch(() => {});
}
}
const connectedInstance: ConnectedInstance = {
origin,
label,