mirror of
https://github.com/aljazceru/ditto.git
synced 2026-01-17 12:24:24 +01:00
Merge branch 'zap-notification-streaming' into 'main'
feat: zap notification in streaming Closes #204 See merge request soapbox-pub/ditto!490
This commit is contained in:
@@ -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));
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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<DittoEvent[]> {
|
||||
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<DittoTables>),
|
||||
events: await gatherEventStats(cache, kysely as Kysely<DittoTables>),
|
||||
@@ -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<DittoEven
|
||||
|
||||
/** Collect authors from the events. */
|
||||
function gatherAuthors({ events, store, signal }: HydrateOpts): Promise<DittoEvent[]> {
|
||||
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<DittoEvent[]> {
|
||||
const ids = new Set<string>();
|
||||
|
||||
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<DittoEvent[]> {
|
||||
const pubkeys = new Set<string>();
|
||||
|
||||
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[],
|
||||
|
||||
@@ -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) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user