From 220f16feb8d5272d71d73db6a9c99d1d56b6bcc9 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Thu, 2 May 2024 14:36:28 -0500 Subject: [PATCH] Notifications: render notifications for kinds 1, 6, and 7 events --- src/controllers/api/notifications.ts | 42 ++++++++++---- src/interfaces/DittoEvent.ts | 1 + src/storages/hydrate.ts | 23 ++++++++ src/views/mastodon/notifications.ts | 83 ++++++++++++++++++++++++---- src/views/mastodon/statuses.ts | 6 +- 5 files changed, 130 insertions(+), 25 deletions(-) diff --git a/src/controllers/api/notifications.ts b/src/controllers/api/notifications.ts index 7820dd8..b2fa15e 100644 --- a/src/controllers/api/notifications.ts +++ b/src/controllers/api/notifications.ts @@ -1,20 +1,40 @@ -import { type AppController } from '@/app.ts'; -import { Storages } from '@/storages.ts'; +import { NostrFilter } from '@nostrify/nostrify'; + +import { AppContext, AppController } from '@/app.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; import { paginated, paginationSchema } from '@/utils/api.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; -const notificationsController: AppController = async (c) => { +const notificationsController: AppController = (c) => { const pubkey = c.get('pubkey')!; const { since, until } = paginationSchema.parse(c.req.query()); - const { signal } = c.req.raw; - const events = await Storages.db.query( - [{ kinds: [1], '#p': [pubkey], since, until }], - { signal }, - ); - - const statuses = await Promise.all(events.map((event) => renderNotification(event, pubkey))); - return paginated(c, events, statuses); + return renderNotifications(c, [{ kinds: [1, 6, 7], '#p': [pubkey], since, until }]); }; +async function renderNotifications(c: AppContext, filters: NostrFilter[]) { + const store = c.get('store'); + const pubkey = c.get('pubkey')!; + const { signal } = c.req.raw; + + const events = await store + .query(filters, { signal }) + .then((events) => events.filter((event) => event.pubkey !== pubkey)) + .then((events) => hydrateEvents({ events, storage: store, signal })); + + if (!events.length) { + return c.json([]); + } + + const notifications = (await Promise + .all(events.map((event) => renderNotification(event, { viewerPubkey: pubkey })))) + .filter(Boolean); + + if (!notifications.length) { + return c.json([]); + } + + return paginated(c, events, notifications); +} + export { notificationsController }; diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index 08879f8..2ef0bb2 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -24,4 +24,5 @@ export interface DittoEvent extends NostrEvent { user?: DittoEvent; repost?: DittoEvent; quote_repost?: DittoEvent; + reacted?: DittoEvent; } diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index dbe277a..716f251 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -26,6 +26,10 @@ async function hydrateEvents(opts: HydrateOpts): Promise { cache.push(event); } + for (const event of await gatherReacted({ events: cache, storage, signal })) { + cache.push(event); + } + for (const event of await gatherQuotes({ events: cache, storage, signal })) { cache.push(event); } @@ -105,6 +109,25 @@ function gatherReposts({ events, storage, signal }: HydrateOpts): Promise { + const ids = new Set(); + + for (const event of events) { + if (event.kind === 7) { + const id = event.tags.find(([name]) => name === 'e')?.[1]; + if (id) { + ids.add(id); + } + } + } + + return storage.query( + [{ ids: [...ids], limit: ids.size }], + { signal }, + ); +} + /** Collect quotes from the events. */ function gatherQuotes({ events, storage, signal }: HydrateOpts): Promise { const ids = new Set(); diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index a153140..266b77b 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -1,19 +1,34 @@ -import { NostrEvent } from '@nostrify/nostrify'; -import { getAuthor } from '@/queries.ts'; +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; import { nostrDate } from '@/utils.ts'; -import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; -function renderNotification(event: NostrEvent, viewerPubkey?: string) { - switch (event.kind) { - case 1: - return renderNotificationMention(event, viewerPubkey); +interface RenderNotificationOpts { + viewerPubkey: string; +} + +function renderNotification(event: DittoEvent, opts: RenderNotificationOpts) { + const mentioned = !!event.tags.find(([name, value]) => name === 'p' && value === opts.viewerPubkey); + + if (event.kind === 1 && mentioned) { + return renderMention(event, opts); + } + + if (event.kind === 6) { + return renderReblog(event, opts); + } + + if (event.kind === 7 && event.content === '+') { + return renderFavourite(event, opts); + } + + if (event.kind === 7) { + return renderReaction(event, opts); } } -async function renderNotificationMention(event: NostrEvent, viewerPubkey?: string) { - const author = await getAuthor(event.pubkey); - const status = await renderStatus({ ...event, author }, { viewerPubkey: viewerPubkey }); +async function renderMention(event: DittoEvent, opts: RenderNotificationOpts) { + const status = await renderStatus(event, opts); if (!status) return; return { @@ -25,4 +40,50 @@ async function renderNotificationMention(event: NostrEvent, viewerPubkey?: strin }; } -export { accountFromPubkey, renderNotification }; +async function renderReblog(event: DittoEvent, opts: RenderNotificationOpts) { + if (event.repost?.kind !== 1) return; + const status = await renderStatus(event.repost, opts); + if (!status) return; + const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + + return { + id: event.id, + type: 'reblog', + created_at: nostrDate(event.created_at).toISOString(), + account, + status, + }; +} + +async function renderFavourite(event: DittoEvent, opts: RenderNotificationOpts) { + if (event.reacted?.kind !== 1) return; + const status = await renderStatus(event.reacted, opts); + if (!status) return; + const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + + return { + id: event.id, + type: 'favourite', + created_at: nostrDate(event.created_at).toISOString(), + account, + status, + }; +} + +async function renderReaction(event: DittoEvent, opts: RenderNotificationOpts) { + if (event.reacted?.kind !== 1) return; + const status = await renderStatus(event.reacted, opts); + if (!status) return; + const account = event.author ? await renderAccount(event.author) : accountFromPubkey(event.pubkey); + + return { + id: event.id, + type: 'pleroma:emoji_reaction', + emoji: event.content, + created_at: nostrDate(event.created_at).toISOString(), + account, + status, + }; +} + +export { renderNotification }; diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 683c667..21d380b 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -14,12 +14,12 @@ import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments. import { renderEmojis } from '@/views/mastodon/emojis.ts'; import { mediaDataSchema } from '@/schemas/nostr.ts'; -interface statusOpts { +interface RenderStatusOpts { viewerPubkey?: string; depth?: number; } -async function renderStatus(event: DittoEvent, opts: statusOpts): Promise { +async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise { const { viewerPubkey, depth = 1 } = opts; if (depth > 2 || depth < 0) return null; @@ -117,7 +117,7 @@ async function renderStatus(event: DittoEvent, opts: statusOpts): Promise { }; } -async function renderReblog(event: DittoEvent, opts: statusOpts) { +async function renderReblog(event: DittoEvent, opts: RenderStatusOpts) { const { viewerPubkey } = opts; if (!event.author) return;