mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 16:02:26 +00:00
Merge pull request #368 from PR0M3TH3AN/codex/update-pointer-selection-logic-in-app.js
Prioritize video root pointers for view tracking
This commit is contained in:
10 files changed
+1027
-131
No files matched your search
@@ -237,10 +237,14 @@
|
||||
|
||||
<!-- Video Timestamp -->
|
||||
<div
|
||||
class="flex items-center justify-between text-sm text-gray-400 mb-4"
|
||||
class="flex items-start justify-between text-sm text-gray-400 mb-4"
|
||||
>
|
||||
<div class="flex items-center gap-2 modal-meta">
|
||||
<span id="videoTimestamp">just now</span>
|
||||
<div class="flex flex-wrap items-center gap-2 modal-meta">
|
||||
<span id="videoTimestamp">Posted just now</span>
|
||||
<span
|
||||
id="videoEditedTimestamp"
|
||||
class="hidden"
|
||||
>Last edited just now</span>
|
||||
<span id="videoViewCount" class="modal-view-count">– views</span>
|
||||
</div>
|
||||
<div id="modalStatus" class="text-gray-300">
|
||||
|
||||
@@ -71,8 +71,14 @@ import ProfileModalController from "./ui/profileModalController.js";
|
||||
import ZapController from "./ui/zapController.js";
|
||||
import { MediaLoader } from "./utils/mediaLoader.js";
|
||||
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,
|
||||
@@ -715,6 +721,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,
|
||||
@@ -1309,34 +1317,14 @@ class Application {
|
||||
}
|
||||
|
||||
const dTagValue = (this.extractDTagValue(video.tags) || "").trim();
|
||||
const normalizedPubkey =
|
||||
typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
const kind =
|
||||
typeof video.kind === "number" && Number.isFinite(video.kind)
|
||||
? video.kind
|
||||
: VIDEO_EVENT_KIND;
|
||||
|
||||
if (dTagValue && normalizedPubkey) {
|
||||
const pointer = ["a", `${kind}:${normalizedPubkey}:${dTagValue}`];
|
||||
const key = pointerArrayToKey(pointer);
|
||||
if (key) {
|
||||
return { pointer, key };
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackId =
|
||||
typeof video.id === "string" && video.id.trim()
|
||||
? video.id.trim()
|
||||
: "";
|
||||
if (fallbackId) {
|
||||
const pointer = ["e", fallbackId];
|
||||
const key = pointerArrayToKey(pointer);
|
||||
if (key) {
|
||||
return { pointer, key };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return resolveVideoPointer({
|
||||
kind: video.kind,
|
||||
pubkey: video.pubkey,
|
||||
videoRootId: video.videoRootId,
|
||||
dTag: dTagValue,
|
||||
fallbackEventId: video.id,
|
||||
});
|
||||
}
|
||||
|
||||
formatViewCountLabel(total) {
|
||||
@@ -6245,6 +6233,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,
|
||||
@@ -6254,53 +6247,26 @@ 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 normalizedPubkey =
|
||||
typeof video.pubkey === "string" ? video.pubkey.trim() : "";
|
||||
const primaryPointer =
|
||||
dTagValue && normalizedPubkey
|
||||
? [
|
||||
"a",
|
||||
`${
|
||||
typeof video.kind === "number" && Number.isFinite(video.kind)
|
||||
? video.kind
|
||||
: 30078
|
||||
}:${normalizedPubkey}:${dTagValue}`,
|
||||
]
|
||||
: null;
|
||||
const fallbackId =
|
||||
typeof (video.id || eventId) === "string"
|
||||
? (video.id || eventId).trim()
|
||||
: "";
|
||||
const fallbackPointer = fallbackId ? ["e", fallbackId] : null;
|
||||
const pointerInfo = resolveVideoPointer({
|
||||
kind: video.kind,
|
||||
pubkey: video.pubkey,
|
||||
videoRootId: video.videoRootId,
|
||||
dTag: dTagValue,
|
||||
fallbackEventId: video.id || eventId,
|
||||
});
|
||||
|
||||
let resolvedPointer = null;
|
||||
let resolvedPointerKey = "";
|
||||
const pointerCandidates = [];
|
||||
if (primaryPointer) {
|
||||
pointerCandidates.push(primaryPointer);
|
||||
}
|
||||
if (fallbackPointer) {
|
||||
pointerCandidates.push(fallbackPointer);
|
||||
}
|
||||
|
||||
for (const candidate of pointerCandidates) {
|
||||
const key = pointerArrayToKey(candidate);
|
||||
if (key) {
|
||||
resolvedPointer = candidate;
|
||||
resolvedPointerKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
this.currentVideoPointer = resolvedPointer && resolvedPointerKey
|
||||
? resolvedPointer
|
||||
: null;
|
||||
this.currentVideoPointerKey = this.currentVideoPointer
|
||||
? resolvedPointerKey
|
||||
: null;
|
||||
this.currentVideoPointer = pointerInfo?.pointer || null;
|
||||
this.currentVideoPointerKey = pointerInfo?.key || null;
|
||||
|
||||
if (this.currentVideo) {
|
||||
this.currentVideo.pointer = this.currentVideoPointer;
|
||||
@@ -6373,12 +6339,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,
|
||||
@@ -6387,6 +6356,8 @@ class Application {
|
||||
});
|
||||
}
|
||||
|
||||
this.ensureModalPostedTimestamp(this.currentVideo);
|
||||
|
||||
const playbackResult =
|
||||
playbackPromise && typeof playbackPromise.then === "function"
|
||||
? await playbackPromise
|
||||
@@ -6395,6 +6366,206 @@ 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);
|
||||
const rootId = getVideoRootIdentifier(video);
|
||||
|
||||
if (video && typeof video === "object") {
|
||||
video.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 {
|
||||
nostrClient.applyRootCreatedAt(video);
|
||||
} catch (error) {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[Application] Failed to sync cached root timestamp with nostrClient:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = "",
|
||||
|
||||
+218
-44
@@ -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;
|
||||
@@ -6004,48 +6081,50 @@ export class NostrClient {
|
||||
|
||||
// We'll collect events here instead of processing them instantly
|
||||
let eventBuffer = [];
|
||||
const EVENT_FLUSH_DEBOUNCE_MS = 75;
|
||||
let flushTimerId = null;
|
||||
|
||||
// 1) On each incoming event, just push to the buffer
|
||||
sub.on("event", (event) => {
|
||||
eventBuffer.push(event);
|
||||
});
|
||||
const flushEventBuffer = () => {
|
||||
if (!eventBuffer.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Process buffered events on a setInterval (e.g., every second)
|
||||
const processInterval = setInterval(() => {
|
||||
if (eventBuffer.length > 0) {
|
||||
// Copy and clear the buffer
|
||||
const toProcess = eventBuffer.slice();
|
||||
eventBuffer = [];
|
||||
const toProcess = eventBuffer;
|
||||
eventBuffer = [];
|
||||
|
||||
// Now handle each event
|
||||
for (const evt of toProcess) {
|
||||
try {
|
||||
const video = convertEventToVideo(evt);
|
||||
for (const evt of toProcess) {
|
||||
try {
|
||||
const video = convertEventToVideo(evt);
|
||||
|
||||
if (video.invalid) {
|
||||
invalidDuringSub.push({ id: video.id, reason: video.reason });
|
||||
continue;
|
||||
}
|
||||
if (video.invalid) {
|
||||
invalidDuringSub.push({ id: video.id, reason: video.reason });
|
||||
continue;
|
||||
}
|
||||
|
||||
this.mergeNip71MetadataIntoVideo(video);
|
||||
this.mergeNip71MetadataIntoVideo(video);
|
||||
this.applyRootCreatedAt(video);
|
||||
|
||||
// Store in allEvents
|
||||
this.allEvents.set(evt.id, video);
|
||||
// Store in allEvents
|
||||
this.allEvents.set(evt.id, video);
|
||||
|
||||
// If it's a "deleted" note, remove from activeMap
|
||||
if (video.deleted) {
|
||||
const activeKey = getActiveKey(video);
|
||||
this.activeMap.delete(activeKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, if it's newer than what we have, update activeMap
|
||||
// If it's a "deleted" note, remove from activeMap
|
||||
if (video.deleted) {
|
||||
const activeKey = getActiveKey(video);
|
||||
const prevActive = this.activeMap.get(activeKey);
|
||||
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) => {
|
||||
this.activeMap.delete(activeKey);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Otherwise, if it's newer than what we have, update activeMap
|
||||
const activeKey = getActiveKey(video);
|
||||
const prevActive = this.activeMap.get(activeKey);
|
||||
if (!prevActive || video.created_at > prevActive.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
onVideo(video); // Trigger the callback that re-renders
|
||||
this.populateNip71MetadataForVideos([video])
|
||||
.then(() => {
|
||||
this.applyRootCreatedAt(video);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (isDevMode) {
|
||||
console.warn(
|
||||
"[nostr] Failed to hydrate NIP-71 metadata for live video:",
|
||||
@@ -6053,18 +6132,43 @@ export class NostrClient {
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("[subscribeVideos] Error processing event:", err);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (isDevMode) {
|
||||
console.error("[subscribeVideos] Error processing event:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Optionally, save data to local storage after processing the batch
|
||||
this.saveLocalData();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Persist processed events after each flush so reloads warm quickly.
|
||||
this.saveLocalData();
|
||||
};
|
||||
|
||||
const scheduleFlush = (immediate = false) => {
|
||||
if (flushTimerId) {
|
||||
if (!immediate) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(flushTimerId);
|
||||
flushTimerId = null;
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
flushEventBuffer();
|
||||
return;
|
||||
}
|
||||
|
||||
flushTimerId = setTimeout(() => {
|
||||
flushTimerId = null;
|
||||
flushEventBuffer();
|
||||
}, EVENT_FLUSH_DEBOUNCE_MS);
|
||||
};
|
||||
|
||||
// 1) On each incoming event, just push to the buffer and schedule a flush
|
||||
sub.on("event", (event) => {
|
||||
eventBuffer.push(event);
|
||||
scheduleFlush(false);
|
||||
});
|
||||
|
||||
// You can still use sub.on("eose") if needed
|
||||
sub.on("eose", () => {
|
||||
@@ -6079,6 +6183,7 @@ export class NostrClient {
|
||||
"[subscribeVideos] Reached EOSE for all relays (historical load done)"
|
||||
);
|
||||
}
|
||||
scheduleFlush(true);
|
||||
});
|
||||
|
||||
// Return the subscription object if you need to unsub manually later
|
||||
@@ -6090,7 +6195,12 @@ export class NostrClient {
|
||||
return;
|
||||
}
|
||||
unsubscribed = true;
|
||||
clearInterval(processInterval);
|
||||
if (flushTimerId) {
|
||||
clearTimeout(flushTimerId);
|
||||
flushTimerId = null;
|
||||
}
|
||||
// Ensure any straggling events are flushed before tearing down.
|
||||
flushEventBuffer();
|
||||
try {
|
||||
return originalUnsub();
|
||||
} catch (err) {
|
||||
@@ -6505,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);
|
||||
}
|
||||
}
|
||||
@@ -6514,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
|
||||
@@ -6525,6 +6637,7 @@ export class NostrClient {
|
||||
|
||||
if (!existing || video.created_at > existing.created_at) {
|
||||
this.activeMap.set(activeKey, video);
|
||||
this.applyRootCreatedAt(video);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6540,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);
|
||||
@@ -6553,6 +6667,7 @@ export class NostrClient {
|
||||
async getEventById(eventId) {
|
||||
const local = this.allEvents.get(eventId);
|
||||
if (local) {
|
||||
this.applyRootCreatedAt(local);
|
||||
return local;
|
||||
}
|
||||
try {
|
||||
@@ -6560,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;
|
||||
}
|
||||
@@ -6963,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() : "";
|
||||
|
||||
@@ -7009,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;
|
||||
|
||||
@@ -7043,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) {
|
||||
@@ -7058,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// js/utils/videoPointer.js
|
||||
|
||||
import { pointerArrayToKey } from "./pointer.js";
|
||||
|
||||
const DEFAULT_VIDEO_KIND = 30078;
|
||||
|
||||
function normalizeString(value) {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
export function resolveVideoPointer({
|
||||
kind,
|
||||
pubkey,
|
||||
videoRootId,
|
||||
dTag,
|
||||
fallbackEventId,
|
||||
} = {}) {
|
||||
const normalizedPubkey = normalizeString(pubkey).toLowerCase();
|
||||
const normalizedKind =
|
||||
typeof kind === "number" && Number.isFinite(kind) ? kind : DEFAULT_VIDEO_KIND;
|
||||
const pointerCandidates = [];
|
||||
|
||||
const rootId = normalizeString(videoRootId);
|
||||
if (rootId && normalizedPubkey) {
|
||||
pointerCandidates.push(["a", `${normalizedKind}:${normalizedPubkey}:${rootId}`]);
|
||||
}
|
||||
|
||||
const normalizedDTag = normalizeString(dTag);
|
||||
if (normalizedDTag && normalizedPubkey) {
|
||||
pointerCandidates.push([
|
||||
"a",
|
||||
`${normalizedKind}:${normalizedPubkey}:${normalizedDTag}`,
|
||||
]);
|
||||
}
|
||||
|
||||
const fallbackId = normalizeString(fallbackEventId);
|
||||
if (fallbackId) {
|
||||
pointerCandidates.push(["e", fallbackId]);
|
||||
}
|
||||
|
||||
for (const pointer of pointerCandidates) {
|
||||
const key = pointerArrayToKey(pointer);
|
||||
if (key) {
|
||||
return { pointer, key };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default resolveVideoPointer;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -47,6 +47,14 @@ if (!globalThis.window.NostrTools) {
|
||||
globalThis.window.NostrTools = {};
|
||||
}
|
||||
|
||||
if (typeof globalThis.window.NostrTools.getEventHash !== "function") {
|
||||
globalThis.window.NostrTools.getEventHash = () => "test-event-hash";
|
||||
}
|
||||
|
||||
if (typeof globalThis.window.NostrTools.signEvent !== "function") {
|
||||
globalThis.window.NostrTools.signEvent = () => "test-event-sig";
|
||||
}
|
||||
|
||||
setNostrEventSchemaOverrides({});
|
||||
|
||||
const canonicalViewEvent = buildViewEvent({
|
||||
@@ -275,11 +283,84 @@ const {
|
||||
unsubscribeFromVideoViewCount,
|
||||
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 });
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
function testPointerPrefersVideoRootId() {
|
||||
const info = resolveVideoPointer({
|
||||
pubkey: "ABCDEF1234",
|
||||
videoRootId: "ROOT-POINTER",
|
||||
dTag: "legacy-d-tag",
|
||||
fallbackEventId: "event-fallback",
|
||||
kind: 30078,
|
||||
});
|
||||
|
||||
assert.ok(info, "expected pointer info for root-backed video");
|
||||
assert.deepEqual(
|
||||
info.pointer,
|
||||
["a", "30078:abcdef1234:ROOT-POINTER"],
|
||||
"videos with a videoRootId should resolve to the root pointer"
|
||||
);
|
||||
assert.equal(
|
||||
info.key,
|
||||
"a:30078:abcdef1234:root-pointer",
|
||||
"root pointer key should be normalized for view counter lookups"
|
||||
);
|
||||
}
|
||||
|
||||
function testPointerFallsBackToDTag() {
|
||||
const info = resolveVideoPointer({
|
||||
pubkey: "ABCDEF1234",
|
||||
dTag: "legacy-pointer",
|
||||
fallbackEventId: "event-fallback",
|
||||
kind: 30078,
|
||||
});
|
||||
|
||||
assert.ok(info, "expected pointer info when only d-tag is present");
|
||||
assert.deepEqual(
|
||||
info.pointer,
|
||||
["a", "30078:abcdef1234:legacy-pointer"],
|
||||
"videos without a root should fall back to the d-tag pointer"
|
||||
);
|
||||
assert.equal(
|
||||
info.key,
|
||||
"a:30078:abcdef1234:legacy-pointer",
|
||||
"d-tag pointer key should match the normalized value"
|
||||
);
|
||||
}
|
||||
|
||||
function testPointerFallsBackToEventId() {
|
||||
const info = resolveVideoPointer({
|
||||
pubkey: "ABCDEF1234",
|
||||
fallbackEventId: "legacy-event-id",
|
||||
kind: 30078,
|
||||
});
|
||||
|
||||
assert.ok(info, "expected pointer info when only event id is present");
|
||||
assert.deepEqual(
|
||||
info.pointer,
|
||||
["e", "legacy-event-id"],
|
||||
"videos without root or d-tag should fall back to the event pointer"
|
||||
);
|
||||
assert.equal(
|
||||
info.key,
|
||||
"e:legacy-event-id",
|
||||
"event pointer key should normalize correctly"
|
||||
);
|
||||
}
|
||||
|
||||
testPointerPrefersVideoRootId();
|
||||
testPointerFallsBackToDTag();
|
||||
testPointerFallsBackToEventId();
|
||||
|
||||
async function testDedupesWithinWindow() {
|
||||
localStorage.clear();
|
||||
harness.reset();
|
||||
@@ -697,6 +778,168 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -704,5 +947,7 @@ await testLocalIngestNotifiesImmediately();
|
||||
await testUnsubscribeStopsCallbacks();
|
||||
await testRelayCountAggregationUsesBestEstimate();
|
||||
await testRecordVideoViewEmitsJsonPayload();
|
||||
await testHydrateHistoryPrefersRootEvent();
|
||||
await testModalPostedTimestampStaysInSync();
|
||||
|
||||
console.log("View counter tests completed successfully.");
|
||||
Reference in new issue
Block a user