feat(mesh): Telegram primitives pass + attachment transport router

Bundles the Phase 2b/3/4/5 work that accumulated across prior sessions
and the new attachment chunking router from this session. Everything
ships in one shot so the full mesh surface stays coherent on-wire.

Telegram primitives (variants 13–18, 20–22):
- Reply / Reaction / ReadReceipt / Forward / Edit / Delete
- Presence heartbeat + last-seen tracking
- ChannelInvite + ContactCard payload types
- MessageKey (sender_pubkey, sender_seq) as cross-transport identity
- Action menu, reply banner, edit banner, tombstones, (edited) marker
- Debounced auto-read-receipts on scroll + message arrival

Activated prototypes (Phase 4):
- PsbtHash send RPC
- Contacts CRUD (in-memory alias/notes/pinned/blocked)
- Outbox 📤 badge, rotate-prekeys button
- Chunked send fallback (MCIIXXTT framing) as auto-failover inside
  send_typed_wire when a typed wire exceeds the LoRa per-frame budget

Unified inbox (Phase 1):
- conversations.list + conversations.messages RPCs (UI collapse deferred)

Attachment transport router (new this session):
- ContentInline variant 23 + ContentInlinePayload carrying file bytes
  directly in the envelope for small files with no Tor path
- mesh.send-content-inline RPC — mirrors to local BlobStore, rides
  send_typed_wire which auto-chunks over MCIIXXTT framing (~2.3 KB cap)
- mesh.transport-advice RPC as single source of truth for tier
  decisions: auto-mesh / choose / tor-only / impossible
- Receive arm writes inline bytes to local BlobStore so the existing
  content_ref card renderer handles both transports uniformly
- MeshState.blob_store field + order-independent propagation from
  RpcHandler::set_blob_store / set_mesh_service
- Frontend handleAttachFile calls advice first, branches into silent
  auto-send, transport-chooser modal, Tor-only path, or red error
- Transport modal with 📡 mesh / 🧅 Tor options + ETA + disabled
  state when peer has no Tor reachability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-14 20:40:19 -04:00
parent 5616bb74e6
commit 6760d11a57
16 changed files with 789 additions and 153 deletions

View File

@@ -407,6 +407,49 @@ export const useMeshStore = defineStore('mesh', () => {
}
}
async function transportAdvice(contactId: number, size: number) {
return rpcClient.call<{
tier: 'auto-mesh' | 'choose' | 'tor-only' | 'impossible'
est_seconds: number
has_tor: boolean
reason: string
size: number
mesh_auto_max: number
mesh_hard_max: number
}>({
method: 'mesh.transport-advice',
params: { contact_id: contactId, size },
})
}
async function sendContentInline(
contactId: number,
mime: string,
bytes: Uint8Array,
filename?: string,
caption?: string,
) {
try {
sending.value = true
error.value = null
// Base64-encode bytes for JSON transport.
let binary = ''
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]!)
const bytes_b64 = btoa(binary)
const res = await rpcClient.call<{ sent: boolean; message_id: number; cid: string; size: number }>({
method: 'mesh.send-content-inline',
params: { contact_id: contactId, mime, filename, caption, bytes_b64 },
})
if (res.sent) await fetchMessages()
return res
} catch (err: unknown) {
error.value = err instanceof Error ? err.message : 'Failed to send inline content'
throw err
} finally {
sending.value = false
}
}
async function sendReply(contactId: number, targetPubkey: string, targetSeq: number, text: string) {
sending.value = true
try {
@@ -633,6 +676,8 @@ export const useMeshStore = defineStore('mesh', () => {
sendCoordinate,
sendAlert,
sendContent,
sendContentInline,
transportAdvice,
fetchContent,
sendReply,
sendReaction,

View File

@@ -1076,6 +1076,71 @@ const attachError = ref<string | null>(null)
const fetchingCids = ref<Set<string>>(new Set())
const fetchedUrls = ref<Map<string, string>>(new Map())
// 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.
interface PendingTransportChoice {
file: File
size: number
est_seconds: number
has_tor: boolean
}
const transportChoice = ref<PendingTransportChoice | null>(null)
let transportChoiceResolve: ((choice: 'mesh' | 'tor' | 'cancel') => void) | null = null
function pickTransport(choice: 'mesh' | 'tor' | 'cancel') {
if (transportChoiceResolve) {
transportChoiceResolve(choice)
transportChoiceResolve = null
}
transportChoice.value = null
}
async function resolveFederationOnion(peerName: string): Promise<string | undefined> {
try {
const fed = await rpcClient.federationListNodes()
const hit = fed.nodes.find(
(n: { name?: string; onion?: string }) =>
(n.name ?? '').toLowerCase() === peerName.toLowerCase() ||
(n.name ?? '').toLowerCase().includes(peerName.toLowerCase()) ||
peerName.toLowerCase().includes((n.name ?? '').toLowerCase()),
)
return hit?.onion ?? undefined
} catch {
return undefined
}
}
async function sendViaMeshInline(file: File, peerContactId: number) {
const buf = await file.arrayBuffer()
const bytes = new Uint8Array(buf)
await mesh.sendContentInline(
peerContactId,
file.type || 'application/octet-stream',
bytes,
file.name,
messageText.value.trim() || undefined,
)
}
async function sendViaTorContentRef(file: File, peerContactId: number, peerName: string) {
const buf = await file.arrayBuffer()
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',
},
credentials: 'include',
body: buf,
})
if (!up.ok) throw new Error(`upload failed: ${up.status}`)
const { cid } = (await up.json()) as { cid: string }
const peerOnion = await resolveFederationOnion(peerName)
await mesh.sendContent(peerContactId, cid, messageText.value.trim() || undefined, peerOnion)
}
async function handleAttachFile(ev: Event) {
const input = ev.target as HTMLInputElement
const file = input.files?.[0]
@@ -1085,50 +1150,37 @@ async function handleAttachFile(ev: Event) {
if (input) input.value = ''
return
}
const peer = activeChatPeer.value
attaching.value = true
attachError.value = null
try {
const buf = await file.arrayBuffer()
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',
},
credentials: 'include',
body: buf,
})
if (!up.ok) {
attachError.value = `upload failed: ${up.status}`
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
}
const { cid } = (await up.json()) as { cid: string }
// Resolve the federation onion for this mesh peer. Meshcore adverts
// don't carry an archipelago DID so the backend can't link them on its
// own — we match on name (both sides use the node's display name).
// Falls back to undefined; the backend will try its own DID lookup or
// error out if no federation path exists.
let peerOnion: string | undefined
try {
const fed = await rpcClient.federationListNodes()
const peerName = activeChatPeer.value.advert_name
const hit = fed.nodes.find(
(n: { name?: string; onion?: string }) =>
(n.name ?? '').toLowerCase() === peerName.toLowerCase() ||
(n.name ?? '').toLowerCase().includes(peerName.toLowerCase()) ||
peerName.toLowerCase().includes((n.name ?? '').toLowerCase()),
)
peerOnion = hit?.onion ?? undefined
} catch {
/* non-fatal — backend will try its own lookup */
if (transport === 'mesh') {
await sendViaMeshInline(file, peer.contact_id)
} else {
await sendViaTorContentRef(file, peer.contact_id, peer.advert_name)
}
await mesh.sendContent(
activeChatPeer.value.contact_id,
cid,
messageText.value.trim() || undefined,
peerOnion,
)
messageText.value = ''
nextTick(() => scrollChatToBottom())
} catch (e) {
@@ -1698,6 +1750,37 @@ function isImageMime(mime?: string): boolean {
</div>
</div>
<!-- Transport chooser modal: shown when attachment size fits both mesh
(inline-chunked) and Tor. User picks which path to send it over. -->
<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>
<p class="mesh-transport-sub">
<strong>{{ transportChoice.file.name }}</strong>
· {{ (transportChoice.size / 1024).toFixed(1) }} KB
</p>
<div class="mesh-transport-options">
<button class="mesh-transport-option" @click="pickTransport('mesh')">
<span class="mesh-transport-icon">📡</span>
<span class="mesh-transport-label">Over mesh</span>
<span class="mesh-transport-meta">~{{ transportChoice.est_seconds }}s · works offline</span>
</button>
<button
class="mesh-transport-option"
:disabled="!transportChoice.has_tor"
@click="pickTransport('tor')"
>
<span class="mesh-transport-icon">🧅</span>
<span class="mesh-transport-label">Over Tor</span>
<span class="mesh-transport-meta">
{{ transportChoice.has_tor ? 'instant · needs onion' : 'no Tor path to peer' }}
</span>
</button>
</div>
<button class="mesh-transport-cancel" @click="pickTransport('cancel')">Cancel</button>
</div>
</div>
</div>
</template>

View File

@@ -305,3 +305,18 @@ select.mesh-bitcoin-input option { background: #1a1a2e; color: rgba(255,255,255,
.mesh-chat-pending-clear { flex: 0 0 auto; align-self: center; margin-left: auto; background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.15); color: rgba(255,255,255,0.85); width: 28px; height: 28px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0.95rem; line-height: 1; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
.mesh-chat-pending-clear:hover { background: rgba(239,68,68,0.3); color: #fff; border-color: rgba(239,68,68,0.6); transform: scale(1.08); }
.mesh-chat-pending-clear:active { transform: scale(0.92); }
/* Transport chooser modal (attachment size router) */
.mesh-transport-modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
.mesh-transport-modal { max-width: 420px; width: 92%; padding: 24px; display: flex; flex-direction: column; gap: 14px; }
.mesh-transport-title { margin: 0; font-size: 1.1rem; color: #fff; }
.mesh-transport-sub { margin: 0; color: rgba(255,255,255,0.6); font-size: 0.85rem; overflow-wrap: anywhere; }
.mesh-transport-options { display: flex; flex-direction: column; gap: 10px; margin-top: 6px; }
.mesh-transport-option { display: flex; align-items: center; gap: 12px; padding: 14px 16px; border-radius: 12px; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.12); color: #fff; cursor: pointer; text-align: left; transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease; }
.mesh-transport-option:hover:not(:disabled) { background: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.25); transform: translateY(-1px); }
.mesh-transport-option:disabled { opacity: 0.4; cursor: not-allowed; }
.mesh-transport-icon { font-size: 1.5rem; flex: 0 0 auto; }
.mesh-transport-label { flex: 1 1 auto; font-weight: 600; }
.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; }