diff --git a/js/app.js b/js/app.js index f7c5ebeb..771c7877 100644 --- a/js/app.js +++ b/js/app.js @@ -74,6 +74,11 @@ import { pointerArrayToKey } from "./utils/pointer.js"; import { resolveVideoPointer } from "./utils/videoPointer.js"; import { isValidMagnetUri } from "./utils/magnetValidators.js"; import { dedupeToNewestByRoot } from "./utils/videoDeduper.js"; +import { + getVideoRootIdentifier, + applyRootTimestampToVideosMap, + syncActiveVideoRootTimestamp, +} from "./utils/videoTimestamps.js"; import { getPubkey as getStoredPubkey, setPubkey as setStoredPubkey, @@ -6432,17 +6437,27 @@ class Application { } const normalized = Math.floor(timestamp); + const rootId = getVideoRootIdentifier(video); if (video && typeof video === "object") { video.rootCreatedAt = normalized; } - if (video?.id && this.videosMap instanceof Map) { - const existing = this.videosMap.get(video.id); - if (existing && typeof existing === "object") { - existing.rootCreatedAt = normalized; - } - } + applyRootTimestampToVideosMap({ + videosMap: this.videosMap, + video, + rootId, + timestamp: normalized, + }); + + syncActiveVideoRootTimestamp({ + activeVideo: this.currentVideo, + rootId, + timestamp: normalized, + buildModalTimestampPayload: (payload) => + this.buildModalTimestampPayload(payload), + videoModal: this.videoModal, + }); if (nostrClient && typeof nostrClient.applyRootCreatedAt === "function") { try { diff --git a/js/utils/videoTimestamps.js b/js/utils/videoTimestamps.js new file mode 100644 index 00000000..9f35c964 --- /dev/null +++ b/js/utils/videoTimestamps.js @@ -0,0 +1,102 @@ +export function getVideoRootIdentifier(video) { + if (!video || typeof video !== "object") { + return ""; + } + + if (typeof video.videoRootId === "string" && video.videoRootId) { + return video.videoRootId; + } + + if (typeof video.id === "string" && video.id) { + return video.id; + } + + return ""; +} + +export function applyRootTimestampToVideosMap({ + videosMap, + video, + rootId = "", + timestamp, +}) { + if (!(videosMap instanceof Map)) { + return; + } + + if (video?.id) { + const existing = videosMap.get(video.id); + if (existing && typeof existing === "object") { + existing.rootCreatedAt = timestamp; + } + } + + if (!rootId) { + return; + } + + for (const stored of videosMap.values()) { + if (!stored || typeof stored !== "object") { + continue; + } + if (stored === video) { + continue; + } + const storedRootId = getVideoRootIdentifier(stored); + if (storedRootId && storedRootId === rootId) { + stored.rootCreatedAt = timestamp; + } + } +} + +export function syncActiveVideoRootTimestamp({ + activeVideo, + rootId = "", + timestamp = null, + buildModalTimestampPayload, + videoModal, +}) { + if (!Number.isFinite(timestamp)) { + return false; + } + + const normalized = Math.floor(timestamp); + if (!activeVideo || typeof activeVideo !== "object") { + return false; + } + + const activeRootId = getVideoRootIdentifier(activeVideo); + if (!activeRootId) { + return false; + } + + if (rootId && activeRootId !== rootId) { + return false; + } + + if (activeVideo.rootCreatedAt === normalized) { + return false; + } + + activeVideo.rootCreatedAt = normalized; + + const editedAt = Number.isFinite(activeVideo.lastEditedAt) + ? Math.floor(activeVideo.lastEditedAt) + : Number.isFinite(activeVideo.created_at) + ? Math.floor(activeVideo.created_at) + : null; + + if ( + typeof buildModalTimestampPayload === "function" && + videoModal && + typeof videoModal.updateMetadata === "function" + ) { + const payload = buildModalTimestampPayload({ + postedAt: normalized, + editedAt, + }); + videoModal.updateMetadata({ timestamps: payload }); + } + + return true; +} diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index 5fc0c66a..0b0d7d18 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -284,6 +284,11 @@ const { ingestLocalViewEvent, } = await import("../js/viewCounter.js"); const { resolveVideoPointer } = await import("../js/utils/videoPointer.js"); +const { + getVideoRootIdentifier, + applyRootTimestampToVideosMap, + syncActiveVideoRootTimestamp, +} = await import("../js/utils/videoTimestamps.js"); initViewCounter({ nostrClient }); @@ -863,6 +868,78 @@ async function testHydrateHistoryPrefersRootEvent() { } } +async function testModalPostedTimestampStaysInSync() { + const videosMap = new Map(); + const currentVideo = { + id: "latest-edit", + videoRootId: "root-event-note", + created_at: 260, + lastEditedAt: 260, + }; + + const storedVideo = { id: "latest-edit", videoRootId: "root-event-note" }; + videosMap.set(storedVideo.id, storedVideo); + + const incomingVideo = { ...currentVideo }; + const rootId = getVideoRootIdentifier(incomingVideo); + const timestamp = 100; + + applyRootTimestampToVideosMap({ + videosMap, + video: incomingVideo, + rootId, + timestamp, + }); + + assert.equal( + storedVideo.rootCreatedAt, + timestamp, + "videosMap entries that share the root should receive the cached timestamp", + ); + + const modalUpdates = []; + const videoModal = { + updateMetadata: ({ timestamps }) => { + if (timestamps) { + modalUpdates.push({ ...timestamps }); + } + }, + }; + + const buildModalTimestampPayload = ({ postedAt, editedAt }) => ({ + posted: postedAt !== null ? `Posted time-${postedAt}` : "", + edited: editedAt !== null ? `Last edited time-${editedAt}` : "", + }); + + const updated = syncActiveVideoRootTimestamp({ + activeVideo: currentVideo, + rootId, + timestamp, + buildModalTimestampPayload, + videoModal, + }); + + assert.ok(updated, "syncActiveVideoRootTimestamp should record the new root time"); + assert.equal( + currentVideo.rootCreatedAt, + timestamp, + "current video should stay aligned with the cached root timestamp", + ); + + const lastModalUpdate = modalUpdates.at(-1); + assert.ok(lastModalUpdate, "video modal should receive a timestamp update"); + assert.equal( + lastModalUpdate.posted, + "Posted time-100", + "modal posted label should reflect the root timestamp", + ); + assert.equal( + lastModalUpdate.edited, + "Last edited time-260", + "modal edited label should continue to show the latest edit time", + ); +} + await testHydrationRefreshesAfterCacheTtl(); await testDedupesWithinWindow(); await testHydrationSkipsStaleEventsAndRollsOff(); @@ -871,5 +948,6 @@ await testUnsubscribeStopsCallbacks(); await testRelayCountAggregationUsesBestEstimate(); await testRecordVideoViewEmitsJsonPayload(); await testHydrateHistoryPrefersRootEvent(); +await testModalPostedTimestampStaysInSync(); console.log("View counter tests completed successfully.");