fix(mesh): collapse cross-transport twin contacts into one conversation (#12)

A node reachable both over LoRa and federation has two MeshPeer rows (radio
twin: low contact_id + firmware key; federation twin: high contact_id +
archipelago key), and messages key by peer_contact_id split across the two ids
— so opening one twin shows an empty thread (the .120->.89 symptom).

- backend: new group_peer_twins() helper groups peers by arch_pubkey_hex (set on
  BOTH twins by bind_federation_twins), keeps the radio id as the mesh-first
  send target, and unions messages across all twin ids. Wired into
  conversations.list / conversations.messages / mesh.contacts-list. +3 unit tests.
- frontend: the live chat list merges client-side (mergedPeers) and matched twins
  by the "Archy-z6Mk..." advert prefix, which the Meshtastic device rename broke
  (radio now advertises the server name). Merge by arch_pubkey_hex instead, which
  the backend reliably sets on both twins. Expose arch_pubkey_hex on MeshPeer.
- fix unrelated stale test: EcashTransaction test missing the new `kind` field.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-06-20 08:01:14 -04:00
co-authored by Claude Opus 4.8
parent 5f7e8dca80
commit f92e442bfc
6 changed files with 180 additions and 6 deletions
+17 -4
View File
@@ -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<u32> = 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" => {
@@ -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<serde_json::Value> = 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!({