fix(peer-files): free-image lightbox, click-to-open routing, in-app open after every payment rail

- Card click now dispatches through openItem(): owned -> cached viewer,
  paid+playable -> 10% preview, paid non-playable -> pay modal (image is
  never fetched pre-purchase), FREE image -> full-screen lightbox streaming
  from /api/peer-content (fixes the click no-op where the old ternary fell
  through to undefined for non-playable free items)
- Viewer footer caption is state-aware: green 'Owned · unlocked' only for
  owned items, neutral 'Free · shared by peer' for free ones; Save streams
  free files instead of calling content.owned-get
- Lightning / invoice-QR / on-chain payment successes now share
  openPurchased() with the ecash flow: mark owned, autoplay audio in the
  bottom bar or open image/video in the viewer (previously a browser
  download that silently fails on the mobile companion)
- closeViewer only revokes blob: URLs (free items use plain stream URLs)
- Added a regression test: free image click opens the lightbox

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-29 13:51:36 -04:00
parent 14d1a453c7
commit f52c540744
2 changed files with 146 additions and 36 deletions

View File

@ -88,7 +88,7 @@
<div
v-if="isMediaMime(item.mime_type)"
class="relative aspect-video overflow-hidden cursor-pointer group"
@click="isOwned(item) ? viewOwned(item) : (isPlayable(item.mime_type) ? playMedia(item) : undefined)"
@click="openItem(item)"
>
<img
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
@ -351,11 +351,12 @@
<div class="mt-3 flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-sm font-medium text-white truncate">{{ viewerItem.filename.split('/').pop() }}</p>
<p class="text-xs text-green-400">Owned · unlocked</p>
<p v-if="isOwned(viewerItem)" class="text-xs text-green-400">Owned · unlocked</p>
<p v-else class="text-xs text-white/50">Free · shared by peer</p>
</div>
<button
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5 shrink-0"
@click="saveOwned(viewerItem)"
@click="saveViewerItem(viewerItem)"
>
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
@ -695,10 +696,17 @@ function base64ToBlob(base64: string, mime: string): Blob {
return new Blob([Uint8Array.from(atob(base64), c => c.charCodeAt(0))], { type: mime })
}
// In-app viewer for owned content (image / video / audio), served from cache.
// In-app viewer (lightbox) for owned content AND free images. Owned content is
// loaded as a blob URL from the purchase cache; free images point straight at
// the Range-capable streaming proxy (no base64 round-trip).
const viewerItem = ref<CatalogItem | null>(null)
const viewerUrl = ref<string | null>(null)
const viewerMime = ref<string>('')
/** Revoke the current viewer URL only when it's a blob: URL free items use a
* plain /api/peer-content URL that must not be passed to revokeObjectURL. */
function releaseViewerUrl() {
if (viewerUrl.value?.startsWith('blob:')) URL.revokeObjectURL(viewerUrl.value)
}
async function viewOwned(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
@ -719,7 +727,7 @@ async function viewOwned(item: CatalogItem) {
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
return
}
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
releaseViewerUrl()
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
viewerMime.value = mime
viewerItem.value = item
@ -730,11 +738,56 @@ async function viewOwned(item: CatalogItem) {
}
}
function closeViewer() {
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
releaseViewerUrl()
viewerUrl.value = null
viewerItem.value = null
viewerMime.value = ''
}
/**
* Card click dispatcher: every peer file opens in the appropriate viewer.
* - owned in-app viewer / bottom-bar player from the purchase cache
* - paid + playable 10% preview (existing behaviour)
* - paid + non-playable (blurred images etc.) pay modal the full content
* is never fetched or revealed before purchase
* - free image full-screen lightbox streaming from the Range proxy
* - free audio/video bottom-bar player / video modal (via playMedia)
*/
function openItem(item: CatalogItem) {
if (isOwned(item)) {
void viewOwned(item)
return
}
if (isPaidItem(item.access)) {
if (isPlayable(item.mime_type)) void playMedia(item)
else openPayModal(item)
return
}
if (isPlayable(item.mime_type)) {
void playMedia(item)
return
}
if (item.mime_type.startsWith('image/')) {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
releaseViewerUrl()
viewerUrl.value = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
viewerMime.value = item.mime_type
viewerItem.value = item
}
}
/** Viewer footer Save: owned content saves from the purchase cache; free
* content streams from the Range proxy. */
function saveViewerItem(item: CatalogItem) {
if (isOwned(item)) {
void saveOwned(item)
return
}
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
streamDownload(`/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`, item)
}
// Save the owned file to disk from cache (the optional "downloadable" path).
async function saveOwned(item: CatalogItem) {
const onion = props.peerId || currentPeer.value?.onion
@ -1205,15 +1258,14 @@ async function pollOnchain(address: string) {
timeout: 30000,
})
if (res?.paid) {
const dl = await rpcClient.call<{ data?: string; error?: string }>({
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-onchain',
params: { onion, content_id: item.id, address },
timeout: 120000,
})
onchainPaying.value = false
if (dl?.data) {
triggerDownload(dl.data, item)
closePayModal()
openPurchased(item, dl.data, dl.mime_type)
} else {
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
@ -1273,6 +1325,36 @@ async function prepareEcashPay() {
}
}
/**
* Shared post-payment success handling for ALL payment rails (ecash,
* lightning-from-node, external invoice QR, on-chain): mark the item owned,
* open it in-app audio autoplays in the global bottom-bar player,
* image/video open in the viewer modal and refresh the owned list.
* A browser download used to fire here instead, which silently fails on the
* mobile companion ("paid but never unlocked"); the viewer's Save button
* still offers an explicit download.
*/
function openPurchased(item: CatalogItem, base64Data: string, mimeType?: string) {
const onion = props.peerId || currentPeer.value?.onion
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = mimeType || item.mime_type
if (mime.startsWith('audio/')) {
// Straight to the bottom-bar player the blob URL intentionally stays
// alive while the bar owns playback.
audioPlayer.play(
URL.createObjectURL(base64ToBlob(base64Data, mime)),
item.filename.split('/').pop() || item.filename,
)
} else {
releaseViewerUrl()
viewerUrl.value = URL.createObjectURL(base64ToBlob(base64Data, mime))
viewerMime.value = mime
viewerItem.value = item
}
closePayModal()
void loadOwned()
}
/** Confirm the ecash payment with the backend the user selected. */
async function confirmEcashPay() {
const item = payItem.value
@ -1291,27 +1373,7 @@ async function confirmEcashPay() {
})
if (result?.data) {
// The purchase is now cached + owned by this node (backend persisted it).
// Mark it owned and open the in-app viewer rather than firing a browser
// download the latter silently fails on the mobile companion, which is
// why a paid item used to never "unlock". The item stays unlocked going
// forward; the viewer offers a Save button for an explicit download.
ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = result.mime_type || item.mime_type
// A just-bought song goes straight to the bottom-bar player the
// owned-content lightbox is for images/video only.
if (mime.startsWith('audio/')) {
audioPlayer.play(
URL.createObjectURL(base64ToBlob(result.data, mime)),
item.filename.split('/').pop() || item.filename,
)
} else {
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
viewerUrl.value = URL.createObjectURL(base64ToBlob(result.data, mime))
viewerMime.value = mime
viewerItem.value = item
}
closePayModal()
void loadOwned()
openPurchased(item, result.data, result.mime_type)
} else if (result?.error) {
// Keep the confirm screen open so the user can switch backend and retry.
purchaseError.value = result.error
@ -1391,14 +1453,13 @@ async function payWithLightning() {
return
}
// 3. Settled pull the file using the payment hash as the gate token.
const dl = await rpcClient.call<{ data?: string; error?: string }>({
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 120000,
})
if (dl?.data) {
triggerDownload(dl.data, item)
closePayModal()
openPurchased(item, dl.data, dl.mime_type)
} else {
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
@ -1428,14 +1489,13 @@ async function pollInvoice() {
if (res?.paid) {
// Settled pull the file using the payment hash as the gate token.
invoiceWaiting.value = false
const dl = await rpcClient.call<{ data?: string; error?: string }>({
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
timeout: 120000,
})
if (dl?.data) {
triggerDownload(dl.data, item)
closePayModal()
openPurchased(item, dl.data, dl.mime_type)
} else {
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}

View File

@ -85,4 +85,54 @@ describe('PeerFiles', () => {
expect(wrapper.text()).toContain('offline')
expect(wrapper.text()).not.toContain('Refreshing peer files...')
})
it('opens the full-screen lightbox when a FREE image card is clicked', async () => {
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({
nodes: [{
did: 'did:key:peer',
pubkey: 'peer',
onion: 'peer.onion',
name: 'Peer',
trust_level: 'trusted',
}],
} as never)
const freeImage = {
id: 'photo-1',
filename: 'sunset.jpg',
mime_type: 'image/jpeg',
size_bytes: 1024,
description: '',
access: 'free',
}
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') return { items: [freeImage] }
if (req.method === 'content.owned-list') return { items: [] }
return {}
}) as never)
const wrapper = mount(PeerFiles, {
props: { peerId: 'peer.onion' },
global: {
plugins: [createPinia()],
stubs: {
Teleport: true,
},
},
})
await flushPromises()
expect(wrapper.text()).toContain('sunset.jpg')
// The free image card click was previously a no-op (the old ternary fell
// through to `undefined` for non-playable free items).
await wrapper.find('.aspect-video').trigger('click')
await flushPromises()
const viewerImg = wrapper
.findAll('img')
.find(img => (img.attributes('src') || '').includes('/api/peer-content/'))
expect(viewerImg).toBeTruthy()
expect(viewerImg!.attributes('src')).toContain('photo-1')
expect(wrapper.text()).toContain('Free · shared by peer')
})
})