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.
This commit is contained in:
Jannis Braun
2026-03-10 19:19:05 +01:00
parent 003eff2268
commit 274f5a710e
8 changed files with 245 additions and 65 deletions
+22 -7
View File
@@ -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<void> {
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<void> {
// 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));
+9 -3
View File
@@ -45,7 +45,7 @@ export class BackspaceApiClient {
update: (data: UpdateUserRequest) => Promise<User>;
get: (id: string) => Promise<User>;
verifyPassword: (password: string) => Promise<VerifyPasswordResponse>;
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<User>('GET', `/users/${id}`),
verifyPassword: (password: string) =>
request<VerifyPasswordResponse>('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 = {
@@ -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}`);
@@ -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<User | null>(null);
const [userOrigin, setUserOrigin] = useState('');
const [activeTab, setActiveTab] = useState<Tab>('about');
const [mutualFriends, setMutualFriends] = useState<User[]>([]);
const [mutualFriends, setMutualFriends] = useState<TaggedMutualFriend[]>([]);
const [mutualSpaces, setMutualSpaces] = useState<MutualSpace[]>([]);
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);
}
}, [isOpen, userId, loadUser, loadMutuals]);
loadMutuals(userId, passedUser);
}
}, [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 (
<button
key={friend.id}
onClick={() => handleViewFriend(friend.id)}
onClick={() => handleViewFriend(friend)}
className="flex items-center gap-2.5 p-2.5 rounded-lg bg-white/[0.03] hover:bg-white/[0.06] border border-white/[0.04] transition-colors text-left"
>
<Avatar
@@ -334,6 +344,14 @@ export function UserProfileModal() {
<div className="text-[11px] text-txt-tertiary capitalize">
{friend.status}
</div>
{friend._instanceOrigin && (
<div className="flex items-center gap-1 text-[10px] text-txt-tertiary/70 truncate">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" className="shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
<span className="truncate">{(() => { try { return new URL(friend._instanceOrigin).host; } catch { return '?'; } })()}</span>
</div>
)}
</div>
</button>
);
@@ -358,28 +376,53 @@ export function UserProfileModal() {
</div>
) : (
<div className="space-y-1">
{mutualSpaces.map((space) => (
{mutualSpaces.map((space) => {
const spaceApi = getApiForOrigin(space._instanceOrigin);
return (
<button
key={space.id}
key={`${space.id}:${space._instanceOrigin}`}
onClick={() => handleGoToSpace(space.id)}
className="flex items-center gap-3 w-full p-2.5 rounded-lg hover:bg-white/[0.06] transition-colors text-left"
>
<div className="relative shrink-0">
{space.icon ? (
<img
src={space.icon.startsWith('http') ? space.icon : api.uploads.url(space.icon)}
src={space.icon.startsWith('http') ? space.icon : spaceApi.uploads.url(space.icon)}
alt={space.name}
className="w-8 h-8 rounded-lg object-cover"
/>
) : (
<div className="w-8 h-8 rounded-lg bg-white/[0.06] flex items-center justify-center text-[13px] font-semibold text-txt-secondary">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center text-[13px] font-semibold text-white"
style={{ background: getSpaceGradient(space.id, space.name).gradient }}
>
{space.name.charAt(0).toUpperCase()}
</div>
)}
{space._instanceOrigin && (
<div className="absolute -bottom-0.5 -right-0.5 w-[14px] h-[14px] rounded-full bg-[#1a1a23] flex items-center justify-center">
<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary/80">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
</div>
)}
</div>
<div className="min-w-0 flex flex-col">
<span className="text-[13px] font-medium text-txt-primary truncate">
{space.name}
</span>
{space._instanceOrigin && (
<span className="text-[10px] text-txt-tertiary/70 truncate flex items-center gap-1">
<svg width="9" height="9" viewBox="0 0 24 24" fill="currentColor" className="shrink-0">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
</svg>
{(() => { try { return new URL(space._instanceOrigin).host; } catch { return '?'; } })()}
</span>
)}
</div>
</button>
))}
);
})}
</div>
)}
</div>
@@ -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)})`
+11 -1
View File
@@ -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 ?? '';
});
+21
View File
@@ -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.
+79
View File
@@ -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<FederatedMutuals> {
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<string>();
const allSpaces: MutualSpace[] = [];
const seenSpaces = new Set<string>();
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 };
}