Show original posted timestamp for videos

This commit is contained in:
thePR0M3TH3AN committed 2025-10-08 21:21:44 -04:00
1 parent c99f6834cd
commit 0434401596
6 files changed
+382 -13

No files matched your search

+15
View File
@@ -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) {
+154 -8
View File
@@ -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;
}
+6
View File
@@ -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;
}
+65 -4
View File
@@ -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();
+51 -1
View File
@@ -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 = "";
+91
View File
@@ -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.");