feat: federate friends/social system across connected instances
Rewrites socialStore to aggregate friends and requests from all connected instances using Promise.allSettled. Parses user@domain in friend requests to route to the correct instance. Removes !isHome guards on social WS events so remote friend requests arrive in real-time. Shows "via hostname" labels on remote friends/requests in the UI.
This commit is contained in:
@@ -3,7 +3,7 @@ import { render, screen, waitFor } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
import { FriendsPage } from './FriendsPage';
|
import { FriendsPage } from './FriendsPage';
|
||||||
import { useSocialStore } from '../../stores/socialStore';
|
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
import type { Friend, FriendRequest } from '@backspace/shared';
|
import type { Friend, FriendRequest } from '@backspace/shared';
|
||||||
|
|
||||||
@@ -25,6 +25,15 @@ vi.mock('../../api/client', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Mock the instanceStore (imported by socialStore)
|
||||||
|
vi.mock('../../stores/instanceStore', () => ({
|
||||||
|
useInstanceStore: {
|
||||||
|
getState: () => ({ instances: [] }),
|
||||||
|
setState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
const mockNavigate = vi.fn();
|
const mockNavigate = vi.fn();
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock('react-router-dom', async () => {
|
||||||
const actual = await vi.importActual('react-router-dom');
|
const actual = await vi.importActual('react-router-dom');
|
||||||
@@ -34,7 +43,7 @@ vi.mock('react-router-dom', async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeFriend = (overrides: Partial<Friend> = {}): Friend => ({
|
const makeFriend = (overrides: Partial<TaggedFriend> = {}): TaggedFriend => ({
|
||||||
id: 'friend-1',
|
id: 'friend-1',
|
||||||
username: 'testfriend',
|
username: 'testfriend',
|
||||||
displayName: 'Test Friend',
|
displayName: 'Test Friend',
|
||||||
@@ -43,15 +52,19 @@ const makeFriend = (overrides: Partial<Friend> = {}): Friend => ({
|
|||||||
customStatus: null,
|
customStatus: null,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
addedAt: Date.now(),
|
addedAt: Date.now(),
|
||||||
|
homeUserId: null,
|
||||||
|
homeInstance: null,
|
||||||
|
_instanceOrigin: '',
|
||||||
...overrides,
|
...overrides,
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeRequest = (overrides: Partial<FriendRequest> = {}): FriendRequest => ({
|
const makeRequest = (overrides: Partial<TaggedFriendRequest> = {}): TaggedFriendRequest => ({
|
||||||
id: 'req-1',
|
id: 'req-1',
|
||||||
fromId: 'other-user',
|
fromId: 'other-user',
|
||||||
toId: 'current-user',
|
toId: 'current-user',
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
|
_instanceOrigin: '',
|
||||||
user: {
|
user: {
|
||||||
id: 'other-user',
|
id: 'other-user',
|
||||||
username: 'otheruser',
|
username: 'otheruser',
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSocialStore } from '../../stores/socialStore';
|
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
||||||
import { useServerStore } from '../../stores/serverStore';
|
import { useServerStore } from '../../stores/serverStore';
|
||||||
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
import { Avatar } from '../ui/Avatar';
|
import { Avatar } from '../ui/Avatar';
|
||||||
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
import { MemberListToggleButton } from '../layout/MemberListToggleButton';
|
||||||
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
import { LoadingSpinner } from '../ui/LoadingSpinner';
|
||||||
import { api } from '../../api/client';
|
import { api } from '../../api/client';
|
||||||
import type { Friend, FriendRequest } from '@backspace/shared';
|
|
||||||
|
|
||||||
type Tab = 'online' | 'all' | 'pending' | 'add';
|
type Tab = 'online' | 'all' | 'pending' | 'add';
|
||||||
|
|
||||||
@@ -51,9 +51,14 @@ export function FriendsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenDm = async (friendId: string) => {
|
const handleOpenDm = async (friendId: string, instanceOrigin: string) => {
|
||||||
try {
|
try {
|
||||||
const dmChannel = await api.dm.create({ userId: friendId });
|
let client = api;
|
||||||
|
if (instanceOrigin) {
|
||||||
|
const instance = useInstanceStore.getState().instances.find(i => i.origin === instanceOrigin);
|
||||||
|
if (instance?.api) client = instance.api;
|
||||||
|
}
|
||||||
|
const dmChannel = await client.dm.create({ userId: friendId });
|
||||||
addDmChannel(dmChannel);
|
addDmChannel(dmChannel);
|
||||||
navigate(`/channels/@me/${dmChannel.id}`);
|
navigate(`/channels/@me/${dmChannel.id}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -84,7 +89,7 @@ export function FriendsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
onlineFriends.map(friend => (
|
onlineFriends.map(friend => (
|
||||||
<FriendItem key={friend.id} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} />
|
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +106,7 @@ export function FriendsPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
friends.map(friend => (
|
friends.map(friend => (
|
||||||
<FriendItem key={friend.id} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id)} />
|
<FriendItem key={`${friend.id}:${friend._instanceOrigin}`} friend={friend} onRemove={() => removeFriend(friend.id)} onDm={() => handleOpenDm(friend.id, friend._instanceOrigin)} />
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -120,7 +125,7 @@ export function FriendsPage() {
|
|||||||
<>
|
<>
|
||||||
{pendingIncoming.map(req => (
|
{pendingIncoming.map(req => (
|
||||||
<RequestItem
|
<RequestItem
|
||||||
key={req.id}
|
key={`${req.id}:${req._instanceOrigin}`}
|
||||||
request={req}
|
request={req}
|
||||||
type="incoming"
|
type="incoming"
|
||||||
onAccept={() => updateFriendRequest(req.id, 'accepted')}
|
onAccept={() => updateFriendRequest(req.id, 'accepted')}
|
||||||
@@ -129,7 +134,7 @@ export function FriendsPage() {
|
|||||||
))}
|
))}
|
||||||
{pendingOutgoing.map(req => (
|
{pendingOutgoing.map(req => (
|
||||||
<RequestItem
|
<RequestItem
|
||||||
key={req.id}
|
key={`${req.id}:${req._instanceOrigin}`}
|
||||||
request={req}
|
request={req}
|
||||||
type="outgoing"
|
type="outgoing"
|
||||||
onCancel={() => cancelFriendRequest(req.id)}
|
onCancel={() => cancelFriendRequest(req.id)}
|
||||||
@@ -227,7 +232,8 @@ function TabButton({ children, active, onClick }: { children: React.ReactNode, a
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () => void, onDm: () => void }) {
|
function FriendItem({ friend, onRemove, onDm }: { friend: TaggedFriend, onRemove: () => void, onDm: () => void }) {
|
||||||
|
const instanceLabel = friend._instanceOrigin ? (() => { try { return new URL(friend._instanceOrigin).host; } catch { return friend._instanceOrigin; } })() : '';
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
<div className="flex items-center justify-between px-3 h-[62px] rounded-[8px] hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -237,7 +243,12 @@ function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () =
|
|||||||
<span className="text-txt-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
|
<span className="text-txt-primary font-semibold text-[15px]">{friend.displayName ?? friend.username}</span>
|
||||||
<span className="text-txt-tertiary text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
|
<span className="text-txt-tertiary text-[13px] opacity-0 group-hover:opacity-100 transition-opacity font-medium">@{friend.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-[12px] text-txt-tertiary font-medium uppercase">{friend.status}</span>
|
<span className="text-[12px] text-txt-tertiary font-medium uppercase">{friend.status}</span>
|
||||||
|
{instanceLabel && (
|
||||||
|
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity pr-2">
|
||||||
@@ -265,7 +276,7 @@ function FriendItem({ friend, onRemove, onDm }: { friend: Friend, onRemove: () =
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
||||||
request: FriendRequest;
|
request: TaggedFriendRequest;
|
||||||
type: 'incoming' | 'outgoing';
|
type: 'incoming' | 'outgoing';
|
||||||
onAccept?: () => void;
|
onAccept?: () => void;
|
||||||
onDecline?: () => void;
|
onDecline?: () => void;
|
||||||
@@ -273,6 +284,7 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
}) {
|
}) {
|
||||||
const user = request.user;
|
const user = request.user;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
const instanceLabel = request._instanceOrigin ? (() => { try { return new URL(request._instanceOrigin).host; } catch { return request._instanceOrigin; } })() : '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
<div className="flex items-center justify-between px-3 py-2.5 rounded-lg hover:bg-interactive-hover group transition-colors border-t border-interactive-muted mx-2">
|
||||||
@@ -283,7 +295,12 @@ function RequestItem({ request, type, onAccept, onDecline, onCancel }: {
|
|||||||
<span className="text-txt-primary font-bold text-sm">{user.displayName ?? user.username}</span>
|
<span className="text-txt-primary font-bold text-sm">{user.displayName ?? user.username}</span>
|
||||||
<span className="text-txt-tertiary text-xs">@{user.username}</span>
|
<span className="text-txt-tertiary text-xs">@{user.username}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
<span className="text-xs text-txt-tertiary">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
|
<span className="text-xs text-txt-tertiary">{type === 'incoming' ? 'Incoming Friend Request' : 'Outgoing Friend Request'}</span>
|
||||||
|
{instanceLabel && (
|
||||||
|
<span className="text-[11px] text-txt-tertiary/60 font-medium">via {instanceLabel}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -316,26 +316,23 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
onReactionRemoved(event.messageId, event.userId, event.emoji);
|
onReactionRemoved(event.messageId, event.userId, event.emoji);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
// ─── Social events (home-only) ──────────────────────────────────────────
|
// ─── Social events (all origins — federation) ──────────────────────────
|
||||||
|
|
||||||
case 'friend_request_received': {
|
case 'friend_request_received': {
|
||||||
if (!isHome) break;
|
|
||||||
const { addIncomingRequest } = useSocialStore.getState();
|
const { addIncomingRequest } = useSocialStore.getState();
|
||||||
addIncomingRequest(event.request);
|
addIncomingRequest(event.request, origin);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'friend_request_accepted': {
|
case 'friend_request_accepted': {
|
||||||
if (!isHome) break;
|
|
||||||
const { addFriendFromAccepted } = useSocialStore.getState();
|
const { addFriendFromAccepted } = useSocialStore.getState();
|
||||||
addFriendFromAccepted(event.friend, event.requestId);
|
addFriendFromAccepted(event.friend, event.requestId, origin);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'friend_removed': {
|
case 'friend_removed': {
|
||||||
if (!isHome) break;
|
|
||||||
const { removeFriendLocally } = useSocialStore.getState();
|
const { removeFriendLocally } = useSocialStore.getState();
|
||||||
removeFriendLocally(event.userId);
|
removeFriendLocally(event.userId, origin);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,6 +478,29 @@ function handleEvent(origin: string, event: ServerEvent): void {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Join request events (home-only) ────────────────────────────────
|
||||||
|
|
||||||
|
case 'join_request_received': {
|
||||||
|
if (!isHome) break;
|
||||||
|
console.log('[WebSocket] Join request received from', event.request.user?.username ?? event.request.userId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'join_request_accepted': {
|
||||||
|
if (!isHome) break;
|
||||||
|
// Add the server to our server list
|
||||||
|
const { addServerFromReady } = useServerStore.getState();
|
||||||
|
addServerFromReady(origin, event.server);
|
||||||
|
console.log('[WebSocket] Join request accepted for server', event.server.name);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'join_request_declined': {
|
||||||
|
if (!isHome) break;
|
||||||
|
console.log('[WebSocket] Join request declined for server', event.request.serverId);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'pong':
|
case 'pong':
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,26 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import type { Friend, FriendRequest, User } from '@backspace/shared';
|
import type { Friend, FriendRequest, User } from '@backspace/shared';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { useInstanceStore } from './instanceStore';
|
||||||
|
|
||||||
|
// ─── Tagged types (origin tracking for federation) ───────────────────────────
|
||||||
|
|
||||||
|
export type TaggedFriend = Friend & { _instanceOrigin: string };
|
||||||
|
export type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string };
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getApiForOrigin(origin: string) {
|
||||||
|
if (!origin) return api;
|
||||||
|
const instance = useInstanceStore.getState().instances.find(i => i.origin === origin);
|
||||||
|
return instance?.api ?? api;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Store ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface SocialState {
|
interface SocialState {
|
||||||
friends: Friend[];
|
friends: TaggedFriend[];
|
||||||
requests: FriendRequest[];
|
requests: TaggedFriendRequest[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
loadFriends: () => Promise<void>;
|
loadFriends: () => Promise<void>;
|
||||||
@@ -14,10 +30,10 @@ interface SocialState {
|
|||||||
cancelFriendRequest: (id: string) => Promise<void>;
|
cancelFriendRequest: (id: string) => Promise<void>;
|
||||||
removeFriend: (id: string) => Promise<void>;
|
removeFriend: (id: string) => Promise<void>;
|
||||||
searchUsers: (query: string) => Promise<User[]>;
|
searchUsers: (query: string) => Promise<User[]>;
|
||||||
addIncomingRequest: (request: FriendRequest) => void;
|
addIncomingRequest: (request: FriendRequest, origin: string) => void;
|
||||||
addFriendFromAccepted: (friend: Friend, requestId: string) => void;
|
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void;
|
||||||
updateFriendPresence: (userId: string, status: string) => void;
|
updateFriendPresence: (userId: string, status: string) => void;
|
||||||
removeFriendLocally: (userId: string) => void;
|
removeFriendLocally: (userId: string, origin: string) => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,8 +46,31 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
loadFriends: async () => {
|
loadFriends: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const friends = await api.social.friends();
|
const instances = useInstanceStore.getState().instances;
|
||||||
set({ friends, isLoading: false });
|
const connectedInstances = instances.filter(i => i.status === 'connected');
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
api.social.friends().then(friends => ({ friends, origin: '' })),
|
||||||
|
...connectedInstances.map(inst =>
|
||||||
|
inst.api.social.friends().then(friends => ({ friends, origin: inst.origin }))
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allFriends: TaggedFriend[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status !== 'fulfilled') continue;
|
||||||
|
const { friends, origin } = result.value;
|
||||||
|
for (const friend of friends) {
|
||||||
|
const key = `${friend.id}:${origin}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
allFriends.push({ ...friend, _instanceOrigin: origin });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ friends: allFriends, isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: (err as Error).message, isLoading: false });
|
set({ error: (err as Error).message, isLoading: false });
|
||||||
}
|
}
|
||||||
@@ -40,8 +79,31 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
loadRequests: async () => {
|
loadRequests: async () => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
const requests = await api.social.requests();
|
const instances = useInstanceStore.getState().instances;
|
||||||
set({ requests, isLoading: false });
|
const connectedInstances = instances.filter(i => i.status === 'connected');
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
api.social.requests().then(requests => ({ requests, origin: '' })),
|
||||||
|
...connectedInstances.map(inst =>
|
||||||
|
inst.api.social.requests().then(requests => ({ requests, origin: inst.origin }))
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allRequests: TaggedFriendRequest[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status !== 'fulfilled') continue;
|
||||||
|
const { requests, origin } = result.value;
|
||||||
|
for (const request of requests) {
|
||||||
|
const key = `${request.id}:${origin}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
allRequests.push({ ...request, _instanceOrigin: origin });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ requests: allRequests, isLoading: false });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: (err as Error).message, isLoading: false });
|
set({ error: (err as Error).message, isLoading: false });
|
||||||
}
|
}
|
||||||
@@ -50,7 +112,43 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
sendFriendRequest: async (username: string) => {
|
sendFriendRequest: async (username: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
|
const atIndex = username.lastIndexOf('@');
|
||||||
|
|
||||||
|
if (atIndex === -1) {
|
||||||
|
// No @ → local user on home instance
|
||||||
await api.social.sendRequest(username);
|
await api.social.sendRequest(username);
|
||||||
|
} else {
|
||||||
|
const baseName = username.slice(0, atIndex);
|
||||||
|
const domain = username.slice(atIndex + 1);
|
||||||
|
|
||||||
|
// Check if domain matches home instance
|
||||||
|
if (domain === window.location.host) {
|
||||||
|
// Strip domain, send to home API
|
||||||
|
await api.social.sendRequest(baseName);
|
||||||
|
} else {
|
||||||
|
// Find a connected instance matching this domain
|
||||||
|
const instances = useInstanceStore.getState().instances;
|
||||||
|
const match = instances.find(inst => {
|
||||||
|
try {
|
||||||
|
return new URL(inst.origin).host === domain;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`Not connected to ${domain}. Add it as an instance in Settings first.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match.status !== 'connected') {
|
||||||
|
throw new Error(`Instance ${domain} is not currently connected. Check your connection in Settings.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// On the remote instance, the user is just "alice", not "alice@orbit"
|
||||||
|
await match.api.social.sendRequest(baseName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await get().loadRequests();
|
await get().loadRequests();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
set({ error: (err as Error).message, isLoading: false });
|
set({ error: (err as Error).message, isLoading: false });
|
||||||
@@ -61,7 +159,12 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
updateFriendRequest: async (id: string, status: 'accepted' | 'declined') => {
|
updateFriendRequest: async (id: string, status: 'accepted' | 'declined') => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await api.social.updateRequest(id, status);
|
// Find the request to determine which instance owns it
|
||||||
|
const request = get().requests.find(r => r.id === id);
|
||||||
|
const origin = request?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
|
||||||
|
await client.social.updateRequest(id, status);
|
||||||
await get().loadRequests();
|
await get().loadRequests();
|
||||||
if (status === 'accepted') {
|
if (status === 'accepted') {
|
||||||
await get().loadFriends();
|
await get().loadFriends();
|
||||||
@@ -75,7 +178,11 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
cancelFriendRequest: async (id: string) => {
|
cancelFriendRequest: async (id: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await api.social.cancelRequest(id);
|
const request = get().requests.find(r => r.id === id);
|
||||||
|
const origin = request?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
|
||||||
|
await client.social.cancelRequest(id);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
requests: state.requests.filter(r => r.id !== id),
|
requests: state.requests.filter(r => r.id !== id),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -89,9 +196,14 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
removeFriend: async (id: string) => {
|
removeFriend: async (id: string) => {
|
||||||
set({ isLoading: true, error: null });
|
set({ isLoading: true, error: null });
|
||||||
try {
|
try {
|
||||||
await api.social.removeFriend(id);
|
// Find the friend to determine which instance owns it
|
||||||
|
const friend = get().friends.find(f => f.id === id);
|
||||||
|
const origin = friend?._instanceOrigin ?? '';
|
||||||
|
const client = getApiForOrigin(origin);
|
||||||
|
|
||||||
|
await client.social.removeFriend(id);
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
friends: state.friends.filter((f) => f.id !== id),
|
friends: state.friends.filter(f => !(f.id === id && f._instanceOrigin === origin)),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -102,7 +214,27 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
|
|
||||||
searchUsers: async (query: string) => {
|
searchUsers: async (query: string) => {
|
||||||
try {
|
try {
|
||||||
return await api.social.search(query);
|
const instances = useInstanceStore.getState().instances;
|
||||||
|
const connectedInstances = instances.filter(i => i.status === 'connected');
|
||||||
|
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
api.social.search(query),
|
||||||
|
...connectedInstances.map(inst => inst.api.social.search(query)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const allUsers: User[] = [];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status !== 'fulfilled') continue;
|
||||||
|
for (const user of result.value) {
|
||||||
|
if (seen.has(user.id)) continue;
|
||||||
|
seen.add(user.id);
|
||||||
|
allUsers.push(user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allUsers;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to search users:', err);
|
console.error('Failed to search users:', err);
|
||||||
return [];
|
return [];
|
||||||
@@ -110,25 +242,30 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
// Called from WS handler when another user sends you a friend request
|
// Called from WS handler when another user sends you a friend request
|
||||||
addIncomingRequest: (request: FriendRequest) => {
|
addIncomingRequest: (request: FriendRequest, origin: string) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
if (state.requests.find(r => r.id === request.id)) return state;
|
const key = `${request.id}:${origin}`;
|
||||||
return { requests: [...state.requests, request] };
|
if (state.requests.find(r => `${r.id}:${r._instanceOrigin}` === key)) return state;
|
||||||
|
return { requests: [...state.requests, { ...request, _instanceOrigin: origin }] };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Called from WS handler when someone accepts your friend request
|
// Called from WS handler when someone accepts your friend request
|
||||||
addFriendFromAccepted: (friend: Friend, requestId: string) => {
|
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => {
|
||||||
set((state) => ({
|
set((state) => {
|
||||||
friends: state.friends.find(f => f.id === friend.id) ? state.friends : [...state.friends, friend],
|
const key = `${friend.id}:${origin}`;
|
||||||
requests: state.requests.filter(r => r.id !== requestId),
|
const alreadyExists = state.friends.some(f => `${f.id}:${f._instanceOrigin}` === key);
|
||||||
}));
|
return {
|
||||||
|
friends: alreadyExists ? state.friends : [...state.friends, { ...friend, _instanceOrigin: origin }],
|
||||||
|
requests: state.requests.filter(r => !(r.id === requestId && r._instanceOrigin === origin)),
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// Called from WS handler when the other user removes us as a friend
|
// Called from WS handler when the other user removes us as a friend
|
||||||
removeFriendLocally: (userId: string) => {
|
removeFriendLocally: (userId: string, origin: string) => {
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
friends: state.friends.filter(f => f.id !== userId),
|
friends: state.friends.filter(f => !(f.id === userId && f._instanceOrigin === origin)),
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user