Refactor watch history telemetry into helper

This commit is contained in:
thePR0M3TH3AN committed 2025-10-09 10:31:09 -04:00
1 parent 0cf50ee7e9
commit 0b0d3628ee
3 files changed
+867 -386

No files matched your search

+99 -386
View File
@@ -38,6 +38,7 @@ import nostrService from "./services/nostrService.js";
import { initQuickR2Upload } from "./r2-quick.js";
import { createWatchHistoryRenderer } from "./historyView.js";
import WatchHistoryController from "./ui/watchHistoryController.js";
import WatchHistoryTelemetry from "./services/watchHistoryTelemetry.js";
import { getSidebarLoadingMarkup } from "./sidebarLoading.js";
import { subscriptions } from "./subscriptions.js";
import {
@@ -163,8 +164,7 @@ class Application {
this.videoDiscussionCountCache = new Map();
this.inFlightDiscussionCounts = new Map();
this.activeIntervals = [];
this.watchHistoryMetadataEnabled = null;
this.watchHistoryPreferenceUnsubscribe = null;
this.watchHistoryTelemetry = null;
this.authEventUnsubscribes = [];
this.unsubscribeFromNostrService = null;
this.videoModalReadyPromise = null;
@@ -241,6 +241,16 @@ class Application {
typeof this.pubkey === "string" && this.pubkey ? this.pubkey : "",
});
this.watchHistoryTelemetry = new WatchHistoryTelemetry({
watchHistoryService,
watchHistoryController: this.watchHistoryController,
nostrClient,
log: (message, ...args) => this.log(message, ...args),
normalizeHexPubkey: (value) => this.normalizeHexPubkey(value),
getActiveUserPubkey: () => this.pubkey,
ingestLocalViewEvent,
});
this.playbackService =
services.playbackService ||
new PlaybackService({
@@ -625,8 +635,6 @@ class Application {
this.currentVideo = null;
this.currentVideoPointer = null;
this.currentVideoPointerKey = null;
this.playbackTelemetryState = null;
this.loggedViewPointerKeys = new Set();
this.videoSubscription = this.nostrService.getVideoSubscription() || null;
this.videoList = null;
this.modalViewCountUnsub = null;
@@ -1108,16 +1116,15 @@ class Application {
// 5. Setup general event listeners
this.setupEventListeners();
const watchHistoryInitPromise = this.initWatchHistoryMetadataSync().catch(
(error) => {
const watchHistoryInitPromise =
this.watchHistoryTelemetry?.initPreferenceSync?.().catch((error) => {
if (isDevMode) {
console.warn(
"[app.init()] Failed to initialize watch history metadata sync:",
error
);
}
}
);
}) || Promise.resolve();
// 6) Load the default view ONLY if there's no #view= already
if (!window.location.hash || !window.location.hash.startsWith("#view=")) {
@@ -3040,146 +3047,20 @@ class Application {
}
cancelPendingViewLogging() {
const state = this.playbackTelemetryState;
if (!state) {
return;
}
if (state.viewTimerId) {
clearTimeout(state.viewTimerId);
}
if (Array.isArray(state.handlers) && state.videoEl) {
for (const { eventName, handler } of state.handlers) {
try {
state.videoEl.removeEventListener(eventName, handler);
} catch (err) {
if (isDevMode) {
console.warn(
`[cancelPendingViewLogging] Failed to detach ${eventName} listener:`,
err
);
}
}
}
}
this.playbackTelemetryState = null;
this.flushWatchHistory("session-end", "cancelPendingViewLogging").catch(
(error) => {
const message =
error && typeof error.message === "string"
? error.message
: String(error ?? "unknown error");
this.log(
`[cancelPendingViewLogging] Watch history flush failed: ${message}`
);
}
);
this.watchHistoryTelemetry?.cancelPlaybackLogging?.();
}
resetViewLoggingState() {
this.cancelPendingViewLogging();
if (this.loggedViewPointerKeys.size > 0) {
this.loggedViewPointerKeys.clear();
}
}
refreshWatchHistoryMetadataSettings() {
if (!watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return this.watchHistoryMetadataEnabled;
}
const previous = this.watchHistoryMetadataEnabled;
let enabled = true;
try {
if (typeof watchHistoryService.getSettings === "function") {
const settings = watchHistoryService.getSettings();
enabled = settings?.metadata?.storeLocally !== false;
} else if (typeof watchHistoryService.shouldStoreMetadata === "function") {
enabled = watchHistoryService.shouldStoreMetadata() !== false;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistory] Failed to read metadata settings:",
error,
);
}
enabled = true;
}
this.watchHistoryMetadataEnabled = enabled;
if (enabled === false && previous !== false) {
try {
watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistory] Failed to purge metadata cache while preference disabled:",
error,
);
}
}
}
return this.watchHistoryMetadataEnabled;
}
async initWatchHistoryMetadataSync() {
if (!watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return;
}
this.refreshWatchHistoryMetadataSettings();
if (
typeof watchHistoryService.subscribe === "function" &&
!this.watchHistoryPreferenceUnsubscribe
) {
try {
const unsubscribe = watchHistoryService.subscribe(
"metadata-preference",
(payload) => {
const previous = this.watchHistoryMetadataEnabled;
const enabled = payload?.enabled !== false;
this.watchHistoryMetadataEnabled = enabled;
if (previous === true && enabled === false) {
try {
watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistory] Failed to clear cached metadata after toggle off:",
error,
);
}
}
}
},
);
if (typeof unsubscribe === "function") {
this.watchHistoryPreferenceUnsubscribe = unsubscribe;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistory] Failed to subscribe to metadata preference changes:",
error,
);
}
}
}
this.watchHistoryTelemetry?.resetPlaybackLoggingState?.();
}
persistWatchHistoryMetadataForVideo(video, pointerInfo) {
if (this.watchHistoryTelemetry) {
this.watchHistoryTelemetry.persistMetadataForVideo(video, pointerInfo);
return;
}
if (
!this.watchHistoryMetadataEnabled ||
!pointerInfo ||
!pointerInfo.key ||
typeof watchHistoryService?.setLocalMetadata !== "function"
@@ -3214,6 +3095,11 @@ class Application {
}
dropWatchHistoryMetadata(pointerKey) {
if (this.watchHistoryTelemetry) {
this.watchHistoryTelemetry.dropMetadata(pointerKey);
return;
}
if (!pointerKey || typeof pointerKey !== "string") {
return;
}
@@ -3254,277 +3140,104 @@ class Application {
}
async handleWatchHistoryRemoval(payload = {}) {
if (!this.watchHistoryController) {
if (!this.watchHistoryTelemetry) {
this.showError("Watch history sync is not available right now.");
const error = new Error("watch-history-disabled");
error.handled = true;
throw error;
}
return this.watchHistoryController.handleWatchHistoryRemoval(payload);
return this.watchHistoryTelemetry.handleRemoval(payload);
}
flushWatchHistory(reason = "session-end", context = "watch-history") {
if (!this.watchHistoryController) {
if (!this.watchHistoryTelemetry) {
return Promise.resolve();
}
return this.watchHistoryController.flush(reason, context);
return this.watchHistoryTelemetry.flush(reason, context);
}
getActiveViewIdentityKey() {
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
if (normalizedUser) {
return `actor:${normalizedUser}`;
if (!this.watchHistoryTelemetry) {
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
if (normalizedUser) {
return `actor:${normalizedUser}`;
}
const sessionActorPubkey = this.normalizeHexPubkey(
nostrClient?.sessionActor?.pubkey
);
if (sessionActorPubkey) {
return `actor:${sessionActorPubkey}`;
}
return "actor:anonymous";
}
const sessionActorPubkey = this.normalizeHexPubkey(
nostrClient?.sessionActor?.pubkey
);
if (sessionActorPubkey) {
return `actor:${sessionActorPubkey}`;
}
return "actor:anonymous";
return this.watchHistoryTelemetry.getActiveViewIdentityKey();
}
deriveViewIdentityKeyFromEvent(event) {
if (!event || typeof event !== "object") {
return "";
if (!this.watchHistoryTelemetry) {
if (!event || typeof event !== "object") {
return "";
}
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
if (!normalizedPubkey) {
return "";
}
return `actor:${normalizedPubkey}`;
}
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
if (!normalizedPubkey) {
return "";
}
return `actor:${normalizedPubkey}`;
return this.watchHistoryTelemetry.deriveViewIdentityKeyFromEvent(event);
}
buildViewCooldownKey(pointerKey, identityKey) {
const normalizedPointerKey =
typeof pointerKey === "string" && pointerKey.trim()
? pointerKey.trim()
: "";
if (!normalizedPointerKey) {
return "";
if (!this.watchHistoryTelemetry) {
const normalizedPointerKey =
typeof pointerKey === "string" && pointerKey.trim()
? pointerKey.trim()
: "";
if (!normalizedPointerKey) {
return "";
}
const normalizedIdentity =
typeof identityKey === "string" && identityKey.trim()
? identityKey.trim().toLowerCase()
: "";
return normalizedIdentity
? `${normalizedPointerKey}::${normalizedIdentity}`
: normalizedPointerKey;
}
const normalizedIdentity =
typeof identityKey === "string" && identityKey.trim()
? identityKey.trim().toLowerCase()
: "";
return normalizedIdentity
? `${normalizedPointerKey}::${normalizedIdentity}`
: normalizedPointerKey;
return this.watchHistoryTelemetry.buildViewCooldownKey(
pointerKey,
identityKey
);
}
preparePlaybackViewLogging(videoEl) {
this.cancelPendingViewLogging();
if (!videoEl || typeof videoEl.addEventListener !== "function") {
preparePlaybackLogging(videoEl) {
if (!this.watchHistoryTelemetry) {
this.cancelPendingViewLogging();
return;
}
const pointer = this.currentVideoPointer;
const pointerKey = this.currentVideoPointerKey || pointerArrayToKey(pointer);
if (!pointer || !pointerKey) {
this.watchHistoryTelemetry.cancelPlaybackLogging();
return;
}
const viewerIdentityKey = this.getActiveViewIdentityKey();
const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey);
if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) {
return;
}
const VIEW_THRESHOLD_SECONDS = 12;
const state = {
videoEl,
this.watchHistoryTelemetry.preparePlaybackLogging({
videoElement: videoEl,
pointer,
pointerKey,
viewerIdentityKey,
handlers: [],
viewTimerId: null,
viewFired: false,
};
const clearTimer = (timerKey) => {
const property = `${timerKey}TimerId`;
if (state[property]) {
clearTimeout(state[property]);
state[property] = null;
}
};
const finalizeView = () => {
if (state.viewFired) {
return;
}
state.viewFired = true;
clearTimer("view");
this.cancelPendingViewLogging();
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
state;
(async () => {
let viewResult;
try {
const canUseWatchHistoryService =
typeof watchHistoryService?.publishView === "function";
const resolveWatchActor = () => {
const normalizedUser = this.normalizeHexPubkey(this.pubkey);
if (normalizedUser) {
return normalizedUser;
}
const normalizedClient = this.normalizeHexPubkey(
nostrClient?.pubkey
);
if (normalizedClient) {
return normalizedClient;
}
const normalizedSession = this.normalizeHexPubkey(
nostrClient?.sessionActor?.pubkey
);
if (normalizedSession) {
return normalizedSession;
}
if (
typeof nostrClient?.pubkey === "string" &&
nostrClient.pubkey.trim()
) {
return nostrClient.pubkey.trim().toLowerCase();
}
if (
typeof nostrClient?.sessionActor?.pubkey === "string" &&
nostrClient.sessionActor.pubkey.trim()
) {
return nostrClient.sessionActor.pubkey.trim().toLowerCase();
}
return "";
};
const activeWatchActor = resolveWatchActor();
const watchMetadata = activeWatchActor ? { actor: activeWatchActor } : undefined;
if (canUseWatchHistoryService) {
viewResult = await watchHistoryService.publishView(
thresholdPointer,
undefined,
watchMetadata
);
} else if (typeof nostrClient?.recordVideoView === "function") {
viewResult = await nostrClient.recordVideoView(thresholdPointer);
} else {
viewResult = { ok: false, error: "view-logging-unavailable" };
}
} catch (error) {
if (isDevMode) {
console.warn(
"[playVideoWithFallback] Exception while recording video view:",
error
);
}
}
const viewOk = !!viewResult?.ok;
if (viewOk) {
const eventIdentityKey =
this.deriveViewIdentityKeyFromEvent(viewResult?.event) ||
state.viewerIdentityKey ||
this.getActiveViewIdentityKey();
const keyToPersist = this.buildViewCooldownKey(
thresholdPointerKey,
eventIdentityKey
);
if (keyToPersist) {
this.loggedViewPointerKeys.add(keyToPersist);
}
if (viewResult?.event) {
ingestLocalViewEvent({
event: viewResult.event,
pointer: thresholdPointer,
});
}
} else if (isDevMode && viewResult) {
console.warn(
"[playVideoWithFallback] View event rejected by relays:",
viewResult
);
}
})().catch((error) => {
if (isDevMode) {
console.warn(
"[playVideoWithFallback] Unexpected error while recording video view:",
error
);
}
});
};
const scheduleTimer = (timerKey, thresholdSeconds, callback) => {
const firedKey = `${timerKey}Fired`;
const idKey = `${timerKey}TimerId`;
if (state[firedKey] || state[idKey]) {
return;
}
const currentSeconds = Number.isFinite(videoEl.currentTime)
? videoEl.currentTime
: 0;
const remainingMs = Math.max(
0,
Math.ceil((thresholdSeconds - currentSeconds) * 1000)
);
if (remainingMs <= 0) {
callback();
return;
}
state[idKey] = window.setTimeout(callback, remainingMs);
};
const registerHandler = (eventName, handler) => {
videoEl.addEventListener(eventName, handler);
state.handlers.push({ eventName, handler });
};
registerHandler("timeupdate", () => {
if (!state.viewFired && videoEl.currentTime >= VIEW_THRESHOLD_SECONDS) {
finalizeView();
}
});
const cancelOnPause = () => {
if (!state.viewFired) {
clearTimer("view");
}
};
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
registerHandler(event, cancelOnPause)
);
const resumeIfNeeded = () => {
if (state.viewFired) {
return;
}
scheduleTimer("view", VIEW_THRESHOLD_SECONDS, finalizeView);
};
["play", "playing"].forEach((event) =>
registerHandler(event, resumeIfNeeded)
);
this.playbackTelemetryState = state;
if (!videoEl.paused && videoEl.currentTime > 0) {
resumeIfNeeded();
}
}
teardownVideoElement(videoElement, { replaceNode = false } = {}) {
@@ -6323,7 +6036,7 @@ class Application {
subscribe("view-logging-request", ({ videoElement } = {}) => {
if (videoElement) {
this.preparePlaybackViewLogging(videoElement);
this.preparePlaybackLogging(videoElement);
}
});
@@ -7060,6 +6773,18 @@ class Application {
this.teardownModalViewCountSubscription();
this.videoModalReadyPromise = null;
if (this.watchHistoryTelemetry) {
try {
this.watchHistoryTelemetry.destroy();
} catch (error) {
console.warn(
"[Application] Failed to destroy watch history telemetry:",
error
);
}
this.watchHistoryTelemetry = null;
}
if (typeof this.unsubscribeFromPubkeyState === "function") {
try {
this.unsubscribeFromPubkeyState();
@@ -7100,18 +6825,6 @@ class Application {
this.nwcSettingsService.clearCache();
}
if (typeof this.watchHistoryPreferenceUnsubscribe === "function") {
try {
this.watchHistoryPreferenceUnsubscribe();
} catch (error) {
console.warn(
"[Application] Failed to unsubscribe watch history preference:",
error
);
}
this.watchHistoryPreferenceUnsubscribe = null;
}
this.authEventUnsubscribes.forEach((unsubscribe) => {
if (typeof unsubscribe === "function") {
try {
+561
View File
@@ -0,0 +1,561 @@
import { isDevMode } from "../config.js";
import { pointerArrayToKey } from "../utils/pointer.js";
import {
ingestLocalViewEvent as defaultIngestLocalViewEvent,
} from "../viewCounter.js";
const DEFAULT_VIEW_THRESHOLD_SECONDS = 12;
function normalizeString(value) {
return typeof value === "string" ? value.trim() : "";
}
export default class WatchHistoryTelemetry {
constructor({
watchHistoryService = null,
watchHistoryController = null,
nostrClient = null,
log,
normalizeHexPubkey,
getActiveUserPubkey,
ingestLocalViewEvent = defaultIngestLocalViewEvent,
viewThresholdSeconds = DEFAULT_VIEW_THRESHOLD_SECONDS,
} = {}) {
this.watchHistoryService = watchHistoryService;
this.watchHistoryController = watchHistoryController;
this.nostrClient = nostrClient;
this.log = typeof log === "function" ? log : () => {};
this.normalizeHexPubkey =
typeof normalizeHexPubkey === "function"
? normalizeHexPubkey
: () => null;
this.getActiveUserPubkey =
typeof getActiveUserPubkey === "function" ? getActiveUserPubkey : () => null;
this.ingestLocalViewEvent =
typeof ingestLocalViewEvent === "function"
? ingestLocalViewEvent
: defaultIngestLocalViewEvent;
this.viewThresholdSeconds = Number.isFinite(viewThresholdSeconds)
? Math.max(0, viewThresholdSeconds)
: DEFAULT_VIEW_THRESHOLD_SECONDS;
this.watchHistoryMetadataEnabled = null;
this.watchHistoryPreferenceUnsubscribe = null;
this.playbackTelemetryState = null;
this.loggedViewPointerKeys = new Set();
}
destroy() {
this.resetPlaybackLoggingState();
this._clearPreferenceSubscription();
this.watchHistoryMetadataEnabled = null;
}
async initPreferenceSync() {
if (!this.watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return null;
}
this.refreshPreferenceSettings();
if (
typeof this.watchHistoryService.subscribe === "function" &&
!this.watchHistoryPreferenceUnsubscribe
) {
try {
const unsubscribe = this.watchHistoryService.subscribe(
"metadata-preference",
(payload) => {
const previous = this.watchHistoryMetadataEnabled;
const enabled = payload?.enabled !== false;
this.watchHistoryMetadataEnabled = enabled;
if (previous === true && enabled === false) {
try {
this.watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to clear cached metadata after toggle off:",
error,
);
}
}
}
},
);
if (typeof unsubscribe === "function") {
this.watchHistoryPreferenceUnsubscribe = unsubscribe;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to subscribe to metadata preference changes:",
error,
);
}
}
}
return this.watchHistoryPreferenceUnsubscribe || null;
}
refreshPreferenceSettings() {
if (!this.watchHistoryService?.isEnabled?.()) {
this.watchHistoryMetadataEnabled = false;
return this.watchHistoryMetadataEnabled;
}
const previous = this.watchHistoryMetadataEnabled;
let enabled = true;
try {
if (typeof this.watchHistoryService.getSettings === "function") {
const settings = this.watchHistoryService.getSettings();
enabled = settings?.metadata?.storeLocally !== false;
} else if (
typeof this.watchHistoryService.shouldStoreMetadata === "function"
) {
enabled = this.watchHistoryService.shouldStoreMetadata() !== false;
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to read metadata settings:",
error,
);
}
enabled = true;
}
this.watchHistoryMetadataEnabled = enabled;
if (enabled === false && previous !== false) {
try {
this.watchHistoryService?.clearLocalMetadata?.();
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to purge metadata cache while preference disabled:",
error,
);
}
}
}
return this.watchHistoryMetadataEnabled;
}
isMetadataPreferenceEnabled() {
return this.watchHistoryMetadataEnabled !== false;
}
persistMetadataForVideo(video, pointerInfo) {
if (
!this.watchHistoryMetadataEnabled ||
!pointerInfo ||
!pointerInfo.key ||
typeof this.watchHistoryService?.setLocalMetadata !== "function"
) {
return;
}
if (!video || typeof video !== "object") {
return;
}
const metadata = {
video: {
id: typeof video.id === "string" ? video.id : "",
title: typeof video.title === "string" ? video.title : "",
thumbnail: typeof video.thumbnail === "string" ? video.thumbnail : "",
pubkey: typeof video.pubkey === "string" ? video.pubkey : "",
},
};
try {
this.watchHistoryService.setLocalMetadata(pointerInfo.key, metadata);
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to persist local metadata for pointer:",
pointerInfo.key,
error,
);
}
}
}
dropMetadata(pointerKey) {
if (!pointerKey || typeof pointerKey !== "string") {
return;
}
if (typeof this.watchHistoryService?.removeLocalMetadata !== "function") {
return;
}
try {
this.watchHistoryService.removeLocalMetadata(pointerKey);
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to remove cached metadata for pointer:",
pointerKey,
error,
);
}
}
}
async handleRemoval(payload = {}) {
if (!this.watchHistoryController) {
const error = new Error("watch-history-disabled");
error.handled = true;
throw error;
}
return this.watchHistoryController.handleWatchHistoryRemoval(payload);
}
flush(reason = "session-end", context = "watch-history") {
if (!this.watchHistoryController) {
return Promise.resolve();
}
try {
return this.watchHistoryController.flush(reason, context);
} catch (error) {
return Promise.reject(error);
}
}
cancelPlaybackLogging({
reason = "session-end",
context = "cancelPendingViewLogging",
} = {}) {
const state = this.playbackTelemetryState;
if (!state) {
return;
}
if (state.viewTimerId) {
this._getTimerHost().clearTimeout(state.viewTimerId);
}
if (Array.isArray(state.handlers) && state.videoEl) {
for (const { eventName, handler } of state.handlers) {
try {
state.videoEl.removeEventListener(eventName, handler);
} catch (error) {
if (isDevMode) {
console.warn(
`[watchHistoryTelemetry] Failed to detach ${eventName} listener:`,
error,
);
}
}
}
}
this.playbackTelemetryState = null;
this.flush(reason, context).catch((error) => {
const message = normalizeString(error?.message) || String(error ?? "unknown");
this.log(
`[watchHistoryTelemetry] Watch history flush failed: ${message}`,
);
});
}
resetPlaybackLoggingState() {
this.cancelPlaybackLogging();
if (this.loggedViewPointerKeys.size > 0) {
this.loggedViewPointerKeys.clear();
}
}
preparePlaybackLogging({
videoElement,
pointer,
pointerKey: explicitPointerKey,
} = {}) {
this.cancelPlaybackLogging();
if (!videoElement || typeof videoElement.addEventListener !== "function") {
return;
}
const pointerKey = explicitPointerKey || pointerArrayToKey(pointer);
if (!pointer || !pointerKey) {
return;
}
const viewerIdentityKey = this.getActiveViewIdentityKey();
const cooldownKey = this.buildViewCooldownKey(pointerKey, viewerIdentityKey);
if (cooldownKey && this.loggedViewPointerKeys.has(cooldownKey)) {
return;
}
const state = {
videoEl: videoElement,
pointer,
pointerKey,
viewerIdentityKey,
handlers: [],
viewTimerId: null,
viewFired: false,
};
const clearTimer = (timerKey) => {
const property = `${timerKey}TimerId`;
if (state[property]) {
this._getTimerHost().clearTimeout(state[property]);
state[property] = null;
}
};
const finalizeView = () => {
if (state.viewFired) {
return;
}
state.viewFired = true;
clearTimer("view");
this.cancelPlaybackLogging();
const { pointer: thresholdPointer, pointerKey: thresholdPointerKey } =
state;
(async () => {
let viewResult;
try {
const canUseWatchHistoryService =
typeof this.watchHistoryService?.publishView === "function";
const resolveWatchActor = () => {
const normalizedUser = this.normalizeHexPubkey(
this.getActiveUserPubkey(),
);
if (normalizedUser) {
return normalizedUser;
}
const normalizedClient = this.normalizeHexPubkey(
this.nostrClient?.pubkey,
);
if (normalizedClient) {
return normalizedClient;
}
const normalizedSession = this.normalizeHexPubkey(
this.nostrClient?.sessionActor?.pubkey,
);
if (normalizedSession) {
return normalizedSession;
}
if (normalizeString(this.nostrClient?.pubkey)) {
return normalizeString(this.nostrClient.pubkey).toLowerCase();
}
if (normalizeString(this.nostrClient?.sessionActor?.pubkey)) {
return normalizeString(
this.nostrClient.sessionActor.pubkey,
).toLowerCase();
}
return "";
};
const activeWatchActor = resolveWatchActor();
const watchMetadata = activeWatchActor
? { actor: activeWatchActor }
: undefined;
if (canUseWatchHistoryService) {
viewResult = await this.watchHistoryService.publishView(
thresholdPointer,
undefined,
watchMetadata,
);
} else if (typeof this.nostrClient?.recordVideoView === "function") {
viewResult = await this.nostrClient.recordVideoView(
thresholdPointer,
);
} else {
viewResult = { ok: false, error: "view-logging-unavailable" };
}
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Exception while recording video view:",
error,
);
}
}
const viewOk = !!viewResult?.ok;
if (viewOk) {
const eventIdentityKey =
this.deriveViewIdentityKeyFromEvent(viewResult?.event) ||
state.viewerIdentityKey ||
this.getActiveViewIdentityKey();
const keyToPersist = this.buildViewCooldownKey(
thresholdPointerKey,
eventIdentityKey,
);
if (keyToPersist) {
this.loggedViewPointerKeys.add(keyToPersist);
}
if (viewResult?.event && this.ingestLocalViewEvent) {
try {
this.ingestLocalViewEvent({
event: viewResult.event,
pointer: thresholdPointer,
});
} catch (error) {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Failed to ingest local view event:",
error,
);
}
}
}
} else if (isDevMode && viewResult) {
console.warn(
"[watchHistoryTelemetry] View event rejected by relays:",
viewResult,
);
}
})().catch((error) => {
if (isDevMode) {
console.warn(
"[watchHistoryTelemetry] Unexpected error while recording video view:",
error,
);
}
});
};
const scheduleTimer = (timerKey, thresholdSeconds, callback) => {
const firedKey = `${timerKey}Fired`;
const idKey = `${timerKey}TimerId`;
if (state[firedKey] || state[idKey]) {
return;
}
const currentSeconds = Number.isFinite(videoElement.currentTime)
? videoElement.currentTime
: 0;
const remainingMs = Math.max(
0,
Math.ceil((thresholdSeconds - currentSeconds) * 1000),
);
if (remainingMs <= 0) {
callback();
return;
}
state[idKey] = this._getTimerHost().setTimeout(callback, remainingMs);
};
const registerHandler = (eventName, handler) => {
videoElement.addEventListener(eventName, handler);
state.handlers.push({ eventName, handler });
};
registerHandler("timeupdate", () => {
if (!state.viewFired && videoElement.currentTime >= this.viewThresholdSeconds) {
finalizeView();
}
});
const cancelOnPause = () => {
if (!state.viewFired) {
clearTimer("view");
}
};
["pause", "waiting", "stalled", "ended", "emptied"].forEach((event) =>
registerHandler(event, cancelOnPause),
);
const resumeIfNeeded = () => {
if (state.viewFired) {
return;
}
scheduleTimer("view", this.viewThresholdSeconds, finalizeView);
};
["play", "playing"].forEach((event) =>
registerHandler(event, resumeIfNeeded),
);
this.playbackTelemetryState = state;
if (!videoElement.paused && videoElement.currentTime > 0) {
resumeIfNeeded();
}
}
getActiveViewIdentityKey() {
const normalizedUser = this.normalizeHexPubkey(this.getActiveUserPubkey());
if (normalizedUser) {
return `actor:${normalizedUser}`;
}
const sessionActorPubkey = this.normalizeHexPubkey(
this.nostrClient?.sessionActor?.pubkey,
);
if (sessionActorPubkey) {
return `actor:${sessionActorPubkey}`;
}
return "actor:anonymous";
}
deriveViewIdentityKeyFromEvent(event) {
if (!event || typeof event !== "object") {
return "";
}
const normalizedPubkey = this.normalizeHexPubkey(event.pubkey);
if (!normalizedPubkey) {
return "";
}
return `actor:${normalizedPubkey}`;
}
buildViewCooldownKey(pointerKey, identityKey) {
const normalizedPointerKey = normalizeString(pointerKey);
if (!normalizedPointerKey) {
return "";
}
const normalizedIdentity = normalizeString(identityKey).toLowerCase();
return normalizedIdentity
? `${normalizedPointerKey}::${normalizedIdentity}`
: normalizedPointerKey;
}
_clearPreferenceSubscription() {
if (typeof this.watchHistoryPreferenceUnsubscribe === "function") {
try {
this.watchHistoryPreferenceUnsubscribe();
} catch (error) {
console.warn(
"[watchHistoryTelemetry] Failed to unsubscribe watch history preference:",
error,
);
}
}
this.watchHistoryPreferenceUnsubscribe = null;
}
_getTimerHost() {
if (typeof window !== "undefined" && window) {
return window;
}
return globalThis;
}
}
@@ -0,0 +1,207 @@
import "../test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import WatchHistoryTelemetry from "../../js/services/watchHistoryTelemetry.js";
const hexPubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
function createTelemetry({
serviceOverrides = {},
controllerOverrides = {},
options = {},
} = {}) {
const watchHistoryService = {
isEnabled: () => true,
getSettings: () => ({ metadata: { storeLocally: true } }),
setLocalMetadata: () => {},
removeLocalMetadata: () => {},
clearLocalMetadata: () => {},
publishView: async () => ({ ok: true }),
subscribe: () => () => {},
...serviceOverrides,
};
const watchHistoryController = {
handleWatchHistoryRemoval: async () => ({}),
flush: async () => {},
...controllerOverrides,
};
const telemetry = new WatchHistoryTelemetry({
watchHistoryService,
watchHistoryController,
nostrClient: options.nostrClient || { pubkey: hexPubkey },
log: options.log || (() => {}),
normalizeHexPubkey:
options.normalizeHexPubkey || ((value) => (typeof value === "string" ? value.toLowerCase() : null)),
getActiveUserPubkey: options.getActiveUserPubkey || (() => hexPubkey),
ingestLocalViewEvent: options.ingestLocalViewEvent || (() => {}),
viewThresholdSeconds: options.viewThresholdSeconds,
});
return { telemetry, watchHistoryService, watchHistoryController };
}
class FakeVideoElement {
constructor() {
this.listeners = new Map();
this.paused = true;
this.currentTime = 0;
}
addEventListener(eventName, handler) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, new Set());
}
this.listeners.get(eventName).add(handler);
}
removeEventListener(eventName, handler) {
const set = this.listeners.get(eventName);
if (!set) {
return;
}
set.delete(handler);
if (set.size === 0) {
this.listeners.delete(eventName);
}
}
emit(eventName) {
const set = this.listeners.get(eventName);
if (!set) {
return;
}
for (const handler of set) {
handler({ type: eventName });
}
}
}
async function flushAsyncOperations() {
await new Promise((resolve) => setTimeout(resolve, 0));
}
async function testMetadataPreferenceSync() {
let subscribeTopic = null;
let subscribeHandler = null;
let cleared = false;
const { telemetry } = createTelemetry({
serviceOverrides: {
subscribe: (topic, handler) => {
subscribeTopic = topic;
subscribeHandler = handler;
return () => {};
},
clearLocalMetadata: () => {
cleared = true;
},
getSettings: () => ({ metadata: { storeLocally: true } }),
},
options: {
getActiveUserPubkey: () => null,
},
});
const unsubscribe = await telemetry.initPreferenceSync();
assert.equal(subscribeTopic, "metadata-preference");
assert.equal(typeof unsubscribe, "function");
assert.equal(telemetry.isMetadataPreferenceEnabled(), true);
subscribeHandler({ enabled: false });
assert.equal(telemetry.isMetadataPreferenceEnabled(), false);
assert.equal(cleared, true);
subscribeHandler({ enabled: true });
assert.equal(telemetry.isMetadataPreferenceEnabled(), true);
}
async function testPreparePlaybackLoggingPublishesOnce() {
const ingestEvents = [];
const publishCalls = [];
const pointer = { type: "e", value: "event-id" };
const pointerKey = "event-id::relay";
const { telemetry } = createTelemetry({
serviceOverrides: {
publishView: async (ptr, _relay, metadata) => {
publishCalls.push({ ptr, metadata });
return {
ok: true,
event: { pubkey: hexPubkey },
};
},
},
options: {
ingestLocalViewEvent: (payload) => ingestEvents.push(payload),
},
});
const videoA = new FakeVideoElement();
videoA.paused = false;
videoA.currentTime = telemetry.viewThresholdSeconds;
telemetry.preparePlaybackLogging({
videoElement: videoA,
pointer,
pointerKey,
});
await flushAsyncOperations();
assert.equal(publishCalls.length, 1, "publishView should be called once");
assert.equal(ingestEvents.length, 1, "ingestLocalViewEvent should run once");
assert.deepEqual(publishCalls[0].ptr, pointer);
assert.deepEqual(ingestEvents[0].pointer, pointer);
const videoB = new FakeVideoElement();
videoB.paused = false;
videoB.currentTime = telemetry.viewThresholdSeconds;
telemetry.preparePlaybackLogging({
videoElement: videoB,
pointer,
pointerKey,
});
await flushAsyncOperations();
assert.equal(
publishCalls.length,
1,
"Repeated preparePlaybackLogging should reuse cooldown cache"
);
}
async function testCancelPlaybackLoggingFlushes() {
let flushArgs = null;
const video = new FakeVideoElement();
const { telemetry } = createTelemetry({
controllerOverrides: {
flush: async (reason, context) => {
flushArgs = { reason, context };
},
},
});
video.paused = true;
video.currentTime = 0;
telemetry.preparePlaybackLogging({
videoElement: video,
pointer: { type: "e", value: "event" },
pointerKey: "event",
});
telemetry.cancelPlaybackLogging({ reason: "unit", context: "test" });
await flushAsyncOperations();
assert.deepEqual(flushArgs, { reason: "unit", context: "test" });
}
await testMetadataPreferenceSync();
await testPreparePlaybackLoggingPublishesOnce();
await testCancelPlaybackLoggingFlushes();
console.log("watch-history-telemetry tests passed");