refactor(web): FriendsPage — toast on server errors, drop ConnectInstanceModal triggers
Removes try/catch on the deleted InstanceNotConnectedError/Disconnected classes (T17). Server now returns structured error codes; client maps them to human-readable toasts via the new mapServerErrorToMessage helper. The friend-add flow no longer triggers ConnectInstanceModal — the server handles all routing/peering/lookup. The modal itself stays for Connections settings and space-join flows.
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
import React, { useEffect, useState, useCallback, useMemo } 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, type TaggedUser, InstanceNotConnectedError, InstanceDisconnectedError } from '../../stores/socialStore';
|
import { useSocialStore, type TaggedFriend, type TaggedFriendRequest, type TaggedUser } from '../../stores/socialStore';
|
||||||
import { useAuthStore } from '../../stores/authStore';
|
import { useAuthStore } from '../../stores/authStore';
|
||||||
import { ConnectInstanceModal } from '../modals/ConnectInstanceModal';
|
|
||||||
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
|
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
|
||||||
|
import { mapServerErrorToMessage } from '../../utils/friendErrors';
|
||||||
import { useSpaceStore } from '../../stores/spaceStore';
|
import { useSpaceStore } from '../../stores/spaceStore';
|
||||||
import { useInstanceStore } from '../../stores/instanceStore';
|
import { useInstanceStore } from '../../stores/instanceStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
@@ -398,11 +398,6 @@ function AddFriendTab({
|
|||||||
const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]);
|
const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]);
|
||||||
const [searchLoading, setSearchLoading] = useState(false);
|
const [searchLoading, setSearchLoading] = useState(false);
|
||||||
const [directAddLoading, setDirectAddLoading] = useState(false);
|
const [directAddLoading, setDirectAddLoading] = useState(false);
|
||||||
const [connectModal, setConnectModal] = useState<{
|
|
||||||
domain: string;
|
|
||||||
isReconnect: boolean;
|
|
||||||
username: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
// Fetch discover on mount
|
// Fetch discover on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -481,34 +476,21 @@ function AddFriendTab({
|
|||||||
addToast('Friend request sent!', 'success');
|
addToast('Friend request sent!', 'success');
|
||||||
setQuery('');
|
setQuery('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InstanceNotConnectedError) {
|
const errorBody = (err as { body?: unknown })?.body;
|
||||||
setConnectModal({ domain: err.domain, isReconnect: false, username: query.trim() });
|
let code: string | undefined;
|
||||||
} else if (err instanceof InstanceDisconnectedError) {
|
let message: string | undefined;
|
||||||
setConnectModal({ domain: err.domain, isReconnect: true, username: query.trim() });
|
if (errorBody && typeof errorBody === 'object') {
|
||||||
} else {
|
code = (errorBody as { error?: string }).error;
|
||||||
addToast((err as Error).message, 'warning');
|
message = (errorBody as { message?: string }).message;
|
||||||
}
|
}
|
||||||
|
// Fall back to the Error message if the server didn't send a structured body.
|
||||||
|
const fallback = message ?? (err instanceof Error ? err.message : undefined);
|
||||||
|
addToast(mapServerErrorToMessage(code, fallback, query.trim()), 'warning');
|
||||||
} finally {
|
} finally {
|
||||||
setDirectAddLoading(false);
|
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)
|
// No-op relationship change for search mode cards (useMemo re-derives from store)
|
||||||
const noopRelationshipChange = useCallback(() => {}, []);
|
const noopRelationshipChange = useCallback(() => {}, []);
|
||||||
|
|
||||||
@@ -618,15 +600,6 @@ function AddFriendTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{connectModal && (
|
|
||||||
<ConnectInstanceModal
|
|
||||||
domain={connectModal.domain}
|
|
||||||
targetDisplayName={connectModal.username}
|
|
||||||
isReconnect={connectModal.isReconnect}
|
|
||||||
onConnected={handleConnected}
|
|
||||||
onCancel={() => setConnectModal(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -647,12 +620,6 @@ function UserDiscoverCard({
|
|||||||
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('');
|
||||||
const addToast = useUIStore((s) => s.addToast);
|
|
||||||
const [connectModal, setConnectModal] = useState<{
|
|
||||||
domain: string;
|
|
||||||
isReconnect: boolean;
|
|
||||||
username: string;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username;
|
const baseName = user.username.includes('@') ? user.username.split('@')[0]! : user.username;
|
||||||
const displayName = user.displayName ?? baseName;
|
const displayName = user.displayName ?? baseName;
|
||||||
@@ -676,32 +643,15 @@ function UserDiscoverCard({
|
|||||||
const requestId = await sendFriendRequest(username);
|
const requestId = await sendFriendRequest(username);
|
||||||
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof InstanceNotConnectedError) {
|
const errorBody = (err as { body?: unknown })?.body;
|
||||||
setConnectModal({ domain: err.domain, isReconnect: false, username });
|
let code: string | undefined;
|
||||||
} else if (err instanceof InstanceDisconnectedError) {
|
let message: string | undefined;
|
||||||
setConnectModal({ domain: err.domain, isReconnect: true, username });
|
if (errorBody && typeof errorBody === 'object') {
|
||||||
} else {
|
code = (errorBody as { error?: string }).error;
|
||||||
setError(err instanceof Error ? err.message : 'Failed to send request');
|
message = (errorBody as { message?: string }).message;
|
||||||
}
|
}
|
||||||
} finally {
|
const fallback = message ?? (err instanceof Error ? err.message : 'Failed to send request');
|
||||||
setActionLoading(false);
|
setError(mapServerErrorToMessage(code, fallback, username));
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDiscoverConnected = async (result: 'new' | 'reconnect') => {
|
|
||||||
const username = connectModal?.username;
|
|
||||||
const domain = connectModal?.domain;
|
|
||||||
setConnectModal(null);
|
|
||||||
if (!username) return;
|
|
||||||
|
|
||||||
setActionLoading(true);
|
|
||||||
try {
|
|
||||||
const requestId = await sendFriendRequest(username);
|
|
||||||
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
|
|
||||||
const verb = result === 'reconnect' ? 'Reconnected to' : 'Connected to';
|
|
||||||
addToast(`${verb} ${domain} — friend request sent!`, 'success');
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to send request');
|
|
||||||
} finally {
|
} finally {
|
||||||
setActionLoading(false);
|
setActionLoading(false);
|
||||||
}
|
}
|
||||||
@@ -885,15 +835,6 @@ function UserDiscoverCard({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{connectModal && (
|
|
||||||
<ConnectInstanceModal
|
|
||||||
domain={connectModal.domain}
|
|
||||||
targetDisplayName={user.displayName ?? baseName}
|
|
||||||
isReconnect={connectModal.isReconnect}
|
|
||||||
onConnected={handleDiscoverConnected}
|
|
||||||
onCancel={() => setConnectModal(null)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Map a server error code (from POST /api/social/requests) to a human-readable
|
||||||
|
* toast message. The server emits these codes; the client renders them.
|
||||||
|
*
|
||||||
|
* Used by FriendsPage and UserProfileModal when the server returns an error
|
||||||
|
* from the friend-add flow.
|
||||||
|
*/
|
||||||
|
export function mapServerErrorToMessage(
|
||||||
|
code: string | undefined,
|
||||||
|
fallback: string | undefined,
|
||||||
|
handle: string,
|
||||||
|
): string {
|
||||||
|
switch (code) {
|
||||||
|
case 'username_required': return 'Enter a username.';
|
||||||
|
case 'cannot_friend_self': return "You can't friend yourself.";
|
||||||
|
case 'peer_rejected':
|
||||||
|
return `Instance has rejected federation. Contact your admin.`;
|
||||||
|
case 'user_not_found':
|
||||||
|
return `No user "${handle}" on the remote instance.`;
|
||||||
|
case 'already_friends': return "You're already friends with this user.";
|
||||||
|
case 'peer_pending_approval':
|
||||||
|
return "The remote instance's admin needs to approve federation. Try again later.";
|
||||||
|
case 'peer_pending':
|
||||||
|
return 'Connecting to the remote instance — try again in a moment.';
|
||||||
|
case 'incoming_request_exists':
|
||||||
|
return `${handle} has already sent you a request — open the Pending tab.`;
|
||||||
|
case 'lookup_rate_limited': return 'Too many lookups; try again in a minute.';
|
||||||
|
case 'peer_unreachable': return 'The remote instance is currently unreachable.';
|
||||||
|
case 'invalid_target_domain': return 'Invalid target domain.';
|
||||||
|
case 'not_authoritative_for_sender':
|
||||||
|
// Should not happen in normal client usage — internal protocol violation.
|
||||||
|
return 'Could not send friend request (authority error).';
|
||||||
|
default: return fallback ?? 'Could not send friend request.';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user