From 0e2f81d222e9ac175ea136236d0ca49f9823851c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:34:44 -0400 Subject: [PATCH 01/10] Mobile: tour bottom-sheet + measured sizing; tidy profile tab-grid tiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Onboarding tour (tourEngine.js): - On narrow viewports (<640px) dock the popover as a fixed BOTTOM SHEET instead of anchoring it — kills the off-screen/jump-to-center glitches caused by short viewports, wrapping buttons and URL-bar resize. The spotlight still highlights the target, scrolled to center so it sits above the sheet. - On desktop, measure the ACTUAL popover size (getBoundingClientRect) and feed it to computeTourPlacement, instead of the fixed 240/336 guesses that pushed tall popovers off-screen. Profile modal mobile tab grid (CSS): - Equal-height tiles via a min-height token + centered content, so long labels no longer leave a ragged grid. - Move the unread dot to the tile's top-right corner (was landing in the grid gutter at the vertical-nav left edge). - Tighter line-height/padding + non-shrinking glyph. Co-Authored-By: Claude Opus 4.8 --- css/tailwind.source.css | 20 ++++++++++++++++--- css/tokens.css | 2 ++ js/ui/onboarding/tourEngine.js | 36 +++++++++++++++++++++++++++++++++- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index fffa0708..07ab5cf6 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -3668,12 +3668,16 @@ display: flex; flex-direction: column; align-items: center; - justify-content: flex-start; + justify-content: center; text-align: center; gap: var(--space-2xs); width: 100%; - padding-block: var(--space-sm); - padding-inline: var(--space-2xs); + /* equal-height tiles: a consistent floor so short and 2-line labels align + (grid rows stretch to the tallest tile, so within a row they match) */ + min-block-size: var(--profile-modal-nav-tile-min-height); + padding-block: var(--space-xs); + padding-inline: var(--space-3xs); + line-height: 1.15; /* wrap labels at spaces, not mid-word */ overflow-wrap: break-word; word-break: normal; @@ -3684,6 +3688,16 @@ .profile-modal__nav > button svg { width: var(--space-lg); height: var(--space-lg); + flex-shrink: 0; + } + + /* In the tile grid the unread dot rides the tile's top-right corner instead + of the vertical-nav left edge (where it landed in the grid gutter). */ + .profile-modal__nav > button .profile-nav__unread-dot { + top: var(--space-2xs); + left: auto; + right: var(--space-2xs); + transform: none; } } diff --git a/css/tokens.css b/css/tokens.css index 239f8286..9551feda 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -359,6 +359,8 @@ --notification-banner-max-inline: var(--size-28rem); --profile-modal-nav-min-width: max-content; --profile-modal-nav-max-width: max-content; + /* Equal-height floor for the mobile tab-grid tiles. */ + --profile-modal-nav-tile-min-height: 4.75rem; --profile-modal-scrollbar-offset: var(--space-2xs); --modal-embed-height: 80vh; --profile-history-max-height: 26.25rem; diff --git a/js/ui/onboarding/tourEngine.js b/js/ui/onboarding/tourEngine.js index 48373890..7278989f 100644 --- a/js/ui/onboarding/tourEngine.js +++ b/js/ui/onboarding/tourEngine.js @@ -57,6 +57,20 @@ const TOUR_CSS = ` padding: 1rem; transition: left 0.28s ease, top 0.28s ease; } +/* Mobile: dock the popover as a bottom sheet instead of anchoring it to the + target. Anchored placement is fragile on phones (short viewports, wrapping + buttons, URL-bar resize) — a fixed sheet is always in a predictable spot and + the spotlight still highlights the (centered-scrolled) target above it. */ +.bv-tour-popover.bv-tour-popover--sheet { + left: 0.75rem; + right: 0.75rem; + top: auto; + bottom: max(0.75rem, env(safe-area-inset-bottom, 0px)); + transform: none; + width: auto; + max-width: 32rem; + margin-inline: auto; +} .bv-tour-popover h3 { font-size: 1rem; font-weight: 700; margin: 0 0 0.375rem; } .bv-tour-popover p { font-size: 0.875rem; margin: 0 0 0.75rem; opacity: 0.9; } .bv-tour-dots { display: flex; gap: 0.375rem; margin-bottom: 0.75rem; } @@ -171,10 +185,13 @@ export function createTour({ } }; + const isMobileViewport = () => (doc.defaultView?.innerWidth || 1200) < 640; + function measure(step) { const target = step.target ? doc.querySelector(step.target) : null; if (!target) { // Centered card: zero-size cutout in the middle (pure dim). + state.popover.classList.remove("bv-tour-popover--sheet"); setVar(state.scrim, "--bv-tour-x", "50vw"); setVar(state.scrim, "--bv-tour-y", "50vh"); setVar(state.scrim, "--bv-tour-w", "0px"); @@ -187,8 +204,13 @@ export function createTour({ return; } + const mobile = isMobileViewport(); try { - target.scrollIntoView({ block: "nearest", inline: "nearest" }); + // Center the target on mobile so it sits above the bottom sheet. + target.scrollIntoView({ + block: mobile ? "center" : "nearest", + inline: "nearest", + }); } catch (error) { // older engines } @@ -200,11 +222,23 @@ export function createTour({ setVar(state.scrim, "--bv-tour-w", `${rect.width + pad * 2}px`); setVar(state.scrim, "--bv-tour-h", `${rect.height + pad * 2}px`); + if (mobile) { + // Bottom sheet — no anchored-placement math to get wrong. + state.popover.classList.add("bv-tour-popover--sheet"); + return; + } + + // Desktop: anchor, but measure the ACTUAL popover so the fit checks are + // accurate (the old 240px/336px guesses pushed tall popovers off-screen). + state.popover.classList.remove("bv-tour-popover--sheet"); + const popRect = state.popover.getBoundingClientRect(); const placementResult = computeTourPlacement({ rect, viewportWidth: doc.defaultView?.innerWidth || 1200, viewportHeight: doc.defaultView?.innerHeight || 800, preferred: step.placement || "", + popWidth: popRect.width || 336, + popHeight: popRect.height || 240, }); setVar(state.popover, "--bv-tour-px", `${placementResult.px}px`); setVar(state.popover, "--bv-tour-py", `${placementResult.py}px`); From c562d9ee65e7a4311709ddeacf2a5b7e8deb96e9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:45:17 -0400 Subject: [PATCH 02/10] Hold the settings-restore prompt until the onboarding tour is done MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "restore storage/wallet from Nostr" confirm was popping over the guided tour (both fire on login). Gate the restore dialog behind a "tour gate": - maybeOfferOnboardingTour opens the gate synchronously when the tour will run (shouldOfferOnboarding), before its own start delay — so ordering can't race. The tour offer is now called before the restore offer on login. - launchBitvidTour gains an onEnd(status) hook (fires on finish AND skip); startOnboardingTour closes the gate on end, and also closes it if the tour never starts (logged out, no eligible steps) so restore can't hang. - The restore prompt's confirm now awaits whenOnboardingTourInactive() before showing — the remote existence check still runs in parallel, only the dialog is held back. No tour → gate stays closed → restore behaves as before. Co-Authored-By: Claude Opus 4.8 --- js/app.js | 64 +++++++++++++++++++++++++++++++--- js/ui/onboarding/bitvidTour.js | 19 ++++++++-- 2 files changed, 75 insertions(+), 8 deletions(-) diff --git a/js/app.js b/js/app.js index 7a05fc74..18ac9cf6 100644 --- a/js/app.js +++ b/js/app.js @@ -102,6 +102,8 @@ import { import { resolveSignerCapabilities } from "./nostr/signerCapabilities.js"; import { rankHashtagsByFrequency } from "./utils/hashtagSuggestions.js"; import { launchBitvidTour } from "./ui/onboarding/bitvidTour.js"; +import { shouldOfferOnboarding } from "./services/onboardingService.js"; +import { showConfirm } from "./ui/confirmDialog.js"; import { decideSignerEnsure, isSignerCapable, @@ -1759,13 +1761,40 @@ class Application { } if (pubkey) { - // Best-effort, never blocks login: offer to restore encrypted settings - // synced from another device. - this.maybeOfferSettingsRestore(pubkey); + // Order matters: offer the tour FIRST so it opens the "tour gate" + // synchronously when it will run. The settings-restore prompt then waits + // on that gate before showing its dialog, so it can't pop over the tour. this.maybeOfferOnboardingTour(pubkey); + this.maybeOfferSettingsRestore(pubkey); } } + // A gate the settings-restore prompt awaits so its confirmation dialog never + // pops over the onboarding tour. Opened synchronously when the tour will run, + // resolved when the tour finishes/exits (or fails to start). + _openTourGate() { + if (this._tourGateResolve) { + return; + } + this._tourGate = new Promise((resolve) => { + this._tourGateResolve = resolve; + }); + } + + _closeTourGate() { + const resolve = this._tourGateResolve; + this._tourGateResolve = null; + this._tourGate = null; + if (typeof resolve === "function") { + resolve(); + } + } + + // Resolves immediately unless the onboarding tour is (about to be) active. + whenOnboardingTourInactive() { + return this._tourGate || Promise.resolve(); + } + // First login of this pubkey on this device: offer the guided tour // (docs/onboarding-plan.md). Deferred so the feed has rendered and the // settings-restore prompt (2s window; only fires for accounts with synced @@ -1775,14 +1804,26 @@ class Application { if (!key) { return; } + // If the tour will actually run, open the gate NOW (synchronously) so the + // settings-restore prompt waits — even though the tour itself starts on a + // delay. If it won't run, leave the gate closed so restore proceeds normally. + if (!shouldOfferOnboarding(key)) { + return; + } + this._openTourGate(); setTimeout(() => { try { if (!this.isUserLoggedIn()) { + this._closeTourGate(); return; } - this.startOnboardingTour({ pubkey: key, force: false }); + const started = this.startOnboardingTour({ pubkey: key, force: false }); + if (!started) { + this._closeTourGate(); + } } catch (error) { devLogger.warn("[Application] Onboarding tour offer failed:", error); + this._closeTourGate(); } }, 3500); } @@ -1795,7 +1836,7 @@ class Application { if (!key) { return false; } - return launchBitvidTour({ + const started = launchBitvidTour({ pubkey: key, force, openProfilePane: (pane) => { @@ -1805,7 +1846,13 @@ class Application { devLogger.warn("[Application] Tour deep-link failed:", error); } }, + // Release the settings-restore prompt once the tour is done/exited. + onEnd: () => this._closeTourGate(), }); + if (!started) { + this._closeTourGate(); + } + return started; } // One-time offer to restore encrypted storage/wallet settings on login (todo @@ -1826,6 +1873,13 @@ class Application { walletSync: createWalletSyncService({ nwcSettings: this.nwcSettingsService, }), + // Never show the restore dialog over the onboarding tour — wait for + // it to finish/exit first. (The remote existence check still runs in + // parallel; only the confirmation dialog is held back.) + confirm: async (message) => { + await this.whenOnboardingTourInactive(); + return showConfirm(message, { confirmLabel: "Restore" }); + }, }); } Promise.resolve( diff --git a/js/ui/onboarding/bitvidTour.js b/js/ui/onboarding/bitvidTour.js index 395a8a1a..724d8ffd 100644 --- a/js/ui/onboarding/bitvidTour.js +++ b/js/ui/onboarding/bitvidTour.js @@ -74,19 +74,32 @@ export function launchBitvidTour({ openProfilePane, document: doc, force = false, + onEnd, } = {}) { if (!force && !shouldOfferOnboarding(pubkey)) { return false; } + // Fire onEnd on both finish and skip so callers can release anything they + // deferred for the tour's duration (e.g. the settings-restore prompt). + const end = (status) => { + markOnboarding(pubkey, status); + try { + onEnd?.(status); + } catch (error) { + // best-effort + } + }; + const tour = createTour({ steps: buildBitvidTourSteps({ openProfilePane }), document: doc, - onFinish: () => markOnboarding(pubkey, "completed"), - onSkip: () => markOnboarding(pubkey, "skipped"), + onFinish: () => end("completed"), + onSkip: () => end("skipped"), }); - return Boolean(tour?.start()); + const started = Boolean(tour?.start()); + return started; } export default launchBitvidTour; From 4f56a2bd268f7d3e12f812b13eca73f4416a0048 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 06:56:52 -0400 Subject: [PATCH 03/10] Wallet restore popup: re-offer on login when missing locally (like storage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The wallet already rode the same login "restore from Nostr" offer as storage, but a once-per-device "offered" flag suppressed it — so on a device without your wallet you fell back to the manual Sync button. Now: - encryptedSyncItem: add hasLocal(pubkey) (non-null payload => item present locally, e.g. a connected wallet / configured storage). - settingsRestorePrompt: re-offer an item whenever it's MISSING locally and a Nostr backup exists — bypassing the "offered once" flag — so it surfaces exactly when you'd want it. Only respect the once-per-device flag when the item IS present locally (no nagging). A new per-device "dismissed" flag, set when you decline, keeps it quiet after an explicit "no". - Accepting enables sync, so it auto-pulls silently thereafter (the "both" behavior: silent auto-pull when enabled + popup when missing & off). - Unknown services (no hasLocal) keep the legacy behavior. - tests: re-offer when missing despite prior offer; no re-offer when present; decline stays quiet. Co-Authored-By: Claude Opus 4.8 --- js/services/encryptedSyncItem.js | 17 +++++++ js/services/settingsRestorePrompt.js | 54 ++++++++++++++++++-- tests/settings-restore-prompt.test.mjs | 68 +++++++++++++++++++++++++- 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/js/services/encryptedSyncItem.js b/js/services/encryptedSyncItem.js index 50cace7b..0c658f4f 100644 --- a/js/services/encryptedSyncItem.js +++ b/js/services/encryptedSyncItem.js @@ -59,6 +59,22 @@ export function createEncryptedSyncItem({ return Boolean(result?.exists); } + // Does this device already have the item locally? (A non-null payload means + // there's something to sync — e.g. a connected wallet / configured storage.) + // Used by the login offer to re-surface a restore when the item is MISSING + // here but a Nostr backup exists. + async function hasLocal(pubkey) { + const key = normalizePubkey(pubkey); + if (!key) { + return false; + } + try { + return Boolean(await buildPayload(key)); + } catch (error) { + return false; + } + } + async function push(pubkey, { confirmOverwrite } = {}) { const key = normalizePubkey(pubkey); if (!key) { @@ -151,6 +167,7 @@ export function createEncryptedSyncItem({ isAvailable, isEnabled, hasRemote, + hasLocal, push, pull, autoPullIfNewer, diff --git a/js/services/settingsRestorePrompt.js b/js/services/settingsRestorePrompt.js index e4eca292..0896d65f 100644 --- a/js/services/settingsRestorePrompt.js +++ b/js/services/settingsRestorePrompt.js @@ -10,13 +10,16 @@ import { showConfirm } from "../ui/confirmDialog.js"; import { setSyncEnabled } from "./settingsSyncFlags.js"; const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; +// Set when the user explicitly declines a restore offer, so the "re-offer while +// the item is missing locally" behavior below doesn't nag them on every login. +const DISMISSED_KEY = "bitvid:settings-sync:dismissed:v1"; -function readOffered() { +function readMap(storageKey) { try { if (typeof localStorage === "undefined") { return {}; } - const raw = localStorage.getItem(OFFERED_KEY); + const raw = localStorage.getItem(storageKey); const parsed = raw ? JSON.parse(raw) : {}; return parsed && typeof parsed === "object" ? parsed : {}; } catch (error) { @@ -24,6 +27,31 @@ function readOffered() { } } +function readOffered() { + return readMap(OFFERED_KEY); +} + +function isDismissed(pubkey, kind) { + const entry = readMap(DISMISSED_KEY)[pubkey]; + return Boolean(entry && typeof entry === "object" && entry[kind]); +} + +function markDismissed(pubkey, kind) { + try { + if (typeof localStorage === "undefined") { + return; + } + const map = readMap(DISMISSED_KEY); + const kinds = + map[pubkey] && typeof map[pubkey] === "object" ? { ...map[pubkey] } : {}; + kinds[kind] = true; + map[pubkey] = kinds; + localStorage.setItem(DISMISSED_KEY, JSON.stringify(map)); + } catch (error) { + // Best-effort. + } +} + // Per-pubkey, PER-ITEM offered state. Tracking only per-pubkey meant the first // item offered (usually storage) flagged the whole pubkey, so a wallet note // published later was never offered — "the wallet doesn't restore like storage". @@ -78,13 +106,29 @@ export function createSettingsRestorePrompt({ ["wallet", walletSync], ]; for (const [kind, service] of services) { + // Enabled items auto-pull silently elsewhere; declined items stay quiet. if ( !service?.isAvailable?.() || service.isEnabled(pubkey) || - hasOffered(pubkey, kind) + isDismissed(pubkey, kind) ) { continue; } + // Re-offer whenever the item is MISSING locally (e.g. a fresh device or a + // cleared wallet) so a Nostr backup gets surfaced — "restore like storage". + // Only when we DO have it locally do we respect the once-per-device flag + // (no nagging). Unknown (no hasLocal) → assume present → keep old behavior. + let localCopy = true; + if (typeof service.hasLocal === "function") { + try { + localCopy = Boolean(await service.hasLocal(pubkey)); + } catch (error) { + localCopy = true; + } + } + if (localCopy && hasOffered(pubkey, kind)) { + continue; + } try { if (await service.hasRemote()) { candidates.push(kind); @@ -147,6 +191,10 @@ export function createSettingsRestorePrompt({ `Restore ${candidates.length > 1 ? "them" : "it"} to this device?` ); if (!accepted) { + // Remember the decline so we don't re-offer a missing item every login. + for (const kind of candidates) { + markDismissed(key, kind); + } return { offered: true, accepted: false }; } diff --git a/tests/settings-restore-prompt.test.mjs b/tests/settings-restore-prompt.test.mjs index 56824e3d..c40b7572 100644 --- a/tests/settings-restore-prompt.test.mjs +++ b/tests/settings-restore-prompt.test.mjs @@ -15,8 +15,13 @@ import { isSyncEnabled } from "../js/services/settingsSyncFlags.js"; const PUBKEY = "c".repeat(64); -function makeSyncStub({ available = true, enabled = false, remote = false } = {}) { - return { +function makeSyncStub({ + available = true, + enabled = false, + remote = false, + local = undefined, +} = {}) { + const stub = { pulls: 0, isAvailable: () => available, isEnabled: () => enabled, @@ -26,8 +31,16 @@ function makeSyncStub({ available = true, enabled = false, remote = false } = {} return { found: true, imported: true }; }, }; + // Only real services expose hasLocal; omitting it (local === undefined) keeps + // the legacy "assume present" behavior the older tests rely on. + if (local !== undefined) { + stub.hasLocal = async () => local; + } + return stub; } +const OFFERED_KEY = "bitvid:settings-sync:offered:v1"; + test("offers and restores items that have a remote copy and aren't already enabled", async () => { localStorage.clear(); const storageSync = makeSyncStub({ remote: true }); @@ -51,6 +64,57 @@ test("offers and restores items that have a remote copy and aren't already enabl assert.deepEqual(restoredCalls, [["storage"]]); }); +test("re-offers a MISSING item even after it was already offered once (restore-like-storage)", async () => { + localStorage.clear(); + // Simulate a prior offer for wallet on this device (the old blocker). + localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } })); + const walletSync = makeSyncStub({ remote: true, local: false }); + const prompt = createSettingsRestorePrompt({ + storageSync: makeSyncStub({ remote: false, local: true }), + walletSync, + confirm: () => true, + }); + + const result = await prompt.maybeOffer(PUBKEY); + assert.equal(result.accepted, true); + assert.deepEqual(result.restored, ["wallet"]); + assert.equal(walletSync.pulls, 1, "missing wallet is re-surfaced despite the prior offer"); +}); + +test("does NOT re-offer an item that is present locally once prompted", async () => { + localStorage.clear(); + localStorage.setItem(OFFERED_KEY, JSON.stringify({ [PUBKEY]: { wallet: true } })); + const walletSync = makeSyncStub({ remote: true, local: true }); + const prompt = createSettingsRestorePrompt({ + storageSync: makeSyncStub({ remote: false, local: true }), + walletSync, + confirm: () => true, + }); + + const result = await prompt.maybeOffer(PUBKEY); + assert.equal(result.offered, false, "present + already offered → no nag"); + assert.equal(walletSync.pulls, 0); +}); + +test("dismissing a missing item stops it re-offering every login", async () => { + localStorage.clear(); + const walletSync = makeSyncStub({ remote: true, local: false }); + const prompt = createSettingsRestorePrompt({ + storageSync: makeSyncStub({ remote: false, local: true }), + walletSync, + confirm: () => false, // decline + }); + + const first = await prompt.maybeOffer(PUBKEY); + assert.equal(first.offered, true); + assert.equal(first.accepted, false); + + // Next login: still missing locally, but the decline is remembered. + const second = await prompt.maybeOffer(PUBKEY); + assert.equal(second.offered, false, "declined → stays quiet even though still missing"); + assert.equal(walletSync.pulls, 0); +}); + test("declining the prompt restores nothing", async () => { localStorage.clear(); const storageSync = makeSyncStub({ remote: true }); From c72c74d15b007ad508b8f2c73b69c99dae6ce581 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:16:52 -0400 Subject: [PATCH 04/10] Mobile profile tab grid: uniform card tiles (fix ellipse/uneven look) The mobile tab grid reshaped btn-ghost pills, so tiles were rounded-full ellipses that turned into circles once a label wrapped, and a single 2-line label ("Admin (Moderation)") stretched its whole row taller than the rest. - Uniform tile height via grid-auto-rows (a 2-line label can no longer stretch its row); token renamed to --profile-modal-nav-tile-height (5rem). - Consistent card radius (--radius-xl) instead of rounded-full. - Subtle --color-panel fill so tiles read as solid cards, not floating outlines (the active tile's bg-primary keeps winning on specificity). - overflow:hidden + block-size:100% so content fills each equal tile cleanly. Co-Authored-By: Claude Opus 4.8 --- css/tailwind.source.css | 14 +++++++++++--- css/tokens.css | 5 +++-- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 07ab5cf6..347e790b 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -3662,6 +3662,9 @@ display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--space-xs); + /* Every tile the SAME height, so a 2-line label can't stretch its row + taller than the others (the "ragged grid of ellipses" fix). */ + grid-auto-rows: var(--profile-modal-nav-tile-height); } .profile-modal__nav > button { @@ -3672,12 +3675,17 @@ text-align: center; gap: var(--space-2xs); width: 100%; - /* equal-height tiles: a consistent floor so short and 2-line labels align - (grid rows stretch to the tallest tile, so within a row they match) */ - min-block-size: var(--profile-modal-nav-tile-min-height); + block-size: 100%; + /* Consistent rounded-square tiles, not rounded-full ellipses (btn's + default) that turn into circles once a tile gets taller. */ + border-radius: var(--radius-xl); + /* Subtle fill so they read as solid cards, not floating outlines. The + active tile's bg-primary (higher specificity) still wins. */ + background-color: var(--color-panel); padding-block: var(--space-xs); padding-inline: var(--space-3xs); line-height: 1.15; + overflow: hidden; /* wrap labels at spaces, not mid-word */ overflow-wrap: break-word; word-break: normal; diff --git a/css/tokens.css b/css/tokens.css index 9551feda..fed818d7 100644 --- a/css/tokens.css +++ b/css/tokens.css @@ -359,8 +359,9 @@ --notification-banner-max-inline: var(--size-28rem); --profile-modal-nav-min-width: max-content; --profile-modal-nav-max-width: max-content; - /* Equal-height floor for the mobile tab-grid tiles. */ - --profile-modal-nav-tile-min-height: 4.75rem; + /* Uniform height for the mobile tab-grid tiles (roomy enough for a 2-line + label, so a wrapping tile never stretches its row taller than the rest). */ + --profile-modal-nav-tile-height: 5rem; --profile-modal-scrollbar-offset: var(--space-2xs); --modal-embed-height: 80vh; --profile-history-max-height: 26.25rem; From 9ae49bf51f90264d89b8a7a27cce5017e13e247e Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:27:14 -0400 Subject: [PATCH 05/10] Mobile: stack the compact creator/account cards (Subscriptions, Friends, Blocked) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These cards were `flex justify-between` — three action buttons squeezed the name/npub into a tiny column, so the (break-all) npub wrapped to 4 lines and the whole card looked crammed on phones. - Shared .profile-account-card + .profile-account-card__actions classes on the Subscriptions, Friends and Blocked cards (was raw utility soup). - Desktop keeps the row layout (summary left, actions right). - Phones ( --- css/tailwind.source.css | 43 +++++++++++++++++++ .../ProfileBlockListController.js | 5 +-- js/ui/profileModalController.js | 8 ++-- 3 files changed, 49 insertions(+), 7 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index 347e790b..fc5e6c67 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -3594,6 +3594,49 @@ display: block; } + /* Compact creator/account cards (Subscriptions, Friends, Blocked). Desktop + lays them out in a row: summary left, actions right. */ + .profile-account-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-md); + padding: var(--space-md); + } + + .profile-account-card__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: var(--space-2xs); + } + + /* Phones: stack the card so the name/npub get the full width (no more + 4-line-wrapped npub in a crammed column), with the actions as a full-width + row of equal-width buttons below. */ + @media (max-width: calc(theme("screens.sm") - 0.02px)) { + .profile-account-card { + flex-direction: column; + align-items: stretch; + gap: var(--space-sm); + padding: var(--space-sm); + } + + .profile-account-card__actions { + gap: var(--space-2xs); + } + + .profile-account-card__actions > * { + flex: 1 1 auto; + } + + /* tighter buttons so all three fit one clean row */ + .profile-account-card__actions .btn-ghost { + padding-inline: var(--space-sm); + } + } + .profile-modal__panes { display: flex; flex: 1 1 auto; diff --git a/js/ui/profileModal/ProfileBlockListController.js b/js/ui/profileModal/ProfileBlockListController.js index 0bbf39f0..145e4daf 100644 --- a/js/ui/profileModal/ProfileBlockListController.js +++ b/js/ui/profileModal/ProfileBlockListController.js @@ -282,8 +282,7 @@ export class ProfileBlockListController { deduped.forEach(({ hex, label }) => { const item = document.createElement("li"); - item.className = - "card flex items-center justify-between gap-4 p-4"; + item.className = "card profile-account-card"; let cachedProfile = null; if (hex) { @@ -311,7 +310,7 @@ export class ProfileBlockListController { }); const actions = document.createElement("div"); - actions.className = "flex flex-wrap items-center justify-end gap-2"; + actions.className = "profile-account-card__actions"; const viewButton = this.mainController.createViewChannelButton({ targetNpub: encodedNpub, diff --git a/js/ui/profileModalController.js b/js/ui/profileModalController.js index 00b7d8db..bb36aa74 100644 --- a/js/ui/profileModalController.js +++ b/js/ui/profileModalController.js @@ -3911,7 +3911,7 @@ export class ProfileModalController { deduped.forEach(({ hex, label }) => { const item = document.createElement("li"); - item.className = "card flex items-center justify-between gap-4 p-4"; + item.className = "card profile-account-card"; let cachedProfile = null; if (hex && typeof this.services.getProfileCacheEntry === "function") { @@ -3943,7 +3943,7 @@ export class ProfileModalController { }); const actions = document.createElement("div"); - actions.className = "flex flex-wrap items-center justify-end gap-2"; + actions.className = "profile-account-card__actions"; const viewButton = this.createViewChannelButton({ targetNpub: encodedNpub, @@ -4230,7 +4230,7 @@ export class ProfileModalController { deduped.forEach(({ hex, label }) => { const item = document.createElement("li"); - item.className = "card flex items-center justify-between gap-4 p-4"; + item.className = "card profile-account-card"; let cachedProfile = null; if (hex && typeof this.services.getProfileCacheEntry === "function") { @@ -4262,7 +4262,7 @@ export class ProfileModalController { }); const actions = document.createElement("div"); - actions.className = "flex flex-wrap items-center justify-end gap-2"; + actions.className = "profile-account-card__actions"; const viewButton = this.createViewChannelButton({ targetNpub: encodedNpub, From 27808533d20d5b5f08376cd3467f4d400ef49d50 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 07:35:05 -0400 Subject: [PATCH 06/10] Mobile: same stacked treatment for the Admin + Moderation account cards Extends the .profile-account-card pattern to the remaining compact account lists so they all behave consistently on phones: - Admin lists (whitelist/blacklist/editors): use the shared .profile-account-card / __actions classes (was bespoke responsive utilities; now full-width equal-width action buttons on mobile like the others). - Moderation overrides: the summary+actions row (a nested flex inside an already-padded card) uses a new padding-less .profile-account-card__row variant so it stacks on phones too. The account switcher (single tap-button with a column meta-stack) and DM conversation rows (tap-to-open, no action buttons) are already mobile-friendly and structurally different, so they're intentionally left as-is. Co-Authored-By: Claude Opus 4.8 --- css/tailwind.source.css | 20 ++++++++++++------- js/ui/profileModal/ProfileAdminRenderer.js | 6 ++---- .../ProfileModerationController.js | 4 ++-- 3 files changed, 17 insertions(+), 13 deletions(-) diff --git a/css/tailwind.source.css b/css/tailwind.source.css index fc5e6c67..08cd16b6 100644 --- a/css/tailwind.source.css +++ b/css/tailwind.source.css @@ -3594,13 +3594,19 @@ display: block; } - /* Compact creator/account cards (Subscriptions, Friends, Blocked). Desktop - lays them out in a row: summary left, actions right. */ - .profile-account-card { + /* Compact creator/account cards (Subscriptions, Friends, Blocked, Admin + lists, Moderation). Desktop lays them out in a row: summary left, actions + right. `.profile-account-card` is the card itself (padded); the + `--row` variant is for a nested flex row inside an already-padded card. */ + .profile-account-card, + .profile-account-card__row { display: flex; align-items: center; justify-content: space-between; gap: var(--space-md); + } + + .profile-account-card { padding: var(--space-md); } @@ -3616,15 +3622,15 @@ 4-line-wrapped npub in a crammed column), with the actions as a full-width row of equal-width buttons below. */ @media (max-width: calc(theme("screens.sm") - 0.02px)) { - .profile-account-card { + .profile-account-card, + .profile-account-card__row { flex-direction: column; align-items: stretch; gap: var(--space-sm); - padding: var(--space-sm); } - .profile-account-card__actions { - gap: var(--space-2xs); + .profile-account-card { + padding: var(--space-sm); } .profile-account-card__actions > * { diff --git a/js/ui/profileModal/ProfileAdminRenderer.js b/js/ui/profileModal/ProfileAdminRenderer.js index c8945b75..f5533e82 100644 --- a/js/ui/profileModal/ProfileAdminRenderer.js +++ b/js/ui/profileModal/ProfileAdminRenderer.js @@ -161,8 +161,7 @@ export class ProfileAdminRenderer { values.forEach((npub) => { const item = document.createElement("li"); - item.className = - "card flex flex-col gap-3 p-4 sm:flex-row sm:items-center sm:justify-between"; + item.className = "card profile-account-card"; const normalizedNpub = typeof npub === "string" ? npub.trim() : ""; const comparableNpub = @@ -216,8 +215,7 @@ export class ProfileAdminRenderer { } const actions = document.createElement("div"); - actions.className = - "flex flex-wrap items-center justify-end gap-2 sm:flex-none"; + actions.className = "profile-account-card__actions"; const viewButton = this.mainController.createViewChannelButton({ targetNpub: encodedNpub, diff --git a/js/ui/profileModal/ProfileModerationController.js b/js/ui/profileModal/ProfileModerationController.js index 57f5fa13..0f9de731 100644 --- a/js/ui/profileModal/ProfileModerationController.js +++ b/js/ui/profileModal/ProfileModerationController.js @@ -501,7 +501,7 @@ export class ProfileModerationController { item.className = "card space-y-2 p-4"; const row = document.createElement("div"); - row.className = "flex items-center justify-between gap-4"; + row.className = "profile-account-card__row"; const authorKey = entry.authorPubkey; let profileSummary = null; @@ -516,7 +516,7 @@ export class ProfileModerationController { profileSummary = this.mainController.dmController.createCompactProfileSummary(summaryData); const actions = document.createElement("div"); - actions.className = "flex flex-wrap items-center justify-end gap-2"; + actions.className = "profile-account-card__actions"; const resetButton = this.mainController.createRemoveButton({ label: "Reset", 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 07/10] 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, From 31848a1e510a5eee0997230f1aef858cd7187931 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 08:39:33 -0400 Subject: [PATCH 08/10] Channel grid: add [channel-load] diagnostics to trace where videos drop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary dev-mode logging to pinpoint why a known video (e.g. a whitelisted creator's NIP-71 video) never renders on their channel wall: - loadUserVideos logs the relay set size, relay event count + kind breakdown, and the cache event count. - buildRenderableChannelVideos logs per-stage counts: input → converted → uniqueById → byRoot → afterDeleted, plus drop counts for blacklist/canAccess and the final render count. devLogger.info → visible in dev mode only (localhost), no prod noise. Co-Authored-By: Claude Opus 4.8 --- js/channelProfile.js | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/js/channelProfile.js b/js/channelProfile.js index 8af1e0bd..361eeef0 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4610,22 +4610,34 @@ function buildRenderableChannelVideos({ events = [], app } = {}) { dedupeVideos(uniqueVideos); let videos = newestByRoot.filter((video) => !video.deleted); + const afterDeleted = videos.length; + let dropBlacklist = 0; + let dropAccess = 0; videos = videos.filter((video) => { if (!video || !video.id) { return false; } if (app?.blacklistedEventIds?.has?.(video.id)) { + dropBlacklist += 1; return false; } if (!accessControl.canAccess(video)) { + dropAccess += 1; return false; } return true; }); + // [channel-load] diagnostics: where do a channel's videos get dropped? + devLogger.info( + `[channel-load] build: input=${events.length} converted=${convertedVideos.length} ` + + `uniqueById=${uniqueVideos.length} byRoot=${newestByRoot.length} afterDeleted=${afterDeleted} ` + + `dropBlacklist=${dropBlacklist} dropAccess=${dropAccess} final=${videos.length}`, + ); + videos.sort((a, b) => b.created_at - a.created_at); return videos; } @@ -5652,6 +5664,18 @@ async function loadUserVideos(pubkey) { await renderInFlight; await ensureAccessPromise; + // [channel-load] diagnostics: what did the relays + cache actually return? + const kindCounts = events.reduce((acc, evt) => { + const k = evt?.kind; + acc[k] = (acc[k] || 0) + 1; + return acc; + }, {}); + devLogger.info( + `[channel-load] pubkey=${(pubkey || "").slice(0, 8)} relays=${relayList.length} ` + + `relayEvents=${events.length} kinds=${JSON.stringify(kindCounts)} ` + + `cacheEvents=${getCachedChannelVideoEvents(pubkey).length}`, + ); + if (loadToken !== currentVideoLoadToken) { return; } From 8d331be1d5cc47fbebd2f59399cd9138f0940c85 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:38:22 -0400 Subject: [PATCH 09/10] Channel grid: always query bundled default relays (fix videos on nos.lol never showing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of "a known video never shows on the channel": the video was a whitelisted creator's NIP-71 note that lives ONLY on nos.lol, but the channel fetch queried nostrClient.relays — the user's CAPPED NIP-65 read set — which dropped nos.lol, so we never queried the one relay that had it. Confirmed by per-relay probe: snort/damus/primal returned 0, nos.lol returned the 2 videos. loadUserVideos now unions the user's relays with DEFAULT_RELAY_URLS (nos.lol, damus, primal, snort) for the scoped channel fetch, so a creator's videos on any bundled default relay are found regardless of the read cap. Channel pages are a deliberate request for one creator, so the slightly wider fan-out is fine. Co-Authored-By: Claude Opus 4.8 --- js/channelProfile.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/js/channelProfile.js b/js/channelProfile.js index 361eeef0..9a587522 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -5611,7 +5611,14 @@ async function loadUserVideos(pubkey) { const knownRelays = Array.isArray(nostrClient.relays) ? nostrClient.relays : Array.from(nostrClient.relays || []); - const relayList = knownRelays.filter((url) => isValidRelayUrl(url)); + // ALWAYS include the bundled defaults. A creator's videos can live only on + // a default relay (e.g. a NIP-71 video that's only on nos.lol) that the + // user's capped NIP-65 read set excludes — otherwise a known, whitelisted + // video never shows on the channel because we never query the one relay + // that has it. + const relayList = [ + ...new Set([...knownRelays, ...DEFAULT_RELAY_URLS]), + ].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 From 18e7197bdce43510a3f520f4a9c7d8f74e8dd446 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:54:38 -0400 Subject: [PATCH 10/10] Channel grid: remove the temporary [channel-load] diagnostics The relay-union fix is confirmed working; drop the dev-only tracing added to hunt it down. Co-Authored-By: Claude Opus 4.8 --- js/channelProfile.js | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/js/channelProfile.js b/js/channelProfile.js index 9a587522..a3b92cda 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -4610,34 +4610,22 @@ function buildRenderableChannelVideos({ events = [], app } = {}) { dedupeVideos(uniqueVideos); let videos = newestByRoot.filter((video) => !video.deleted); - const afterDeleted = videos.length; - let dropBlacklist = 0; - let dropAccess = 0; videos = videos.filter((video) => { if (!video || !video.id) { return false; } if (app?.blacklistedEventIds?.has?.(video.id)) { - dropBlacklist += 1; return false; } if (!accessControl.canAccess(video)) { - dropAccess += 1; return false; } return true; }); - // [channel-load] diagnostics: where do a channel's videos get dropped? - devLogger.info( - `[channel-load] build: input=${events.length} converted=${convertedVideos.length} ` + - `uniqueById=${uniqueVideos.length} byRoot=${newestByRoot.length} afterDeleted=${afterDeleted} ` + - `dropBlacklist=${dropBlacklist} dropAccess=${dropAccess} final=${videos.length}`, - ); - videos.sort((a, b) => b.created_at - a.created_at); return videos; } @@ -5671,18 +5659,6 @@ async function loadUserVideos(pubkey) { await renderInFlight; await ensureAccessPromise; - // [channel-load] diagnostics: what did the relays + cache actually return? - const kindCounts = events.reduce((acc, evt) => { - const k = evt?.kind; - acc[k] = (acc[k] || 0) + 1; - return acc; - }, {}); - devLogger.info( - `[channel-load] pubkey=${(pubkey || "").slice(0, 8)} relays=${relayList.length} ` + - `relayEvents=${events.length} kinds=${JSON.stringify(kindCounts)} ` + - `cacheEvents=${getCachedChannelVideoEvents(pubkey).length}`, - ); - if (loadToken !== currentVideoLoadToken) { return; }