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 { 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,6 +1349,14 @@ export function ConnectedInstances() {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
return (
|
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>
|
||||||
<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
|
||||||
@@ -1110,5 +1442,6 @@ export function ConnectedInstances() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
}));
|
||||||
Reference in New Issue
Block a user