feat(nostr): optional display name in the presence event, asked at toggle-on

Turning discovery on prompts for a name; it rides the public announcement
(clean_display_name both directions: single line, control-stripped, 32-char
cap — it round-trips through untrusted relays). Blank lists as npub only;
off/on keeps the stored name; sending an empty name clears it. Discovery
lists show the name with the npub beneath. Own-npub display switches to
middle-ellipsis so the comparable tail stays visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-10 11:54:54 -04:00
co-authored by Claude Fable 5
parent a2ff3502bd
commit 2786c0727f
5 changed files with 162 additions and 25 deletions
+6 -3
View File
@@ -884,14 +884,16 @@ class RPCClient {
// `handshake.poll` queues inbound requests into the federation pending
// inbox for manual approval (it does NOT auto-accept).
async nostrDiscoveryStatus(): Promise<{ enabled: boolean; npub?: string | null }> {
async nostrDiscoveryStatus(): Promise<{ enabled: boolean; npub?: string | null; name?: string | null }> {
return this.call({ method: 'nostr.discovery-status', params: {} })
}
async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> {
async nostrSetDiscovery(enabled: boolean, name?: string): Promise<{ enabled: boolean }> {
// `name` omitted = backend keeps the stored display name; empty string
// clears it. Only sent when the caller explicitly provides it.
return this.call({
method: 'nostr.set-discovery',
params: { enabled },
params: name === undefined ? { enabled } : { enabled, name },
timeout: 30000,
})
}
@@ -902,6 +904,7 @@ class RPCClient {
nostr_npub: string
did: string
version: string
name?: string | null
}>
}> {
return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 })
+80 -5
View File
@@ -55,7 +55,10 @@
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourNodeNpub') }}</p>
<p class="text-xs font-mono text-white/80 truncate" :title="nodeNpub">{{ nodeNpub }}</p>
<p v-if="nodeName" class="text-sm text-white/90 truncate mb-0.5">{{ nodeName }}</p>
<!-- Middle-ellipsis, never CSS truncate: the tail is the part a
human compares against another listing, so it must stay visible -->
<p class="text-xs font-mono text-white/80 truncate" :title="nodeNpub">{{ midNpub(nodeNpub) }}</p>
</div>
<button @click="copyNpub" class="shrink-0 p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -83,7 +86,8 @@
class="p-3 bg-white/5 rounded-lg border border-white/10 flex items-start justify-between gap-3"
>
<div class="min-w-0 flex-1">
<div class="text-sm text-white truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div class="text-sm text-white truncate">{{ node.name || shortNpub(node.nostr_npub) }}</div>
<div v-if="node.name" class="text-[11px] text-white/50 font-mono truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
</div>
@@ -115,6 +119,32 @@
@send="confirmPeerRequest"
@cancel="requestModalTarget = null"
/>
<!-- Name prompt on the way to discoverable: the announcement is public,
so the name travels with it. Blank is fine — npub-only listing. -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showNameModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="cancelNameModal">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-md w-full relative z-10">
<h3 class="text-lg font-semibold text-white mb-2">Name your node</h3>
<p class="text-sm text-white/60 mb-4">Other nodes will see this name next to your npub in their discovery list. It's public. Leave blank to list as npub only.</p>
<input
v-model="nameInput"
type="text"
maxlength="32"
placeholder="e.g. Dorian's basement node"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
@keyup.enter="confirmNameModal"
/>
<div class="flex gap-3">
<button @click="cancelNameModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="confirmNameModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">Turn on discovery</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -139,15 +169,21 @@ const emit = defineEmits<{
const nodeVisibility = ref<VisibilityLevel>('hidden')
const nodeNpub = ref<string | null>(null)
const nodeName = ref<string | null>(null)
const visibilityLoading = ref(false)
const settingVisibility = ref(false)
const discoverEnabled = ref(false)
// Name-prompt state: turning discovery ON routes through a small dialog so
// the operator can (optionally) name the node before it announces itself.
const showNameModal = ref(false)
const nameInput = ref('')
interface DiscoverableNode {
nostr_pubkey: string
nostr_npub: string
did: string
version: string
name?: string | null
}
const discoveredNodes = ref<DiscoverableNode[]>([])
@@ -155,6 +191,12 @@ const discovering = ref(false)
const requestingPeer = ref<string | null>(null)
const requestedPeers = ref(new Set<string>())
/** Own-npub display: keep the start and the FULL tail visible, ellipsis in
* the middle. (shortNpub below stays as-is — it formats the discovered list.) */
function midNpub(npub: string): string {
return npub.length > 24 ? `${npub.slice(0, 12)}${npub.slice(-10)}` : npub
}
function shortNpub(npub: string): string {
if (!npub) return 'unknown'
return npub.length > 21 ? `${npub.slice(0, 12)}${npub.slice(-6)}` : npub
@@ -175,6 +217,7 @@ async function loadVisibility() {
])
discoverEnabled.value = !!disc.enabled
nodeNpub.value = disc.npub || null
nodeName.value = disc.name || null
nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden'
if (discoverEnabled.value) void discoverNodes()
} catch {
@@ -186,10 +229,33 @@ async function loadVisibility() {
async function toggleDiscoverable(enabled: boolean) {
if (settingVisibility.value) return
if (enabled) {
// Turning ON goes through the name dialog: the node is about to announce
// itself publicly, and this is the natural moment to (optionally) name it.
nameInput.value = nodeName.value || ''
showNameModal.value = true
return
}
await applyDiscovery(false)
}
function cancelNameModal() {
showNameModal.value = false
// The switch never actually flipped server-side; snap the UI back.
discoverEnabled.value = false
}
async function confirmNameModal() {
showNameModal.value = false
// Send exactly what's in the box: text sets the name, blank clears it.
await applyDiscovery(true, nameInput.value.trim())
}
async function applyDiscovery(enabled: boolean, name?: string) {
settingVisibility.value = true
try {
// Public means public: the switch drives nostr presence publishing.
const res = await rpcClient.nostrSetDiscovery(enabled)
const res = await rpcClient.nostrSetDiscovery(enabled, name)
discoverEnabled.value = !!res.enabled
// Keep the legacy visibility string in sync (cosmetic; best-effort).
const level: VisibilityLevel = enabled ? 'public' : 'hidden'
@@ -198,8 +264,17 @@ async function toggleDiscoverable(enabled: boolean) {
.then(() => { nodeVisibility.value = level })
.catch(() => {})
emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery')
if (enabled) void discoverNodes()
else discoveredNodes.value = []
if (enabled) {
if (name !== undefined) nodeName.value = name || null
// Re-read status so the npub/name shown reflect post-enable state
// without a page reload.
rpcClient.nostrDiscoveryStatus()
.then((s) => { nodeNpub.value = s.npub || null; nodeName.value = s.name || null })
.catch(() => {})
void discoverNodes()
} else {
discoveredNodes.value = []
}
} catch {
emit('toast', t('web5.failedToUpdateVisibility'))
} finally {