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:
Jannis Braun
2026-04-25 22:33:26 +02:00
parent 9d3f75b33c
commit 7309f44de5
2 changed files with 54 additions and 78 deletions
@@ -1,9 +1,9 @@
import React, { useEffect, useState, useCallback, useMemo } from 'react';
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 { ConnectInstanceModal } from '../modals/ConnectInstanceModal';
import { useDiscoverStore, type TaggedDiscoverUser } from '../../stores/discoverStore';
import { mapServerErrorToMessage } from '../../utils/friendErrors';
import { useSpaceStore } from '../../stores/spaceStore';
import { useInstanceStore } from '../../stores/instanceStore';
import { useUIStore } from '../../stores/uiStore';
@@ -398,11 +398,6 @@ function AddFriendTab({
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 discover on mount
useEffect(() => {
@@ -481,34 +476,21 @@ function AddFriendTab({
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');
const errorBody = (err as { body?: unknown })?.body;
let code: string | undefined;
let message: string | undefined;
if (errorBody && typeof errorBody === 'object') {
code = (errorBody as { error?: string }).error;
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 {
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(() => {}, []);
@@ -618,15 +600,6 @@ function AddFriendTab({
)}
</div>
{connectModal && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={connectModal.username}
isReconnect={connectModal.isReconnect}
onConnected={handleConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div>
);
}
@@ -647,12 +620,6 @@ function UserDiscoverCard({
const openModal = useUIStore((s) => s.openModal);
const [actionLoading, setActionLoading] = useState(false);
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 displayName = user.displayName ?? baseName;
@@ -676,32 +643,15 @@ function UserDiscoverCard({
const requestId = await sendFriendRequest(username);
onRelationshipChange(user.id, user._instanceOrigin, 'outbound_pending', requestId);
} catch (err) {
if (err instanceof InstanceNotConnectedError) {
setConnectModal({ domain: err.domain, isReconnect: false, username });
} else if (err instanceof InstanceDisconnectedError) {
setConnectModal({ domain: err.domain, isReconnect: true, username });
} else {
setError(err instanceof Error ? err.message : 'Failed to send request');
const errorBody = (err as { body?: unknown })?.body;
let code: string | undefined;
let message: string | undefined;
if (errorBody && typeof errorBody === 'object') {
code = (errorBody as { error?: string }).error;
message = (errorBody as { message?: string }).message;
}
} finally {
setActionLoading(false);
}
};
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');
const fallback = message ?? (err instanceof Error ? err.message : 'Failed to send request');
setError(mapServerErrorToMessage(code, fallback, username));
} finally {
setActionLoading(false);
}
@@ -885,15 +835,6 @@ function UserDiscoverCard({
</button>
)}
</div>
{connectModal && (
<ConnectInstanceModal
domain={connectModal.domain}
targetDisplayName={user.displayName ?? baseName}
isReconnect={connectModal.isReconnect}
onConnected={handleDiscoverConnected}
onCancel={() => setConnectModal(null)}
/>
)}
</div>
);
}
+35
View File
@@ -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.';
}
}