diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 66dfa8be..9f1e91e5 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -95,12 +95,17 @@ impl RpcHandler { if let Some(svc) = service.as_ref() { let peers = svc.peers().await; let messages = svc.messages(None).await; - // Per-peer last message. - for peer in &peers { + // Collapse radio/federation twins into one conversation per identity + // so a node reachable both ways shows once, with its messages unioned + // across both twin contact_ids (#12). + let groups = mesh::group_peer_twins(&peers); + for group in &groups { + let peer = &group.canonical; + // Newest message across ALL twin contact_ids in this group. let last = messages .iter() .rev() - .find(|m| m.peer_contact_id == peer.contact_id); + .find(|m| group.contact_ids.contains(&m.peer_contact_id)); let is_federation = peer.contact_id & 0x8000_0000 != 0; conversations.push(serde_json::json!({ "id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id), @@ -163,8 +168,16 @@ impl RpcHandler { let filtered: Vec<_> = match kind { "mesh" | "federation" => { let contact_id: u32 = rest.parse().unwrap_or(0); + // Resolve this id's twin group and union messages across all of + // its contact_ids, so opening either twin shows the full thread + // (federation-injected + radio messages) (#12). + let ids: Vec = mesh::group_peer_twins(&svc.peers().await) + .into_iter() + .find(|g| g.contact_ids.contains(&contact_id)) + .map(|g| g.contact_ids) + .unwrap_or_else(|| vec![contact_id]); all.into_iter() - .filter(|m| m.peer_contact_id == contact_id) + .filter(|m| ids.contains(&m.peer_contact_id)) .collect() } "channel" => { diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 57b06f0d..07c2ca81 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -1133,9 +1133,13 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; let state = svc.shared_state(); let contacts = state.contacts.read().await; - let peers = state.peers.read().await; + let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect(); + // Collapse radio/federation twins so a node reachable both ways shows as + // one contact instead of two (#12). + let groups = crate::mesh::group_peer_twins(&peer_vec); let mut out: Vec = Vec::new(); - for peer in peers.values() { + for group in &groups { + let peer = &group.canonical; if let Some(pk) = peer.pubkey_hex.as_ref() { let entry = contacts.get(pk).cloned().unwrap_or_default(); out.push(serde_json::json!({ diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index f78406d2..956dc27e 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -127,6 +127,91 @@ pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap, +} + +/// Collapse cross-transport twin peers into one conversation per identity. +/// +/// The same node commonly appears twice in the peer table: a radio twin (low +/// `contact_id`, firmware routing key) and a federation twin (high +/// `contact_id`, archipelago key), correlated by [`bind_federation_twins`] +/// which copies `arch_pubkey_hex` onto the radio twin but leaves both rows. +/// Messages are keyed by `peer_contact_id`, so they split across the two ids: +/// a federation-injected message sits on the federation row while the user may +/// open the radio row and see an empty thread (the `.120`→`.89` symptom). +/// +/// Group peers by `arch_pubkey_hex` when set, else treat each peer as its own +/// singleton group keyed by `contact_id`. Grouping is done ONLY here at surface +/// time — never re-keyed at bind time — so outbound routing keeps the distinct +/// per-twin `contact_id`s and stays mesh-first. First-seen order is preserved +/// for stable downstream sorting. +pub(crate) fn group_peer_twins(peers: &[MeshPeer]) -> Vec { + let mut order: Vec = Vec::new(); + let mut groups: std::collections::HashMap> = + std::collections::HashMap::new(); + for p in peers { + let key = match p.arch_pubkey_hex.as_deref() { + Some(arch) if !arch.is_empty() => format!("arch:{}", arch.to_ascii_lowercase()), + _ => format!("cid:{}", p.contact_id), + }; + if !groups.contains_key(&key) { + order.push(key.clone()); + } + groups.entry(key).or_default().push(p.clone()); + } + + let mut out = Vec::with_capacity(order.len()); + for key in order { + let members = match groups.remove(&key) { + Some(m) if !m.is_empty() => m, + _ => continue, + }; + let contact_ids: Vec = members.iter().map(|m| m.contact_id).collect(); + // Canonical = the radio twin (lowest id below the federation base) when + // one exists, else the lowest id overall (a federation-only peer). + let canonical_src = members + .iter() + .filter(|m| m.contact_id < FEDERATION_CONTACT_ID_BASE) + .min_by_key(|m| m.contact_id) + .or_else(|| members.iter().min_by_key(|m| m.contact_id)) + .expect("non-empty members"); + let mut canonical = canonical_src.clone(); + // Heal gaps from the twin: a radio row may lack the advert name, arch + // identity, or did that only the federation row carries. + if canonical.advert_name.trim().is_empty() { + if let Some(named) = members.iter().find(|m| !m.advert_name.trim().is_empty()) { + canonical.advert_name = named.advert_name.clone(); + } + } + if canonical.arch_pubkey_hex.is_none() { + canonical.arch_pubkey_hex = members.iter().find_map(|m| m.arch_pubkey_hex.clone()); + } + if canonical.did.is_none() { + canonical.did = members.iter().find_map(|m| m.did.clone()); + } + // Reachable if ANY twin is reachable (radio path or off-radio federation). + canonical.reachable = members.iter().any(|m| m.reachable); + out.push(PeerGroup { + canonical, + contact_ids, + }); + } + out +} + /// Upsert a mesh peer record representing a federation node so the UI can /// address it as a chat and `mesh.send-content` can route ContentRef to it. /// Existing entries (same contact_id) are updated in place, preserving any @@ -1760,6 +1845,57 @@ async fn bitcoin_rpc_getblockheader_by_height( mod tests { use super::*; + fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer { + MeshPeer { + contact_id, + advert_name: name.to_string(), + did: None, + pubkey_hex: Some(format!("fw{contact_id}")), + arch_pubkey_hex: arch.map(|s| s.to_string()), + x25519_pubkey: None, + rssi: None, + snr: None, + last_heard: String::new(), + hops: 0, + last_advert: 0, + reachable, + } + } + + #[test] + fn test_group_peer_twins_collapses_radio_and_federation() { + let radio = mk_peer(42, "Archy-X250-EXP", Some("ABCD"), false); + let fed = mk_peer(0x8000_0001, "Archy-X250-EXP", Some("abcd"), true); + let groups = group_peer_twins(&[radio, fed]); + assert_eq!(groups.len(), 1, "twins must collapse to one conversation"); + let g = &groups[0]; + // Canonical = the radio twin (mesh-first send), but reachability is the + // OR across twins (federation is reachable off-radio). + assert_eq!(g.canonical.contact_id, 42); + assert!(g.canonical.reachable); + // Both ids retained so messages can be unioned across them. + assert!(g.contact_ids.contains(&42) && g.contact_ids.contains(&0x8000_0001)); + } + + #[test] + fn test_group_peer_twins_keeps_distinct_identities_and_unbound_radio() { + // Two different identities + one radio peer that was never bound to a + // federation twin (arch = None) → three separate conversations. + let a = mk_peer(1, "Alice", Some("aa"), false); + let b = mk_peer(2, "Bob", Some("bb"), true); + let lonely = mk_peer(3, "Carol-radio", None, false); + let groups = group_peer_twins(&[a, b, lonely]); + assert_eq!(groups.len(), 3); + } + + #[test] + fn test_group_peer_twins_federation_only_uses_federation_id() { + let fed = mk_peer(0x8000_00ff, "Arch Dev", Some("dead"), true); + let groups = group_peer_twins(&[fed]); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].canonical.contact_id, 0x8000_00ff); + } + #[test] fn test_mesh_config_default() { let config = MeshConfig::default(); diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 00b63bc2..c889e620 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -1507,6 +1507,7 @@ mod tests { description: "test".into(), mint_url: String::new(), peer: String::new(), + kind: default_tx_kind(), }; let json = serde_json::to_string(&tx).unwrap(); assert!(json.contains("\"streamingpayment\"")); diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index afd9676d..9bb95e5f 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -26,6 +26,12 @@ export interface MeshPeer { advert_name: string did: string | null pubkey_hex: string | null + /** Verified archipelago ed25519 identity key. The backend binds this onto + * BOTH a node's twins — the federation peer natively and the radio twin via + * `bind_federation_twins` — so it's the most reliable key for collapsing the + * cross-transport duplicate (survives the device rename, unlike the advert + * name). Absent on radio peers that were never matched to a federation twin. */ + arch_pubkey_hex?: string | null rssi: number | null snr: number | null last_heard: string diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 6f46db7d..cc79db2a 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -560,6 +560,20 @@ function fedDidKeySuffix(did: string): string | null { } function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } { + // Strongest signal: the verified archipelago identity key. The backend binds + // it onto BOTH twins (federation peer natively, radio twin via + // `bind_federation_twins`), so grouping by it collapses the cross-transport + // duplicate regardless of advert name — this is what survives the Meshtastic + // device rename, which broke the `Archy-z6Mk…` did-prefix match below. Prefer + // the matching federation node's `did:` key so this stays consistent with the + // federation-only placeholder pass (which dedups on `did:`); fall back to + // an `arch:` key only when no federation entry is known for the identity. + if (peer.arch_pubkey_hex) { + for (const fed of fedNodesByDid.value.values()) { + if (fed.pubkey === peer.arch_pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed } + } + return { key: `arch:${peer.arch_pubkey_hex.toLowerCase()}`, matchedFed: null } + } if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null } // pubkey cross-ref: a federation node may share the archipelago pubkey // with this radio peer if it's the same physical node (rare today, since