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:
co-authored by
Claude Fable 5
parent
a2ff3502bd
commit
2786c0727f
@@ -32,6 +32,9 @@ use crate::nostr_handshake::DISCOVERY_STATE_FILE as NOSTR_STATE_FILE;
|
||||
struct NostrDiscoveryState {
|
||||
#[serde(default)]
|
||||
enabled: bool,
|
||||
/// Operator-chosen display name carried in the presence event.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
|
||||
@@ -64,7 +67,7 @@ impl RpcHandler {
|
||||
let npub = nostr_handshake::own_npub(&self.config.data_dir.join("identity"))
|
||||
.await
|
||||
.unwrap_or(None);
|
||||
Ok(serde_json::json!({ "enabled": state.enabled, "npub": npub }))
|
||||
Ok(serde_json::json!({ "enabled": state.enabled, "npub": npub, "name": state.name }))
|
||||
}
|
||||
|
||||
/// Set the runtime discoverability flag. If turning ON, publish presence
|
||||
@@ -84,7 +87,24 @@ impl RpcHandler {
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
|
||||
|
||||
save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
|
||||
// Optional display name. Absent param = keep the stored name (so a
|
||||
// plain off/on toggle doesn't forget it); present-but-empty clears it.
|
||||
let prior = load_discovery_state(&self.config.data_dir).await;
|
||||
let name = match params.get("name") {
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.and_then(nostr_handshake::clean_display_name),
|
||||
None => prior.name,
|
||||
};
|
||||
|
||||
save_discovery_state(
|
||||
&self.config.data_dir,
|
||||
&NostrDiscoveryState {
|
||||
enabled,
|
||||
name: name.clone(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
if enabled && !self.config.nostr_relays.is_empty() {
|
||||
let (data, _) = self.state_manager.get_snapshot().await;
|
||||
@@ -94,11 +114,13 @@ impl RpcHandler {
|
||||
let version = data.server_info.version.clone();
|
||||
let relays = self.handshake_relays().await;
|
||||
let tor_proxy = self.config.nostr_tor_proxy.clone();
|
||||
let publish_name = name.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = nostr_handshake::publish_presence(
|
||||
&identity_dir,
|
||||
&did,
|
||||
&version,
|
||||
publish_name.as_deref(),
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
|
||||
@@ -47,14 +47,36 @@ pub const DISCOVERY_STATE_FILE: &str = "nostr_discovery_state.json";
|
||||
/// tolerates three misses.
|
||||
pub const PRESENCE_TTL_SECS: u64 = 48 * 3600;
|
||||
|
||||
/// Read the runtime discovery override. `None` means the toggle has never
|
||||
/// been used on this node — callers fall back to the config flag.
|
||||
pub async fn discovery_enabled_override(data_dir: &Path) -> Option<bool> {
|
||||
let raw = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE))
|
||||
.await
|
||||
.ok()?;
|
||||
let v: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
v.get("enabled").and_then(|e| e.as_bool())
|
||||
/// Read the runtime discovery override and the operator-chosen display name.
|
||||
/// Enabled `None` means the toggle has never been used on this node —
|
||||
/// callers fall back to the config flag.
|
||||
pub async fn discovery_overrides(data_dir: &Path) -> (Option<bool>, Option<String>) {
|
||||
let Ok(raw) = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)).await else {
|
||||
return (None, None);
|
||||
};
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
|
||||
return (None, None);
|
||||
};
|
||||
let enabled = v.get("enabled").and_then(|e| e.as_bool());
|
||||
let name = v
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.and_then(clean_display_name);
|
||||
(enabled, name)
|
||||
}
|
||||
|
||||
/// Display names travel in a PUBLIC relay event and come back from untrusted
|
||||
/// peers — normalise both directions: single line, control chars stripped,
|
||||
/// hard length cap, empty collapses to None.
|
||||
pub fn clean_display_name(raw: &str) -> Option<String> {
|
||||
let cleaned: String = raw
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.take(32)
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.to_string();
|
||||
(!cleaned.is_empty()).then_some(cleaned)
|
||||
}
|
||||
|
||||
/// This node's own published npub (bech32), if discovery keys exist.
|
||||
@@ -161,6 +183,7 @@ pub async fn publish_presence(
|
||||
identity_dir: &Path,
|
||||
did: &str,
|
||||
version: &str,
|
||||
name: Option<&str>,
|
||||
relays: &[String],
|
||||
tor_proxy: Option<&str>,
|
||||
) -> Result<()> {
|
||||
@@ -176,14 +199,20 @@ pub async fn publish_presence(
|
||||
let nostr_npub = keys.public_key().to_bech32().unwrap_or_default();
|
||||
let client = build_client(keys, tor_proxy)?;
|
||||
|
||||
let content = serde_json::json!({
|
||||
let mut fields = serde_json::json!({
|
||||
"did": did,
|
||||
"nostr_pubkey": nostr_pubkey,
|
||||
"nostr_npub": nostr_npub,
|
||||
"version": version,
|
||||
// No onion address — exchanged only via encrypted DM
|
||||
})
|
||||
.to_string();
|
||||
});
|
||||
// Operator-chosen display name (optional, already normalised). Public by
|
||||
// construction: it exists to label this node in other nodes' discovery
|
||||
// lists, so only ever include what clean_display_name lets through.
|
||||
if let Some(n) = name.and_then(clean_display_name) {
|
||||
fields["name"] = serde_json::Value::String(n);
|
||||
}
|
||||
let content = fields.to_string();
|
||||
|
||||
for url in relays {
|
||||
let _ = client.add_relay(url).await;
|
||||
@@ -259,6 +288,9 @@ pub struct DiscoverableNode {
|
||||
pub nostr_npub: String,
|
||||
pub did: String,
|
||||
pub version: String,
|
||||
/// Operator-chosen display name from the presence event. Untrusted peer
|
||||
/// input — normalised through `clean_display_name` on the way in.
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn discover_nodes(
|
||||
@@ -333,11 +365,16 @@ pub async fn discover_nodes(
|
||||
.ok()
|
||||
.and_then(|pk| pk.to_bech32().ok())
|
||||
.unwrap_or_default();
|
||||
let name = content
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(clean_display_name);
|
||||
nodes.push(DiscoverableNode {
|
||||
nostr_pubkey,
|
||||
nostr_npub,
|
||||
did,
|
||||
version,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,10 +234,9 @@ impl Server {
|
||||
tokio::spawn(async move {
|
||||
const HEARTBEAT_SECS: u64 = 12 * 3600; // < PRESENCE_TTL_SECS/3
|
||||
loop {
|
||||
let enabled =
|
||||
nostr_handshake::discovery_enabled_override(&data_dir_for_relays)
|
||||
.await
|
||||
.unwrap_or(config_flag);
|
||||
let (enabled_override, display_name) =
|
||||
nostr_handshake::discovery_overrides(&data_dir_for_relays).await;
|
||||
let enabled = enabled_override.unwrap_or(config_flag);
|
||||
if enabled {
|
||||
let relays = crate::nostr_relays::merged_relay_list(
|
||||
&data_dir_for_relays,
|
||||
@@ -249,6 +248,7 @@ impl Server {
|
||||
&identity_dir,
|
||||
&did,
|
||||
&version,
|
||||
display_name.as_deref(),
|
||||
&relays,
|
||||
tor_proxy.as_deref(),
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user