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:
archipelago 2026-07-14 09:28:21 -04:00
parent 9471d3727f
commit e113a2560e
3 changed files with 114 additions and 10 deletions

View 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>

View File

@ -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'

View File

@ -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')