fix(watch-history): record new watches under the logged-in identity, not the anonymous session actor

View events are always signed by the anonymous session actor (privacy for the
public view counter). publishView read the watched-actor from that view event's
pubkey, so a logged-in user's new watches were enqueued — and republished via
updateWatchHistoryList — under the SESSION actor. The read path queries
authors:[loggedIn] only, so those session-authored months were never
discovered: newly watched videos never appeared in the synced watch history
(e.g. a 2026-06 month that never showed up despite watching June videos).

Fix:
- publishView now resolves the watch-history actor to the logged-in pubkey
  first, falling back to the view event / session actor only when logged out.
- A watch is flagged session/local-only only when nobody is logged in
  (previously it was flagged whenever the view actor differed from the
  logged-in user, which was always true given anonymized views).

Adds tests/watch-history-records-under-login.test.mjs (mutation-verified: both
the actor-resolution and the session-flag fixes are independently load-bearing).

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-06-21 10:55:27 -04:00
1 parent 25c56fad48
commit 48b6f10e0d
2 files changed
+104 -6

No files matched your search

+9 -6
View File
@@ -641,9 +641,13 @@ async function publishView(pointerInput, createdAt) {
return viewResult;
}
// Watch history belongs to the LOGGED-IN user. View events are signed by an
// anonymous session actor (privacy for the public counter), so using the view
// event's pubkey filed every logged-in watch under the session actor
// (local-only) — it never synced. Prefer the logged-in pubkey.
let actorCandidate =
viewResult?.event?.pubkey ||
nostrClient?.pubkey ||
viewResult?.event?.pubkey ||
nostrClient?.sessionActor?.pubkey ||
"";
let actorKey = normalizeActorKey(actorCandidate);
@@ -713,11 +717,10 @@ async function publishView(pointerInput, createdAt) {
}
);
const normalizedLogged = normalizeActorKey(nostrClient?.pubkey);
const normalizedEventActor = normalizeActorKey(
viewResult?.event?.pubkey || actorCandidate
);
if (normalizedEventActor && normalizedEventActor !== normalizedLogged) {
// A watch is a local-only "session" entry only when nobody is logged in.
// (View events are always signed by the anonymous session actor, so comparing
// against the view actor wrongly flagged every logged-in watch as session.)
if (!normalizeActorKey(nostrClient?.pubkey)) {
normalizedPointer.session = true;
}
@@ -0,0 +1,95 @@
// View events are ALWAYS signed by the anonymous session actor (privacy for the
// public view counter). publishView used to read the watched-actor from that
// view event's pubkey, so a logged-in user's new watches were enqueued — and
// republished — under the SESSION actor. The read path queries authors:[loggedIn]
// only, so those session-authored months were invisible: newly watched videos
// never appeared in the synced watch history.
//
// Correct behavior: when a user is logged in, a new watch belongs to THEIR
// identity (enqueued under the logged-in actor, NOT marked session), so it syncs
// and is discoverable on read. Logged-out watches still go to the session/local
// queue.
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import test from "node:test";
import { watchHistoryService } from "../js/watchHistoryService.js";
import { nostrClient } from "../js/nostrClientFacade.js";
const LOGGED_IN = "a".repeat(64);
const SESSION = "b".repeat(64);
// View events are anonymized: recordVideoView resolves a session actor and the
// returned event is signed by it — NOT by the logged-in user.
function stubAnonymousViews() {
const originals = {
recordVideoView: nostrClient.recordVideoView,
scheduleWatchHistoryRepublish: nostrClient.scheduleWatchHistoryRepublish,
pubkey: nostrClient.pubkey,
sessionActor: nostrClient.sessionActor,
};
nostrClient.sessionActor = { pubkey: SESSION };
nostrClient.recordVideoView = async () => ({
ok: true,
event: { pubkey: SESSION }, // anonymous session-signed view event
});
// Don't actually hit relays during the republish step.
nostrClient.scheduleWatchHistoryRepublish = () => {};
return () => Object.assign(nostrClient, originals);
}
test("a logged-in user's new watch is filed under THEIR identity, not the session actor", async () => {
const restore = stubAnonymousViews();
nostrClient.pubkey = LOGGED_IN;
try {
await watchHistoryService.publishView(
{ type: "e", value: "freshly-watched-video", watchedAt: 1750000000 },
1750000000,
);
const loggedInQueue = watchHistoryService.getQueuedPointers(LOGGED_IN);
const sessionQueue = watchHistoryService.getQueuedPointers(SESSION);
const inLoggedIn = loggedInQueue.find(
(p) => p?.value === "freshly-watched-video",
);
assert.ok(
inLoggedIn,
"new watch must be queued under the LOGGED-IN actor so it syncs to their history",
);
assert.notEqual(
inLoggedIn.session,
true,
"a logged-in user's watch must NOT be flagged session/local-only",
);
assert.equal(
sessionQueue.some((p) => p?.value === "freshly-watched-video"),
false,
"the watch must NOT be stranded in the anonymous session queue",
);
} finally {
restore();
}
});
test("a logged-out watch still goes to the session/local queue", async () => {
const restore = stubAnonymousViews();
nostrClient.pubkey = ""; // logged out
try {
await watchHistoryService.publishView(
{ type: "e", value: "anon-watched-video", watchedAt: 1750000001 },
1750000001,
);
const sessionQueue = watchHistoryService.getQueuedPointers(SESSION);
const entry = sessionQueue.find((p) => p?.value === "anon-watched-video");
assert.ok(entry, "logged-out watch must be queued under the session actor");
assert.equal(
entry.session,
true,
"logged-out watch must be flagged as a local/session entry",
);
} finally {
restore();
}
});