mirror of
https://github.com/PR0M3TH3AN/bitvid.git
synced 2026-11-03 19:35:28 +00:00
zaps: profile-zap counting + channel "sats zapped" total (#47 — closes the feature)
The store now supports p: (profile/channel) pointers — every zap tagged to a creator pubkey (direct profile zaps AND zaps to their videos, both carry a `p` tag), from 9735 receipts + verified tallies: - pointerKey accepts "p"; new profilePointer(pubkey) helper. - runBatch batches p: pointers into a #p filter; both folds count #p-tagged events (deduped by payment_hash like a:/e:). - Channel page: a "⚡ N sats zapped" total under the lightning address, wired to the store via js/channelZapTotal.js (own module for channelProfile.js's size budget), hidden until nonzero. Completes the D2 video + profile scope; the bitvid-native zap tally feature is now feature-complete (publish + count + chart + video + profile). Tests: profilePointer/pointerKey + #p counting in tests/zap-totals.test.mjs. Lint (size cap ok) + build green. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
1 parent
974fdc836d
commit
cf82978060
6 files changed
+148
-6
No files matched your search
@@ -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
|
||||
|
||||
@@ -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:<pubkey> pointer in the store).
|
||||
wireChannelZapTotal(currentChannelHex);
|
||||
}
|
||||
|
||||
async function fetchChannelProfileFromRelays(pubkey) {
|
||||
|
||||
@@ -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:<pubkey> 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;
|
||||
+25
-3
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -140,6 +140,13 @@
|
||||
>Website</a
|
||||
>
|
||||
<p id="channelLightning" class="text-sm text-muted-strong"></p>
|
||||
<!-- #47: total sats zapped to this creator (bitvid tallies + 9735s).
|
||||
Hidden until there's a nonzero total. -->
|
||||
<p
|
||||
id="channelZapTotal"
|
||||
class="text-sm text-zap font-medium hidden"
|
||||
title="Total sats zapped to this creator via bitvid"
|
||||
></p>
|
||||
</div>
|
||||
|
||||
<!-- Area for Subscribe/Unsubscribe button -->
|
||||
|
||||
Reference in new issue
Block a user