From 04344015964efd6ff2c3d64a756bc590e41cf3b8 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Wed, 8 Oct 2025 21:21:44 -0400 Subject: [PATCH] Show original posted timestamp for videos --- js/app.js | 15 ++++ js/nostr.js | 162 ++++++++++++++++++++++++++++++++-- js/services/nostrService.js | 6 ++ js/ui/components/VideoCard.js | 69 ++++++++++++++- js/ui/views/VideoListView.js | 52 ++++++++++- tests/view-counter.test.mjs | 91 +++++++++++++++++++ 6 files changed, 382 insertions(+), 13 deletions(-) diff --git a/js/app.js b/js/app.js index 17fc5fe2..f7c5ebeb 100644 --- a/js/app.js +++ b/js/app.js @@ -716,6 +716,8 @@ class Application { getShareUrlBase: () => this.getShareUrlBase(), buildShareUrlFromNevent: (nevent) => this.buildShareUrlFromNevent(nevent), buildShareUrlFromEventId: (eventId) => this.buildShareUrlFromEventId(eventId), + getKnownVideoPostedAt: (video) => this.getKnownVideoPostedAt(video), + resolveVideoPostedAt: (video) => this.resolveVideoPostedAt(video), canManageBlacklist: () => this.canCurrentUserManageBlacklist(), canEditVideo: (video) => video?.pubkey === this.pubkey, canDeleteVideo: (video) => video?.pubkey === this.pubkey, @@ -6441,6 +6443,19 @@ class Application { existing.rootCreatedAt = normalized; } } + + if (nostrClient && typeof nostrClient.applyRootCreatedAt === "function") { + try { + nostrClient.applyRootCreatedAt(video); + } catch (error) { + if (isDevMode) { + console.warn( + "[Application] Failed to sync cached root timestamp with nostrClient:", + error + ); + } + } + } } async resolveVideoPostedAt(video) { diff --git a/js/nostr.js b/js/nostr.js index 928c0a08..4e380719 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -2474,6 +2474,8 @@ export class NostrClient { // “activeMap” holds only the newest version for each root this.activeMap = new Map(); + this.rootCreatedAtByRoot = new Map(); + this.hasRestoredLocalData = false; this.sessionActor = null; @@ -2489,6 +2491,79 @@ export class NostrClient { this.countUnsupportedRelays = new Set(); } + applyRootCreatedAt(video) { + if (!video || typeof video !== "object") { + return null; + } + + const rootId = + typeof video.videoRootId === "string" && video.videoRootId + ? video.videoRootId + : typeof video.id === "string" + ? video.id + : ""; + + if (!rootId) { + if ("rootCreatedAt" in video) { + delete video.rootCreatedAt; + } + return null; + } + + const candidates = []; + + const declaredRoot = Number.isFinite(video.rootCreatedAt) + ? Math.floor(video.rootCreatedAt) + : null; + if (declaredRoot !== null) { + candidates.push(declaredRoot); + } + + const nip71Created = Number.isFinite(video?.nip71Source?.created_at) + ? Math.floor(video.nip71Source.created_at) + : null; + if (nip71Created !== null) { + candidates.push(nip71Created); + } + + const createdAt = Number.isFinite(video.created_at) + ? Math.floor(video.created_at) + : null; + if (createdAt !== null) { + candidates.push(createdAt); + } + + const existingValue = this.rootCreatedAtByRoot.get(rootId); + if (Number.isFinite(existingValue)) { + candidates.push(Math.floor(existingValue)); + } + + let earliest = null; + for (const candidate of candidates) { + if (!Number.isFinite(candidate)) { + continue; + } + if (earliest === null || candidate < earliest) { + earliest = candidate; + } + } + + if (earliest !== null) { + this.rootCreatedAtByRoot.set(rootId, earliest); + video.rootCreatedAt = earliest; + } else if ("rootCreatedAt" in video) { + delete video.rootCreatedAt; + } + + const activeKey = getActiveKey(video); + const activeVideo = this.activeMap.get(activeKey); + if (activeVideo && activeVideo !== video && earliest !== null) { + activeVideo.rootCreatedAt = earliest; + } + + return earliest; + } + restoreSessionActorFromStorage() { if (typeof localStorage === "undefined") { return null; @@ -2807,12 +2882,14 @@ export class NostrClient { this.allEvents.clear(); this.activeMap.clear(); + this.rootCreatedAtByRoot.clear(); for (const [id, video] of Object.entries(events)) { if (!id || !video || typeof video !== "object") { continue; } + this.applyRootCreatedAt(video); this.allEvents.set(id, video); if (video.deleted) { continue; @@ -6025,6 +6102,7 @@ export class NostrClient { } this.mergeNip71MetadataIntoVideo(video); + this.applyRootCreatedAt(video); // Store in allEvents this.allEvents.set(evt.id, video); @@ -6042,14 +6120,18 @@ export class NostrClient { if (!prevActive || video.created_at > prevActive.created_at) { this.activeMap.set(activeKey, video); onVideo(video); // Trigger the callback that re-renders - this.populateNip71MetadataForVideos([video]).catch((error) => { - if (isDevMode) { - console.warn( - "[nostr] Failed to hydrate NIP-71 metadata for live video:", - error - ); - } - }); + this.populateNip71MetadataForVideos([video]) + .then(() => { + this.applyRootCreatedAt(video); + }) + .catch((error) => { + if (isDevMode) { + console.warn( + "[nostr] Failed to hydrate NIP-71 metadata for live video:", + error + ); + } + }); } } catch (err) { if (isDevMode) { @@ -6533,6 +6615,7 @@ export class NostrClient { invalidNotes.push({ id: vid.id, reason: vid.reason }); } else { // Only add if good + this.applyRootCreatedAt(vid); localAll.set(evt.id, vid); } } @@ -6542,6 +6625,7 @@ export class NostrClient { // Merge into allEvents for (const [id, vid] of localAll.entries()) { this.allEvents.set(id, vid); + this.applyRootCreatedAt(vid); } // Rebuild activeMap @@ -6553,6 +6637,7 @@ export class NostrClient { if (!existing || video.created_at > existing.created_at) { this.activeMap.set(activeKey, video); + this.applyRootCreatedAt(video); } } @@ -6568,6 +6653,7 @@ export class NostrClient { (a, b) => b.created_at - a.created_at ); await this.populateNip71MetadataForVideos(activeVideos); + activeVideos.forEach((video) => this.applyRootCreatedAt(video)); return activeVideos; } catch (err) { console.error("fetchVideos error:", err); @@ -6581,6 +6667,7 @@ export class NostrClient { async getEventById(eventId) { const local = this.allEvents.get(eventId); if (local) { + this.applyRootCreatedAt(local); return local; } try { @@ -6588,6 +6675,7 @@ export class NostrClient { const maybeEvt = await this.pool.get([url], { ids: [eventId] }); if (maybeEvt && maybeEvt.id === eventId) { const video = convertEventToVideo(maybeEvt); + this.applyRootCreatedAt(video); this.allEvents.set(eventId, video); return video; } @@ -6991,6 +7079,8 @@ export class NostrClient { return []; } + this.applyRootCreatedAt(video); + const targetRoot = typeof video.videoRootId === "string" ? video.videoRootId : ""; const targetPubkey = typeof video.pubkey === "string" ? video.pubkey.toLowerCase() : ""; @@ -7037,6 +7127,58 @@ export class NostrClient { let localMatches = collectLocalMatches(); + const ensureRootPresence = async () => { + const normalizedRoot = targetRoot && typeof targetRoot === "string" + ? targetRoot + : ""; + if (!normalizedRoot || normalizedRoot === video.id) { + return; + } + + const alreadyPresent = localMatches.some((entry) => entry?.id === normalizedRoot); + if (alreadyPresent) { + return; + } + + const cachedRoot = this.allEvents.get(normalizedRoot); + if (cachedRoot) { + this.applyRootCreatedAt(cachedRoot); + localMatches.push(cachedRoot); + return; + } + + if ( + !this.pool || + typeof this.pool.get !== "function" || + !Array.isArray(this.relays) || + !this.relays.length + ) { + return; + } + + try { + const rootEvent = await this.pool.get(this.relays, { ids: [normalizedRoot] }); + if (rootEvent && rootEvent.id === normalizedRoot) { + const parsed = convertEventToVideo(rootEvent); + if (!parsed.invalid) { + this.mergeNip71MetadataIntoVideo(parsed); + this.applyRootCreatedAt(parsed); + this.allEvents.set(rootEvent.id, parsed); + localMatches.push(parsed); + } + } + } catch (error) { + if (isDevMode) { + console.warn( + `[nostr] Failed to fetch root event ${normalizedRoot} for history:`, + error + ); + } + } + }; + + await ensureRootPresence(); + const shouldFetchFromRelays = localMatches.filter((entry) => !entry.deleted).length <= 1 && targetDTag; @@ -7071,6 +7213,8 @@ export class NostrClient { try { const parsed = convertEventToVideo(evt); if (!parsed.invalid) { + this.mergeNip71MetadataIntoVideo(parsed); + this.applyRootCreatedAt(parsed); this.allEvents.set(evt.id, parsed); } } catch (err) { @@ -7086,10 +7230,12 @@ export class NostrClient { } localMatches = collectLocalMatches(); + await ensureRootPresence(); } localMatches.sort((a, b) => b.created_at - a.created_at); await this.populateNip71MetadataForVideos(localMatches); + localMatches.forEach((entry) => this.applyRootCreatedAt(entry)); return localMatches; } diff --git a/js/services/nostrService.js b/js/services/nostrService.js index f2eb25f7..0a57236a 100644 --- a/js/services/nostrService.js +++ b/js/services/nostrService.js @@ -354,6 +354,12 @@ export class NostrService { if (video.invalid) { continue; } + if ( + this.nostrClient && + typeof this.nostrClient.applyRootCreatedAt === "function" + ) { + this.nostrClient.applyRootCreatedAt(video); + } if (collected.has(video.id)) { continue; } diff --git a/js/ui/components/VideoCard.js b/js/ui/components/VideoCard.js index 735ba7e2..f8b31a60 100644 --- a/js/ui/components/VideoCard.js +++ b/js/ui/components/VideoCard.js @@ -5,6 +5,7 @@ export class VideoCard { index = 0, shareUrl = "#", timeAgo = "", + postedAt = null, pointerInfo = null, highlightClass = "", animationClass = "", @@ -91,6 +92,7 @@ export class VideoCard { this.discussionCountEl = null; this.authorPicEl = null; this.authorNameEl = null; + this.timestampEl = null; this.playbackUrl = typeof video.url === "string" ? video.url.trim() : ""; @@ -105,11 +107,26 @@ export class VideoCard { this.showUnsupportedTorrentBadge = !this.playbackUrl && this.magnetProvided && !this.magnetSupported; + const normalizedPostedAt = Number.isFinite(postedAt) + ? Math.floor(postedAt) + : null; + this.postedAt = normalizedPostedAt; + + if (this.postedAt !== null && this.video && typeof this.video === "object") { + this.video.rootCreatedAt = this.postedAt; + } + + const fallbackTimestamp = + this.postedAt !== null + ? this.postedAt + : Number.isFinite(video?.created_at) + ? Math.floor(video.created_at) + : null; + this.timeAgo = - timeAgo || - (typeof this.formatters.formatTimeAgo === "function" - ? this.formatters.formatTimeAgo(video.created_at) - : ""); + typeof timeAgo === "string" && timeAgo + ? timeAgo + : this.formatTimestampForLabel(fallbackTimestamp); this.build(); } @@ -440,6 +457,7 @@ export class VideoCard { textContent: this.timeAgo, }); metadata.appendChild(timeEl); + this.timestampEl = timeEl; if (this.pointerInfo && this.pointerInfo.key) { const dot = this.createElement("span", { @@ -980,6 +998,49 @@ export class VideoCard { } } + formatTimestampForLabel(timestamp) { + if (!Number.isFinite(timestamp)) { + return ""; + } + + const normalized = Math.floor(timestamp); + if (typeof this.formatters.formatTimeAgo === "function") { + try { + const formatted = this.formatters.formatTimeAgo(normalized); + return typeof formatted === "string" ? formatted : ""; + } catch (error) { + if (this.window?.console?.warn) { + this.window.console.warn( + "[VideoCard] formatTimeAgo formatter threw", + error + ); + } + } + } + + return ""; + } + + updatePostedAt(timestamp) { + if (!Number.isFinite(timestamp)) { + return; + } + + const normalized = Math.floor(timestamp); + this.postedAt = normalized; + + if (this.video && typeof this.video === "object") { + this.video.rootCreatedAt = normalized; + } + + const label = this.formatTimestampForLabel(normalized); + this.timeAgo = label; + + if (this.timestampEl) { + this.timestampEl.textContent = label; + } + } + bindEvents() { if (this.ensureGlobalMoreMenuHandlers) { this.ensureGlobalMoreMenuHandlers(); diff --git a/js/ui/views/VideoListView.js b/js/ui/views/VideoListView.js index d0d77467..d5170cab 100644 --- a/js/ui/views/VideoListView.js +++ b/js/ui/views/VideoListView.js @@ -98,6 +98,14 @@ export class VideoListView { typeof utils.buildShareUrlFromEventId === "function" ? utils.buildShareUrlFromEventId : () => "", + getKnownVideoPostedAt: + typeof utils.getKnownVideoPostedAt === "function" + ? utils.getKnownVideoPostedAt + : () => null, + resolveVideoPostedAt: + typeof utils.resolveVideoPostedAt === "function" + ? utils.resolveVideoPostedAt + : null, canManageBlacklist: typeof utils.canManageBlacklist === "function" ? utils.canManageBlacklist @@ -325,7 +333,20 @@ export class VideoListView { canEdit && video.isPrivate ? "video-card--owner-private" : ""; const isNewlyRendered = !previouslyRenderedIds.has(video.id); const animationClass = isNewlyRendered ? "video-card--enter" : ""; - const timeAgo = this.formatters.formatTimeAgo(video.created_at); + const knownPostedAt = this.utils.getKnownVideoPostedAt(video); + const normalizedPostedAt = Number.isFinite(knownPostedAt) + ? Math.floor(knownPostedAt) + : null; + const fallbackTimestamp = + normalizedPostedAt !== null + ? normalizedPostedAt + : Number.isFinite(video?.created_at) + ? Math.floor(video.created_at) + : null; + const timeAgo = + fallbackTimestamp !== null + ? this.formatters.formatTimeAgo(fallbackTimestamp) + : ""; let hasOlder = false; if (canEdit && video.videoRootId) { @@ -344,6 +365,7 @@ export class VideoListView { shareUrl, pointerInfo, timeAgo, + postedAt: normalizedPostedAt, highlightClass, animationClass, capabilities: { @@ -435,6 +457,34 @@ export class VideoListView { fragment.appendChild(cardEl); this.renderedVideoIds.add(video.id); this.videoCardInstances.push(videoCard); + + const shouldResolvePostedAt = + normalizedPostedAt === null && + typeof this.utils.resolveVideoPostedAt === "function"; + + if (shouldResolvePostedAt) { + Promise.resolve(this.utils.resolveVideoPostedAt(video)) + .then((resolvedPostedAt) => { + if (!Number.isFinite(resolvedPostedAt)) { + return; + } + if (!this.videoCardInstances.includes(videoCard)) { + return; + } + if (videoCard.video?.id !== video.id) { + return; + } + videoCard.updatePostedAt(Math.floor(resolvedPostedAt)); + }) + .catch((error) => { + if (this.window?.console?.warn) { + this.window.console.warn( + "[VideoListView] Failed to resolve posted timestamp:", + error + ); + } + }); + } }); this.container.innerHTML = ""; diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index a797e44d..5fc0c66a 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -773,6 +773,96 @@ async function testHydrationRefreshesAfterCacheTtl() { } } +async function testHydrateHistoryPrefersRootEvent() { + const originalPool = nostrClient.pool; + const originalRelays = Array.isArray(nostrClient.relays) + ? [...nostrClient.relays] + : []; + const originalPopulate = nostrClient.populateNip71MetadataForVideos; + const originalAllEvents = nostrClient.allEvents; + const originalActiveMap = nostrClient.activeMap; + const originalRootMap = nostrClient.rootCreatedAtByRoot; + + const rootId = "root-event-note"; + const latestVideo = { + id: "latest-edit", + videoRootId: rootId, + pubkey: "PUBKEY123", + created_at: 200, + title: "Edited title", + url: "https://example.com/edited.mp4", + tags: [["t", "video"]], + deleted: false, + }; + + const rootEvent = { + id: rootId, + pubkey: latestVideo.pubkey, + created_at: 100, + kind: 30078, + content: JSON.stringify({ + version: 3, + title: "Original title", + url: "https://example.com/original.mp4", + videoRootId: rootId, + description: "", + mode: "live", + }), + tags: [["t", "video"]], + }; + + const getCalls = []; + + try { + nostrClient.pool = { + get: async (relays, filter) => { + getCalls.push({ relays, filter }); + return rootEvent; + }, + list: async () => [], + }; + nostrClient.relays = ["wss://relay.example"]; + nostrClient.populateNip71MetadataForVideos = async () => {}; + nostrClient.allEvents = new Map([[latestVideo.id, latestVideo]]); + nostrClient.activeMap = new Map([[`ROOT:${rootId}`, latestVideo]]); + nostrClient.rootCreatedAtByRoot = new Map(); + + const history = await nostrClient.hydrateVideoHistory(latestVideo); + + assert.equal( + getCalls.length, + 1, + "hydrateVideoHistory should fetch the root event when it is missing locally" + ); + + assert.ok(Array.isArray(history) && history.length >= 2); + const fetchedRoot = history.find((entry) => entry.id === rootId); + assert.ok(fetchedRoot, "history should include the fetched root event"); + assert.equal( + fetchedRoot.created_at, + rootEvent.created_at, + "root event should preserve its original created_at timestamp" + ); + assert.equal( + latestVideo.rootCreatedAt, + rootEvent.created_at, + "latest revision should inherit the root created_at timestamp" + ); + assert.equal( + nostrClient.rootCreatedAtByRoot.get(rootId), + rootEvent.created_at, + "nostrClient should cache the earliest created_at per root" + ); + } finally { + nostrClient.pool = originalPool; + nostrClient.relays = originalRelays; + nostrClient.populateNip71MetadataForVideos = originalPopulate; + nostrClient.allEvents = originalAllEvents; + nostrClient.activeMap = originalActiveMap; + nostrClient.rootCreatedAtByRoot = originalRootMap; + } +} + await testHydrationRefreshesAfterCacheTtl(); await testDedupesWithinWindow(); await testHydrationSkipsStaleEventsAndRollsOff(); @@ -780,5 +870,6 @@ await testLocalIngestNotifiesImmediately(); await testUnsubscribeStopsCallbacks(); await testRelayCountAggregationUsesBestEstimate(); await testRecordVideoViewEmitsJsonPayload(); +await testHydrateHistoryPrefersRootEvent(); console.log("View counter tests completed successfully.");