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>
140 lines
5.3 KiB
Vue
140 lines
5.3 KiB
Vue
<template>
|
|
<div v-if="visibleRequests.length > 0" class="glass-card p-6 mb-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-base font-semibold text-white">Pending Peer Requests</h2>
|
|
<p class="text-xs text-white/60">
|
|
Inbound requests await your approval. Outbound requests show what you've sent.
|
|
Approved peers are added as <span class="text-orange-300">Observer</span> — promote
|
|
to Trusted manually if you want them to receive state syncs.
|
|
</p>
|
|
</div>
|
|
<button
|
|
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/80 hover:text-white disabled:opacity-50"
|
|
:disabled="polling"
|
|
@click="$emit('poll')"
|
|
>
|
|
{{ polling ? 'Polling…' : 'Poll Now' }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="space-y-3">
|
|
<div
|
|
v-for="req in visibleRequests"
|
|
:key="req.id"
|
|
class="p-3 bg-white/5 rounded-lg border border-white/10"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0 flex-1">
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<span
|
|
class="inline-block px-2 py-0.5 text-[10px] uppercase tracking-wide rounded"
|
|
:class="badgeClass(req)"
|
|
>{{ badgeLabel(req) }}</span>
|
|
<span class="text-sm text-white font-medium truncate">
|
|
{{ req.from_name || shortNpub(req.from_nostr_npub) }}
|
|
</span>
|
|
</div>
|
|
<div class="text-[11px] text-white/50 font-mono truncate">{{ req.from_nostr_npub }}</div>
|
|
<div v-if="req.from_did" class="text-[11px] text-white/40 font-mono truncate">{{ req.from_did }}</div>
|
|
<p v-if="req.message" class="mt-2 text-xs text-white/70 italic">"{{ req.message }}"</p>
|
|
<p class="mt-1 text-[10px] text-white/40">{{ relative(req.received_at) }}</p>
|
|
</div>
|
|
<div v-if="req.state === 'pending' && !req.outbound" class="flex flex-col gap-2 shrink-0">
|
|
<button
|
|
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-green-300 hover:text-green-200 disabled:opacity-50"
|
|
:disabled="busyId === req.id"
|
|
@click="$emit('approve', req.id)"
|
|
>
|
|
{{ busyId === req.id ? '…' : 'Approve' }}
|
|
</button>
|
|
<button
|
|
class="px-3 py-1 glass-button glass-button-sm rounded text-xs text-red-300 hover:text-red-200 disabled:opacity-50"
|
|
:disabled="busyId === req.id"
|
|
@click="$emit('reject', req.id)"
|
|
>
|
|
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>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed } from 'vue'
|
|
import type { PendingPeerRequest } from '@/api/rpc-client'
|
|
|
|
const props = defineProps<{
|
|
requests: PendingPeerRequest[]
|
|
polling: boolean
|
|
busyId: string | null
|
|
}>()
|
|
|
|
defineEmits<{
|
|
poll: []
|
|
approve: [id: string]
|
|
reject: [id: string]
|
|
cancel: [id: string]
|
|
}>()
|
|
|
|
// Hide already-handled rows older than 24h to keep the panel from growing
|
|
// indefinitely. The backend keeps the full audit trail in the file; the UI
|
|
// only shows what's actionable or recent.
|
|
const visibleRequests = computed(() => {
|
|
const cutoff = Date.now() - 24 * 60 * 60 * 1000
|
|
return props.requests.filter((r) => {
|
|
if (r.state === 'pending' || r.state === 'sent') return true
|
|
const ts = new Date(r.received_at).getTime()
|
|
return Number.isFinite(ts) && ts >= cutoff
|
|
})
|
|
})
|
|
|
|
function badgeClass(r: PendingPeerRequest): string {
|
|
if (r.state === 'pending') return 'bg-yellow-500/20 text-yellow-300'
|
|
if (r.state === 'sent') return 'bg-blue-500/20 text-blue-300'
|
|
if (r.state === 'approved') return 'bg-green-500/20 text-green-300'
|
|
if (r.state === 'rejected') return 'bg-red-500/20 text-red-300'
|
|
return 'bg-white/10 text-white/50'
|
|
}
|
|
|
|
function badgeLabel(r: PendingPeerRequest): string {
|
|
if (r.outbound && r.state === 'sent') return 'Sent'
|
|
if (r.outbound && r.state === 'approved') return 'They approved'
|
|
if (r.outbound && r.state === 'rejected') return 'They rejected'
|
|
if (r.outbound) return r.state
|
|
if (r.state === 'pending') return 'Inbound'
|
|
return r.state
|
|
}
|
|
|
|
function shortNpub(npub: string): string {
|
|
if (!npub || npub.length < 16) return npub
|
|
return `${npub.slice(0, 12)}…${npub.slice(-6)}`
|
|
}
|
|
|
|
function relative(iso: string): string {
|
|
const ts = new Date(iso).getTime()
|
|
if (!Number.isFinite(ts)) return iso
|
|
const diff = Date.now() - ts
|
|
const sec = Math.floor(diff / 1000)
|
|
if (sec < 60) return `${sec}s ago`
|
|
const min = Math.floor(sec / 60)
|
|
if (min < 60) return `${min}m ago`
|
|
const hr = Math.floor(min / 60)
|
|
if (hr < 24) return `${hr}h ago`
|
|
const day = Math.floor(hr / 24)
|
|
return `${day}d ago`
|
|
}
|
|
</script>
|