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
@@ -7,6 +7,7 @@ import { mapServerErrorToMessage } from '../../utils/friendErrors';
import { useSpaceStore } from '../../stores/spaceStore'; import { useSpaceStore } from '../../stores/spaceStore';
import { useInstanceStore } from '../../stores/instanceStore'; import { useInstanceStore } from '../../stores/instanceStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useFederationStore } from '../../stores/federationStore';
import { Avatar } from '../ui/Avatar'; import { Avatar } from '../ui/Avatar';
import { MemberListToggleButton } from '../layout/MemberListToggleButton'; import { MemberListToggleButton } from '../layout/MemberListToggleButton';
import { LoadingSpinner } from '../ui/LoadingSpinner'; import { LoadingSpinner } from '../ui/LoadingSpinner';
@@ -34,6 +35,17 @@ export function FriendsPage({ mobile }: FriendsPageProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const addDmChannel = useSpaceStore((s) => s.addDmChannel); 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 { const {
friends, friends,
requests, requests,
@@ -394,7 +406,13 @@ function AddFriendTab({
const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers); const fetchDiscoverUsers = useDiscoverStore((s) => s.fetchUsers);
const updateRelationship = useDiscoverStore((s) => s.updateRelationship); 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<TaggedUser[]>([]); const [rawSearchResults, setRawSearchResults] = useState<TaggedUser[]>([]);
const [searchLoading, setSearchLoading] = useState(false); const [searchLoading, setSearchLoading] = useState(false);
const [directAddLoading, setDirectAddLoading] = useState(false); const [directAddLoading, setDirectAddLoading] = useState(false);
@@ -1,9 +1,17 @@
import React, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import ReactDOM from 'react-dom'; 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 { useInstanceStore, DifferentPasswordError, isSelfOrigin } from '../../stores/instanceStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { useUIStore } from '../../stores/uiStore'; import { useUIStore } from '../../stores/uiStore';
import { useFederationStore } from '../../stores/federationStore';
import { isElectron } from '../../platform/platform'; import { isElectron } from '../../platform/platform';
import { ConfirmDialog } from '../ui/ConfirmDialog'; 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 (
<div className="bg-white/[0.02] rounded-md px-3 py-2.5 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm text-txt-primary truncate">
{actionVerbPhrase(subscription.triggerReason, subscription.triggerTarget)}
</div>
<div className="text-[11px] text-txt-tertiary truncate">
on <span className="text-txt-secondary">{peerLabel}</span>
{subscription.peerInstanceName && (
<span className="ml-1 text-txt-tertiary/70">({host})</span>
)}
</div>
</div>
<button
type="button"
onClick={handleCancel}
disabled={busy}
className="px-3 py-1.5 text-xs font-medium bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary rounded transition-colors shrink-0 disabled:opacity-50"
>
{busy ? 'Cancelling...' : 'Cancel'}
</button>
</div>
);
}
function PendingPeeringSubscriptionsSection() {
const subscriptions = useFederationStore((s) => s.peeringSubscriptions);
if (subscriptions.length === 0) return null;
return (
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Pending Peering Approvals
</div>
<p className="text-xs text-txt-tertiary mb-2">
Your admin must approve before these requests can proceed.
</p>
<div className="rounded-lg bg-white/[0.02] p-3 space-y-2">
{subscriptions.map((s) => (
<PendingSubscriptionRow key={s.id} subscription={s} />
))}
</div>
</div>
);
}
// ─── 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 (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
);
}
if (kind === 'denied') {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z" />
</svg>
);
}
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className={className}>
<path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z" />
</svg>
);
}
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 (
<div className={`rounded-md px-3 py-2.5 ${accent.surface}`}>
<div className="flex items-start gap-2.5">
<div className={`w-6 h-6 rounded-full flex items-center justify-center shrink-0 ${accent.iconBg}`}>
<NotificationIcon kind={notification.kind} className={accent.iconColor} />
</div>
<div className="min-w-0 flex-1">
<div className="text-sm text-txt-primary">{primaryText}</div>
<div className="text-[11px] text-txt-tertiary mt-0.5">{contextText}</div>
<div className="flex items-center gap-2 mt-2 flex-wrap">
{showRetry && (
<button
type="button"
onClick={() => onRetry(notification)}
className="px-3 py-1.5 text-xs font-medium bg-status-online/15 text-status-online hover:bg-status-online/25 rounded transition-colors"
>
Retry your {actionLabel(notification.triggerReason)}
</button>
)}
<button
type="button"
onClick={handleDismiss}
disabled={busy}
className="px-3 py-1.5 text-xs font-medium bg-white/[0.06] hover:bg-white/[0.1] text-txt-secondary rounded transition-colors disabled:opacity-50"
>
{busy ? 'Dismissing...' : 'Dismiss'}
</button>
</div>
</div>
</div>
</div>
);
}
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 (
<div>
<div className="flex items-center justify-between mb-1.5">
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider">
Recent Peering Outcomes
</div>
{notifications.length > 1 && (
<button
type="button"
onClick={handleDismissAll}
disabled={bulkBusy}
className="text-[11px] text-txt-tertiary hover:text-txt-secondary transition-colors disabled:opacity-50"
>
{bulkBusy ? 'Dismissing...' : 'Dismiss all'}
</button>
)}
</div>
<div className="space-y-2">
{notifications.map((n) => (
<PeeringNotificationCard key={n.id} notification={n} onRetry={handleRetry} />
))}
</div>
</div>
);
}
// ─── Main component ────────────────────────────────────────────────────────── // ─── Main component ──────────────────────────────────────────────────────────
export function ConnectedInstances() { export function ConnectedInstances() {
@@ -974,6 +1287,17 @@ export function ConnectedInstances() {
const registry = useInstanceStore((s) => s.registry); const registry = useInstanceStore((s) => s.registry);
const user = useAuthStore((s) => s.user); 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 [showAddForm, setShowAddForm] = useState(false);
const [filter, setFilter] = useState<StatusFilter>('all'); const [filter, setFilter] = useState<StatusFilter>('all');
const [sortBy, setSortBy] = useState<SortBy>('dateAdded'); const [sortBy, setSortBy] = useState<SortBy>('dateAdded');
@@ -1025,7 +1349,15 @@ export function ConnectedInstances() {
: null; : null;
return ( return (
<div> <div className="space-y-5">
{/* Terminal-state outcomes first — newly resolved requests warrant the
user's attention (especially approvals they can now retry). */}
<RecentPeeringOutcomesSection />
{/* Active waiting state. */}
<PendingPeeringSubscriptionsSection />
<div>
<div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5"> <div className="text-[11px] font-semibold text-txt-tertiary uppercase tracking-wider mb-1.5">
Connected Instances Connected Instances
</div> </div>
@@ -1109,6 +1441,7 @@ export function ConnectedInstances() {
</button> </button>
)} )}
</div> </div>
</div>
</div> </div>
); );
} }
+28
View File
@@ -14,6 +14,7 @@ import { getActiveRoom } from './useLiveKit';
import { useUIStore } from '../stores/uiStore'; 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';
import { useFederationStore } from '../stores/federationStore';
// ─── Rejected peer origins (for unreachable member indicators) ─────────────── // ─── Rejected peer origins (for unreachable member indicators) ───────────────
const rejectedPeerOrigins = new Set<string>(); const rejectedPeerOrigins = new Set<string>();
@@ -781,6 +782,33 @@ function handleEvent(origin: string, event: ServerEvent): void {
break; 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': case 'dm_message_deleted':
if (!isHome && !activePeerOrigins.has(origin)) break; if (!isHome && !activePeerOrigins.has(origin)) break;
removeMessage(event.messageId, event.dmChannelId); removeMessage(event.messageId, event.dmChannelId);
@@ -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;
},
}));