feat: add zaps_amount_cashu to event_stats (with tests)

add zapped_cashu and zaps_amount_cashu field to MastodonStatus
This commit is contained in:
P. Reis 2025-03-25 18:19:40 -03:00
parent 83c96c88b7
commit 7dc56f594b
9 changed files with 95 additions and 0 deletions

View file

@ -36,6 +36,7 @@ interface EventStatsRow {
quotes_count: number; quotes_count: number;
reactions: string; reactions: string;
zaps_amount: number; zaps_amount: number;
zaps_amount_cashu: number;
link_preview?: MastodonPreviewCard; link_preview?: MastodonPreviewCard;
} }

View file

@ -0,0 +1,12 @@
import type { Kysely } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema
.alterTable('event_stats')
.addColumn('zaps_amount_cashu', 'integer', (col) => col.notNull().defaultTo(0))
.execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.alterTable('event_stats').dropColumn('zaps_amount_cashu').execute();
}

View file

@ -24,6 +24,7 @@ export interface EventStats {
quotes_count: number; quotes_count: number;
reactions: Record<string, number>; reactions: Record<string, number>;
zaps_amount: number; zaps_amount: number;
zaps_amount_cashu: number;
link_preview?: MastodonPreviewCard; link_preview?: MastodonPreviewCard;
} }

View file

@ -448,6 +448,7 @@ export class DittoRelayStore implements NRelay {
quotes_count: 0, quotes_count: 0,
reactions: '{}', reactions: '{}',
zaps_amount: 0, zaps_amount: 0,
zaps_amount_cashu: 0,
link_preview: linkPreview, link_preview: linkPreview,
}) })
.onConflict((oc) => oc.column('event_id').doUpdateSet({ link_preview: linkPreview })) .onConflict((oc) => oc.column('event_id').doUpdateSet({ link_preview: linkPreview }))

View file

@ -411,6 +411,7 @@ async function gatherEventStats(
quotes_count: Math.max(0, row.quotes_count), quotes_count: Math.max(0, row.quotes_count),
reactions: row.reactions, reactions: row.reactions,
zaps_amount: Math.max(0, row.zaps_amount), zaps_amount: Math.max(0, row.zaps_amount),
zaps_amount_cashu: Math.max(0, row.zaps_amount_cashu),
link_preview: row.link_preview, link_preview: row.link_preview,
})); }));
} }

View file

@ -161,6 +161,48 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => {
assertEquals(stats!.reactions_count, 3); assertEquals(stats!.reactions_count, 3);
}); });
Deno.test('updateStats with kind 9321 increments zaps_amount_cashu count', async () => {
await using test = await setupTest();
const { kysely, relay } = test;
const note = genEvent({ kind: 1 });
await relay.event(note);
await updateStats({
...test,
event: genEvent({
kind: 9321,
content: 'Do you love me?',
tags: [
['e', note.id],
[
'proof',
'{"id":"004f7adf2a04356c","amount":29,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}',
],
],
}),
});
await updateStats({
...test,
event: genEvent({
kind: 9321,
content: 'Ultimatum',
tags: [
['e', note.id],
[
'proof',
'{"id":"004f7adf2a04356c","amount":100,"secret":"6780378b186cf7ada639ce4807803ad5e4a71217688430512f35074f9bca99c0","C":"03f0dd8df04427c8c53e4ae9ce8eb91c4880203d6236d1d745c788a5d7a47aaff3","dleq":{"e":"bd22fcdb7ede1edb52b9b8c6e1194939112928e7b4fc0176325e7671fb2bd351","s":"a9ad015571a0e538d62966a16d2facf806fb956c746a3dfa41fa689486431c67","r":"b283980e30bf5a31a45e5e296e93ae9f20bf3a140c884b3b4cd952dbecc521df"}}',
],
],
}),
});
const stats = await getEventStats(kysely, note.id);
assertEquals(stats!.zaps_amount_cashu, 129);
});
Deno.test('updateStats with kind 5 decrements reactions count', async () => { Deno.test('updateStats with kind 5 decrements reactions count', async () => {
await using test = await setupTest(); await using test = await setupTest();
const { kysely, relay } = test; const { kysely, relay } = test;

View file

@ -1,3 +1,5 @@
import { type Proof } from '@cashu/cashu-ts';
import { proofSchema } from '@ditto/cashu';
import { DittoTables } from '@ditto/db'; import { DittoTables } from '@ditto/db';
import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify';
import { Insertable, Kysely, UpdateObject } from 'kysely'; import { Insertable, Kysely, UpdateObject } from 'kysely';
@ -38,6 +40,8 @@ export async function updateStats(opts: UpdateStatsOpts): Promise<void> {
return handleEvent7(opts); return handleEvent7(opts);
case 9735: case 9735:
return handleEvent9735(opts); return handleEvent9735(opts);
case 9321:
return handleEvent9321(opts);
} }
} }
@ -232,6 +236,32 @@ async function handleEvent9735(opts: UpdateStatsOpts): Promise<void> {
); );
} }
/** Update stats for kind 9321 event. */
async function handleEvent9321(opts: UpdateStatsOpts): Promise<void> {
const { kysely, event } = opts;
// https://github.com/nostr-protocol/nips/blob/master/61.md#nutzap-event
// It's possible to nutzap a profile without nutzapping a post, but we don't care about this case
const id = event.tags.find(([name]) => name === 'e')?.[1];
if (!id) return;
const proofs = (event.tags.filter(([name]) => name === 'proof').map(([_, proof]) => {
const { success, data } = n.json().pipe(proofSchema).safeParse(proof);
if (!success) return;
return data;
})
.filter(Boolean)) as Proof[];
const amount = proofs.reduce((prev, current) => prev + current.amount, 0);
await updateEventStats(
kysely,
id,
({ zaps_amount_cashu }) => ({ zaps_amount_cashu: Math.max(0, zaps_amount_cashu + amount) }),
);
}
/** Get the pubkeys that were added and removed from a follow event. */ /** Get the pubkeys that were added and removed from a follow event. */
export function getFollowDiff( export function getFollowDiff(
tags: string[][], tags: string[][],
@ -318,6 +348,7 @@ export async function updateEventStats(
reactions_count: 0, reactions_count: 0,
quotes_count: 0, quotes_count: 0,
zaps_amount: 0, zaps_amount: 0,
zaps_amount_cashu: 0,
reactions: '{}', reactions: '{}',
}; };

View file

@ -69,6 +69,7 @@ async function renderStatus(
? await store.query([ ? await store.query([
{ kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [6], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [7], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [9321], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [9734], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10001], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
{ kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 }, { kinds: [10003], '#e': [event.id], authors: [viewerPubkey], limit: 1 },
@ -80,6 +81,7 @@ async function renderStatus(
const pinEvent = relatedEvents.find((event) => event.kind === 10001); const pinEvent = relatedEvents.find((event) => event.kind === 10001);
const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const bookmarkEvent = relatedEvents.find((event) => event.kind === 10003);
const zapEvent = relatedEvents.find((event) => event.kind === 9734); const zapEvent = relatedEvents.find((event) => event.kind === 9734);
const nutzapEvent = relatedEvents.find((event) => event.kind === 9321);
const compatMentions = buildInlineRecipients(mentions.filter((m) => { const compatMentions = buildInlineRecipients(mentions.filter((m) => {
if (m.id === account.id) return false; if (m.id === account.id) return false;
@ -136,6 +138,7 @@ async function renderStatus(
reblogs_count: event.event_stats?.reposts_count ?? 0, reblogs_count: event.event_stats?.reposts_count ?? 0,
favourites_count: event.event_stats?.reactions['+'] ?? 0, favourites_count: event.event_stats?.reactions['+'] ?? 0,
zaps_amount: event.event_stats?.zaps_amount ?? 0, zaps_amount: event.event_stats?.zaps_amount ?? 0,
zaps_amount_cashu: event.event_stats?.zaps_amount_cashu ?? 0,
favourited: reactionEvent?.content === '+', favourited: reactionEvent?.content === '+',
reblogged: Boolean(repostEvent), reblogged: Boolean(repostEvent),
muted: false, muted: false,
@ -155,6 +158,7 @@ async function renderStatus(
uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`), uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`),
url: Conf.local(`/@${account.acct}/${event.id}`), url: Conf.local(`/@${account.acct}/${event.id}`),
zapped: Boolean(zapEvent), zapped: Boolean(zapEvent),
zapped_cashu: Boolean(nutzapEvent),
ditto: { ditto: {
external_url: Conf.external(nevent), external_url: Conf.external(nevent),
}, },

View file

@ -18,6 +18,7 @@ export interface MastodonStatus {
reblogs_count: number; reblogs_count: number;
favourites_count: number; favourites_count: number;
zaps_amount: number; zaps_amount: number;
zaps_amount_cashu: number;
favourited: boolean; favourited: boolean;
reblogged: boolean; reblogged: boolean;
muted: boolean; muted: boolean;
@ -35,6 +36,7 @@ export interface MastodonStatus {
uri: string; uri: string;
url: string; url: string;
zapped: boolean; zapped: boolean;
zapped_cashu: boolean;
pleroma: { pleroma: {
emoji_reactions: { name: string; count: number; me: boolean }[]; emoji_reactions: { name: string; count: number; me: boolean }[];
expires_at?: string; expires_at?: string;