feat(ui): PeerFiles renders from the shared peer-browse cache (B4)
Some checks failed
Demo images / Build & push demo images (push) Failing after 2m11s
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:
parent
a3f07d5ac6
commit
d605d0d544
@ -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. */
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user