From c7c3331dbfa3beb4275aa125bd73f7ccfc05989e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Wed, 4 Mar 2026 13:45:25 +0100 Subject: [PATCH] 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. --- packages/server/src/db/migrate.ts | 3 ++- packages/server/src/db/schema.ts | 1 + packages/server/src/routes/auth.ts | 3 ++- packages/server/src/routes/users.ts | 10 +++++++++- packages/server/src/utils/sanitize.ts | 1 + packages/shared/src/types.ts | 3 +++ packages/web/src/components/chat/FriendsPage.test.tsx | 4 ++++ packages/web/src/components/layout/MemberSidebar.tsx | 5 ----- packages/web/src/components/ui/Avatar.tsx | 2 +- packages/web/src/components/ui/UserProfilePopout.tsx | 2 +- packages/web/src/components/voice/StreamTile.tsx | 4 +--- packages/web/src/components/voice/VoiceChannel.tsx | 11 ++++++----- packages/web/src/components/voice/VoiceUser.tsx | 4 +--- packages/web/src/hooks/useLiveKit.ts | 6 +++++- packages/web/src/stores/instanceStore.ts | 10 ++++++++++ 15 files changed, 47 insertions(+), 22 deletions(-) diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index 044f9a0f..1c1ec615 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -60,7 +60,8 @@ export function runMigrations(db: Database.Database): void { name: 'users', columns: [ { name: 'home_instance', type: 'TEXT' }, - { name: 'replicated_instances', type: "TEXT DEFAULT '[]'" } + { name: 'replicated_instances', type: "TEXT DEFAULT '[]'" }, + { name: 'home_user_id', type: 'TEXT' } ] }, { diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 911b39d7..899e93e3 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -10,6 +10,7 @@ export const users = sqliteTable('users', { customStatus: text('custom_status'), isAdmin: integer('is_admin').default(0), homeInstance: text('home_instance'), + homeUserId: text('home_user_id'), replicatedInstances: text('replicated_instances').default('[]'), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 15ea3d35..a67108ac 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -17,7 +17,7 @@ export async function authRoutes(app: FastifyInstance): Promise { }, }, }, async (request, reply) => { - const { username, password, displayName, homeInstance } = request.body; + const { username, password, displayName, homeInstance, homeUserId } = request.body; if (!username || typeof username !== 'string') { return reply.code(400).send({ error: 'Username is required', statusCode: 400 }); @@ -102,6 +102,7 @@ export async function authRoutes(app: FastifyInstance): Promise { status: 'online', isAdmin: isFirstUser ? 1 : 0, homeInstance: homeInstance || null, + homeUserId: (homeInstance && homeUserId && typeof homeUserId === 'string') ? homeUserId : null, createdAt: now, }).run(); diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index f051accd..70ff034e 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -38,7 +38,7 @@ export async function userRoutes(app: FastifyInstance): Promise { }); 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 updateData: Record = {}; @@ -97,6 +97,14 @@ export async function userRoutes(app: FastifyInstance): Promise { 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) { return reply.code(400).send({ error: 'No fields to update', statusCode: 400 }); } diff --git a/packages/server/src/utils/sanitize.ts b/packages/server/src/utils/sanitize.ts index 39d6c379..07544764 100644 --- a/packages/server/src/utils/sanitize.ts +++ b/packages/server/src/utils/sanitize.ts @@ -21,6 +21,7 @@ export function sanitizeUser(row: typeof schema.users.$inferSelect): User { isAdmin: row.isAdmin === 1, createdAt: row.createdAt, homeInstance: row.homeInstance ?? null, + homeUserId: row.homeUserId ?? null, replicatedInstances, }; } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 90910815..8a31ab72 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -10,6 +10,7 @@ export interface User { isAdmin: boolean; createdAt: number; homeInstance: string | null; + homeUserId: string | null; replicatedInstances: ReplicatedInstance[]; } @@ -253,6 +254,7 @@ export interface RegisterRequest { password: string; displayName?: string; homeInstance?: string; + homeUserId?: string; } export interface LoginRequest { @@ -293,6 +295,7 @@ export interface UpdateUserRequest { customStatus?: string; status?: UserStatus; replicatedInstances?: ReplicatedInstance[]; + homeUserId?: string; } export interface UpdateMemberRequest { diff --git a/packages/web/src/components/chat/FriendsPage.test.tsx b/packages/web/src/components/chat/FriendsPage.test.tsx index 61c084cf..f561f151 100644 --- a/packages/web/src/components/chat/FriendsPage.test.tsx +++ b/packages/web/src/components/chat/FriendsPage.test.tsx @@ -62,6 +62,7 @@ const makeRequest = (overrides: Partial = {}): 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: [], }, }); diff --git a/packages/web/src/components/layout/MemberSidebar.tsx b/packages/web/src/components/layout/MemberSidebar.tsx index ab34328e..4d6a4e6a 100644 --- a/packages/web/src/components/layout/MemberSidebar.tsx +++ b/packages/web/src/components/layout/MemberSidebar.tsx @@ -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 (
{ if (onClick) { diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index 7c84c4de..53fbfce0 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -55,7 +55,7 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout
diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index 5e2d064a..e2942509 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -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; diff --git a/packages/web/src/components/voice/VoiceChannel.tsx b/packages/web/src/components/voice/VoiceChannel.tsx index 86fab9cd..ccde919b 100644 --- a/packages/web/src/components/voice/VoiceChannel.tsx +++ b/packages/web/src/components/voice/VoiceChannel.tsx @@ -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} /> {displayName} {/* Status badges */} diff --git a/packages/web/src/components/voice/VoiceUser.tsx b/packages/web/src/components/voice/VoiceUser.tsx index 1abe2aa1..42e7e632 100644 --- a/packages/web/src/components/voice/VoiceUser.tsx +++ b/packages/web/src/components/voice/VoiceUser.tsx @@ -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 --- diff --git a/packages/web/src/hooks/useLiveKit.ts b/packages/web/src/hooks/useLiveKit.ts index b2637ee1..75725d57 100644 --- a/packages/web/src/hooks/useLiveKit.ts +++ b/packages/web/src/hooks/useLiveKit.ts @@ -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, diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index ce6151c4..20fdd6c2 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -150,6 +150,7 @@ export const useInstanceStore = create((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((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((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,