feat(federation): cancel button for outbound pending peer requests

Previously the Pending Peer Requests panel only had Approve/Reject for
inbound rows; outbound rows in the 'sent' state had no action and
would sit there until the target explicitly approved or rejected. Now
you can Cancel an outbound request — the local row is dropped and a
PeerCancel nostr DM is sent so the target's inbound row also
disappears.

Backend:
- HandshakeMessage::PeerCancel {reason: Option<String>} variant.
- nostr_handshake::send_peer_cancel() mirrors send_peer_reject.
- handshake.poll handler dispatches inbound PeerCancel: finds the
  matching inbound pending row (same from_nostr_pubkey, state=Pending)
  and deletes it. Reply shape gains `cancelled_inbound: [id]`.
- federation::pending::delete() — hard-remove (set_state only
  transitions; we don't want 'Cancelled' ghosts in the audit trail).
- federation.cancel-request RPC: outbound+Sent only, default
  notify=true (cancelling silently is a footgun), best-effort DM
  (relay failure doesn't block local deletion). Wired in dispatcher.

Frontend:
- PendingRequestsPanel.vue: Cancel button appears only on
  outbound+sent rows. Emits 'cancel' event with request id.
- Federation.vue: cancelPending(id) handler calls
  rpcClient.federationCancelRequest and reloads the list.
- rpcClient.federationCancelRequest(id, reason?, notify=true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-19 02:28:16 -04:00
co-authored by Claude Opus 4.7
parent a658e924e1
commit 95f52572fc
8 changed files with 179 additions and 0 deletions
+19
View File
@@ -742,6 +742,25 @@ class RPCClient {
})
}
/// Withdraw an outbound peer request we sent. Default notify=true so
/// the recipient's inbound row disappears from their UI (the main
/// UX reason to cancel — avoid leaving a stale handshake dangling).
async federationCancelRequest(
id: string,
reason?: string,
notify = true,
): Promise<{ cancelled: boolean; id: string; notified: boolean }> {
return this.call({
method: 'federation.cancel-request',
params: {
id,
...(reason ? { reason } : {}),
notify,
},
timeout: 30000,
})
}
async federationSyncState(): Promise<{
synced: number
failed: number
+17
View File
@@ -91,6 +91,7 @@
@poll="pollHandshake"
@approve="approvePending"
@reject="rejectPending"
@cancel="cancelPending"
/>
<NodeList
@@ -346,6 +347,22 @@ async function rejectPending(id: string) {
}
}
async function cancelPending(id: string) {
pendingBusyId.value = id
discoveryError.value = ''
try {
// Default notify=true from the rpc-client — the peer's inbound row
// disappears from their UI so they don't have to wonder about a
// stale handshake.
await rpcClient.federationCancelRequest(id)
await loadPendingRequests()
} catch (e: unknown) {
discoveryError.value = e instanceof Error ? e.message : 'Cancel failed'
} finally {
pendingBusyId.value = null
}
}
function isOnlineCheck(node: FederatedNode): boolean {
if (!node.last_seen) return false
const lastSeen = new Date(node.last_seen).getTime()
@@ -56,6 +56,16 @@
Reject
</button>
</div>
<div v-else-if="req.outbound && req.state === 'sent'" class="flex flex-col gap-2 shrink-0">
<button
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-white/70 hover:text-red-300 disabled:opacity-50"
:disabled="busyId === req.id"
:title="'Withdraw the request and notify the peer to drop their pending row'"
@click="$emit('cancel', req.id)"
>
{{ busyId === req.id ? '' : 'Cancel' }}
</button>
</div>
</div>
</div>
</div>
@@ -76,6 +86,7 @@ defineEmits<{
poll: []
approve: [id: string]
reject: [id: string]
cancel: [id: string]
}>()
// Hide already-handled rows older than 24h to keep the panel from growing