diff --git a/fixtures/events/event-0-the-one-who-quote-repost.json b/fixtures/events/event-0-the-one-who-quote-repost.json new file mode 100644 index 0000000..3aabeeb --- /dev/null +++ b/fixtures/events/event-0-the-one-who-quote-repost.json @@ -0,0 +1,9 @@ +{ + "id": "6bc9ca44feb5a261841873def54a81cc328737391dc10f7eada31173a399517d", + "pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4", + "created_at": 1712851917, + "kind": 0, + "tags": [], + "content": "{\"name\":\"patrickReiis\",\"picture\":\"https://void.cat/d/EMs8Qdn5wsAMrZ5T9T44sz.webp\"}", + "sig": "cedbd2585c18c9ee8cbafa4e3b1fefbe68cc15deeabcb0519791c6d715f92d1439ca9ac7584185a94d521709f9023fcbafab47a074a7ce8a247d3ce4dfce8af3" +} \ No newline at end of file diff --git a/fixtures/events/event-1-quote-repost.json b/fixtures/events/event-1-quote-repost.json new file mode 100644 index 0000000..e9d94e9 --- /dev/null +++ b/fixtures/events/event-1-quote-repost.json @@ -0,0 +1,15 @@ +{ + "id": "e0c2b45143717d62f85880aa7e26f2c3f4b10ada9ef547ae2479cfdd94ea2ce6", + "pubkey": "47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4", + "created_at": 1713217672, + "kind": 1, + "tags": [ + [ + "q", + "826b28986cbe8196faaddb502bbcfb9d5521981d24e858ad1924a178e18f570f", + "wss://relay.mostr.pub/" + ] + ], + "content": "I like this lottery.\nnostr:nevent1qqsgy6egnpktaqvkl2kak5pthnae64fpnqwjf6zc45vjfgtcux84wrcpzemhxue69uhhyetvv9ujumt0wd68ytnsw43z7q3q08pv4cg5ag52nq082kd5leu9ffrn2gdg6g4xdwatn73y36uzplmqxpqqqqqqzrvwgz7", + "sig": "5a40475e719ad4cf98dd685a268158995c25050057632564d38789ce39a66e9d34b2d4ec9bef650b60bcfe8106415385f28ba291e168a1d02e32e092b8b86615" +} diff --git a/fixtures/events/event-1-that-will-be-quote-reposted.json b/fixtures/events/event-1-that-will-be-quote-reposted.json new file mode 100644 index 0000000..f9ad396 --- /dev/null +++ b/fixtures/events/event-1-that-will-be-quote-reposted.json @@ -0,0 +1,27 @@ +{ + "id": "826b28986cbe8196faaddb502bbcfb9d5521981d24e858ad1924a178e18f570f", + "pubkey": "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6", + "created_at": 1711675519, + "kind": 1, + "tags": [ + [ + "zap", + "79c2cae114ea28a981e7559b4fe7854a473521a8d22a66bbab9fa248eb820ff6", + "wss://relay.mostr.pub", + "0.915" + ], + [ + "zap", + "6be38f8c63df7dbf84db7ec4a6e6fbbd8d19dca3b980efad18585c46f04b26f9", + "wss://relay.mostr.pub", + "0.085" + ], + [ + "proxy", + "https://gleasonator.com/objects/66216159-a709-431b-81e9-e4e1f86e20e4", + "activitypub" + ] + ], + "content": "The Bitcoin Lottery is free to play, and you can win millions! Unlimited tries!\n\nJust guess 12 words mnemonic seed phrase words.", + "sig": "b76264f9a7ec0860a9dd3b72f94e81ed6c0d848eee2bc5cc89b78b1cb1b4e00243f0f354c0185824fe16eb16cfcab511275388b6acd29e0d05d97dea1564d5be" +} \ No newline at end of file diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 852c2b6..7762783 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -163,7 +163,7 @@ const accountStatusesController: AppController = async (c) => { return events; }); - const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); + const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))); return paginated(c, events, statuses); }; @@ -310,7 +310,7 @@ const favouritesController: AppController = async (c) => { hydrateEvents({ events, relations: ['author', 'event_stats', 'author_stats'], storage: eventsDB, signal }) ); - const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey')))); + const statuses = await Promise.all(events1.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))); return paginated(c, events1, statuses); }; diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index ec85192..fba315a 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -52,7 +52,7 @@ const searchController: AppController = async (c) => { Promise.all( results .filter((event) => event.kind === 1) - .map((event) => renderStatus(event, c.get('pubkey'))), + .map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })), ), ]); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 7e4a4c3..45f1209 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -40,12 +40,12 @@ const statusController: AppController = async (c) => { const event = await getEvent(id, { kind: 1, - relations: ['author', 'event_stats', 'author_stats'], + relations: ['author', 'event_stats', 'author_stats', 'quote_repost'], signal: AbortSignal.timeout(1500), }); if (event) { - return c.json(await renderStatus(event, c.get('pubkey'))); + return c.json(await renderStatus(event, { viewerPubkey: c.get('pubkey') })); } return c.json({ error: 'Event not found.' }, 404); @@ -130,7 +130,7 @@ const createStatusController: AppController = async (c) => { }, c); const author = await getAuthor(event.pubkey); - return c.json(await renderStatus({ ...event, author }, c.get('pubkey'))); + return c.json(await renderStatus({ ...event, author }, { viewerPubkey: c.get('pubkey') })); }; const deleteStatusController: AppController = async (c) => { @@ -147,7 +147,7 @@ const deleteStatusController: AppController = async (c) => { }, c); const author = await getAuthor(event.pubkey); - return c.json(await renderStatus({ ...event, author }, pubkey)); + return c.json(await renderStatus({ ...event, author }, { viewerPubkey: pubkey })); } else { return c.json({ error: 'Unauthorized' }, 403); } @@ -161,7 +161,7 @@ const contextController: AppController = async (c) => { const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); async function renderStatuses(events: NostrEvent[]) { - const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); + const statuses = await Promise.all(events.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') }))); return statuses.filter(Boolean); } @@ -191,7 +191,7 @@ const favouriteController: AppController = async (c) => { ], }, c); - const status = await renderStatus(target, c.get('pubkey')); + const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') }); if (status) { status.favourited = true; @@ -259,7 +259,7 @@ const unreblogStatusController: AppController = async (c) => { tags: [['e', repostedEvent.id]], }, c); - return c.json(await renderStatus(event)); + return c.json(await renderStatus(event, {})); }; const rebloggedByController: AppController = (c) => { @@ -285,7 +285,7 @@ const bookmarkController: AppController = async (c) => { c, ); - const status = await renderStatus(event, pubkey); + const status = await renderStatus(event, { viewerPubkey: pubkey }); if (status) { status.bookmarked = true; } @@ -312,7 +312,7 @@ const unbookmarkController: AppController = async (c) => { c, ); - const status = await renderStatus(event, pubkey); + const status = await renderStatus(event, { viewerPubkey: pubkey }); if (status) { status.bookmarked = false; } @@ -339,7 +339,7 @@ const pinController: AppController = async (c) => { c, ); - const status = await renderStatus(event, pubkey); + const status = await renderStatus(event, { viewerPubkey: pubkey }); if (status) { status.pinned = true; } @@ -368,7 +368,7 @@ const unpinController: AppController = async (c) => { c, ); - const status = await renderStatus(event, pubkey); + const status = await renderStatus(event, { viewerPubkey: pubkey }); if (status) { status.pinned = false; } @@ -411,7 +411,7 @@ const zapController: AppController = async (c) => { ], }, c); - const status = await renderStatus(target, c.get('pubkey')); + const status = await renderStatus(target, { viewerPubkey: c.get('pubkey') }); status.zapped = true; return c.json(status); diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 643ac01..8239645 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -79,7 +79,7 @@ const streamingController: AppController = (c) => { } continue; } - const status = await renderStatus(event, pubkey); + const status = await renderStatus(event, { viewerPubkey: pubkey }); if (status) { send('update', status); } diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index c54abd4..467b115 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -51,7 +51,7 @@ async function renderStatuses(c: AppContext, filters: NostrFilter[]) { .then((events) => hydrateEvents({ events, - relations: ['author', 'author_stats', 'event_stats', 'repost'], + relations: ['author', 'author_stats', 'event_stats', 'repost', 'quote_repost'], storage: eventsDB, signal, }) @@ -65,7 +65,7 @@ async function renderStatuses(c: AppContext, filters: NostrFilter[]) { if (event.kind === 6) { return renderReblog(event); } - return renderStatus(event, c.get('pubkey')); + return renderStatus(event, { viewerPubkey: c.get('pubkey') }); }))).filter((boolean) => boolean); if (!statuses.length) { diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index ca38a42..c1d85ae 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -23,4 +23,5 @@ export interface DittoEvent extends NostrEvent { d_author?: DittoEvent; user?: DittoEvent; repost?: NostrEvent; + quote_repost?: NostrEvent; } diff --git a/src/storages/hydrate.test.ts b/src/storages/hydrate.test.ts index 671e357..550693f 100644 --- a/src/storages/hydrate.test.ts +++ b/src/storages/hydrate.test.ts @@ -1,69 +1,162 @@ import { assertEquals } from '@/deps-test.ts'; -import { EventsDB } from '@/storages/events-db.ts'; -import { db } from '@/db.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; +import { NCache } from 'jsr:@nostrify/nostrify'; import event0 from '~/fixtures/events/event-0.json' with { type: 'json' }; import event0madePost from '~/fixtures/events/event-0-the-one-who-post-and-users-repost.json' with { type: 'json' }; import event0madeRepost from '~/fixtures/events/event-0-the-one-who-repost.json' with { type: 'json' }; +import event0madeQuoteRepost from '~/fixtures/events/event-0-the-one-who-quote-repost.json' with { type: 'json' }; import event1 from '~/fixtures/events/event-1.json' with { type: 'json' }; +import event1quoteRepost from '~/fixtures/events/event-1-quote-repost.json' with { type: 'json' }; +import event1willBeQuoteReposted from '~/fixtures/events/event-1-that-will-be-quote-reposted.json' with { + type: 'json', +}; import event1reposted from '~/fixtures/events/event-1-reposted.json' with { type: 'json' }; import event6 from '~/fixtures/events/event-6.json' with { type: 'json' }; import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -const eventsDB = new EventsDB(db); - Deno.test('hydrate author', async () => { - // Save events to database - await eventsDB.event(event0); - await eventsDB.event(event1); + const db = new NCache({ max: 100 }); - assertEquals((event1 as DittoEvent).author, undefined, "Event hasn't been hydrated yet"); + const event0copy = structuredClone(event0); + const event1copy = structuredClone(event1); + + // Save events to database + await db.event(event0copy); + await db.event(event1copy); + + assertEquals((event1copy as DittoEvent).author, undefined, "Event hasn't been hydrated yet"); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1000); await hydrateEvents({ - events: [event1], + events: [event1copy], relations: ['author'], - storage: eventsDB, + storage: db, signal: controller.signal, }); - const expectedEvent = { ...event1, author: event0 }; - assertEquals(event1, expectedEvent); + const expectedEvent = { ...event1copy, author: event0copy }; + assertEquals(event1copy, expectedEvent); - await eventsDB.remove([{ kinds: [0, 1] }]); - assertEquals(await eventsDB.query([{ kinds: [0, 1] }]), []); + await db.remove([{ kinds: [0, 1] }]); + assertEquals(await db.query([{ kinds: [0, 1] }]), []); clearTimeout(timeoutId); }); Deno.test('hydrate repost', async () => { - // Save events to database - await eventsDB.event(event0madePost); - await eventsDB.event(event0madeRepost); - await eventsDB.event(event1reposted); - await eventsDB.event(event6); + const db = new NCache({ max: 100 }); - assertEquals((event6 as DittoEvent).author, undefined, "Event hasn't been hydrated author yet"); - assertEquals((event6 as DittoEvent).repost, undefined, "Event hasn't been hydrated repost yet"); + const event0madePostCopy = structuredClone(event0madePost); + const event0madeRepostCopy = structuredClone(event0madeRepost); + const event1repostedCopy = structuredClone(event1reposted); + const event6copy = structuredClone(event6); + + // Save events to database + await db.event(event0madePostCopy); + await db.event(event0madeRepostCopy); + await db.event(event1repostedCopy); + await db.event(event6copy); + + assertEquals((event6copy as DittoEvent).author, undefined, "Event hasn't been hydrated author yet"); + assertEquals((event6copy as DittoEvent).repost, undefined, "Event hasn't been hydrated repost yet"); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 1000); await hydrateEvents({ - events: [event6], + events: [event6copy], relations: ['repost', 'author'], - storage: eventsDB, + storage: db, signal: controller.signal, }); - const expectedEvent6 = { ...event6, author: event0madeRepost, repost: { ...event1reposted, author: event0madePost } }; - assertEquals(event6, expectedEvent6); + const expectedEvent6 = { + ...event6copy, + author: event0madeRepostCopy, + repost: { ...event1repostedCopy, author: event0madePostCopy }, + }; + assertEquals(event6copy, expectedEvent6); - await eventsDB.remove([{ kinds: [0, 1, 6] }]); - assertEquals(await eventsDB.query([{ kinds: [0, 1, 6] }]), []); + await db.remove([{ kinds: [0, 1, 6] }]); + assertEquals(await db.query([{ kinds: [0, 1, 6] }]), []); + + clearTimeout(timeoutId); +}); + +Deno.test('hydrate quote repost with hydrate author', async () => { + const db = new NCache({ max: 100 }); + + const event0madeQuoteRepostCopy = structuredClone(event0madeQuoteRepost); + const event0copy = structuredClone(event0); + const event1quoteRepostCopy = structuredClone(event1quoteRepost); + const event1willBeQuoteRepostedCopy = structuredClone(event1willBeQuoteReposted); + + // Save events to database + await db.event(event0madeQuoteRepostCopy); + await db.event(event0copy); + await db.event(event1quoteRepostCopy); + await db.event(event1willBeQuoteRepostedCopy); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1000); + + await hydrateEvents({ + events: [event1quoteRepostCopy], + relations: ['author', 'quote_repost'], // if author is called first the performance will be better + storage: db, + signal: controller.signal, + }); + + const expectedEvent1quoteRepost = { + ...event1quoteRepostCopy, + author: event0madeQuoteRepostCopy, + quote_repost: { ...event1willBeQuoteRepostedCopy, author: event0copy }, + }; + + assertEquals(event1quoteRepostCopy, expectedEvent1quoteRepost); + + await db.remove([{ kinds: [0, 1] }]); + assertEquals(await db.query([{ kinds: [0, 1] }]), []); + + clearTimeout(timeoutId); +}); + +Deno.test('hydrate quote repost WITHOUT hydrate author', async () => { + const db = new NCache({ max: 100 }); + + const event0madeQuoteRepostCopy = structuredClone(event0madeQuoteRepost); + const event0copy = structuredClone(event0); + const event1quoteRepostCopy = structuredClone(event1quoteRepost); + const event1willBeQuoteRepostedCopy = structuredClone(event1willBeQuoteReposted); + + // Save events to database + await db.event(event0madeQuoteRepostCopy); + await db.event(event0copy); + await db.event(event1quoteRepostCopy); + await db.event(event1willBeQuoteRepostedCopy); + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1000); + + await hydrateEvents({ + events: [event1quoteRepostCopy, event1willBeQuoteRepostedCopy], + relations: ['quote_repost'], + storage: db, + signal: controller.signal, + }); + + const expectedEvent1quoteRepost = { + ...event1quoteRepost, + quote_repost: { ...event1willBeQuoteRepostedCopy, author: event0copy }, + }; + + assertEquals(event1quoteRepostCopy, expectedEvent1quoteRepost); + + await db.remove([{ kinds: [0, 1] }]); + assertEquals(await db.query([{ kinds: [0, 1] }]), []); clearTimeout(timeoutId); }); diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 7e2b1da..3fbce5f 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -36,6 +36,9 @@ async function hydrateEvents(opts: HydrateEventOpts): Promise { case 'repost': await hydrateRepostEvents({ events, storage, signal }); break; + case 'quote_repost': + await hydrateQuoteRepostEvents({ events, storage, signal }); + break; } } @@ -126,7 +129,7 @@ async function hydrateRepostEvents(opts: Omit): P } return event.id; }), - }]); + }], { signal }); for (const event of events) { if (event.kind === 6) { @@ -144,6 +147,49 @@ async function hydrateRepostEvents(opts: Omit): P return events; } +async function hydrateQuoteRepostEvents(opts: Omit): Promise { + const { events, storage, signal } = opts; + + const results = await storage.query([{ + kinds: [1], + ids: events.map((event) => { + if (event.kind === 1) { + const originalPostId = event.tags.find(([name]) => name === 'q')?.[1]; + if (!originalPostId) return event.id; + else return originalPostId; + } + return event.id; + }), + }], { signal }); + + for (const event of events) { + if (event.kind === 1) { + const originalPostId = event.tags.find(([name]) => name === 'q')?.[1]; + if (!originalPostId) continue; + + const originalPostEvent = events.find((event) => event.id === originalPostId); + if (!originalPostEvent) { + const originalPostEvent = results.find((event) => event.id === originalPostId); + if (!originalPostEvent) continue; + + await hydrateEvents({ events: [originalPostEvent], storage: storage, signal: signal, relations: ['author'] }); + + event.quote_repost = originalPostEvent; + continue; + } + if (!originalPostEvent.author) { + await hydrateEvents({ events: [originalPostEvent], storage: storage, signal: signal, relations: ['author'] }); + + event.quote_repost = originalPostEvent; + continue; + } + event.quote_repost = originalPostEvent; + } + } + + return events; +} + /** Return a normalized event without any non-standard keys. */ function purifyEvent(event: NostrEvent): NostrEvent { return { diff --git a/src/views.ts b/src/views.ts index f9e84fc..0ccab9f 100644 --- a/src/views.ts +++ b/src/views.ts @@ -62,7 +62,7 @@ async function renderStatuses(c: AppContext, ids: string[], signal = AbortSignal const sortedEvents = [...events].sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id)); const statuses = await Promise.all( - sortedEvents.map((event) => renderStatus(event, c.get('pubkey'))), + sortedEvents.map((event) => renderStatus(event, { viewerPubkey: c.get('pubkey') })), ); // TODO: pagination with min_id and max_id based on the order of `ids`. diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index 8ec7bf4..cb91dec 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -13,7 +13,7 @@ function renderNotification(event: NostrEvent, viewerPubkey?: string) { async function renderNotificationMention(event: NostrEvent, viewerPubkey?: string) { const author = await getAuthor(event.pubkey); - const status = await renderStatus({ ...event, author }, viewerPubkey); + const status = await renderStatus({ ...event, author }, { viewerPubkey: viewerPubkey }); if (!status) return; return { diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 19ce381..989bdef 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -14,7 +14,16 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; -async function renderStatus(event: DittoEvent, viewerPubkey?: string) { +interface statusOpts { + viewerPubkey?: string; + depth?: number; +} + +async function renderStatus(event: DittoEvent, opts: statusOpts): Promise { + const { viewerPubkey, depth = 1 } = opts; + + if (depth > 2 || depth < 0) return null; + const note = nip19.noteEncode(event.id); const account = event.author @@ -67,6 +76,8 @@ async function renderStatus(event: DittoEvent, viewerPubkey?: string) { const media = [...mediaLinks, ...mediaTags]; + const quoteStatus = !event.quote_repost ? null : await renderStatus(event.quote_repost, { depth: depth + 1 }); + return { id: event.id, account, @@ -94,6 +105,8 @@ async function renderStatus(event: DittoEvent, viewerPubkey?: string) { tags: [], emojis: renderEmojis(event), poll: null, + quote: quoteStatus, + quote_id: quoteStatus ? quoteStatus.id : null, uri: Conf.external(note), url: Conf.external(note), zapped: Boolean(zapEvent), @@ -108,7 +121,7 @@ async function renderReblog(event: DittoEvent) { if (!event.repost) return; - const reblog = await renderStatus(event.repost); + const reblog = await renderStatus(event.repost, {}); reblog.reblogged = true; return {