feat(ui): mesh chat polish — transport pills in image modal, hop-route modal, reaction dropdown, real read-tracking
Demo images / Build & push demo images (push) Failing after 3m29s

- Image quality modal: 'Send via' pills (LoRa / FIPS / Tor) when the
  peer is federation-reachable — mesh.transport-advice now returns
  has_fips + last_transport alongside has_tor. Picking FIPS/Tor routes
  the image over the content-ref path instead of the radio.
- Attachment modals (transport chooser, image quality, new hop modal)
  Teleport to body so the backdrop dims the FULL viewport — rendered
  in-place they sat inside a transformed glass panel that trapped
  position:fixed to the right chat panel.
- Click a message's transport pill → route modal: radio hops + live
  SNR/RSSI quality for LoRa transports, overlay/circuit shape for
  FIPS/Tor, delivery + E2E state.
- Reactions move behind a compact 'React ▾' dropdown with a larger
  12-emoji palette.
- Unread badges now clear like a normal chat app: opening a contact
  clears ALL twins of the merged conversation (badge sums every
  contact_id — clearing just the clicked one left it stuck), and only
  once the chat has scrolled to the latest messages; scrolled up into
  history, new arrivals accumulate until you scroll back down.
- Refresh button shows only the spinner while refreshing (text+spinner
  overflowed the fixed button width).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-28 21:01:36 -04:00
co-authored by Claude Fable 5
parent e62f911810
commit 0aa3941c40
4 changed files with 252 additions and 36 deletions
@@ -575,14 +575,17 @@ impl RpcHandler {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let has_tor = peer_pubkey_hex
.as_ref()
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
.unwrap_or(false)
|| peer_did
.as_ref()
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let fed_node = nodes.iter().find(|n| {
peer_pubkey_hex.as_deref() == Some(n.pubkey.as_str())
|| peer_did.as_deref() == Some(n.did.as_str())
});
let has_tor = fed_node.is_some();
// Distinct FIPS capability so the frontend can offer FIPS as its own
// pill (not just a generic "Tor") — the dial layer still picks
// FIPS-first with Tor fallback on the actual send; these are honest
// capability labels, with `last_transport` saying what worked last.
let has_fips = fed_node.is_some_and(|n| n.fips_npub.is_some());
let last_transport = fed_node.and_then(|n| n.last_transport.clone());
let est_seconds = (size.saturating_add(lora_bytes_per_sec - 1) / lora_bytes_per_sec).max(1);
@@ -618,6 +621,8 @@ impl RpcHandler {
"tier": tier,
"est_seconds": est_seconds,
"has_tor": has_tor,
"has_fips": has_fips,
"last_transport": last_transport,
"reason": reason,
"size": size,
"mesh_auto_max": MESH_AUTO_MAX,