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
+24 -2
View File
@@ -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(),
)
+48 -11
View File
@@ -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,
});
}
}
+4 -4
View File
@@ -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(),
)