From 363bf6cfba480b8eaff8dd9aa7569004518b403b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Sat, 20 Jun 2026 14:26:32 -0400 Subject: [PATCH] feat(my-videos): collapse user events to latest-per-video (Phase 1, step 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second pure piece of the "My Videos" tab: collapseUserVideos reduces all of a user's video events (every edit/revert/deletion the client holds) to one entry per addressable video, keeping the latest version — which carries the current state, including deleted:true when the latest is a tombstone (those are gone from the active feed but still matter here for storage cleanup). Filters to the owner only, drops invalid notes, sorts newest-first. Tests + mutation-verified: owner filtering, dedup-to-latest, deleted-latest preserved, re-publish-after-delete wins, invalid dropped, empty-pubkey returns nothing (never leak all videos). Flipping latest->oldest fails 3 scenarios. Build + full lint green. Co-Authored-By: Claude Opus 4.8 --- js/ui/profileModal/myVideosData.js | 60 ++++++++++++++++++++ tests/my-videos-collapse.test.mjs | 90 ++++++++++++++++++++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 js/ui/profileModal/myVideosData.js create mode 100644 tests/my-videos-collapse.test.mjs diff --git a/js/ui/profileModal/myVideosData.js b/js/ui/profileModal/myVideosData.js new file mode 100644 index 00000000..3bf6000f --- /dev/null +++ b/js/ui/profileModal/myVideosData.js @@ -0,0 +1,60 @@ +// Data aggregation for the "My Videos" management tab. +// +// The Nostr client keeps every version of every video the user has published +// (edits, reverts, deletions) in `allEvents`. For a management view we want ONE +// row per video reflecting its CURRENT state — i.e. the latest version per +// addressable identity (root id / d-tag) — including videos whose latest version +// is a deletion tombstone (those are gone from the active feed but still matter +// here for storage cleanup). Pure/synchronous so it's deterministic + testable. + +import { getActiveKey } from "../../nostr/utils.js"; + +/** + * Collapse all of a user's video events into one entry per video, keeping the + * latest version (highest created_at) of each — which carries the current state, + * including `deleted: true` when the latest version is a tombstone. + * + * @param {Array} videos parsed video notes (e.g. nostrClient.allEvents values) + * @param {string} pubkey the owner's hex pubkey (only their videos are kept) + * @returns {Array} latest-per-video, newest first + */ +export function collapseUserVideos(videos, pubkey) { + const target = typeof pubkey === "string" ? pubkey.toLowerCase() : ""; + if (!target) { + return []; + } + const list = Array.isArray(videos) ? videos : []; + const latestByKey = new Map(); + + for (const video of list) { + if (!video || typeof video !== "object" || video.invalid) { + continue; + } + const videoPubkey = + typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : ""; + if (videoPubkey !== target) { + continue; + } + const key = getActiveKey(video); + if (!key) { + continue; + } + const created = Number.isFinite(video.created_at) + ? Math.floor(video.created_at) + : 0; + const existing = latestByKey.get(key); + const existingCreated = + existing && Number.isFinite(existing.created_at) + ? Math.floor(existing.created_at) + : -1; + // >= so that, among same-timestamp versions, the last seen wins; the latest + // version is the authoritative current state of the video. + if (!existing || created >= existingCreated) { + latestByKey.set(key, video); + } + } + + return Array.from(latestByKey.values()).sort( + (a, b) => (Number(b.created_at) || 0) - (Number(a.created_at) || 0), + ); +} diff --git a/tests/my-videos-collapse.test.mjs b/tests/my-videos-collapse.test.mjs new file mode 100644 index 00000000..acc0d2c9 --- /dev/null +++ b/tests/my-videos-collapse.test.mjs @@ -0,0 +1,90 @@ +// The "My Videos" tab shows ONE row per video reflecting its current state. +// collapseUserVideos must: keep only the owner's videos, dedup to the latest +// version per addressable identity (root id / d-tag), preserve a deleted latest +// state, drop invalid notes, and sort newest-first. A management view that +// showed stale/older versions (or another user's videos) would be misleading +// and dangerous (wrong delete targets). + +import assert from "node:assert/strict"; +import test from "node:test"; +import { collapseUserVideos } from "../js/ui/profileModal/myVideosData.js"; + +const ME = "a".repeat(64); +const OTHER = "b".repeat(64); + +const v = (over) => ({ + id: Math.random().toString(36).slice(2), + pubkey: ME, + videoRootId: "root-1", + title: "T", + url: "https://cdn/x.mp4", + created_at: 1000, + deleted: false, + ...over, +}); + +test("keeps only the owner's videos", () => { + const out = collapseUserVideos( + [v({ videoRootId: "mine" }), v({ pubkey: OTHER, videoRootId: "theirs" })], + ME, + ); + assert.equal(out.length, 1); + assert.equal(out[0].videoRootId, "mine"); +}); + +test("dedups to the latest version per video root", () => { + const out = collapseUserVideos( + [ + v({ videoRootId: "r", created_at: 100, title: "old" }), + v({ videoRootId: "r", created_at: 300, title: "new" }), + v({ videoRootId: "r", created_at: 200, title: "mid" }), + ], + ME, + ); + assert.equal(out.length, 1, "one row per video"); + assert.equal(out[0].title, "new", "keeps the latest version"); +}); + +test("a video whose latest version is deleted shows as deleted", () => { + const out = collapseUserVideos( + [ + v({ videoRootId: "r", created_at: 100, deleted: false, url: "https://cdn/x.mp4" }), + v({ videoRootId: "r", created_at: 500, deleted: true, url: "" }), + ], + ME, + ); + assert.equal(out.length, 1); + assert.equal(out[0].deleted, true, "the tombstone is the current state"); +}); + +test("an older deletion does not override a newer live version (re-published)", () => { + const out = collapseUserVideos( + [ + v({ videoRootId: "r", created_at: 100, deleted: true, url: "" }), + v({ videoRootId: "r", created_at: 900, deleted: false, url: "https://cdn/new.mp4" }), + ], + ME, + ); + assert.equal(out[0].deleted, false, "the newer live version wins"); + assert.equal(out[0].url, "https://cdn/new.mp4"); +}); + +test("drops invalid notes and sorts newest-first across videos", () => { + const out = collapseUserVideos( + [ + v({ videoRootId: "a", created_at: 100 }), + v({ videoRootId: "b", created_at: 300 }), + { pubkey: ME, invalid: true, videoRootId: "c", created_at: 999 }, + ], + ME, + ); + assert.deepEqual( + out.map((x) => x.videoRootId), + ["b", "a"], + "newest first, invalid dropped", + ); +}); + +test("empty pubkey returns nothing (never leak all videos)", () => { + assert.deepEqual(collapseUserVideos([v({})], ""), []); +});