From cebbd5c859a9faeca510a5997dae72406ca1e1c0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 22:42:53 +0200 Subject: [PATCH] feat(web): user-facing pending peering subscriptions and outcome notifications MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two new inline sections in the user-facing federation/connections settings panel: 'Recent peering outcomes' (terminal-state notifications with Retry-for-approved + Dismiss) and 'Pending peering approvals' (active subscriber rows the user is waiting on, with Cancel). New WS handlers for peering_subscription_changed and peering_notification_received refresh the lists in real-time and surface a transient toast for online users. Retry deep-link for friend_add prefills the friend-add input with the original target handle (other reasons get Dismiss only — the gate doesn't wire those paths yet). --- .../web/src/components/chat/FriendsPage.tsx | 20 +- .../components/modals/ConnectedInstances.tsx | 339 +++++++++++++++++- packages/web/src/hooks/useWebSocket.ts | 28 ++ packages/web/src/stores/federationStore.ts | 96 +++++ 4 files changed, 479 insertions(+), 4 deletions(-) create mode 100644 packages/web/src/stores/federationStore.ts diff --git a/packages/web/src/components/chat/FriendsPage.tsx b/packages/web/src/components/chat/FriendsPage.tsx index 39df158b..14cfc717 100644 --- a/packages/web/src/components/chat/FriendsPage.tsx +++ b/packages/web/src/components/chat/FriendsPage.tsx @@ -7,6 +7,7 @@ import { mapServerErrorToMessage } from '../../utils/friendErrors'; import { useSpaceStore } from '../../stores/spaceStore'; import { useInstanceStore } from '../../stores/instanceStore'; import { useUIStore } from '../../stores/uiStore'; +import { useFederationStore } from '../../stores/federationStore'; import { Avatar } from '../ui/Avatar'; import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { LoadingSpinner } from '../ui/LoadingSpinner'; @@ -34,6 +35,17 @@ export function FriendsPage({ mobile }: FriendsPageProps) { const navigate = useNavigate(); const addDmChannel = useSpaceStore((s) => s.addDmChannel); + // If the user clicked "Retry your friend request" in the Connections panel + // and we just navigated here, the federation store carries the original + // target. Switching to the Add tab makes the AddFriendTab mount, which then + // consumes the prefill into its query input. We only check on mount — the + // store value is one-shot (cleared by AddFriendTab on consume). + useEffect(() => { + if (useFederationStore.getState().pendingFriendAddPrefill) { + setActiveTab('add'); + } + }, []); + const { friends, requests, @@ -394,7 +406,13 @@ function AddFriendTab({ const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers); const updateRelationship = useDiscoverStore((s) => s.updateRelationship); - const [query, setQuery] = useState(''); + const [query, setQuery] = useState(() => { + // Consume the federation store's pending friend-add prefill at mount so + // a Retry click in the Connections panel lands here with the original + // target already in the input. The consume call clears the store value + // so subsequent mounts (e.g. tab switching) start empty. + return useFederationStore.getState().consumePendingFriendAddPrefill() ?? ''; + }); const [rawSearchResults, setRawSearchResults] = useState([]); const [searchLoading, setSearchLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false); diff --git a/packages/web/src/components/modals/ConnectedInstances.tsx b/packages/web/src/components/modals/ConnectedInstances.tsx index 103dd92d..4d60c178 100644 --- a/packages/web/src/components/modals/ConnectedInstances.tsx +++ b/packages/web/src/components/modals/ConnectedInstances.tsx @@ -1,9 +1,17 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import ReactDOM from 'react-dom'; -import type { InstanceInfoResponse, FederationRegistryEntry } from '@backspace/shared'; +import { useNavigate } from 'react-router-dom'; +import type { + InstanceInfoResponse, + FederationRegistryEntry, + PeeringSubscription, + PeeringNotification, + PeeringTriggerReason, +} from '@backspace/shared'; import { useInstanceStore, DifferentPasswordError, isSelfOrigin } from '../../stores/instanceStore'; import { useAuthStore } from '../../stores/authStore'; import { useUIStore } from '../../stores/uiStore'; +import { useFederationStore } from '../../stores/federationStore'; import { isElectron } from '../../platform/platform'; import { ConfirmDialog } from '../ui/ConfirmDialog'; @@ -967,6 +975,311 @@ function sortEntries(entries: FederationRegistryEntry[], sortBy: SortBy): Federa }); } +// ─── Outbound peering gate helpers ────────────────────────────────────────── + +function actionLabel(reason: PeeringTriggerReason): string { + switch (reason) { + case 'friend_add': return 'friend request'; + case 'space_join': return 'space join'; + case 'direct_message': return 'direct message'; + } +} + +function actionVerbPhrase(reason: PeeringTriggerReason, target: string): string { + switch (reason) { + case 'friend_add': return `Friend request to ${target}`; + case 'space_join': return `Join ${target}`; + case 'direct_message': return `Direct message to ${target}`; + } +} + +// ─── Pending peering subscriptions section ────────────────────────────────── + +function PendingSubscriptionRow({ subscription }: { subscription: PeeringSubscription }) { + const cancelPeeringSubscription = useFederationStore((s) => s.cancelPeeringSubscription); + const addToast = useUIStore((s) => s.addToast); + const [busy, setBusy] = useState(false); + + const host = safeHost(subscription.peerOrigin); + const peerLabel = subscription.peerInstanceName || host; + + const handleCancel = async () => { + setBusy(true); + try { + await cancelPeeringSubscription(subscription.id); + addToast('Peering request cancelled', 'success', 3000); + } catch (err) { + addToast( + `Failed to cancel: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'warning', + 5000, + ); + setBusy(false); + } + }; + + return ( +
+
+
+ {actionVerbPhrase(subscription.triggerReason, subscription.triggerTarget)} +
+
+ on {peerLabel} + {subscription.peerInstanceName && ( + ({host}) + )} +
+
+ +
+ ); +} + +function PendingPeeringSubscriptionsSection() { + const subscriptions = useFederationStore((s) => s.peeringSubscriptions); + + if (subscriptions.length === 0) return null; + + return ( +
+
+ Pending Peering Approvals +
+

+ Your admin must approve before these requests can proceed. +

+
+ {subscriptions.map((s) => ( + + ))} +
+
+ ); +} + +// ─── Recent peering outcomes section ──────────────────────────────────────── + +function notificationAccentClasses(kind: PeeringNotification['kind']): { + surface: string; + iconBg: string; + iconColor: string; +} { + switch (kind) { + case 'approved': + return { + surface: 'bg-status-online/[0.06] border border-status-online/15', + iconBg: 'bg-status-online/15', + iconColor: 'text-status-online', + }; + case 'denied': + return { + surface: 'bg-accent-rose/[0.06] border border-accent-rose/15', + iconBg: 'bg-accent-rose/15', + iconColor: 'text-txt-danger', + }; + case 'expired': + return { + surface: 'bg-accent-amber/[0.06] border border-accent-amber/15', + iconBg: 'bg-accent-amber/15', + iconColor: 'text-accent-amber', + }; + } +} + +function NotificationIcon({ kind, className }: { kind: PeeringNotification['kind']; className: string }) { + // approved: check, denied: cross, expired: clock + if (kind === 'approved') { + return ( + + + + ); + } + if (kind === 'denied') { + return ( + + + + ); + } + return ( + + + + ); +} + +function PeeringNotificationCard({ + notification, + onRetry, +}: { + notification: PeeringNotification; + onRetry: (notification: PeeringNotification) => void; +}) { + const markPeeringNotificationRead = useFederationStore((s) => s.markPeeringNotificationRead); + const addToast = useUIStore((s) => s.addToast); + const [busy, setBusy] = useState(false); + + const host = safeHost(notification.peerOrigin); + const accent = notificationAccentClasses(notification.kind); + + const handleDismiss = async () => { + setBusy(true); + try { + await markPeeringNotificationRead(notification.id); + } catch (err) { + addToast( + `Failed to dismiss: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'warning', + 5000, + ); + setBusy(false); + } + }; + + // Retry is only meaningful on approved notifications, and the gate currently + // only wires friend_add. space_join and direct_message reach the gate via + // backend paths that aren't user-initiated end-to-end yet, so we hide Retry + // for those rather than promise an action we cannot deliver. + const showRetry = + notification.kind === 'approved' && notification.triggerReason === 'friend_add'; + + let primaryText: string; + if (notification.kind === 'approved') { + primaryText = `Your peering request to ${host} was approved by your admin.`; + } else if (notification.kind === 'denied') { + primaryText = `Your peering request to ${host} was denied by your admin.`; + } else { + primaryText = `Your peering request to ${host} expired without admin action.`; + } + + const contextText = + notification.kind === 'approved' && notification.triggerReason !== 'friend_add' + ? `Original action: ${actionVerbPhrase(notification.triggerReason, notification.triggerTarget)}.` + : `Original action: ${actionVerbPhrase(notification.triggerReason, notification.triggerTarget)}`; + + return ( +
+
+
+ +
+
+
{primaryText}
+
{contextText}
+
+ {showRetry && ( + + )} + +
+
+
+
+ ); +} + +function RecentPeeringOutcomesSection() { + const notifications = useFederationStore((s) => s.peeringNotifications); + const markAllPeeringNotificationsRead = useFederationStore((s) => s.markAllPeeringNotificationsRead); + const setPendingFriendAddPrefill = useFederationStore((s) => s.setPendingFriendAddPrefill); + const closeModal = useUIStore((s) => s.closeModal); + const setShowDms = useUIStore((s) => s.setShowDms); + const setMobileTab = useUIStore((s) => s.setMobileTab); + const isMobile = useUIStore((s) => s.isMobile); + const addToast = useUIStore((s) => s.addToast); + const navigate = useNavigate(); + const [bulkBusy, setBulkBusy] = useState(false); + + if (notifications.length === 0) return null; + + const handleRetry = (notification: PeeringNotification) => { + if (notification.triggerReason !== 'friend_add') { + // Defensive — Retry button is only rendered for friend_add. Bail + // silently if a future change widens this without updating the handler. + return; + } + // Set the prefill side-channel before navigating so AddFriendTab finds + // it on its initial render. + setPendingFriendAddPrefill(notification.triggerTarget); + // Mark this notification read in the background — the user has acted on + // it. Use the per-id endpoint so other unread notifications stay visible. + void useFederationStore.getState().markPeeringNotificationRead(notification.id); + // Close the settings modal that hosts this panel. + closeModal(); + if (isMobile) { + // Mobile: jump to the DMs/Friends tab so MobileShell renders FriendsPage. + setMobileTab('dms'); + } else { + // Desktop: route to /channels/@me — AppLayout's effect calls + // setShowDms(true) for the @me path, and MainContent renders FriendsPage + // when no DM channel is selected. + setShowDms(true); + navigate('/channels/@me'); + } + }; + + const handleDismissAll = async () => { + setBulkBusy(true); + try { + await markAllPeeringNotificationsRead(); + } catch (err) { + addToast( + `Failed to dismiss all: ${err instanceof Error ? err.message : 'Unknown error'}`, + 'warning', + 5000, + ); + setBulkBusy(false); + } + }; + + return ( +
+
+
+ Recent Peering Outcomes +
+ {notifications.length > 1 && ( + + )} +
+
+ {notifications.map((n) => ( + + ))} +
+
+ ); +} + // ─── Main component ────────────────────────────────────────────────────────── export function ConnectedInstances() { @@ -974,6 +1287,17 @@ export function ConnectedInstances() { const registry = useInstanceStore((s) => s.registry); const user = useAuthStore((s) => s.user); + const refetchPeeringSubscriptions = useFederationStore((s) => s.refetchPeeringSubscriptions); + const refetchPeeringNotifications = useFederationStore((s) => s.refetchPeeringNotifications); + + // Hydrate the outbound-peering-gate user surfaces on mount. WS events + // (peering_subscription_changed / peering_notification_received) will keep + // them fresh while the panel stays mounted. + useEffect(() => { + void refetchPeeringSubscriptions(); + void refetchPeeringNotifications(); + }, [refetchPeeringSubscriptions, refetchPeeringNotifications]); + const [showAddForm, setShowAddForm] = useState(false); const [filter, setFilter] = useState('all'); const [sortBy, setSortBy] = useState('dateAdded'); @@ -1025,7 +1349,15 @@ export function ConnectedInstances() { : null; return ( -
+
+ {/* Terminal-state outcomes first — newly resolved requests warrant the + user's attention (especially approvals they can now retry). */} + + + {/* Active waiting state. */} + + +
Connected Instances
@@ -1109,6 +1441,7 @@ export function ConnectedInstances() { )}
+
); } diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 760f2279..f86a1131 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -14,6 +14,7 @@ import { getActiveRoom } from './useLiveKit'; import { useUIStore } from '../stores/uiStore'; import { useActivityStore } from '../stores/activityStore'; import { useDiscoverStore } from '../stores/discoverStore'; +import { useFederationStore } from '../stores/federationStore'; // ─── Rejected peer origins (for unreachable member indicators) ─────────────── const rejectedPeerOrigins = new Set(); @@ -781,6 +782,33 @@ function handleEvent(origin: string, event: ServerEvent): void { break; } + case 'peering_subscription_changed': { + // The user's pending peering-subscription set changed (admin approved/ + // denied/expired the parent request, or the user cancelled a row from + // another tab). Refetch — the server is the source of truth. + void useFederationStore.getState().refetchPeeringSubscriptions(); + break; + } + + case 'peering_notification_received': { + // Terminal-state outcome arrived for one of the user's outbound peering + // requests. Refetch the notifications list and surface a transient + // toast — the inline list in the Connections panel is the persistent + // surface; the toast is opportunistic for online users. + void useFederationStore.getState().refetchPeeringNotifications(); + const message = + event.kind === 'approved' + ? 'Your peering request was approved' + : event.kind === 'denied' + ? 'Your peering request was denied' + : 'Your peering request expired'; + // uiStore exposes 'info' | 'warning' | 'success' — use 'success' for + // approved, 'warning' for denied/expired (no error severity exists). + const severity: 'success' | 'warning' = event.kind === 'approved' ? 'success' : 'warning'; + useUIStore.getState().addToast(message, severity, 4500); + break; + } + case 'dm_message_deleted': if (!isHome && !activePeerOrigins.has(origin)) break; removeMessage(event.messageId, event.dmChannelId); diff --git a/packages/web/src/stores/federationStore.ts b/packages/web/src/stores/federationStore.ts new file mode 100644 index 00000000..31efb581 --- /dev/null +++ b/packages/web/src/stores/federationStore.ts @@ -0,0 +1,96 @@ +import { create } from 'zustand'; +import type { PeeringSubscription, PeeringNotification } from '@backspace/shared'; +import { api } from '../api/client'; + +/** + * Outbound peering gate user-facing state. + * + * Holds the current user's pending peering subscriptions (rows they own in + * `peer_approval_subscribers` joined to their parent peering request) and + * their unread terminal-state notifications. Both lists are scoped to the + * home instance API client — federation gating is a home-instance concern; + * remote instances do not surface their own outbound queues to this user. + * + * The retry deep-link side-channel (`pendingFriendAddPrefill`) carries the + * trigger target from a Retry click in the Connections panel into the + * `AddFriendTab` on the Friends page. The consuming component reads and + * clears the value on mount. + */ +interface FederationState { + peeringSubscriptions: PeeringSubscription[]; + peeringNotifications: PeeringNotification[]; + + /** + * Side-channel for the friend-add Retry deep-link. The Connections panel + * sets this to the original `triggerTarget` (e.g. `alice@orbit.tld`), + * navigates to the Friends page, and the AddFriendTab consumes + clears + * it on mount to prefill its query input. + */ + pendingFriendAddPrefill: string | null; + + refetchPeeringSubscriptions: () => Promise; + refetchPeeringNotifications: () => Promise; + cancelPeeringSubscription: (id: string) => Promise; + markPeeringNotificationRead: (id: string) => Promise; + markAllPeeringNotificationsRead: () => Promise; + + setPendingFriendAddPrefill: (value: string | null) => void; + consumePendingFriendAddPrefill: () => string | null; +} + +export const useFederationStore = create((set, get) => ({ + peeringSubscriptions: [], + peeringNotifications: [], + pendingFriendAddPrefill: null, + + refetchPeeringSubscriptions: async () => { + try { + const { subscriptions } = await api.federation.peeringSubscriptions(); + set({ peeringSubscriptions: subscriptions }); + } catch (err) { + console.error('Failed to load peering subscriptions:', err); + } + }, + + refetchPeeringNotifications: async () => { + try { + // unreadOnly=true — UI only ever shows unread terminal notifications. + const { notifications } = await api.federation.peeringNotifications(true); + set({ peeringNotifications: notifications }); + } catch (err) { + console.error('Failed to load peering notifications:', err); + } + }, + + cancelPeeringSubscription: async (id) => { + await api.federation.cancelPeeringSubscription(id); + // Optimistic local update — the WS `peering_subscription_changed` event + // will arrive shortly and reconcile, but we drop the row immediately so + // the UI feels responsive. + set((state) => ({ + peeringSubscriptions: state.peeringSubscriptions.filter((s) => s.id !== id), + })); + }, + + markPeeringNotificationRead: async (id) => { + await api.federation.markPeeringNotificationRead(id); + set((state) => ({ + peeringNotifications: state.peeringNotifications.filter((n) => n.id !== id), + })); + }, + + markAllPeeringNotificationsRead: async () => { + await api.federation.markAllPeeringNotificationsRead(); + set({ peeringNotifications: [] }); + }, + + setPendingFriendAddPrefill: (value) => set({ pendingFriendAddPrefill: value }), + + consumePendingFriendAddPrefill: () => { + const value = get().pendingFriendAddPrefill; + if (value !== null) { + set({ pendingFriendAddPrefill: null }); + } + return value; + }, +}));