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,