From 274f5a710e31d349eb496e9c2099975cbc426045 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:19:05 +0100 Subject: [PATCH] fix: use space gradients for mutual space avatars and add federation indicators in profile modal Space avatars in the mutual spaces tab now use getSpaceGradient() instead of a flat grey background, matching the sidebar appearance. Mutual friends and spaces from remote instances show a globe icon with the instance hostname. Also wires up federated mutuals loading, correct API client routing for remote user profiles, and the new mutuals utility. --- packages/server/src/routes/users.ts | 29 +++- packages/web/src/api/client.ts | 12 +- .../web/src/components/modals/NewDmModal.tsx | 8 +- .../components/modals/UserProfileModal.tsx | 129 ++++++++++++------ .../src/components/ui/UserProfilePopout.tsx | 20 +-- packages/web/src/stores/instanceStore.ts | 12 +- packages/web/src/stores/spaceStore.ts | 21 +++ packages/web/src/utils/mutuals.ts | 79 +++++++++++ 8 files changed, 245 insertions(+), 65 deletions(-) create mode 100644 packages/web/src/utils/mutuals.ts diff --git a/packages/server/src/routes/users.ts b/packages/server/src/routes/users.ts index 1c27e99a..0de18928 100644 --- a/packages/server/src/routes/users.ts +++ b/packages/server/src/routes/users.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq, inArray } from 'drizzle-orm'; +import { eq, or, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate, verifyPassword } from '../utils/auth.js'; import { connectionManager } from '../ws/handler.js'; @@ -182,16 +182,31 @@ export async function userRoutes(app: FastifyInstance): Promise { return reply.code(200).send(sanitizeUser(user)); }); - app.get<{ Params: { id: string } }>('/api/users/:id/mutuals', { preHandler: authenticate }, async (request, reply) => { + app.get<{ Params: { id: string }; Querystring: { homeUserId?: string } }>( + '/api/users/:id/mutuals', { preHandler: authenticate }, async (request, reply) => { const { id: targetId } = request.params; + const homeUserId = request.query.homeUserId; const myId = request.userId; const db = getDb(); + // Resolve target: try path ID first, then homeUserId fallback (federation) + let resolvedTargetId = targetId; + const directUser = db.select().from(schema.users).where(eq(schema.users.id, targetId)).get(); + if (!directUser && homeUserId) { + const fallbackUser = db.select().from(schema.users) + .where(or(eq(schema.users.homeUserId, homeUserId), eq(schema.users.id, homeUserId))).get(); + if (fallbackUser) resolvedTargetId = fallbackUser.id; + } + // Mutual friends: users who are friends with both me and the target - const myFriendRows = db.select().from(schema.friends).where(eq(schema.friends.userId, myId)).all(); - const targetFriendRows = db.select().from(schema.friends).where(eq(schema.friends.userId, targetId)).all(); - const myFriendIds = new Set(myFriendRows.map((f) => f.friendId)); - const targetFriendIds = new Set(targetFriendRows.map((f) => f.friendId)); + const myFriendRows = db.select().from(schema.friends).where( + or(eq(schema.friends.userId, myId), eq(schema.friends.friendId, myId)) + ).all(); + const targetFriendRows = db.select().from(schema.friends).where( + or(eq(schema.friends.userId, resolvedTargetId), eq(schema.friends.friendId, resolvedTargetId)) + ).all(); + const myFriendIds = new Set(myFriendRows.map(f => f.userId === myId ? f.friendId : f.userId)); + const targetFriendIds = new Set(targetFriendRows.map(f => f.userId === resolvedTargetId ? f.friendId : f.userId)); const mutualFriendIds = [...myFriendIds].filter((id) => targetFriendIds.has(id)); const mutualFriends = mutualFriendIds.length > 0 @@ -200,7 +215,7 @@ export async function userRoutes(app: FastifyInstance): Promise { // Mutual spaces: spaces both me and the target are members of const myMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, myId)).all(); - const targetMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, targetId)).all(); + const targetMemberships = db.select().from(schema.spaceMembers).where(eq(schema.spaceMembers.userId, resolvedTargetId)).all(); const mySpaceIds = new Set(myMemberships.map((m) => m.spaceId)); const targetSpaceIds = new Set(targetMemberships.map((m) => m.spaceId)); const mutualSpaceIds = [...mySpaceIds].filter((id) => targetSpaceIds.has(id)); diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 624062a1..a69a9763 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -45,7 +45,7 @@ export class BackspaceApiClient { update: (data: UpdateUserRequest) => Promise; get: (id: string) => Promise; verifyPassword: (password: string) => Promise; - getMutuals: (id: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>; + getMutuals: (id: string, homeUserId?: string) => Promise<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>; }; readonly spaces: { @@ -211,8 +211,14 @@ export class BackspaceApiClient { get: (id: string) => request('GET', `/users/${id}`), verifyPassword: (password: string) => request('POST', '/users/@me/verify-password', { password }), - getMutuals: (id: string) => - request<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>('GET', `/users/${id}/mutuals`), + getMutuals: (id: string, homeUserId?: string) => { + const params = new URLSearchParams(); + if (homeUserId) params.set('homeUserId', homeUserId); + const qs = params.toString(); + return request<{ mutualFriends: User[]; mutualSpaces: { id: string; name: string; icon: string | null }[] }>( + 'GET', `/users/${id}/mutuals${qs ? `?${qs}` : ''}` + ); + }, }; this.spaces = { diff --git a/packages/web/src/components/modals/NewDmModal.tsx b/packages/web/src/components/modals/NewDmModal.tsx index bffce32b..c43b5f4a 100644 --- a/packages/web/src/components/modals/NewDmModal.tsx +++ b/packages/web/src/components/modals/NewDmModal.tsx @@ -3,7 +3,7 @@ import { useNavigate } from 'react-router-dom'; import { Modal } from '../ui/Modal'; import { Avatar } from '../ui/Avatar'; import { useUIStore } from '../../stores/uiStore'; -import { useSpaceStore } from '../../stores/spaceStore'; +import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { api } from '../../api/client'; import type { User } from '@backspace/shared'; @@ -66,8 +66,10 @@ export function NewDmModal() { navigate(`/channels/@me/${existing.dm.id}`); return; } - const channel = await api.dm.create({ userId: user.id }); - addDmChannel(channel); + const origin = resolveUserOrigin(user); + const dmApi = getApiForOrigin(origin); + const channel = await dmApi.dm.create({ userId: user.id }); + addDmChannel(channel, origin); closeModal(); useUIStore.getState().setShowDms(true); navigate(`/channels/@me/${channel.id}`); diff --git a/packages/web/src/components/modals/UserProfileModal.tsx b/packages/web/src/components/modals/UserProfileModal.tsx index 81cd68d9..e1839a2b 100644 --- a/packages/web/src/components/modals/UserProfileModal.tsx +++ b/packages/web/src/components/modals/UserProfileModal.tsx @@ -4,21 +4,15 @@ import ReactMarkdown from 'react-markdown'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; -import { api } from '../../api/client'; import { useUIStore } from '../../stores/uiStore'; -import { useSpaceStore } from '../../stores/spaceStore'; +import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { useSocialStore } from '../../stores/socialStore'; -import { getAvatarGradient, adjustColor } from '../../utils/gradients'; +import { getAvatarGradient, getSpaceGradient, adjustColor } from '../../utils/gradients'; import { parseFederatedUsername } from '../../utils/identity'; +import { loadFederatedMutuals, type TaggedMutualFriend, type MutualSpace } from '../../utils/mutuals'; type Tab = 'about' | 'friends' | 'spaces'; -interface MutualSpace { - id: string; - name: string; - icon: string | null; -} - export function UserProfileModal() { const activeModal = useUIStore((s) => s.activeModal); const modalData = useUIStore((s) => s.modalData); @@ -30,31 +24,35 @@ export function UserProfileModal() { const removeFriend = useSocialStore((s) => s.removeFriend); const [user, setUser] = useState(null); + const [userOrigin, setUserOrigin] = useState(''); const [activeTab, setActiveTab] = useState('about'); - const [mutualFriends, setMutualFriends] = useState([]); + const [mutualFriends, setMutualFriends] = useState([]); const [mutualSpaces, setMutualSpaces] = useState([]); const [loadingMutuals, setLoadingMutuals] = useState(false); const [friendActionLoading, setFriendActionLoading] = useState(false); const isOpen = activeModal === 'userProfile'; const userId = modalData?.userId as string | undefined; + const passedUser = modalData?.user as User | undefined; + const passedOrigin = (modalData?.origin as string | undefined) ?? ''; // Determine friendship status const isFriend = user ? friends.some((f) => f.id === user.id) : false; - const loadUser = useCallback(async (id: string) => { + const loadUser = useCallback(async (id: string, origin: string) => { try { - const u = await api.users.get(id); + const targetApi = getApiForOrigin(origin); + const u = await targetApi.users.get(id); setUser(u); } catch { // User not found } }, []); - const loadMutuals = useCallback(async (id: string) => { + const loadMutuals = useCallback(async (id: string, targetUser?: User) => { setLoadingMutuals(true); try { - const data = await api.users.getMutuals(id); + const data = await loadFederatedMutuals(id, targetUser?.homeUserId); setMutualFriends(data.mutualFriends); setMutualSpaces(data.mutualSpaces); } catch { @@ -68,15 +66,23 @@ export function UserProfileModal() { useEffect(() => { if (isOpen && userId) { setActiveTab('about'); - loadUser(userId); - loadMutuals(userId); + const origin = passedOrigin || (passedUser ? resolveUserOrigin(passedUser) : ''); + setUserOrigin(origin); + // Use the passed user directly (avoids 404 for federated users on local API) + if (passedUser) { + setUser(passedUser); + } else { + loadUser(userId, origin); + } + loadMutuals(userId, passedUser); } - }, [isOpen, userId, loadUser, loadMutuals]); + }, [isOpen, userId, passedUser, passedOrigin, loadUser, loadMutuals]); // Reset on close useEffect(() => { if (!isOpen) { setUser(null); + setUserOrigin(''); setMutualFriends([]); setMutualSpaces([]); } @@ -97,9 +103,10 @@ export function UserProfileModal() { const { baseName, domain } = parseFederatedUsername(user.username); const displayName = user.displayName ?? baseName; - // Banner + // Banner — use correct API client for remote users + const profileApi = getApiForOrigin(userOrigin); const bannerSrc = user.banner - ? (user.banner.startsWith('http') ? user.banner : api.uploads.url(user.banner)) + ? (user.banner.startsWith('http') ? user.banner : profileApi.uploads.url(user.banner)) : null; const bannerFallback = user.accentColor ? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})` @@ -114,8 +121,9 @@ export function UserProfileModal() { navigate(`/channels/@me/${existing.dm.id}`); return; } - const channel = await api.dm.create({ userId: user.id }); - addDmChannel(channel); + const dmApi = getApiForOrigin(userOrigin); + const channel = await dmApi.dm.create({ userId: user.id }); + addDmChannel(channel, userOrigin); useUIStore.getState().setShowDms(true); closeModal(); navigate(`/channels/@me/${channel.id}`); @@ -139,12 +147,14 @@ export function UserProfileModal() { } }; - const handleViewFriend = (friendId: string) => { - loadUser(friendId); - loadMutuals(friendId); + const handleViewFriend = (friend: TaggedMutualFriend) => { + const friendOrigin = friend._instanceOrigin || resolveUserOrigin(friend); + setUserOrigin(friendOrigin); + setUser(friend); + loadMutuals(friend.id, friend); setActiveTab('about'); // Update modal data so re-opening preserves context - useUIStore.getState().openModal('userProfile', { userId: friendId }); + useUIStore.getState().openModal('userProfile', { userId: friend.id, user: friend, origin: friendOrigin }); }; const handleGoToSpace = (spaceId: string) => { @@ -317,7 +327,7 @@ export function UserProfileModal() { return ( ); @@ -358,28 +376,53 @@ export function UserProfileModal() { ) : (
- {mutualSpaces.map((space) => ( + {mutualSpaces.map((space) => { + const spaceApi = getApiForOrigin(space._instanceOrigin); + return ( - ))} + ); + })}
)} diff --git a/packages/web/src/components/ui/UserProfilePopout.tsx b/packages/web/src/components/ui/UserProfilePopout.tsx index 5b810f2c..ab9d5369 100644 --- a/packages/web/src/components/ui/UserProfilePopout.tsx +++ b/packages/web/src/components/ui/UserProfilePopout.tsx @@ -4,11 +4,11 @@ import ReactMarkdown from 'react-markdown'; import type { User } from '@backspace/shared'; import { Avatar } from '../ui/Avatar'; import { Username } from '../ui/Username'; -import { api } from '../../api/client'; -import { useSpaceStore } from '../../stores/spaceStore'; +import { useSpaceStore, getApiForOrigin, resolveUserOrigin } from '../../stores/spaceStore'; import { useUIStore } from '../../stores/uiStore'; import { getAvatarGradient, adjustColor } from '../../utils/gradients'; import { parseFederatedUsername } from '../../utils/identity'; +import { loadFederatedMutuals } from '../../utils/mutuals'; interface UserProfilePopoutProps { user: User; @@ -23,13 +23,16 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout const { baseName, domain } = parseFederatedUsername(user.username); const displayName = user.displayName ?? baseName; + const origin = resolveUserOrigin(user); + const userApi = getApiForOrigin(origin); + const [mutualCounts, setMutualCounts] = useState<{ friends: number; spaces: number } | null>(null); useEffect(() => { - api.users.getMutuals(user.id) + loadFederatedMutuals(user.id, user.homeUserId) .then((data) => setMutualCounts({ friends: data.mutualFriends.length, spaces: data.mutualSpaces.length })) .catch(() => {}); - }, [user.id]); + }, [user.id, user.homeUserId]); const top = position ? Math.min(Math.max(8, position.top), window.innerHeight - 460) @@ -47,8 +50,9 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout navigate(`/channels/@me/${existing.dm.id}`); return; } - const channel = await api.dm.create({ userId: user.id }); - addDmChannel(channel); + const dmApi = getApiForOrigin(origin); + const channel = await dmApi.dm.create({ userId: user.id }); + addDmChannel(channel, origin); useUIStore.getState().setShowDms(true); onClose(); navigate(`/channels/@me/${channel.id}`); @@ -59,12 +63,12 @@ export function UserProfilePopout({ user, onClose, position }: UserProfilePopout const handleViewFullProfile = () => { onClose(); - openModal('userProfile', { userId: user.id }); + openModal('userProfile', { userId: user.id, user, origin }); }; // Banner display const bannerSrc = user.banner - ? (user.banner.startsWith('http') ? user.banner : api.uploads.url(user.banner)) + ? (user.banner.startsWith('http') ? user.banner : userApi.uploads.url(user.banner)) : null; const bannerFallback = user.accentColor ? `linear-gradient(135deg, ${user.accentColor}, ${adjustColor(user.accentColor, -40)})` diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index bdc81d2c..2334af89 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -2,7 +2,7 @@ import { create } from 'zustand'; import type { User, InstanceInfoResponse, ReplicatedInstance, AuthResponse } from '@backspace/shared'; import { BackspaceApiClient, createApiClient, api } from '../api/client'; import { useAuthStore } from './authStore'; -import { setApiForOriginResolver, setUserIdForOriginResolver, useSpaceStore } from './spaceStore'; +import { setApiForOriginResolver, setUserIdForOriginResolver, setOriginFromHostnameResolver, useSpaceStore } from './spaceStore'; import { connectInstance, disconnectInstance, disconnectAllRemote } from '../hooks/useWebSocket'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -605,3 +605,13 @@ setUserIdForOriginResolver((origin: string): string | undefined => { const instance = useInstanceStore.getState().instances.find(i => i.origin === origin); return instance?.user.id; }); + +// ─── Hostname → origin resolution (federation) ──────────────────────────────── +// Maps a user's homeInstance hostname to its full origin URL. + +setOriginFromHostnameResolver((hostname: string): string => { + const inst = useInstanceStore.getState().instances.find(i => { + try { return new URL(i.origin).host === hostname; } catch { return false; } + }); + return inst?.origin ?? ''; +}); diff --git a/packages/web/src/stores/spaceStore.ts b/packages/web/src/stores/spaceStore.ts index 97986178..86fb657d 100644 --- a/packages/web/src/stores/spaceStore.ts +++ b/packages/web/src/stores/spaceStore.ts @@ -582,6 +582,27 @@ export function getApiForOrigin(origin: string): BackspaceApiClient { return _getApiForOrigin(origin); } +// ─── Hostname → origin resolution (federation) ──────────────────────────────── +// Converts a user's `homeInstance` hostname (e.g. "remote.example.com") to a +// full origin URL (e.g. "https://remote.example.com") by looking up connected +// instances. Registered by instanceStore on import. + +let _resolveOriginFromHostname: ((hostname: string) => string) | null = null; + +export function setOriginFromHostnameResolver(resolver: (hostname: string) => string): void { + _resolveOriginFromHostname = resolver; +} + +/** + * Returns the instance origin for a federated user based on their homeInstance. + * '' = home/local user, 'https://...' = remote instance. + */ +export function resolveUserOrigin(user: { homeInstance?: string | null }): string { + const host = user.homeInstance; + if (!host || host === window.location.host) return ''; + return _resolveOriginFromHostname?.(host) ?? ''; +} + // ─── User ID resolution (federation) ────────────────────────────────────────── // Same resolver pattern as getApiForOrigin — registered by instanceStore on // import to break the circular dependency chain. diff --git a/packages/web/src/utils/mutuals.ts b/packages/web/src/utils/mutuals.ts new file mode 100644 index 00000000..167583a6 --- /dev/null +++ b/packages/web/src/utils/mutuals.ts @@ -0,0 +1,79 @@ +import type { User } from '@backspace/shared'; +import { api } from '../api/client'; +import { useInstanceStore } from '../stores/instanceStore'; +import { normalizeUserAssets, resolveAssetUrl } from './assetUrls'; + +// ─── Tagged types ──────────────────────────────────────────────────────────── + +export type TaggedMutualFriend = User & { _instanceOrigin: string }; + +export interface MutualSpace { + id: string; + name: string; + icon: string | null; + _instanceOrigin: string; +} + +export interface FederatedMutuals { + mutualFriends: TaggedMutualFriend[]; + mutualSpaces: MutualSpace[]; +} + +// ─── Federation fan-out ────────────────────────────────────────────────────── + +/** + * Load mutual friends and mutual spaces across all connected instances. + * Follows the same Promise.allSettled fan-out pattern as socialStore.loadFriends(). + * + * - Deduplicates friends by canonical identity (homeUserId ?? id) + * - Concatenates spaces (spaces on different instances are distinct) + * - Normalizes assets for remote-origin results + */ +export async function loadFederatedMutuals( + targetUserId: string, + targetHomeUserId?: string | null, +): Promise { + const instances = useInstanceStore.getState().instances; + const connectedInstances = instances.filter(i => i.status === 'connected'); + + const canonicalHomeId = targetHomeUserId ?? targetUserId; + + const results = await Promise.allSettled([ + api.users.getMutuals(targetUserId, canonicalHomeId) + .then(data => ({ data, origin: '' })), + ...connectedInstances.map(inst => + inst.api.users.getMutuals(targetUserId, canonicalHomeId) + .then(data => ({ data, origin: inst.origin })) + ), + ]); + + const allFriends: TaggedMutualFriend[] = []; + const seenFriends = new Set(); + const allSpaces: MutualSpace[] = []; + const seenSpaces = new Set(); + + for (const result of results) { + if (result.status !== 'fulfilled') continue; + const { data, origin } = result.value; + + // Deduplicate friends by canonical identity (homeUserId ?? id) + for (const friend of data.mutualFriends) { + const canonicalId = friend.homeUserId ?? friend.id; + if (seenFriends.has(canonicalId)) continue; + seenFriends.add(canonicalId); + if (origin) normalizeUserAssets(friend, origin); + allFriends.push({ ...friend, _instanceOrigin: origin }); + } + + // Spaces on different instances are distinct — deduplicate within same origin + for (const space of data.mutualSpaces) { + const key = `${space.id}:${origin}`; + if (seenSpaces.has(key)) continue; + seenSpaces.add(key); + const icon = origin ? (resolveAssetUrl(space.icon, origin) ?? space.icon) : space.icon; + allSpaces.push({ ...space, icon, _instanceOrigin: origin }); + } + } + + return { mutualFriends: allFriends, mutualSpaces: allSpaces }; +}