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
|
||||
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.'
|
||||
}
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user