From bc9a210c759822f1c9d53d8efbd31731da143f47 Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 31 Jul 2026 22:17:57 -0400 Subject: [PATCH] fix(01-14): route Paid Files pictures/videos into the app lightbox with a visible wait (UIFIX-04/UIFIX-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud.vue's viewPaidItem() called window.open() instead of the in-app MediaLightbox, and its content.owned-get fetch had a 60s timeout with no loading indicator and a swallowed catch. Moves the fetch/decode/route logic into a new usePaidItemViewer composable: image/video route to a second MediaLightbox instance fed a synthetic FileBrowserItem, audio still goes to the global bottom-bar player, and anything with no in-app viewer keeps today's browser-tab fallback. The Paid Files row now shows an "Opening…" spinner (matching PeerFiles' existing treatment) for the fetch's duration, becomes non-interactive to prevent double-fetch, and a real error surfaces through the view's existing alert-error block instead of an empty catch. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/usePaidItemViewer.test.ts | 189 ++++++++++++++++++ neode-ui/src/composables/usePaidItemViewer.ts | 139 +++++++++++++ neode-ui/src/views/Cloud.vue | 55 ++--- 3 files changed, 358 insertions(+), 25 deletions(-) create mode 100644 neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts create mode 100644 neode-ui/src/composables/usePaidItemViewer.ts diff --git a/neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts b/neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts new file mode 100644 index 00000000..0b2f80b9 --- /dev/null +++ b/neode-ui/src/composables/__tests__/usePaidItemViewer.test.ts @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// ── Module boundary stubs (per plan: jsdom has no real blob decoding — +// assert on what was requested and what was routed where, not byte content) ── +vi.mock('@/api/rpc-client', () => ({ + rpcClient: { + call: vi.fn(), + }, +})) + +const playMock = vi.fn() +vi.mock('../useAudioPlayer', () => ({ + useAudioPlayer: () => ({ play: playMock }), +})) + +import { rpcClient } from '@/api/rpc-client' +import { usePaidItemViewer, paidItemKey, type OwnedItemLike } from '../usePaidItemViewer' + +const mockedRpc = vi.mocked(rpcClient) + +const IMAGE_ITEM: OwnedItemLike = { + onion: 'abc123.onion', + content_id: 'content-1', + filename: 'photos/sunset.jpg', + mime_type: 'image/jpeg', + size_bytes: 2048, +} +const VIDEO_ITEM: OwnedItemLike = { + onion: 'abc123.onion', + content_id: 'content-2', + filename: 'clips/holiday.mp4', + mime_type: 'video/mp4', + size_bytes: 4096, +} +const AUDIO_ITEM: OwnedItemLike = { + onion: 'abc123.onion', + content_id: 'content-3', + filename: 'music/track.mp3', + mime_type: 'audio/mpeg', + size_bytes: 1024, +} +const DOC_ITEM: OwnedItemLike = { + onion: 'abc123.onion', + content_id: 'content-4', + filename: 'docs/invoice.pdf', + mime_type: 'application/pdf', + size_bytes: 512, +} + +function deferred() { + let resolve!: (v: T) => void + let reject!: (e: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +describe('usePaidItemViewer — UIFIX-04 (lightbox routing) + UIFIX-06 (loading/error)', () => { + let createObjectURLSpy: ReturnType + let revokeObjectURLSpy: ReturnType + let windowOpenSpy: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + createObjectURLSpy = vi.fn(() => 'blob:mock-url') + revokeObjectURLSpy = vi.fn() + URL.createObjectURL = createObjectURLSpy as unknown as typeof URL.createObjectURL + URL.revokeObjectURL = revokeObjectURLSpy as unknown as typeof URL.revokeObjectURL + windowOpenSpy = vi.fn() + window.open = windowOpenSpy as unknown as typeof window.open + // atob is provided by jsdom; stub it to avoid depending on real base64 semantics. + vi.stubGlobal('atob', vi.fn(() => 'binarydata')) + }) + + it('routes an image mime to the lightbox, not window.open', async () => { + mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' }) + const viewer = usePaidItemViewer() + + await viewer.open(IMAGE_ITEM) + + expect(windowOpenSpy).not.toHaveBeenCalled() + expect(viewer.lightboxIndex.value).toBe(0) + expect(viewer.lightboxItems.value).toHaveLength(1) + expect(viewer.error.value).toBeNull() + }) + + it('routes a video mime to the lightbox, not window.open', async () => { + mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'video/mp4' }) + const viewer = usePaidItemViewer() + + await viewer.open(VIDEO_ITEM) + + expect(windowOpenSpy).not.toHaveBeenCalled() + expect(viewer.lightboxIndex.value).toBe(0) + expect(viewer.lightboxItems.value[0].name).toBe('holiday.mp4') + }) + + it('routes an audio mime to the audio player, never the lightbox', async () => { + mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'audio/mpeg' }) + const viewer = usePaidItemViewer() + + await viewer.open(AUDIO_ITEM) + + expect(playMock).toHaveBeenCalledWith('blob:mock-url', 'track.mp3') + expect(viewer.lightboxIndex.value).toBeNull() + expect(windowOpenSpy).not.toHaveBeenCalled() + }) + + it('falls back to the browser tab for a mime with no in-app viewer', async () => { + mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'application/pdf' }) + const viewer = usePaidItemViewer() + + await viewer.open(DOC_ITEM) + + expect(windowOpenSpy).toHaveBeenCalledWith('blob:mock-url', '_blank', 'noopener') + expect(viewer.lightboxIndex.value).toBeNull() + // Existing revoke timer for the browser-tab path is untouched. + vi.advanceTimersByTime(60000) + expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url') + }) + + it('the synthetic lightbox item name carries the real extension', async () => { + mockedRpc.call.mockResolvedValue({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' }) + const viewer = usePaidItemViewer() + + await viewer.open(IMAGE_ITEM) + + expect(viewer.lightboxItems.value[0].name.endsWith('.jpg')).toBe(true) + }) + + it('sets opening for the whole duration of the fetch and clears it on success', async () => { + const d = deferred<{ data_base64: string; mime_type: string }>() + mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType) + const viewer = usePaidItemViewer() + + const p = viewer.open(IMAGE_ITEM) + expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM)) + + d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' }) + await p + + expect(viewer.opening.value).toBeNull() + }) + + it('clears opening and does not derive it from a background-refresh flag — it is driven only by the fetch in flight', async () => { + // No cached-resource / refreshing concept is wired into this composable at + // all: opening only ever reflects the current open() call's own RPC. + const d = deferred<{ data_base64: string; mime_type: string }>() + mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType) + const viewer = usePaidItemViewer() + + expect(viewer.opening.value).toBeNull() // idle before any open() + const p = viewer.open(IMAGE_ITEM) + expect(viewer.opening.value).toBe(paidItemKey(IMAGE_ITEM)) + d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' }) + await p + expect(viewer.opening.value).toBeNull() // back to idle the instant the fetch settles — no lingering "refreshing" state + }) + + it('surfaces a rejected/timed-out fetch as an error, clears opening, and does not throw past the caller', async () => { + mockedRpc.call.mockRejectedValue(new Error('Request timeout')) + const viewer = usePaidItemViewer() + + await expect(viewer.open(IMAGE_ITEM)).resolves.toBeUndefined() + + expect(viewer.error.value).toBeTruthy() + expect(viewer.opening.value).toBeNull() + expect(viewer.lightboxIndex.value).toBeNull() + }) + + it('issues exactly one RPC when open() is called twice in quick succession for the same item', async () => { + const d = deferred<{ data_base64: string; mime_type: string }>() + mockedRpc.call.mockReturnValue(d.promise as unknown as ReturnType) + const viewer = usePaidItemViewer() + + const p1 = viewer.open(IMAGE_ITEM) + const p2 = viewer.open(IMAGE_ITEM) + + expect(mockedRpc.call).toHaveBeenCalledTimes(1) + + d.resolve({ data_base64: 'ZmFrZQ==', mime_type: 'image/jpeg' }) + await Promise.all([p1, p2]) + + expect(mockedRpc.call).toHaveBeenCalledTimes(1) + }) +}) diff --git a/neode-ui/src/composables/usePaidItemViewer.ts b/neode-ui/src/composables/usePaidItemViewer.ts new file mode 100644 index 00000000..27913bd2 --- /dev/null +++ b/neode-ui/src/composables/usePaidItemViewer.ts @@ -0,0 +1,139 @@ +import { ref } from 'vue' +import { rpcClient } from '@/api/rpc-client' +import type { FileBrowserItem } from '@/api/filebrowser-client' +import { useAudioPlayer } from './useAudioPlayer' + +/** + * Shape of a purchased item as listed by `content.owned-list` — the minimum + * fields this composable needs to fetch and route it. Cloud.vue's `PaidItem` + * interface satisfies this structurally. + */ +export interface OwnedItemLike { + onion: string + content_id: string + filename: string + mime_type: string + size_bytes: number +} + +/** Same key formula the Paid Files row uses for `:key` — one key, one row. */ +export function paidItemKey(it: OwnedItemLike): string { + return it.onion + it.content_id +} + +/** + * Fetch, decode, route-to-viewer and loading/error state for a purchased + * item (UIFIX-04 / UIFIX-06). + * + * - image/video → routed into the caller's MediaLightbox via `lightboxItems` + * / `lightboxIndex`, fed a synthetic FileBrowserItem whose blob URL is + * served back through `resolveBlobUrl`. The lightbox owns revoking that + * URL on unmount — this composable must never schedule a competing revoke + * for a URL it has handed to the lightbox. + * - audio → today's global bottom-bar player, unchanged. + * - anything else (no in-app viewer) → today's browser-tab fallback, + * unchanged, including its own revoke timer. + */ +export function usePaidItemViewer() { + const audioPlayer = useAudioPlayer() + + const opening = ref(null) + const error = ref(null) + const lightboxItems = ref([]) + const lightboxIndex = ref(null) + + // Synthetic-path -> already-fetched blob URL. Populated only for items + // routed to the lightbox; resolveBlobUrl reads from here and never fetches. + const urlByPath = new Map() + // One in-flight fetch per item key — a second open() for the same key + // while the first is pending returns the same promise instead of issuing + // a second RPC (UIFIX-04 concurrency edge / T-01-65). + const inFlight = new Map>() + + async function resolveBlobUrl(path: string): Promise { + const url = urlByPath.get(path) + if (!url) throw new Error('Not resolved') + return url + } + + function runOpen(it: OwnedItemLike, key: string): Promise { + return (async () => { + opening.value = key + error.value = null + try { + const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({ + method: 'content.owned-get', + params: { onion: it.onion, content_id: it.content_id }, + timeout: 60000, + }) + const b64 = res.data_base64 || res.data + if (!b64) { + error.value = "Couldn't open this file — the peer returned no data." + return + } + const bin = atob(b64) + const arr = new Uint8Array(bin.length) + for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i) + const mime = res.mime_type || it.mime_type + const url = URL.createObjectURL(new Blob([arr], { type: mime })) + + // Music ALWAYS plays in the global bottom-bar player — never a + // lightbox (blob URL stays alive for the bar; it owns playback now). + if (mime.startsWith('audio/')) { + audioPlayer.play(url, it.filename.split('/').pop() || it.filename) + return + } + + if (mime.startsWith('image/') || mime.startsWith('video/')) { + const basename = it.filename.split('/').pop() || it.filename + const path = `paid://${key}` + urlByPath.set(path, url) + const synthetic: FileBrowserItem = { + name: basename, + path, + size: it.size_bytes, + modified: '', + isDir: false, + type: mime, + extension: basename.includes('.') ? basename.split('.').pop()!.toLowerCase() : '', + } + lightboxItems.value = [synthetic] + lightboxIndex.value = 0 + return + } + + // No in-app viewer (documents, etc.) — keep today's behaviour. + window.open(url, '_blank', 'noopener') + setTimeout(() => URL.revokeObjectURL(url), 60000) + } catch { + error.value = "Couldn't open this file — it may be unavailable right now." + } finally { + opening.value = null + inFlight.delete(key) + } + })() + } + + function open(it: OwnedItemLike): Promise { + const key = paidItemKey(it) + const existing = inFlight.get(key) + if (existing) return existing + const task = runOpen(it, key) + inFlight.set(key, task) + return task + } + + function closeLightbox() { + lightboxIndex.value = null + } + + return { + opening, + error, + lightboxItems, + lightboxIndex, + resolveBlobUrl, + open, + closeLightbox, + } +} diff --git a/neode-ui/src/views/Cloud.vue b/neode-ui/src/views/Cloud.vue index 6c4352d3..d83a397d 100644 --- a/neode-ui/src/views/Cloud.vue +++ b/neode-ui/src/views/Cloud.vue @@ -161,8 +161,9 @@
{{ it.mime_type.startsWith('image/') ? '🖼️' : it.mime_type.startsWith('video/') ? '🎬' : it.mime_type.startsWith('audio/') ? '🎵' : '📄' }}
@@ -173,7 +174,14 @@ · {{ new Date(it.purchased_at).toLocaleDateString() }}

- Paid + + + Opening… + + Paid
@@ -398,6 +406,18 @@ :stream-url="cloudStore.streamUrl" @close="lightboxIndex = null" /> + + + @@ -412,6 +432,7 @@ import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-clien import { rpcClient } from '@/api/rpc-client' import { getFileCategory } from '../composables/useFileType' import { useAudioPlayer } from '../composables/useAudioPlayer' +import { usePaidItemViewer, paidItemKey } from '../composables/usePaidItemViewer' import FileCard from '../components/cloud/FileCard.vue' import ShareModal from '../components/cloud/ShareModal.vue' import MediaLightbox from '../components/cloud/MediaLightbox.vue' @@ -469,29 +490,13 @@ const paidLoading = computed(() => paidResource.entry.loadState === 'loading') function loadPaidItems() { return paidResource.refresh() } +// Fetch, decode, route-to-viewer state for a purchased item (UIFIX-04 +// lightbox routing + UIFIX-06 loading/error surfacing). See the composable +// for the mime-routing rules and URL-ownership contract. +const paidItemViewer = usePaidItemViewer() async function viewPaidItem(it: PaidItem) { - try { - const res = await rpcClient.call<{ data_base64?: string; data?: string; mime_type?: string }>({ - method: 'content.owned-get', - params: { onion: it.onion, content_id: it.content_id }, - timeout: 60000, - }) - const b64 = res.data_base64 || res.data - if (!b64) return - const bin = atob(b64) - const arr = new Uint8Array(bin.length) - for (let i = 0; i < bin.length; i++) arr[i] = bin.charCodeAt(i) - const mime = res.mime_type || it.mime_type - const url = URL.createObjectURL(new Blob([arr], { type: mime })) - // Music ALWAYS plays in the global bottom-bar player — never a popup/ - // lightbox (blob URL stays alive for the bar; it owns playback now). - if (mime.startsWith('audio/')) { - audioPlayer.play(url, it.filename.split('/').pop() || it.filename) - return - } - window.open(url, '_blank', 'noopener') - setTimeout(() => URL.revokeObjectURL(url), 60000) - } catch { /* viewer is best-effort; the file is also in the user's folders */ } + await paidItemViewer.open(it) + if (paidItemViewer.error.value) loadError.value = paidItemViewer.error.value } watch(activeTab, (t) => { if (t === 'paid') void loadPaidItems() })