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
+2 -1
View File
@@ -60,7 +60,8 @@ export function runMigrations(db: Database.Database): void {
name: 'users', name: 'users',
columns: [ columns: [
{ name: 'home_instance', type: 'TEXT' }, { name: 'home_instance', type: 'TEXT' },
{ name: 'replicated_instances', type: "TEXT DEFAULT '[]'" } { name: 'replicated_instances', type: "TEXT DEFAULT '[]'" },
{ name: 'home_user_id', type: 'TEXT' }
] ]
}, },
{ {
+1
View File
@@ -10,6 +10,7 @@ export const users = sqliteTable('users', {
customStatus: text('custom_status'), customStatus: text('custom_status'),
isAdmin: integer('is_admin').default(0), isAdmin: integer('is_admin').default(0),
homeInstance: text('home_instance'), homeInstance: text('home_instance'),
homeUserId: text('home_user_id'),
replicatedInstances: text('replicated_instances').default('[]'), replicatedInstances: text('replicated_instances').default('[]'),
createdAt: integer('created_at').notNull(), createdAt: integer('created_at').notNull(),
}); });
+2 -1
View File
@@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
}, },
}, },
}, async (request, reply) => { }, async (request, reply) => {
const { username, password, displayName, homeInstance } = request.body; const { username, password, displayName, homeInstance, homeUserId } = request.body;
if (!username || typeof username !== 'string') { if (!username || typeof username !== 'string') {
return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); return reply.code(400).send({ error: 'Username is required', statusCode: 400 });
@@ -102,6 +102,7 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
status: 'online', status: 'online',
isAdmin: isFirstUser ? 1 : 0, isAdmin: isFirstUser ? 1 : 0,
homeInstance: homeInstance || null, homeInstance: homeInstance || null,
homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null,
createdAt: now, createdAt: now,
}).run(); }).run();
+9 -1
View File
@@ -38,7 +38,7 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
}); });
app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => { app.patch<{ Body: UpdateUserRequest }>('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
const { displayName, avatar, customStatus, status, replicatedInstances } = request.body; const { displayName, avatar, customStatus, status, replicatedInstances, homeUserId } = request.body;
const db = getDb(); const db = getDb();
const updateData: Record<string, string | null | undefined> = {}; const updateData: Record<string, string | null | undefined> = {};
@@ -97,6 +97,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
updateData.replicatedInstances = JSON.stringify(replicatedInstances); updateData.replicatedInstances = JSON.stringify(replicatedInstances);
} }
if (homeUserId !== undefined) {
// Only allow setting homeUserId for replicated users (has homeInstance)
const currentUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (currentUser?.homeInstance && typeof homeUserId === 'string' && homeUserId.length > 0) {
updateData.homeUserId = homeUserId;
}
}
if (Object.keys(updateData).length === 0) { if (Object.keys(updateData).length === 0) {
return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); return reply.code(400).send({ error: 'No fields to update', statusCode: 400 });
} }
+1
View File
@@ -21,6 +21,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User {
isAdmin: row.isAdmin === 1, isAdmin: row.isAdmin === 1,
createdAt: row.createdAt, createdAt: row.createdAt,
homeInstance: row.homeInstance ?? null, homeInstance: row.homeInstance ?? null,
homeUserId: row.homeUserId ?? null,
replicatedInstances, replicatedInstances,
}; };
} }
+3
View File
@@ -10,6 +10,7 @@ export interface User {
isAdmin: boolean; isAdmin: boolean;
createdAt: number; createdAt: number;
homeInstance: string | null; homeInstance: string | null;
homeUserId: string | null;
replicatedInstances: ReplicatedInstance[]; replicatedInstances: ReplicatedInstance[];
} }
@@ -253,6 +254,7 @@ export interface RegisterRequest {
password: string; password: string;
displayName?: string; displayName?: string;
homeInstance?: string; homeInstance?: string;
homeUserId?: string;
} }
export interface LoginRequest { export interface LoginRequest {
@@ -293,6 +295,7 @@ export interface UpdateUserRequest {
customStatus?: string; customStatus?: string;
status?: UserStatus; status?: UserStatus;
replicatedInstances?: ReplicatedInstance[]; replicatedInstances?: ReplicatedInstance[];
homeUserId?: string;
} }
export interface UpdateMemberRequest { export interface UpdateMemberRequest {
@@ -62,6 +62,7 @@ const makeRequest = (overrides: Partial<FriendRequest> = {}): FriendRequest => (
isAdmin: false, isAdmin: false,
createdAt: Date.now(), createdAt: Date.now(),
homeInstance: null, homeInstance: null,
homeUserId: null,
replicatedInstances: [], replicatedInstances: [],
}, },
...overrides, ...overrides,
@@ -218,6 +219,7 @@ describe('FriendsPage', () => {
isAdmin: false, isAdmin: false,
createdAt: Date.now(), createdAt: Date.now(),
homeInstance: null, homeInstance: null,
homeUserId: null,
replicatedInstances: [], replicatedInstances: [],
}, },
}); });
@@ -265,6 +267,7 @@ describe('FriendsPage', () => {
isAdmin: false, isAdmin: false,
createdAt: Date.now(), createdAt: Date.now(),
homeInstance: null, homeInstance: null,
homeUserId: null,
replicatedInstances: [], replicatedInstances: [],
}, },
}); });
@@ -307,6 +310,7 @@ describe('FriendsPage', () => {
isAdmin: false, isAdmin: false,
createdAt: Date.now(), createdAt: Date.now(),
homeInstance: null, homeInstance: null,
homeUserId: null,
replicatedInstances: [], replicatedInstances: [],
}, },
}); });
@@ -2,8 +2,6 @@ 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';
@@ -48,7 +46,6 @@ 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;
@@ -100,7 +97,6 @@ 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}
@@ -114,7 +110,6 @@ 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 -1
View File
@@ -48,7 +48,7 @@ export function Avatar({ src, name, size = 40, status, className = '', onClick,
const initials = name.charAt(0).toUpperCase(); const initials = name.charAt(0).toUpperCase();
// Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px // Match prototype: 24px→10px, 32-34px→12px, 40px→15px, 56px+→18px
const fontPx = size <= 24 ? 10 : size <= 34 ? 12 : size <= 44 ? 15 : 18; 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) => { const handleClick = (e: React.MouseEvent) => {
if (onClick) { if (onClick) {
@@ -55,7 +55,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
<div <div
className="h-[48px] rounded-t-[12px]" className="h-[48px] rounded-t-[12px]"
style={{ style={{
background: getAvatarGradient(user.id, displayName).gradient, background: getAvatarGradient(user.homeUserId ?? user.id, displayName).gradient,
opacity: 0.6, opacity: 0.6,
}} }}
/> />
@@ -1,7 +1,6 @@
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';
@@ -24,8 +23,7 @@ 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 = participant.homeUserId ?? userId;
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;
@@ -1,7 +1,5 @@
import React from 'react'; import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore'; import { useVoiceStore } from '../../stores/voiceStore';
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';
@@ -20,8 +18,11 @@ 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 authUser = useAuthStore((s) => s.user); const currentUserId = useVoiceStore((s) => {
const currentUserId = authUser?.id; // 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 members = useServerStore((s) => s.members);
const isActive = currentVoiceChannel === channelId; const isActive = currentVoiceChannel === channelId;
@@ -75,7 +76,7 @@ export function VoiceChannel({ channelId, channelName, onClick }: VoiceChannelPr
name={displayName} name={displayName}
size={24} size={24}
status={status} 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> <span className="text-[13px] text-txt-secondary truncate flex-1 min-w-0">{displayName}</span>
{/* Status badges */} {/* Status badges */}
@@ -1,7 +1,6 @@
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 {
@@ -21,8 +20,7 @@ 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 = participant.homeUserId ?? participant.userId;
const avatarUserId = isLocal ? (homeUser?.id ?? participant.userId) : participant.userId;
// --- VIDEO & UI --- // --- VIDEO & UI ---
+5 -1
View File
@@ -12,7 +12,7 @@ import {
LocalAudioTrack, LocalAudioTrack,
LocalTrackPublication, LocalTrackPublication,
} from 'livekit-client'; } from 'livekit-client';
import { getApiForOrigin, getChannelOrigin } from '../stores/serverStore'; import { getApiForOrigin, getChannelOrigin, useServerStore } from '../stores/serverStore';
import { useVoiceStore } from '../stores/voiceStore'; import { useVoiceStore } from '../stores/voiceStore';
import { AudioManager } from '../audio/AudioManager'; import { AudioManager } from '../audio/AudioManager';
import { SpeakingDetector } from '../audio/SpeakingDetector'; import { SpeakingDetector } from '../audio/SpeakingDetector';
@@ -37,6 +37,7 @@ export interface ParticipantInfo {
identity: string; identity: string;
userId: string; userId: string;
username: string; username: string;
homeUserId: string | null;
isMuted: boolean; isMuted: boolean;
isDeafened: boolean; isDeafened: boolean;
isCameraOn: boolean; isCameraOn: boolean;
@@ -152,6 +153,8 @@ export function useLiveKit() {
const processParticipant = (p: Participant, isLocal: boolean) => { const processParticipant = (p: Participant, isLocal: boolean) => {
if (!p.identity) return; if (!p.identity) return;
const { userId, username } = parseIdentity(p.identity); 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 audioTrack: MediaStreamTrack | null = null;
let videoTrack: MediaStreamTrack | null = null; let videoTrack: MediaStreamTrack | null = null;
let screenTrack: MediaStreamTrack | null = null; let screenTrack: MediaStreamTrack | null = null;
@@ -194,6 +197,7 @@ export function useLiveKit() {
identity: p.identity, identity: p.identity,
userId, userId,
username, username,
homeUserId,
isMuted: isPartMuted, isMuted: isPartMuted,
isDeafened: isPartDeafened, isDeafened: isPartDeafened,
isCameraOn: !!videoTrack, isCameraOn: !!videoTrack,
+10
View File
@@ -150,6 +150,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
password, password,
displayName: displayName || currentUser.displayName || undefined, displayName: displayName || currentUser.displayName || undefined,
homeInstance, homeInstance,
homeUserId: currentUser.id,
}); });
} catch (err) { } catch (err) {
const message = (err as Error).message; const message = (err as Error).message;
@@ -162,6 +163,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
password, password,
displayName: displayName || currentUser.displayName || undefined, displayName: displayName || currentUser.displayName || undefined,
homeInstance, homeInstance,
homeUserId: currentUser.id,
}); });
} catch (err2) { } catch (err2) {
const msg2 = (err2 as Error).message; const msg2 = (err2 as Error).message;
@@ -380,6 +382,14 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Non-critical — keep cached label // 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 = { const connectedInstance: ConnectedInstance = {
origin, origin,
label, label,