diff --git a/docs/nostr-analytics.md b/docs/nostr-analytics.md index 1aee3e8b..e181e8fb 100644 --- a/docs/nostr-analytics.md +++ b/docs/nostr-analytics.md @@ -22,6 +22,8 @@ When a player crosses the 12-second watch threshold, BitVid invokes [`publishVie To avoid double-counting the same session, the playback stack respects the cooldown keys generated in [`js/app.js`](../js/app.js#L6825-L6893). The combination of pointer identity and viewer fingerprint prevents re-logging until the dedupe window expires or the user switches accounts. +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. + ### 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. diff --git a/docs/nostr-event-schemas.md b/docs/nostr-event-schemas.md index ebfef8b8..9316ee3d 100644 --- a/docs/nostr-event-schemas.md +++ b/docs/nostr-event-schemas.md @@ -48,7 +48,7 @@ read/write split. | Video post (`NOTE_TYPES.VIDEO_POST`) | `30078` | `['t','video']`, `['d', ]` plus optional schema append tags | JSON payload using Content Schema v3 (`version`, `title`, optional `url`, `magnet`, `thumbnail`, `description`, `mode`, `videoRootId`, `deleted`, `isPrivate`, `enableComments`, `ws`, `xs`) | | NIP-94 mirror (`NOTE_TYPES.VIDEO_MIRROR`) | `1063` | Tags forwarded from `publishVideo` (URL, mime type, thumbnail, alt text, magnet) | Plain text alt description | | Relay list (`NOTE_TYPES.RELAY_LIST`) | `10002` | Repeating `['r', ]` tags, optionally with a marker of `'read'` or `'write'` to scope the relay; marker omitted for read/write relays | Empty content | -| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']` plus either `['e', ]` or `['a',
]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | +| View counter (`NOTE_TYPES.VIEW_EVENT`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | Canonical tag set: `['t','view']`, a pointer tag (`['e', ]` or `['a',
]`), and a stable dedupe tag `['d', ]`, with optional `['session','true']` when a session actor signs; schema overrides may append extra tags. `['video', ...]` is supported for legacy overrides only. | Optional plaintext message | | Watch history index (`NOTE_TYPES.WATCH_HISTORY_INDEX`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', WATCH_HISTORY_LIST_IDENTIFIER]`, `['snapshot', ]`, `['chunks', ]`, repeated `['a',
]` pointers to each chunk event plus schema append tags | JSON payload `{ snapshot, totalChunks }` | | Watch history chunk (`NOTE_TYPES.WATCH_HISTORY_CHUNK`) | `WATCH_HISTORY_KIND` (default `30079`, clients also read legacy `30078`) | `['d', ]`, `['encrypted','nip04']`, `['snapshot', ]`, `['chunk', , ]`, pointer tags for each entry, plus schema append tags | NIP-04 encrypted JSON chunk (`{ version, snapshot, chunkIndex, totalChunks, items[] }`) | | Subscription list (`NOTE_TYPES.SUBSCRIPTION_LIST`) | `30002` | `['d', 'subscriptions']` | NIP-04 encrypted JSON `{ subPubkeys: string[] }` | diff --git a/js/nostr.js b/js/nostr.js index 01c22c4c..548eb0e1 100644 --- a/js/nostr.js +++ b/js/nostr.js @@ -660,6 +660,48 @@ function deriveViewEventPointerScope(pointer) { 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) { + if (isDevMode) { + console.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; @@ -2976,6 +3018,8 @@ class NostrClient { } } + const dedupeTag = generateViewEventDedupeTag(actorPubkey, pointer, createdAt); + const event = buildViewEvent({ pubkey: actorPubkey, created_at: createdAt, @@ -2983,6 +3027,7 @@ class NostrClient { pointerTag, includeSessionTag: usingSessionActor, additionalTags, + dedupeTag, content, }); diff --git a/js/nostrEventSchemas.js b/js/nostrEventSchemas.js index 9f3720b1..f07eae8d 100644 --- a/js/nostrEventSchemas.js +++ b/js/nostrEventSchemas.js @@ -146,6 +146,7 @@ const BASE_SCHEMAS = { label: "View counter", kind: WATCH_HISTORY_KIND, topicTag: { name: "t", value: "view" }, + identifierTag: { name: "d" }, sessionTag: { name: "session", value: "true" }, appendTags: DEFAULT_APPEND_TAGS, content: { diff --git a/tests/view-counter.test.mjs b/tests/view-counter.test.mjs index db93f43b..3b87f52f 100644 --- a/tests/view-counter.test.mjs +++ b/tests/view-counter.test.mjs @@ -59,8 +59,12 @@ const canonicalViewEvent = buildViewEvent({ assert.deepEqual( canonicalViewEvent.tags, - [["t", "view"], [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value]], - "view event should only include topic and supplied pointer tags by default" + [ + ["t", "view"], + [CACHE_TTL_TEST_POINTER.type, CACHE_TTL_TEST_POINTER.value], + ["d", "ignore-dedupe"], + ], + "view event should include topic, pointer, and dedupe tags when provided" ); const sessionViewEvent = buildViewEvent({