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:
@@ -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));
|
||||
|
||||
@@ -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)})`
|
||||
|
||||
@@ -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 ?? '';
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in New Issue
Block a user