Add view event bindings for default nostr client

This commit is contained in:
thePR0M3TH3AN committed 2025-10-22 21:48:38 -04:00
1 parent 083750f0f2
commit 6c730704d0
6 files changed
+198 -26

No files matched your search

+3
View File
@@ -17,6 +17,9 @@ bitvid is follow-centric. Your Home feed comes from people you follow (F1). Disc
- **Lists**: NIP-51 (mute list 10000, categorized people 30000, bookmarks 30001).
- **Replies/threads**: NIP-10 (comments).
- **Counts**: NIP-45 (relay COUNT; optional fallbacks).
- **View metrics**: [`js/nostr/viewEventBindings.js`](../../js/nostr/viewEventBindings.js) wraps the shared client so moderation
toggles hit the same record/list/subscribe/count helpers that enforce our NIP-71/NIP-78 guard rails when relays disable optional
methods.
## Runtime flow (blur, hide, override)
- [`ModerationService`](../../js/services/moderationService.js) orchestrates ingest and scoring. Review the service for the trusted-report math and helper entry points.
+4
View File
@@ -24,6 +24,10 @@ To avoid double-counting the same session, the playback stack respects the coold
Every view event includes a unique `#d` tag derived from the pointer scope, the signing actor, and high-entropy randomness. Because kind `30079` is parameterized, this `#d` tag is what keeps individual view notes permanent—without it, a fresh event would overwrite the previous one from the same actor. Leave the tag intact (or provide one when overriding the schema) so relays retain the full stream of view history.
### Client bindings & feature toggles
When wiring moderation or playback toggles into view analytics, call through [`js/nostr/viewEventBindings.js`](../js/nostr/viewEventBindings.js). The binding wraps the singleton client so feature flags, relay discovery, and COUNT/LIST fallbacks all run through the same guard clauses that enforce the NIP-71/NIP-78 schemas. Modules that need to no-op when relays disable optional methods can rely on the binding’s "unavailable" errors to short-circuit cleanly.
### Hydration, COUNT, and UI sync
Background hydration keeps optimistic counts honest. The view counter subscribes to live events while simultaneously fetching historical lists and issuing a `COUNT` request. Whenever the authoritative total returned by `COUNT` exceeds the locally accumulated sum, the hydration routine overwrites the optimistic value so the UI stays aligned with relay truth. If `COUNT` falls back (because relays reject it or the browser is offline), the locally deduped history still anchors totals, and the next successful hydration pass reconciles any drift. This interplay means cards, modals, and dashboards settle on the same number without requiring a full page reload.
+11 -22
View File
@@ -4,6 +4,12 @@ import {
nostrClient,
requestDefaultExtensionPermissions,
} from "./nostr/defaultClient.js";
import {
recordVideoView,
listVideoViewEvents,
subscribeVideoViewEvents,
countVideoViewEvents,
} from "./nostr/viewEventBindings.js";
import {
updateWatchHistoryList,
removeWatchHistoryItem,
@@ -11,30 +17,13 @@ import {
export { nostrClient, requestDefaultExtensionPermissions };
export const recordVideoView = (...args) =>
nostrClient.recordVideoView(...args);
export { updateWatchHistoryList, removeWatchHistoryItem };
export const listVideoViewEvents = (...args) => {
if (typeof nostrClient.listVideoViewEvents !== "function") {
throw new Error("Video view listing is unavailable in this build.");
}
return nostrClient.listVideoViewEvents(...args);
};
export const subscribeVideoViewEvents = (...args) => {
if (typeof nostrClient.subscribeVideoViewEvents !== "function") {
throw new Error("Video view subscriptions are unavailable in this build.");
}
return nostrClient.subscribeVideoViewEvents(...args);
};
export const countVideoViewEvents = (...args) => {
if (typeof nostrClient.countVideoViewEvents !== "function") {
throw new Error("Video view counting is unavailable in this build.");
}
return nostrClient.countVideoViewEvents(...args);
export {
recordVideoView,
listVideoViewEvents,
subscribeVideoViewEvents,
countVideoViewEvents,
};
export {
+42
View File
@@ -0,0 +1,42 @@
// js/nostr/viewEventBindings.js
import { nostrClient } from "./defaultClient.js";
function ensureOptionalBinding(methodName, errorMessage) {
const handler =
nostrClient && typeof nostrClient[methodName] === "function"
? nostrClient[methodName]
: null;
if (!handler) {
throw new Error(errorMessage);
}
return handler;
}
export function recordVideoView(...args) {
return nostrClient.recordVideoView(...args);
}
export function listVideoViewEvents(...args) {
const handler = ensureOptionalBinding(
"listVideoViewEvents",
"Video view listing is unavailable in this build."
);
return handler.apply(nostrClient, args);
}
export function subscribeVideoViewEvents(...args) {
const handler = ensureOptionalBinding(
"subscribeVideoViewEvents",
"Video view subscriptions are unavailable in this build."
);
return handler.apply(nostrClient, args);
}
export function countVideoViewEvents(...args) {
const handler = ensureOptionalBinding(
"countVideoViewEvents",
"Video view counting is unavailable in this build."
);
return handler.apply(nostrClient, args);
}
+4 -4
View File
@@ -22,18 +22,18 @@ import {
countVideoViewEvents,
listVideoViewEvents,
subscribeVideoViewEvents,
} from "./nostr/index.js";
} from "./nostr/viewEventBindings.js";
const VIEW_COUNTER_STORAGE_KEY = "bitvid:view-counter:v1";
const STORAGE_DEBOUNCE_MS = 500;
const SECONDS_PER_DAY = 86_400;
const listVideoViewEventsApi = (pointer, options) =>
listVideoViewEvents(nostrClient, pointer, options);
listVideoViewEvents(pointer, options);
const subscribeVideoViewEventsApi = (pointer, options) =>
subscribeVideoViewEvents(nostrClient, pointer, options);
subscribeVideoViewEvents(pointer, options);
const countVideoViewEventsApi = (pointer, options) =>
countVideoViewEvents(nostrClient, pointer, options);
countVideoViewEvents(pointer, options);
/** @type {Map<string, ViewCounterState>} */
const pointerStates = new Map();
+134
View File
@@ -0,0 +1,134 @@
// Run with: node tests/nostr-view-event-bindings.test.mjs
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
const { nostrClient } = await import("../js/nostr.js");
const {
listVideoViewEvents,
subscribeVideoViewEvents,
countVideoViewEvents,
} = await import("../js/nostr/viewEventBindings.js");
function temporarilyUnset(client, methodName) {
const original = client[methodName];
client[methodName] = undefined;
return () => {
client[methodName] = original;
};
}
async function testListBindingGuardsMissingMethod() {
const restore = temporarilyUnset(nostrClient, "listVideoViewEvents");
try {
assert.throws(() => listVideoViewEvents({ type: "e", value: "guard" }), {
message: "Video view listing is unavailable in this build.",
});
} finally {
restore();
}
}
async function testSubscribeBindingGuardsMissingMethod() {
const restore = temporarilyUnset(nostrClient, "subscribeVideoViewEvents");
try {
assert.throws(
() => subscribeVideoViewEvents({ type: "e", value: "guard" }),
{
message: "Video view subscriptions are unavailable in this build.",
}
);
} finally {
restore();
}
}
async function testCountBindingGuardsMissingMethod() {
const restore = temporarilyUnset(nostrClient, "countVideoViewEvents");
try {
assert.throws(() => countVideoViewEvents({ type: "e", value: "guard" }), {
message: "Video view counting is unavailable in this build.",
});
} finally {
restore();
}
}
async function testListBindingPassesThroughWhenAvailable() {
const pointer = { type: "e", value: "list-success" };
const options = { limit: 5 };
const sentinel = [{ id: "1" }];
const original = nostrClient.listVideoViewEvents;
let receivedArgs = null;
nostrClient.listVideoViewEvents = async (...args) => {
receivedArgs = args;
return sentinel;
};
try {
const result = await listVideoViewEvents(pointer, options);
assert.equal(result, sentinel, "binding should return the client result");
assert.deepEqual(
receivedArgs,
[pointer, options],
"binding should forward pointer and options"
);
} finally {
nostrClient.listVideoViewEvents = original;
}
}
async function testSubscribeBindingPassesThroughWhenAvailable() {
const pointer = { type: "e", value: "subscribe-success" };
const handler = () => {};
const options = { onEvent: handler };
const original = nostrClient.subscribeVideoViewEvents;
let receivedArgs = null;
const unsubscribe = () => "unsub";
nostrClient.subscribeVideoViewEvents = (...args) => {
receivedArgs = args;
return unsubscribe;
};
try {
const result = subscribeVideoViewEvents(pointer, options);
assert.equal(result, unsubscribe, "binding should return unsubscribe value");
assert.deepEqual(
receivedArgs,
[pointer, options],
"binding should forward subscription arguments"
);
} finally {
nostrClient.subscribeVideoViewEvents = original;
}
}
async function testCountBindingPassesThroughWhenAvailable() {
const pointer = { type: "e", value: "count-success" };
const options = { relays: ["wss://example"] };
const sentinel = { total: 1 };
const original = nostrClient.countVideoViewEvents;
let receivedArgs = null;
nostrClient.countVideoViewEvents = async (...args) => {
receivedArgs = args;
return sentinel;
};
try {
const result = await countVideoViewEvents(pointer, options);
assert.equal(result, sentinel, "binding should return the client result");
assert.deepEqual(
receivedArgs,
[pointer, options],
"binding should forward pointer and options"
);
} finally {
nostrClient.countVideoViewEvents = original;
}
}
await testListBindingGuardsMissingMethod();
await testSubscribeBindingGuardsMissingMethod();
await testCountBindingGuardsMissingMethod();
await testListBindingPassesThroughWhenAvailable();
await testSubscribeBindingPassesThroughWhenAvailable();
await testCountBindingPassesThroughWhenAvailable();
console.log("nostr-view-event-bindings tests passed");