diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs
index 48cbb314..325b32d1 100644
--- a/core/archipelago/src/mesh/mod.rs
+++ b/core/archipelago/src/mesh/mod.rs
@@ -1153,7 +1153,32 @@ impl MeshService {
let peer = peers
.get(&contact_id)
.ok_or_else(|| anyhow::anyhow!("Peer not found"))?;
- let pubkey_hex = peer
+ // Cross-transport twin resolution: callers frequently hold the
+ // FEDERATION twin's contact_id (the UI's merged conversation row),
+ // whose pubkey_hex is the Archipelago ed25519 key — NOT a radio
+ // routing key. Sending a Reticulum resource with that prefix fails
+ // with "Unknown Reticulum prefix" (observed live 2026-07-28,
+ // image-over-LoRa to a merged contact). Route via the radio twin —
+ // same arch identity, radio-range id — whose pubkey_hex is the
+ // actual over-the-air routing key (RNS dest hash / firmware key).
+ let radio_peer = if peer.contact_id >= FEDERATION_CONTACT_ID_BASE {
+ peer.arch_pubkey_hex
+ .as_deref()
+ .and_then(|arch| {
+ peers.values().find(|p| {
+ p.contact_id < FEDERATION_CONTACT_ID_BASE
+ && p.arch_pubkey_hex.as_deref() == Some(arch)
+ })
+ })
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "Peer is federation-only (no radio twin) — not reachable over the radio"
+ )
+ })?
+ } else {
+ peer
+ };
+ let pubkey_hex = radio_peer
.pubkey_hex
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Peer has no public key"))?;
@@ -1291,12 +1316,44 @@ impl MeshService {
.map(|p| !p.reachable && p.arch_pubkey_hex.is_some())
.unwrap_or(false)
};
+ // Transport policy: LoRa first when it can actually carry the message,
+ // then FIPS, then Tor. A federation-synthetic id (what the UI's merged
+ // conversation holds) used to ALWAYS take the federation path, even
+ // when the very same node was sitting one LoRa hop away — so chats
+ // between two radio-equipped nodes silently rode FIPS/Tor. If the
+ // federation contact has a REACHABLE radio twin (same archipelago
+ // identity, radio-range id) and the payload fits the radio, skip the
+ // federation branch: the fall-through LoRa path twin-resolves the
+ // routing key via peer_dest_prefix.
+ let device_connected = self.state.status.read().await.device_connected;
+ let radio_twin_reachable = is_federation_synthetic && !exceeds_lora && device_connected && {
+ let peers = self.state.peers.read().await;
+ peers
+ .get(&contact_id)
+ .and_then(|p| p.arch_pubkey_hex.clone())
+ .map(|arch| {
+ peers.values().any(|p| {
+ p.contact_id < FEDERATION_CONTACT_ID_BASE
+ && p.reachable
+ && p.arch_pubkey_hex.as_deref() == Some(arch.as_str())
+ })
+ })
+ .unwrap_or(false)
+ };
let mesh_only_mode = load_config(&self.data_dir)
.await
.ok()
.and_then(|cfg| cfg.mesh_only_mode)
.unwrap_or(false);
+ if radio_twin_reachable && !mesh_only_mode {
+ tracing::info!(
+ contact_id,
+ bytes = wire.len(),
+ "Radio-first routing: federation contact has a reachable radio twin — sending over LoRa"
+ );
+ }
if !mesh_only_mode
+ && !radio_twin_reachable
&& (is_federation_synthetic || exceeds_lora || radio_federated_unreachable)
{
// Resolve the peer's pubkey/did. Prefer the live mesh peer table,
diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue
index 91bcec7a..69a2ee4f 100644
--- a/neode-ui/src/views/Mesh.vue
+++ b/neode-ui/src/views/Mesh.vue
@@ -2194,8 +2194,14 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
+ >
+
+
diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css
index e792582a..3f0968b5 100644
--- a/neode-ui/src/views/mesh/mesh-styles.css
+++ b/neode-ui/src/views/mesh/mesh-styles.css
@@ -374,13 +374,35 @@
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
.mesh-typed-content-image-wrap { position: relative; display: inline-block; }
.mesh-typed-content-download-btn {
- position: absolute; bottom: 6px; right: 6px; width: 1.75rem; height: 1.75rem;
- border-radius: 50%; border: 1px solid rgba(255,255,255,0.15);
- background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.85); font-size: 0.85rem;
+ position: absolute; bottom: 8px; right: 8px;
+ width: 2.25rem; height: 2.25rem; min-width: 2.25rem; flex-shrink: 0;
+ border-radius: 50%; border: 1px solid rgba(255,255,255,0.18);
+ background: rgba(10,10,14,0.55); color: rgba(255,255,255,0.9);
display: flex; align-items: center; justify-content: center; cursor: pointer;
- backdrop-filter: blur(6px);
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
+ box-shadow: 0 2px 8px rgba(0,0,0,0.35);
+ transition: background 0.15s ease, transform 0.15s ease;
}
-.mesh-typed-content-download-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
+.mesh-typed-content-download-btn svg { width: 1.05rem; height: 1.05rem; }
+.mesh-typed-content-download-btn:hover { background: rgba(251,146,60,0.35); color: #fff; transform: scale(1.06); }
+.mesh-typed-content-download-btn:active { transform: scale(0.96); }
+/* Pre-fetch "Download" pill under an incoming attachment. The generic .btn it
+ replaced collapsed to its text width inside the narrow mobile bubble and
+ looked squashed — this is a full-width glass pill in the house style. */
+.mesh-typed-content-fetch-btn {
+ display: flex; align-items: center; justify-content: center; gap: 7px;
+ width: 100%; min-height: 2.4rem; padding: 8px 14px; margin-top: 2px;
+ border-radius: 12px; border: 1px solid rgba(255,255,255,0.14);
+ background: rgba(255,255,255,0.07); color: rgba(255,255,255,0.9);
+ font-size: 0.82rem; font-weight: 500; cursor: pointer; white-space: nowrap;
+ backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px);
+ transition: background 0.15s ease, border-color 0.15s ease;
+}
+.mesh-typed-content-fetch-btn svg { width: 1rem; height: 1rem; flex-shrink: 0; }
+.mesh-typed-content-fetch-btn:hover:not(:disabled) {
+ background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.4); color: #fff;
+}
+.mesh-typed-content-fetch-btn:disabled { opacity: 0.6; cursor: default; }
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }