From 0aa3941c40b2fced685e7d6ad23de2363d80b50b Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 28 Jul 2026 21:01:36 -0400 Subject: [PATCH] =?UTF-8?q?feat(ui):=20mesh=20chat=20polish=20=E2=80=94=20?= =?UTF-8?q?transport=20pills=20in=20image=20modal,=20hop-route=20modal,=20?= =?UTF-8?q?reaction=20dropdown,=20real=20read-tracking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../src/api/rpc/mesh/typed_messages.rs | 21 +- neode-ui/src/stores/mesh.ts | 30 ++- neode-ui/src/views/Mesh.vue | 213 ++++++++++++++++-- neode-ui/src/views/mesh/mesh-styles.css | 24 ++ 4 files changed, 252 insertions(+), 36 deletions(-) diff --git a/core/archipelago/src/api/rpc/mesh/typed_messages.rs b/core/archipelago/src/api/rpc/mesh/typed_messages.rs index 85577b07..639d0dcc 100644 --- a/core/archipelago/src/api/rpc/mesh/typed_messages.rs +++ b/core/archipelago/src/api/rpc/mesh/typed_messages.rs @@ -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, diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index a7c11b5c..d32e06ce 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -264,8 +264,14 @@ export const useMeshStore = defineStore('mesh', () => { // Track unread message counts per peer (contact_id -> count) const unreadCounts = ref>({}) - // Currently viewing chat for this contact_id (clears unread) - const viewingChatId = ref(null) + // Contact ids of the chat currently on screen — ALL twins of the merged + // conversation, not just the clicked row's id, since the unread badge sums + // across every underlying contact_id (a message can land on any twin). + const viewingChatIds = ref([]) + // Whether the open chat is scrolled to (near) the bottom. New messages only + // auto-clear as read while the latest messages are actually in view — + // standard chat-app semantics; scrolled-up-in-history still counts unread. + const viewingAtBottom = ref(true) // Total unread count for nav badge const totalUnread = computed(() => Object.values(unreadCounts.value).reduce((a, b) => a + b, 0) @@ -401,8 +407,11 @@ export const useMeshStore = defineStore('mesh', () => { m => m.direction === 'received' && !messages.value.some(existing => existing.id === m.id) ) for (const msg of newMsgs) { - // Don't count as unread if we're currently viewing that chat - if (msg.peer_contact_id !== viewingChatId.value) { + // Don't count as unread if we're currently viewing that chat AND the + // bottom (latest messages) is in view — i.e. the user actually sees it. + const seenLive = + viewingChatIds.value.includes(msg.peer_contact_id) && viewingAtBottom.value + if (!seenLive) { unreadCounts.value[msg.peer_contact_id] = (unreadCounts.value[msg.peer_contact_id] || 0) + 1 } } @@ -530,13 +539,15 @@ export const useMeshStore = defineStore('mesh', () => { federatedPositions.value = next } - function markChatRead(contactId: number) { - viewingChatId.value = contactId - delete unreadCounts.value[contactId] + function markChatRead(contactId: number | number[]) { + const ids = Array.isArray(contactId) ? contactId : [contactId] + viewingChatIds.value = ids + for (const id of ids) delete unreadCounts.value[id] } function clearViewingChat() { - viewingChatId.value = null + viewingChatIds.value = [] + viewingAtBottom.value = true } async function sendMessage(contactId: number, message: string) { @@ -681,6 +692,8 @@ export const useMeshStore = defineStore('mesh', () => { tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible' est_seconds: number has_tor: boolean + has_fips: boolean + last_transport: string | null reason: string size: number mesh_auto_max: number @@ -999,6 +1012,7 @@ export const useMeshStore = defineStore('mesh', () => { error, sending, unreadCounts, + viewingAtBottom, totalUnread, nodePositions, federatedPositions, diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 69a2ee4f..05d927eb 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -896,8 +896,14 @@ function openChat(peer: MeshPeer) { messageText.value = '' activeTab.value = 'chat' mobileShowChat.value = true - mesh.markChatRead(peer.contact_id) - nextTick(() => scrollChatToBottom()) + nextTick(() => { + // Clear unread only after the chat has rendered and auto-scrolled to the + // latest messages ("you've actually seen them"), and clear ALL twins of + // the merged conversation — the badge sums across every contact_id, so + // clearing just the clicked row's id left the badge stuck. + scrollChatToBottom() + mesh.markChatRead(activeMergedPeer.value?.contact_ids ?? peer.contact_id) + }) } function openChannelChat(channel: { index: number; name: string }) { @@ -1009,9 +1015,25 @@ async function handleSendMessage() { function scrollChatToBottom() { if (chatScrollEl.value) { chatScrollEl.value.scrollTop = chatScrollEl.value.scrollHeight + mesh.viewingAtBottom = true } } +// Scroll-position read tracking: while the latest messages are in view the +// open conversation stays "read" (new arrivals don't badge); scrolled up into +// history, incoming messages accumulate unread like any normal chat app — +// scrolling back down clears them. +function onChatScroll() { + const el = chatScrollEl.value + if (!el) return + const nearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 48 + mesh.viewingAtBottom = nearBottom + if (nearBottom && activeMergedPeer.value) { + mesh.markChatRead(activeMergedPeer.value.contact_ids) + } + scheduleReadReceipt() +} + // Keep the compose field focused after a send so the user can keep typing // without re-clicking it (Enter re-focuses natively, but the Send button // click otherwise leaves focus on the button). @@ -1136,14 +1158,15 @@ interface PendingReply { } const pendingReply = ref(null) const actionMenuForId = ref(null) -const QUICK_REACTIONS = ['👍', '❤️', '😂', '😮', '😢', '🙏'] function openActionMenu(msgId: number, ev?: Event) { ev?.stopPropagation() actionMenuForId.value = actionMenuForId.value === msgId ? null : msgId + reactionPickerForId.value = null } function closeActionMenu() { actionMenuForId.value = null + reactionPickerForId.value = null } function handleDocClickForMenu(ev: MouseEvent) { if (actionMenuForId.value === null) return @@ -1253,6 +1276,43 @@ function scheduleReadReceipt() { }, 400) } +// Hop visualization modal — click a message's transport pill to see how it +// traveled: radio hops + signal quality for LoRa transports, overlay/circuit +// shape for FIPS/Tor. Signal numbers are the PEER's current values (per-link, +// not stored per-message) — labelled as such in the modal. +const hopVizMsg = ref(null) +const hopVizPeer = computed(() => { + const m = hopVizMsg.value + if (!m) return null + return mesh.peers.find(p => p.contact_id === m.peer_contact_id) ?? activeChatPeer.value +}) +function hopVizHops(): number | null { + const p = hopVizPeer.value + if (!p || p.hops == null || p.hops === 0xff) return null + return p.hops +} +function signalQualityLabel(snr: number | null, rssi: number | null): string { + if (snr == null && rssi == null) return 'signal unknown' + if (snr != null) { + if (snr > 5) return 'excellent signal' + if (snr > 0) return 'good signal' + if (snr > -10) return 'fair signal' + return 'weak signal' + } + if (rssi != null) { + if (rssi > -80) return 'excellent signal' + if (rssi > -100) return 'good signal' + if (rssi > -115) return 'fair signal' + return 'weak signal' + } + return 'signal unknown' +} + +// Reaction dropdown: the quick-reaction row lives behind a "React" toggle so +// the action menu stays compact and a larger emoji palette fits. +const reactionPickerForId = ref(null) +const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡'] + const reactionInFlight = ref(null) // `${msgId}:${emoji}` while RPC is running async function reactTo(msg: MeshMessage, emoji: string) { const key = messageKeyFor(msg) @@ -1440,6 +1500,14 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') { const imageQualityChoice = ref<{ file: File } | null>(null) const imageQualityEstimates = ref>(new Map()) let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null +// Transport pills at the bottom of the image modal: when the peer is also a +// federation node (FIPS overlay and/or Tor), the user can send the image that +// way instead of over LoRa. 'lora' keeps the existing radio flow; 'fips'/'tor' +// route via the blob/content-ref path (the dial layer prefers FIPS with Tor +// fallback — the pills are capability labels, defaulted to whichever of the +// two actually worked last for this peer). +const imageSendTransport = ref<'lora' | 'fips' | 'tor'>('lora') +const imageTransportOptions = ref<{ fips: boolean; tor: boolean }>({ fips: false, tor: false }) function formatEstSeconds(seconds: number): string { if (seconds < 60) return `~${seconds}s` @@ -1449,6 +1517,8 @@ function formatEstSeconds(seconds: number): string { async function openImageQualityDialog(file: File, peerContactId: number): Promise { imageQualityChoice.value = { file } imageQualityEstimates.value = new Map() + imageSendTransport.value = 'lora' + imageTransportOptions.value = { fips: false, tor: false } // Fire off estimates for all presets in parallel — each preset's nominal // target size (or the real file size for 'original') against the SAME // mesh.transport-advice RPC the non-image attach flow already uses. @@ -1460,6 +1530,7 @@ async function openImageQualityDialog(file: File, peerContactId: number): Promis const label = advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds) imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label) + imageTransportOptions.value = { fips: advice.has_fips, tor: advice.has_tor } } catch { imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?') } @@ -1588,12 +1659,18 @@ async function handleAttachFile(ev: Event) { attaching.value = true attachError.value = null try { + let forcedTransport: 'fips' | 'tor' | null = null if (file.type.startsWith('image/')) { const preset = await openImageQualityDialog(file, peer.contact_id) if (!preset) return // user cancelled + if (imageSendTransport.value !== 'lora') forcedTransport = imageSendTransport.value file = await compressImage(file, preset) } - if (!(await sendFileViaBestTransport(file, peer))) return + if (forcedTransport) { + // User picked a FIPS/Tor pill in the image modal — skip the LoRa + // advice/chooser flow entirely and ship via the content-ref path. + await sendViaTorContentRef(file, peer.contact_id, peer.advert_name) + } else if (!(await sendFileViaBestTransport(file, peer))) return messageText.value = '' nextTick(() => scrollChatToBottom()) } catch (e) { @@ -1889,9 +1966,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { > {{ transport.meshOnly ? 'Go Online' : 'Off-Grid' }} - @@ -2082,7 +2161,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { {{ timeAgo(activeChatPeer.last_heard) }} -
+
No messages yet. Say hello!
@@ -2252,7 +2331,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
{{ msg.plaintext }}
- {{ transportLabel(msg) }} + {{ transportLabel(msg) }} E2E (edited) ✓✓ @@ -2283,17 +2362,25 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { + @click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id" + >😀 React ▾ +
+ +
@@ -2432,7 +2519,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { + (inline-chunked) and Tor. User picks which path to send it over. + Teleported to body so the fixed backdrop covers the FULL viewport — + rendered in place it sits inside a transformed/filtered glass panel, + which traps position:fixed to just the right chat panel. --> +

📎 How should I send this?

@@ -2461,10 +2552,13 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
+
+ from the same mesh.transport-advice RPC the file-attach flow uses. + Teleported to body — see the transport chooser above. --> +

🖼️ Choose Image Quality

@@ -2481,12 +2575,91 @@ async function downloadAttachment(payload: MeshAttachmentPayload) { > 🖼️ {{ preset.displayName }} — {{ preset.description }} - {{ imageQualityEstimates.get(preset.key) ?? '…' }} + {{ + imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant' + }}
+ +
+ Send via + + + +
+
+ + + +
+
+

{{ transportLabel(hopVizMsg) }} route

+

+ {{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }} +

+
+
+ 🏝️ + {{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }} +
+ + + +
+ 🏝️ + {{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }} +
+
+
+ {{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }} + SNR {{ hopVizPeer.snr.toFixed(1) }} dB + RSSI {{ hopVizPeer.rssi }} dBm +

Signal values are the current link readings for this peer, not a snapshot from this message.

+
+
+ E2E + {{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }} + {{ timeAgo(hopVizMsg.timestamp) }} +
+ +
+
+