Scope watch history sync to logged users

This commit is contained in:
thePR0M3TH3AN committed 2025-10-02 18:26:43 -04:00
1 parent b1a493f113
commit c3ed4bc6ea
3 files changed
+241 -32

No files matched your search

+31 -8
View File
@@ -16,7 +16,7 @@ export const WATCH_HISTORY_EMPTY_COPY =
"Your watch history is empty. Watch some videos to populate this list.";
export const WATCH_HISTORY_DISABLED_COPY =
"Encrypted watch history sync is disabled by this server. Local history stays on this device only.";
"Watch history sync is unavailable. Connect a NIP-07 extension or log in to enable encrypted syncing.";
const WATCH_HISTORY_METADATA_PREF_KEY =
"bitvid:watch-history:metadata-preference";
@@ -739,11 +739,18 @@ export function createWatchHistoryRenderer(config = {}) {
},
} = config;
const syncEnabled = watchHistoryService.isEnabled?.() === true;
const syncEnabled =
typeof watchHistoryService.isEnabled === "function"
? watchHistoryService.isEnabled() === true
: false;
const localSupported =
typeof watchHistoryService.supportsLocalHistory === "function"
? watchHistoryService.supportsLocalHistory() !== false
: true;
? watchHistoryService.supportsLocalHistory() === true
: false;
const localOnly =
typeof watchHistoryService.isLocalOnly === "function"
? watchHistoryService.isLocalOnly() === true
: false;
const state = {
initialized: false,
@@ -771,6 +778,7 @@ export function createWatchHistoryRenderer(config = {}) {
sessionFallbackActive: false,
syncEnabled,
localSupported,
localOnly,
featureEnabled: syncEnabled || localSupported,
};
@@ -903,13 +911,22 @@ export function createWatchHistoryRenderer(config = {}) {
}
function updateFeatureBanner() {
const syncEnabled = watchHistoryService.isEnabled?.() === true;
const actor = typeof state.actor === "string" ? state.actor : undefined;
const syncEnabled =
typeof watchHistoryService.isEnabled === "function"
? watchHistoryService.isEnabled(actor) === true
: false;
const localSupported =
typeof watchHistoryService.supportsLocalHistory === "function"
? watchHistoryService.supportsLocalHistory() !== false
: true;
? watchHistoryService.supportsLocalHistory(actor) === true
: false;
const localOnly =
typeof watchHistoryService.isLocalOnly === "function"
? watchHistoryService.isLocalOnly(actor) === true
: false;
state.syncEnabled = syncEnabled;
state.localSupported = localSupported;
state.localOnly = localOnly;
state.featureEnabled = syncEnabled || localSupported;
if (!(elements.featureBanner instanceof HTMLElement)) {
return;
@@ -919,9 +936,15 @@ export function createWatchHistoryRenderer(config = {}) {
setHidden(elements.featureBanner, true);
return;
}
if (localOnly) {
elements.featureBanner.textContent =
"Watch history sync requires a logged-in Nostr account. This guest session is stored locally only.";
setHidden(elements.featureBanner, false);
return;
}
if (localSupported) {
elements.featureBanner.textContent =
"Watch history sync is disabled on this server. Local history only.";
"Watch history sync is disabled right now. We'll keep a local copy on this device.";
} else {
elements.featureBanner.textContent =
"Watch history is disabled on this server.";
+101 -22
View File
@@ -36,10 +36,10 @@ const state = {
},
};
function isFeatureEnabled() {
function resolveFlagEnabled() {
try {
if (typeof getWatchHistoryV2Enabled === "function") {
return getWatchHistoryV2Enabled();
return getWatchHistoryV2Enabled() === true;
}
} catch (error) {
if (isDevMode) {
@@ -52,6 +52,79 @@ function isFeatureEnabled() {
return FEATURE_WATCH_HISTORY_V2 === true;
}
function getLoggedInActorKey() {
return normalizeActorKey(nostrClient?.pubkey);
}
function getSessionActorKey() {
const logged = getLoggedInActorKey();
if (logged) {
return "";
}
return normalizeActorKey(nostrClient?.sessionActor?.pubkey);
}
function resolveEffectiveActorKey(actorInput) {
const supplied =
typeof actorInput === "string" && actorInput.trim()
? normalizeActorKey(actorInput)
: "";
if (supplied) {
return supplied;
}
const logged = getLoggedInActorKey();
if (logged) {
return logged;
}
const session = getSessionActorKey();
if (session) {
return session;
}
return "";
}
function isFeatureEnabled(actorInput) {
const baseEnabled = resolveFlagEnabled();
const actorKey = resolveEffectiveActorKey(actorInput);
const logged = getLoggedInActorKey();
const session = getSessionActorKey();
if (actorKey && logged && actorKey === logged) {
return true;
}
if (actorKey && session && actorKey === session) {
return false;
}
if (!actorKey) {
if (logged) {
return true;
}
if (session) {
return false;
}
}
return baseEnabled;
}
function isLocalOnly(actorInput) {
const actorKey = resolveEffectiveActorKey(actorInput);
const session = getSessionActorKey();
if (!session) {
return false;
}
if (!actorKey) {
return true;
}
return actorKey === session;
}
function supportsLocalHistory(actorInput) {
return isLocalOnly(actorInput);
}
function getSessionStorage() {
try {
if (typeof window !== "undefined" && window.sessionStorage) {
@@ -557,7 +630,7 @@ function restoreQueueState() {
queue.lastSnapshotReason = null;
}
if (queue.pendingSnapshotId && isFeatureEnabled()) {
if (queue.pendingSnapshotId && isFeatureEnabled(actorKey)) {
scheduleRepublishForQueue(actorKey);
}
}
@@ -702,7 +775,7 @@ function resolveWatchedAt(...candidates) {
}
function scheduleRepublishForQueue(actorKey) {
if (!isFeatureEnabled()) {
if (!isFeatureEnabled(actorKey)) {
return;
}
const queue = state.queues.get(actorKey);
@@ -849,6 +922,8 @@ async function publishView(pointerInput, createdAt, metadata = {}) {
return viewResult;
}
const remoteEnabled = isFeatureEnabled(actorKey);
const normalizedPointer = normalizePointerInput(pointer);
if (!normalizedPointer) {
console.warn(
@@ -871,6 +946,7 @@ async function publishView(pointerInput, createdAt, metadata = {}) {
pointerKey: key,
watchedAt: normalizedPointer.watchedAt,
sessionEvent: normalizedPointer.session === true,
remoteSyncEnabled: remoteEnabled,
}
);
@@ -952,9 +1028,9 @@ async function publishView(pointerInput, createdAt, metadata = {}) {
}
);
if (!isFeatureEnabled()) {
if (!remoteEnabled) {
console.info(
"[watchHistoryService] Watch history sync disabled; retaining pointer in local session queue only.",
"[watchHistoryService] Watch history sync unavailable for this actor; retaining pointer in local session queue only.",
{
actor: actorKey,
pointerKey: key,
@@ -967,22 +1043,24 @@ async function publishView(pointerInput, createdAt, metadata = {}) {
}
async function snapshot(items, options = {}) {
if (!isFeatureEnabled()) {
console.info(
"[watchHistoryService] Snapshot skipped because watch history feature is disabled.",
{
requestedItems: Array.isArray(items) ? items.length : 0,
reason: typeof options.reason === "string" ? options.reason : "manual",
}
);
return { ok: true, skipped: true, reason: "feature-disabled" };
}
const reason = typeof options.reason === "string" ? options.reason : "manual";
const actorKey = resolveActorKey(options.actor);
if (!actorKey) {
return { ok: false, error: "missing-actor" };
}
if (!isFeatureEnabled(actorKey)) {
console.info(
"[watchHistoryService] Snapshot skipped because watch history sync is unavailable for this actor.",
{
actor: actorKey,
requestedItems: Array.isArray(items) ? items.length : 0,
reason,
}
);
return { ok: true, skipped: true, reason: "feature-disabled" };
}
if (state.inflightSnapshots.has(actorKey)) {
return state.inflightSnapshots.get(actorKey);
}
@@ -1091,13 +1169,13 @@ async function loadLatest(actorInput) {
return [];
}
if (!isFeatureEnabled()) {
if (!isFeatureEnabled(actorKey)) {
if (!state.restored) {
restoreQueueState();
}
const items = collectQueueItems(actorKey);
console.info(
"[watchHistoryService] loadLatest returning session watch history queue.",
"[watchHistoryService] loadLatest returning local-only watch history queue.",
{
actor: actorKey,
itemCount: items.length,
@@ -1178,13 +1256,13 @@ async function loadLatest(actorInput) {
}
async function getFingerprint(actorInput) {
if (!isFeatureEnabled()) {
return "";
}
const actorKey = resolveActorKey(actorInput);
if (!actorKey) {
return "";
}
if (!isFeatureEnabled(actorKey)) {
return "";
}
const now = Date.now();
const cacheEntry = state.fingerprintCache.get(actorKey);
if (
@@ -1261,7 +1339,8 @@ function getSettings() {
const watchHistoryService = {
isEnabled: isFeatureEnabled,
supportsLocalHistory: () => true,
supportsLocalHistory,
isLocalOnly,
publishView,
snapshot,
loadLatest,
+109 -2
View File
@@ -48,6 +48,9 @@ const originalWatchHistoryCache = nostrClient.watchHistoryCache;
const originalWatchHistoryStorage = nostrClient.watchHistoryStorage;
const originalScheduleWatchHistoryRepublish =
nostrClient.scheduleWatchHistoryRepublish;
const originalResolveWatchHistory = nostrClient.resolveWatchHistory;
const originalGetWatchHistoryFingerprint =
nostrClient.getWatchHistoryFingerprint;
nostrClient.watchHistoryCache = new Map();
nostrClient.watchHistoryStorage = {
@@ -628,7 +631,7 @@ async function testWatchHistoryPartialRelayRetry() {
const originalRecordView = nostrClient.recordVideoView;
try {
nostrClient.pubkey = "";
nostrClient.pubkey = actor;
nostrClient.sessionActor = { pubkey: actor, privateKey: "partial-priv" };
nostrClient.ensureSessionActor = async () => actor;
@@ -986,12 +989,14 @@ async function testWatchHistoryLocalFallbackWhenDisabled() {
const actor = "local-fallback-actor";
const originalRecordView = nostrClient.recordVideoView;
const originalPub = nostrClient.pubkey;
const originalSession = nostrClient.sessionActor;
try {
setWatchHistoryV2Enabled(false);
localStorage.clear();
watchHistoryService.resetProgress();
nostrClient.pubkey = "";
nostrClient.sessionActor = { pubkey: actor, privateKey: "local-priv" };
nostrClient.recordVideoView = async (_pointer, options = {}) => ({
ok: true,
event: {
@@ -1003,13 +1008,27 @@ async function testWatchHistoryLocalFallbackWhenDisabled() {
const supported =
typeof watchHistoryService.supportsLocalHistory === "function"
? watchHistoryService.supportsLocalHistory()
? watchHistoryService.supportsLocalHistory(actor)
: true;
const localOnly =
typeof watchHistoryService.isLocalOnly === "function"
? watchHistoryService.isLocalOnly(actor)
: true;
const enabled =
typeof watchHistoryService.isEnabled === "function"
? watchHistoryService.isEnabled(actor)
: false;
assert.equal(
supported,
true,
"service should report local history support while sync disabled",
);
assert.equal(localOnly, true, "guest session should be treated as local only");
assert.equal(
enabled,
false,
"sync should be disabled for session actors when feature flag is off",
);
const createdAt = 1_700_500_000;
await watchHistoryService.publishView(
@@ -1042,6 +1061,90 @@ async function testWatchHistoryLocalFallbackWhenDisabled() {
watchHistoryService.resetProgress();
nostrClient.recordVideoView = originalRecordView;
nostrClient.pubkey = originalPub;
nostrClient.sessionActor = originalSession;
}
}
async function testWatchHistorySyncEnabledForLoggedInUsers() {
console.log("Running watch history logged-in sync override test...");
const actor = "npub-logged-sync";
const originalPub = nostrClient.pubkey;
const originalSession = nostrClient.sessionActor;
const originalResolve = nostrClient.resolveWatchHistory;
const originalFingerprint = nostrClient.getWatchHistoryFingerprint;
try {
setWatchHistoryV2Enabled(false);
localStorage.clear();
watchHistoryService.resetProgress();
nostrClient.pubkey = actor;
nostrClient.sessionActor = null;
let resolveCalls = 0;
nostrClient.resolveWatchHistory = async (requestedActor, options = {}) => {
resolveCalls += 1;
assert.equal(
requestedActor,
actor,
"resolveWatchHistory should target the logged-in actor",
);
assert.equal(
options?.forceRefresh,
true,
"loadLatest should force a relay refresh for logged-in actors",
);
return [
{
type: "e",
value: "remote-pointer",
watchedAt: 1_700_600_000,
},
];
};
let fingerprintCalls = 0;
nostrClient.getWatchHistoryFingerprint = async (requestedActor, items) => {
fingerprintCalls += 1;
assert.equal(
requestedActor,
actor,
"fingerprint lookup should use the logged-in actor",
);
assert(Array.isArray(items), "fingerprint helper expects item array input");
return "fingerprint-logged";
};
const enabled =
typeof watchHistoryService.isEnabled === "function"
? watchHistoryService.isEnabled(actor)
: false;
assert.equal(
enabled,
true,
"sync should remain enabled for logged-in actors even when the flag is disabled",
);
const items = await watchHistoryService.loadLatest(actor);
assert.equal(
resolveCalls > 0,
true,
"loadLatest should fetch from relays for logged-in actors",
);
assert.equal(items.length, 1, "loadLatest should return relay data for logged-in actors");
assert.equal(items[0]?.value, "remote-pointer");
assert.equal(
fingerprintCalls > 0,
true,
"fingerprint cache should update for logged-in actors",
);
} finally {
setWatchHistoryV2Enabled(true);
watchHistoryService.resetProgress();
nostrClient.resolveWatchHistory = originalResolve;
nostrClient.getWatchHistoryFingerprint = originalFingerprint;
nostrClient.pubkey = originalPub;
nostrClient.sessionActor = originalSession;
}
}
@@ -1052,6 +1155,7 @@ await testWatchHistoryPartialRelayRetry();
await testResolveWatchHistoryBatchingWindow();
await testWatchHistoryServiceIntegration();
await testWatchHistoryLocalFallbackWhenDisabled();
await testWatchHistorySyncEnabledForLoggedInUsers();
console.log("watch-history.test.mjs completed successfully");
@@ -1070,6 +1174,9 @@ nostrClient.watchHistoryCache = originalWatchHistoryCache;
nostrClient.watchHistoryStorage = originalWatchHistoryStorage;
nostrClient.scheduleWatchHistoryRepublish =
originalScheduleWatchHistoryRepublish;
nostrClient.resolveWatchHistory = originalResolveWatchHistory;
nostrClient.getWatchHistoryFingerprint =
originalGetWatchHistoryFingerprint;
if (!originalFlag) {
setWatchHistoryV2Enabled(false);