diff --git a/fixtures/events/kind-0-jack.json b/fixtures/events/kind-0-jack.json new file mode 100644 index 00000000..d422377e --- /dev/null +++ b/fixtures/events/kind-0-jack.json @@ -0,0 +1 @@ +{"kind":0,"id":"f7b1a3ca3fa77bffded2024568da939e8cd3ed2403004e1ecb56d556f299ad2a","pubkey":"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2","created_at":1715441226,"tags":[],"content":"{\"banner\":\"https:\\/\\/m.primal.net\\/IBZO.jpg\",\"website\":\"\",\"picture\":\"https:\\/\\/image.nostr.build\\/26867ce34e4b11f0a1d083114919a9f4eca699f3b007454c396ef48c43628315.jpg\",\"lud06\":\"\",\"display_name\":\"\",\"lud16\":\"jack@primal.net\",\"nip05\":\"\",\"name\":\"jack\",\"about\":\"bitcoin \u0026 chill\"}","sig":"9792ceb1e9c73a6c2140540ddbac4279361cae4cc41888019d9dd47d09c1e7cee55948f6e1af824fa0f856d892686352bc757ad157f766f0da656d5e80b38bc7"} diff --git a/fixtures/events/kind-0-patrick.json b/fixtures/events/kind-0-patrick.json new file mode 100644 index 00000000..db0defbe --- /dev/null +++ b/fixtures/events/kind-0-patrick.json @@ -0,0 +1 @@ +{"kind":0,"id":"34bc588a4ff5ca8570a1ad4114485239f83c135b09636dbc16df338f73079e42","pubkey":"47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4","created_at":1726076335,"tags":[],"content":"{\"about\":\"Coding with nature's inspiration, embracing solitude's wisdom. Team Soapbox.\",\"bot\":false,\"lud16\":\"patrickreiis@getalby.com\",\"name\":\"patrickReiis\",\"nip05\":\"patrick@patrickdosreis.com\",\"picture\":\"https://image.nostr.build/2177817a323ed8a58d508fb25160e1c2f38f60256125b764c82c988869916e84.jpg\",\"website\":\"https://patrickdosreis.com/\",\"pubkey\":\"47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4\",\"npub\":\"npub1gujeqakgt7fyp6zjggxhyy7ft623qtcaay5lkc8n8gkry4cvnrzqd3f67z\",\"created_at\":1717600965}","sig":"2780887e58d6e59cc9c03cca8a583bc121d2c74d98cc434d22e65c1f56da1bb09d79fc7cc3c4ee5b829773c17d6f482b114dc951c1683c3908cedff783d785ad"} diff --git a/fixtures/events/kind-1-being-zapped.json b/fixtures/events/kind-1-being-zapped.json new file mode 100644 index 00000000..00d33f06 --- /dev/null +++ b/fixtures/events/kind-1-being-zapped.json @@ -0,0 +1 @@ +{"kind":1,"id":"02e52f80e2e6a3ad0993e9c4a7b4e6afc79d067c6ff9c6df3fb2896342dee2df","pubkey":"47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4","created_at":1724609131,"tags":[["e","677c701036eae20632a7677ee6eece0c62e259d5c72864d78a3bbe419c0d2d2c","wss://gleasonator.dev/relay","root"],["e","677c701036eae20632a7677ee6eece0c62e259d5c72864d78a3bbe419c0d2d2c","wss://gleasonator.dev/relay","reply"],["p","82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"]],"content":"Please I don't want to go back to the shoe factory","sig":"ce6ca329701eec5db0b182bd52c48777b9eccaac298180a6601d8c5156060d944768d71376e7d24c24cefb6619d1467f6a30e0ca574d68f748b38c784e4ced59"} diff --git a/fixtures/events/kind-9735-jack-zap-patrick.json b/fixtures/events/kind-9735-jack-zap-patrick.json new file mode 100644 index 00000000..2abe9210 --- /dev/null +++ b/fixtures/events/kind-9735-jack-zap-patrick.json @@ -0,0 +1 @@ +{"kind":9735,"id":"a57d30d59e7442f9a2ad329400a6cbf29c2b34b1e69e4cdce8bc2fe751d9268f","pubkey":"79f00d3f5a19ec806189fcab03c1be4ff81d18ee4f653c88fac41fe03570f432","created_at":1724610766,"tags":[["p","47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4"],["e","02e52f80e2e6a3ad0993e9c4a7b4e6afc79d067c6ff9c6df3fb2896342dee2df"],["P","82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2"],["bolt11","lnbc52250n1pnvk7xvpp5l776w7354zz9mh7sf3dlq8znkfjhysse9dwda9c7se7jwpglng0qhp5jp5cqy7n7wz9jlvd0aa40ws0d3e78l4ug2pzfen2m56mwg0qahrscqzzsxqyz5vqsp5v30pn2u86h3mz69wlvmu9vam9wudlnt4fv9wcxn24s6vrkj842gq9qxpqysgqw9mfxpyce3fhfue8p88exx8g6gn5ut9c2tz8awnw377dmhqymszrsjg49waxprkd6ggdzn90dwpgjwhdtx45052ukylkwvu5q05w5lspyjpg37"],["preimage","18264e7cce0b91bfd2016362e8a239591674c0f51ffa152acf5d73edac675432"],["description","{\"id\":\"092cd6341b42604b8e908f5bed45cbd60d98bff33258ab4f83f24a7fad445065\",\"pubkey\":\"82341f882b6eabcd2ba7f1ef90aad961cf074af15b9ef44a09f9d2a8fbfbe6a2\",\"created_at\":1724610762,\"kind\":9734,\"tags\":[[\"p\",\"47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4\"],[\"e\",\"02e52f80e2e6a3ad0993e9c4a7b4e6afc79d067c6ff9c6df3fb2896342dee2df\"],[\"amount\",\"5225000\"],[\"relays\",\"wss://relay.exit.pub\",\"wss://relay.damus.io\",\"wss://nos.lol\",\"wss://relay.mostr.pub\",\"wss://relay.primal.net\"]],\"content\":\"🫂\",\"sig\":\"84a36873000d5003c85c56996be856c598e91f66bf2cae9ee9d984892a11774310acf81eae2b40e9fbf25040b91239e840f856c44b68be2d23e4451fa6c5762a\"}"]],"content":"🫂","sig":"087adfe3c5831e2d760678b2929f35340c35662929acb8050f0956a2a95ba2917bf610f921e3d3fc0c08a123c6f721574eb80ca469fe7e33b6581e976844bfcc"} diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index 1b9c746a..144064a6 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -4,10 +4,9 @@ import { z } from 'zod'; import { AppContext, AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { DittoPagination } from '@/interfaces/DittoPagination.ts'; -import { getAmount } from '@/utils/bolt11.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { paginated } from '@/utils/api.ts'; -import { renderNotification, RenderNotificationOpts } from '@/views/mastodon/notifications.ts'; +import { renderNotification } from '@/views/mastodon/notifications.ts'; /** Set of known notification types across backends. */ const notificationTypes = new Set([ @@ -86,54 +85,17 @@ async function renderNotifications( const { signal } = c.req.raw; const opts = { signal, limit: params.limit, timeout: Conf.db.timeouts.timelines }; - const zapsRelatedFilter: NostrFilter[] = []; - const events = await store .query(filters, opts) - .then((events) => - events.filter((event) => { - if (event.kind === 9735) { - const zappedEventId = event.tags.find(([name]) => name === 'e')?.[1]; - if (zappedEventId) zapsRelatedFilter.push({ kinds: [1], ids: [zappedEventId] }); - const zapSender = event.tags.find(([name]) => name === 'P')?.[1]; - if (zapSender) zapsRelatedFilter.push({ kinds: [0], authors: [zapSender] }); - } - - return event.pubkey !== pubkey; - }) - ) + .then((events) => events.filter((event) => event.pubkey !== pubkey)) .then((events) => hydrateEvents({ events, store, signal })); if (!events.length) { return c.json([]); } - const zapSendersAndPosts = await store - .query(zapsRelatedFilter, opts) - .then((events) => hydrateEvents({ events, store, signal })); - const notifications = (await Promise.all(events.map((event) => { - const opts: RenderNotificationOpts = { viewerPubkey: pubkey }; - if (event.kind === 9735) { - const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1]; - const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString); - // By getting the pubkey from the zap request we guarantee who is the sender - // some clients don't put the P tag in the zap receipt... - const zapSender = zapRequest?.pubkey; - const zappedPost = event.tags.find(([name]) => name === 'e')?.[1]; - - const amountSchema = z.coerce.number().int().nonnegative().catch(0); - // amount in millisats - const amount = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1])); - - opts['zap'] = { - zapSender: zapSendersAndPosts.find(({ pubkey, kind }) => kind === 0 && pubkey === zapSender) ?? zapSender, - zappedPost: zapSendersAndPosts.find(({ id }) => id === zappedPost), - amount, - message: zapRequest?.content, - }; - } - return renderNotification(event, opts); + return renderNotification(event, { viewerPubkey: pubkey }); }))) .filter((notification) => notification && types.has(notification.type)); diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 4bbe6177..cee7c57e 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,3 +1,4 @@ +import TTLCache from '@isaacs/ttlcache'; import { NostrEvent, NostrFilter } from '@nostrify/nostrify'; import Debug from '@soapbox/stickynotes/debug'; import { z } from 'zod'; @@ -16,7 +17,6 @@ import { Storages } from '@/storages.ts'; import { bech32ToPubkey, Time } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; -import TTLCache from '@isaacs/ttlcache'; const debug = Debug('ditto:streaming'); diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index 2f2aef26..dcaec6ae 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -37,4 +37,10 @@ export interface DittoEvent extends NostrEvent { reported_notes?: DittoEvent[]; /** Admin event relationship. */ info?: DittoEvent; + /** Kind 1 being zapped */ + zapped?: DittoEvent; + /** Kind 0 or pubkey that zapped */ + zap_sender?: DittoEvent | string; + zap_amount?: number; + zap_message?: string; } diff --git a/src/storages/hydrate.test.ts b/src/storages/hydrate.test.ts index 3eb70bf8..1527f321 100644 --- a/src/storages/hydrate.test.ts +++ b/src/storages/hydrate.test.ts @@ -143,3 +143,37 @@ Deno.test('hydrateEvents(): report pubkey and post // kind 1984 --- WITHOUT stat }; assertEquals(reportEvent, expectedEvent); }); + +Deno.test('hydrateEvents(): zap sender, zap amount, zapped post // kind 9735 --- WITHOUT stats', async () => { + const relay = new MockRelay(); + await using db = await createTestDB(); + + const zapSender = await eventFixture('kind-0-jack'); + const zapReceipt = await eventFixture('kind-9735-jack-zap-patrick'); + const zappedPost = await eventFixture('kind-1-being-zapped'); + const zapReceiver = await eventFixture('kind-0-patrick'); + + // Save events to database + await relay.event(zapSender); + await relay.event(zapReceipt); + await relay.event(zappedPost); + await relay.event(zapReceiver); + + await hydrateEvents({ + events: [zapReceipt], + store: relay, + kysely: db.kysely, + }); + + const expectedEvent: DittoEvent = { + ...zapReceipt, + zap_sender: zapSender, + zapped: { + ...zappedPost, + author: zapReceiver, + }, + zap_amount: 5225000, // millisats + zap_message: '🫂', + }; + assertEquals(zapReceipt, expectedEvent); +}); diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 5948018b..a30608ca 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -1,12 +1,15 @@ import { NStore } from '@nostrify/nostrify'; import { Kysely } from 'kysely'; import { matchFilter } from 'nostr-tools'; +import { NSchema as n } from '@nostrify/nostrify'; +import { z } from 'zod'; import { DittoTables } from '@/db/DittoTables.ts'; import { Conf } from '@/config.ts'; import { type DittoEvent } from '@/interfaces/DittoEvent.ts'; import { findQuoteTag } from '@/utils/tags.ts'; import { findQuoteInContent } from '@/utils/note.ts'; +import { getAmount } from '@/utils/bolt11.ts'; import { Storages } from '@/storages.ts'; interface HydrateOpts { @@ -58,6 +61,14 @@ async function hydrateEvents(opts: HydrateOpts): Promise { cache.push(event); } + for (const event of await gatherZapped({ events: cache, store, signal })) { + cache.push(event); + } + + for (const event of await gatherZapSender({ events: cache, store, signal })) { + cache.push(event); + } + const stats = { authors: await gatherAuthorStats(cache, kysely as Kysely), events: await gatherEventStats(cache, kysely as Kysely), @@ -130,6 +141,29 @@ export function assembleEvents( event.reported_notes = reportedEvents; } + if (event.kind === 9735) { + const amountSchema = z.coerce.number().int().nonnegative().catch(0); + // amount in millisats + const amount = amountSchema.parse(getAmount(event?.tags.find(([name]) => name === 'bolt11')?.[1])); + event.zap_amount = amount; + + const id = event.tags.find(([name]) => name === 'e')?.[1]; + if (id) { + event.zapped = b.find((e) => matchFilter({ kinds: [1], ids: [id] }, e)); + } + + const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1]; + const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString); + // By getting the pubkey from the zap request we guarantee who is the sender + // some clients don't put the P tag in the zap receipt... + const zapSender = zapRequest?.pubkey; + if (zapSender) { + event.zap_sender = b.find((e) => matchFilter({ kinds: [0], authors: [zapSender] }, e)) ?? zapSender; + } + + event.zap_message = zapRequest?.content ?? ''; + } + event.author_stats = stats.authors.find((stats) => stats.pubkey === event.pubkey); event.event_stats = eventStats.find((stats) => stats.event_id === event.id); } @@ -196,7 +230,13 @@ function gatherQuotes({ events, store, signal }: HydrateOpts): Promise { - const pubkeys = new Set(events.map((event) => event.pubkey)); + const pubkeys = new Set(events.map((event) => { + if (event.kind === 9735) { + const pubkey = event.tags.find(([name]) => name === 'p')?.[1]; + if (pubkey) return pubkey; + } + return event.pubkey; + })); return store.query( [{ kinds: [0], authors: [...pubkeys], limit: pubkeys.size }], @@ -277,6 +317,48 @@ function gatherReportedProfiles({ events, store, signal }: HydrateOpts): Promise ); } +/** Collect events being zapped. */ +function gatherZapped({ events, store, signal }: HydrateOpts): Promise { + const ids = new Set(); + + for (const event of events) { + if (event.kind === 9735) { + const id = event.tags.find(([name]) => name === 'e')?.[1]; + if (id) { + ids.add(id); + } + } + } + + return store.query( + [{ ids: [...ids], limit: ids.size }], + { signal }, + ); +} + +/** Collect author that zapped. */ +function gatherZapSender({ events, store, signal }: HydrateOpts): Promise { + const pubkeys = new Set(); + + for (const event of events) { + if (event.kind === 9735) { + const zapRequestString = event?.tags?.find(([name]) => name === 'description')?.[1]; + const zapRequest = n.json().pipe(n.event()).optional().catch(undefined).parse(zapRequestString); + // By getting the pubkey from the zap request we guarantee who is the sender + // some clients don't put the P tag in the zap receipt... + const zapSender = zapRequest?.pubkey; + if (zapSender) { + pubkeys.add(zapSender); + } + } + } + + return store.query( + [{ kinds: [0], limit: pubkeys.size }], + { signal }, + ); +} + /** Collect author stats from the events. */ async function gatherAuthorStats( events: DittoEvent[], diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index f2438ad2..973a7a6d 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -6,14 +6,8 @@ import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { nostrDate } from '@/utils.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -export interface RenderNotificationOpts { +interface RenderNotificationOpts { viewerPubkey: string; - zap?: { - zapSender?: NostrEvent | NostrEvent['pubkey']; // kind 0 or pubkey - zappedPost?: NostrEvent; - amount?: number; - message?: string; - }; } function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { @@ -120,23 +114,23 @@ async function renderNameGrant(event: DittoEvent) { } async function renderZap(event: DittoEvent, opts: RenderNotificationOpts) { - if (!opts.zap?.zapSender) return; + if (!event.zap_sender) return; - const { amount = 0, message = '' } = opts.zap; - if (amount < 1) return; + const { zap_amount = 0, zap_message = '' } = event; + if (zap_amount < 1) return; - const account = typeof opts.zap.zapSender !== 'string' - ? await renderAccount(opts.zap.zapSender) - : await accountFromPubkey(opts.zap.zapSender); + const account = typeof event.zap_sender !== 'string' + ? await renderAccount(event.zap_sender) + : await accountFromPubkey(event.zap_sender); return { id: notificationId(event), type: 'ditto:zap', - amount, - message, + amount: zap_amount, + message: zap_message, created_at: nostrDate(event.created_at).toISOString(), account, - ...(opts.zap?.zappedPost ? { status: await renderStatus(opts.zap?.zappedPost, opts) } : {}), + ...(event.zapped ? { status: await renderStatus(event.zapped, opts) } : {}), }; }