feat(ui): mesh chat polish — transport pills in image modal, hop-route modal, reaction dropdown, real read-tracking
Some checks failed
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
parent e62f911810
commit 0aa3941c40
4 changed files with 252 additions and 36 deletions

View File

@ -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,

View File

@ -264,8 +264,14 @@ export const useMeshStore = defineStore('mesh', () => {
// Track unread message counts per peer (contact_id -> count)
const unreadCounts = ref<Record<number, number>>({})
// Currently viewing chat for this contact_id (clears unread)
const viewingChatId = ref<number | null>(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<number[]>([])
// 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,

View File

@ -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<PendingReply | null>(null)
const actionMenuForId = ref<number | null>(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<MeshMessage | null>(null)
const hopVizPeer = computed<MeshPeer | null>(() => {
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<number | null>(null)
const REACTION_PALETTE = ['👍', '❤️', '😂', '😮', '😢', '🙏', '🔥', '🎉', '💯', '👀', '😡', '🫡']
const reactionInFlight = ref<string | null>(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<Map<string, string>>(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<ImageCompressionPreset | null> {
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' }}
</button>
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh">
<!-- While refreshing show ONLY the spinner spinner + "Refreshing…"
together overflow the button's fixed width. -->
<button class="glass-button mesh-action-btn" :disabled="refreshing" @click="handleRefresh" :title="refreshing ? 'Refreshing…' : 'Re-query the radio and reload peers, contacts and federation nodes'">
<span v-if="refreshing" class="mesh-refresh-spinner" aria-hidden="true"></span>
{{ refreshing ? 'Refreshing…' : 'Refresh' }}
<template v-if="!refreshing">Refresh</template>
</button>
</div>
@ -2082,7 +2161,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<span v-if="activeChatPeer" class="mesh-chat-header-time">{{ timeAgo(activeChatPeer.last_heard) }}</span>
</div>
</div>
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="scheduleReadReceipt" @wheel.stop.prevent="onChatWheel">
<div ref="chatScrollEl" class="mesh-chat-messages" @scroll="onChatScroll" @wheel.stop.prevent="onChatWheel">
<div v-if="chatMessages.length === 0" class="mesh-chat-no-messages">
No messages yet. Say hello!
</div>
@ -2252,7 +2331,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Default: plain text -->
<div v-else class="mesh-chat-bubble-text">{{ msg.plaintext }}</div>
<div class="mesh-chat-bubble-meta">
<span v-if="transportLabel(msg)" class="mesh-chat-transport" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg)">{{ transportLabel(msg) }}</span>
<span v-if="transportLabel(msg)" class="mesh-chat-transport mesh-chat-transport-clickable" :class="'transport-' + msg.transport" :title="'Delivered over ' + transportLabel(msg) + ' — click for route details'" @click.stop="hopVizMsg = msg">{{ transportLabel(msg) }}</span>
<span v-if="msg.encrypted" class="mesh-chat-e2e">E2E</span>
<span v-if="isEditedMessage(msg) !== null" class="mesh-chat-edited">(edited)</span>
<span v-if="msg.delivered && msg.direction === 'sent'" class="mesh-chat-ack">&#x2713;&#x2713;</span>
@ -2283,17 +2362,25 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="startEditOf(msg)">Edit</button>
<button v-if="msg.direction === 'sent'" class="mesh-chat-action-btn mesh-chat-action-danger" :disabled="reactionInFlight !== null" @click="deleteOwnMessage(msg)">Delete</button>
<button
v-for="emoji in QUICK_REACTIONS"
:key="emoji"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
class="mesh-chat-action-btn"
:class="{ active: reactionPickerForId === msg.id }"
:disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)"
>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
@click="reactionPickerForId = reactionPickerForId === msg.id ? null : msg.id"
>😀 React </button>
<button class="mesh-chat-action-btn" :disabled="reactionInFlight !== null" @click="closeActionMenu"></button>
<div v-if="reactionPickerForId === msg.id" class="mesh-chat-reaction-dropdown">
<button
v-for="emoji in REACTION_PALETTE"
:key="emoji"
class="mesh-chat-reaction-btn"
:class="{ 'is-busy': reactionInFlight === `${msg.id}:${emoji}` }"
:disabled="reactionInFlight !== null"
@click="reactTo(msg, emoji)"
>
<span v-if="reactionInFlight === `${msg.id}:${emoji}`" class="mesh-spinner" aria-hidden="true"></span>
<span v-else>{{ emoji }}</span>
</button>
</div>
</div>
</div>
</div>
@ -2432,7 +2519,11 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
</Teleport>
<!-- Transport chooser modal: shown when attachment size fits both mesh
(inline-chunked) and Tor. User picks which path to send it over. -->
(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. -->
<Teleport to="body">
<div v-if="transportChoice" class="mesh-transport-modal-backdrop" @click.self="pickTransport('cancel')">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">📎 How should I send this?</h3>
@ -2461,10 +2552,13 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
</div>
</div>
</Teleport>
<!-- Image quality-picker modal: shown before sending an image attachment.
Each preset shows its nominal size target + a transfer-time estimate
from the same mesh.transport-advice RPC the file-attach flow uses. -->
from the same mesh.transport-advice RPC the file-attach flow uses.
Teleported to body see the transport chooser above. -->
<Teleport to="body">
<div v-if="imageQualityChoice" class="mesh-transport-modal-backdrop" @click.self="pickImageQuality(null)">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">🖼 Choose Image Quality</h3>
@ -2481,12 +2575,91 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
>
<span class="mesh-transport-icon">🖼</span>
<span class="mesh-transport-label">{{ preset.displayName }} {{ preset.description }}</span>
<span class="mesh-transport-meta">{{ imageQualityEstimates.get(preset.key) ?? '…' }}</span>
<span class="mesh-transport-meta">{{
imageSendTransport === 'lora' ? (imageQualityEstimates.get(preset.key) ?? '…') : 'instant'
}}</span>
</button>
</div>
<!-- Transport pills: LoRa is always available (it's a mesh chat);
FIPS/Tor appear when the peer is a reachable federation node,
letting the user send the image that way instead. -->
<div
v-if="imageTransportOptions.fips || imageTransportOptions.tor"
class="mesh-image-transport-row"
>
<span class="mesh-image-transport-caption">Send via</span>
<button
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'lora' }"
@click="imageSendTransport = 'lora'"
>📡 LoRa</button>
<button
v-if="imageTransportOptions.fips"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'fips' }"
@click="imageSendTransport = 'fips'"
> FIPS</button>
<button
v-if="imageTransportOptions.tor"
class="mesh-image-transport-pill"
:class="{ active: imageSendTransport === 'tor' }"
@click="imageSendTransport = 'tor'"
>🧅 Tor</button>
</div>
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
</div>
</div>
</Teleport>
<!-- Hop visualization modal: click a message's transport pill to see how
it traveled radio hops + live signal for LoRa transports, overlay/
circuit shape for FIPS/Tor. Teleported to body (full-screen backdrop). -->
<Teleport to="body">
<div v-if="hopVizMsg" class="mesh-transport-modal-backdrop" @click.self="hopVizMsg = null">
<div class="glass-card mesh-transport-modal">
<h3 class="mesh-transport-title">{{ transportLabel(hopVizMsg) }} route</h3>
<p class="mesh-transport-sub">
{{ hopVizMsg.direction === 'sent' ? 'You → ' + (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'peer') + ' → You' }}
</p>
<div class="mesh-hopviz-chain">
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? 'You' : (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') }}</span>
</div>
<template v-if="hopVizMsg.transport === 'tor'">
<div class="mesh-hopviz-link">🧅 3 anonymous relays</div>
</template>
<template v-else-if="hopVizMsg.transport === 'fips'">
<div class="mesh-hopviz-link"> FIPS overlay · direct peer-to-peer</div>
</template>
<template v-else>
<div class="mesh-hopviz-link">
📡 {{ hopVizHops() === null || hopVizHops() === 0 ? 'direct radio link' : `${hopVizHops()} hop${hopVizHops() === 1 ? '' : 's'}` }}
<span v-if="hopVizHops() !== null && hopVizHops()! > 0" class="mesh-hopviz-dots">
<span v-for="i in Math.min(hopVizHops()!, 6)" :key="i" class="mesh-hopviz-dot" title="relay node"></span>
</span>
</div>
</template>
<div class="mesh-hopviz-node">
<span class="mesh-hopviz-icon">🏝</span>
<span class="mesh-hopviz-name">{{ hopVizMsg.direction === 'sent' ? (hopVizPeer?.advert_name || hopVizMsg.peer_name || 'Peer') : 'You' }}</span>
</div>
</div>
<div v-if="hopVizMsg.transport !== 'tor' && hopVizMsg.transport !== 'fips'" class="mesh-hopviz-signal">
<span>{{ signalQualityLabel(hopVizPeer?.snr ?? null, hopVizPeer?.rssi ?? null) }}</span>
<span v-if="hopVizPeer?.snr != null" class="mesh-hopviz-meta">SNR {{ hopVizPeer.snr.toFixed(1) }} dB</span>
<span v-if="hopVizPeer?.rssi != null" class="mesh-hopviz-meta">RSSI {{ hopVizPeer.rssi }} dBm</span>
<p class="mesh-hopviz-note">Signal values are the current link readings for this peer, not a snapshot from this message.</p>
</div>
<div class="mesh-hopviz-signal">
<span v-if="hopVizMsg.encrypted" class="mesh-chat-e2e">E2E</span>
<span class="mesh-hopviz-meta">{{ hopVizMsg.delivered && hopVizMsg.direction === 'sent' ? 'delivered ✓✓' : hopVizMsg.direction === 'sent' ? 'sent' : 'received' }}</span>
<span class="mesh-hopviz-meta">{{ timeAgo(hopVizMsg.timestamp) }}</span>
</div>
<button class="mesh-transport-cancel" @click="hopVizMsg = null">Close</button>
</div>
</div>
</Teleport>
<!-- The "mesh device detected" setup flow is now the global
MeshDeviceSetupModal mounted in App.vue (fires on every page,

View File

@ -581,3 +581,27 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-transport-meta { flex: 0 0 auto; font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-transport-cancel { margin-top: 4px; padding: 8px; background: transparent; border: none; color: rgba(255,255,255,0.5); cursor: pointer; font-size: 0.85rem; }
.mesh-transport-cancel:hover { color: #fff; }
/* Transport pills at the bottom of the image quality modal pick LoRa vs
FIPS vs Tor for the outgoing image when the peer is federation-reachable. */
.mesh-image-transport-row { display: flex; align-items: center; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.mesh-image-transport-caption { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-image-transport-pill { padding: 6px 12px; border-radius: 999px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.75); cursor: pointer; font-size: 0.8rem; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease; }
.mesh-image-transport-pill:hover { background: rgba(255,255,255,0.1); }
.mesh-image-transport-pill.active { background: rgba(251,146,60,0.18); border-color: rgba(251,146,60,0.7); color: #fff; }
/* Hop visualization modal + clickable transport pill */
.mesh-chat-transport-clickable { cursor: pointer; }
.mesh-chat-transport-clickable:hover { filter: brightness(1.4); }
.mesh-hopviz-chain { display: flex; align-items: center; gap: 10px; justify-content: center; padding: 14px 8px; flex-wrap: wrap; }
.mesh-hopviz-node { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 72px; }
.mesh-hopviz-icon { font-size: 1.8rem; }
.mesh-hopviz-name { font-size: 0.8rem; font-weight: 600; color: #fff; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mesh-hopviz-link { flex: 1 1 auto; text-align: center; font-size: 0.8rem; color: rgba(255,255,255,0.75); border-top: 2px dashed rgba(251,146,60,0.5); padding-top: 6px; min-width: 120px; }
.mesh-hopviz-dots { margin-left: 6px; letter-spacing: 6px; color: rgba(251,146,60,0.9); }
.mesh-hopviz-signal { display: flex; align-items: center; gap: 10px; justify-content: center; flex-wrap: wrap; font-size: 0.8rem; color: rgba(255,255,255,0.75); padding: 4px 0; }
.mesh-hopviz-meta { font-size: 0.75rem; color: rgba(255,255,255,0.5); }
.mesh-hopviz-note { flex-basis: 100%; text-align: center; font-size: 0.68rem; color: rgba(255,255,255,0.35); margin: 2px 0 0; }
/* Reaction dropdown inside the message action menu */
.mesh-chat-reaction-dropdown { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 4px; padding-top: 6px; }