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:
parent
14d1a453c7
commit
f52c540744
@ -88,7 +88,7 @@
|
|||||||
<div
|
<div
|
||||||
v-if="isMediaMime(item.mime_type)"
|
v-if="isMediaMime(item.mime_type)"
|
||||||
class="relative aspect-video overflow-hidden cursor-pointer group"
|
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
|
<img
|
||||||
v-if="item.mime_type.startsWith('image/') && previewUrls[item.id]"
|
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="mt-3 flex items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-medium text-white truncate">{{ viewerItem.filename.split('/').pop() }}</p>
|
<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>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="glass-button px-3 py-1.5 rounded-lg text-xs font-medium flex items-center gap-1.5 shrink-0"
|
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">
|
<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" />
|
<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 })
|
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 viewerItem = ref<CatalogItem | null>(null)
|
||||||
const viewerUrl = ref<string | null>(null)
|
const viewerUrl = ref<string | null>(null)
|
||||||
const viewerMime = ref<string>('')
|
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) {
|
async function viewOwned(item: CatalogItem) {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
if (!onion) return
|
if (!onion) return
|
||||||
@ -719,7 +727,7 @@ async function viewOwned(item: CatalogItem) {
|
|||||||
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
|
audioPlayer.play(url, item.filename.split('/').pop() || item.filename)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
releaseViewerUrl()
|
||||||
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
viewerUrl.value = URL.createObjectURL(base64ToBlob(res.data, mime))
|
||||||
viewerMime.value = mime
|
viewerMime.value = mime
|
||||||
viewerItem.value = item
|
viewerItem.value = item
|
||||||
@ -730,11 +738,56 @@ async function viewOwned(item: CatalogItem) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function closeViewer() {
|
function closeViewer() {
|
||||||
if (viewerUrl.value) URL.revokeObjectURL(viewerUrl.value)
|
releaseViewerUrl()
|
||||||
viewerUrl.value = null
|
viewerUrl.value = null
|
||||||
viewerItem.value = null
|
viewerItem.value = null
|
||||||
viewerMime.value = ''
|
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).
|
// Save the owned file to disk from cache (the optional "downloadable" path).
|
||||||
async function saveOwned(item: CatalogItem) {
|
async function saveOwned(item: CatalogItem) {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = props.peerId || currentPeer.value?.onion
|
||||||
@ -1205,15 +1258,14 @@ async function pollOnchain(address: string) {
|
|||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
})
|
})
|
||||||
if (res?.paid) {
|
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',
|
method: 'content.download-peer-onchain',
|
||||||
params: { onion, content_id: item.id, address },
|
params: { onion, content_id: item.id, address },
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
onchainPaying.value = false
|
onchainPaying.value = false
|
||||||
if (dl?.data) {
|
if (dl?.data) {
|
||||||
triggerDownload(dl.data, item)
|
openPurchased(item, dl.data, dl.mime_type)
|
||||||
closePayModal()
|
|
||||||
} else {
|
} else {
|
||||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
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. */
|
/** Confirm the ecash payment with the backend the user selected. */
|
||||||
async function confirmEcashPay() {
|
async function confirmEcashPay() {
|
||||||
const item = payItem.value
|
const item = payItem.value
|
||||||
@ -1291,27 +1373,7 @@ async function confirmEcashPay() {
|
|||||||
})
|
})
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
// The purchase is now cached + owned by this node (backend persisted it).
|
// 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
|
openPurchased(item, result.data, result.mime_type)
|
||||||
// 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()
|
|
||||||
} else if (result?.error) {
|
} else if (result?.error) {
|
||||||
// Keep the confirm screen open so the user can switch backend and retry.
|
// Keep the confirm screen open so the user can switch backend and retry.
|
||||||
purchaseError.value = result.error
|
purchaseError.value = result.error
|
||||||
@ -1391,14 +1453,13 @@ async function payWithLightning() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 3. Settled — pull the file using the payment hash as the gate token.
|
// 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',
|
method: 'content.download-peer-invoice',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
if (dl?.data) {
|
if (dl?.data) {
|
||||||
triggerDownload(dl.data, item)
|
openPurchased(item, dl.data, dl.mime_type)
|
||||||
closePayModal()
|
|
||||||
} else {
|
} else {
|
||||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||||
}
|
}
|
||||||
@ -1428,14 +1489,13 @@ async function pollInvoice() {
|
|||||||
if (res?.paid) {
|
if (res?.paid) {
|
||||||
// Settled — pull the file using the payment hash as the gate token.
|
// Settled — pull the file using the payment hash as the gate token.
|
||||||
invoiceWaiting.value = false
|
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',
|
method: 'content.download-peer-invoice',
|
||||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||||
timeout: 120000,
|
timeout: 120000,
|
||||||
})
|
})
|
||||||
if (dl?.data) {
|
if (dl?.data) {
|
||||||
triggerDownload(dl.data, item)
|
openPurchased(item, dl.data, dl.mime_type)
|
||||||
closePayModal()
|
|
||||||
} else {
|
} else {
|
||||||
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||||
}
|
}
|
||||||
|
|||||||
@ -85,4 +85,54 @@ describe('PeerFiles', () => {
|
|||||||
expect(wrapper.text()).toContain('offline')
|
expect(wrapper.text()).toContain('offline')
|
||||||
expect(wrapper.text()).not.toContain('Refreshing peer files...')
|
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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user