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:
@@ -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
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user