feat(my-videos): collapse user events to latest-per-video (Phase 1, step 2)

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 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-20 14:26:32 -04:00
1 parent 8cb03d23cc
commit 363bf6cfba
2 files changed
+150

No files matched your search

+60
View File
@@ -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<object>} videos parsed video notes (e.g. nostrClient.allEvents values)
* @param {string} pubkey the owner's hex pubkey (only their videos are kept)
* @returns {Array<object>} 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),
);
}
+90
View File
@@ -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({})], ""), []);
});