diff --git a/src/app.ts b/src/app.ts index 5ab31d8..276f9ee 100644 --- a/src/app.ts +++ b/src/app.ts @@ -88,6 +88,7 @@ import { unpinController, unreblogStatusController, zapController, + zappedByController, } from '@/controllers/api/statuses.ts'; import { streamingController } from '@/controllers/api/streaming.ts'; import { suggestionsV1Controller, suggestionsV2Controller } from '@/controllers/api/suggestions.ts'; @@ -259,6 +260,7 @@ app.post('/api/v1/ditto/names', requireSigner, nameRequestController); app.get('/api/v1/ditto/names', requireSigner, nameRequestsController); app.post('/api/v1/ditto/zap', requireSigner, zapController); +app.get('/api/v1/ditto/statuses/:id{[0-9a-f]{64}}/zapped_by', zappedByController); app.post('/api/v1/reports', requireSigner, reportController); app.get('/api/v1/admin/reports', requireSigner, requireRole('admin'), adminReportsController); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index ce4cbfa..7b9e82c 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -20,6 +20,7 @@ import { lookupPubkey } from '@/utils/lookup.ts'; import { addTag, deleteTag } from '@/utils/tags.ts'; import { asyncReplaceAll } from '@/utils/text.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; const createStatusSchema = z.object({ in_reply_to_id: n.id().nullish(), @@ -541,6 +542,40 @@ const zapController: AppController = async (c) => { } }; +const zappedByController: AppController = async (c) => { + const id = c.req.param('id'); + const store = await Storages.db(); + const amountSchema = z.coerce.number().int().nonnegative().catch(0); + + const events: DittoEvent[] = (await store.query([{ kinds: [9735], '#e': [id], limit: 100 }])).map((event) => { + const zapRequest = event.tags.find(([name]) => name === 'description')?.[1]; + if (!zapRequest) return; + try { + return JSON.parse(zapRequest); + } catch { + return; + } + }).filter(Boolean); + + await hydrateEvents({ events, store }); + + const results = (await Promise.all( + events.map(async (event) => { + const amount = amountSchema.parse(event.tags.find(([name]) => name === 'amount')?.[1]); + const comment = event?.content ?? ''; + const account = event?.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); + + return { + comment, + amount, + account, + }; + }), + )).filter(Boolean); + + return c.json(results); +}; + export { bookmarkController, contextController, @@ -557,4 +592,5 @@ export { unpinController, unreblogStatusController, zapController, + zappedByController, }; diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index d9e320d..aed8c8c 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -23,6 +23,7 @@ interface EventStatsRow { reactions_count: number; quotes_count: number; reactions: string; + zaps_amount: number; } interface EventRow { diff --git a/src/db/migrations/025_event_stats_add_zap_count.ts b/src/db/migrations/025_event_stats_add_zap_count.ts new file mode 100644 index 0000000..9147990 --- /dev/null +++ b/src/db/migrations/025_event_stats_add_zap_count.ts @@ -0,0 +1,12 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('event_stats') + .addColumn('zaps_amount', 'integer', (col) => col.notNull().defaultTo(0)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('event_stats').dropColumn('zaps_amount').execute(); +} diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index 85e11d6..2f2aef2 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -13,6 +13,7 @@ export interface EventStats { reposts_count: number; quotes_count: number; reactions: Record; + zaps_amount: number; } /** Internal Event representation used by Ditto, including extra keys. */ diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 9ec9e8c..3c26432 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -330,6 +330,7 @@ async function gatherEventStats(events: DittoEvent[]): Promise, event: NostrEvent, x: n } } +/** Update stats for kind 9735 event. */ +async function handleEvent9735(kysely: Kysely, event: NostrEvent): Promise { + // https://github.com/nostr-protocol/nips/blob/master/57.md#appendix-f-validating-zap-receipts + const id = event.tags.find(([name]) => name === 'e')?.[1]; + if (!id) return; + + const amountSchema = z.coerce.number().int().nonnegative().catch(0); + let amount = 0; + try { + const zapRequest = n.json().pipe(n.event()).parse(event.tags.find(([name]) => name === 'description')?.[1]); + amount = amountSchema.parse(zapRequest.tags.find(([name]) => name === 'amount')?.[1]); + if (amount <= 0) return; + } catch { + return; + } + + await updateEventStats( + kysely, + id, + ({ zaps_amount }) => ({ zaps_amount: Math.max(0, zaps_amount + amount) }), + ); +} + /** Get the pubkeys that were added and removed from a follow event. */ export function getFollowDiff( tags: string[][], @@ -219,6 +245,7 @@ export async function updateEventStats( reposts_count: 0, reactions_count: 0, quotes_count: 0, + zaps_amount: 0, reactions: '{}', }; diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index ed14c8e..a0874b3 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -104,6 +104,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< replies_count: event.event_stats?.replies_count ?? 0, reblogs_count: event.event_stats?.reposts_count ?? 0, favourites_count: event.event_stats?.reactions['+'] ?? 0, + zaps_amount: event.event_stats?.zaps_amount ?? 0, favourited: reactionEvent?.content === '+', reblogged: Boolean(repostEvent), muted: false,