Compare commits
3 Commits
archy-hwco
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3589c3a6b9 | ||
|
|
0aa3941c40 | ||
|
|
e62f911810 |
@ -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,
|
||||
|
||||
@ -525,6 +525,28 @@ impl ReticulumLink {
|
||||
info!(count = self.peers.len(), "Loaded persisted Reticulum peers");
|
||||
}
|
||||
|
||||
/// Resolve a caller-supplied 6-byte routing prefix to a full 16-byte RNS
|
||||
/// destination hash. Two shapes arrive here depending on how the contact
|
||||
/// record was born (observed live 2026-07-28, image-to-merged-contact):
|
||||
/// 1. the peer's RNS dest-hash prefix (contacts created from an RNS
|
||||
/// announce) — direct `prefix_to_hash` hit;
|
||||
/// 2. the peer's Archipelago ed25519 pubkey prefix (radio twins bound
|
||||
/// via the ARCHY announce blob store the arch key as `pubkey_hex`) —
|
||||
/// no dest-hash match possible, so fall back to scanning peers whose
|
||||
/// announce-bound `arch_pubkey_hex` starts with the prefix.
|
||||
fn resolve_dest_hash(&self, prefix: &[u8; 6]) -> Option<[u8; 16]> {
|
||||
if let Some(hash) = self.prefix_to_hash.get(prefix) {
|
||||
return Some(*hash);
|
||||
}
|
||||
let hex_prefix = hex::encode(prefix);
|
||||
self.peers.values().find_map(|p| {
|
||||
p.arch_pubkey_hex
|
||||
.as_deref()
|
||||
.filter(|arch| arch.starts_with(&hex_prefix))
|
||||
.map(|_| p.dest_hash)
|
||||
})
|
||||
}
|
||||
|
||||
/// Best-effort sync write of the current peer table — called after any
|
||||
/// insert that adds/renames a peer. Infrequent (announces/first-contact,
|
||||
/// not per-message) so a blocking write here is a fine trade for keeping
|
||||
@ -601,16 +623,12 @@ impl ReticulumLink {
|
||||
dest_pubkey_prefix: &[u8; 6],
|
||||
payload: &[u8],
|
||||
) -> Result<()> {
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
.copied()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
hex::encode(dest_pubkey_prefix)
|
||||
)
|
||||
})?;
|
||||
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
hex::encode(dest_pubkey_prefix)
|
||||
)
|
||||
})?;
|
||||
// Typed-envelope payloads (ReadReceipt/Reaction/etc. — anything small
|
||||
// enough for the single-frame path) are raw binary CBOR, not text.
|
||||
// `from_utf8_lossy` would irreversibly mangle them since `content`
|
||||
@ -645,16 +663,12 @@ impl ReticulumLink {
|
||||
caption: Option<&str>,
|
||||
) -> Result<()> {
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
.copied()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
hex::encode(dest_pubkey_prefix)
|
||||
)
|
||||
})?;
|
||||
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
hex::encode(dest_pubkey_prefix)
|
||||
)
|
||||
})?;
|
||||
self.send_rpc(serde_json::json!({
|
||||
"cmd": "send",
|
||||
"dest_hash": hex::encode(dest_hash),
|
||||
@ -677,16 +691,12 @@ impl ReticulumLink {
|
||||
/// `handle_event`, not awaited here.
|
||||
pub async fn send_resource(&mut self, dest_pubkey_prefix: &[u8; 6], data: &[u8]) -> Result<()> {
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
let dest_hash = self
|
||||
.prefix_to_hash
|
||||
.get(dest_pubkey_prefix)
|
||||
.copied()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
hex::encode(dest_pubkey_prefix)
|
||||
)
|
||||
})?;
|
||||
let dest_hash = self.resolve_dest_hash(dest_pubkey_prefix).with_context(|| {
|
||||
format!(
|
||||
"Unknown Reticulum prefix {} — peer hasn't announced yet",
|
||||
hex::encode(dest_pubkey_prefix)
|
||||
)
|
||||
})?;
|
||||
let req_id = self.next_resource_id();
|
||||
self.send_rpc(serde_json::json!({
|
||||
"cmd": "send_resource",
|
||||
@ -1023,10 +1033,19 @@ impl ReticulumLink {
|
||||
.push_back(build_synthetic_frame(&prefix, &content));
|
||||
}
|
||||
Some("resource_recv") => {
|
||||
let Some(source_hex) = ev.get("source_hash").and_then(Value::as_str) else {
|
||||
return;
|
||||
};
|
||||
let source_hex = ev
|
||||
.get("source_hash")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let Ok(source_hash) = parse_hash16(source_hex) else {
|
||||
// An empty source_hash means the sender's link wasn't
|
||||
// identified (pre-identify daemon build on the far end) —
|
||||
// the blob is undeliverable without attribution, but say
|
||||
// so instead of vanishing it.
|
||||
warn!(
|
||||
source = source_hex,
|
||||
"Dropping inbound Reticulum resource without valid source identity"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let prefix: [u8; 6] = source_hash[..6].try_into().unwrap();
|
||||
|
||||
@ -281,8 +281,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)
|
||||
@ -444,8 +450,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
|
||||
}
|
||||
}
|
||||
@ -573,13 +582,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) {
|
||||
@ -724,6 +735,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
|
||||
@ -1042,6 +1055,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; }
|
||||
|
||||
@ -437,13 +437,29 @@ class ReticulumDaemon:
|
||||
identity, RNS.Destination.OUT, RNS.Destination.SINGLE,
|
||||
"archy", "resource",
|
||||
)
|
||||
link = RNS.Link(out_dest)
|
||||
# established_callback identifies us to the peer BEFORE any Resource
|
||||
# goes out: the receiving daemon attributes an inbound resource via
|
||||
# link.get_remote_identity() (see _on_resource_received), which is
|
||||
# None unless the initiator calls identify() — without this every
|
||||
# transfer arrives with an empty source_hash and the Rust side can't
|
||||
# route it to a contact (observed live 2026-07-28: 5KB image sent,
|
||||
# concluded COMPLETE, never surfaced on the receiving node).
|
||||
link = RNS.Link(
|
||||
out_dest,
|
||||
established_callback=lambda lk: self._on_out_link_established(
|
||||
resource_hash, lk
|
||||
),
|
||||
)
|
||||
link.set_link_closed_callback(
|
||||
lambda lk: self.links.pop(resource_hash, None)
|
||||
)
|
||||
self.links[resource_hash] = link
|
||||
return link, resource_hash
|
||||
|
||||
def _on_out_link_established(self, resource_hash: bytes, link):
|
||||
link.identify(self.identity)
|
||||
self._flush_pending_resource_sends(resource_hash, link)
|
||||
|
||||
def _send_resource(self, req: dict):
|
||||
import RNS
|
||||
req_id = req.get("id", "")
|
||||
@ -463,14 +479,14 @@ class ReticulumDaemon:
|
||||
if link.status == RNS.Link.ACTIVE:
|
||||
self._start_resource(link, data, req_id)
|
||||
else:
|
||||
# Link is establishing — queue and flush from the established
|
||||
# callback. set_link_established_callback only fires once per Link
|
||||
# object (the cache is reused across sends), so re-set it here to
|
||||
# make sure THIS send's queue entry gets flushed too.
|
||||
# Link is establishing — queue; the creation-time established
|
||||
# callback (_on_out_link_established) identifies us then flushes
|
||||
# the queue. Re-check afterwards to close the race where the
|
||||
# link went ACTIVE between the status check and the append
|
||||
# (the callback fires once, on the RNS thread).
|
||||
self.pending_resource_sends[resource_hash].append((data, req_id))
|
||||
link.set_link_established_callback(
|
||||
lambda lk: self._flush_pending_resource_sends(resource_hash, lk)
|
||||
)
|
||||
if link.status == RNS.Link.ACTIVE:
|
||||
self._flush_pending_resource_sends(resource_hash, link)
|
||||
|
||||
def _flush_pending_resource_sends(self, resource_hash: bytes, link):
|
||||
import RNS
|
||||
@ -518,10 +534,17 @@ class ReticulumDaemon:
|
||||
RNS.Destination.hash(identity, "lxmf", "delivery").hex()
|
||||
if identity is not None else ""
|
||||
)
|
||||
# RNS hands a concluded Resource's data as a file-like object
|
||||
# (BufferedReader over the assembled stream), not bytes —
|
||||
# observed live 2026-07-28: b64encode raised "a bytes-like
|
||||
# object is required" and every received transfer was lost.
|
||||
data = resource.data
|
||||
if hasattr(data, "read"):
|
||||
data = data.read()
|
||||
self._emit_threadsafe({
|
||||
"event": "resource_recv",
|
||||
"source_hash": source_hash,
|
||||
"data_b64": base64.b64encode(resource.data).decode("ascii"),
|
||||
"data_b64": base64.b64encode(data).decode("ascii"),
|
||||
})
|
||||
except Exception as e: # never let a callback kill the RNS thread
|
||||
self._emit_threadsafe({"event": "error", "where": "resource_recv",
|
||||
@ -531,7 +554,14 @@ class ReticulumDaemon:
|
||||
sock_path = self.args.socket
|
||||
if os.path.exists(sock_path):
|
||||
os.unlink(sock_path)
|
||||
server = await asyncio.start_unix_server(self._handle_client, path=sock_path)
|
||||
# limit: asyncio's default per-line StreamReader cap is 64KiB, but
|
||||
# send_resource requests carry the whole payload base64-encoded on one
|
||||
# JSON line — a ~48KB+ attachment overflows the default and the raised
|
||||
# LimitOverrunError tears down the RPC connection (the Rust side then
|
||||
# sees "reticulum-daemon is gone" and restarts the whole session).
|
||||
server = await asyncio.start_unix_server(
|
||||
self._handle_client, path=sock_path, limit=16 * 1024 * 1024
|
||||
)
|
||||
os.chmod(sock_path, 0o600)
|
||||
self.announce()
|
||||
async with server:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user