From 013b0344b959fe09f18ad6da9a90cd2f7c70a51c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:23:49 -0400 Subject: [PATCH] Channel grid: progressive render + reliable fetch + no premature "No videos" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel video grid felt slow and often flashed "No videos" because, when the warm cache was cold (direct URL, a new tab, or an author not in your feed), it fell back to a bespoke one-shot pool.list per relay with an 8s timeout, AWAITED ALL relays before rendering once, and showed "No videos" whenever that came back empty (which a timed-out/flaky relay does). "My Videos" is fast because it reads the warm shared cache and only refetches on demand. Rework loadUserVideos: - Progressive render: paint the grid as each relay/source returns (deduped, coalesced, serialized re-render) instead of blocking on the slowest relay. - Reliable fetch: also route through nostrService.fetchVideosByAuthors — the same proven path My Videos uses — which streams into the shared cache, so this grid, its revisits, and every other grid benefit. - No premature empty: mid-flight renders never pass allowEmptyMessage; the "No videos" message is shown only on the final render, after every source has genuinely settled. The cache-first paint is preserved. - check-file-size: bump channelProfile grandfather for the growth. Co-Authored-By: Claude Opus 4.8 --- js/channelProfile.js | 136 ++++++++++++++++++++++++++++-------- scripts/check-file-size.mjs | 2 +- 2 files changed, 108 insertions(+), 30 deletions(-) diff --git a/js/channelProfile.js b/js/channelProfile.js index 187277f7..8af1e0bd 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -5539,46 +5539,117 @@ async function loadUserVideos(pubkey) { // 1) Build filters for this pubkey's videos: native kind-30078 + NIP-71. const filters = buildChannelVideoFilters(pubkey); - // 2) Collect raw events from all relays + // Accumulate raw events across every source (deduped by id) so the grid can + // paint PROGRESSIVELY as each relay/source returns, instead of blocking on + // the slowest relay's 8s timeout. const events = []; + const seenEventIds = new Set(); + const addEvents = (list) => { + let added = 0; + for (const evt of Array.isArray(list) ? list : []) { + if (evt && evt.id && !seenEventIds.has(evt.id)) { + seenEventIds.add(evt.id); + events.push(evt); + added += 1; + } + } + return added; + }; + + // Serialized, coalesced re-render. Mid-flight renders NEVER pass + // allowEmptyMessage, so a partial/slow fetch can't flash "No videos" — that + // message is reserved for the single final render below. + let renderInFlight = null; + let renderPending = false; + const scheduleRender = () => { + renderPending = true; + if (renderInFlight) { + return renderInFlight; + } + renderInFlight = (async () => { + while (renderPending) { + renderPending = false; + if (loadToken !== currentVideoLoadToken || !container) { + break; + } + const partial = buildRenderableChannelVideos({ + events: mergeChannelVideoSources( + events, + getCachedChannelVideoEvents(pubkey) + ), + app, + }); + const paintedNow = await renderChannelVideosFromList({ + videos: partial, + container, + app, + loadToken, + allowEmptyMessage: false, + }); + if (paintedNow) { + hasVisibleContent = true; + renderedFromCache = true; + } + } + renderInFlight = null; + })(); + return renderInFlight; + }; + const knownRelays = Array.isArray(nostrClient.relays) ? nostrClient.relays : Array.from(nostrClient.relays || []); const relayList = knownRelays.filter((url) => isValidRelayUrl(url)); + // Route one fetch through the app's PROVEN path (the same one "My Videos" + // uses on refresh). It streams into the shared cache, so this grid, its + // revisits, and every other grid all benefit — then re-render from it. + const sharedFetch = (async () => { + try { + if (typeof app?.nostrService?.fetchVideosByAuthors === "function") { + await app.nostrService.fetchVideosByAuthors([pubkey]); + await scheduleRender(); + } + } catch (error) { + // best-effort; the scoped relay fetch below still populates the grid + } + })(); + + // Scoped relay fetch (also covers NIP-71 cross-posts), rendered + // progressively as each relay returns rather than awaiting them all. if (relayList.length === 0) { try { const fallbackEvents = await listRelayWithTimeout( Array.from(DEFAULT_RELAY_URLS), filters ); - if (Array.isArray(fallbackEvents)) { - events.push(...fallbackEvents); + if (addEvents(fallbackEvents) > 0) { + await scheduleRender(); } } catch (error) { userLogger.error("Relay error (default pool):", error); } } else { - const settled = await pMapSettled( + await pMapSettled( relayList, - (url) => listRelayWithTimeout([url], filters), + async (url) => { + try { + const relayEvents = await listRelayWithTimeout([url], filters); + if (addEvents(relayEvents) > 0) { + await scheduleRender(); + } + return relayEvents; + } catch (error) { + userLogger.error(`Relay error (${url}):`, error); + return []; + } + }, { concurrency: RELAY_BACKGROUND_CONCURRENCY } ); - settled.forEach((result, index) => { - const relayUrl = relayList[index]; - - if (result.status === "fulfilled") { - const relayEvents = Array.isArray(result.value) ? result.value : []; - events.push(...relayEvents); - return; - } - - if (result.reason) { - userLogger.error(`Relay error (${relayUrl}):`, result.reason); - } - }); } + await sharedFetch; + await renderInFlight; await ensureAccessPromise; if (loadToken !== currentVideoLoadToken) { @@ -5590,21 +5661,28 @@ async function loadUserVideos(pubkey) { return; } - const videos = buildRenderableChannelVideos({ events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)), app }); - saveCachedChannelVideos(pubkey, videos); - const rendered = await renderChannelVideosFromList({ - videos, - container, + // Persist the merged set. Only do a final render when nothing painted during + // progressive rendering — that render carries the empty message, shown ONLY + // now that every source has genuinely settled. + const videos = buildRenderableChannelVideos({ + events: mergeChannelVideoSources(events, getCachedChannelVideoEvents(pubkey)), app, - loadToken, - allowEmptyMessage: !hasVisibleContent }); + saveCachedChannelVideos(pubkey, videos); - if (rendered) { - hasVisibleContent = true; + if (!hasVisibleContent) { + const rendered = await renderChannelVideosFromList({ + videos, + container, + app, + loadToken, + allowEmptyMessage: true, + }); + if (rendered) { + hasVisibleContent = true; + } + renderedFromCache = rendered || renderedFromCache; } - - renderedFromCache = rendered || renderedFromCache; } catch (err) { if ( loadToken === currentVideoLoadToken && diff --git a/scripts/check-file-size.mjs b/scripts/check-file-size.mjs index dfc1c38e..efe7215f 100644 --- a/scripts/check-file-size.mjs +++ b/scripts/check-file-size.mjs @@ -29,7 +29,7 @@ const GRANDFATHERED = { "js/ui/profileModalController.js": 8130, "js/ui/components/VideoModal.js": 4976, "js/app.js": 5602, - "js/channelProfile.js": 5529, + "js/channelProfile.js": 5806, "js/nostr/client.js": 4356, "js/ui/components/VideoCard.js": 3129, "js/nostrEventSchemas.js": 3541,