feat(ui): PeerFiles renders from the shared peer-browse cache (B4)
Demo images / Build & push demo images (push) Failing after 2m11s
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:
co-authored by
Claude Fable 5
parent
a3f07d5ac6
commit
d605d0d544
@@ -22,7 +22,7 @@
|
|||||||
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
|
// - Abort-on-unmount: the fetcher receives an AbortSignal that fires when
|
||||||
// the last subscribed component unmounts.
|
// 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'
|
import { useResourcesStore, type ResourceEntry, type ResourceLoadState } from '@/stores/resources'
|
||||||
|
|
||||||
export interface CachedResourceOptions<T> {
|
export interface CachedResourceOptions<T> {
|
||||||
@@ -44,12 +44,12 @@ export interface CachedResourceOptions<T> {
|
|||||||
export interface CachedResource<T> {
|
export interface CachedResource<T> {
|
||||||
entry: ResourceEntry<T>
|
entry: ResourceEntry<T>
|
||||||
/** Convenience computed views over the entry. */
|
/** Convenience computed views over the entry. */
|
||||||
data: ReturnType<typeof computed<T | null>>
|
data: ComputedRef<T | null>
|
||||||
loadState: ReturnType<typeof computed<ResourceLoadState>>
|
loadState: ComputedRef<ResourceLoadState>
|
||||||
error: ReturnType<typeof computed<string | null>>
|
error: ComputedRef<string | null>
|
||||||
/** True when data exists but is older than the TTL (drive an age badge). */
|
/** True when data exists but is older than the TTL (drive an age badge). */
|
||||||
isStale: ReturnType<typeof computed<boolean>>
|
isStale: ComputedRef<boolean>
|
||||||
ageMs: ReturnType<typeof computed<number | null>>
|
ageMs: ComputedRef<number | null>
|
||||||
/** Force a refresh now (deduped with any in-flight one). */
|
/** Force a refresh now (deduped with any in-flight one). */
|
||||||
refresh: () => Promise<void>
|
refresh: () => Promise<void>
|
||||||
/** Mark stale + debounce-refresh all mounted users of this key. */
|
/** Mark stale + debounce-refresh all mounted users of this key. */
|
||||||
|
|||||||
@@ -594,10 +594,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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 { useRouter } from 'vue-router'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import { rpcClient } from '@/api/rpc-client'
|
import { rpcClient } from '@/api/rpc-client'
|
||||||
|
import { useResourcesStore } from '@/stores/resources'
|
||||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||||
import { pipSupported, togglePip } from '@/utils/pip'
|
import { pipSupported, togglePip } from '@/utils/pip'
|
||||||
import BackButton from '@/components/BackButton.vue'
|
import BackButton from '@/components/BackButton.vue'
|
||||||
@@ -625,16 +626,33 @@ interface CatalogItem {
|
|||||||
access: string | { paid: { price_sats: number } }
|
access: string | { paid: { price_sats: number } }
|
||||||
}
|
}
|
||||||
|
|
||||||
const loading = ref(true)
|
const resources = useResourcesStore()
|
||||||
const currentPeer = ref<PeerNode | null>(null)
|
const currentPeer = ref<PeerNode | null>(null)
|
||||||
const catalogError = ref('')
|
|
||||||
const catalogItems = ref<CatalogItem[]>([])
|
|
||||||
const downloading = ref<string | null>(null)
|
const downloading = ref<string | null>(null)
|
||||||
const playing = ref<string | null>(null)
|
const playing = ref<string | null>(null)
|
||||||
const purchaseError = 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)
|
// 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).
|
// 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(() => {
|
const transportPill = computed(() => {
|
||||||
switch (transport.value) {
|
switch (transport.value) {
|
||||||
case 'fips':
|
case 'fips':
|
||||||
@@ -807,44 +825,62 @@ onMounted(async () => {
|
|||||||
loadCatalog(),
|
loadCatalog(),
|
||||||
loadOwned(),
|
loadOwned(),
|
||||||
])
|
])
|
||||||
} else {
|
|
||||||
loading.value = false
|
|
||||||
}
|
}
|
||||||
|
// No peerId → peerOnion is empty and `loading` stays false on its own.
|
||||||
})
|
})
|
||||||
|
|
||||||
async function loadCatalog() {
|
function loadCatalog(): Promise<void> {
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
const onion = peerOnion.value
|
||||||
if (!onion) return
|
if (!onion) return Promise.resolve()
|
||||||
const hadItems = catalogItems.value.length > 0
|
return resources.refresh<PeerBrowse>(`cloud.peer-browse:${onion}`, async () => {
|
||||||
loading.value = true
|
const t0 = Date.now()
|
||||||
catalogError.value = ''
|
|
||||||
try {
|
|
||||||
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
const result = await rpcClient.call<{ items?: CatalogItem[]; transport?: string }>({
|
||||||
method: 'content.browse-peer',
|
method: 'content.browse-peer',
|
||||||
params: { onion },
|
params: { onion },
|
||||||
timeout: 30000,
|
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
|
watch(catalogItems, (items) => {
|
||||||
// Audio files don't need visual thumbnails — they show a waveform icon
|
const onion = peerOnion.value
|
||||||
watch(catalogItems, async (items) => {
|
|
||||||
const onion = props.peerId || currentPeer.value?.onion
|
|
||||||
if (!onion) return
|
if (!onion) return
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if ((item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')) && !previewUrls[item.id]) {
|
const isVisual = item.mime_type.startsWith('image/') || item.mime_type.startsWith('video/')
|
||||||
loadPreview(onion, item)
|
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) {
|
async function loadPreview(onion: string, item: CatalogItem) {
|
||||||
try {
|
try {
|
||||||
@@ -852,6 +888,8 @@ async function loadPreview(onion: string, item: CatalogItem) {
|
|||||||
method: 'content.preview-peer',
|
method: 'content.preview-peer',
|
||||||
params: { onion, content_id: item.id },
|
params: { onion, content_id: item.id },
|
||||||
timeout: 30000,
|
timeout: 30000,
|
||||||
|
maxRetries: 1,
|
||||||
|
signal: previewAborter.signal,
|
||||||
})
|
})
|
||||||
if (result?.data) {
|
if (result?.data) {
|
||||||
const mime = result.content_type || item.mime_type
|
const mime = result.content_type || item.mime_type
|
||||||
|
|||||||
Reference in New Issue
Block a user