From d514e0e5e4404f1aab456bcfa5b984e89ac747db Mon Sep 17 00:00:00 2001 From: Dorian Date: Tue, 14 Apr 2026 10:24:27 -0400 Subject: [PATCH] fix(mesh): DM-via-channel tunnel + disable presence spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meshcore direct unicast silently drops between our two Archy nodes (firmware reports flood sends with resp_code=6 but nothing arrives). Wrap DMs as channel-1 broadcasts with a [0xD1][dest_prefix(6)][inner] header; receivers filter by prefix and dispatch the inner payload through the existing typed/base64/chunk ladder. Shrink chunk body to 125B so the wrapper still fits the 160B LoRa budget. Auto-heal routing: CMD_RESET_PATH (0x0D) any type-1 contact with path_len=0 on refresh so floods take over. send_text now returns the firmware's flood/direct mode flag for diagnostics. Disable the 120s presence heartbeat broadcaster — its CBOR payload was being re-echoed as plaintext by the shared repeater, spamming every visible node with garbled "Archy-…: av�…fstatusfonline…" messages on channel 0. mesh.broadcast-presence RPC stays registered but no longer transmits. Re-enable only once presence moves off the shared broadcast path. Also: MeshState.cmd_tx behind RwLock so stop()→start() cycles don't fail with "command channel already consumed"; MeshService.send_cmd helper; drop_message_by_id for control envelopes that shouldn't appear as Sent bubbles; self_advert_name reflected into MeshStatus after set; path_len/flags parsed out of RESP_CONTACT. Frontend: unified inbox merges mesh peers with federation nodes by DID/pubkey/name; hide presence/read_receipt/edit/channel_invite/ contact_card from chat stream; publicChannel index → 1 to match the new DM-via-channel routing. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/api/rpc/mesh/typed_messages.rs | 49 +- core/archipelago/src/mesh/listener/frames.rs | 155 ++++++- core/archipelago/src/mesh/listener/mod.rs | 16 +- core/archipelago/src/mesh/listener/session.rs | 99 +++- core/archipelago/src/mesh/mod.rs | 60 ++- core/archipelago/src/mesh/protocol.rs | 32 +- core/archipelago/src/mesh/serial.rs | 30 +- neode-ui/src/stores/mesh.ts | 7 + neode-ui/src/views/Mesh.vue | 430 ++++++++++++++++-- neode-ui/src/views/mesh/mesh-styles.css | 4 + 10 files changed, 771 insertions(+), 111 deletions(-) diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index aec0bb33..4a183202 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -3,7 +3,7 @@ use crate::blobs::DEFAULT_CAP_TTL_SECS; use crate::mesh::message_types::{ self, AlertPayload, AlertType, ChannelInvitePayload, ContentRefPayload, Coordinate, DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MessageKey, MeshMessageType, - PresencePayload, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload, + PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload, TypedEnvelope, }; use anyhow::Result; @@ -647,6 +647,10 @@ impl RpcHandler { let msg = svc .send_typed_wire(contact_id, wire, "read_receipt", &display, typed_json, seq) .await?; + // Read receipts are control envelopes; the receiver uses them to + // roll the ✓✓ marker forward on the matching outgoing bubble. They + // must not surface as standalone bubbles in our own chat history. + svc.drop_message_by_id(msg.id).await; info!(contact_id, seq, "Sent read receipt over mesh"); Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq })) } @@ -776,6 +780,10 @@ impl RpcHandler { let msg = svc .send_typed_wire(contact_id, wire, "edit", &new_text, typed_json, seq) .await?; + // Edits are control envelopes — they mutate the target bubble in + // apply_local_edit below, so the standalone Sent record has no UI + // value and would just clutter the chat. + svc.drop_message_by_id(msg.id).await; // Best-effort: apply the edit to our own local copy too, so the UI // updates without waiting for an echo. @@ -824,6 +832,10 @@ impl RpcHandler { let msg = svc .send_typed_wire(contact_id, wire, "delete", "(deleted)", typed_json, seq) .await?; + // Delete is a control envelope — apply_local_delete below tombstones + // the target bubble in place, so the standalone Sent record is just + // noise in the chat history. + svc.drop_message_by_id(msg.id).await; svc.apply_local_delete(target_seq).await; @@ -836,35 +848,14 @@ impl RpcHandler { /// Params: `{ channel?, status? }`. Defaults: channel 0, status "online". pub(in crate::api::rpc) async fn handle_mesh_broadcast_presence( &self, - params: Option, + _params: Option, ) -> Result { - let params = params.unwrap_or(serde_json::json!({})); - let channel = params["channel"].as_u64().unwrap_or(0) as u8; - let status = params["status"].as_str().unwrap_or("online").to_string(); - - let presence = PresencePayload { - status: status.clone(), - last_active: chrono::Utc::now().timestamp() as u32, - }; - - let service = self.mesh_service.read().await; - let svc = service - .as_ref() - .ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?; - let seq = svc.next_send_seq(0).await; - let payload = message_types::encode_payload(&presence)?; - let envelope = TypedEnvelope::new(MeshMessageType::Presence, payload).with_seq(seq); - let wire = envelope.to_wire()?; - let typed_json = serde_json::to_value(&presence).ok(); - // Best-effort: if the mesh device isn't connected, skip silently — - // presence heartbeats don't deserve a user-visible error. - match svc - .send_channel_typed_wire(channel, wire, "presence", &status, typed_json, seq) - .await - { - Ok(_) => Ok(serde_json::json!({ "sent": true, "sender_seq": seq })), - Err(e) => Ok(serde_json::json!({ "sent": false, "reason": e.to_string() })), - } + // DISABLED: presence broadcasts were spamming the public channel + // with malformed CBOR bytes (repeaters re-echoed our + // PresencePayload as plaintext, producing "av�…fstatusfonline…"). + // The RPC stays registered so frontends that still call it don't + // hard-fail, but it no longer transmits anything. + Ok(serde_json::json!({ "sent": false, "reason": "presence disabled" })) } /// mesh.presence-list — return the in-memory presence map (pubkey → status+timestamps). diff --git a/core/archipelago/src/mesh/listener/frames.rs b/core/archipelago/src/mesh/listener/frames.rs index 4933b6f0..5d9719eb 100644 --- a/core/archipelago/src/mesh/listener/frames.rs +++ b/core/archipelago/src/mesh/listener/frames.rs @@ -94,15 +94,7 @@ pub(super) async fn handle_frame( match protocol::parse_channel_msg_v3_raw(&frame.data) { Ok((channel_idx, payload)) => { if !payload.is_empty() { - let chan_contact_id = u32::MAX - (channel_idx as u32); - let chan_name = format!("Channel {}", channel_idx); - if TypedEnvelope::is_typed(&payload) { - handle_typed_message(&payload, chan_contact_id, &chan_name, state).await; - } else { - let text = String::from_utf8_lossy(&payload).to_string(); - store_plain_message(state, chan_contact_id, &chan_name, &text).await; - info!(channel = channel_idx, "Received mesh channel message (v3)"); - } + handle_channel_payload(state, channel_idx, &payload).await; } } Err(e) => warn!("Failed to parse v3 channel message: {}", e), @@ -114,15 +106,7 @@ pub(super) async fn handle_frame( match protocol::parse_channel_msg_v1_raw(&frame.data) { Ok((channel_idx, payload)) => { if !payload.is_empty() { - let chan_contact_id = u32::MAX - (channel_idx as u32); - let chan_name = format!("Channel {}", channel_idx); - if TypedEnvelope::is_typed(&payload) { - handle_typed_message(&payload, chan_contact_id, &chan_name, state).await; - } else { - let text = String::from_utf8_lossy(&payload).to_string(); - store_plain_message(state, chan_contact_id, &chan_name, &text).await; - info!(channel = channel_idx, "Received mesh channel message"); - } + handle_channel_payload(state, channel_idx, &payload).await; } } Err(e) => warn!("Failed to parse channel message: {}", e), @@ -141,3 +125,138 @@ pub(super) async fn handle_frame( } false } + +/// Process a channel-broadcast payload. If the payload carries the +/// DM-via-channel marker and the destination prefix matches any of our +/// local mesh peer pubkeys (or we can't tell), the inner payload is +/// dispatched through the direct-message path so it lands in the right +/// chat. Otherwise it's handled as a normal channel text/typed message. +async fn handle_channel_payload( + state: &Arc, + channel_idx: u8, + payload: &[u8], +) { + // DM-via-channel wrapper: [marker(1)][dest_prefix(6)][inner…] + if payload.len() >= 7 && payload[0] == protocol::DM_VIA_CHANNEL_MARKER { + let dest_prefix: [u8; 6] = payload[1..7] + .try_into() + .expect("sliced 6 bytes"); + let inner = &payload[7..]; + + // If the destination prefix matches a contact we know about that + // isn't ourselves, forward it (the channel broadcast is shared by + // everyone but only the intended recipient should treat it as a + // DM). We compare against our mesh contacts — if the prefix is + // not one of our known peers AND not our self_node_id, we drop + // because it's someone else's DM bouncing through the mesh. + let addressed_to_us = dest_prefix_is_us(state, &dest_prefix).await; + if !addressed_to_us { + debug!( + dest = %hex::encode(dest_prefix), + inner_len = inner.len(), + "Dropping DM-via-channel (not for us)" + ); + return; + } + + info!( + dest = %hex::encode(dest_prefix), + inner_len = inner.len(), + channel = channel_idx, + "Received DM via channel (addressed to us)" + ); + + // Treat the inner payload exactly the same as we'd treat a direct + // unicast frame — resolve the sender from our peer table (we + // don't know the sender here, so use a synthetic-ish contact_id + // derived from the first peer whose dest_prefix != us), and + // dispatch through the typed / base64 / plain-text ladder. + // Because the wrapped frame doesn't carry the sender prefix, we + // pick "the other side of the conversation" — there are only two + // known archipelago peers in the radio neighborhood, so the + // sender is whoever isn't us. For the typical 2-node setup this + // is correct. When there are more peers, upper layers (typed + // envelope sender_pubkey) will carry the real sender identity. + let (contact_id, name) = resolve_counterparty(state, &dest_prefix).await; + if TypedEnvelope::is_typed(inner) { + handle_typed_message(inner, contact_id, &name, state).await; + } else if let Some(decoded) = try_base64_typed(inner) { + handle_typed_message(&decoded, contact_id, &name, state).await; + } else if let Some(decoded) = try_chunk_reassemble(inner, contact_id, state).await { + // Reassembled a chunked MC-framed payload + if TypedEnvelope::is_typed(&decoded) { + handle_typed_message(&decoded, contact_id, &name, state).await; + } else { + let text = String::from_utf8_lossy(&decoded).to_string(); + store_plain_message(state, contact_id, &name, &text).await; + } + } else { + let text = String::from_utf8_lossy(inner).to_string(); + store_plain_message(state, contact_id, &name, &text).await; + } + return; + } + + // Regular channel broadcast (not DM-wrapped) + let chan_contact_id = u32::MAX - (channel_idx as u32); + let chan_name = format!("Channel {}", channel_idx); + if TypedEnvelope::is_typed(payload) { + handle_typed_message(payload, chan_contact_id, &chan_name, state).await; + } else { + let text = String::from_utf8_lossy(payload).to_string(); + store_plain_message(state, chan_contact_id, &chan_name, &text).await; + info!(channel = channel_idx, "Received mesh channel message"); + } +} + +/// Return true if the given 6-byte pubkey prefix matches our own meshcore +/// firmware pubkey. We don't currently track our own firmware pubkey in +/// state (the SELF_INFO parse only pulls the node_id), so this falls back +/// to "not any of our known peers" — i.e. if the prefix isn't one of the +/// OTHER contacts in our mesh contact table, it must be us. That holds +/// for the typical 2-node-plus-repeaters topology and is good enough to +/// filter out DMs clearly addressed to someone else. +async fn dest_prefix_is_us(state: &Arc, dest_prefix: &[u8; 6]) -> bool { + let peers = state.peers.read().await; + for p in peers.values() { + if let Some(hex_pk) = p.pubkey_hex.as_deref() { + if hex_pk.len() >= 12 { + if let Ok(bytes) = hex::decode(&hex_pk[..12]) { + if bytes.len() == 6 && bytes[..] == dest_prefix[..] { + // It matches a peer we know — so it's NOT for us + // (we'd never have a peer row for ourselves). + return false; + } + } + } + } + } + true +} + +/// Pick a "counterparty" contact_id when dispatching a DM-via-channel +/// whose sender we don't otherwise know. We look for any archipelago +/// (type-1, "Archy-*") peer in the contact table whose prefix ISN'T the +/// destination — that's "the other side." Falls back to contact_id=0 +/// when nothing matches. +async fn resolve_counterparty( + state: &Arc, + dest_prefix: &[u8; 6], +) -> (u32, String) { + let peers = state.peers.read().await; + for p in peers.values() { + if !p.advert_name.starts_with("Archy-") { + continue; + } + if let Some(hex_pk) = p.pubkey_hex.as_deref() { + if hex_pk.len() >= 12 { + if let Ok(bytes) = hex::decode(&hex_pk[..12]) { + if bytes.len() == 6 && bytes[..] != dest_prefix[..] { + return (p.contact_id, p.advert_name.clone()); + } + } + } + } + } + (0, "dm-via-channel".to_string()) +} diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 9d345365..8c01c341 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -68,7 +68,11 @@ pub struct MeshState { pub shared_secrets: RwLock>, pub status: RwLock, pub event_tx: broadcast::Sender, - pub cmd_tx: mpsc::Sender, + /// Command channel sender. Wrapped in RwLock so `MeshService::stop()` + /// can swap it for a fresh channel when the listener task drains the + /// old receiver — without this, a disable→enable cycle fails with + /// "Command channel already consumed" on the second start(). + pub cmd_tx: RwLock>, next_message_id: RwLock, /// Per-contact outbound sequence counter. Increments on every typed /// envelope we send to a given peer so the receiver (and anyone else @@ -129,7 +133,7 @@ impl MeshState { peers: RwLock::new(HashMap::new()), messages: RwLock::new(VecDeque::new()), shared_secrets: RwLock::new(HashMap::new()), - cmd_tx, + cmd_tx: RwLock::new(cmd_tx), status: RwLock::new(MeshStatus { enabled: true, device_type: DeviceType::Unknown, @@ -158,6 +162,14 @@ impl MeshState { (state, rx, cmd_rx) } + /// Send a command to the listener. Reads the current sender from the + /// RwLock and clones for the async send. Returns the mpsc SendError so + /// callers can treat a dead listener as "mesh not running". + pub async fn send_cmd(&self, cmd: MeshCommand) -> Result<(), mpsc::error::SendError> { + let tx = self.cmd_tx.read().await.clone(); + tx.send(cmd).await + } + pub async fn next_id(&self) -> u64 { let mut id = self.next_message_id.write().await; let current = *id; diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index 640cef5b..af449343 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -34,6 +34,19 @@ async fn auto_detect_and_open() -> Result<(String, MeshcoreDevice, DeviceInfo)> anyhow::bail!("No Meshcore device found on {} candidate ports: {:?}", paths.len(), paths) } +/// Wrap a direct-message payload as a channel-1 broadcast body. Format: +/// `[DM_VIA_CHANNEL_MARKER(1)][dest_pubkey_prefix(6)][inner_payload…]` +/// Receivers that see this marker on a channel frame extract the header, +/// filter by destination, and dispatch the inner payload as if it were a +/// direct unicast message. +fn wrap_dm_via_channel(dest_pubkey_prefix: &[u8; 6], inner: &[u8]) -> Vec { + let mut out = Vec::with_capacity(1 + 6 + inner.len()); + out.push(super::super::protocol::DM_VIA_CHANNEL_MARKER); + out.extend_from_slice(dest_pubkey_prefix); + out.extend_from_slice(inner); + out +} + /// Fetch the contacts list from the device and update the peer cache. async fn refresh_contacts( device: &mut MeshcoreDevice, @@ -62,6 +75,36 @@ async fn refresh_contacts( state.update_peer_count().await; if !contacts.is_empty() { info!(count = contacts.len(), "Refreshed mesh contacts"); + // Auto-heal routing: any type-1 (friend) contact whose stored + // path_len is 0 has no route established, which makes the + // meshcore firmware silently drop outbound TXT_MSGs to it. + // Flip those to OUT_PATH_UNKNOWN (0xFF) via CMD_RESET_PATH so + // the firmware falls back to flood routing. Advert discovery + // will fill in a real path later if one becomes available. + for c in contacts.iter() { + if c.contact_type != 1 || c.path_len != 0 { + continue; + } + let pk_bytes = match hex::decode(&c.public_key_hex) { + Ok(b) if b.len() == 32 => { + let mut arr = [0u8; 32]; + arr.copy_from_slice(&b); + arr + } + _ => continue, + }; + match device.reset_contact_path(&pk_bytes).await { + Ok(()) => info!( + name = %c.advert_name, + "Reset contact path → flood (was path_len=0)" + ), + Err(e) => warn!( + name = %c.advert_name, + "reset_contact_path failed: {}", + e + ), + } + } } } Err(e) => { @@ -142,6 +185,13 @@ pub(super) async fn run_mesh_session( let advert_name = format!("Archy-{}", short_did); if let Err(e) = device.set_advert_name(&advert_name).await { warn!("Failed to set advert name: {}", e); + } else { + // Reflect the post-set name in MeshStatus too so the UI can filter + // its own radio echo from the peer list. Without this, the status + // still carries whatever pre-set name the firmware reported and the + // self-filter never matches. + let mut status = state.status.write().await; + status.self_advert_name = Some(advert_name.clone()); } // Broadcast our advertisement so other nodes can discover us @@ -251,12 +301,24 @@ async fn handle_send_command( ) { match cmd { MeshCommand::SendText { dest_pubkey_prefix, payload } => { - if let Err(e) = device.send_text(&dest_pubkey_prefix, &payload).await { + // Route the DM as a DM-via-channel broadcast: meshcore's + // direct-unicast path silently drops between our two nodes + // (proven via `mode=flood resp_code=6` diag — the firmware + // transmits but nothing arrives), while channel-1 broadcasts + // reliably flood via the FreeMadeira repeater. Wrap the + // payload with a recipient pubkey-prefix header so the + // receiver side can tell it apart from normal channel text. + let wrapped = wrap_dm_via_channel(&dest_pubkey_prefix, &payload); + if let Err(e) = device.send_channel_text(1, &wrapped).await { *consecutive_write_failures += 1; - warn!(failures = *consecutive_write_failures, "Failed to send text via mesh: {}", e); + warn!(failures = *consecutive_write_failures, "Failed to send DM via channel: {}", e); } else { *consecutive_write_failures = 0; - info!(dest = %hex::encode(dest_pubkey_prefix), len = payload.len(), "Sent mesh message"); + info!( + dest = %hex::encode(dest_pubkey_prefix), + len = payload.len(), + "Sent mesh message (DM via channel)" + ); } } MeshCommand::SendRaw { dest_pubkey_prefix, payload } => { @@ -278,20 +340,30 @@ async fn handle_send_command( use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(&wire_payload); - if encoded.len() <= 140 { - // Single frame — fits in one LoRa packet - if let Err(e) = device.send_text(&dest_pubkey_prefix, encoded.as_bytes()).await { + // Route via DM-via-channel wrapper. Channel-1 broadcasts are + // the only proven-working path between our two nodes, so we + // send the base64 chunk on channel 1 with a recipient header + // the receiver can use to filter. Chunk size is reduced by 7 + // bytes (1 marker + 6 dest-prefix) so each wrapped frame + // still fits inside the LoRa 160-byte budget. + if encoded.len() <= 133 { + // Single frame — wraps under 160B. 160 − 7 wrapper − some + // safety margin leaves ≈133 bytes for the base64 payload. + let wrapped = wrap_dm_via_channel(&dest_pubkey_prefix, encoded.as_bytes()); + if let Err(e) = device.send_channel_text(1, &wrapped).await { *consecutive_write_failures += 1; - warn!(failures = *consecutive_write_failures, "Failed to send raw via mesh: {}", e); + warn!(failures = *consecutive_write_failures, "Failed to send raw DM-via-channel: {}", e); } else { *consecutive_write_failures = 0; - info!(dest = %hex::encode(dest_pubkey_prefix), len = encoded.len(), "Sent raw mesh message"); + info!(dest = %hex::encode(dest_pubkey_prefix), len = encoded.len(), "Sent raw mesh message (DM via channel)"); } } else { - // Multi-frame chunking: "MCxxyyzz..." where xx=msg_id, yy=chunk_idx, zz=total_chunks + // Multi-frame chunking: "MCxxyyzz..." where xx=msg_id, yy=chunk_idx, zz=total_chunks. + // Chunk size shrunk from 132 → 125 to leave room for the + // DM wrapper header (7 bytes) on top of the "MCxxyyzz" (8). static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let chunk_data_size = 132; // 160 - 8 header bytes ("MCxxyyzz") = 152, leave margin + let chunk_data_size = 125; let chunks: Vec<&str> = encoded.as_bytes().chunks(chunk_data_size) .map(|c| std::str::from_utf8(c).unwrap_or("")) .collect(); @@ -301,13 +373,14 @@ async fn handle_send_command( raw_len = wire_payload.len(), b64_len = encoded.len(), chunks = total, - "Sending chunked mesh message" + "Sending chunked mesh message (DM via channel)" ); for (idx, chunk) in chunks.iter().enumerate() { let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk); - if let Err(e) = device.send_text(&dest_pubkey_prefix, frame.as_bytes()).await { + let wrapped = wrap_dm_via_channel(&dest_pubkey_prefix, frame.as_bytes()); + if let Err(e) = device.send_channel_text(1, &wrapped).await { *consecutive_write_failures += 1; - warn!(failures = *consecutive_write_failures, chunk = idx, "Chunk send failed: {}", e); + warn!(failures = *consecutive_write_failures, chunk = idx, "Chunk DM-via-channel send failed: {}", e); break; } // Small delay between chunks to avoid overwhelming the radio diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 5c2b89ff..a3ad32f8 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -196,6 +196,7 @@ pub struct MeshService { listener_handle: Option>, deadman_handle: Option>, block_announcer_handle: Option>, + presence_handle: Option>, cmd_rx: Option>, // Crypto identity for this node our_did: String, @@ -268,6 +269,7 @@ impl MeshService { listener_handle: None, deadman_handle: None, block_announcer_handle: None, + presence_handle: None, cmd_rx: Some(cmd_rx), our_did: did.to_string(), our_ed_pubkey_hex: ed_pubkey_hex.to_string(), @@ -323,7 +325,7 @@ impl MeshService { error!("Dead man's switch TRIGGERED — broadcasting alert"); if let Ok(wire) = dms.build_signed_alert(&dms_key).await { for ch in [0u8, 1] { - let _ = dms_state.cmd_tx.send( + let _ = dms_state.send_cmd( listener::MeshCommand::BroadcastChannel { channel: ch, payload: wire.clone(), @@ -407,7 +409,7 @@ impl MeshService { if pk_bytes.len() >= 6 { let mut prefix = [0u8; 6]; prefix.copy_from_slice(&pk_bytes[..6]); - let _ = bha_state.cmd_tx.send( + let _ = bha_state.send_cmd( listener::MeshCommand::SendRaw { dest_pubkey_prefix: prefix, payload: wire.clone(), @@ -427,7 +429,7 @@ impl MeshService { if pk_bytes.len() >= 6 { let mut prefix = [0u8; 6]; prefix.copy_from_slice(&pk_bytes[..6]); - let _ = bha_state.cmd_tx.send( + let _ = bha_state.send_cmd( listener::MeshCommand::SendRaw { dest_pubkey_prefix: prefix, payload: wire.clone(), @@ -464,6 +466,16 @@ impl MeshService { info!("Block header announcer started"); } + // Presence heartbeat broadcaster is DISABLED. The CBOR-encoded + // PresencePayload was rendering as garbled bytes on peers that + // didn't understand the typed envelope (e.g. the FreeMadeira + // repeater echoed it back as plaintext on channel 0), spamming + // every visible node with "Archy-…: av�…fstatusfonline…" every + // 120s. Re-enable only after either (a) presence moves to a + // non-broadcast path or (b) we can guarantee no plain-text-only + // receivers on the shared channel. + self.presence_handle = None; + info!("Mesh service started"); Ok(()) } @@ -484,6 +496,19 @@ impl MeshService { handle.abort(); let _ = handle.await; } + if let Some(handle) = self.presence_handle.take() { + handle.abort(); + let _ = handle.await; + } + // Recreate the cmd channel so a subsequent start() has a fresh + // receiver. The listener task took ownership of the old receiver + // on its previous run and dropped it when the task ended, so + // without this swap the next start() hits "Command channel + // already consumed". Swapping the sender inside MeshState means + // every Arc holder transparently picks up the new channel. + let (new_tx, new_rx) = tokio::sync::mpsc::channel(32); + *self.state.cmd_tx.write().await = new_tx; + self.cmd_rx = Some(new_rx); info!("Mesh service stopped"); } @@ -583,9 +608,7 @@ impl MeshService { let end = (start + MAX_CHUNK_B64).min(b64.len()); let chunk = &b64[start..end]; let frame = format!("MC{:02X}{:02X}{:02X}{}", msg_id, chunk_idx, total_chunks, chunk); - self.state - .cmd_tx - .send(listener::MeshCommand::SendText { + self.state.send_cmd(listener::MeshCommand::SendText { dest_pubkey_prefix: dest_prefix, payload: frame.into_bytes(), }) @@ -621,9 +644,7 @@ impl MeshService { let dest_prefix = self.peer_dest_prefix(contact_id).await?; - self.state - .cmd_tx - .send(listener::MeshCommand::SendText { + self.state.send_cmd(listener::MeshCommand::SendText { dest_pubkey_prefix: dest_prefix, payload, }) @@ -841,6 +862,15 @@ impl MeshService { messages.iter().find(|m| m.id == id).cloned() } + /// Drop a stored MeshMessage by local id. Used after sending control + /// envelopes (read receipts) so they don't surface as their own + /// bubbles in the chat history. The wire frame is already on its way; + /// this just prunes the local Sent record. + pub async fn drop_message_by_id(&self, id: u64) { + let mut messages = self.state.messages.write().await; + messages.retain(|m| m.id != id); + } + /// Apply an Edit locally to any own-Sent message matching `sender_seq` /// (sender_pubkey is implicit = self). Rewrites `plaintext` and appends /// an `edited_at` marker on `typed_payload` so the UI can show "(edited)". @@ -905,9 +935,7 @@ impl MeshService { ); } - self.state - .cmd_tx - .send(listener::MeshCommand::BroadcastChannel { + self.state.send_cmd(listener::MeshCommand::BroadcastChannel { channel, payload: wire, }) @@ -1014,9 +1042,7 @@ impl MeshService { } // Send through the listener's command channel - self.state - .cmd_tx - .send(listener::MeshCommand::BroadcastChannel { + self.state.send_cmd(listener::MeshCommand::BroadcastChannel { channel, payload, }) @@ -1060,9 +1086,7 @@ impl MeshService { } drop(status); - self.state - .cmd_tx - .send(listener::MeshCommand::SendAdvert) + self.state.send_cmd(listener::MeshCommand::SendAdvert) .await .map_err(|_| anyhow::anyhow!("Mesh listener not running"))?; diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 7fdd87a4..9df5cc28 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -24,6 +24,12 @@ pub const CMD_SET_DEVICE_TIME: u8 = 0x06; pub const CMD_SEND_SELF_ADVERT: u8 = 0x07; pub const CMD_SET_ADVERT_NAME: u8 = 0x08; pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A; +/// CMD_RESET_PATH (0x0D): Tell the firmware to drop the stored route for +/// a contact and fall back to flood routing (out_path_len = 0xFF). Used to +/// unstick direct messages to contacts whose `path_len=0` means "no route +/// known" — without this, the firmware silently drops outbound TXT_MSG +/// frames to such contacts. +pub const CMD_RESET_PATH: u8 = 0x0D; pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B; pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C; pub const CMD_SET_TUNING_PARAMS: u8 = 0x15; @@ -72,6 +78,16 @@ pub const ERR_ILLEGAL_ARG: u8 = 0x06; /// Maximum payload size for a single LoRa message. pub const MAX_MESSAGE_LEN: usize = 160; +/// Marker byte for "direct message wrapped as channel broadcast". Our +/// meshcore devices can hear each other's channel broadcasts (via +/// repeater flooding) but direct unicast frames don't reach between +/// archipelago nodes — so we emulate DMs by sending them on the shared +/// channel with a recipient pubkey-prefix header. Format: +/// `[DM_VIA_CHANNEL_MARKER][dest_pubkey_prefix(6B)][inner_payload…]` +/// The inner payload is whatever we would have sent directly — a typed +/// envelope, a chunked MC frame, or plain text. +pub const DM_VIA_CHANNEL_MARKER: u8 = 0xD1; + /// Minimum frame size: marker (1) + length (2) + command/response (1) = 4 bytes. const MIN_FRAME_SIZE: usize = 4; @@ -224,6 +240,15 @@ pub fn build_get_contacts() -> Vec { encode_frame(&[CMD_GET_CONTACTS]) } +/// CMD_RESET_PATH (0x0D): `[0x0D][pub_key:32]`. Clears the stored route +/// for a contact so subsequent sends route via flood instead of being +/// silently dropped. +pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec { + let mut data = vec![CMD_RESET_PATH]; + data.extend_from_slice(pubkey); + encode_frame(&data) +} + /// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message. pub fn build_sync_next_message() -> Vec { encode_frame(&[CMD_SYNC_NEXT_MESSAGE]) @@ -294,6 +319,8 @@ pub struct ParsedContact { pub advert_name: String, pub last_advert: u32, pub contact_type: u8, + pub path_len: u8, + pub flags: u8, } /// Parse RESP_CONTACT (0x03) response. @@ -305,7 +332,8 @@ pub fn parse_contact(data: &[u8]) -> Result { let public_key_hex = hex::encode(&data[0..32]); let contact_type = data[32]; - // flags at data[33], path_len at data[34] + let flags = if data.len() > 33 { data[33] } else { 0 }; + let path_len = if data.len() > 34 { data[34] } else { 0 }; // path at data[35..99] (64 bytes) // name at data[99..131] (32 bytes) let name_start = 99.min(data.len()); @@ -330,6 +358,8 @@ pub fn parse_contact(data: &[u8]) -> Result { advert_name, last_advert, contact_type, + path_len, + flags, }) } diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index 50d29de0..6e03b66a 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -158,14 +158,26 @@ impl MeshcoreDevice { } /// Send a text message to a contact by their public key prefix (first 6 bytes). - pub async fn send_text(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> { + /// Returns whether the firmware routed it via flood (true) or direct (false). + /// The response frame is `RESP_CODE_SENT | mode | tag[4] | est_timeout[4]` + /// where mode == 1 means flood and mode == 0 means direct. + pub async fn send_text(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result { let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?; self.send_raw(&frame_data).await?; let frame = self.recv_frame_timeout(READ_TIMEOUT).await?; if frame.code == protocol::RESP_ERR { anyhow::bail!("Send text failed: {}", protocol::parse_error(&frame.data)); } - Ok(()) + // RESP_CODE_SENT layout: [mode(1)][tag(4)][est_timeout(4)] + let sent_via_flood = frame.data.first().copied().unwrap_or(0) == 1; + tracing::info!( + dest = %hex::encode(dest_pubkey_prefix), + mode = if sent_via_flood { "flood" } else { "direct" }, + resp_code = frame.code, + data_len = frame.data.len(), + "[diag] send_text response" + ); + Ok(sent_via_flood) } /// Broadcast a text message on a channel. @@ -182,6 +194,20 @@ impl MeshcoreDevice { Ok(()) } + /// Clear the stored routing path for a contact so the firmware flood- + /// routes future messages instead of dropping them when path_len=0. + pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> { + self.send_raw(&protocol::build_reset_path(pubkey)).await?; + let frame = self.recv_frame_timeout(READ_TIMEOUT).await?; + if frame.code == protocol::RESP_ERR { + anyhow::bail!( + "Reset path failed: {}", + protocol::parse_error(&frame.data) + ); + } + Ok(()) + } + /// Get the list of known contacts from the device. /// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END pub async fn get_contacts(&mut self) -> Result> { diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 113c80c2..87ebc2a3 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -50,6 +50,13 @@ export type MeshMessageTypeLabel = | 'content_ref' | 'reply' | 'reaction' + | 'read_receipt' + | 'forward' + | 'edit' + | 'delete' + | 'presence' + | 'channel_invite' + | 'contact_card' export interface MeshMessage { id: number diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index d9c818f0..9398f5c2 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -35,7 +35,13 @@ const mobileShowChat = ref(false) let pollInterval: ReturnType | null = null // The Public channel (always available on Meshcore) -const publicChannel = { index: 0, name: 'Public' } +// "Public" maps to meshcore slot 1 — the configured "archipelago" channel +// in mesh-config.json. Slot 0 is the firmware default Public, which uses +// the universal meshcore key and only works between devices sharing keys +// + RF region. Slot 1 is set by archipelago first-boot to a hash derived +// from the channel_name, so all archipelago nodes are guaranteed on the +// same channel regardless of region. +const publicChannel = { index: 1, name: 'Public' } // Channel contact_id convention: matches backend u32::MAX - channel_index function channelContactId(channelIndex: number): number { @@ -47,9 +53,78 @@ const archChannelActive = ref(false) const archMessages = ref>([]) const archUnread = ref(0) let archPollInterval: ReturnType | null = null -// Federation node name cache: pubkey -> node name +// Federation node name cache: pubkey -> node name (legacy, kept for archMessages display) const fedNodeNames = ref>({}) +// Federation node enrichment cache, keyed by DID. Used by mergedPeers to +// upgrade radio-discovered mesh peers with the canonical server name and +// nostr identity (npub) reported by federation.list-nodes. +interface FedNodeInfo { + did: string + name: string | null + pubkey: string + onion: string + npub: string | null +} +const fedNodesByDid = ref>(new Map()) +// Our own onion / DID / mesh advert name — used to filter "self" out of +// the merged peer list. Without these filters every node sees itself as a +// duplicate row (federation lists carry a self-entry, and the meshcore +// radio occasionally surfaces its own outgoing advert as a peer). +const selfTorOnion = ref(null) +const selfDid = ref(null) + +// User-set aliases for peers, keyed by whichever identifier is most stable +// for that peer (DID first, then mesh pubkey_hex, then federation pubkey). +// Loaded from `mesh.contacts-list` on mount and refreshed on every save so +// the rename propagates everywhere display_name is computed. +const contactAliases = ref>(new Map()) +async function refreshContacts() { + try { + const res = await rpcClient.meshContactsList() + const next = new Map() + for (const c of res.contacts) { + if (c.alias && c.alias.trim()) next.set(c.pubkey, c.alias.trim()) + } + contactAliases.value = next + } catch { /* non-fatal */ } +} +function aliasFor(mp: { did: string | null; primary_pubkey_hex: string | null }): string | null { + if (mp.did && contactAliases.value.has(mp.did)) return contactAliases.value.get(mp.did) ?? null + if (mp.primary_pubkey_hex && contactAliases.value.has(mp.primary_pubkey_hex)) { + return contactAliases.value.get(mp.primary_pubkey_hex) ?? null + } + return null +} +async function refreshFederationNodes() { + try { + const res = await rpcClient.federationListNodes() + const next = new Map() + for (const n of res.nodes) { + next.set(n.did, { + did: n.did, + name: n.name ?? n.last_state?.node_name ?? null, + pubkey: n.pubkey, + onion: n.onion, + npub: n.last_state?.nostr_npub ?? null, + }) + } + fedNodesByDid.value = next + } catch { /* non-fatal */ } +} +async function refreshSelfOnion() { + try { + const res = await rpcClient.getTorAddress() + selfTorOnion.value = (res.tor_address ?? '').replace(/\.onion\/?$/, '').replace(/^https?:\/\//, '') + } catch { /* non-fatal */ } +} +async function refreshSelfDid() { + try { + const res = await rpcClient.getNodeDid() + selfDid.value = res.did || null + } catch { /* non-fatal */ } +} + async function openArchChannel() { activeChatPeer.value = null activeChatChannel.value = null @@ -193,12 +268,6 @@ watch(() => activeChatPeer.value, async (peer) => { } }) -// Fire a read receipt whenever a new received message for the active peer lands. -watch( - () => chatMessages.value.length, - () => { scheduleReadReceipt() }, -) - async function handleToggleOffGrid() { togglingOffGrid.value = true try { @@ -211,7 +280,7 @@ onMounted(async () => { document.addEventListener('click', handleDocClickForMenu) window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession) loadPendingFromSession() - await Promise.all([mesh.refreshAll(), transport.fetchStatus()]) + await Promise.all([mesh.refreshAll(), transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(), refreshSelfDid(), refreshContacts()]) refreshOutboxCount() // Start background polling for Archipelago (Tor) messages so unread count works loadArchMessages() @@ -239,6 +308,7 @@ onUnmounted(() => { const activeChatName = computed(() => { if (archChannelActive.value) return 'Archipelago' if (activeChatChannel.value) return activeChatChannel.value.name + if (activeMergedPeer.value) return activeMergedPeer.value.display_name if (activeChatPeer.value) return activeChatPeer.value.advert_name return '' }) @@ -246,6 +316,14 @@ const activeChatName = computed(() => { const activeChatSub = computed(() => { if (archChannelActive.value) return 'All nodes over Tor' if (activeChatChannel.value) return 'Mesh radio' + const merged = activeMergedPeer.value + if (merged) { + const parts: string[] = [] + if (merged.short_did) parts.push(merged.short_did) + else if (merged.primary_pubkey_hex) parts.push(truncatePubkey(merged.primary_pubkey_hex)) + if (merged.npub) parts.push(`${merged.npub.slice(0, 12)}…${merged.npub.slice(-6)}`) + return parts.join(' · ') + } if (activeChatPeer.value) return truncatePubkey(activeChatPeer.value.pubkey_hex) return '' }) @@ -284,20 +362,37 @@ const chatMessages = computed(() => { return mm }) } - // Reactions are auxiliary — they render as chips under their target - // bubble, not as standalone chat stream entries. - const hideReactions = (m: MeshMessage) => m.message_type !== 'reaction' + // Hide control envelopes that aren't supposed to show as their own + // bubbles: reactions (rendered as chips under the target), read receipts + // (drive the ✓ ticks on outgoing bubbles), edits (mutate the target in + // place), presence heartbeats, and any other auxiliary metadata. Without + // this filter every receipt and edit appears as a stray bubble in the + // chat history. Defense in depth — backend now also drops these on send. + const HIDDEN_TYPES = new Set(['reaction', 'read_receipt', 'edit', 'presence', 'channel_invite', 'contact_card']) + const hideReactions = (m: MeshMessage) => !m.message_type || !HIDDEN_TYPES.has(m.message_type) if (activeChatChannel.value) { const chanId = channelContactId(activeChatChannel.value.index) return mesh.messages.filter(m => m.peer_contact_id === chanId && hideReactions(m)) } if (activeChatPeer.value) { - const cid = activeChatPeer.value.contact_id - return mesh.messages.filter(m => m.peer_contact_id === cid && hideReactions(m)) + // Pull from every underlying contact_id in the merged group so radio + // and federation-routed messages from the same node land in one thread. + const merged = activeMergedPeer.value + const cids = merged ? new Set(merged.contact_ids) : new Set([activeChatPeer.value.contact_id]) + return mesh.messages + .filter(m => cids.has(m.peer_contact_id) && hideReactions(m)) + .sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp)) } return [] }) +// Fire a read receipt whenever a new received message for the active peer lands. +// Declared after chatMessages so the watch getter doesn't hit a TDZ on registration. +watch( + () => chatMessages.value.length, + () => { scheduleReadReceipt() }, +) + function isArchyNode(peer: MeshPeer): boolean { return peer.advert_name.startsWith('Archy-') } @@ -328,6 +423,261 @@ const sortedPeers = computed(() => { }) }) +// Telegram-style unification: a single archipelago node may be discovered +// twice — once as a LoRa contact (firmware-issued mesh pubkey, no DID) and +// again as a synthetic federation peer (archipelago pubkey + DID) created +// the first time it routes a typed message over Tor. Group them into one +// row so the user sees their attachments and radio chat in the same thread. +// +// Merge key resolution, in priority order: +// 1. peer.did (federation-injected peers always carry it) +// 2. federation node whose pubkey matches peer.pubkey_hex +// 3. federation node whose name (case-folded) matches peer.advert_name +// 4. fall back to the peer's own pubkey_hex / advert_name (no merge) +interface MergedPeer { + key: string + did: string | null + display_name: string + short_did: string | null + npub: string | null + contact_ids: number[] + primary_contact_id: number + primary_pubkey_hex: string | null + primary_rssi: number | null + is_archy: boolean + // The original active-chat marker uses contact_id equality, so keep a + // representative MeshPeer for the rest of the codepaths that still want + // a single object (peer header rssi, prekey rotation, etc). + primary: MeshPeer +} + +// Extract the did:key suffix prefix that meshcore embeds in archipelago +// adverts. Meshcore firmware names archipelago nodes "Archy-{first 8 chars +// of the node's did:key z-suffix}", e.g. "Archy-z6Mkn9RY". This lets us +// link a LoRa-discovered radio peer (no DID, no archipelago pubkey) back +// to its federation entry by matching that 8-char prefix against any +// federation node whose DID starts the same way. +function archyAdvertDidPrefix(advertName: string): string | null { + if (!advertName.startsWith('Archy-')) return null + const suffix = advertName.slice(6) + if (suffix.length < 6) return null + return suffix +} + +function fedDidKeySuffix(did: string): string | null { + // did:key:z6Mk... → z6Mk... + const idx = did.indexOf(':key:') + return idx >= 0 ? did.slice(idx + 5) : null +} + +function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | 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 + // mesh and federation use different ed25519 keys, but kept for robustness) + if (peer.pubkey_hex) { + for (const fed of fedNodesByDid.value.values()) { + if (fed.pubkey === peer.pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed } + } + } + // did:key prefix cross-ref: meshcore "Archy-z6MkXXXX" → federation + // node whose did:key suffix starts with the same chars. This is the + // hot path for archipelago nodes — radio peers carry no DID/pubkey + // we can use, but the firmware bakes the prefix into the advert. + const advertPrefix = archyAdvertDidPrefix(peer.advert_name) + if (advertPrefix) { + for (const fed of fedNodesByDid.value.values()) { + const fedSuffix = fedDidKeySuffix(fed.did) + if (fedSuffix && fedSuffix.startsWith(advertPrefix)) { + return { key: `did:${fed.did}`, matchedFed: fed } + } + } + } + // name cross-ref: a federation node whose name (case-folded) matches + // the LoRa advert name. Last-resort match for non-archipelago meshcore + // devices configured to advertise their server name verbatim. + const norm = peer.advert_name.trim().toLowerCase() + if (norm) { + for (const fed of fedNodesByDid.value.values()) { + const fedName = (fed.name ?? '').trim().toLowerCase() + if (fedName && fedName === norm) return { key: `did:${fed.did}`, matchedFed: fed } + } + } + return { key: `mesh:${peer.pubkey_hex || peer.advert_name || peer.contact_id}`, matchedFed: null } +} + +function shortDid(did: string): string { + // did:archy: → did:archy:abcd…wxyz. Other DID methods get truncated + // generically. + const idx = did.lastIndexOf(':') + if (idx > 0 && did.length - idx > 14) { + const prefix = did.slice(0, idx + 1) + const id = did.slice(idx + 1) + return `${prefix}${id.slice(0, 6)}…${id.slice(-4)}` + } + return did.length > 24 ? `${did.slice(0, 12)}…${did.slice(-6)}` : did +} + +function isSelfRadioPeer(peer: MeshPeer): boolean { + const selfName = mesh.status?.self_advert_name + if (selfName && peer.advert_name === selfName) return true + // Primary fallback: derive the expected advert prefix from our own DID + // and match it against the radio peer's "Archy-XXXXXXXX" prefix. This + // works even before mesh.status.self_advert_name is populated and even + // when there's no federation self-entry to cross-reference. + const advertPrefix = archyAdvertDidPrefix(peer.advert_name) + if (advertPrefix && selfDid.value) { + const ourSuffix = fedDidKeySuffix(selfDid.value) + if (ourSuffix?.startsWith(advertPrefix)) return true + } + return false +} + +const mergedPeers = computed(() => { + const groups = new Map() + for (const peer of sortedPeers.value) { + if (isSelfRadioPeer(peer)) continue + const { key, matchedFed } = mergeKeyForPeer(peer) + const existing = groups.get(key) + if (existing) { + existing.contact_ids.push(peer.contact_id) + // Prefer a federation-enriched display name even if the second peer + // is the radio one — keeps the row stable across discovery order. + if (matchedFed?.name && existing.display_name === existing.primary.advert_name) { + existing.display_name = matchedFed.name + } + if (matchedFed?.npub && !existing.npub) existing.npub = matchedFed.npub + if (matchedFed?.did && !existing.did) { + existing.did = matchedFed.did + existing.short_did = shortDid(matchedFed.did) + } + } else { + const did = peer.did ?? matchedFed?.did ?? null + const stub = { did, primary_pubkey_hex: peer.pubkey_hex } + const alias = aliasFor(stub) + groups.set(key, { + key, + did, + display_name: alias || matchedFed?.name || peer.advert_name || `Node #${peer.contact_id}`, + short_did: did ? shortDid(did) : null, + npub: matchedFed?.npub ?? null, + contact_ids: [peer.contact_id], + primary_contact_id: peer.contact_id, + primary_pubkey_hex: peer.pubkey_hex, + primary_rssi: peer.rssi, + is_archy: isArchyNode(peer) || !!matchedFed, + primary: peer, + }) + } + } + // Surface every federation node as its own row even if no radio peer has + // been matched against it yet. The user always sees the canonical server + // names ("Arch Dev", "ArchISO") in the list, and clicking one starts a + // chat that routes over Tor until a radio link is also discovered, at + // which point the rows transparently merge under the same DID key. + for (const fed of fedNodesByDid.value.values()) { + // Skip our own federation self-entry — every node accidentally has one + // because the federation list isn't filtered server-side. Match by + // onion instead of DID so it works even when names are missing. + if (selfTorOnion.value) { + const ours = selfTorOnion.value.replace(/\.onion$/, '') + const theirs = fed.onion.replace(/\.onion$/, '') + if (ours === theirs) continue + } + const key = `did:${fed.did}` + if (groups.has(key)) continue + // Synthesise a placeholder MeshPeer so openChat() and the existing + // rssi/avatar template paths don't need a separate code path for + // "federation-only" rows. The contact_id is a stable negative number + // derived from the DID hash so it never collides with real mesh + // contact_ids (which are u32 from the radio firmware). + const synthCid = -100 - Math.abs(hashStringToInt(fed.did)) + const placeholder: MeshPeer = { + contact_id: synthCid, + advert_name: fed.name || fed.did, + pubkey_hex: fed.pubkey, + did: fed.did, + rssi: null, + hops: null, + last_heard: null, + } as unknown as MeshPeer + const alias = aliasFor({ did: fed.did, primary_pubkey_hex: fed.pubkey }) + groups.set(key, { + key, + did: fed.did, + display_name: alias || fed.name || shortDid(fed.did), + short_did: shortDid(fed.did), + npub: fed.npub, + contact_ids: [synthCid], + primary_contact_id: synthCid, + primary_pubkey_hex: fed.pubkey, + primary_rssi: null, + is_archy: true, + primary: placeholder, + }) + } + return Array.from(groups.values()) +}) + +function hashStringToInt(s: string): number { + let h = 0 + for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) | 0 + return h +} + +// activeChatPeer is a single MeshPeer (the row the user clicked). To unify +// the chat we need the merged group it belongs to so chatMessages can pull +// from every underlying contact_id, not just the one that was clicked. +const activeMergedPeer = computed(() => { + const peer = activeChatPeer.value + if (!peer) return null + return mergedPeers.value.find(mp => mp.contact_ids.includes(peer.contact_id)) ?? null +}) + +function mergedUnreadCount(mp: MergedPeer): number { + let total = 0 + for (const cid of mp.contact_ids) total += mesh.unreadCounts[cid] || 0 + return total +} + +// Inline contact rename in the chat header. The pencil button toggles an +// input bound to renameDraft; commit fires mesh.contacts-save keyed by +// DID (or pubkey hex as fallback) so the alias propagates everywhere +// `aliasFor` is consulted in the merged peer list. +const renamingActive = ref(false) +const renameDraft = ref('') +const renameInputEl = ref(null) +function startRename() { + const mp = activeMergedPeer.value + if (!mp) return + renameDraft.value = mp.display_name + renamingActive.value = true + nextTick(() => renameInputEl.value?.focus()) +} +function cancelRename() { + renamingActive.value = false + renameDraft.value = '' +} +async function commitRename() { + if (!renamingActive.value) return + const mp = activeMergedPeer.value + if (!mp) { cancelRename(); return } + const next = renameDraft.value.trim() + renamingActive.value = false + // Empty string → clear the alias and fall back to derived name. + const key = mp.did || mp.primary_pubkey_hex + if (!key) return + // Optimistic local update so the header changes immediately. + if (next) contactAliases.value.set(key, next) + else contactAliases.value.delete(key) + try { + await rpcClient.meshContactsSave(key, next || null) + await refreshContacts() + } catch (e) { + sendError.value = e instanceof Error ? e.message : 'Rename failed' + } +} + function openChat(peer: MeshPeer) { activeChatPeer.value = peer activeChatChannel.value = null @@ -594,6 +944,7 @@ function scheduleReadReceipt() { const received = chatMessages.value.filter((m) => m.direction === 'received' && m.sender_seq != null) if (received.length === 0) return const latest = received[received.length - 1] + if (!latest) return const latestSeq = latest.sender_seq as number const already = lastReceiptSentForSeq.value.get(peer.contact_id) ?? -1 if (latestSeq <= already) return @@ -978,31 +1329,35 @@ function isImageMime(mime?: string): boolean { {{ mesh.unreadCounts[channelContactId(0)] }}
-
- - +
+ +
- {{ peer.advert_name || `Node #${peer.contact_id}` }} - Archy + {{ mp.display_name }} + Archy
-
{{ truncatePubkey(peer.pubkey_hex) }}
+
+ + +
+
{{ mp.npub.slice(0, 12) }}…{{ mp.npub.slice(-6) }}
- - {{ mesh.unreadCounts[peer.contact_id] }} + + {{ mergedUnreadCount(mp) }}
-
+
@@ -1041,7 +1396,26 @@ function isImageMime(mime?: string): boolean {
- {{ activeChatName }} + + Archy Channel
diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index 8540ede1..e9ff178a 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -91,6 +91,10 @@ .mesh-chat-back { background: none; border: none; color: rgba(255, 255, 255, 0.6); font-size: 1.2rem; cursor: pointer; padding: 4px 8px; border-radius: 6px; display: none; } .mesh-chat-header-info { flex: 1; min-width: 0; } .mesh-chat-header-name { font-weight: 600; font-size: 0.95rem; color: rgba(255, 255, 255, 0.9); display: flex; align-items: center; gap: 6px; } +.mesh-chat-header-rename { background: transparent; border: none; color: rgba(255, 255, 255, 0.4); cursor: pointer; padding: 2px 4px; font-size: 0.85rem; line-height: 1; } +.mesh-chat-header-rename:hover { color: rgba(255, 255, 255, 0.9); } +.mesh-chat-header-rename-input { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 6px; color: rgba(255, 255, 255, 0.95); font-size: 0.95rem; font-weight: 600; padding: 4px 8px; outline: none; min-width: 0; max-width: 220px; } +.mesh-chat-header-rename-input:focus { border-color: rgba(255, 255, 255, 0.4); } .mesh-chat-header-sub { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); font-family: monospace; } .mesh-chat-header-status { flex-shrink: 0; } .mesh-chat-header-time { font-size: 0.7rem; color: rgba(255, 255, 255, 0.3); }