From 729471d6927244c5b6e67d4970ceb5762a255ce1 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 2 Sep 2024 09:52:34 -0300 Subject: [PATCH 1/4] feat(notifications api): implement zap notification calls database for zap events --- src/controllers/api/notifications.ts | 51 ++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index 54e88edf..00ac9930 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -4,9 +4,10 @@ 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 } from '@/views/mastodon/notifications.ts'; +import { renderNotification, RenderNotificationOpts } from '@/views/mastodon/notifications.ts'; /** Set of known notification types across backends. */ const notificationTypes = new Set([ @@ -23,6 +24,7 @@ const notificationTypes = new Set([ 'severed_relationships', 'pleroma:emoji_reaction', 'ditto:name_grant', + 'ditto:zap', ]); const notificationsSchema = z.object({ @@ -67,6 +69,10 @@ const notificationsController: AppController = async (c) => { filters.push({ kinds: [30360], authors: [Conf.pubkey], '#p': [pubkey], ...params }); } + if (types.has('ditto:zap')) { + filters.push({ kinds: [9735], '#p': [pubkey] }); + } + return renderNotifications(filters, types, params, c); }; @@ -81,16 +87,55 @@ 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) => event.pubkey !== pubkey)) + .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) => hydrateEvents({ events, store, signal })); if (!events.length) { return c.json([]); } - const notifications = (await Promise.all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey })))) + 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); + }))) .filter((notification) => notification && types.has(notification.type)); if (!notifications.length) { From 96e99f38c4f4d1676e77e5a0953e682ba2f2e730 Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Mon, 2 Sep 2024 09:53:33 -0300 Subject: [PATCH 2/4] feat(views): render and return zap notification --- src/views/mastodon/notifications.ts | 35 +++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index 5fc20a2f..f2438ad2 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -1,13 +1,19 @@ import { NostrEvent } from '@nostrify/nostrify'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { Conf } from '@/config.ts'; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { nostrDate } from '@/utils.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -interface RenderNotificationOpts { +export 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) { @@ -32,6 +38,10 @@ function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { if (event.kind === 30360 && event.pubkey === Conf.pubkey) { return renderNameGrant(event); } + + if (event.kind === 9735) { + return renderZap(event, opts); + } } async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { @@ -109,6 +119,27 @@ async function renderNameGrant(event: DittoEvent) { }; } +async function renderZap(event: DittoEvent, opts: RenderNotificationOpts) { + if (!opts.zap?.zapSender) return; + + const { amount = 0, message = '' } = opts.zap; + if (amount < 1) return; + + const account = typeof opts.zap.zapSender !== 'string' + ? await renderAccount(opts.zap.zapSender) + : await accountFromPubkey(opts.zap.zapSender); + + return { + id: notificationId(event), + type: 'ditto:zap', + amount, + message, + created_at: nostrDate(event.created_at).toISOString(), + account, + ...(opts.zap?.zappedPost ? { status: await renderStatus(opts.zap?.zappedPost, opts) } : {}), + }; +} + /** This helps notifications be sorted in the correct order. */ function notificationId({ id, created_at }: NostrEvent): string { return `${created_at}-${id}`; From 6d9d2fd42a1fa39185c0178db16d431d43fa1cbb Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 3 Sep 2024 22:22:17 -0300 Subject: [PATCH 3/4] fix: get event id from max_id sometimes the 'max_id' format can come as `${created_at}-${id}` so if that's the case, we split by the - (minus) character --- src/schemas/pagination.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/schemas/pagination.ts b/src/schemas/pagination.ts index d7a6cf68..89e3c5f6 100644 --- a/src/schemas/pagination.ts +++ b/src/schemas/pagination.ts @@ -2,7 +2,10 @@ import { z } from 'zod'; /** Schema to parse pagination query params. */ export const paginationSchema = z.object({ - max_id: z.string().optional().catch(undefined), + max_id: z.string().transform((val) => { + if (!val.includes('-')) return val; + return val.split('-')[1]; + }).optional().catch(undefined), min_id: z.string().optional().catch(undefined), since: z.coerce.number().nonnegative().optional().catch(undefined), until: z.coerce.number().nonnegative().optional().catch(undefined), From 486dff83b98c01b5818c632916732ee27df9034e Mon Sep 17 00:00:00 2001 From: "P. Reis" Date: Tue, 3 Sep 2024 22:23:15 -0300 Subject: [PATCH 4/4] fix: pass parameters params in ditto:zap notification --- src/controllers/api/notifications.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index 00ac9930..864e54d4 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -70,7 +70,7 @@ const notificationsController: AppController = async (c) => { } if (types.has('ditto:zap')) { - filters.push({ kinds: [9735], '#p': [pubkey] }); + filters.push({ kinds: [9735], '#p': [pubkey], ...params }); } return renderNotifications(filters, types, params, c);