feat(web): user-facing pending peering subscriptions and outcome notifications

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).
This commit is contained in:
Jannis Braun
2026-04-26 22:42:53 +02:00
parent eddf2254cc
commit cebbd5c859
4 changed files with 479 additions and 4 deletions
@@ -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<void>;
refetchPeeringNotifications: () => Promise<void>;
cancelPeeringSubscription: (id: string) => Promise<void>;
markPeeringNotificationRead: (id: string) => Promise<void>;
markAllPeeringNotificationsRead: () => Promise<void>;
setPendingFriendAddPrefill: (value: string | null) => void;
consumePendingFriendAddPrefill: () => string | null;
}
export const useFederationStore = create<FederationState>((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;
},
}));