feat(mesh-ui): receive share-to-mesh postMessage + pending attachment

App.vue listens for postMessage({type:'share-to-mesh',cid,...}) from
marketplace app iframes, stashes the payload in sessionStorage, and
routes to /mesh. Mesh.vue reads the stash on mount (and on a synthetic
'archipelago:share-to-mesh' event when already on the view), showing a
pending-attachment banner in the compose area. Send becomes Share and
flushes the CID via mesh.send-content with the input text as caption.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 12:58:04 -04:00
parent f94f5da6ee
commit ab927afbaa
2 changed files with 89 additions and 2 deletions

View File

@@ -212,6 +212,7 @@ onMounted(async () => {
window.addEventListener('mousedown', onUserActivity)
window.addEventListener('keydown', onUserActivity)
window.addEventListener('touchstart', onUserActivity)
window.addEventListener('message', onShareToMeshMessage)
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
const isDirectRoute = route.path !== '/'
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
@@ -241,8 +242,31 @@ onBeforeUnmount(() => {
window.removeEventListener('mousedown', onUserActivity)
window.removeEventListener('keydown', onUserActivity)
window.removeEventListener('touchstart', onUserActivity)
window.removeEventListener('message', onShareToMeshMessage)
})
/**
* Phase 3c: marketplace app iframes share files into mesh chats by POSTing
* to /api/share-to-mesh then postMessaging the CID back to this parent
* window. We stash it in sessionStorage + route to /mesh; Mesh.vue reads the
* stash on mount and stages it as a pending attachment.
*/
function onShareToMeshMessage(ev: MessageEvent) {
const data = ev.data as { type?: string; cid?: string } | null
if (!data || data.type !== 'share-to-mesh' || !data.cid) return
try {
sessionStorage.setItem('archipelago_share_to_mesh', JSON.stringify(data))
} catch {
/* quota — fall through */
}
if (route.path !== '/mesh') {
router.push('/mesh')
} else {
// Already on /mesh — dispatch a synthetic event so the view picks it up.
window.dispatchEvent(new CustomEvent('archipelago:share-to-mesh'))
}
}
/**
* Handle splash screen completion
* Routes user directly to appropriate screen based on onboarding status (from backend)

View File

@@ -177,6 +177,8 @@ async function handleToggleOffGrid() {
onMounted(async () => {
window.addEventListener('resize', handleResize)
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
loadPendingFromSession()
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
// Start background polling for Archipelago (Tor) messages so unread count works
loadArchMessages()
@@ -194,6 +196,7 @@ onMounted(async () => {
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
if (pollInterval) clearInterval(pollInterval)
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
})
@@ -307,6 +310,23 @@ async function handleSendMessage() {
nextTick(() => scrollChatToBottom())
return
}
// Pending share-to-mesh attachment: Send flushes the CID as a ContentRef
// rather than a plain text message. Any text in the input becomes the
// caption. Only valid for direct peers (channel broadcast of content_ref
// isn't in scope for Phase 3c).
if (pendingAttachment.value && activeChatPeer.value) {
sendError.value = ''
try {
const caption = messageText.value.trim() || undefined
await mesh.sendContent(activeChatPeer.value.contact_id, pendingAttachment.value.cid, caption)
messageText.value = ''
pendingAttachment.value = null
nextTick(() => scrollChatToBottom())
} catch (err: unknown) {
sendError.value = err instanceof Error ? err.message : 'Share failed'
}
return
}
if (!messageText.value.trim()) return
sendError.value = ''
try {
@@ -424,6 +444,43 @@ async function handleBlobUpload(ev: Event) {
}
}
// ── share-to-mesh iframe intent (Phase 3c) ────────────────────────────────
// Marketplace app iframes POST a file to `/api/share-to-mesh` then call
// `window.parent.postMessage({type:'share-to-mesh', cid, ...})`. We park the
// CID as a pending attachment; next time the user picks a peer and hits Send
// (with optional caption text), we call mesh.send-content on that CID.
interface PendingAttachment {
cid: string
size: number
mime: string
filename: string | null
self_url?: string
}
const pendingAttachment = ref<PendingAttachment | null>(null)
function loadPendingFromSession() {
try {
const raw = sessionStorage.getItem('archipelago_share_to_mesh')
if (!raw) return
sessionStorage.removeItem('archipelago_share_to_mesh')
const data = JSON.parse(raw) as { cid?: string; size?: number; mime?: string; filename?: string | null; self_url?: string }
if (!data.cid) return
pendingAttachment.value = {
cid: data.cid,
size: data.size ?? 0,
mime: data.mime ?? 'application/octet-stream',
filename: data.filename ?? null,
self_url: data.self_url,
}
} catch {
/* ignore */
}
}
function clearPendingAttachment() {
pendingAttachment.value = null
}
// ── ContentRef attach + fetch (Phase 3b) ──────────────────────────────────
const attaching = ref(false)
const attachError = ref<string | null>(null)
@@ -883,6 +940,12 @@ async function verifyBlobRoundTrip() {
<div class="mesh-chat-compose">
<div v-if="sendError" class="mesh-chat-send-error">{{ sendError }}</div>
<div v-if="attachError" class="mesh-chat-send-error">{{ attachError }}</div>
<div v-if="pendingAttachment" class="mesh-chat-pending-attachment">
<span class="mesh-typed-icon">📎</span>
<span class="mesh-chat-pending-name">{{ pendingAttachment.filename || pendingAttachment.mime }}</span>
<span class="mesh-chat-pending-size">{{ pendingAttachment.size }} B</span>
<button class="mesh-chat-pending-clear" @click="clearPendingAttachment" title="Discard attachment"></button>
</div>
<div class="mesh-chat-compose-row">
<label
v-if="activeChatPeer"
@@ -901,10 +964,10 @@ async function verifyBlobRoundTrip() {
/>
<button
class="glass-button mesh-chat-send-btn"
:disabled="!messageText.trim() || mesh.sending || sendingArch"
:disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch"
@click="handleSendMessage"
>
{{ (mesh.sending || sendingArch) ? '...' : 'Send' }}
{{ (mesh.sending || sendingArch) ? '...' : (pendingAttachment ? 'Share' : 'Send') }}
</button>
</div>
</div>