feat(federation): inbound peer requests live on the map — blinking nodes + accept/reject
- Inbound pending requests render as blinking yellow point-globes on an orbit outside the peers, dotted-linked to self, with their own dashed guide ring and a Request legend chip. - Tapping one opens a black-glass popover (name, request message, Accept/Reject) centred over the scene; tap-away or ✕ dismisses. - Reject: the node swells and pops out of existence, taking its link. - Accept: green burst ring, the point cloud/link morph to the trusted colour and the globe glides inward onto the peer orbit, then the data refresh replaces it with the real peer node. - Wired to the same approve/reject RPCs as the pending panel; new motionTokens.color.pending (#facc15) token. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
e0aebf50f8
commit
6ba24b0f4a
@@ -12,6 +12,7 @@
|
||||
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.trusted }"></span>Trusted</span>
|
||||
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.observer }"></span>Observer</span>
|
||||
<span class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.untrusted }"></span>Untrusted</span>
|
||||
<span v-if="requests?.length" class="node-map-legend-item"><span class="node-map-dot" :style="{ background: motionTokens.color.pending }"></span>Request</span>
|
||||
</div>
|
||||
<!-- 2D/3D projection toggle — tweens the camera between the flat radial
|
||||
layout and the depth view -->
|
||||
@@ -26,7 +27,22 @@
|
||||
>{{ m.toUpperCase() }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="peerCount === 0" class="node-map-empty">
|
||||
<!-- Peer request popover: black glass, centred over the scene -->
|
||||
<Transition name="nm-pop">
|
||||
<div v-if="activeRequest" class="node-map-popover" role="dialog" aria-label="Peer request" @click.stop>
|
||||
<button class="node-map-popover-close" aria-label="Dismiss" @click="activeRequest = null">✕</button>
|
||||
<span class="node-map-popover-badge">Peer request</span>
|
||||
<p class="node-map-popover-title">{{ activeRequest.label }}</p>
|
||||
<p class="node-map-popover-sub">wants to peer with your node</p>
|
||||
<p v-if="activeRequest.message" class="node-map-popover-msg">“{{ activeRequest.message }}”</p>
|
||||
<div class="node-map-popover-actions">
|
||||
<button class="node-map-popover-btn nm-reject" @click="decideRequest('reject')">Reject</button>
|
||||
<button class="node-map-popover-btn nm-accept" @click="decideRequest('approve')">Accept</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div v-if="peerCount === 0 && (requests?.length ?? 0) === 0" class="node-map-empty">
|
||||
<p class="node-map-empty-title">No peers yet</p>
|
||||
<p class="node-map-empty-sub">Invite a peer or discover nodes to grow your federation</p>
|
||||
</div>
|
||||
@@ -52,13 +68,24 @@ export interface MapLink {
|
||||
target: string
|
||||
}
|
||||
|
||||
/** Inbound peer request awaiting a decision — rendered as a blinking yellow
|
||||
* globe on the outermost orbit. */
|
||||
export interface MapRequest {
|
||||
id: string
|
||||
label: string
|
||||
message: string | null
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
nodes: MapNode[]
|
||||
links: MapLink[]
|
||||
requests?: MapRequest[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'select', did: string): void
|
||||
(e: 'approve', id: string): void
|
||||
(e: 'reject', id: string): void
|
||||
}>()
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
@@ -116,7 +143,31 @@ interface PeerVis {
|
||||
depth: number
|
||||
}
|
||||
|
||||
/** A pending request on the map. Shares the peer projection maths; adds a
|
||||
* blink phase and decision-animation fields (deathScale for the reject pop,
|
||||
* a tweenable ringRadius for the accept glide). */
|
||||
interface RequestVis {
|
||||
req: MapRequest
|
||||
angle: number
|
||||
ringRadius: number
|
||||
yJitter: number
|
||||
bobPhase: number
|
||||
p: number
|
||||
blinking: boolean
|
||||
deathScale: number
|
||||
el: SVGGElement
|
||||
outline: SVGCircleElement
|
||||
halo: SVGCircleElement
|
||||
coreGlow: SVGCircleElement
|
||||
label: SVGTextElement
|
||||
linkEl: SVGPathElement
|
||||
dots: GlobeDot[]
|
||||
radiusPx: number
|
||||
depth: number
|
||||
}
|
||||
|
||||
let peers: PeerVis[] = []
|
||||
let requestVis: RequestVis[] = []
|
||||
let selfEl: SVGGElement | null = null
|
||||
const selfState = { p: 0 }
|
||||
let ringPaths: { el: SVGPathElement; radius: number }[] = []
|
||||
@@ -193,9 +244,11 @@ function clearScene() {
|
||||
const svg = svgRef.value
|
||||
if (svg) while (svg.firstChild) svg.removeChild(svg.firstChild)
|
||||
peers = []
|
||||
requestVis = []
|
||||
ringPaths = []
|
||||
selfEl = null
|
||||
selfDots = []
|
||||
activeRequest.value = null
|
||||
nodesLayer = null
|
||||
linksLayer = null
|
||||
ringsLayer = null
|
||||
@@ -390,6 +443,81 @@ function buildScene() {
|
||||
depth: 0,
|
||||
})
|
||||
})
|
||||
|
||||
// Inbound peer requests: blinking yellow globes on an orbit OUTSIDE the
|
||||
// peers — visually "knocking at the door", linked to self by a dotted line.
|
||||
const reqs = props.requests ?? []
|
||||
if (reqs.length) {
|
||||
const maxPeerRingRadius = peerNodes.length
|
||||
? 1 + ringFor(peerNodes.length - 1, peerNodes.length).ring * 0.65
|
||||
: 1
|
||||
const reqRing = maxPeerRingRadius + 0.65
|
||||
const color = motionTokens.color.pending
|
||||
const ringEl = el('path', {
|
||||
fill: 'none',
|
||||
stroke: color,
|
||||
'stroke-opacity': '0.12',
|
||||
'stroke-width': '1',
|
||||
'stroke-dasharray': '4 5',
|
||||
opacity: '0',
|
||||
})
|
||||
ringsLayer.appendChild(ringEl)
|
||||
ringPaths.push({ el: ringEl, radius: reqRing })
|
||||
|
||||
reqs.forEach((req, i) => {
|
||||
const r = 7.5
|
||||
const g = el('g', { cursor: 'pointer' }) as SVGGElement
|
||||
const halo = el('circle', { r: String(r * 2), fill: color, opacity: '0.14' })
|
||||
const outline = el('circle', {
|
||||
r: String(r), fill: 'none', stroke: color,
|
||||
'stroke-width': '1', 'stroke-opacity': '0.5', 'stroke-dasharray': '3 3',
|
||||
})
|
||||
const coreGlow = el('circle', { r: String(r * 0.4), fill: color, opacity: '0.25' })
|
||||
g.append(halo, outline, coreGlow)
|
||||
const dots = makeGlobe(g, r, color, 24)
|
||||
const label = el('text', {
|
||||
dy: String(r + 15),
|
||||
'text-anchor': 'middle',
|
||||
class: 'nm-label',
|
||||
fill: motionTokens.color.textSecondary,
|
||||
'font-size': '11px',
|
||||
'font-family': "'Avenir Next', system-ui, sans-serif",
|
||||
})
|
||||
label.textContent = req.label
|
||||
const title = el('title')
|
||||
title.textContent = `Peer request from ${req.label}`
|
||||
g.append(label, title)
|
||||
// stopPropagation: the same click must not bubble to the container's
|
||||
// popover-dismiss handler and immediately close what it just opened
|
||||
g.addEventListener('click', (e) => { e.stopPropagation(); activeRequest.value = req })
|
||||
nodesLayer!.appendChild(g)
|
||||
const linkEl = el('path', {
|
||||
fill: 'none', stroke: color,
|
||||
'stroke-width': '1.2', 'stroke-opacity': '0.3', 'stroke-dasharray': '2 5',
|
||||
})
|
||||
linksLayer!.appendChild(linkEl)
|
||||
|
||||
requestVis.push({
|
||||
req,
|
||||
angle: (i / reqs.length) * Math.PI * 2 + 0.35,
|
||||
ringRadius: reqRing,
|
||||
yJitter: (hash01(i + 40) - 0.5) * 0.2,
|
||||
bobPhase: hash01(i + 140) * Math.PI * 2,
|
||||
p: 0,
|
||||
blinking: true,
|
||||
deathScale: 1,
|
||||
el: g,
|
||||
outline,
|
||||
halo,
|
||||
coreGlow,
|
||||
label,
|
||||
linkEl,
|
||||
dots,
|
||||
radiusPx: r,
|
||||
depth: 0,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** One frame: project every element through the camera and write attrs. */
|
||||
@@ -455,12 +583,34 @@ function render() {
|
||||
}
|
||||
}
|
||||
|
||||
// Pending requests: same projection as peers, plus the attention blink
|
||||
// (skipped once a decision animation has started or under reduced motion)
|
||||
for (const rv of requestVis) {
|
||||
const dist = rv.ringRadius * (1.9 - 0.9 * rv.p)
|
||||
const sway = staticMode ? 0 : Math.sin(elapsed * 0.55 + rv.bobPhase) * 0.04
|
||||
const a = rv.angle + sway
|
||||
const x = Math.cos(a) * dist
|
||||
const z = Math.sin(a) * dist
|
||||
const y = rv.yJitter
|
||||
const pt = project(x, y, z)
|
||||
rv.depth = pt.z
|
||||
if (updateDots) renderGlobe(rv.dots, rv.radiusPx, cam.rotY + elapsed * 0.18 + rv.bobPhase, 0.95)
|
||||
const blink = rv.blinking && !staticMode ? 0.55 + 0.45 * Math.sin(elapsed * 3.2 + rv.bobPhase) : 1
|
||||
const s = pt.s * cam.zoom * (0.4 + 0.6 * rv.p) * rv.deathScale
|
||||
rv.el.setAttribute('transform', `translate(${pt.x},${pt.y}) scale(${Math.max(s, 0.001)})`)
|
||||
rv.el.setAttribute('opacity', String(rv.p * blink))
|
||||
const mid = project(x * 0.5, y * 0.5 - 0.12, z * 0.5)
|
||||
rv.linkEl.setAttribute('d', `M${origin.x.toFixed(1)} ${origin.y.toFixed(1)} Q${mid.x.toFixed(1)} ${mid.y.toFixed(1)} ${pt.x.toFixed(1)} ${pt.y.toFixed(1)}`)
|
||||
rv.linkEl.setAttribute('opacity', String(rv.p * Math.min(blink + 0.2, 1)))
|
||||
}
|
||||
|
||||
// Painter's order: farthest first so near nodes overlap far ones. The self
|
||||
// node sits at z=0 (mid-plane) and takes part in the same sort, so peers
|
||||
// orbiting in front genuinely pass over it. Only touch the DOM when the
|
||||
// order actually changed.
|
||||
if (nodesLayer && peers.length > 0) {
|
||||
if (nodesLayer && (peers.length > 0 || requestVis.length > 0)) {
|
||||
const drawables: { el: SVGGElement; depth: number }[] = peers.map(p => ({ el: p.el, depth: p.depth }))
|
||||
for (const rv of requestVis) drawables.push({ el: rv.el, depth: rv.depth })
|
||||
if (selfEl) drawables.push({ el: selfEl, depth: 0 })
|
||||
drawables.sort((a, b) => b.depth - a.depth)
|
||||
let dirty = false
|
||||
@@ -500,6 +650,7 @@ function playIntro() {
|
||||
cam.zoom = 1
|
||||
selfState.p = 1
|
||||
for (const p of peers) p.p = 1
|
||||
for (const rv of requestVis) rv.p = 1
|
||||
for (const r of ringPaths) r.el.setAttribute('opacity', '1')
|
||||
render()
|
||||
return
|
||||
@@ -523,6 +674,59 @@ function playIntro() {
|
||||
stagger: { each: 0.07, from: 'random' },
|
||||
}, 0.45)
|
||||
}
|
||||
if (requestVis.length) {
|
||||
intro.to(requestVis, {
|
||||
p: 1,
|
||||
duration: 0.7,
|
||||
ease: motionTokens.ease.arrive,
|
||||
stagger: 0.08,
|
||||
}, 0.7)
|
||||
}
|
||||
}
|
||||
|
||||
/* ----------------------- request popover ------------------------- */
|
||||
|
||||
const activeRequest = ref<MapRequest | null>(null)
|
||||
|
||||
function decideRequest(decision: 'approve' | 'reject') {
|
||||
const req = activeRequest.value
|
||||
if (!req) return
|
||||
activeRequest.value = null
|
||||
const rv = requestVis.find(v => v.req.id === req.id)
|
||||
if (rv && !staticMode) {
|
||||
rv.blinking = false
|
||||
if (decision === 'reject') {
|
||||
// Pop out of existence: a quick swell, then collapse — the dotted
|
||||
// link dies with the node.
|
||||
gsap.timeline()
|
||||
.to(rv, { deathScale: 1.22, duration: 0.14, ease: 'power2.out' })
|
||||
.to(rv, { deathScale: 0.001, p: 0.0001, duration: 0.32, ease: 'back.in(2.4)' })
|
||||
} else {
|
||||
// Join: green burst ring, the point cloud and link morph to the
|
||||
// trusted colour, and the globe glides inward onto the peer orbit.
|
||||
const trusted = motionTokens.color.trusted
|
||||
const burst = el('circle', {
|
||||
r: String(rv.radiusPx), fill: 'none',
|
||||
stroke: trusted, 'stroke-width': '2', opacity: '0.8',
|
||||
})
|
||||
rv.el.appendChild(burst)
|
||||
gsap.to(burst, { attr: { r: rv.radiusPx * 3.2 }, opacity: 0, duration: 0.9, ease: 'sine.out' })
|
||||
rv.outline.removeAttribute('stroke-dasharray')
|
||||
rv.linkEl.removeAttribute('stroke-dasharray')
|
||||
gsap.to(rv.dots.map(d => d.el), { attr: { fill: trusted }, duration: 0.6, ease: motionTokens.ease.inOut })
|
||||
gsap.to([rv.halo, rv.coreGlow], { attr: { fill: trusted }, duration: 0.6, ease: motionTokens.ease.inOut })
|
||||
gsap.to([rv.outline, rv.linkEl], { attr: { stroke: trusted }, duration: 0.6, ease: motionTokens.ease.inOut })
|
||||
gsap.to(rv, { ringRadius: 1, duration: 0.9, ease: motionTokens.ease.inOut, delay: 0.15 })
|
||||
}
|
||||
}
|
||||
if (decision === 'approve') emit('approve', req.id)
|
||||
else emit('reject', req.id)
|
||||
}
|
||||
|
||||
/** Tapping empty map space dismisses the popover (request-node clicks
|
||||
* stopPropagation, and the popover itself uses @click.stop). */
|
||||
function onStageClick() {
|
||||
if (activeRequest.value) activeRequest.value = null
|
||||
}
|
||||
|
||||
/* --------------------------- interaction --------------------------- */
|
||||
@@ -668,6 +872,7 @@ function measure() {
|
||||
const graphSignature = computed(() => JSON.stringify({
|
||||
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
|
||||
links: props.links.map(l => [l.source, l.target]),
|
||||
requests: (props.requests ?? []).map(r => [r.id, r.label]),
|
||||
}))
|
||||
|
||||
let hasIntroPlayed = false
|
||||
@@ -684,8 +889,12 @@ function rebuild(replayIntro: boolean) {
|
||||
selfState.p = 1
|
||||
if (staticMode) {
|
||||
for (const p of peers) p.p = 1
|
||||
for (const rv of requestVis) rv.p = 1
|
||||
} else {
|
||||
gsap.to(peers, { p: 1, duration: 0.5, ease: motionTokens.ease.arrive, stagger: 0.03 })
|
||||
if (requestVis.length) {
|
||||
gsap.to(requestVis, { p: 1, duration: 0.5, ease: motionTokens.ease.arrive, stagger: 0.05 })
|
||||
}
|
||||
}
|
||||
for (const r of ringPaths) r.el.setAttribute('opacity', '1')
|
||||
render()
|
||||
@@ -700,6 +909,7 @@ onMounted(() => {
|
||||
resizeObserver.observe(containerRef.value)
|
||||
containerRef.value.addEventListener('pointerdown', onPointerDown)
|
||||
containerRef.value.addEventListener('click', onClickCapture, true)
|
||||
containerRef.value.addEventListener('click', onStageClick)
|
||||
// Window-level so a drag that leaves the container keeps tracking
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
@@ -826,6 +1036,105 @@ watch(graphSignature, () => rebuild(false))
|
||||
pointer-events: none;
|
||||
margin: 0;
|
||||
}
|
||||
.node-map-popover {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: min(300px, calc(100% - 32px));
|
||||
padding: 18px 16px 14px;
|
||||
border-radius: 1rem;
|
||||
background: rgba(0, 0, 0, 0.78);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.16);
|
||||
text-align: center;
|
||||
}
|
||||
.node-map-popover-close {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
min-height: 0 !important;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.node-map-popover-close:hover {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
.node-map-popover-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #facc15;
|
||||
background: rgba(250, 204, 21, 0.12);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.node-map-popover-title {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.node-map-popover-sub {
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
margin: 2px 0 0;
|
||||
}
|
||||
.node-map-popover-msg {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
margin: 10px 0 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.node-map-popover-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.node-map-popover-btn {
|
||||
flex: 1;
|
||||
padding: 9px 0;
|
||||
border-radius: 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
.node-map-popover-btn.nm-accept {
|
||||
color: #4ade80;
|
||||
background: rgba(74, 222, 128, 0.12);
|
||||
}
|
||||
.node-map-popover-btn.nm-accept:hover {
|
||||
background: rgba(74, 222, 128, 0.22);
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
}
|
||||
.node-map-popover-btn.nm-reject {
|
||||
color: #f87171;
|
||||
background: rgba(239, 68, 68, 0.10);
|
||||
}
|
||||
.node-map-popover-btn.nm-reject:hover {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
border-color: rgba(239, 68, 68, 0.4);
|
||||
}
|
||||
/* Popover enter/leave: quick glass pop */
|
||||
.nm-pop-enter-active,
|
||||
.nm-pop-leave-active {
|
||||
transition: opacity 0.22s ease, transform 0.22s ease;
|
||||
}
|
||||
.nm-pop-enter-from,
|
||||
.nm-pop-leave-to {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.92);
|
||||
}
|
||||
|
||||
.node-map-empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -22,6 +22,8 @@ export const motionTokens = {
|
||||
observer: '#fb923c',
|
||||
untrusted: '#ef4444',
|
||||
neutral: '#9ca3af',
|
||||
/** Pending/attention — inbound peer requests awaiting a decision. */
|
||||
pending: '#facc15',
|
||||
/** Text/line opacities on the dark glass ground. */
|
||||
textPrimary: 'rgba(255, 255, 255, 0.95)',
|
||||
textSecondary: 'rgba(255, 255, 255, 0.7)',
|
||||
|
||||
@@ -46,7 +46,14 @@
|
||||
|
||||
<!-- Network Map View — fills all remaining height to the bottom edge -->
|
||||
<div v-if="mapActive" class="flex-1 min-h-0">
|
||||
<NetworkMap3D :nodes="mapNodes" :links="mapLinks" @select="onMapSelect" />
|
||||
<NetworkMap3D
|
||||
:nodes="mapNodes"
|
||||
:links="mapLinks"
|
||||
:requests="mapRequests"
|
||||
@select="onMapSelect"
|
||||
@approve="approvePending"
|
||||
@reject="rejectPending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template v-if="activeView === 'list'">
|
||||
@@ -367,6 +374,16 @@ const mapLinks = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
/** Inbound pending requests for the map — blinking yellow nodes the user can
|
||||
* accept/reject in place (same RPCs as the pending panel). */
|
||||
const mapRequests = computed(() => pendingRequests.value
|
||||
.filter(r => !r.outbound && r.state === 'pending')
|
||||
.map(r => ({
|
||||
id: r.id,
|
||||
label: r.from_name || `${r.from_nostr_npub.slice(0, 12)}…`,
|
||||
message: r.message,
|
||||
})))
|
||||
|
||||
const dwnStatusRes = useCachedResource<DwnStatus>({
|
||||
key: 'federation.dwn-status',
|
||||
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
|
||||
|
||||
Reference in New Issue
Block a user