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:
@@ -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<TaggedUser[]>([]);
|
||||
const [searchLoading, setSearchLoading] = 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 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 (
|
||||
<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 ──────────────────────────────────────────────────────────
|
||||
|
||||
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<StatusFilter>('all');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('dateAdded');
|
||||
@@ -1025,6 +1349,14 @@ export function ConnectedInstances() {
|
||||
: null;
|
||||
|
||||
return (
|
||||
<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">
|
||||
Connected Instances
|
||||
@@ -1110,5 +1442,6 @@ export function ConnectedInstances() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user