diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 8cb66112..ffe96fd2 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -390,7 +390,7 @@ export type ClientEvent = // Server → Client Events export type ServerEvent = - | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record; userActivities?: Record } + | { type: 'ready'; user: User; spaces: SpaceWithChannelsAndMembers[]; dmChannels: DmChannel[]; folders?: SpaceFolder[]; spaceLayout?: SpaceLayoutItem[] | null; layoutUpdatedAt?: number; voiceStates?: Record; voiceUserStates?: Record; readStates?: ReadState[]; activeCalls?: ActiveCallInfo[]; spaceVoiceStates?: Record; userActivities?: Record; rejectedPeerOrigins?: string[] } | { type: 'message_created'; message: MessageWithUser } | { type: 'message_updated'; message: MessageWithUser } | { type: 'message_deleted'; messageId: string; channelId: string } diff --git a/packages/web/src/api/client.ts b/packages/web/src/api/client.ts index 519a8876..94eece7c 100644 --- a/packages/web/src/api/client.ts +++ b/packages/web/src/api/client.ts @@ -224,6 +224,7 @@ export class BackspaceApiClient { readonly federation: { initiatePeering: (data: { remoteOrigin: string }) => Promise<{ peer: FederationPeer }>; + ensurePeered: (data: { remoteOrigin: string }) => Promise<{ peeringStatus: string; peerId?: string; error?: string }>; peers: () => Promise<{ peers: FederationPeer[] }>; revokePeer: (id: string) => Promise<{ success: boolean }>; rotatePeerSecret: (id: string) => Promise<{ success: boolean; gracePeriodMs: number }>; @@ -666,6 +667,10 @@ export class BackspaceApiClient { request<{ peer: FederationPeer }>( 'POST', '/federation/peer/initiate', data ), + ensurePeered: (data: { remoteOrigin: string }) => + request<{ peeringStatus: string; peerId?: string; error?: string }>( + 'POST', '/federation/peer/ensure', data + ), peers: () => request<{ peers: FederationPeer[] }>( 'GET', '/federation/peers' diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 44f4c906..99bc55c1 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -15,6 +15,13 @@ import { useUIStore } from '../stores/uiStore'; import { useActivityStore } from '../stores/activityStore'; import { useDiscoverStore } from '../stores/discoverStore'; +// ─── Rejected peer origins (for unreachable member indicators) ─────────────── +const rejectedPeerOrigins = new Set(); + +export function getRejectedPeerOrigins(): Set { + return rejectedPeerOrigins; +} + // ─── Connection state ───────────────────────────────────────────────────────── interface ConnectionState { @@ -364,6 +371,16 @@ function handleEvent(origin: string, event: ServerEvent): void { loadFriends(); 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; case 'message_created': @@ -638,6 +655,23 @@ function handleEvent(origin: string, event: ServerEvent): void { 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': removeMessage(event.messageId, event.dmChannelId); break; diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 799c4dee..38ad36b3 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -341,11 +341,25 @@ export const useInstanceStore = create((set, get) => ({ // Open WebSocket connection to the remote instance 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 { - 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) { - 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) @@ -977,7 +991,7 @@ export const useInstanceStore = create((set, get) => ({ connectInstance(origin, cachedEntry.token); // 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) { if (isNetworkError(err)) { // Instance unreachable (NAT hairpinning, DNS, server down) — token may still be valid