From 9de3be34bce3f74d7d6e963f22165bbe47ae8701 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:25:50 -0400 Subject: [PATCH 1/4] Add tombstone guards to nostr video cache --- js/nostr.js | 178 ++++++++++++++++++++++++++++++- js/services/nostrService.js | 13 +++ tests/nostr-delete-flow.test.mjs | 168 ++++++++++++++++++++++++++++- 3 files changed, 353 insertions(+), 6 deletions(-) diff --git a/js/nostr.js b/js/nostr.js index 7c724952..b39d08ed 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -3156,6 +3156,9 @@ export class NostrClient { // “activeMap” holds only the newest version for each root this.activeMap = new Map(); + // Track the newest deletion timestamp for each active key + this.tombstones = new Map(); + this.rootCreatedAtByRoot = new Map(); this.hasRestoredLocalData = false; @@ -3176,6 +3179,93 @@ export class NostrClient { storedPermissions instanceof Set ? storedPermissions : new Set(); } + recordTombstone(activeKey, createdAt) { + const key = typeof activeKey === "string" ? activeKey.trim() : ""; + if (!key) { + return; + } + + let timestamp = Number(createdAt); + if (!Number.isFinite(timestamp)) { + return; + } + timestamp = Math.max(0, Math.floor(timestamp)); + if (timestamp <= 0) { + return; + } + + const previous = this.tombstones.get(key); + const previousTimestamp = Number.isFinite(previous) + ? Math.max(0, Math.floor(previous)) + : 0; + if (previousTimestamp >= timestamp) { + return; + } + + this.tombstones.set(key, timestamp); + + const activeVideo = this.activeMap.get(key); + if (activeVideo) { + const activeCreated = Number.isFinite(activeVideo?.created_at) + ? Math.floor(activeVideo.created_at) + : 0; + if (activeCreated && activeCreated <= timestamp) { + activeVideo.deleted = true; + this.activeMap.delete(key); + } + } + + for (const video of this.allEvents.values()) { + if (!video || typeof video !== "object") { + continue; + } + if (getActiveKey(video) !== key) { + continue; + } + const createdAtValue = Number.isFinite(video?.created_at) + ? Math.floor(video.created_at) + : 0; + if (createdAtValue && createdAtValue <= timestamp) { + video.deleted = true; + } + } + } + + isOlderThanTombstone(video) { + if (!video || typeof video !== "object") { + return false; + } + + const activeKey = getActiveKey(video); + if (!activeKey) { + return false; + } + + const tombstone = this.tombstones.get(activeKey); + if (!Number.isFinite(tombstone)) { + return false; + } + + const normalizedTombstone = Math.max(0, Math.floor(tombstone)); + const createdAtValue = Number.isFinite(video?.created_at) + ? Math.floor(video.created_at) + : 0; + + return createdAtValue > 0 && createdAtValue <= normalizedTombstone; + } + + applyTombstoneGuard(video) { + if (!video || typeof video !== "object") { + return false; + } + + const isGuarded = this.isOlderThanTombstone(video); + if (isGuarded) { + video.deleted = true; + } + return isGuarded; + } + markExtensionPermissions(methods = []) { if (!this.extensionPermissionCache) { this.extensionPermissionCache = new Set(); @@ -3659,6 +3749,21 @@ export class NostrClient { this.rawEvents.clear(); this.activeMap.clear(); this.rootCreatedAtByRoot.clear(); + this.tombstones.clear(); + + if (Array.isArray(payload.tombstones)) { + for (const entry of payload.tombstones) { + if (!Array.isArray(entry) || entry.length < 2) { + continue; + } + const [key, value] = entry; + const normalizedKey = typeof key === "string" ? key.trim() : ""; + const timestamp = Number.isFinite(value) ? Math.floor(value) : 0; + if (normalizedKey && timestamp > 0) { + this.tombstones.set(normalizedKey, timestamp); + } + } + } for (const [id, video] of Object.entries(events)) { if (!id || !video || typeof video !== "object") { @@ -3666,12 +3771,19 @@ export class NostrClient { } this.applyRootCreatedAt(video); + const activeKey = getActiveKey(video); + + if (video.deleted) { + this.recordTombstone(activeKey, video.created_at); + } else { + this.applyTombstoneGuard(video); + } + this.allEvents.set(id, video); if (video.deleted) { continue; } - const activeKey = getActiveKey(video); const existing = this.activeMap.get(activeKey); if (!existing || video.created_at > existing.created_at) { this.activeMap.set(activeKey, video); @@ -7139,7 +7251,7 @@ export class NostrClient { videoRootId: baseRoot, }; - await this.revertVideo( + const signedRevert = await this.revertVideo( { id: vid.id, pubkey: vid.pubkey, @@ -7161,9 +7273,15 @@ export class NostrClient { const activeKey = getActiveKey(cached); if (activeKey) { this.activeMap.delete(activeKey); + const revertCreatedAt = Number.isFinite(signedRevert?.created_at) + ? Math.floor(signedRevert.created_at) + : Math.floor(Date.now() / 1000); + this.recordTombstone(activeKey, revertCreatedAt); } } + this.saveLocalData(); + return true; } @@ -7179,6 +7297,7 @@ export class NostrClient { version: 1, savedAt: Date.now(), events: {}, + tombstones: Array.from(this.tombstones.entries()), }; for (const [id, vid] of this.allEvents.entries()) { @@ -7239,18 +7358,34 @@ export class NostrClient { this.mergeNip71MetadataIntoVideo(video); this.applyRootCreatedAt(video); + const activeKey = getActiveKey(video); + const wasDeletedEvent = video.deleted === true; + + if (wasDeletedEvent) { + this.recordTombstone(activeKey, video.created_at); + } else { + this.applyTombstoneGuard(video); + } + // Store in allEvents this.allEvents.set(evt.id, video); // If it's a "deleted" note, remove from activeMap if (video.deleted) { - const activeKey = getActiveKey(video); - this.activeMap.delete(activeKey); + if (activeKey) { + if (wasDeletedEvent) { + this.activeMap.delete(activeKey); + } else { + const currentActive = this.activeMap.get(activeKey); + if (currentActive?.id === video.id) { + this.activeMap.delete(activeKey); + } + } + } continue; } // Otherwise, if it's newer than what we have, update activeMap - const activeKey = getActiveKey(video); const prevActive = this.activeMap.get(activeKey); if (!prevActive || video.created_at > prevActive.created_at) { this.activeMap.set(activeKey, video); @@ -7747,6 +7882,12 @@ export class NostrClient { } else { // Only add if good this.applyRootCreatedAt(vid); + const activeKey = getActiveKey(vid); + if (vid.deleted) { + this.recordTombstone(activeKey, vid.created_at); + } else { + this.applyTombstoneGuard(vid); + } localAll.set(evt.id, vid); } } @@ -7919,6 +8060,12 @@ export class NostrClient { if (local) { this.applyRootCreatedAt(local); + const localKey = getActiveKey(local); + if (local.deleted) { + this.recordTombstone(localKey, local.created_at); + } else { + this.applyTombstoneGuard(local); + } if (!includeRaw) { return local; } @@ -7939,6 +8086,12 @@ export class NostrClient { const video = convertEventToVideo(rawEvent); this.applyRootCreatedAt(video); + const activeKey = getActiveKey(video); + if (video.deleted) { + this.recordTombstone(activeKey, video.created_at); + } else { + this.applyTombstoneGuard(video); + } this.allEvents.set(eventId, video); if (includeRaw) { @@ -8925,6 +9078,9 @@ export class NostrClient { if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) { if (!seen.has(candidate.id)) { seen.add(candidate.id); + if (!candidate.deleted) { + this.applyTombstoneGuard(candidate); + } matches.push(candidate); } } @@ -8971,6 +9127,12 @@ export class NostrClient { if (!parsed.invalid) { this.mergeNip71MetadataIntoVideo(parsed); this.applyRootCreatedAt(parsed); + const activeKey = getActiveKey(parsed); + if (parsed.deleted) { + this.recordTombstone(activeKey, parsed.created_at); + } else { + this.applyTombstoneGuard(parsed); + } this.allEvents.set(rootEvent.id, parsed); localMatches.push(parsed); } @@ -9025,6 +9187,12 @@ export class NostrClient { if (!parsed.invalid) { this.mergeNip71MetadataIntoVideo(parsed); this.applyRootCreatedAt(parsed); + const activeKey = getActiveKey(parsed); + if (parsed.deleted) { + this.recordTombstone(activeKey, parsed.created_at); + } else { + this.applyTombstoneGuard(parsed); + } this.allEvents.set(evt.id, parsed); } } catch (err) { diff --git a/js/services/nostrService.js b/js/services/nostrService.js index e8d80107..8163b7e9 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -659,6 +659,14 @@ export class NostrService { if (map.has(eventId)) { const existing = map.get(eventId); + if (existing && !existing.deleted) { + this.nostrClient.applyTombstoneGuard(existing); + } + if (!existing || existing.deleted) { + map.delete(eventId); + setStoredVideosMap(map); + return null; + } if (isBlockedNsfw(existing)) { map.delete(eventId); setStoredVideosMap(map); @@ -668,6 +676,11 @@ export class NostrService { } const cached = this.nostrClient.allEvents.get(eventId); + if (cached) { + if (!cached.deleted) { + this.nostrClient.applyTombstoneGuard(cached); + } + } if (cached && !cached.deleted) { if (isBlockedNsfw(cached)) { return null; diff --git a/tests/nostr-delete-flow.test.mjs b/tests/nostr-delete-flow.test.mjs index 44126383..ed6660d8 100644 --- a/tests/nostr-delete-flow.test.mjs +++ b/tests/nostr-delete-flow.test.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { NostrClient } from "../js/nostr.js"; await (async function testDeleteFlowPublishesDeletionFlag() { + localStorage.clear(); const client = new NostrClient(); client.hydrateVideoHistory = async () => {}; @@ -29,9 +30,13 @@ await (async function testDeleteFlowPublishesDeletionFlag() { client.allEvents.set(baseVideo.id, { ...baseVideo }); const revertCalls = []; + const revertEvents = []; client.revertVideo = async (event) => { revertCalls.push(event); - return { id: `revert-${revertCalls.length}` }; + const created_at = baseVideo.created_at + 500 + revertCalls.length; + const revertEvent = { id: `revert-${revertCalls.length}`, created_at }; + revertEvents.push(revertEvent); + return revertEvent; }; const result = await client.deleteAllVersions(videoRootId, pubkey, { @@ -50,6 +55,12 @@ await (async function testDeleteFlowPublishesDeletionFlag() { assert.equal(cached.deleted, true, "cached original video should be marked deleted"); const activeKey = `ROOT:${videoRootId}`; + const tombstoneValue = client.tombstones.get(activeKey); + assert.equal( + tombstoneValue, + revertEvents[0].created_at, + "deleteAllVersions should seed tombstone with revert timestamp", + ); client.activeMap.set(activeKey, { ...baseVideo, videoRootId, @@ -101,6 +112,161 @@ await (async function testDeleteFlowPublishesDeletionFlag() { const storedDelete = client.allEvents.get(deleteEvent.id); assert.equal(storedDelete.deleted, true, "stored delete event should be marked deleted"); + assert.equal( + client.tombstones.get(activeKey), + deleteEvent.created_at, + "subscription delete event should update tombstone", + ); +})(); + +await (async function testTombstonePersistenceAcrossSaveRestore() { + localStorage.clear(); + const client = new NostrClient(); + const videoRootId = "root-persist"; + const activeKey = `ROOT:${videoRootId}`; + + const legacyVideo = { + id: "persist-video", + pubkey: "PUBKEY456", + created_at: 1_700_000_100, + version: 3, + videoRootId, + title: "Persisted", + url: "https://example.com/video.mp4", + magnet: "", + thumbnail: "", + description: "", + mode: "live", + tags: [ + ["t", "video"], + ["d", videoRootId], + ], + }; + + client.allEvents.set(legacyVideo.id, { ...legacyVideo }); + client.recordTombstone(activeKey, legacyVideo.created_at + 50); + client.saveLocalData(); + + const restored = new NostrClient(); + restored.restoreLocalData(); + + assert.equal( + restored.tombstones.get(activeKey), + legacyVideo.created_at + 50, + "tombstone timestamp should persist across save/restore", + ); + + const restoredVideo = restored.allEvents.get(legacyVideo.id); + assert.equal(restoredVideo.deleted, true, "restored video should remain tombstoned"); + assert.equal( + restored.activeMap.has(activeKey), + false, + "restored tombstoned video should not populate activeMap", + ); +})(); + +await (async function testSubscribeVideosSkipsOlderThanTombstone() { + localStorage.clear(); + const client = new NostrClient(); + client.populateNip71MetadataForVideos = async () => {}; + + const seenUpdates = []; + const handlers = {}; + const fakeSub = { + on(type, handler) { + handlers[type] = handler; + }, + unsub() {}, + }; + + client.pool = { + sub() { + return fakeSub; + }, + }; + + client.subscribeVideos((videoUpdate) => { + seenUpdates.push(videoUpdate); + }); + + const pubkey = "PUBKEY789"; + const videoRootId = "root-sub"; + const tags = [ + ["t", "video"], + ["d", videoRootId], + ]; + + const deleteEvent = { + id: "delete-event-guard", + pubkey, + created_at: 1_700_100_000, + content: JSON.stringify({ + version: 3, + deleted: true, + title: "", + url: "", + magnet: "", + thumbnail: "", + description: "", + mode: "live", + videoRootId, + }), + tags, + }; + + handlers.event(deleteEvent); + if (typeof handlers.eose === "function") { + handlers.eose(); + } + + const activeKey = `ROOT:${videoRootId}`; + assert.equal( + client.tombstones.get(activeKey), + deleteEvent.created_at, + "delete subscription event should seed tombstone", + ); + assert.equal( + client.activeMap.has(activeKey), + false, + "deleted subscription event should clear active map entry", + ); + + const olderEvent = { + id: "older-event", + pubkey, + created_at: deleteEvent.created_at - 60, + content: JSON.stringify({ + version: 3, + deleted: false, + title: "Resurfaced", + url: "https://example.com/video.mp4", + magnet: "", + thumbnail: "", + description: "", + mode: "live", + videoRootId, + }), + tags, + }; + + handlers.event(olderEvent); + if (typeof handlers.eose === "function") { + handlers.eose(); + } + + assert.equal( + seenUpdates.length, + 0, + "older events published after tombstone should not trigger callbacks", + ); + + const storedOlder = client.allEvents.get(olderEvent.id); + assert.equal(storedOlder.deleted, true, "older event should be marked deleted by tombstone guard"); + assert.equal( + client.activeMap.has(activeKey), + false, + "older event should not repopulate the active map", + ); })(); console.log("nostr delete flow tests passed"); From 779e0366f03f4518322d97c6acac4ec4fdf4d8b9 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:29:46 -0400 Subject: [PATCH 2/4] Allow color-mix backgrounds in stylelint --- stylelint.config.cjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/stylelint.config.cjs b/stylelint.config.cjs index 370f8f50..f1f51a2f 100644 --- a/stylelint.config.cjs +++ b/stylelint.config.cjs @@ -1,6 +1,7 @@ const VAR_PATTERN = /^var\(--.*\)$/; const CONTAINS_VAR_PATTERN = /var\(--.*\)/; const LINEAR_GRADIENT_PATTERN = /^linear-gradient\(/; +const COLOR_MIX_PATTERN = /^color-mix\([\s\S]+\)$/; const NON_TOKEN_LENGTH_PATTERN = /(?!.*var\().*\b\d*\.?\d+(?:px|rem)\b/; module.exports = { @@ -11,7 +12,7 @@ module.exports = { 'color-no-hex': true, 'declaration-property-value-allowed-list': { color: [VAR_PATTERN, 'inherit', 'currentColor'], - 'background-color': [VAR_PATTERN, 'transparent', 'inherit'], + 'background-color': [VAR_PATTERN, 'transparent', 'inherit', 'currentColor', COLOR_MIX_PATTERN], background: [VAR_PATTERN, CONTAINS_VAR_PATTERN, LINEAR_GRADIENT_PATTERN, 'none', 'transparent'], 'border-color': [VAR_PATTERN, CONTAINS_VAR_PATTERN, 'transparent', 'inherit', 'currentColor'], 'outline-color': [VAR_PATTERN, 'transparent', 'inherit', 'currentColor'], From 1e13acbc78bc6c10953cc25eae28b1cd0782beaf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:45:52 -0400 Subject: [PATCH 3/4] Remove inline sidebar width probe styles in visual test --- tests/visual/overlay-layers.spec.ts | 78 ++++++++++++++++++++++++++--- 1 file changed, 72 insertions(+), 6 deletions(-) diff --git a/tests/visual/overlay-layers.spec.ts b/tests/visual/overlay-layers.spec.ts index b33fccaa..ac0b054e 100644 --- a/tests/visual/overlay-layers.spec.ts +++ b/tests/visual/overlay-layers.spec.ts @@ -57,11 +57,44 @@ test.describe("overlay layering tokens", () => { throw new Error("Missing sidebar layout nodes"); } + const ensureProbeStyles = () => { + if (document.getElementById("sidebar-width-probe-style")) { + return; + } + const style = document.createElement("style"); + style.id = "sidebar-width-probe-style"; + style.textContent = ` + .sidebar-width-probe { + position: absolute; + visibility: hidden; + } + + .sidebar-width-probe--collapsed { + width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap)); + } + + .sidebar-width-probe--expanded { + width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap)); + } + `; + document.head.appendChild(style); + }; + + ensureProbeStyles(); + const computeMargin = (widthVar) => { + const variant = + widthVar === "--sidebar-width-collapsed" + ? "sidebar-width-probe--collapsed" + : widthVar === "--sidebar-width-expanded" + ? "sidebar-width-probe--expanded" + : null; + if (!variant) { + throw new Error(`Unsupported sidebar width variable: ${widthVar}`); + } + const probe = document.createElement("div"); - probe.style.position = "absolute"; - probe.style.visibility = "hidden"; - probe.style.width = `calc(var(${widthVar}) + var(--sidebar-content-gap))`; + probe.classList.add("sidebar-width-probe", variant); document.body.appendChild(probe); const width = parseFloat(window.getComputedStyle(probe).width); probe.remove(); @@ -101,11 +134,44 @@ test.describe("overlay layering tokens", () => { throw new Error("Missing sidebar layout nodes"); } + const ensureProbeStyles = () => { + if (document.getElementById("sidebar-width-probe-style")) { + return; + } + const style = document.createElement("style"); + style.id = "sidebar-width-probe-style"; + style.textContent = ` + .sidebar-width-probe { + position: absolute; + visibility: hidden; + } + + .sidebar-width-probe--collapsed { + width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap)); + } + + .sidebar-width-probe--expanded { + width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap)); + } + `; + document.head.appendChild(style); + }; + + ensureProbeStyles(); + const computeMargin = (widthVar) => { + const variant = + widthVar === "--sidebar-width-collapsed" + ? "sidebar-width-probe--collapsed" + : widthVar === "--sidebar-width-expanded" + ? "sidebar-width-probe--expanded" + : null; + if (!variant) { + throw new Error(`Unsupported sidebar width variable: ${widthVar}`); + } + const probe = document.createElement("div"); - probe.style.position = "absolute"; - probe.style.visibility = "hidden"; - probe.style.width = `calc(var(${widthVar}) + var(--sidebar-content-gap))`; + probe.classList.add("sidebar-width-probe", variant); document.body.appendChild(probe); const width = parseFloat(window.getComputedStyle(probe).width); probe.remove(); From 767c7fba4b69053e8d2ceab0a552c0c606088088 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Mon, 20 Oct 2025 10:49:27 -0400 Subject: [PATCH 4/4] Refine popover tests to avoid inline style access --- tests/ui/popoverEngine.test.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/ui/popoverEngine.test.mjs b/tests/ui/popoverEngine.test.mjs index c7cbee3b..8dae962a 100644 --- a/tests/ui/popoverEngine.test.mjs +++ b/tests/ui/popoverEngine.test.mjs @@ -741,10 +741,12 @@ test("applies token-based sizing and arrow positioning", async () => { const panel = documentRef.getElementById("panel-with-arrow"); const arrow = panel.querySelector(".arrow"); - assert.equal(panel.style.maxWidth, "320px"); - assert.equal(panel.style.maxHeight, "240px"); + const panelStyles = getComputedStyle(panel); + assert.equal(panelStyles.maxWidth, "320px"); + assert.equal(panelStyles.maxHeight, "240px"); assert.equal(arrow.dataset.popoverArrowSide, "bottom"); - assert.notEqual(arrow.style.top, ""); + const arrowStyles = getComputedStyle(arrow); + assert.notEqual(arrowStyles.top, ""); popover.destroy(); });