diff --git a/docs/zap-tally-plan.md b/docs/zap-tally-plan.md index ae6a2b29..46ea8a21 100644 --- a/docs/zap-tally-plan.md +++ b/docs/zap-tally-plan.md @@ -471,9 +471,12 @@ tallies simply stop being counted. No data migration. - [x] Tests: verify, schema, publisher, cross-source dedup, flag-off, e2e verify. - [x] `lint` + `build` + suites green; pushed to `unstable`. - [ ] **Remaining v1 work:** - - [ ] **Profile-zap counting (remaining):** the store queries `#a`/`#e` - only — add `#p` batches so a profile-only tally (no video pointer) is - fetched, and a channel-page total UI surface (D2 chose video+profile). + - [x] **Profile-zap counting DONE:** pointerKey/store support `p:` + pointers (profilePointer(pubkey)); runBatch queries `#p` and both + folds count `#p`-tagged 9735s + tallies. Channel page shows a + "⚡ N sats zapped" total (js/channelZapTotal.js, hidden until + nonzero). Tests in tests/zap-totals.test.mjs. This CLOSES the zap + tally feature (D2 video + profile). - [x] **Retry path:** `zapController.executeRetry` fires `onZapSuccess` so retried shares publish tallies too. (commit afed041f) - [x] **Popularity chart §5.9 DONE:** orange zaps-over-time line beside diff --git a/js/channelProfile.js b/js/channelProfile.js index af3ff7dd..5c476341 100644 --- a/js/channelProfile.js +++ b/js/channelProfile.js @@ -2,6 +2,7 @@ import { nostrClient } from "./nostrClientFacade.js"; import { convertChannelEvent, buildChannelVideoFilters, loadCachedChannelVideos, saveCachedChannelVideos, mergeChannelVideoSources } from "./channelProfileVideos.js"; +import { wireChannelZapTotal } from "./channelZapTotal.js"; import { dedupeVideos } from "./utils/videoDeduper.js"; import { DEFAULT_RELAY_URLS } from "./nostr/toolkit.js"; import { subscriptions } from "./subscriptions.js"; @@ -5227,6 +5228,9 @@ function applyChannelProfileMetadata({ } setChannelZapVisibility(Boolean(lightningAddress)); + + // #47: total sats zapped to this creator (p: pointer in the store). + wireChannelZapTotal(currentChannelHex); } async function fetchChannelProfileFromRelays(pubkey) { diff --git a/js/channelZapTotal.js b/js/channelZapTotal.js new file mode 100644 index 00000000..b502e4a4 --- /dev/null +++ b/js/channelZapTotal.js @@ -0,0 +1,68 @@ +// js/channelZapTotal.js +// +// Channel-page "sats zapped to this creator" total (#47, docs/zap-tally-plan.md +// D2 profile path). Uses a p: pointer in the shared zap-totals store, so +// it counts every zap tagged to the creator — direct profile zaps AND zaps to +// their videos (both carry a `p` tag) — from 9735 receipts + verified bitvid +// tallies. Hidden until there's a nonzero total. Kept out of channelProfile.js +// (file-size budget). + +import { + profilePointer, + requestVideoZapTotal, + getVideoZapTotalSnapshot, + onZapTotalsChanged, +} from "./zapTotals.js"; +import { formatViewCount } from "./viewCounter.js"; +import { FEATURE_ZAP_TALLY } from "./constants.js"; + +let unsub = null; + +function teardown() { + if (typeof unsub === "function") { + try { + unsub(); + } catch (error) { + // best effort + } + unsub = null; + } +} + +export function teardownChannelZapTotal() { + teardown(); +} + +export function wireChannelZapTotal(pubkey, { document: doc = globalThis.document } = {}) { + teardown(); + const el = doc && typeof doc.getElementById === "function" + ? doc.getElementById("channelZapTotal") + : null; + if (!el) { + return; + } + const pointer = FEATURE_ZAP_TALLY ? profilePointer(pubkey) : null; + if (!pointer) { + el.classList.add("hidden"); + return; + } + const render = () => { + let sats = 0; + try { + sats = getVideoZapTotalSnapshot(pointer) || 0; + } catch (error) { + sats = 0; + } + if (sats > 0) { + el.textContent = `⚡ ${formatViewCount(sats)} sats zapped`; + el.classList.remove("hidden"); + } else { + el.classList.add("hidden"); + } + }; + requestVideoZapTotal(pointer); // primes the store (also schedules the fetch) + render(); + unsub = onZapTotalsChanged(render); +} + +export default wireChannelZapTotal; diff --git a/js/zapTotals.js b/js/zapTotals.js index a35dd2bc..28bb3ed7 100644 --- a/js/zapTotals.js +++ b/js/zapTotals.js @@ -39,14 +39,16 @@ function tagValues(event, name) { // Accepts BOTH pointer shapes used in the app: the canonical tag-style array // ["a"|"e", value, relay?] (what deriveVideoPointerInfo/resolveVideoPointer // produce) and the object form { type, value }. +const POINTER_TYPES = new Set(["a", "e", "p"]); + export function pointerKey(pointer) { let type = ""; let value = ""; if (Array.isArray(pointer)) { - type = pointer[0] === "a" || pointer[0] === "e" ? pointer[0] : ""; + type = POINTER_TYPES.has(pointer[0]) ? pointer[0] : ""; value = typeof pointer[1] === "string" ? pointer[1].trim() : ""; } else if (pointer && typeof pointer === "object") { - type = pointer.type === "a" || pointer.type === "e" ? pointer.type : ""; + type = POINTER_TYPES.has(pointer.type) ? pointer.type : ""; value = typeof pointer.value === "string" ? pointer.value.trim() : ""; } if (!type || !value) { @@ -55,6 +57,14 @@ export function pointerKey(pointer) { return `${type}:${value}`; } +// A `p:` (profile/channel) pointer for a creator pubkey — the total sats zapped +// to that pubkey via bitvid (direct profile zaps AND zaps to their videos, both +// tagged `p`). Used by the channel page; distinct key space from video a:/e:. +export function profilePointer(pubkey) { + const hex = typeof pubkey === "string" ? pubkey.trim().toLowerCase() : ""; + return hex ? ["p", hex] : null; +} + // Sats carried by one receipt: bolt11 first (the actual paid invoice), then // the zap request's amount tag (msats) embedded in the description. export function extractReceiptAmountSats(event, tools) { @@ -207,9 +217,11 @@ export function createZapTotalsStore({ const aValues = []; const eValues = []; + const pValues = []; for (const pointer of batch.values()) { if (pointer.type === "a") aValues.push(pointer.value); else if (pointer.type === "e") eValues.push(pointer.value); + else if (pointer.type === "p") pValues.push(pointer.value); } // One query for both sources: real 9735 receipts + bitvid tallies. @@ -236,6 +248,15 @@ export function createZapTotalsStore({ limit: RECEIPTS_PER_FILTER_LIMIT, }); } + // Profile/channel totals: every zap to this pubkey (direct or via a video) + // is tagged `#p`. Only queried when a p: pointer is requested (channel page). + for (let i = 0; i < pValues.length; i += MAX_POINTERS_PER_FILTER) { + filters.push({ + kinds, + "#p": pValues.slice(i, i + MAX_POINTERS_PER_FILTER), + limit: RECEIPTS_PER_FILTER_LIMIT, + }); + } let events = []; try { @@ -267,6 +288,7 @@ export function createZapTotalsStore({ const keys = [ ...tagValues(event, "a").map((value) => `a:${value}`), ...tagValues(event, "e").map((value) => `e:${value}`), + ...tagValues(event, "p").map((value) => `p:${value}`), ].filter((key) => batch.has(key) || totals.has(key)); if (!keys.length) { continue; @@ -310,7 +332,7 @@ export function createZapTotalsStore({ continue; } const keys = verdict.pointerTags - .filter((t) => t[0] === "a" || t[0] === "e") + .filter((t) => t[0] === "a" || t[0] === "e" || t[0] === "p") .map((t) => `${t[0]}:${t[1]}`) .filter((key) => batch.has(key) || totals.has(key)); for (const key of keys) { diff --git a/tests/zap-totals.test.mjs b/tests/zap-totals.test.mjs index 15e34ca9..bc279061 100644 --- a/tests/zap-totals.test.mjs +++ b/tests/zap-totals.test.mjs @@ -32,10 +32,13 @@ import { createZapTotalsStore, extractReceiptAmountSats, pointerKey, + profilePointer, ZAP_RECEIPT_KIND, } from "../js/zapTotals.js"; import { createMostZappedSorter } from "../js/feedEngine/sorters.js"; +const CREATOR_HEX = "c".repeat(64); + const A1 = "30078:aaaa:video-1"; const A2 = "30078:bbbb:video-2"; @@ -304,6 +307,41 @@ test("cross-source dedup: a 9735 and a tally with the same payment_hash count on ); }); +test("profilePointer + pointerKey accept p: (channel total)", () => { + assert.deepEqual(profilePointer(CREATOR_HEX), ["p", CREATOR_HEX]); + assert.equal(profilePointer(" " + CREATOR_HEX.toUpperCase() + " ")[1], CREATOR_HEX); + assert.equal(profilePointer(""), null); + assert.equal(pointerKey(["p", CREATOR_HEX]), `p:${CREATOR_HEX}`); + assert.equal(pointerKey({ type: "p", value: CREATOR_HEX }), `p:${CREATOR_HEX}`); +}); + +test("profile counting: a p: pointer counts #p-tagged zaps (direct + via video)", async () => { + // A video zap's 9735 carries both #a (video) and #p (creator). Querying the + // creator's p: pointer should count it toward the channel total. + const store = createZapTotalsStore({ + persistKey: null, + isTallyEnabled: () => false, // 9735-only for this test + getTools: () => ({ nip57: { getSatoshisAmountFromBolt11: () => NaN } }), + getClient: () => ({ + relays: ["wss://relay.example"], + getSubscriptionManager: () => ({ + list: async () => [ + receipt("r1", [["p", CREATOR_HEX], ["a", A1], amountTag(1500000)]), // 1500 sats + receipt("r2", [["p", CREATOR_HEX], amountTag(600000)]), // 600 sats, direct + ], + }), + }), + schedule: (fn) => { fn(); return 1; }, + }); + store.request({ type: "p", value: CREATOR_HEX }); + await store.flush(); + assert.equal( + store.getSnapshot({ type: "p", value: CREATOR_HEX }), + 2100, + "channel total sums every #p-tagged zap", + ); +}); + test("tally counting is gated: disabled → tallies ignored", async () => { const store = makeTallyStore({ enabled: false, diff --git a/views/channel-profile.html b/views/channel-profile.html index 32939924..76d68571 100644 --- a/views/channel-profile.html +++ b/views/channel-profile.html @@ -140,6 +140,13 @@ >Website

+ +