diff --git a/benchmarks/profile_cache_storage_bench.js b/benchmarks/profile_cache_storage_bench.js new file mode 100644 index 00000000..feffb2a1 --- /dev/null +++ b/benchmarks/profile_cache_storage_bench.js @@ -0,0 +1,66 @@ +import { profileCache } from "../js/state/profileCache.js"; + +// Polyfill localStorage +const store = new Map(); +global.localStorage = { + getItem: (key) => store.get(key) || null, + setItem: (key, value) => store.set(key, value), + removeItem: (key) => store.delete(key), + clear: () => store.clear(), +}; + +// Polyfill requestIdleCallback +global.requestIdleCallback = (cb) => setTimeout(() => { + cb({ + timeRemaining: () => 50, + didTimeout: false + }); +}, 0); + +// Polyfill window (might be needed by logger or others) +if (typeof window === 'undefined') { + global.window = global; +} + +function generateLargeData() { + const data = []; + // Generate roughly 2MB of JSON data + for (let i = 0; i < 20000; i++) { + data.push({ id: i, content: "x".repeat(100) }); + } + return data; +} + +async function runBenchmark() { + const pubkey = "0".repeat(64); + const section = "watchHistory"; + const data = generateLargeData(); + + // Set active profile to ensure logic runs + profileCache.setActiveProfile(pubkey); + + console.log("Benchmarking setProfileData with large payload..."); + + // Warmup + profileCache.setProfileData(pubkey, section, [{id: 1}]); + + const start = performance.now(); + // We call it multiple times to amplify the effect, but a single call with 2MB is enough to show blocking + profileCache.setProfileData(pubkey, section, data); + const end = performance.now(); + + console.log(`Execution time (Synchronous part): ${(end - start).toFixed(4)} ms`); + + // Wait for idle callback + await new Promise(resolve => setTimeout(resolve, 50)); + + // Verify data is "stored" + const stored = localStorage.getItem(profileCache.getStorageKey(pubkey, section)); + if (stored) { + console.log("Data successfully stored in localStorage (length: " + stored.length + ")"); + } else { + console.log("Data NOT found in localStorage"); + } +} + +runBenchmark(); diff --git a/docs/moderation/fixtures/index.html b/docs/moderation/fixtures/index.html index c18e1f2a..f8cdd3fe 100644 --- a/docs/moderation/fixtures/index.html +++ b/docs/moderation/fixtures/index.html @@ -75,7 +75,8 @@ id: "1".repeat(64), title: "Fixture: Blurred Preview", pubkey: "a".repeat(64), - thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg" + thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", + url: "https://example.com/dummy.mp4" }, moderation: { blockAutoplay: true, @@ -103,7 +104,8 @@ id: "2".repeat(64), title: "Fixture: Autoplay Blocked", pubkey: "e".repeat(64), - thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg" + thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", + url: "https://example.com/dummy.mp4" }, moderation: { blockAutoplay: true, @@ -127,7 +129,8 @@ id: "3".repeat(64), title: "Fixture: Override Persistence", pubkey: "f".repeat(64), - thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg" + thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", + url: "https://example.com/dummy.mp4" }, moderation: { blockAutoplay: true, @@ -152,7 +155,8 @@ id: "4".repeat(64), title: "Fixture: Trusted Report Hide", pubkey: "aa".repeat(32), - thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg" + thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", + url: "https://example.com/dummy.mp4" }, moderation: { blockAutoplay: true, @@ -177,7 +181,8 @@ id: "5".repeat(64), title: "Fixture: Trusted Mute Hide", pubkey: "bb".repeat(32), - thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg" + thumbnail: "/assets/jpg/video-thumbnail-fallback.jpg", + url: "https://example.com/dummy.mp4" }, moderation: { blockAutoplay: false, @@ -607,6 +612,12 @@ } return true; }; + + card.onModerationHide = () => { + overrides = setOverride(overrides, video.id, { showAnyway: false }); + refreshModeration(); + return true; + }; } const root = card.getRoot(); diff --git a/js/state/profileCache.js b/js/state/profileCache.js index 4e0bc561..14317b8b 100644 --- a/js/state/profileCache.js +++ b/js/state/profileCache.js @@ -16,6 +16,15 @@ const SECTION_TO_NOTE_TYPE = { "profile": NOTE_TYPES.PROFILE_METADATA, }; +// Helper to defer non-critical work to idle periods +function runIdle(callback) { + if (typeof requestIdleCallback !== "undefined") { + requestIdleCallback(callback); + } else { + setTimeout(callback, 1); + } +} + function sanitizeProfileString(value) { if (typeof value !== "string") { return ""; @@ -316,17 +325,19 @@ class ProfileCache { const storageKey = this.getStorageKey(pubkey, section); const tier = this.getStorageTier(section); - // Save to storage + // Save to storage (Optimized: defer blocking I/O) if (tier === STORAGE_TIERS.LOCAL_STORAGE && typeof localStorage !== "undefined") { - try { - if (data === null || data === undefined) { - localStorage.removeItem(storageKey); - } else { - localStorage.setItem(storageKey, JSON.stringify(data)); + runIdle(() => { + try { + if (data === null || data === undefined) { + localStorage.removeItem(storageKey); + } else { + localStorage.setItem(storageKey, JSON.stringify(data)); + } + } catch (error) { + userLogger.warn(`[ProfileCache] Failed to save ${section} for ${pubkey}`, error); } - } catch (error) { - userLogger.warn(`[ProfileCache] Failed to save ${section} for ${pubkey}`, error); - } + }); } // Update memory cache diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 4a68da36..342184ea 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -1856,9 +1856,9 @@ export class VideoCard { target.hidden = false; target.removeAttribute("hidden"); target.removeAttribute("aria-hidden"); - // If target is summary container, ensure it's displayed as block + // If target is summary container, ensure it's displayed as flex (to match bv-stack) if (target !== slot) { - target.style.setProperty("display", "block", "important"); + target.style.setProperty("display", "flex", "important"); } else { target.style.removeProperty("display"); } @@ -2009,7 +2009,7 @@ export class VideoCard { if (!this.hiddenSummaryEl) { this.hiddenSummaryEl = this.createElement("div", { - classNames: ["p-md", "bv-stack", "bv-stack--tight"], + classNames: ["p-md", "bv-stack", "bv-stack--tight", "items-start"], }); this.hiddenSummaryEl.dataset.moderationHiddenContainer = "true"; this.hiddenSummaryEl.setAttribute("role", "group"); diff --git a/tests/profile-cache.test.mjs b/tests/profile-cache.test.mjs index 527ab7a4..fcd54338 100644 --- a/tests/profile-cache.test.mjs +++ b/tests/profile-cache.test.mjs @@ -79,7 +79,7 @@ test("ProfileCache: resolveAddressKey", (t) => { assert.equal(keyUnknown, `bitvid:profile:${pubkey}:unknownSection:v1`); }); -test("ProfileCache: set and get (memory and persistence)", (t) => { +test("ProfileCache: set and get (memory and persistence)", async (t) => { const pubkey = "c".repeat(64); profileCache.setActiveProfile(pubkey); @@ -98,6 +98,10 @@ test("ProfileCache: set and get (memory and persistence)", (t) => { // Check persistence const storageKey = profileCache.getStorageKey(pubkey, section); + + // Wait for async write (runIdle/setTimeout) + await new Promise((resolve) => setTimeout(resolve, 20)); + const storedRaw = localStorage.getItem(storageKey); assert.ok(storedRaw); assert.deepEqual(JSON.parse(storedRaw), data); @@ -110,7 +114,7 @@ test("ProfileCache: set and get (memory and persistence)", (t) => { assert.deepEqual(reloaded, data); }); -test("ProfileCache: setProfile normalization and storage", (t) => { +test("ProfileCache: setProfile normalization and storage", async (t) => { const pubkey = "d".repeat(64); const profileInput = { name: " Test User ", @@ -131,6 +135,10 @@ test("ProfileCache: setProfile normalization and storage", (t) => { // Verify persistence const storageKey = profileCache.getStorageKey(pubkey, "profile"); + + // Wait for async write (runIdle/setTimeout) + await new Promise((resolve) => setTimeout(resolve, 20)); + const stored = JSON.parse(localStorage.getItem(storageKey)); assert.deepEqual(stored.profile, entry.profile); }); diff --git a/tests/test-helpers/setup-localstorage.mjs b/tests/test-helpers/setup-localstorage.mjs index ed24fc84..db67e7ac 100644 --- a/tests/test-helpers/setup-localstorage.mjs +++ b/tests/test-helpers/setup-localstorage.mjs @@ -44,6 +44,16 @@ if (typeof globalThis.window.localStorage === "undefined") { globalThis.window.localStorage = globalThis.localStorage; } +// Polyfill requestIdleCallback for synchronous testing if undefined +if (typeof globalThis.requestIdleCallback === "undefined") { + globalThis.requestIdleCallback = (cb) => cb(); + globalThis.cancelIdleCallback = () => {}; + if (globalThis.window) { + globalThis.window.requestIdleCallback = globalThis.requestIdleCallback; + globalThis.window.cancelIdleCallback = globalThis.cancelIdleCallback; + } +} + if (typeof globalThis.window.crypto === "undefined" && typeof globalThis.crypto !== "undefined") { globalThis.window.crypto = globalThis.crypto; } diff --git a/tests/visual/moderation.spec.ts b/tests/visual/moderation.spec.ts index 944a0a83..7f05beaa 100644 --- a/tests/visual/moderation.spec.ts +++ b/tests/visual/moderation.spec.ts @@ -89,14 +89,11 @@ test.describe("moderation fixtures", () => { await expect(overrideCard).toHaveAttribute("data-autoplay-policy", "blocked"); await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred"); - // TODO: Investigate why visibility check fails in fixture environment (headless) - // await expect(showAnywayButton).toBeVisible(); + await expect(showAnywayButton).toBeVisible(); await expect(restoreButtonQuery).toHaveCount(0); - // TODO: Investigate why click times out in fixture environment (headless) - // await showAnywayButton.click({ force: true }); + await showAnywayButton.click(); - /* await expect(overrideCard).toHaveAttribute( "data-moderation-override", "show-anyway" @@ -143,7 +140,6 @@ test.describe("moderation fixtures", () => { await expect( reloadedCard.getByRole("button", { name: "Show anyway" }) ).toBeVisible(); - */ }); test("trusted report hide fixture supports show anyway override", async ({ page }) => { @@ -166,14 +162,11 @@ test.describe("moderation fixtures", () => { await expect(hideCard).toHaveAttribute("data-moderation-hide-reason", "trusted-report-hide"); await expect(hideCard).toHaveAttribute("data-moderation-hide-trusted-report-count", "3"); await expect(badge).toContainText("Hidden ยท 3 trusted spam reports"); - // TODO: Investigate visibility check failure (fails with 0x0 size in headless) - // await expect(showAnywayButton).toBeVisible(); + await expect(showAnywayButton).toBeVisible(); await expect(restoreButtonQuery).toHaveCount(0); - // TODO: Investigate click timeout failure - // await showAnywayButton.click({ force: true }); + await showAnywayButton.click(); - /* await expect(hideCard).not.toHaveAttribute("data-moderation-hidden", "true"); await expect(hideCard).toHaveAttribute("data-moderation-override", "show-anyway"); await expect(hideCard.locator('[data-moderation-badge="true"]')).toContainText( @@ -190,7 +183,6 @@ test.describe("moderation fixtures", () => { await expect(hideCard).toHaveAttribute("data-moderation-hidden", "true"); await expect(hideCard).not.toHaveAttribute("data-moderation-override", "show-anyway"); await expect(hideCard.getByRole("button", { name: "Show anyway" })).toBeVisible(); - */ }); test("trusted mute hide fixture annotates mute reason and can be overridden", async ({ @@ -227,10 +219,8 @@ test.describe("moderation fixtures", () => { await expect(showAnywayLocator).toHaveAttribute("aria-label", "Show anyway"); await expect(restoreButtonQuery).toHaveCount(0); - // TODO: Investigate click timeout failure - // await showAnywayButton.click({ force: true }); + await showAnywayButton.click(); - /* await expect(muteCard).not.toHaveAttribute("data-moderation-hidden", "true"); await expect(muteCard).toHaveAttribute("data-moderation-override", "show-anyway"); await expect(muteCard.locator('[data-moderation-badge="true"]')).toContainText( @@ -249,6 +239,5 @@ test.describe("moderation fixtures", () => { await expect(muteCard).not.toHaveAttribute("data-moderation-override", "show-anyway"); await expect(thumbnail).toHaveAttribute("data-thumbnail-state", "blurred"); await expect(muteCard.getByRole("button", { name: "Show anyway" })).toBeVisible(); - */ }); });