feat(ui): peer requests confirm via modal with optional message
Request-to-Peer (Federation Discover modal + Web5 Node Visibility list) no longer fires instantly: a BaseModal confirm shows the target, an optional 280-char message (handshake.connect already carried it), and Send/Cancel with the standard orange CTA. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9471d3727f
commit
e113a2560e
66
neode-ui/src/components/federation/PeerRequestModal.vue
Normal file
66
neode-ui/src/components/federation/PeerRequestModal.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<BaseModal
|
||||
:show="show"
|
||||
title="Request to Peer"
|
||||
max-width="max-w-md"
|
||||
:z-index="'z-[3200]'"
|
||||
@close="$emit('cancel')"
|
||||
>
|
||||
<p class="text-white/70 text-sm mb-1">
|
||||
Send a connection request to
|
||||
<span class="text-white font-medium">{{ targetLabel }}</span>
|
||||
</p>
|
||||
<p class="text-white/45 text-xs mb-4">
|
||||
They'll see your request and approve or decline it. Approved peers connect
|
||||
at the Peer level — never trusted automatically.
|
||||
</p>
|
||||
|
||||
<label class="block text-xs text-white/60 mb-1">Message (optional)</label>
|
||||
<textarea
|
||||
v-model="message"
|
||||
rows="3"
|
||||
maxlength="280"
|
||||
placeholder="Hey — mind if we peer?"
|
||||
class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60 resize-none"
|
||||
></textarea>
|
||||
<p class="text-[11px] text-white/30 text-right mt-1">{{ message.length }}/280</p>
|
||||
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button class="flex-1 glass-button px-4 py-2 rounded-lg text-sm" @click="$emit('cancel')">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
|
||||
:disabled="sending"
|
||||
@click="$emit('send', message.trim() || undefined)"
|
||||
>
|
||||
{{ sending ? 'Sending…' : 'Send Request' }}
|
||||
</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
targetLabel: string
|
||||
sending?: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
send: [message: string | undefined]
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const message = ref('')
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(visible) => {
|
||||
if (visible) message.value = ''
|
||||
},
|
||||
)
|
||||
</script>
|
||||
@ -100,12 +100,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<PeerRequestModal
|
||||
:show="requestTarget !== null"
|
||||
:target-label="requestTarget?.label ?? ''"
|
||||
:sending="sendingTo !== null && sendingTo === requestTarget?.target"
|
||||
@send="confirmRequest"
|
||||
@cancel="requestTarget = null"
|
||||
/>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { rpcClient, type PendingPeerRequest } from '@/api/rpc-client'
|
||||
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
|
||||
|
||||
interface DiscoverableNode {
|
||||
nostr_pubkey: string
|
||||
@ -156,22 +165,33 @@ async function refresh() {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendTo(node: DiscoverableNode) {
|
||||
await sendInternal(node.nostr_pubkey)
|
||||
// Request confirmation modal: peer requests always offer an optional
|
||||
// message before anything is sent (Request/Cancel).
|
||||
const requestTarget = ref<{ target: string; label: string; clearManual: boolean } | null>(null)
|
||||
|
||||
function sendTo(node: DiscoverableNode) {
|
||||
requestTarget.value = { target: node.nostr_pubkey, label: shortNpub(node.nostr_npub), clearManual: false }
|
||||
}
|
||||
|
||||
async function sendDirect() {
|
||||
function sendDirect() {
|
||||
const v = manualNpub.value.trim()
|
||||
if (!v) return
|
||||
await sendInternal(v)
|
||||
manualNpub.value = ''
|
||||
requestTarget.value = { target: v, label: v.length > 21 ? `${v.slice(0, 12)}…${v.slice(-6)}` : v, clearManual: true }
|
||||
}
|
||||
|
||||
async function sendInternal(target: string) {
|
||||
async function confirmRequest(message: string | undefined) {
|
||||
const req = requestTarget.value
|
||||
if (!req) return
|
||||
await sendInternal(req.target, message)
|
||||
if (req.clearManual) manualNpub.value = ''
|
||||
requestTarget.value = null
|
||||
}
|
||||
|
||||
async function sendInternal(target: string, message?: string) {
|
||||
sendingTo.value = target
|
||||
error.value = ''
|
||||
try {
|
||||
await rpcClient.handshakeConnect(target)
|
||||
await rpcClient.handshakeConnect(target, message)
|
||||
emit('sent')
|
||||
} catch (e: unknown) {
|
||||
error.value = e instanceof Error ? e.message : 'Send failed'
|
||||
|
||||
@ -87,7 +87,7 @@
|
||||
<button
|
||||
class="px-3 py-1.5 glass-button glass-button-sm rounded text-xs text-white/90 hover:text-white disabled:opacity-50 shrink-0"
|
||||
:disabled="requestingPeer === node.nostr_pubkey || requestedPeers.has(node.nostr_pubkey)"
|
||||
@click="requestToPeer(node)"
|
||||
@click="requestModalTarget = node"
|
||||
>
|
||||
{{ requestedPeers.has(node.nostr_pubkey) ? 'Requested' : requestingPeer === node.nostr_pubkey ? 'Sending…' : 'Request to Peer' }}
|
||||
</button>
|
||||
@ -99,6 +99,14 @@
|
||||
<p v-if="discoverEnabled" class="mt-3 text-xs text-amber-400/80">
|
||||
{{ t('web5.discoverableWarning') }}
|
||||
</p>
|
||||
|
||||
<PeerRequestModal
|
||||
:show="requestModalTarget !== null"
|
||||
:target-label="requestModalTarget ? shortNpub(requestModalTarget.nostr_npub) : ''"
|
||||
:sending="requestingPeer !== null"
|
||||
@send="confirmPeerRequest"
|
||||
@cancel="requestModalTarget = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -107,6 +115,7 @@ import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import ToggleSwitch from '@/components/ToggleSwitch.vue'
|
||||
import PeerRequestModal from '@/components/federation/PeerRequestModal.vue'
|
||||
import { safeClipboardWrite } from './utils'
|
||||
import type { VisibilityLevel } from './types'
|
||||
|
||||
@ -203,13 +212,22 @@ async function discoverNodes() {
|
||||
}
|
||||
}
|
||||
|
||||
async function requestToPeer(node: DiscoverableNode) {
|
||||
const requestModalTarget = ref<DiscoverableNode | null>(null)
|
||||
|
||||
async function confirmPeerRequest(message: string | undefined) {
|
||||
const node = requestModalTarget.value
|
||||
if (!node) return
|
||||
await requestToPeer(node, message)
|
||||
requestModalTarget.value = null
|
||||
}
|
||||
|
||||
async function requestToPeer(node: DiscoverableNode, message?: string) {
|
||||
if (requestingPeer.value) return
|
||||
requestingPeer.value = node.nostr_pubkey
|
||||
try {
|
||||
// Connection requests always land as Peer (observer) on approval —
|
||||
// never trusted — so a mistaken request can't hand over fleet access.
|
||||
await rpcClient.handshakeConnect(node.nostr_pubkey)
|
||||
await rpcClient.handshakeConnect(node.nostr_pubkey, message)
|
||||
requestedPeers.value.add(node.nostr_pubkey)
|
||||
requestedPeers.value = new Set(requestedPeers.value)
|
||||
emit('toast', 'Peer request sent — awaiting their approval')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user