feat: client ensurePeered API, connection flow swap, WS event handlers for peer rejection

This commit is contained in:
Jannis Braun
2026-04-09 13:56:18 +02:00
parent 5f700c1a51
commit 4285e44d2d
4 changed files with 58 additions and 5 deletions
+1 -1
View File
@@ -390,7 +390,7 @@ export type ClientEvent =
// Server → Client Events // Server → Client Events
export type ServerEvent = export type ServerEvent =
| { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]> } | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record<string, string[]>; voiceUserStates?: Record<string, { isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }>; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record<string, { spaceMuted: boolean; spaceDeafened: boolean }>; userActivities?: Record<string, Activity[]>; rejectedPeerOrigins?: string[] }
| { type: 'message_created'; message: MessageWithUser } | { type: 'message_created'; message: MessageWithUser }
| { type: 'message_updated'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser }
| { type: 'message_deleted'; messageId: string; channelId: string } | { type: 'message_deleted'; messageId: string; channelId: string }
+5
View File
@@ -224,6 +224,7 @@ export class BackspaceApiClient {
readonly federation: { readonly federation: {
initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer }>; initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer }>;
ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>;
peers: () => Promise<{ peers: FederationPeer[] }>; peers: () => Promise<{ peers: FederationPeer[] }>;
revokePeer: (id: string) => Promise<{ success: boolean }>; revokePeer: (id: string) => Promise<{ success: boolean }>;
rotatePeerSecret: (id: string) => Promise<{ success: boolean; gracePeriodMs: number }>; rotatePeerSecret: (id: string) => Promise<{ success: boolean; gracePeriodMs: number }>;
@@ -666,6 +667,10 @@ export class BackspaceApiClient {
request<{ peer: FederationPeer }>( request<{ peer: FederationPeer }>(
'POST', '/federation/peer/initiate', data 'POST', '/federation/peer/initiate', data
), ),
ensurePeered: (data: { remoteOrigin: string }) =>
request<{ peeringStatus: string; peerId?: string; error?: string }>(
'POST', '/federation/peer/ensure', data
),
peers: () => peers: () =>
request<{ peers: FederationPeer[] }>( request<{ peers: FederationPeer[] }>(
'GET', '/federation/peers' 'GET', '/federation/peers'
+34
View File
@@ -15,6 +15,13 @@ import { useUIStore } from '../stores/uiStore';
import { useActivityStore } from '../stores/activityStore'; import { useActivityStore } from '../stores/activityStore';
import { useDiscoverStore } from '../stores/discoverStore'; import { useDiscoverStore } from '../stores/discoverStore';
// ─── Rejected peer origins (for unreachable member indicators) ───────────────
const rejectedPeerOrigins = new Set<string>();
export function getRejectedPeerOrigins(): Set<string> {
return rejectedPeerOrigins;
}
// ─── Connection state ───────────────────────────────────────────────────────── // ─── Connection state ─────────────────────────────────────────────────────────
interface ConnectionState { interface ConnectionState {
@@ -364,6 +371,16 @@ function handleEvent(origin: string, event: ServerEvent): void {
loadFriends(); loadFriends();
loadRequests(); loadRequests();
} }
// Populate rejected peer origins for DM member indicators
if (isHome) {
rejectedPeerOrigins.clear();
if (Array.isArray(event.rejectedPeerOrigins)) {
for (const o of event.rejectedPeerOrigins) {
rejectedPeerOrigins.add(o);
}
}
}
break; break;
case 'message_created': case 'message_created':
@@ -638,6 +655,23 @@ function handleEvent(origin: string, event: ServerEvent): void {
break; break;
} }
case 'federation_peer_rejected': {
const { addToast } = useUIStore.getState();
rejectedPeerOrigins.add(event.peerOrigin);
const label = event.peerLabel || event.peerOrigin;
addToast(
`Cannot relay messages to ${label} — their server requires manual peering approval. Contact their admin to set up peering.`,
'warning',
10000,
);
break;
}
case 'federation_peer_active': {
rejectedPeerOrigins.delete(event.peerOrigin);
break;
}
case 'dm_message_deleted': case 'dm_message_deleted':
removeMessage(event.messageId, event.dmChannelId); removeMessage(event.messageId, event.dmChannelId);
break; break;
+18 -4
View File
@@ -341,11 +341,25 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
// Open WebSocket connection to the remote instance // Open WebSocket connection to the remote instance
connectInstance(origin, response.token); connectInstance(origin, response.token);
// Initiate server-to-server peering for DM relay (non-fatal) // Ensure server-to-server peering for DM relay (non-fatal)
try { try {
await api.federation.initiatePeering({ remoteOrigin: origin }); const peerResult = await api.federation.ensurePeered({ remoteOrigin: origin });
if (peerResult.peeringStatus === 'rejected') {
const { addToast } = useUIStore.getState();
addToast(
`Cross-instance messaging unavailable — ${instance.label} requires manual peering approval`,
'warning',
10000,
);
} else if (peerResult.peeringStatus === 'pending') {
const { addToast } = useUIStore.getState();
addToast(
`Peering with ${instance.label} in progress — cross-instance messaging will be available shortly`,
'info',
);
}
} catch (err) { } catch (err) {
console.warn('[federation] Peering initiation failed (non-fatal):', err); console.warn('[federation] Peering attempt failed (non-fatal):', err);
} }
// Sync instance list to all instances (fire-and-forget) // Sync instance list to all instances (fire-and-forget)
@@ -977,7 +991,7 @@ export const useInstanceStore = create<InstanceState>((set, get) => ({
connectInstance(origin, cachedEntry.token); connectInstance(origin, cachedEntry.token);
// Initiate server-to-server peering for DM relay (non-fatal, idempotent) // Initiate server-to-server peering for DM relay (non-fatal, idempotent)
api.federation.initiatePeering({ remoteOrigin: origin }).catch(() => {}); api.federation.ensurePeered({ remoteOrigin: origin }).catch(() => {});
} catch (err) { } catch (err) {
if (isNetworkError(err)) { if (isNetworkError(err)) {
// Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid // Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid