mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-10-27 07:52:24 +00:00
Merge pull request #805 from PR0M3TH3AN/codex/create-viewevents-module-and-update-imports
Refactor view event helpers into dedicated module
This commit is contained in:
6 files changed
+1073
-844
No files matched your search
@@ -64,6 +64,7 @@ import {
|
||||
formatViewCount,
|
||||
ingestLocalViewEvent,
|
||||
} from "./viewCounter.js";
|
||||
import { recordVideoView as recordVideoViewApi } from "./nostr/viewEvents.js";
|
||||
import { splitAndZap as splitAndZapDefault } from "./payments/zapSplit.js";
|
||||
import { showLoginRequiredToZapNotification } from "./payments/zapNotifications.js";
|
||||
import {
|
||||
@@ -8689,6 +8690,8 @@ class Application {
|
||||
}
|
||||
}
|
||||
|
||||
Application.recordVideoView = recordVideoViewApi;
|
||||
|
||||
/**
|
||||
* Given an array of video objects,
|
||||
* return only the newest (by created_at) for each videoRootId.
|
||||
|
||||
+31
-840
@@ -1,12 +1,7 @@
|
||||
// js/nostr.js
|
||||
|
||||
import {
|
||||
isDevMode,
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS,
|
||||
} from "./config.js";
|
||||
import { FEATURE_PUBLISH_NIP71, VIEW_FILTER_INCLUDE_LEGACY_VIDEO } from "./constants.js";
|
||||
import { isDevMode, ENSURE_PRESENCE_REBROADCAST_COOLDOWN_SECONDS } from "./config.js";
|
||||
import { FEATURE_PUBLISH_NIP71 } from "./constants.js";
|
||||
import { accessControl } from "./accessControl.js";
|
||||
import {
|
||||
registerNostrClient,
|
||||
@@ -49,12 +44,18 @@ import {
|
||||
buildVideoPostEvent,
|
||||
buildVideoMirrorEvent,
|
||||
buildRepostEvent,
|
||||
buildViewEvent,
|
||||
buildWatchHistoryIndexEvent,
|
||||
buildWatchHistoryChunkEvent,
|
||||
getNostrEventSchema,
|
||||
NOTE_TYPES,
|
||||
} from "./nostrEventSchemas.js";
|
||||
import {
|
||||
listVideoViewEvents as listVideoViewEventsForClient,
|
||||
subscribeVideoViewEvents as subscribeVideoViewEventsForClient,
|
||||
countVideoViewEvents as countVideoViewEventsForClient,
|
||||
publishViewEvent as publishViewEventForClient,
|
||||
recordVideoView as recordVideoViewForClient,
|
||||
} from "./nostr/viewEvents.js";
|
||||
import {
|
||||
publishEventToRelay,
|
||||
publishEventToRelays,
|
||||
@@ -181,242 +182,9 @@ const EVENTS_CACHE_STORAGE_KEY = "bitvid:eventsCache:v1";
|
||||
const LEGACY_EVENTS_STORAGE_KEY = "bitvidEvents";
|
||||
const EVENTS_CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
|
||||
const REBROADCAST_GUARD_PREFIX = "bitvid:rebroadcast:v1";
|
||||
|
||||
|
||||
const viewEventPublishMemory = new Map();
|
||||
const rebroadcastAttemptMemory = new Map();
|
||||
|
||||
function resolveVideoViewPointer(pointer) {
|
||||
const normalized = normalizePointerInput(pointer);
|
||||
if (!normalized || typeof normalized.value !== "string") {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const value = normalized.value.trim();
|
||||
if (!value) {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const type = normalized.type === "a" ? "a" : "e";
|
||||
const descriptor = { type, value };
|
||||
|
||||
if (typeof normalized.relay === "string" && normalized.relay.trim()) {
|
||||
descriptor.relay = normalized.relay.trim();
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
function createVideoViewEventFilters(pointer) {
|
||||
let resolved;
|
||||
|
||||
if (
|
||||
pointer &&
|
||||
typeof pointer === "object" &&
|
||||
(pointer.type === "a" || pointer.type === "e") &&
|
||||
typeof pointer.value === "string"
|
||||
) {
|
||||
const value = pointer.value.trim();
|
||||
if (!value) {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
resolved = { type: pointer.type === "a" ? "a" : "e", value };
|
||||
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
|
||||
resolved.relay = pointer.relay.trim();
|
||||
}
|
||||
} else {
|
||||
resolved = resolveVideoViewPointer(pointer);
|
||||
}
|
||||
|
||||
const pointerFilter = {
|
||||
kinds: [VIEW_EVENT_KIND],
|
||||
"#t": ["view"],
|
||||
};
|
||||
|
||||
if (resolved.type === "a") {
|
||||
pointerFilter["#a"] = [resolved.value];
|
||||
} else {
|
||||
pointerFilter["#e"] = [resolved.value];
|
||||
}
|
||||
|
||||
const filters = [pointerFilter];
|
||||
|
||||
if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) {
|
||||
filters.push({
|
||||
kinds: [VIEW_EVENT_KIND],
|
||||
"#t": ["view"],
|
||||
"#video": [resolved.value],
|
||||
});
|
||||
}
|
||||
|
||||
return { pointer: resolved, filters };
|
||||
}
|
||||
|
||||
function deriveViewEventBucketIndex(createdAtSeconds) {
|
||||
const timestamp = Number.isFinite(createdAtSeconds)
|
||||
? Math.floor(createdAtSeconds)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const windowSize = Math.max(
|
||||
1,
|
||||
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
|
||||
);
|
||||
return Math.floor(timestamp / windowSize);
|
||||
}
|
||||
|
||||
function getViewEventGuardWindowMs() {
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
|
||||
);
|
||||
return windowSeconds * 1000;
|
||||
}
|
||||
|
||||
function deriveViewEventPointerScope(pointer) {
|
||||
const pointerValue =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim().toLowerCase() : "";
|
||||
if (!pointerValue) {
|
||||
return "";
|
||||
}
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
return `${pointerType}:${pointerValue}`;
|
||||
}
|
||||
|
||||
function generateViewEventEntropy() {
|
||||
const cryptoRef =
|
||||
(typeof globalThis !== "undefined" &&
|
||||
/** @type {Crypto | undefined} */ (globalThis.crypto)) ||
|
||||
null;
|
||||
|
||||
if (cryptoRef && typeof cryptoRef.getRandomValues === "function") {
|
||||
try {
|
||||
const buffer = new Uint32Array(2);
|
||||
cryptoRef.getRandomValues(buffer);
|
||||
return Array.from(buffer, (value) =>
|
||||
value.toString(16).padStart(8, "0")
|
||||
).join("");
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to gather crypto entropy for view event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackA = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
const fallbackB = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
return `${fallbackA}${fallbackB}`;
|
||||
}
|
||||
|
||||
function generateViewEventDedupeTag(actorPubkey, pointer, createdAtSeconds) {
|
||||
const scope = deriveViewEventPointerScope(pointer) || "unknown";
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" && actorPubkey.trim()
|
||||
? actorPubkey.trim().toLowerCase()
|
||||
: "anon";
|
||||
const timestamp = Number.isFinite(createdAtSeconds)
|
||||
? Math.max(0, Math.floor(createdAtSeconds))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const entropy = generateViewEventEntropy();
|
||||
return `${scope}:${normalizedActor}:${timestamp}:${entropy}`;
|
||||
}
|
||||
|
||||
function hasRecentViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const now = Date.now();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
|
||||
if (entry) {
|
||||
const age = now - Number(entry.seenAt);
|
||||
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
} else if (Number(entry.bucket) === bucketIndex) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
let rawValue = null;
|
||||
try {
|
||||
rawValue = localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to read view guard entry:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== "string" || !rawValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2);
|
||||
const storedBucket = Number(storedBucketRaw);
|
||||
const storedSeenAt = Number(storedSeenRaw);
|
||||
|
||||
if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to clear corrupt view guard entry:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now - storedSeenAt >= windowMs) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to remove expired view guard entry:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: storedBucket,
|
||||
seenAt: storedSeenAt,
|
||||
});
|
||||
|
||||
return storedBucket === bucketIndex;
|
||||
}
|
||||
|
||||
function rememberViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: bucketIndex,
|
||||
seenAt: now,
|
||||
});
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to persist view guard entry:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function deriveRebroadcastBucketIndex(referenceSeconds = null) {
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
@@ -578,89 +346,6 @@ function getRebroadcastCooldownState(scope) {
|
||||
};
|
||||
}
|
||||
|
||||
function isVideoViewEvent(event, pointer) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(event.kind) || event.kind !== VIEW_EVENT_KIND) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
let hasViewTag = false;
|
||||
let matchesPointer = false;
|
||||
|
||||
const pointerValueRaw =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim() : "";
|
||||
const pointerValueLower = pointerValueRaw.toLowerCase();
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = typeof tag[0] === "string" ? tag[0].toLowerCase() : "";
|
||||
const tagValue = typeof tag[1] === "string" ? tag[1].trim().toLowerCase() : "";
|
||||
|
||||
if (!tagName || !tagValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tagName === "t" && tagValue === "view") {
|
||||
hasViewTag = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "a" && tagName === "a" && tagValue === pointerValueLower) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "e" && tagName === "e" && tagValue === pointerValueLower) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO &&
|
||||
tagName === "video" &&
|
||||
tagValue === pointerValueLower
|
||||
) {
|
||||
matchesPointer = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasViewTag && matchesPointer;
|
||||
}
|
||||
|
||||
const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
|
||||
const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind)
|
||||
? VIEW_EVENT_SCHEMA.kind
|
||||
: 30079;
|
||||
|
||||
let ingestLocalViewEventRef = null;
|
||||
|
||||
async function loadIngestLocalViewEvent() {
|
||||
if (typeof ingestLocalViewEventRef === "function") {
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
try {
|
||||
const module = await import("./viewCounter.js");
|
||||
if (typeof module?.ingestLocalViewEvent === "function") {
|
||||
ingestLocalViewEventRef = module.ingestLocalViewEvent;
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to load view counter ingest helper:",
|
||||
error
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// To limit error spam
|
||||
let errorLogCount = 0;
|
||||
const MAX_ERROR_LOGS = 100;
|
||||
@@ -3226,535 +2911,33 @@ export class NostrClient {
|
||||
);
|
||||
}
|
||||
async listVideoViewEvents(pointer, options = {}) {
|
||||
if (!this.pool) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
|
||||
pointer
|
||||
);
|
||||
const { since, until, limit, relays } = options || {};
|
||||
|
||||
for (const filter of filters) {
|
||||
if (!filter || typeof filter !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (Number.isFinite(since)) {
|
||||
filter.since = Math.floor(since);
|
||||
}
|
||||
if (Number.isFinite(until)) {
|
||||
filter.until = Math.floor(until);
|
||||
}
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
filter.limit = Math.floor(limit);
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = Array.isArray(relays) && relays.length
|
||||
? relays
|
||||
: Array.isArray(this.relays) && this.relays.length
|
||||
? this.relays
|
||||
: RELAY_URLS;
|
||||
|
||||
let rawResults;
|
||||
try {
|
||||
rawResults = await this.pool.list(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to list video view events:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const flattenResults = (input) => {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flat = [];
|
||||
for (const chunk of input) {
|
||||
if (Array.isArray(chunk)) {
|
||||
for (const item of chunk) {
|
||||
if (item && typeof item === "object") {
|
||||
flat.push(item);
|
||||
}
|
||||
}
|
||||
} else if (chunk && typeof chunk === "object") {
|
||||
flat.push(chunk);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
};
|
||||
|
||||
const flattened = flattenResults(rawResults);
|
||||
const dedupe = new Map();
|
||||
const order = [];
|
||||
|
||||
for (const event of flattened) {
|
||||
if (!isVideoViewEvent(event, pointerDescriptor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventId = typeof event.id === "string" ? event.id : null;
|
||||
if (!eventId) {
|
||||
order.push({ type: "raw", event });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = dedupe.get(eventId);
|
||||
if (!existing) {
|
||||
dedupe.set(eventId, event);
|
||||
order.push({ type: "id", key: eventId });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingCreated = Number.isFinite(existing?.created_at)
|
||||
? existing.created_at
|
||||
: 0;
|
||||
const incomingCreated = Number.isFinite(event.created_at)
|
||||
? event.created_at
|
||||
: 0;
|
||||
if (incomingCreated > existingCreated) {
|
||||
dedupe.set(eventId, event);
|
||||
}
|
||||
}
|
||||
|
||||
return order
|
||||
.map((entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.type === "raw") {
|
||||
return entry.event || null;
|
||||
}
|
||||
if (entry.type === "id") {
|
||||
return dedupe.get(entry.key) || null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return listVideoViewEventsForClient(this, pointer, options);
|
||||
}
|
||||
|
||||
subscribeVideoViewEvents(pointer, options = {}) {
|
||||
if (!this.pool) {
|
||||
devLogger.warn("[nostr] Unable to subscribe to view events: pool missing.");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
|
||||
pointer
|
||||
);
|
||||
|
||||
if (Number.isFinite(options?.since)) {
|
||||
for (const filter of filters) {
|
||||
if (filter && typeof filter === "object") {
|
||||
filter.since = Math.floor(options.since);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = Array.isArray(options?.relays) && options.relays.length
|
||||
? options.relays
|
||||
: Array.isArray(this.relays) && this.relays.length
|
||||
? this.relays
|
||||
: RELAY_URLS;
|
||||
|
||||
const onEvent = typeof options?.onEvent === "function" ? options.onEvent : null;
|
||||
|
||||
let subscription;
|
||||
try {
|
||||
subscription = this.pool.sub(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to open video view subscription:", error);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (onEvent) {
|
||||
subscription.on("event", (event) => {
|
||||
if (isVideoViewEvent(event, pointerDescriptor)) {
|
||||
try {
|
||||
onEvent(event);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Video view event handler threw:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const originalUnsub =
|
||||
typeof subscription.unsub === "function"
|
||||
? subscription.unsub.bind(subscription)
|
||||
: null;
|
||||
|
||||
let unsubscribed = false;
|
||||
return () => {
|
||||
if (unsubscribed) {
|
||||
return;
|
||||
}
|
||||
unsubscribed = true;
|
||||
if (originalUnsub) {
|
||||
try {
|
||||
originalUnsub();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to unsubscribe from video view events:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
return subscribeVideoViewEventsForClient(this, pointer, options);
|
||||
}
|
||||
|
||||
async countVideoViewEvents(pointer, options = {}) {
|
||||
const relayList =
|
||||
Array.isArray(options?.relays) && options.relays.length
|
||||
? options.relays
|
||||
: undefined;
|
||||
|
||||
const fallbackListOptions = (() => {
|
||||
const listOptions = {};
|
||||
|
||||
if (relayList) {
|
||||
listOptions.relays = relayList;
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.since)) {
|
||||
listOptions.since = Math.floor(options.since);
|
||||
} else {
|
||||
const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
|
||||
const horizonDays = Number.isFinite(horizonDaysRaw)
|
||||
? Math.max(0, Math.floor(horizonDaysRaw))
|
||||
: 0;
|
||||
if (horizonDays > 0) {
|
||||
const secondsPerDay = 86_400;
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
const fallbackSinceSeconds = Math.max(
|
||||
0,
|
||||
nowSeconds - horizonDays * secondsPerDay
|
||||
);
|
||||
listOptions.since = Math.floor(fallbackSinceSeconds);
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.until)) {
|
||||
listOptions.until = Math.floor(options.until);
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.limit) && options.limit > 0) {
|
||||
listOptions.limit = Math.floor(options.limit);
|
||||
}
|
||||
|
||||
return listOptions;
|
||||
})();
|
||||
|
||||
if (!this.pool) {
|
||||
const events = await this.listVideoViewEvents(pointer, {
|
||||
...fallbackListOptions,
|
||||
});
|
||||
return {
|
||||
total: Array.isArray(events) ? events.length : 0,
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { filters } = createVideoViewEventFilters(pointer);
|
||||
const pointerFilter = Array.isArray(filters) && filters.length ? filters[0] : null;
|
||||
if (!pointerFilter || typeof pointerFilter !== "object") {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const signal = options?.signal;
|
||||
const normalizeAbortError = () => {
|
||||
if (signal?.reason instanceof Error) {
|
||||
return signal.reason;
|
||||
}
|
||||
if (typeof DOMException === "function") {
|
||||
return new DOMException("Operation aborted", "AbortError");
|
||||
}
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
throw normalizeAbortError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.countEventsAcrossRelays([pointerFilter], {
|
||||
relays: relayList,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
|
||||
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
|
||||
return { ...result, fallback: false };
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] COUNT view request failed:", error);
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw normalizeAbortError();
|
||||
}
|
||||
|
||||
const abortPromise =
|
||||
signal &&
|
||||
typeof signal === "object" &&
|
||||
typeof signal.addEventListener === "function"
|
||||
? new Promise((_, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(normalizeAbortError());
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
: null;
|
||||
|
||||
const listPromise = this.listVideoViewEvents(pointer, {
|
||||
...fallbackListOptions,
|
||||
});
|
||||
|
||||
const events = abortPromise
|
||||
? await Promise.race([listPromise, abortPromise])
|
||||
: await listPromise;
|
||||
|
||||
const uniqueCount = Array.isArray(events)
|
||||
? (() => {
|
||||
const withIds = events
|
||||
.filter((event) => event && typeof event.id === "string")
|
||||
.map((event) => event.id);
|
||||
if (withIds.length === 0) {
|
||||
return events.length;
|
||||
}
|
||||
return new Set(withIds).size;
|
||||
})()
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total: uniqueCount,
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
};
|
||||
return countVideoViewEventsForClient(this, pointer, options);
|
||||
}
|
||||
|
||||
async publishViewEvent(videoPointer, options = {}) {
|
||||
if (!this.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const pointer = normalizePointerInput(videoPointer);
|
||||
if (!pointer) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const actorPubkey = await this.ensureSessionActor();
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const guardScope = deriveViewEventPointerScope(pointer);
|
||||
const guardBucket = deriveViewEventBucketIndex(createdAt);
|
||||
if (guardScope && hasRecentViewPublish(guardScope, guardBucket)) {
|
||||
devLogger.info("[nostr] Skipping duplicate view publish for scope", guardScope);
|
||||
return {
|
||||
ok: true,
|
||||
duplicate: true,
|
||||
event: null,
|
||||
results: [],
|
||||
acceptedRelays: [],
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
|
||||
const normalizedLogged =
|
||||
typeof this.pubkey === "string" ? this.pubkey.toLowerCase() : "";
|
||||
const usingSessionActor =
|
||||
normalizedActor && normalizedActor !== normalizedLogged;
|
||||
|
||||
const additionalTags = Array.isArray(options.additionalTags)
|
||||
? options.additionalTags.filter(
|
||||
(tag) => Array.isArray(tag) && typeof tag[0] === "string"
|
||||
)
|
||||
: [];
|
||||
|
||||
const pointerTag =
|
||||
pointer.type === "a"
|
||||
? pointer.relay
|
||||
? ["a", pointer.value, pointer.relay]
|
||||
: ["a", pointer.value]
|
||||
: pointer.relay
|
||||
? ["e", pointer.value, pointer.relay]
|
||||
: ["e", pointer.value];
|
||||
|
||||
let content = "";
|
||||
if (typeof options.content === "string") {
|
||||
content = options.content;
|
||||
} else if (
|
||||
options.content &&
|
||||
typeof options.content === "object" &&
|
||||
!Array.isArray(options.content)
|
||||
) {
|
||||
try {
|
||||
content = JSON.stringify(options.content);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to serialize custom view event content:",
|
||||
error
|
||||
);
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
const payload = {
|
||||
target: {
|
||||
type: pointer.type,
|
||||
value: pointer.value,
|
||||
},
|
||||
created_at: createdAt,
|
||||
};
|
||||
if (pointer.relay) {
|
||||
payload.target.relay = pointer.relay;
|
||||
}
|
||||
try {
|
||||
content = JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to serialize default view event content:",
|
||||
error
|
||||
);
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
const event = buildViewEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
pointerValue: pointer.value,
|
||||
pointerTag,
|
||||
dedupeTag: generateViewEventDedupeTag(actorPubkey, pointer, createdAt),
|
||||
includeSessionTag: usingSessionActor,
|
||||
additionalTags,
|
||||
content,
|
||||
return publishViewEventForClient(this, videoPointer, options, {
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
signEventWithPrivateKey,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const signer = resolveActiveSigner(actorPubkey);
|
||||
const canUseActiveSigner =
|
||||
normalizedActor &&
|
||||
normalizedActor === normalizedLogged &&
|
||||
signer &&
|
||||
typeof signer.signEvent === "function";
|
||||
|
||||
if (canUseActiveSigner) {
|
||||
let permissionResult = { ok: true };
|
||||
if (shouldRequestExtensionPermissions(signer)) {
|
||||
permissionResult = await this.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
);
|
||||
}
|
||||
if (permissionResult.ok) {
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign view event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; signing view event with session key.",
|
||||
permissionResult.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!signedEvent) {
|
||||
try {
|
||||
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
|
||||
await this.ensureSessionActor(true);
|
||||
}
|
||||
if (!this.sessionActor || this.sessionActor.pubkey !== actorPubkey) {
|
||||
throw new Error("session-actor-mismatch");
|
||||
}
|
||||
const privateKey = this.sessionActor.privateKey;
|
||||
signedEvent = signEventWithPrivateKey(event, privateKey);
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostr] Failed to sign view event with session key:", error);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
}
|
||||
|
||||
const relayList =
|
||||
Array.isArray(options.relays) && options.relays.length
|
||||
? options.relays
|
||||
: this.relays;
|
||||
const relays =
|
||||
Array.isArray(relayList) && relayList.length ? relayList : RELAY_URLS;
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relays.map((url) => publishEventToRelay(this.pool, url, signedEvent))
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
const success = acceptedRelays.length > 0;
|
||||
if (success) {
|
||||
if (guardScope) {
|
||||
rememberViewPublish(guardScope, guardBucket);
|
||||
}
|
||||
devLogger.info(
|
||||
`[nostr] View event accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", ")
|
||||
);
|
||||
} else {
|
||||
userLogger.warn("[nostr] View event rejected by relays:", publishResults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
|
||||
async recordVideoView(videoPointer, options = {}) {
|
||||
const pointer = normalizePointerInput(videoPointer);
|
||||
if (!pointer) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const view = await this.publishViewEvent(pointer, options);
|
||||
|
||||
if (view?.ok && view.event) {
|
||||
try {
|
||||
const ingest = await loadIngestLocalViewEvent();
|
||||
if (typeof ingest === "function") {
|
||||
ingest({ event: view.event, pointer });
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to ingest optimistic view event:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return view;
|
||||
return recordVideoViewForClient(this, videoPointer, options, {
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
signEventWithPrivateKey,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -7080,3 +6263,11 @@ export {
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
};
|
||||
export {
|
||||
deriveViewEventBucketIndex,
|
||||
deriveViewEventPointerScope,
|
||||
hasRecentViewPublish,
|
||||
rememberViewPublish,
|
||||
createVideoViewEventFilters,
|
||||
getViewEventGuardWindowMs,
|
||||
} from "./nostr/viewEvents.js";
|
||||
@@ -0,0 +1,887 @@
|
||||
import {
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
VIEW_COUNT_BACKFILL_MAX_DAYS,
|
||||
} from "../config.js";
|
||||
import { VIEW_FILTER_INCLUDE_LEGACY_VIDEO } from "../constants.js";
|
||||
import {
|
||||
buildViewEvent,
|
||||
getNostrEventSchema,
|
||||
NOTE_TYPES,
|
||||
} from "../nostrEventSchemas.js";
|
||||
import { publishEventToRelay } from "../nostrPublish.js";
|
||||
import { RELAY_URLS } from "./toolkit.js";
|
||||
import { normalizePointerInput } from "./watchHistory.js";
|
||||
import { devLogger, userLogger } from "../utils/logger.js";
|
||||
|
||||
const VIEW_EVENT_GUARD_PREFIX = "bitvid:viewed";
|
||||
|
||||
const viewEventPublishMemory = new Map();
|
||||
|
||||
const VIEW_EVENT_SCHEMA = getNostrEventSchema(NOTE_TYPES.VIEW_EVENT);
|
||||
export const VIEW_EVENT_KIND = Number.isFinite(VIEW_EVENT_SCHEMA?.kind)
|
||||
? VIEW_EVENT_SCHEMA.kind
|
||||
: 30079;
|
||||
|
||||
function resolveVideoViewPointer(pointer) {
|
||||
const normalized = normalizePointerInput(pointer);
|
||||
if (!normalized || typeof normalized.value !== "string") {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const value = normalized.value.trim();
|
||||
if (!value) {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const type = normalized.type === "a" ? "a" : "e";
|
||||
const descriptor = { type, value };
|
||||
|
||||
if (typeof normalized.relay === "string" && normalized.relay.trim()) {
|
||||
descriptor.relay = normalized.relay.trim();
|
||||
}
|
||||
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
export function createVideoViewEventFilters(pointer) {
|
||||
let resolved;
|
||||
|
||||
if (
|
||||
pointer &&
|
||||
typeof pointer === "object" &&
|
||||
(pointer.type === "a" || pointer.type === "e") &&
|
||||
typeof pointer.value === "string"
|
||||
) {
|
||||
const value = pointer.value.trim();
|
||||
if (!value) {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
resolved = { type: pointer.type === "a" ? "a" : "e", value };
|
||||
if (typeof pointer.relay === "string" && pointer.relay.trim()) {
|
||||
resolved.relay = pointer.relay.trim();
|
||||
}
|
||||
} else {
|
||||
resolved = resolveVideoViewPointer(pointer);
|
||||
}
|
||||
|
||||
const pointerFilter = {
|
||||
kinds: [VIEW_EVENT_KIND],
|
||||
"#t": ["view"],
|
||||
};
|
||||
|
||||
if (resolved.type === "a") {
|
||||
pointerFilter["#a"] = [resolved.value];
|
||||
} else {
|
||||
pointerFilter["#e"] = [resolved.value];
|
||||
}
|
||||
|
||||
const filters = [pointerFilter];
|
||||
|
||||
if (VIEW_FILTER_INCLUDE_LEGACY_VIDEO) {
|
||||
filters.push({
|
||||
kinds: [VIEW_EVENT_KIND],
|
||||
"#t": ["view"],
|
||||
"#video": [resolved.value],
|
||||
});
|
||||
}
|
||||
|
||||
return { pointer: resolved, filters };
|
||||
}
|
||||
|
||||
export function deriveViewEventBucketIndex(createdAtSeconds) {
|
||||
const timestamp = Number.isFinite(createdAtSeconds)
|
||||
? Math.floor(createdAtSeconds)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const windowSize = Math.max(
|
||||
1,
|
||||
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
|
||||
);
|
||||
return Math.floor(timestamp / windowSize);
|
||||
}
|
||||
|
||||
export function getViewEventGuardWindowMs() {
|
||||
const windowSeconds = Math.max(
|
||||
1,
|
||||
Number(VIEW_COUNT_DEDUPE_WINDOW_SECONDS) || 0
|
||||
);
|
||||
return windowSeconds * 1000;
|
||||
}
|
||||
|
||||
export function deriveViewEventPointerScope(pointer) {
|
||||
const pointerValue =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim().toLowerCase() : "";
|
||||
if (!pointerValue) {
|
||||
return "";
|
||||
}
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
return `${pointerType}:${pointerValue}`;
|
||||
}
|
||||
|
||||
function generateViewEventEntropy() {
|
||||
const cryptoRef =
|
||||
(typeof globalThis !== "undefined" &&
|
||||
/** @type {Crypto | undefined} */ (globalThis.crypto)) ||
|
||||
null;
|
||||
|
||||
if (cryptoRef && typeof cryptoRef.getRandomValues === "function") {
|
||||
try {
|
||||
const buffer = new Uint32Array(2);
|
||||
cryptoRef.getRandomValues(buffer);
|
||||
return Array.from(buffer, (value) =>
|
||||
value.toString(16).padStart(8, "0")
|
||||
).join("");
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to gather crypto entropy for view event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackA = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
const fallbackB = Math.floor(Math.random() * 0xffffffff)
|
||||
.toString(16)
|
||||
.padStart(8, "0");
|
||||
return `${fallbackA}${fallbackB}`;
|
||||
}
|
||||
|
||||
function generateViewEventDedupeTag(actorPubkey, pointer, createdAtSeconds) {
|
||||
const scope = deriveViewEventPointerScope(pointer) || "unknown";
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" && actorPubkey.trim()
|
||||
? actorPubkey.trim().toLowerCase()
|
||||
: "anon";
|
||||
const timestamp = Number.isFinite(createdAtSeconds)
|
||||
? Math.max(0, Math.floor(createdAtSeconds))
|
||||
: Math.floor(Date.now() / 1000);
|
||||
const entropy = generateViewEventEntropy();
|
||||
return `${scope}:${normalizedActor}:${timestamp}:${entropy}`;
|
||||
}
|
||||
|
||||
export function hasRecentViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const now = Date.now();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
|
||||
if (entry) {
|
||||
const age = now - Number(entry.seenAt);
|
||||
if (!Number.isFinite(entry.seenAt) || age >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
} else if (Number(entry.bucket) === bucketIndex) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
let rawValue = null;
|
||||
try {
|
||||
rawValue = localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to read view guard entry:", error);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof rawValue !== "string" || !rawValue) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [storedBucketRaw, storedSeenRaw] = rawValue.split(":", 2);
|
||||
const storedBucket = Number(storedBucketRaw);
|
||||
const storedSeenAt = Number(storedSeenRaw);
|
||||
|
||||
if (!Number.isFinite(storedBucket) || !Number.isFinite(storedSeenAt)) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to clear corrupt view guard entry:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (now - storedSeenAt >= windowMs) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to remove expired view guard entry:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: storedBucket,
|
||||
seenAt: storedSeenAt,
|
||||
});
|
||||
|
||||
return storedBucket === bucketIndex;
|
||||
}
|
||||
|
||||
export function rememberViewPublish(scope, bucketIndex) {
|
||||
if (!scope || !Number.isFinite(bucketIndex)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
const entry = viewEventPublishMemory.get(scope);
|
||||
if (entry && Number.isFinite(entry.seenAt) && now - entry.seenAt >= windowMs) {
|
||||
viewEventPublishMemory.delete(scope);
|
||||
}
|
||||
|
||||
viewEventPublishMemory.set(scope, {
|
||||
bucket: bucketIndex,
|
||||
seenAt: now,
|
||||
});
|
||||
|
||||
if (typeof localStorage === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const storageKey = `${VIEW_EVENT_GUARD_PREFIX}:${scope}`;
|
||||
try {
|
||||
localStorage.setItem(storageKey, `${bucketIndex}:${now}`);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to persist view guard entry:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function isVideoViewEvent(event, pointer) {
|
||||
if (!event || typeof event !== "object") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(event.kind) || event.kind !== VIEW_EVENT_KIND) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(event.tags) ? event.tags : [];
|
||||
let hasViewTag = false;
|
||||
let matchesPointer = false;
|
||||
|
||||
const pointerValueRaw =
|
||||
typeof pointer?.value === "string" ? pointer.value.trim() : "";
|
||||
const pointerValueLower = pointerValueRaw.toLowerCase();
|
||||
const pointerType = pointer?.type === "a" ? "a" : "e";
|
||||
|
||||
for (const tag of tags) {
|
||||
if (!Array.isArray(tag) || tag.length < 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const tagName = typeof tag[0] === "string" ? tag[0].toLowerCase() : "";
|
||||
const tagValue = typeof tag[1] === "string" ? tag[1].trim().toLowerCase() : "";
|
||||
|
||||
if (!tagName || !tagValue) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tagName === "t" && tagValue === "view") {
|
||||
hasViewTag = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "a" && tagName === "a" && tagValue === pointerValueLower) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (pointerType === "e" && tagName === "e" && tagValue === pointerValueLower) {
|
||||
matchesPointer = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
VIEW_FILTER_INCLUDE_LEGACY_VIDEO &&
|
||||
tagName === "video" &&
|
||||
tagValue === pointerValueLower
|
||||
) {
|
||||
matchesPointer = true;
|
||||
}
|
||||
}
|
||||
|
||||
return hasViewTag && matchesPointer;
|
||||
}
|
||||
|
||||
const VIEW_EVENT_COUNT_SECONDS_PER_DAY = 86_400;
|
||||
|
||||
function flattenListResults(input) {
|
||||
if (!Array.isArray(input)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const flat = [];
|
||||
for (const chunk of input) {
|
||||
if (Array.isArray(chunk)) {
|
||||
for (const item of chunk) {
|
||||
if (item && typeof item === "object") {
|
||||
flat.push(item);
|
||||
}
|
||||
}
|
||||
} else if (chunk && typeof chunk === "object") {
|
||||
flat.push(chunk);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
function sanitizeRelayList(primary, fallback) {
|
||||
if (Array.isArray(primary) && primary.length) {
|
||||
return primary;
|
||||
}
|
||||
if (Array.isArray(fallback) && fallback.length) {
|
||||
return fallback;
|
||||
}
|
||||
return RELAY_URLS;
|
||||
}
|
||||
|
||||
export async function listVideoViewEvents(client, pointer, options = {}) {
|
||||
const pool = client?.pool;
|
||||
const canQueryPool = pool && typeof pool.list === "function";
|
||||
|
||||
if (!canQueryPool) {
|
||||
if (
|
||||
client &&
|
||||
Object.prototype.hasOwnProperty.call(client, "listVideoViewEvents") &&
|
||||
typeof client.listVideoViewEvents === "function"
|
||||
) {
|
||||
return client.listVideoViewEvents(pointer, options);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
|
||||
pointer
|
||||
);
|
||||
const { since, until, limit, relays } = options || {};
|
||||
|
||||
for (const filter of filters) {
|
||||
if (!filter || typeof filter !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (Number.isFinite(since)) {
|
||||
filter.since = Math.floor(since);
|
||||
}
|
||||
if (Number.isFinite(until)) {
|
||||
filter.until = Math.floor(until);
|
||||
}
|
||||
if (Number.isFinite(limit) && limit > 0) {
|
||||
filter.limit = Math.floor(limit);
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(relays, client.relays);
|
||||
|
||||
let rawResults;
|
||||
try {
|
||||
rawResults = await pool.list(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to list video view events:", error);
|
||||
return [];
|
||||
}
|
||||
|
||||
const flattened = flattenListResults(rawResults);
|
||||
const dedupe = new Map();
|
||||
const order = [];
|
||||
|
||||
for (const event of flattened) {
|
||||
if (!isVideoViewEvent(event, pointerDescriptor)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const eventId = typeof event.id === "string" ? event.id : null;
|
||||
if (!eventId) {
|
||||
order.push({ type: "raw", event });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = dedupe.get(eventId);
|
||||
if (!existing) {
|
||||
dedupe.set(eventId, event);
|
||||
order.push({ type: "id", key: eventId });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingCreated = Number.isFinite(existing?.created_at)
|
||||
? existing.created_at
|
||||
: 0;
|
||||
const incomingCreated = Number.isFinite(event.created_at)
|
||||
? event.created_at
|
||||
: 0;
|
||||
if (incomingCreated > existingCreated) {
|
||||
dedupe.set(eventId, event);
|
||||
}
|
||||
}
|
||||
|
||||
return order
|
||||
.map((entry) => {
|
||||
if (!entry) {
|
||||
return null;
|
||||
}
|
||||
if (entry.type === "raw") {
|
||||
return entry.event || null;
|
||||
}
|
||||
if (entry.type === "id") {
|
||||
return dedupe.get(entry.key) || null;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function subscribeVideoViewEvents(client, pointer, options = {}) {
|
||||
const pool = client?.pool;
|
||||
const canSubscribe = pool && typeof pool.sub === "function";
|
||||
|
||||
if (!canSubscribe) {
|
||||
devLogger.warn("[nostr] Unable to subscribe to view events: pool missing.");
|
||||
return () => {};
|
||||
}
|
||||
|
||||
const { pointer: pointerDescriptor, filters } = createVideoViewEventFilters(
|
||||
pointer
|
||||
);
|
||||
|
||||
if (Number.isFinite(options?.since)) {
|
||||
for (const filter of filters) {
|
||||
if (filter && typeof filter === "object") {
|
||||
filter.since = Math.floor(options.since);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options?.relays, client.relays);
|
||||
const onEvent = typeof options?.onEvent === "function" ? options.onEvent : null;
|
||||
|
||||
let subscription;
|
||||
try {
|
||||
subscription = pool.sub(relayList, filters);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to open video view subscription:", error);
|
||||
return () => {};
|
||||
}
|
||||
|
||||
if (onEvent) {
|
||||
subscription.on("event", (event) => {
|
||||
if (isVideoViewEvent(event, pointerDescriptor)) {
|
||||
try {
|
||||
onEvent(event);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Video view event handler threw:", error);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const originalUnsub =
|
||||
typeof subscription.unsub === "function"
|
||||
? subscription.unsub.bind(subscription)
|
||||
: null;
|
||||
|
||||
let unsubscribed = false;
|
||||
return () => {
|
||||
if (unsubscribed) {
|
||||
return;
|
||||
}
|
||||
unsubscribed = true;
|
||||
if (originalUnsub) {
|
||||
try {
|
||||
originalUnsub();
|
||||
} catch (error) {
|
||||
devLogger.warn(
|
||||
"[nostr] Failed to unsubscribe from video view events:",
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function countVideoViewEvents(client, pointer, options = {}) {
|
||||
const relayList =
|
||||
Array.isArray(options?.relays) && options.relays.length
|
||||
? options.relays
|
||||
: undefined;
|
||||
|
||||
const fallbackListOptions = (() => {
|
||||
const listOptions = {};
|
||||
|
||||
if (relayList) {
|
||||
listOptions.relays = relayList;
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.since)) {
|
||||
listOptions.since = Math.floor(options.since);
|
||||
} else {
|
||||
const horizonDaysRaw = Number(VIEW_COUNT_BACKFILL_MAX_DAYS);
|
||||
const horizonDays = Number.isFinite(horizonDaysRaw)
|
||||
? Math.max(0, Math.floor(horizonDaysRaw))
|
||||
: 0;
|
||||
if (horizonDays > 0) {
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
listOptions.since = Math.max(
|
||||
0,
|
||||
nowSeconds - horizonDays * VIEW_EVENT_COUNT_SECONDS_PER_DAY
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.until)) {
|
||||
listOptions.until = Math.floor(options.until);
|
||||
}
|
||||
|
||||
if (Number.isFinite(options?.limit) && options.limit > 0) {
|
||||
listOptions.limit = Math.floor(options.limit);
|
||||
}
|
||||
|
||||
return listOptions;
|
||||
})();
|
||||
|
||||
const canAttemptCount =
|
||||
typeof client?.countEventsAcrossRelays === "function";
|
||||
|
||||
if (!canAttemptCount) {
|
||||
const events = await listVideoViewEvents(client, pointer, {
|
||||
...fallbackListOptions,
|
||||
});
|
||||
return {
|
||||
total: Array.isArray(events) ? events.length : 0,
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { filters } = createVideoViewEventFilters(pointer);
|
||||
const pointerFilter = Array.isArray(filters) && filters.length ? filters[0] : null;
|
||||
if (!pointerFilter || typeof pointerFilter !== "object") {
|
||||
throw new Error("Invalid video pointer supplied for view lookup.");
|
||||
}
|
||||
|
||||
const signal = options?.signal;
|
||||
const normalizeAbortError = () => {
|
||||
if (signal?.reason instanceof Error) {
|
||||
return signal.reason;
|
||||
}
|
||||
if (typeof DOMException === "function") {
|
||||
return new DOMException("Operation aborted", "AbortError");
|
||||
}
|
||||
const error = new Error("Operation aborted");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
throw normalizeAbortError();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await client.countEventsAcrossRelays([pointerFilter], {
|
||||
relays: sanitizeRelayList(relayList, client.relays),
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
|
||||
if (result?.perRelay?.some((entry) => entry && entry.ok)) {
|
||||
return { ...result, fallback: false };
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.code !== "count-unsupported") {
|
||||
devLogger.warn("[nostr] COUNT view request failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
throw normalizeAbortError();
|
||||
}
|
||||
|
||||
const abortPromise =
|
||||
signal &&
|
||||
typeof signal === "object" &&
|
||||
typeof signal.addEventListener === "function"
|
||||
? new Promise((_, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
reject(normalizeAbortError());
|
||||
},
|
||||
{ once: true }
|
||||
);
|
||||
})
|
||||
: null;
|
||||
|
||||
const listPromise = listVideoViewEvents(client, pointer, {
|
||||
...fallbackListOptions,
|
||||
});
|
||||
|
||||
const events = abortPromise
|
||||
? await Promise.race([listPromise, abortPromise])
|
||||
: await listPromise;
|
||||
|
||||
const uniqueCount = Array.isArray(events)
|
||||
? (() => {
|
||||
const withIds = events
|
||||
.filter((event) => event && typeof event.id === "string")
|
||||
.map((event) => event.id);
|
||||
if (withIds.length === 0) {
|
||||
return events.length;
|
||||
}
|
||||
return new Set(withIds).size;
|
||||
})()
|
||||
: 0;
|
||||
|
||||
return {
|
||||
total: uniqueCount,
|
||||
perRelay: [],
|
||||
best: null,
|
||||
fallback: true,
|
||||
};
|
||||
}
|
||||
|
||||
let ingestLocalViewEventRef = null;
|
||||
|
||||
async function loadIngestLocalViewEvent() {
|
||||
if (typeof ingestLocalViewEventRef === "function") {
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
try {
|
||||
const module = await import("../viewCounter.js");
|
||||
if (typeof module?.ingestLocalViewEvent === "function") {
|
||||
ingestLocalViewEventRef = module.ingestLocalViewEvent;
|
||||
return ingestLocalViewEventRef;
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to load view counter ingest helper:", error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function publishViewEvent(
|
||||
client,
|
||||
videoPointer,
|
||||
options = {},
|
||||
{
|
||||
resolveActiveSigner,
|
||||
shouldRequestExtensionPermissions,
|
||||
signEventWithPrivateKey,
|
||||
DEFAULT_NIP07_PERMISSION_METHODS,
|
||||
}
|
||||
) {
|
||||
if (!client?.pool) {
|
||||
return { ok: false, error: "nostr-uninitialized" };
|
||||
}
|
||||
|
||||
const pointer = normalizePointerInput(videoPointer);
|
||||
if (!pointer) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const actorPubkey = await client.ensureSessionActor();
|
||||
if (!actorPubkey) {
|
||||
return { ok: false, error: "missing-actor" };
|
||||
}
|
||||
|
||||
const createdAt =
|
||||
typeof options.created_at === "number" && options.created_at > 0
|
||||
? Math.floor(options.created_at)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const guardScope = deriveViewEventPointerScope(pointer);
|
||||
const guardBucket = deriveViewEventBucketIndex(createdAt);
|
||||
if (guardScope && hasRecentViewPublish(guardScope, guardBucket)) {
|
||||
devLogger.info("[nostr] Skipping duplicate view publish for scope", guardScope);
|
||||
return {
|
||||
ok: true,
|
||||
duplicate: true,
|
||||
event: null,
|
||||
results: [],
|
||||
acceptedRelays: [],
|
||||
};
|
||||
}
|
||||
|
||||
const normalizedActor =
|
||||
typeof actorPubkey === "string" ? actorPubkey.toLowerCase() : "";
|
||||
const normalizedLogged =
|
||||
typeof client.pubkey === "string" ? client.pubkey.toLowerCase() : "";
|
||||
const usingSessionActor =
|
||||
normalizedActor && normalizedActor !== normalizedLogged;
|
||||
|
||||
const additionalTags = Array.isArray(options.additionalTags)
|
||||
? options.additionalTags.filter(
|
||||
(tag) => Array.isArray(tag) && typeof tag[0] === "string"
|
||||
)
|
||||
: [];
|
||||
|
||||
const pointerTag =
|
||||
pointer.type === "a"
|
||||
? pointer.relay
|
||||
? ["a", pointer.value, pointer.relay]
|
||||
: ["a", pointer.value]
|
||||
: pointer.relay
|
||||
? ["e", pointer.value, pointer.relay]
|
||||
: ["e", pointer.value];
|
||||
|
||||
let content = "";
|
||||
if (typeof options.content === "string") {
|
||||
content = options.content;
|
||||
} else if (
|
||||
options.content &&
|
||||
typeof options.content === "object" &&
|
||||
!Array.isArray(options.content)
|
||||
) {
|
||||
try {
|
||||
content = JSON.stringify(options.content);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to serialize custom view event content:", error);
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
const payload = {
|
||||
target: {
|
||||
type: pointer.type,
|
||||
value: pointer.value,
|
||||
},
|
||||
created_at: createdAt,
|
||||
};
|
||||
if (pointer.relay) {
|
||||
payload.target.relay = pointer.relay;
|
||||
}
|
||||
try {
|
||||
content = JSON.stringify(payload);
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to serialize default view event content:", error);
|
||||
content = "";
|
||||
}
|
||||
}
|
||||
|
||||
const event = buildViewEvent({
|
||||
pubkey: actorPubkey,
|
||||
created_at: createdAt,
|
||||
pointerValue: pointer.value,
|
||||
pointerTag,
|
||||
dedupeTag: generateViewEventDedupeTag(actorPubkey, pointer, createdAt),
|
||||
includeSessionTag: usingSessionActor,
|
||||
additionalTags,
|
||||
content,
|
||||
});
|
||||
|
||||
let signedEvent = null;
|
||||
|
||||
const signer = resolveActiveSigner(actorPubkey);
|
||||
const canUseActiveSigner =
|
||||
normalizedActor &&
|
||||
normalizedActor === normalizedLogged &&
|
||||
signer &&
|
||||
typeof signer.signEvent === "function";
|
||||
|
||||
if (canUseActiveSigner) {
|
||||
let permissionResult = { ok: true };
|
||||
if (shouldRequestExtensionPermissions(signer)) {
|
||||
permissionResult = await client.ensureExtensionPermissions(
|
||||
DEFAULT_NIP07_PERMISSION_METHODS
|
||||
);
|
||||
}
|
||||
if (permissionResult.ok) {
|
||||
try {
|
||||
signedEvent = await signer.signEvent(event);
|
||||
} catch (error) {
|
||||
userLogger.warn(
|
||||
"[nostr] Failed to sign view event with active signer:",
|
||||
error,
|
||||
);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
} else {
|
||||
userLogger.warn(
|
||||
"[nostr] Active signer permissions missing; signing view event with session key.",
|
||||
permissionResult.error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!signedEvent) {
|
||||
try {
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
await client.ensureSessionActor(true);
|
||||
}
|
||||
if (!client.sessionActor || client.sessionActor.pubkey !== actorPubkey) {
|
||||
throw new Error("session-actor-mismatch");
|
||||
}
|
||||
const privateKey = client.sessionActor.privateKey;
|
||||
signedEvent = signEventWithPrivateKey(event, privateKey);
|
||||
} catch (error) {
|
||||
userLogger.warn("[nostr] Failed to sign view event with session key:", error);
|
||||
return { ok: false, error: "signing-failed", details: error };
|
||||
}
|
||||
}
|
||||
|
||||
const relayList = sanitizeRelayList(options.relays, client.relays);
|
||||
|
||||
const publishResults = await Promise.all(
|
||||
relayList.map((url) => publishEventToRelay(client.pool, url, signedEvent))
|
||||
);
|
||||
|
||||
const acceptedRelays = publishResults
|
||||
.filter((result) => result.success)
|
||||
.map((result) => result.url)
|
||||
.filter((url) => typeof url === "string" && url);
|
||||
const success = acceptedRelays.length > 0;
|
||||
if (success) {
|
||||
if (guardScope) {
|
||||
rememberViewPublish(guardScope, guardBucket);
|
||||
}
|
||||
devLogger.info(
|
||||
`[nostr] View event accepted by ${acceptedRelays.length} relay(s):`,
|
||||
acceptedRelays.join(", ")
|
||||
);
|
||||
} else {
|
||||
userLogger.warn("[nostr] View event rejected by relays:", publishResults);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: success,
|
||||
event: signedEvent,
|
||||
results: publishResults,
|
||||
acceptedRelays,
|
||||
};
|
||||
}
|
||||
|
||||
export async function recordVideoView(
|
||||
client,
|
||||
videoPointer,
|
||||
options = {},
|
||||
helpers
|
||||
) {
|
||||
const pointer = normalizePointerInput(videoPointer);
|
||||
if (!pointer) {
|
||||
return { ok: false, error: "invalid-pointer" };
|
||||
}
|
||||
|
||||
const view = await publishViewEvent(client, pointer, options, helpers);
|
||||
|
||||
if (view?.ok && view.event) {
|
||||
try {
|
||||
const ingest = await loadIngestLocalViewEvent();
|
||||
if (typeof ingest === "function") {
|
||||
ingest({ event: view.event, pointer });
|
||||
}
|
||||
} catch (error) {
|
||||
devLogger.warn("[nostr] Failed to ingest optimistic view event:", error);
|
||||
}
|
||||
}
|
||||
|
||||
return view;
|
||||
}
|
||||
|
||||
export const __testExports = {
|
||||
resolveVideoViewPointer,
|
||||
generateViewEventEntropy,
|
||||
generateViewEventDedupeTag,
|
||||
isVideoViewEvent,
|
||||
loadIngestLocalViewEvent,
|
||||
};
|
||||
@@ -21,6 +21,10 @@ import { ALLOW_NSFW_CONTENT } from "./config.js";
|
||||
import { devLogger, userLogger } from "./utils/logger.js";
|
||||
import moderationService from "./services/moderationService.js";
|
||||
import nostrService from "./services/nostrService.js";
|
||||
import {
|
||||
listVideoViewEvents as listVideoViewEventsApi,
|
||||
subscribeVideoViewEvents as subscribeVideoViewEventsApi,
|
||||
} from "./nostr/viewEvents.js";
|
||||
|
||||
function normalizeHexPubkey(value) {
|
||||
if (typeof value !== "string") {
|
||||
@@ -1242,4 +1246,7 @@ class SubscriptionsManager {
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionsManager.listVideoViewEvents = listVideoViewEventsApi;
|
||||
SubscriptionsManager.subscribeVideoViewEvents = subscribeVideoViewEventsApi;
|
||||
|
||||
export const subscriptions = new SubscriptionsManager();
|
||||
+24
-4
@@ -17,16 +17,36 @@ import {
|
||||
VIEW_COUNT_DEDUPE_WINDOW_SECONDS,
|
||||
} from "./config.js";
|
||||
import { userLogger } from "./utils/logger.js";
|
||||
import { nostrClient } from "./nostr.js";
|
||||
import {
|
||||
countVideoViewEvents,
|
||||
listVideoViewEvents,
|
||||
subscribeVideoViewEvents,
|
||||
} from "./nostr.js";
|
||||
countVideoViewEvents as countVideoViewEventsForClient,
|
||||
listVideoViewEvents as listVideoViewEventsForClient,
|
||||
subscribeVideoViewEvents as subscribeVideoViewEventsForClient,
|
||||
} from "./nostr/viewEvents.js";
|
||||
|
||||
const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1";
|
||||
const STORAGE_DEBOUNCE_MS = 500;
|
||||
const SECONDS_PER_DAY = 86_400;
|
||||
|
||||
const listVideoViewEvents = (pointer, options) => {
|
||||
if (typeof nostrClient.listVideoViewEvents === "function") {
|
||||
return nostrClient.listVideoViewEvents(pointer, options);
|
||||
}
|
||||
return listVideoViewEventsForClient(nostrClient, pointer, options);
|
||||
};
|
||||
const countVideoViewEvents = (pointer, options) => {
|
||||
if (typeof nostrClient.countVideoViewEvents === "function") {
|
||||
return nostrClient.countVideoViewEvents(pointer, options);
|
||||
}
|
||||
return countVideoViewEventsForClient(nostrClient, pointer, options);
|
||||
};
|
||||
const subscribeVideoViewEvents = (pointer, options) => {
|
||||
if (typeof nostrClient.subscribeVideoViewEvents === "function") {
|
||||
return nostrClient.subscribeVideoViewEvents(pointer, options);
|
||||
}
|
||||
return subscribeVideoViewEventsForClient(nostrClient, pointer, options);
|
||||
};
|
||||
|
||||
/** @type {Map<string, ViewCounterState>} */
|
||||
const pointerStates = new Map();
|
||||
/** @type {Map<string, PointerListeners>} */
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// Run with: node tests/nostr-view-events.test.mjs
|
||||
|
||||
import "./test-helpers/setup-localstorage.mjs";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
deriveViewEventBucketIndex,
|
||||
hasRecentViewPublish,
|
||||
rememberViewPublish,
|
||||
createVideoViewEventFilters,
|
||||
getViewEventGuardWindowMs,
|
||||
} = await import("../js/nostr/viewEvents.js");
|
||||
const {
|
||||
setViewFilterIncludeLegacyVideo,
|
||||
resetRuntimeFlags,
|
||||
} = await import("../js/constants.js");
|
||||
|
||||
async function withMockedNow(initialMs, callback) {
|
||||
const originalNow = Date.now;
|
||||
let nowMs = initialMs;
|
||||
Date.now = () => nowMs;
|
||||
const setNow = (nextMs) => {
|
||||
nowMs = nextMs;
|
||||
};
|
||||
try {
|
||||
await callback({ setNow, getNow: () => nowMs });
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRememberViewPublishHonorsGuardWindow() {
|
||||
localStorage.clear();
|
||||
|
||||
const scope = "a:test-pointer";
|
||||
const bucket = deriveViewEventBucketIndex(1_700_000_000);
|
||||
const windowMs = getViewEventGuardWindowMs();
|
||||
|
||||
await withMockedNow(1_700_000_000_000, async ({ setNow }) => {
|
||||
assert.equal(
|
||||
hasRecentViewPublish(scope, bucket),
|
||||
false,
|
||||
"guard should not report recent publish before remembering",
|
||||
);
|
||||
|
||||
rememberViewPublish(scope, bucket);
|
||||
|
||||
assert.equal(
|
||||
hasRecentViewPublish(scope, bucket),
|
||||
true,
|
||||
"guard should report recent publish immediately after remembering",
|
||||
);
|
||||
|
||||
const halfWindow = Math.max(1, Math.floor(windowMs / 2));
|
||||
setNow(1_700_000_000_000 + halfWindow);
|
||||
assert.equal(
|
||||
hasRecentViewPublish(scope, bucket),
|
||||
true,
|
||||
"guard should continue blocking within the same window",
|
||||
);
|
||||
|
||||
setNow(1_700_000_000_000 + windowMs + 5_000);
|
||||
assert.equal(
|
||||
hasRecentViewPublish(scope, bucket),
|
||||
false,
|
||||
"guard should allow publishes once the window has elapsed",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function assertPointerFilterStructure(filter, pointerValue, label) {
|
||||
assert.ok(filter, `${label} filter should be defined`);
|
||||
assert.ok(Array.isArray(filter.kinds), `${label} filter should specify kinds`);
|
||||
assert.ok(
|
||||
Number.isFinite(filter.kinds[0]),
|
||||
`${label} filter should target a numeric kind`,
|
||||
);
|
||||
assert.deepEqual(filter["#t"], ["view"], `${label} filter should require the view tag`);
|
||||
assert.deepEqual(filter["#a"] ?? filter["#e"], [pointerValue], `${label} filter should bind the pointer value`);
|
||||
}
|
||||
|
||||
async function testCreateVideoViewEventFiltersForAddressPointer() {
|
||||
const pointerValue = "30079:abc:def";
|
||||
const pointer = { type: "a", value: pointerValue, relay: "wss://relay.example" };
|
||||
const { pointer: descriptor, filters } = createVideoViewEventFilters(pointer);
|
||||
|
||||
assert.deepEqual(
|
||||
descriptor,
|
||||
{ type: "a", value: pointerValue, relay: "wss://relay.example" },
|
||||
"descriptor should preserve pointer structure",
|
||||
);
|
||||
|
||||
assert.ok(Array.isArray(filters) && filters.length === 1, "default filters should include only the pointer filter");
|
||||
assertPointerFilterStructure(filters[0], pointerValue, "primary");
|
||||
}
|
||||
|
||||
async function testCreateVideoViewEventFiltersIncludeLegacyWhenEnabled() {
|
||||
const pointerValue = "legacy-event";
|
||||
resetRuntimeFlags();
|
||||
setViewFilterIncludeLegacyVideo(true);
|
||||
try {
|
||||
const { filters } = createVideoViewEventFilters({ type: "e", value: pointerValue });
|
||||
assert.ok(Array.isArray(filters) && filters.length === 2, "legacy flag should add a secondary filter");
|
||||
assertPointerFilterStructure(filters[0], pointerValue, "primary");
|
||||
const legacyFilter = filters[1];
|
||||
assert.ok(legacyFilter, "legacy filter should exist when flag enabled");
|
||||
assert.deepEqual(
|
||||
legacyFilter["#video"],
|
||||
[pointerValue],
|
||||
"legacy filter should match the pointer value via #video tag",
|
||||
);
|
||||
} finally {
|
||||
resetRuntimeFlags();
|
||||
}
|
||||
}
|
||||
|
||||
await testRememberViewPublishHonorsGuardWindow();
|
||||
await testCreateVideoViewEventFiltersForAddressPointer();
|
||||
await testCreateVideoViewEventFiltersIncludeLegacyWhenEnabled();
|
||||
|
||||
console.log("nostr view events tests passed");
|
||||
Reference in new issue
Block a user