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:
parent
471d57f4ff
commit
a530a906b8
@ -212,6 +212,7 @@ onMounted(async () => {
|
|||||||
window.addEventListener('mousedown', onUserActivity)
|
window.addEventListener('mousedown', onUserActivity)
|
||||||
window.addEventListener('keydown', onUserActivity)
|
window.addEventListener('keydown', onUserActivity)
|
||||||
window.addEventListener('touchstart', onUserActivity)
|
window.addEventListener('touchstart', onUserActivity)
|
||||||
|
window.addEventListener('message', onShareToMeshMessage)
|
||||||
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
const seenIntro = localStorage.getItem('neode_intro_seen') === '1'
|
||||||
const isDirectRoute = route.path !== '/'
|
const isDirectRoute = route.path !== '/'
|
||||||
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
const fromBoot = sessionStorage.getItem('archipelago_from_boot') === '1'
|
||||||
@ -241,8 +242,31 @@ onBeforeUnmount(() => {
|
|||||||
window.removeEventListener('mousedown', onUserActivity)
|
window.removeEventListener('mousedown', onUserActivity)
|
||||||
window.removeEventListener('keydown', onUserActivity)
|
window.removeEventListener('keydown', onUserActivity)
|
||||||
window.removeEventListener('touchstart', 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
|
* Handle splash screen completion
|
||||||
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
* Routes user directly to appropriate screen based on onboarding status (from backend)
|
||||||
|
|||||||
@ -177,6 +177,8 @@ async function handleToggleOffGrid() {
|
|||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('resize', handleResize)
|
window.addEventListener('resize', handleResize)
|
||||||
|
window.addEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||||
|
loadPendingFromSession()
|
||||||
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
|
await Promise.all([mesh.refreshAll(), transport.fetchStatus()])
|
||||||
// Start background polling for Archipelago (Tor) messages so unread count works
|
// Start background polling for Archipelago (Tor) messages so unread count works
|
||||||
loadArchMessages()
|
loadArchMessages()
|
||||||
@ -194,6 +196,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
|
window.removeEventListener('archipelago:share-to-mesh', loadPendingFromSession)
|
||||||
if (pollInterval) clearInterval(pollInterval)
|
if (pollInterval) clearInterval(pollInterval)
|
||||||
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
if (archPollInterval) { clearInterval(archPollInterval); archPollInterval = null }
|
||||||
})
|
})
|
||||||
@ -307,6 +310,23 @@ async function handleSendMessage() {
|
|||||||
nextTick(() => scrollChatToBottom())
|
nextTick(() => scrollChatToBottom())
|
||||||
return
|
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
|
if (!messageText.value.trim()) return
|
||||||
sendError.value = ''
|
sendError.value = ''
|
||||||
try {
|
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) ──────────────────────────────────
|
// ── ContentRef attach + fetch (Phase 3b) ──────────────────────────────────
|
||||||
const attaching = ref(false)
|
const attaching = ref(false)
|
||||||
const attachError = ref<string | null>(null)
|
const attachError = ref<string | null>(null)
|
||||||
@ -883,6 +940,12 @@ async function verifyBlobRoundTrip() {
|
|||||||
<div class="mesh-chat-compose">
|
<div class="mesh-chat-compose">
|
||||||
<div v-if="sendError" class="mesh-chat-send-error">{{ sendError }}</div>
|
<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="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">
|
<div class="mesh-chat-compose-row">
|
||||||
<label
|
<label
|
||||||
v-if="activeChatPeer"
|
v-if="activeChatPeer"
|
||||||
@ -901,10 +964,10 @@ async function verifyBlobRoundTrip() {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
class="glass-button mesh-chat-send-btn"
|
class="glass-button mesh-chat-send-btn"
|
||||||
:disabled="!messageText.trim() || mesh.sending || sendingArch"
|
:disabled="(!messageText.trim() && !pendingAttachment) || mesh.sending || sendingArch"
|
||||||
@click="handleSendMessage"
|
@click="handleSendMessage"
|
||||||
>
|
>
|
||||||
{{ (mesh.sending || sendingArch) ? '...' : 'Send' }}
|
{{ (mesh.sending || sendingArch) ? '...' : (pendingAttachment ? 'Share' : 'Send') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user