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
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:
parent
e62f911810
commit
0aa3941c40
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">✓✓</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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user