Merge branch 'feature/unified-friend-search'
This commit is contained in:
@@ -5,6 +5,7 @@ import { MemoryRouter } from 'react-router-dom';
|
|||||||
import { FriendsPage } from './FriendsPage';
|
import { FriendsPage } from './FriendsPage';
|
||||||
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest } from '../../stores/socialStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import type { Friend, FriendRequest } from '@backspace/shared';
|
import type { Friend, FriendRequest } from '@backspace/shared';
|
||||||
|
|
||||||
// Mock the mascot animation hook
|
// Mock the mascot animation hook
|
||||||
@@ -36,17 +37,80 @@ vi.mock('../../api/client', () => ({
|
|||||||
cancelRequest: vi.fn().mockResolvedValue({ success: true }),
|
cancelRequest: vi.fn().mockResolvedValue({ success: true }),
|
||||||
removeFriend: vi.fn().mockResolvedValue({ success: true }),
|
removeFriend: vi.fn().mockResolvedValue({ success: true }),
|
||||||
search: vi.fn().mockResolvedValue([]),
|
search: vi.fn().mockResolvedValue([]),
|
||||||
|
discover: vi.fn().mockResolvedValue({ users: [], total: 0 }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock the instanceStore (imported by socialStore)
|
// Mock the instanceStore (imported by socialStore)
|
||||||
vi.mock('../../stores/instanceStore', () => ({
|
vi.mock('../../stores/instanceStore', () => ({
|
||||||
useInstanceStore: {
|
useInstanceStore: Object.assign(
|
||||||
getState: () => ({ instances: [] }),
|
(selector: (s: any) => any) => selector({
|
||||||
|
instances: [],
|
||||||
|
_autoConnectDone: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
getState: () => ({ instances: [], _autoConnectDone: true }),
|
||||||
setState: vi.fn(),
|
setState: vi.fn(),
|
||||||
subscribe: vi.fn(),
|
subscribe: vi.fn(),
|
||||||
},
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../stores/discoverStore', () => ({
|
||||||
|
useDiscoverStore: Object.assign(
|
||||||
|
(selector: (s: any) => any) => selector({
|
||||||
|
users: [],
|
||||||
|
isLoading: false,
|
||||||
|
searchQuery: '',
|
||||||
|
setSearchQuery: vi.fn(),
|
||||||
|
fetchUsers: vi.fn(),
|
||||||
|
updateRelationship: vi.fn(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
getState: () => ({
|
||||||
|
users: [],
|
||||||
|
isLoading: false,
|
||||||
|
searchQuery: '',
|
||||||
|
fetchUsers: vi.fn(),
|
||||||
|
updateRelationship: vi.fn(),
|
||||||
|
}),
|
||||||
|
setState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../stores/authStore', () => ({
|
||||||
|
useAuthStore: Object.assign(
|
||||||
|
(selector: (s: any) => any) => selector({
|
||||||
|
user: { id: 'current-user' },
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
getState: () => ({ user: { id: 'current-user' } }),
|
||||||
|
setState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock activityStore
|
||||||
|
vi.mock('../../stores/activityStore', () => ({
|
||||||
|
useActivityStore: Object.assign(
|
||||||
|
(selector: (s: any) => any) => selector({
|
||||||
|
userActivities: new Map(),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
getState: () => ({ userActivities: new Map(), reset: vi.fn() }),
|
||||||
|
setState: vi.fn(),
|
||||||
|
subscribe: vi.fn(),
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock ConnectInstanceModal
|
||||||
|
vi.mock('../modals/ConnectInstanceModal', () => ({
|
||||||
|
ConnectInstanceModal: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockNavigate = vi.fn();
|
const mockNavigate = vi.fn();
|
||||||
@@ -122,72 +186,103 @@ beforeEach(() => {
|
|||||||
error: null,
|
error: null,
|
||||||
loadFriends: vi.fn(),
|
loadFriends: vi.fn(),
|
||||||
loadRequests: vi.fn(),
|
loadRequests: vi.fn(),
|
||||||
|
searchUsers: vi.fn().mockResolvedValue([]),
|
||||||
});
|
});
|
||||||
useSpaceStore.setState({
|
useSpaceStore.setState({
|
||||||
dmChannels: [],
|
dmChannels: [],
|
||||||
|
findExistingDmForUser: () => null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FriendsPage', () => {
|
describe('FriendsPage', () => {
|
||||||
describe('Add Friend tab', () => {
|
describe('Add Friend tab', () => {
|
||||||
it('renders the Add Friend form when tab is clicked', async () => {
|
it('renders the search input when Add Friend tab is clicked', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
renderFriendsPage();
|
renderFriendsPage();
|
||||||
|
|
||||||
const addFriendTab = screen.getByText('Add Friend');
|
const addFriendTab = screen.getByText('Add Friend');
|
||||||
await user.click(addFriendTab);
|
await user.click(addFriendTab);
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText('You can add a friend with their username')).toBeInTheDocument();
|
expect(screen.getByPlaceholderText(/Search or add by username/)).toBeInTheDocument();
|
||||||
expect(screen.getByText('Send Friend Request')).toBeInTheDocument();
|
expect(screen.getByText('Find People')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls sendFriendRequest with the username when form is submitted', async () => {
|
it('shows Direct Add row and sends request for user@domain input', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const mockSendFriendRequest = vi.fn().mockResolvedValue(undefined);
|
const mockSendFriendRequest = vi.fn().mockResolvedValue('req-123');
|
||||||
useSocialStore.setState({
|
useSocialStore.setState({
|
||||||
sendFriendRequest: mockSendFriendRequest,
|
sendFriendRequest: mockSendFriendRequest,
|
||||||
});
|
});
|
||||||
|
|
||||||
renderFriendsPage();
|
renderFriendsPage();
|
||||||
|
|
||||||
// Switch to Add Friend tab
|
|
||||||
await user.click(screen.getByText('Add Friend'));
|
await user.click(screen.getByText('Add Friend'));
|
||||||
|
|
||||||
// Type username
|
const input = screen.getByPlaceholderText(/Search or add by username/);
|
||||||
const input = screen.getByPlaceholderText('You can add a friend with their username');
|
await user.type(input, 'newbuddy@remote.example.com');
|
||||||
await user.type(input, 'newbuddy');
|
|
||||||
|
|
||||||
// Click send
|
// Direct Add row should appear
|
||||||
await user.click(screen.getByText('Send Friend Request'));
|
expect(screen.getByText(/Send friend request to/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Click Send Request
|
||||||
|
await user.click(screen.getByText('Send Request'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy');
|
expect(mockSendFriendRequest).toHaveBeenCalledWith('newbuddy@remote.example.com');
|
||||||
});
|
|
||||||
|
|
||||||
// Should show success message
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/Success! Your friend request to newbuddy has been sent/)).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows error when sendFriendRequest fails', async () => {
|
it('shows toast when Direct Add request fails', async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found'));
|
const mockSendFriendRequest = vi.fn().mockRejectedValue(new Error('User not found'));
|
||||||
|
const mockAddToast = vi.fn();
|
||||||
useSocialStore.setState({
|
useSocialStore.setState({
|
||||||
sendFriendRequest: mockSendFriendRequest,
|
sendFriendRequest: mockSendFriendRequest,
|
||||||
});
|
});
|
||||||
|
useUIStore.setState({
|
||||||
|
addToast: mockAddToast,
|
||||||
|
});
|
||||||
|
|
||||||
renderFriendsPage();
|
renderFriendsPage();
|
||||||
await user.click(screen.getByText('Add Friend'));
|
await user.click(screen.getByText('Add Friend'));
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText('You can add a friend with their username');
|
const input = screen.getByPlaceholderText(/Search or add by username/);
|
||||||
await user.type(input, 'ghost');
|
await user.type(input, 'ghost@remote.example.com');
|
||||||
await user.click(screen.getByText('Send Friend Request'));
|
await user.click(screen.getByText('Send Request'));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText('User not found')).toBeInTheDocument();
|
expect(mockAddToast).toHaveBeenCalledWith('User not found', 'warning');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not show Direct Add row for plain usernames', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderFriendsPage();
|
||||||
|
await user.click(screen.getByText('Add Friend'));
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Search or add by username/);
|
||||||
|
await user.type(input, 'marc');
|
||||||
|
|
||||||
|
expect(screen.queryByText(/Send friend request to/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls searchUsers when typing a non-@ query', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const mockSearchUsers = vi.fn().mockResolvedValue([]);
|
||||||
|
useSocialStore.setState({
|
||||||
|
searchUsers: mockSearchUsers,
|
||||||
|
});
|
||||||
|
|
||||||
|
renderFriendsPage();
|
||||||
|
await user.click(screen.getByText('Add Friend'));
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText(/Search or add by username/);
|
||||||
|
await user.type(input, 'marc');
|
||||||
|
|
||||||
|
// Wait for debounce
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockSearchUsers).toHaveBeenCalledWith('marc');
|
||||||
|
}, { timeout: 500 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('DM button on friend item', () => {
|
describe('DM button on friend item', () => {
|
||||||
@@ -202,6 +297,7 @@ describe('FriendsPage', () => {
|
|||||||
});
|
});
|
||||||
useSpaceStore.setState({
|
useSpaceStore.setState({
|
||||||
addDmChannel: mockAddDmChannel,
|
addDmChannel: mockAddDmChannel,
|
||||||
|
findExistingDmForUser: () => null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock the dm.create API
|
// Mock the dm.create API
|
||||||
@@ -226,7 +322,7 @@ describe('FriendsPage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' }));
|
expect(mockAddDmChannel).toHaveBeenCalledWith(expect.objectContaining({ id: 'dm-channel-99' }), '');
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore';
|
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore';
|
||||||
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { ConnectInstanceModal } from '../modals/ConnectInstanceModal';
|
import { ConnectInstanceModal } from '../modals/ConnectInstanceModal';
|
||||||
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
|
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
@@ -26,14 +27,6 @@ interface FriendsPageProps {
|
|||||||
|
|
||||||
export function FriendsPage({ mobile }: FriendsPageProps) {
|
export function FriendsPage({ mobile }: FriendsPageProps) {
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('online');
|
const [activeTab, setActiveTab] = useState<Tab>('online');
|
||||||
const [addUsername, setAddUsername] = useState('');
|
|
||||||
const [addStatus, setAddStatus] = useState<{ type: 'success' | 'error', message: string } | null>(null);
|
|
||||||
const addToast = useUIStore((s) => s.addToast);
|
|
||||||
const [connectModal, setConnectModal] = useState<{
|
|
||||||
domain: string;
|
|
||||||
isReconnect: boolean;
|
|
||||||
username: string;
|
|
||||||
} | null>(null);
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
const addDmChannel = useSpaceStore((s) => s.addDmChannel);
|
||||||
|
|
||||||
@@ -43,7 +36,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
isLoading,
|
isLoading,
|
||||||
loadFriends,
|
loadFriends,
|
||||||
loadRequests,
|
loadRequests,
|
||||||
sendFriendRequest,
|
|
||||||
updateFriendRequest,
|
updateFriendRequest,
|
||||||
cancelFriendRequest,
|
cancelFriendRequest,
|
||||||
removeFriend
|
removeFriend
|
||||||
@@ -61,41 +53,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
const pendingIncoming = requests.filter(r => r.status === 'pending' && r.user?.id === r.fromId);
|
const pendingIncoming = requests.filter(r => r.status === 'pending' && r.user?.id === r.fromId);
|
||||||
const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.user?.id === r.toId);
|
const pendingOutgoing = requests.filter(r => r.status === 'pending' && r.user?.id === r.toId);
|
||||||
|
|
||||||
const handleAddFriend = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
if (!addUsername.trim()) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendFriendRequest(addUsername.trim());
|
|
||||||
setAddStatus({ type: 'success', message: `Success! Your friend request to ${addUsername} has been sent.` });
|
|
||||||
setAddUsername('');
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof InstanceNotConnectedError) {
|
|
||||||
setConnectModal({ domain: err.domain, isReconnect: false, username: addUsername.trim() });
|
|
||||||
} else if (err instanceof InstanceDisconnectedError) {
|
|
||||||
setConnectModal({ domain: err.domain, isReconnect: true, username: addUsername.trim() });
|
|
||||||
} else {
|
|
||||||
setAddStatus({ type: 'error', message: (err as Error).message });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddFriendConnected = async (result: 'new' | 'reconnect') => {
|
|
||||||
const username = connectModal?.username;
|
|
||||||
const domain = connectModal?.domain;
|
|
||||||
setConnectModal(null);
|
|
||||||
if (!username) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await sendFriendRequest(username);
|
|
||||||
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
|
|
||||||
setAddStatus({ type: 'success', message: `${verb} ${domain} — friend request sent!` });
|
|
||||||
setAddUsername('');
|
|
||||||
} catch (err) {
|
|
||||||
addToast((err as Error).message, 'warning');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenDm = async (friendId: string, instanceOrigin: string, homeUserId?: string) => {
|
const handleOpenDm = async (friendId: string, instanceOrigin: string, homeUserId?: string) => {
|
||||||
try {
|
try {
|
||||||
// Check if a DM already exists with this user (on any instance)
|
// Check if a DM already exists with this user (on any instance)
|
||||||
@@ -201,11 +158,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
case 'add':
|
case 'add':
|
||||||
return (
|
return (
|
||||||
<AddFriendTab
|
<AddFriendTab
|
||||||
addUsername={addUsername}
|
|
||||||
setAddUsername={setAddUsername}
|
|
||||||
addStatus={addStatus}
|
|
||||||
isLoading={isLoading}
|
|
||||||
onSubmit={handleAddFriend}
|
|
||||||
onOpenDm={handleOpenDm}
|
onOpenDm={handleOpenDm}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -396,16 +348,6 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{renderTabContent()}
|
{renderTabContent()}
|
||||||
|
|
||||||
{connectModal && (
|
|
||||||
<ConnectInstanceModal
|
|
||||||
domain={connectModal.domain}
|
|
||||||
targetDisplayName={connectModal.username}
|
|
||||||
isReconnect={connectModal.isReconnect}
|
|
||||||
onConnected={handleAddFriendConnected}
|
|
||||||
onCancel={() => setConnectModal(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -413,97 +355,165 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
|
|||||||
// ─── Add Friend Tab ─────────────────────────────────────────────────────────
|
// ─── Add Friend Tab ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function AddFriendTab({
|
function AddFriendTab({
|
||||||
addUsername,
|
|
||||||
setAddUsername,
|
|
||||||
addStatus,
|
|
||||||
isLoading,
|
|
||||||
onSubmit,
|
|
||||||
onOpenDm,
|
onOpenDm,
|
||||||
}: {
|
}: {
|
||||||
addUsername: string;
|
|
||||||
setAddUsername: (v: string) => void;
|
|
||||||
addStatus: { type: 'success' | 'error'; message: string } | null;
|
|
||||||
isLoading: boolean;
|
|
||||||
onSubmit: (e: React.FormEvent) => void;
|
|
||||||
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const searchUsers = useSocialStore((s) => s.searchUsers);
|
||||||
|
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||||
|
const friends = useSocialStore((s) => s.friends);
|
||||||
|
const requests = useSocialStore((s) => s.requests);
|
||||||
|
const currentUser = useAuthStore((s) => s.user);
|
||||||
|
const instances = useInstanceStore((s) => s.instances);
|
||||||
|
const addToast = useUIStore((s) => s.addToast);
|
||||||
|
|
||||||
const discoverUsers = useDiscoverStore((s) => s.users);
|
const discoverUsers = useDiscoverStore((s) => s.users);
|
||||||
const discoverLoading = useDiscoverStore((s) => s.isLoading);
|
const discoverLoading = useDiscoverStore((s) => s.isLoading);
|
||||||
const discoverQuery = useDiscoverStore((s) => s.searchQuery);
|
const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers);
|
||||||
const setDiscoverQuery = useDiscoverStore((s) => s.setSearchQuery);
|
const updateRelationship = useDiscoverStore((s) => s.updateRelationship);
|
||||||
const fetchUsers = useDiscoverStore((s) => s.fetchUsers);
|
|
||||||
|
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const [query, setQuery] = useState('');
|
||||||
|
const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]);
|
||||||
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
|
const [directAddLoading, setDirectAddLoading] = useState(false);
|
||||||
|
const [connectModal, setConnectModal] = useState<{
|
||||||
|
domain: string;
|
||||||
|
isReconnect: boolean;
|
||||||
|
username: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Fetch discovery on mount
|
// Fetch discover on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchDiscoverUsers();
|
||||||
}, [fetchUsers]);
|
}, [fetchDiscoverUsers]);
|
||||||
|
|
||||||
// Cleanup debounce timer
|
// Debounced search with race condition guard
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
if (!query.trim()) {
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
setRawSearchResults([]);
|
||||||
};
|
setSearchLoading(false);
|
||||||
}, []);
|
return;
|
||||||
|
}
|
||||||
const handleDiscoverSearch = useCallback((value: string) => {
|
let isActive = true;
|
||||||
setDiscoverQuery(value);
|
setSearchLoading(true);
|
||||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
const timer = setTimeout(async () => {
|
||||||
debounceRef.current = setTimeout(() => {
|
const results = await searchUsers(query.trim());
|
||||||
fetchUsers(value || undefined);
|
if (isActive) {
|
||||||
|
setRawSearchResults(results);
|
||||||
|
setSearchLoading(false);
|
||||||
|
}
|
||||||
}, 300);
|
}, 300);
|
||||||
}, [setDiscoverQuery, fetchUsers]);
|
return () => { isActive = false; clearTimeout(timer); };
|
||||||
|
}, [query, searchUsers]);
|
||||||
|
|
||||||
|
// Self-exclusion set
|
||||||
|
const selfIds = useMemo(() => {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
if (currentUser?.id) ids.add(`${currentUser.id}:`);
|
||||||
|
for (const inst of instances) {
|
||||||
|
if (inst.user?.id) ids.add(`${inst.user.id}:${inst.origin}`);
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}, [currentUser?.id, instances]);
|
||||||
|
|
||||||
|
const isSearchMode = query.trim().length > 0;
|
||||||
|
|
||||||
|
// Enrich search results with friend/request status at render time
|
||||||
|
const enrichedSearchResults: TaggedDiscoverUser[] = useMemo(() => {
|
||||||
|
if (!isSearchMode) return [];
|
||||||
|
return rawSearchResults
|
||||||
|
.filter(u => !selfIds.has(`${u.id}:${u._instanceOrigin}`))
|
||||||
|
.map(user => {
|
||||||
|
const isFriend = friends.some(f => f.id === user.id && f._instanceOrigin === user._instanceOrigin);
|
||||||
|
if (isFriend) {
|
||||||
|
return { ...user, relationship: 'friends' as const, mutualFriendCount: 0, mutualSpaceCount: 0 };
|
||||||
|
}
|
||||||
|
const outbound = requests.find(r => r.status === 'pending' && r.user?.id === r.toId && r.user?.id === user.id && r._instanceOrigin === user._instanceOrigin);
|
||||||
|
if (outbound) {
|
||||||
|
return { ...user, relationship: 'outbound_pending' as const, requestId: outbound.id, mutualFriendCount: 0, mutualSpaceCount: 0 };
|
||||||
|
}
|
||||||
|
const inbound = requests.find(r => r.status === 'pending' && r.user?.id === r.fromId && r.user?.id === user.id && r._instanceOrigin === user._instanceOrigin);
|
||||||
|
if (inbound) {
|
||||||
|
return { ...user, relationship: 'inbound_pending' as const, requestId: inbound.id, mutualFriendCount: 0, mutualSpaceCount: 0 };
|
||||||
|
}
|
||||||
|
return { ...user, relationship: 'none' as const, mutualFriendCount: 0, mutualSpaceCount: 0 };
|
||||||
|
});
|
||||||
|
}, [rawSearchResults, friends, requests, selfIds, isSearchMode]);
|
||||||
|
|
||||||
|
// Direct Add detection (synchronous, not debounced)
|
||||||
|
const atIndex = query.lastIndexOf('@');
|
||||||
|
const showDirectAdd = atIndex > 0 && atIndex < query.length - 1;
|
||||||
|
|
||||||
|
// Direct Add handler
|
||||||
|
const handleDirectAdd = async () => {
|
||||||
|
setDirectAddLoading(true);
|
||||||
|
try {
|
||||||
|
await sendFriendRequest(query.trim());
|
||||||
|
addToast('Friend request sent!', 'success');
|
||||||
|
setQuery('');
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof InstanceNotConnectedError) {
|
||||||
|
setConnectModal({ domain: err.domain, isReconnect: false, username: query.trim() });
|
||||||
|
} else if (err instanceof InstanceDisconnectedError) {
|
||||||
|
setConnectModal({ domain: err.domain, isReconnect: true, username: query.trim() });
|
||||||
|
} else {
|
||||||
|
addToast((err as Error).message, 'warning');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDirectAddLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connect modal handler
|
||||||
|
const handleConnected = async (result: 'new' | 'reconnect') => {
|
||||||
|
const username = connectModal?.username;
|
||||||
|
const domain = connectModal?.domain;
|
||||||
|
setConnectModal(null);
|
||||||
|
if (!username) return;
|
||||||
|
try {
|
||||||
|
await sendFriendRequest(username);
|
||||||
|
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
|
||||||
|
addToast(`${verb} ${domain} — friend request sent!`, 'success');
|
||||||
|
setQuery('');
|
||||||
|
} catch (err) {
|
||||||
|
addToast((err as Error).message, 'warning');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// No-op relationship change for search mode cards (useMemo re-derives from store)
|
||||||
|
const noopRelationshipChange = useCallback(() => {}, []);
|
||||||
|
|
||||||
|
// Determine which list to display
|
||||||
|
const displayUsers = isSearchMode ? enrichedSearchResults : discoverUsers;
|
||||||
|
const displayLoading = isSearchMode ? searchLoading : discoverLoading;
|
||||||
|
const emptyLabel = isSearchMode
|
||||||
|
? 'No users match your search.'
|
||||||
|
: 'No discoverable users yet — invite people to join!';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<div className="p-6 pb-4">
|
<div className="p-6 pb-4">
|
||||||
<h2 className="text-base font-bold text-txt-primary uppercase mb-2">Add Friend</h2>
|
{/* Header */}
|
||||||
<p className="text-sm text-txt-tertiary mb-4">You can add friends with their Backspace username.</p>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<form onSubmit={onSubmit} className="flex flex-col gap-2 mb-4">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
<input
|
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
type="text"
|
|
||||||
placeholder="Enter a username..."
|
|
||||||
value={addUsername}
|
|
||||||
onChange={(e) => setAddUsername(e.target.value)}
|
|
||||||
className="input-search w-full px-4 py-3 rounded-lg"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={!addUsername.trim() || isLoading}
|
|
||||||
className="w-full py-2.5 rounded-lg bg-accent-primary hover:bg-accent-primary-hover disabled:opacity-50 disabled:bg-accent-primary text-white text-sm font-medium transition-colors"
|
|
||||||
>
|
|
||||||
Send Friend Request
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{addStatus && (
|
|
||||||
<div className={`text-sm p-3 rounded-lg border mb-4 ${addStatus.type === 'success' ? 'text-txt-positive border-status-online/20 bg-status-online/5' : 'text-txt-danger border-accent-rose/20 bg-accent-rose/5'}`}>
|
|
||||||
{addStatus.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Discover People section */}
|
|
||||||
<div className="px-6 pb-6">
|
|
||||||
<div className="flex items-center gap-2 mb-4">
|
|
||||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
|
||||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
|
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-txt-tertiary">Discover People</span>
|
<h2 className="text-base font-bold text-txt-primary uppercase">Find People</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-sm text-txt-tertiary mb-4">Search by username or use <span className="font-medium text-txt-secondary">user@instance</span> to add someone directly.</p>
|
||||||
|
|
||||||
{/* Discover search */}
|
{/* Unified search input */}
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search people..."
|
placeholder="Search or add by username..."
|
||||||
value={discoverQuery}
|
value={query}
|
||||||
onChange={(e) => handleDiscoverSearch(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
className="input-search w-full"
|
className="input-search w-full px-4 py-3 rounded-lg"
|
||||||
/>
|
/>
|
||||||
{discoverQuery && (
|
{query && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDiscoverSearch('')}
|
onClick={() => setQuery('')}
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-txt-tertiary hover:text-txt-secondary"
|
className="absolute right-2 top-1/2 -translate-y-1/2 text-txt-tertiary hover:text-txt-secondary"
|
||||||
>
|
>
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -513,34 +523,81 @@ function AddFriendTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid */}
|
{/* Direct Add action row */}
|
||||||
{discoverLoading && discoverUsers.length === 0 ? (
|
{showDirectAdd && (
|
||||||
|
<div className="bg-surface-input rounded-lg p-3 mb-4 flex items-center gap-3">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary flex-shrink-0">
|
||||||
|
<path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1 min-w-0 text-sm text-txt-secondary">
|
||||||
|
Send friend request to <span className="font-semibold text-txt-primary">{query.trim()}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleDirectAdd}
|
||||||
|
disabled={directAddLoading}
|
||||||
|
className="px-3 py-1.5 rounded-md bg-accent-primary hover:bg-accent-primary-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex-shrink-0"
|
||||||
|
>
|
||||||
|
{directAddLoading ? 'Sending...' : 'Send Request'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Results grid */}
|
||||||
|
<div className="px-6 pb-6">
|
||||||
|
{!isSearchMode && (
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary">
|
||||||
|
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.49L17.5 6.5 9.99 9.99 6.5 17.5zm5.5-6.6c.61 0 1.1.49 1.1 1.1s-.49 1.1-1.1 1.1-1.1-.49-1.1-1.1.49-1.1 1.1-1.1z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wider text-txt-tertiary">Discover People</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{displayLoading && displayUsers.length === 0 ? (
|
||||||
<div className="flex items-center justify-center h-32">
|
<div className="flex items-center justify-center h-32">
|
||||||
<LoadingSpinner />
|
<LoadingSpinner />
|
||||||
</div>
|
</div>
|
||||||
) : discoverUsers.length === 0 ? (
|
) : displayUsers.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-32 opacity-60">
|
<div className="flex flex-col items-center justify-center h-32 opacity-60">
|
||||||
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary mb-2">
|
<svg width="36" height="36" viewBox="0 0 24 24" fill="currentColor" className="text-txt-tertiary mb-2">
|
||||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
|
||||||
</svg>
|
</svg>
|
||||||
<p className="text-txt-tertiary text-sm">
|
<p className="text-txt-tertiary text-sm">{emptyLabel}</p>
|
||||||
{discoverQuery
|
|
||||||
? 'No users match your search.'
|
|
||||||
: 'No discoverable users yet — invite people to join!'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||||
{discoverUsers.map((user) => (
|
{isSearchMode
|
||||||
|
? enrichedSearchResults.map((user) => (
|
||||||
<UserDiscoverCard
|
<UserDiscoverCard
|
||||||
key={`${user.id}:${user._instanceOrigin}`}
|
key={`${user.id}:${user._instanceOrigin}`}
|
||||||
user={user}
|
user={user}
|
||||||
onOpenDm={onOpenDm}
|
onOpenDm={onOpenDm}
|
||||||
|
onRelationshipChange={noopRelationshipChange}
|
||||||
/>
|
/>
|
||||||
))}
|
))
|
||||||
|
: discoverUsers.map((user) => (
|
||||||
|
<UserDiscoverCard
|
||||||
|
key={`${user.id}:${user._instanceOrigin}`}
|
||||||
|
user={user}
|
||||||
|
onOpenDm={onOpenDm}
|
||||||
|
onRelationshipChange={updateRelationship}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{connectModal && (
|
||||||
|
<ConnectInstanceModal
|
||||||
|
domain={connectModal.domain}
|
||||||
|
targetDisplayName={connectModal.username}
|
||||||
|
isReconnect={connectModal.isReconnect}
|
||||||
|
onConnected={handleConnected}
|
||||||
|
onCancel={() => setConnectModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -550,13 +607,14 @@ function AddFriendTab({
|
|||||||
function UserDiscoverCard({
|
function UserDiscoverCard({
|
||||||
user,
|
user,
|
||||||
onOpenDm,
|
onOpenDm,
|
||||||
|
onRelationshipChange,
|
||||||
}: {
|
}: {
|
||||||
user: TaggedDiscoverUser;
|
user: TaggedDiscoverUser;
|
||||||
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
onOpenDm: (userId: string, origin: string, homeUserId?: string) => void;
|
||||||
|
onRelationshipChange: (userId: string, origin: string, relationship: TaggedDiscoverUser['relationship'], requestId?: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
const sendFriendRequest = useSocialStore((s) => s.sendFriendRequest);
|
||||||
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
const updateFriendRequest = useSocialStore((s) => s.updateFriendRequest);
|
||||||
const updateRelationship = useDiscoverStore((s) => s.updateRelationship);
|
|
||||||
const openModal = useUIStore((s) => s.openModal);
|
const openModal = useUIStore((s) => s.openModal);
|
||||||
const [actionLoading, setActionLoading] = useState(false);
|
const [actionLoading, setActionLoading] = useState(false);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -587,7 +645,7 @@ function UserDiscoverCard({
|
|||||||
const username = user._instanceOrigin ? baseName + '@' + (originLabel ?? '') : baseName;
|
const username = user._instanceOrigin ? baseName + '@' + (originLabel ?? '') : baseName;
|
||||||
try {
|
try {
|
||||||
const requestId = await sendFriendRequest(username);
|
const requestId = await sendFriendRequest(username);
|
||||||
updateRelationship(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InstanceNotConnectedError) {
|
if (err instanceof InstanceNotConnectedError) {
|
||||||
setConnectModal({ domain: err.domain, isReconnect: false, username });
|
setConnectModal({ domain: err.domain, isReconnect: false, username });
|
||||||
@@ -610,7 +668,7 @@ function UserDiscoverCard({
|
|||||||
setActionLoading(true);
|
setActionLoading(true);
|
||||||
try {
|
try {
|
||||||
const requestId = await sendFriendRequest(username);
|
const requestId = await sendFriendRequest(username);
|
||||||
updateRelationship(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
||||||
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
|
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
|
||||||
addToast(`${verb} ${domain} — friend request sent!`, 'success');
|
addToast(`${verb} ${domain} — friend request sent!`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -626,7 +684,7 @@ function UserDiscoverCard({
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await updateFriendRequest(user.requestId, 'accepted');
|
await updateFriendRequest(user.requestId, 'accepted');
|
||||||
updateRelationship(user.id, user._instanceOrigin, 'friends');
|
onRelationshipChange(user.id, user._instanceOrigin, 'friends');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to accept request');
|
setError(err instanceof Error ? err.message : 'Failed to accept request');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -640,7 +698,7 @@ function UserDiscoverCard({
|
|||||||
setError('');
|
setError('');
|
||||||
try {
|
try {
|
||||||
await updateFriendRequest(user.requestId, 'declined');
|
await updateFriendRequest(user.requestId, 'declined');
|
||||||
updateRelationship(user.id, user._instanceOrigin, 'none');
|
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to decline request');
|
setError(err instanceof Error ? err.message : 'Failed to decline request');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -658,7 +716,7 @@ function UserDiscoverCard({
|
|||||||
? (useInstanceStore.getState().instances.find(i => i.origin === origin)?.api ?? api)
|
? (useInstanceStore.getState().instances.find(i => i.origin === origin)?.api ?? api)
|
||||||
: api;
|
: api;
|
||||||
await client.social.cancelRequest(user.requestId);
|
await client.social.cancelRequest(user.requestId);
|
||||||
updateRelationship(user.id, user._instanceOrigin, 'none');
|
onRelationshipChange(user.id, user._instanceOrigin, 'none');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Failed to cancel request');
|
setError(err instanceof Error ? err.message : 'Failed to cancel request');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class InstanceDisconnectedError extends Error {
|
|||||||
|
|
||||||
export type TaggedFriend = Friend & { _instanceOrigin: string };
|
export type TaggedFriend = Friend & { _instanceOrigin: string };
|
||||||
export type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string };
|
export type TaggedFriendRequest = FriendRequest & { _instanceOrigin: string };
|
||||||
|
export type TaggedUser = User & { _instanceOrigin: string };
|
||||||
|
|
||||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ interface SocialState {
|
|||||||
updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise<void>;
|
updateFriendRequest: (id: string, status: 'accepted' | 'declined') => Promise<void>;
|
||||||
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<TaggedUser[]>;
|
||||||
addIncomingRequest: (request: FriendRequest, origin: string) => void;
|
addIncomingRequest: (request: FriendRequest, origin: string) => void;
|
||||||
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void;
|
addFriendFromAccepted: (friend: Friend, requestId: string, origin: string) => void;
|
||||||
updateFriendPresence: (userId: string, status: string) => void;
|
updateFriendPresence: (userId: string, status: string) => void;
|
||||||
@@ -253,7 +254,7 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
|
|
||||||
const results = await Promise.allSettled(searches.map(s => s.promise));
|
const results = await Promise.allSettled(searches.map(s => s.promise));
|
||||||
|
|
||||||
const allUsers: User[] = [];
|
const allUsers: TaggedUser[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
|
|
||||||
results.forEach((result, i) => {
|
results.forEach((result, i) => {
|
||||||
@@ -264,7 +265,7 @@ export const useSocialStore = create<SocialState>((set, get) => ({
|
|||||||
if (seen.has(dedupeKey)) continue;
|
if (seen.has(dedupeKey)) continue;
|
||||||
seen.add(dedupeKey);
|
seen.add(dedupeKey);
|
||||||
if (origin) normalizeUserAssets(user, origin);
|
if (origin) normalizeUserAssets(user, origin);
|
||||||
allUsers.push(user);
|
allUsers.push({ ...user, _instanceOrigin: origin });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user