-
just now
+
+ Posted just now
+ Last edited just now
– views
diff --git a/js/app.js b/js/app.js
index 08d52500..17fc5fe2 100644
--- a/js/app.js
+++ b/js/app.js
@@ -6226,6 +6226,11 @@ class Application {
hasUrl: !!trimmedUrl,
});
+ const knownPostedAt = this.getKnownVideoPostedAt(video);
+ const normalizedEditedAt = Number.isFinite(video.created_at)
+ ? Math.floor(video.created_at)
+ : null;
+
this.currentVideo = {
...video,
url: trimmedUrl,
@@ -6235,8 +6240,15 @@ class Application {
torrentSupported: magnetSupported,
legacyInfoHash: video.legacyInfoHash || legacyInfoHash,
lightningAddress: null,
+ lastEditedAt: normalizedEditedAt,
};
+ if (Number.isFinite(knownPostedAt)) {
+ this.cacheVideoRootCreatedAt(this.currentVideo, knownPostedAt);
+ } else if (this.currentVideo.rootCreatedAt) {
+ delete this.currentVideo.rootCreatedAt;
+ }
+
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
const pointerInfo = resolveVideoPointer({
kind: video.kind,
@@ -6320,12 +6332,15 @@ class Application {
const creatorNpub = this.safeEncodeNpub(video.pubkey) || video.pubkey;
if (this.videoModal) {
- const formattedTimestamp = this.formatTimeAgo(video.created_at);
+ const timestampPayload = this.buildModalTimestampPayload({
+ postedAt: this.currentVideo?.rootCreatedAt ?? null,
+ editedAt: normalizedEditedAt,
+ });
const displayNpub = `${creatorNpub.slice(0, 8)}...${creatorNpub.slice(-4)}`;
this.videoModal.updateMetadata({
title: video.title || "Untitled",
description: video.description || "No description available.",
- timestamp: formattedTimestamp,
+ timestamps: timestampPayload,
creator: {
name: creatorProfile.name,
avatarUrl: creatorProfile.picture,
@@ -6334,6 +6349,8 @@ class Application {
});
}
+ this.ensureModalPostedTimestamp(this.currentVideo);
+
const playbackResult =
playbackPromise && typeof playbackPromise.then === "function"
? await playbackPromise
@@ -6342,6 +6359,183 @@ class Application {
return playbackResult;
}
+ buildModalTimestampPayload({ postedAt = null, editedAt = null } = {}) {
+ const normalizedPostedAt = Number.isFinite(postedAt)
+ ? Math.floor(postedAt)
+ : null;
+ const normalizedEditedAt = Number.isFinite(editedAt)
+ ? Math.floor(editedAt)
+ : null;
+
+ const payload = {
+ posted: "",
+ edited: "",
+ };
+
+ const effectivePostedAt =
+ normalizedPostedAt !== null ? normalizedPostedAt : normalizedEditedAt;
+
+ if (effectivePostedAt !== null) {
+ payload.posted = `Posted ${this.formatTimeAgo(effectivePostedAt)}`;
+ }
+
+ const shouldShowEdited =
+ normalizedEditedAt !== null &&
+ (normalizedPostedAt === null || normalizedEditedAt - normalizedPostedAt >= 60);
+
+ if (shouldShowEdited) {
+ payload.edited = `Last edited ${this.formatTimeAgo(normalizedEditedAt)}`;
+ }
+
+ return payload;
+ }
+
+ getKnownVideoPostedAt(video) {
+ if (!video || typeof video !== "object") {
+ return null;
+ }
+
+ const directValue = Number.isFinite(video.rootCreatedAt)
+ ? Math.floor(video.rootCreatedAt)
+ : null;
+ if (directValue !== null) {
+ return directValue;
+ }
+
+ if (video.id && this.videosMap instanceof Map) {
+ const stored = this.videosMap.get(video.id);
+ const storedValue = Number.isFinite(stored?.rootCreatedAt)
+ ? Math.floor(stored.rootCreatedAt)
+ : null;
+ if (storedValue !== null) {
+ video.rootCreatedAt = storedValue;
+ return storedValue;
+ }
+ }
+
+ const nip71Created = Number.isFinite(video?.nip71Source?.created_at)
+ ? Math.floor(video.nip71Source.created_at)
+ : null;
+
+ if (nip71Created !== null) {
+ return nip71Created;
+ }
+
+ return null;
+ }
+
+ cacheVideoRootCreatedAt(video, timestamp) {
+ if (!Number.isFinite(timestamp)) {
+ return;
+ }
+
+ const normalized = Math.floor(timestamp);
+
+ 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;
+ }
+ }
+ }
+
+ async resolveVideoPostedAt(video) {
+ if (!video || typeof video !== "object") {
+ return null;
+ }
+
+ const cached = this.getKnownVideoPostedAt(video);
+ if (cached !== null) {
+ return cached;
+ }
+
+ if (!nostrClient || typeof nostrClient.hydrateVideoHistory !== "function") {
+ const fallback = Number.isFinite(video.created_at)
+ ? Math.floor(video.created_at)
+ : null;
+ if (fallback !== null) {
+ this.cacheVideoRootCreatedAt(video, fallback);
+ }
+ return fallback;
+ }
+
+ try {
+ const history = await nostrClient.hydrateVideoHistory(video);
+ if (Array.isArray(history) && history.length) {
+ let earliest = null;
+ for (const entry of history) {
+ if (!entry || entry.deleted) {
+ continue;
+ }
+ const created = Number.isFinite(entry.created_at)
+ ? Math.floor(entry.created_at)
+ : null;
+ if (created === null) {
+ continue;
+ }
+ if (earliest === null || created < earliest) {
+ earliest = created;
+ }
+ }
+
+ if (earliest === null) {
+ const lastEntry = history[history.length - 1];
+ if (Number.isFinite(lastEntry?.created_at)) {
+ earliest = Math.floor(lastEntry.created_at);
+ }
+ }
+
+ if (earliest !== null) {
+ this.cacheVideoRootCreatedAt(video, earliest);
+ return earliest;
+ }
+ }
+ } catch (error) {
+ if (isDevMode) {
+ console.warn(
+ "[Application] Failed to hydrate video history for timestamps:",
+ error
+ );
+ }
+ }
+
+ const fallback = Number.isFinite(video.created_at)
+ ? Math.floor(video.created_at)
+ : null;
+ if (fallback !== null) {
+ this.cacheVideoRootCreatedAt(video, fallback);
+ }
+ return fallback;
+ }
+
+ async ensureModalPostedTimestamp(video) {
+ if (!video || !this.videoModal) {
+ return;
+ }
+
+ const postedAt = await this.resolveVideoPostedAt(video);
+ if (!this.videoModal || this.currentVideo !== video) {
+ return;
+ }
+
+ const editedAt = Number.isFinite(video.lastEditedAt)
+ ? Math.floor(video.lastEditedAt)
+ : Number.isFinite(video.created_at)
+ ? Math.floor(video.created_at)
+ : null;
+
+ const payload = this.buildModalTimestampPayload({
+ postedAt,
+ editedAt,
+ });
+
+ this.videoModal.updateMetadata({ timestamps: payload });
+ }
+
async playVideoWithoutEvent({
url = "",
magnet = "",
diff --git a/js/ui/components/VideoModal.js b/js/ui/components/VideoModal.js
index 81b8903a..5e7197f0 100644
--- a/js/ui/components/VideoModal.js
+++ b/js/ui/components/VideoModal.js
@@ -35,6 +35,7 @@ export class VideoModal {
this.videoTitle = null;
this.videoDescription = null;
this.videoTimestamp = null;
+ this.videoEditedTimestamp = null;
this.videoViewCountEl = null;
this.creatorAvatar = null;
this.creatorName = null;
@@ -185,6 +186,8 @@ export class VideoModal {
playerModal.querySelector("#videoDescription") || null;
this.videoTimestamp =
playerModal.querySelector("#videoTimestamp") || null;
+ this.videoEditedTimestamp =
+ playerModal.querySelector("#videoEditedTimestamp") || null;
this.videoViewCountEl =
playerModal.querySelector("#videoViewCount") || null;
this.creatorAvatar =
@@ -933,26 +936,55 @@ export class VideoModal {
title,
description,
timestamp,
+ timestamps,
viewCount,
creator,
} = {}) {
- if (this.videoTitle) {
+ if (this.videoTitle && title !== undefined) {
this.videoTitle.textContent = title || "Untitled";
}
- if (this.videoDescription) {
+ if (this.videoDescription && description !== undefined) {
this.videoDescription.textContent = description || "";
}
- if (this.videoTimestamp) {
- this.videoTimestamp.textContent = timestamp || "";
+ if (timestamps) {
+ this.updateTimestamps(timestamps);
+ } else if (timestamp !== undefined) {
+ this.updateTimestamps({ posted: timestamp });
}
- if (typeof viewCount === "string") {
- this.updateViewCountLabel(viewCount);
+ if (this.videoViewCountEl && viewCount !== undefined) {
+ if (typeof viewCount === "string") {
+ this.updateViewCountLabel(viewCount);
+ } else {
+ this.updateViewCountLabel("");
+ }
}
- if (creator) {
+ if (creator !== undefined) {
this.updateCreator(creator);
}
}
+ updateTimestamps({ posted, edited } = {}) {
+ if (this.videoTimestamp) {
+ if (posted) {
+ this.videoTimestamp.textContent = posted;
+ this.videoTimestamp.classList.remove("hidden");
+ } else {
+ this.videoTimestamp.textContent = "";
+ this.videoTimestamp.classList.add("hidden");
+ }
+ }
+
+ if (this.videoEditedTimestamp) {
+ if (edited) {
+ this.videoEditedTimestamp.textContent = edited;
+ this.videoEditedTimestamp.classList.remove("hidden");
+ } else {
+ this.videoEditedTimestamp.textContent = "";
+ this.videoEditedTimestamp.classList.add("hidden");
+ }
+ }
+ }
+
updateCreator({ name, avatarUrl, npub } = {}) {
if (this.creatorName) {
this.creatorName.textContent = name || "Unknown";
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 4/5] 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.");
From f9e2b975d1e1d5d95b1026782d822859eeaf4654 Mon Sep 17 00:00:00 2001
From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com>
Date: Wed, 8 Oct 2025 21:35:26 -0400
Subject: [PATCH 5/5] Synchronize modal and card posted timestamps
---
js/app.js | 27 +++++++---
js/utils/videoTimestamps.js | 102 ++++++++++++++++++++++++++++++++++++
tests/view-counter.test.mjs | 78 +++++++++++++++++++++++++++
3 files changed, 201 insertions(+), 6 deletions(-)
create mode 100644 js/utils/videoTimestamps.js
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.");