feat(mesh): Reticulum LoRa hardware gates pass + RNS Resource transfer + image/voice attachments
Phase 0 gates #2/#3 (two-node LXMF-over-LoRa, external Sideband interop) passed on real hardware (.116's flashed Heltec V3 RNode <-> a phone-flashed RNode running Sideband) — RNS announce, encrypted DM round-trip, and contact binding all verified live. Fixed two bugs found in the process: the Reticulum send path wasn't stamping outbound messages as E2E despite LXMF being unconditionally encrypted, and the per-message transport pill collapsed Meshcore/Meshtastic into one generic "lora" color instead of distinguishing the three radio transports. Built on top of that link: a Columba-style image/file send experience — compression-quality presets with a real transfer-time estimate (mesh.transport-advice, now device-throughput-aware), receive-side thumbnail previews + auto-render for already-local attachments, and async voice messages, all reusing the existing ContentRef/ContentInline attachment pipeline. The headline addition is genuine RNS Resource transfer support (daemon-side RNS.Link + RNS.Resource, Rust-side send_resource/resource_recv plumbing, a new "resource-mesh" transport-advice tier) so compressed photos up to 2MB now actually transfer over LoRa for Reticulum peers instead of always falling back to Tor past the small inline-chunk cap. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
12e7990b10
commit
f54c853128
@@ -78,8 +78,9 @@ export interface MeshMessage {
|
||||
timestamp: string
|
||||
delivered: boolean
|
||||
encrypted: boolean
|
||||
/// How the message traveled: "lora" (mesh radio), "fips", or "tor".
|
||||
/// Drives the per-message transport pill. Absent until known.
|
||||
/// How the message traveled: "meshtastic", "meshcore", "reticulum" (radio
|
||||
/// transports, one per device kind), "fips", or "tor". Drives the
|
||||
/// per-message transport pill. Absent until known.
|
||||
transport?: string | null
|
||||
message_type?: MeshMessageTypeLabel
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -459,7 +460,7 @@ export const useMeshStore = defineStore('mesh', () => {
|
||||
|
||||
async function transportAdvice(contactId: number, size: number) {
|
||||
return rpcClient.call<{
|
||||
tier: 'auto-mesh' | 'choose' | 'tor-only' | 'impossible'
|
||||
tier: 'auto-mesh' | 'choose' | 'resource-mesh' | 'tor-only' | 'impossible'
|
||||
est_seconds: number
|
||||
has_tor: boolean
|
||||
reason: string
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Client-side image compression presets for the mesh chat attachment picker,
|
||||
// mirroring Columba's ImageCompressionPreset (Low/Medium/High/Original) —
|
||||
// resize + iteratively-quality-reduced JPEG, entirely in the browser so no
|
||||
// extra round-trip is needed before the existing send pipeline takes over.
|
||||
|
||||
export interface ImageCompressionPreset {
|
||||
key: 'low' | 'medium' | 'high' | 'original'
|
||||
displayName: string
|
||||
description: string
|
||||
maxDimensionPx: number
|
||||
targetBytes: number
|
||||
initialQuality: number
|
||||
minQuality: number
|
||||
}
|
||||
|
||||
export const IMAGE_COMPRESSION_PRESETS: ImageCompressionPreset[] = [
|
||||
{
|
||||
key: 'low',
|
||||
displayName: 'Low',
|
||||
description: '32KB max — best for LoRa',
|
||||
maxDimensionPx: 320,
|
||||
targetBytes: 32 * 1024,
|
||||
initialQuality: 60,
|
||||
minQuality: 30,
|
||||
},
|
||||
{
|
||||
key: 'medium',
|
||||
displayName: 'Medium',
|
||||
description: '128KB max — balanced',
|
||||
maxDimensionPx: 800,
|
||||
targetBytes: 128 * 1024,
|
||||
initialQuality: 75,
|
||||
minQuality: 40,
|
||||
},
|
||||
{
|
||||
key: 'high',
|
||||
displayName: 'High',
|
||||
description: '512KB max — good quality',
|
||||
maxDimensionPx: 2048,
|
||||
targetBytes: 512 * 1024,
|
||||
initialQuality: 90,
|
||||
minQuality: 50,
|
||||
},
|
||||
{
|
||||
key: 'original',
|
||||
displayName: 'Original',
|
||||
description: 'No compression',
|
||||
maxDimensionPx: Infinity,
|
||||
targetBytes: Infinity,
|
||||
initialQuality: 100,
|
||||
minQuality: 100,
|
||||
},
|
||||
]
|
||||
|
||||
/** Resize + iteratively shrink JPEG quality until under the preset's target size
|
||||
* (or `minQuality` is reached, whichever comes first). `original` is a no-op. */
|
||||
export async function compressImage(file: File, preset: ImageCompressionPreset): Promise<File> {
|
||||
if (preset.key === 'original') return file
|
||||
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const { width, height } = scaledDimensions(bitmap.width, bitmap.height, preset.maxDimensionPx)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas 2D context unavailable')
|
||||
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||
bitmap.close()
|
||||
|
||||
let quality = preset.initialQuality / 100
|
||||
let blob = await canvasToJpegBlob(canvas, quality)
|
||||
while (blob.size > preset.targetBytes && quality > preset.minQuality / 100) {
|
||||
quality = Math.max(quality - 0.1, preset.minQuality / 100)
|
||||
blob = await canvasToJpegBlob(canvas, quality)
|
||||
}
|
||||
|
||||
const name = file.name.replace(/\.[^./\\]+$/, '') + '.jpg'
|
||||
return new File([blob], name, { type: 'image/jpeg', lastModified: Date.now() })
|
||||
}
|
||||
|
||||
/** Tiny low-res JPEG (default 64px / low quality) for the `thumb_bytes` field
|
||||
* on a ContentRef message — shown immediately on receipt, before the full
|
||||
* image is fetched. */
|
||||
export async function makeThumbnail(file: File, maxDimensionPx = 64, quality = 0.4): Promise<Uint8Array> {
|
||||
const bitmap = await createImageBitmap(file)
|
||||
const { width, height } = scaledDimensions(bitmap.width, bitmap.height, maxDimensionPx)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error('Canvas 2D context unavailable')
|
||||
ctx.drawImage(bitmap, 0, 0, width, height)
|
||||
bitmap.close()
|
||||
|
||||
const blob = await canvasToJpegBlob(canvas, quality)
|
||||
return new Uint8Array(await blob.arrayBuffer())
|
||||
}
|
||||
|
||||
function scaledDimensions(width: number, height: number, maxDimensionPx: number): { width: number; height: number } {
|
||||
if (!Number.isFinite(maxDimensionPx) || (width <= maxDimensionPx && height <= maxDimensionPx)) {
|
||||
return { width, height }
|
||||
}
|
||||
const scale = maxDimensionPx / Math.max(width, height)
|
||||
return { width: Math.max(1, Math.round(width * scale)), height: Math.max(1, Math.round(height * scale)) }
|
||||
}
|
||||
|
||||
function canvasToJpegBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
|
||||
return new Promise((resolve, reject) => {
|
||||
canvas.toBlob(
|
||||
(blob) => (blob ? resolve(blob) : reject(new Error('canvas.toBlob failed'))),
|
||||
'image/jpeg',
|
||||
quality,
|
||||
)
|
||||
})
|
||||
}
|
||||
+254
-35
@@ -11,6 +11,7 @@ import MeshDeadmanPanel from '@/views/mesh/MeshDeadmanPanel.vue'
|
||||
import MeshAssistantPanel from '@/views/mesh/MeshAssistantPanel.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { wsClient } from '@/api/websocket'
|
||||
import { IMAGE_COMPRESSION_PRESETS, compressImage, makeThumbnail, type ImageCompressionPreset } from '@/utils/imageCompression'
|
||||
import '@/views/mesh/mesh-styles.css'
|
||||
|
||||
const mesh = useMeshStore()
|
||||
@@ -1120,11 +1121,13 @@ function isEditedMessage(msg: MeshMessage): number | null {
|
||||
function isDeletedMessage(msg: MeshMessage): boolean {
|
||||
return msg.message_type === 'delete' || msg.typed_payload?.deleted === true
|
||||
}
|
||||
/// Short label for the per-message transport pill (LoRa / FIPS / Tor), or null
|
||||
/// when the transport isn't known. Covers both meshcore and meshtastic since
|
||||
/// the field lives on the shared MeshMessage.
|
||||
/// Short label for the per-message transport pill (Meshtastic / Meshcore /
|
||||
/// Reticulum / FIPS / Tor), or null when the transport isn't known.
|
||||
function transportLabel(msg: MeshMessage): string | null {
|
||||
switch (msg.transport) {
|
||||
case 'meshtastic': return 'Meshtastic'
|
||||
case 'meshcore': return 'Meshcore'
|
||||
case 'reticulum': return 'Reticulum'
|
||||
case 'lora': return 'LoRa'
|
||||
case 'fips': return 'FIPS'
|
||||
case 'tor': return 'Tor'
|
||||
@@ -1277,6 +1280,34 @@ const attachError = ref<string | null>(null)
|
||||
const fetchingCids = ref<Set<string>>(new Set())
|
||||
const fetchedUrls = ref<Map<string, string>>(new Map())
|
||||
|
||||
// Auto-render attachments whose bytes are already local — an inline
|
||||
// (mesh.send-content-inline) ContentRef has its bytes written to our
|
||||
// BlobStore the moment it's sent/received (dispatch.rs), so there's no real
|
||||
// fetch to wait on; skip the explicit "Download" click for those. Runs
|
||||
// `immediate: true` so already-loaded history gets the same treatment as
|
||||
// newly-arriving messages.
|
||||
const autoFetchedCids = new Set<string>()
|
||||
watch(
|
||||
() => chatMessages.value.length,
|
||||
() => {
|
||||
for (const msg of chatMessages.value) {
|
||||
const payload = msg.typed_payload as { cid?: string; inline?: boolean } | undefined
|
||||
if (
|
||||
msg.message_type === 'content_ref' &&
|
||||
payload?.inline &&
|
||||
payload.cid &&
|
||||
!fetchedUrls.value.has(payload.cid) &&
|
||||
!fetchingCids.value.has(payload.cid) &&
|
||||
!autoFetchedCids.has(payload.cid)
|
||||
) {
|
||||
autoFetchedCids.add(payload.cid)
|
||||
void handleFetchContent(msg.typed_payload as any)
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// Transport chooser modal state — populated when advice comes back as
|
||||
// "choose" (size fits both inline-over-mesh AND Tor). User picks a path;
|
||||
// `transportChoiceResolve` finishes the promise started by handleAttachFile.
|
||||
@@ -1297,6 +1328,51 @@ function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
|
||||
transportChoice.value = null
|
||||
}
|
||||
|
||||
// Image quality-picker modal — shown before sending an image attachment.
|
||||
// Presets skip 'original'-specific compression (handled in compressImage)
|
||||
// but still show a transfer-time estimate for it, fetched the same way as
|
||||
// every other preset via the existing mesh.transport-advice RPC.
|
||||
const imageQualityChoice = ref<{ file: File } | null>(null)
|
||||
const imageQualityEstimates = ref<Map<string, string>>(new Map())
|
||||
let imageQualityResolve: ((preset: ImageCompressionPreset | null) => void) | null = null
|
||||
|
||||
function formatEstSeconds(seconds: number): string {
|
||||
if (seconds < 60) return `~${seconds}s`
|
||||
return `~${Math.round(seconds / 60)}m`
|
||||
}
|
||||
|
||||
async function openImageQualityDialog(file: File, peerContactId: number): Promise<ImageCompressionPreset | null> {
|
||||
imageQualityChoice.value = { file }
|
||||
imageQualityEstimates.value = new Map()
|
||||
// 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.
|
||||
void Promise.all(
|
||||
IMAGE_COMPRESSION_PRESETS.map(async (preset) => {
|
||||
const size = preset.key === 'original' ? file.size : Math.min(preset.targetBytes, file.size)
|
||||
try {
|
||||
const advice = await mesh.transportAdvice(peerContactId, size)
|
||||
const label =
|
||||
advice.tier === 'impossible' ? 'too large' : formatEstSeconds(advice.est_seconds)
|
||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, label)
|
||||
} catch {
|
||||
imageQualityEstimates.value = new Map(imageQualityEstimates.value).set(preset.key, '?')
|
||||
}
|
||||
}),
|
||||
)
|
||||
return new Promise<ImageCompressionPreset | null>((resolve) => {
|
||||
imageQualityResolve = resolve
|
||||
})
|
||||
}
|
||||
|
||||
function pickImageQuality(preset: ImageCompressionPreset | null) {
|
||||
if (imageQualityResolve) {
|
||||
imageQualityResolve(preset)
|
||||
imageQualityResolve = null
|
||||
}
|
||||
imageQualityChoice.value = null
|
||||
}
|
||||
|
||||
async function resolveFederationOnion(peerName: string): Promise<string | undefined> {
|
||||
try {
|
||||
const fed = await rpcClient.federationListNodes()
|
||||
@@ -1326,13 +1402,24 @@ async function sendViaMeshInline(file: File, peerContactId: number) {
|
||||
|
||||
async function sendViaTorContentRef(file: File, peerContactId: number, peerName: string) {
|
||||
const buf = await file.arrayBuffer()
|
||||
const headers: Record<string, string> = {
|
||||
'X-Blob-Mime': file.type || 'application/octet-stream',
|
||||
'X-Blob-Filename': file.name,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
}
|
||||
// Tiny thumbnail so the receiver sees a preview immediately instead of
|
||||
// waiting on an explicit fetch — see the content_ref render branch below.
|
||||
if (file.type.startsWith('image/')) {
|
||||
try {
|
||||
const thumb = await makeThumbnail(file)
|
||||
headers['X-Blob-Thumb'] = btoa(String.fromCharCode(...thumb))
|
||||
} catch {
|
||||
// Best-effort — a missing thumbnail just means no preview, not a failed send.
|
||||
}
|
||||
}
|
||||
const up = await fetch('/api/blob', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Blob-Mime': file.type || 'application/octet-stream',
|
||||
'X-Blob-Filename': file.name,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
headers,
|
||||
credentials: 'include',
|
||||
body: buf,
|
||||
})
|
||||
@@ -1342,9 +1429,50 @@ async function sendViaTorContentRef(file: File, peerContactId: number, peerName:
|
||||
await mesh.sendContent(peerContactId, cid, messageText.value.trim() || undefined, peerOnion)
|
||||
}
|
||||
|
||||
/** Resolve the best transport for `file` via mesh.transport-advice (prompting
|
||||
* with the transport-chooser modal for the ambiguous "choose" tier) and send
|
||||
* it. Shared by the file-attach flow and voice messages — both just need
|
||||
* "given a File and a peer, get it there". Returns false if the user
|
||||
* cancelled or the send was rejected as impossible (caller already informed
|
||||
* via attachError); true on success. */
|
||||
async function sendFileViaBestTransport(file: File, peer: MeshPeer): Promise<boolean> {
|
||||
const advice = await mesh.transportAdvice(peer.contact_id, file.size)
|
||||
let transport: 'mesh' | 'tor' | 'cancel'
|
||||
if (advice.tier === 'auto-mesh' || advice.tier === 'resource-mesh') {
|
||||
// 'resource-mesh' (Reticulum-only, large-over-LoRa via RNS Resource) is
|
||||
// routed by the SAME mesh.send-content-inline call as 'auto-mesh' — the
|
||||
// backend decides internally whether to use the small inline-chunk path
|
||||
// or a Resource transfer based on size + active device type.
|
||||
transport = 'mesh'
|
||||
} else if (advice.tier === 'tor-only') {
|
||||
transport = 'tor'
|
||||
} else if (advice.tier === 'impossible') {
|
||||
attachError.value = `Cannot send: ${advice.reason} (${(file.size / 1024).toFixed(1)} KB)`
|
||||
return false
|
||||
} else {
|
||||
// "choose" — open modal and wait for user to pick
|
||||
transport = await new Promise<'mesh' | 'tor' | 'cancel'>((resolve) => {
|
||||
transportChoiceResolve = resolve
|
||||
transportChoice.value = {
|
||||
file,
|
||||
size: file.size,
|
||||
est_seconds: advice.est_seconds,
|
||||
has_tor: advice.has_tor,
|
||||
}
|
||||
})
|
||||
if (transport === 'cancel') return false
|
||||
}
|
||||
if (transport === 'mesh') {
|
||||
await sendViaMeshInline(file, peer.contact_id)
|
||||
} else {
|
||||
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleAttachFile(ev: Event) {
|
||||
const input = ev.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
let file = input.files?.[0]
|
||||
if (!file) return
|
||||
if (!activeChatPeer.value) {
|
||||
attachError.value = 'Pick a peer first'
|
||||
@@ -1355,33 +1483,12 @@ async function handleAttachFile(ev: Event) {
|
||||
attaching.value = true
|
||||
attachError.value = null
|
||||
try {
|
||||
const advice = await mesh.transportAdvice(peer.contact_id, file.size)
|
||||
let transport: 'mesh' | 'tor' | 'cancel'
|
||||
if (advice.tier === 'auto-mesh') {
|
||||
transport = 'mesh'
|
||||
} else if (advice.tier === 'tor-only') {
|
||||
transport = 'tor'
|
||||
} else if (advice.tier === 'impossible') {
|
||||
attachError.value = `Cannot send: ${advice.reason} (${(file.size / 1024).toFixed(1)} KB)`
|
||||
return
|
||||
} else {
|
||||
// "choose" — open modal and wait for user to pick
|
||||
transport = await new Promise<'mesh' | 'tor' | 'cancel'>((resolve) => {
|
||||
transportChoiceResolve = resolve
|
||||
transportChoice.value = {
|
||||
file,
|
||||
size: file.size,
|
||||
est_seconds: advice.est_seconds,
|
||||
has_tor: advice.has_tor,
|
||||
}
|
||||
})
|
||||
if (transport === 'cancel') return
|
||||
}
|
||||
if (transport === 'mesh') {
|
||||
await sendViaMeshInline(file, peer.contact_id)
|
||||
} else {
|
||||
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
|
||||
if (file.type.startsWith('image/')) {
|
||||
const preset = await openImageQualityDialog(file, peer.contact_id)
|
||||
if (!preset) return // user cancelled
|
||||
file = await compressImage(file, preset)
|
||||
}
|
||||
if (!(await sendFileViaBestTransport(file, peer))) return
|
||||
messageText.value = ''
|
||||
nextTick(() => scrollChatToBottom())
|
||||
} catch (e) {
|
||||
@@ -1392,6 +1499,66 @@ async function handleAttachFile(ev: Event) {
|
||||
}
|
||||
}
|
||||
|
||||
// Voice messages — async/store-and-forward (a recorded clip sent as a normal
|
||||
// attachment), NOT a live call; reuses sendFileViaBestTransport exactly like
|
||||
// any other file. Hold-to-record: press the mic button, release to send.
|
||||
const isRecordingVoice = ref(false)
|
||||
let voiceRecorder: MediaRecorder | null = null
|
||||
let voiceRecorderStream: MediaStream | null = null
|
||||
let voiceChunks: Blob[] = []
|
||||
|
||||
async function startVoiceRecording() {
|
||||
if (isRecordingVoice.value || attaching.value || !activeChatPeer.value) return
|
||||
try {
|
||||
voiceRecorderStream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
} catch (e) {
|
||||
attachError.value = e instanceof Error ? e.message : 'Microphone access denied'
|
||||
return
|
||||
}
|
||||
voiceChunks = []
|
||||
voiceRecorder = new MediaRecorder(voiceRecorderStream, { mimeType: 'audio/webm;codecs=opus' })
|
||||
voiceRecorder.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) voiceChunks.push(e.data)
|
||||
}
|
||||
voiceRecorder.start()
|
||||
isRecordingVoice.value = true
|
||||
}
|
||||
|
||||
async function stopVoiceRecording() {
|
||||
if (!isRecordingVoice.value || !voiceRecorder) return
|
||||
const recorder = voiceRecorder
|
||||
const stream = voiceRecorderStream
|
||||
isRecordingVoice.value = false
|
||||
voiceRecorder = null
|
||||
voiceRecorderStream = null
|
||||
const blob = await new Promise<Blob>((resolve) => {
|
||||
recorder.onstop = () => resolve(new Blob(voiceChunks, { type: 'audio/webm' }))
|
||||
recorder.stop()
|
||||
})
|
||||
stream?.getTracks().forEach((t) => t.stop())
|
||||
if (blob.size === 0 || !activeChatPeer.value) return
|
||||
|
||||
const peer = activeChatPeer.value
|
||||
const file = new File([blob], `voice-${Date.now()}.webm`, { type: 'audio/webm' })
|
||||
attaching.value = true
|
||||
attachError.value = null
|
||||
try {
|
||||
if (await sendFileViaBestTransport(file, peer)) {
|
||||
nextTick(() => scrollChatToBottom())
|
||||
}
|
||||
} catch (e) {
|
||||
attachError.value = e instanceof Error ? e.message : 'Failed to send voice message'
|
||||
} finally {
|
||||
attaching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** pointerleave while still holding (e.g. dragged off the button) — stop and
|
||||
* send rather than silently discarding the in-progress recording. */
|
||||
function stopVoiceRecordingIfActive() {
|
||||
if (isRecordingVoice.value) void stopVoiceRecording()
|
||||
}
|
||||
|
||||
async function handleFetchContent(payload: {
|
||||
cid: string
|
||||
sender_onion: string
|
||||
@@ -1829,12 +1996,24 @@ function isImageMime(mime?: string): boolean {
|
||||
class="mesh-typed-content-preview"
|
||||
alt="attachment"
|
||||
/>
|
||||
<audio
|
||||
v-else-if="(msg.typed_payload.mime || '').startsWith('audio/')"
|
||||
:src="fetchedUrls.get(msg.typed_payload.cid)"
|
||||
controls
|
||||
class="mesh-typed-content-audio"
|
||||
/>
|
||||
<a v-else :href="fetchedUrls.get(msg.typed_payload.cid)" target="_blank" class="btn">Open</a>
|
||||
</template>
|
||||
<template v-else-if="msg.direction === 'sent'">
|
||||
<span class="mesh-typed-content-hint">(shared from this node)</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img
|
||||
v-if="msg.typed_payload.thumb_bytes && isImageMime(msg.typed_payload.mime)"
|
||||
:src="`data:${msg.typed_payload.mime};base64,${msg.typed_payload.thumb_bytes}`"
|
||||
class="mesh-typed-content-preview mesh-typed-content-thumb"
|
||||
alt="thumbnail preview"
|
||||
/>
|
||||
<button
|
||||
class="btn"
|
||||
:disabled="fetchingCids.has(msg.typed_payload.cid)"
|
||||
@@ -1930,6 +2109,20 @@ function isImageMime(mime?: string): boolean {
|
||||
<span v-if="attaching" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<span v-else>📎</span>
|
||||
</label>
|
||||
<button
|
||||
v-if="activeChatPeer"
|
||||
type="button"
|
||||
class="glass-button mesh-chat-record-btn"
|
||||
:class="{ 'is-recording': isRecordingVoice }"
|
||||
:disabled="attaching"
|
||||
:title="isRecordingVoice ? 'Release to send' : 'Hold to record a voice message'"
|
||||
@pointerdown.prevent="startVoiceRecording"
|
||||
@pointerup.prevent="stopVoiceRecording"
|
||||
@pointerleave="stopVoiceRecordingIfActive"
|
||||
>
|
||||
<span v-if="isRecordingVoice" class="mesh-spinner" aria-hidden="true"></span>
|
||||
<span v-else>🎤</span>
|
||||
</button>
|
||||
<input
|
||||
v-model="messageText"
|
||||
class="mesh-chat-input"
|
||||
@@ -2027,6 +2220,32 @@ function isImageMime(mime?: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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. -->
|
||||
<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>
|
||||
<p class="mesh-transport-sub">
|
||||
<strong>{{ imageQualityChoice.file.name }}</strong>
|
||||
· {{ (imageQualityChoice.file.size / 1024).toFixed(1) }} KB original
|
||||
</p>
|
||||
<div class="mesh-transport-options">
|
||||
<button
|
||||
v-for="preset in IMAGE_COMPRESSION_PRESETS"
|
||||
:key="preset.key"
|
||||
class="mesh-transport-option"
|
||||
@click="pickImageQuality(preset)"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
<button class="mesh-transport-cancel" @click="pickImageQuality(null)">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -149,9 +149,12 @@
|
||||
.mesh-chat-bubble-meta { display: flex; align-items: center; gap: 6px; margin-top: 4px; justify-content: flex-end; }
|
||||
.mesh-chat-bubble-time { font-size: 0.65rem; color: rgba(255, 255, 255, 0.3); }
|
||||
.mesh-chat-e2e { font-size: 0.55rem; font-weight: 700; color: #4ade80; padding: 0 3px; border: 1px solid rgba(74, 222, 128, 0.3); border-radius: 3px; }
|
||||
/* Per-message transport pill (Mesh / FIPS / Tor), styled like the E2E badge. */
|
||||
/* Per-message transport pill (Meshtastic / Meshcore / Reticulum / FIPS / Tor), styled like the E2E badge. */
|
||||
.mesh-chat-transport { font-size: 0.55rem; font-weight: 700; padding: 0 3px; border-radius: 3px; border: 1px solid currentColor; opacity: 0.85; }
|
||||
.mesh-chat-transport.transport-lora { color: #f59e0b; } /* Mesh/LoRa — amber */
|
||||
.mesh-chat-transport.transport-meshtastic { color: #3eb489; } /* Meshtastic — mint */
|
||||
.mesh-chat-transport.transport-meshcore { color: #fb923c; } /* Meshcore — orange */
|
||||
.mesh-chat-transport.transport-reticulum { color: #60a5fa; } /* Reticulum — blue */
|
||||
.mesh-chat-transport.transport-lora { color: #f59e0b; } /* legacy generic Mesh/LoRa (pre-split) — amber */
|
||||
.mesh-chat-transport.transport-fips { color: #a78bfa; } /* FIPS — violet */
|
||||
.mesh-chat-transport.transport-tor { color: #818cf8; } /* Tor — indigo */
|
||||
.mesh-chat-ack { font-size: 0.7rem; color: #3b82f6; }
|
||||
@@ -338,6 +341,9 @@
|
||||
.mesh-typed-coordinate-link { display: inline-block; font-size: 0.75rem; color: #3b82f6; margin-top: 4px; text-decoration: underline; }
|
||||
.typed-block_header { border-left: 3px solid #a855f7; }
|
||||
.mesh-typed-block { display: flex; align-items: center; gap: 4px; color: #a855f7; font-size: 0.8rem; }
|
||||
.mesh-typed-content-preview { max-width: 220px; max-height: 220px; border-radius: 10px; display: block; }
|
||||
.mesh-typed-content-thumb { opacity: 0.85; filter: blur(0.5px); margin-bottom: 6px; }
|
||||
.mesh-typed-content-audio { width: 220px; max-width: 100%; display: block; }
|
||||
.mesh-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; }
|
||||
.mesh-tab { flex: 1; padding: 8px 12px; border: none; background: transparent; color: rgba(255,255,255,0.5); font-size: 0.82rem; font-weight: 500; border-radius: 8px; cursor: pointer; transition: all 0.2s ease; display: flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.mesh-tab:hover { color: rgba(255,255,255,0.8); background: rgba(255,255,255,0.05); }
|
||||
@@ -464,6 +470,8 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
|
||||
.mesh-spinner { display: inline-block; width: 1em; height: 1em; border: 2px solid rgba(255,255,255,0.25); border-top-color: #fb923c; border-radius: 50%; animation: mesh-spin 0.7s linear infinite; vertical-align: middle; }
|
||||
@keyframes mesh-spin { to { transform: rotate(360deg); } }
|
||||
.mesh-chat-attach-btn.is-busy { opacity: 0.8; cursor: wait; }
|
||||
.mesh-chat-record-btn.is-recording { background: rgba(239,68,68,0.25); animation: mesh-record-pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes mesh-record-pulse { 0%, 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0.4); } 50% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } }
|
||||
.mesh-chat-reaction-btn.is-busy { background: rgba(251,146,60,0.25); }
|
||||
.mesh-chat-reaction-btn:disabled { opacity: 0.6; cursor: wait; }
|
||||
|
||||
|
||||
Reference in New Issue
Block a user