feat(ui): PeerFiles renders from the shared peer-browse cache (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m11s

- PeerFiles.vue reads the SAME `cloud.peer-browse:<onion>` entry Cloud.vue's
  per-peer fan-in fills, so Cloud → peer files paints instantly from cache
  and revalidates behind it; catalog/error/loading/transport are now
  computed views over the store entry.
- preview-peer fan-out is capped at 3 concurrent with a queue (was one 30s
  RPC per media item, all at once, unbounded) and aborts on unmount.
- browse + preview RPCs drop to maxRetries:1 — retry×3 turned one slow
  peer into a 90s spinner.
- fix useCachedResource's interface types: `ReturnType<typeof computed<T>>`
  resolves to the writable overload (WritableComputedRef), which broke
  vue-tsc against the plain computed() returns; use ComputedRef<T>.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-28 04:46:26 -04:00
parent a3f07d5ac6
commit d605d0d544
2 changed files with 72 additions and 34 deletions

View File

@ -22,7 +22,7 @@
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
// the last subscribed component unmounts.
import { computed, getCurrentScope, onScopeDispose } from 'vue'
import { computed, getCurrentScope, onScopeDispose, type ComputedRef } from 'vue'
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
export interface CachedResourceOptions<T> {
@ -44,12 +44,12 @@ export interface CachedResourceOptions<T> {
export interface CachedResource<T> {
entry: ResourceEntry<T>
/** Convenience computed views over the entry. */
data: ReturnType<typeof computed<T | null>>
loadState: ReturnType<typeof computed<ResourceLoadState>>
error: ReturnType<typeof computed<string | null>>
data: ComputedRef<T | null>
loadState: ComputedRef<ResourceLoadState>
error: ComputedRef<string | null>
/** True when data exists but is older than the TTL (drive an age badge). */
isStale: ReturnType<typeof computed<boolean>>
ageMs: ReturnType<typeof computed<number | null>>
isStale: ComputedRef<boolean>
ageMs: ComputedRef<number | null>
/** Force a refresh now (deduped with any in-flight one). */
refresh: () => Promise<void>
/** Mark stale + debounce-refresh all mounted users of this key. */

View File

@ -594,10 +594,11 @@
</template>
<script setup lang="ts">
import { ref, computed, reactive, watch, onMounted } from 'vue'
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import { rpcClient } from '@/api/rpc-client'
import { useResourcesStore } from '@/stores/resources'
import { useAudioPlayer } from '@/composables/useAudioPlayer'
import { pipSupported, togglePip } from '@/utils/pip'
import BackButton from '@/components/BackButton.vue'
@ -625,16 +626,33 @@ interface CatalogItem {
access: string | { paid: { price_sats: number } }
}
const loading = ref(true)
const resources = useResourcesStore()
const currentPeer = ref<PeerNode | null>(null)
const catalogError = ref('')
const catalogItems = ref<CatalogItem[]>([])
const downloading = ref<string | null>(null)
const playing = ref<string | null>(null)
const purchaseError = ref<string | null>(null)
// The catalog is the SAME cached entry Cloud.vue's per-peer fan-in fills
// (`cloud.peer-browse:<onion>`): arriving here from the Cloud page paints
// the file list instantly from cache and revalidates behind it.
interface PeerBrowse {
items: CatalogItem[]
transport: string | null
latencyMs: number
}
const peerOnion = computed(() => props.peerId || currentPeer.value?.onion || '')
function browseEntry() {
return resources.entry<PeerBrowse>(`cloud.peer-browse:${peerOnion.value}`)
}
const catalogItems = computed(() => browseEntry().data?.items ?? [])
const catalogError = computed(() => browseEntry().error ?? '')
const loading = computed(() => {
const s = browseEntry().loadState
return s === 'loading' || s === 'refreshing' || (s === 'idle' && !!peerOnion.value)
})
// Transport actually used to reach this peer (returned by content.browse-peer)
// so we can show a FIPS/Tor pill instead of always assuming Tor (B21).
const transport = ref<string | null>(null)
const transport = computed(() => browseEntry().data?.transport ?? null)
const transportPill = computed(() => {
switch (transport.value) {
case 'fips':
@ -807,44 +825,62 @@ onMounted(async () => {
loadCatalog(),
loadOwned(),
])
} else {
loading.value = false
}
// No peerId peerOnion is empty and `loading` stays false on its own.
})
async function loadCatalog() {
const onion = props.peerId || currentPeer.value?.onion
if (!onion) return
const hadItems = catalogItems.value.length > 0
loading.value = true
catalogError.value = ''
try {
function loadCatalog(): Promise<void> {
const onion = peerOnion.value
if (!onion) return Promise.resolve()
return resources.refresh<PeerBrowse>(`cloud.peer-browse:${onion}`, async () => {
const t0 = Date.now()
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
method: 'content.browse-peer',
params: { onion },
timeout: 30000,
// The caller has its own timeout UX; retry×3 turned one slow peer
// into a 90s spinner.
maxRetries: 1,
dedup: true,
})
return { items: result?.items ?? [], transport: result?.transport ?? null, latencyMs: Date.now() - t0 }
})
}
// Load visual previews for image and video items when catalog loads.
// Audio files don't need visual thumbnails they show a waveform icon.
// The fan-out is capped (3 concurrent) and aborts on unmount it used to
// fire one 30s RPC per media item all at once, unbounded.
const previewAborter = new AbortController()
onUnmounted(() => previewAborter.abort())
const previewQueued = new Set<string>()
let previewQueue: CatalogItem[] = []
let previewWorkers = 0
const PREVIEW_CONCURRENCY = 3
function pumpPreviews(onion: string) {
while (previewWorkers < PREVIEW_CONCURRENCY && previewQueue.length > 0) {
const item = previewQueue.shift()!
previewWorkers++
void loadPreview(onion, item).finally(() => {
previewWorkers--
pumpPreviews(onion)
})
catalogItems.value = result?.items ?? []
transport.value = result?.transport ?? null
} catch (e: unknown) {
catalogError.value = e instanceof Error ? e.message : 'Failed to connect to peer'
if (!hadItems) catalogItems.value = []
} finally {
loading.value = false
}
}
// Load visual previews for image and video items when catalog loads
// Audio files don't need visual thumbnails they show a waveform icon
watch(catalogItems, async (items) => {
const onion = props.peerId || currentPeer.value?.onion
watch(catalogItems, (items) => {
const onion = peerOnion.value
if (!onion) return
for (const item of items) {
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) {
loadPreview(onion, item)
const isVisual = item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')
if (isVisual && !previewUrls[item.id] && !previewQueued.has(item.id)) {
previewQueued.add(item.id)
previewQueue.push(item)
}
}
})
pumpPreviews(onion)
}, { immediate: true })
async function loadPreview(onion: string, item: CatalogItem) {
try {
@ -852,6 +888,8 @@ async function loadPreview(onion: string, item: CatalogItem) {
method: 'content.preview-peer',
params: { onion, content_id: item.id },
timeout: 30000,
maxRetries: 1,
signal: previewAborter.signal,
})
if (result?.data) {
const mime = result.content_type || item.mime_type