diff --git a/packages/db/DittoTables.ts b/packages/db/DittoTables.ts index 12763c57..934720f5 100644 --- a/packages/db/DittoTables.ts +++ b/packages/db/DittoTables.ts @@ -36,6 +36,7 @@ interface EventStatsRow { quotes_count: number; reactions: string; zaps_amount: number; + zaps_amount_cashu: number; link_preview?: MastodonPreviewCard; } diff --git a/packages/db/migrations/054_event_stats_add_zap_cashu_count.ts b/packages/db/migrations/054_event_stats_add_zap_cashu_count.ts new file mode 100644 index 00000000..472a504d --- /dev/null +++ b/packages/db/migrations/054_event_stats_add_zap_cashu_count.ts @@ -0,0 +1,12 @@ +import type { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('event_stats') + .addColumn('zaps_amount_cashu', 'integer', (col) => col.notNull().defaultTo(0)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('event_stats').dropColumn('zaps_amount_cashu').execute(); +} diff --git a/packages/ditto/interfaces/DittoEvent.ts b/packages/ditto/interfaces/DittoEvent.ts index cdd4343d..d80a5418 100644 --- a/packages/ditto/interfaces/DittoEvent.ts +++ b/packages/ditto/interfaces/DittoEvent.ts @@ -24,6 +24,7 @@ export interface EventStats { quotes_count: number; reactions: Record; zaps_amount: number; + zaps_amount_cashu: number; link_preview?: MastodonPreviewCard; } diff --git a/packages/ditto/storages/DittoRelayStore.ts b/packages/ditto/storages/DittoRelayStore.ts index 40478eac..14a4969e 100644 --- a/packages/ditto/storages/DittoRelayStore.ts +++ b/packages/ditto/storages/DittoRelayStore.ts @@ -448,6 +448,7 @@ export class DittoRelayStore implements NRelay { quotes_count: 0, reactions: '{}', zaps_amount: 0, + zaps_amount_cashu: 0, link_preview: linkPreview, }) .onConflict((oc) => oc.column('event_id').doUpdateSet({ link_preview: linkPreview })) diff --git a/packages/ditto/storages/hydrate.ts b/packages/ditto/storages/hydrate.ts index d2c64e90..60ec89bd 100644 --- a/packages/ditto/storages/hydrate.ts +++ b/packages/ditto/storages/hydrate.ts @@ -411,6 +411,7 @@ async function gatherEventStats( quotes_count: Math.max(0, row.quotes_count), reactions: row.reactions, zaps_amount: Math.max(0, row.zaps_amount), + zaps_amount_cashu: Math.max(0, row.zaps_amount_cashu), link_preview: row.link_preview, })); } diff --git a/packages/ditto/utils/stats.test.ts b/packages/ditto/utils/stats.test.ts index 0fe7b0ae..52a197b8 100644 --- a/packages/ditto/utils/stats.test.ts +++ b/packages/ditto/utils/stats.test.ts @@ -161,6 +161,48 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => { 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 () => { await using test = await setupTest(); const { kysely, relay } = test; diff --git a/packages/ditto/utils/stats.ts b/packages/ditto/utils/stats.ts index 576c6e01..a880c800 100644 --- a/packages/ditto/utils/stats.ts +++ b/packages/ditto/utils/stats.ts @@ -1,3 +1,5 @@ +import { type Proof } from '@cashu/cashu-ts'; +import { proofSchema } from '@ditto/cashu'; import { DittoTables } from '@ditto/db'; import { NostrEvent, NSchema as n, NStore } from '@nostrify/nostrify'; import { Insertable, Kysely, UpdateObject } from 'kysely'; @@ -38,6 +40,8 @@ export async function updateStats(opts: UpdateStatsOpts): Promise { return handleEvent7(opts); case 9735: return handleEvent9735(opts); + case 9321: + return handleEvent9321(opts); } } @@ -232,6 +236,32 @@ async function handleEvent9735(opts: UpdateStatsOpts): Promise { ); } +/** Update stats for kind 9321 event. */ +async function handleEvent9321(opts: UpdateStatsOpts): Promise { + 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. */ export function getFollowDiff( tags: string[][], @@ -318,6 +348,7 @@ export async function updateEventStats( reactions_count: 0, quotes_count: 0, zaps_amount: 0, + zaps_amount_cashu: 0, reactions: '{}', }; diff --git a/packages/ditto/views/mastodon/statuses.ts b/packages/ditto/views/mastodon/statuses.ts index ba2e8d86..1c91173c 100644 --- a/packages/ditto/views/mastodon/statuses.ts +++ b/packages/ditto/views/mastodon/statuses.ts @@ -69,6 +69,7 @@ async function renderStatus( ? await store.query([ { kinds: [6], '#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: [10001], '#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 bookmarkEvent = relatedEvents.find((event) => event.kind === 10003); const zapEvent = relatedEvents.find((event) => event.kind === 9734); + const nutzapEvent = relatedEvents.find((event) => event.kind === 9321); const compatMentions = buildInlineRecipients(mentions.filter((m) => { if (m.id === account.id) return false; @@ -136,6 +138,7 @@ async function renderStatus( reblogs_count: event.event_stats?.reposts_count ?? 0, favourites_count: event.event_stats?.reactions['+'] ?? 0, zaps_amount: event.event_stats?.zaps_amount ?? 0, + zaps_amount_cashu: event.event_stats?.zaps_amount_cashu ?? 0, favourited: reactionEvent?.content === '+', reblogged: Boolean(repostEvent), muted: false, @@ -155,6 +158,7 @@ async function renderStatus( uri: Conf.local(`/users/${account.acct}/statuses/${event.id}`), url: Conf.local(`/@${account.acct}/${event.id}`), zapped: Boolean(zapEvent), + zapped_cashu: Boolean(nutzapEvent), ditto: { external_url: Conf.external(nevent), }, diff --git a/packages/mastoapi/types/MastodonStatus.ts b/packages/mastoapi/types/MastodonStatus.ts index 019e5a7b..5196bfce 100644 --- a/packages/mastoapi/types/MastodonStatus.ts +++ b/packages/mastoapi/types/MastodonStatus.ts @@ -18,6 +18,7 @@ export interface MastodonStatus { reblogs_count: number; favourites_count: number; zaps_amount: number; + zaps_amount_cashu: number; favourited: boolean; reblogged: boolean; muted: boolean; @@ -35,6 +36,7 @@ export interface MastodonStatus { uri: string; url: string; zapped: boolean; + zapped_cashu: boolean; pleroma: { emoji_reactions: { name: string; count: number; me: boolean }[]; expires_at?: string;