diff --git a/src/app.ts b/src/app.ts index 38ca4dc..80e260e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -62,6 +62,7 @@ import { favouriteController, favouritedByController, pinController, + quotesController, rebloggedByController, reblogStatusController, statusController, @@ -189,6 +190,8 @@ app.post('/api/v1/statuses/:id{[0-9a-f]{64}}/unreblog', requireSigner, unreblogS app.post('/api/v1/statuses', requireSigner, createStatusController); app.delete('/api/v1/statuses/:id{[0-9a-f]{64}}', requireSigner, deleteStatusController); +app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/quotes', quotesController); + app.post('/api/v1/media', mediaController); app.post('/api/v2/media', mediaController); diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index ad21381..4666c34 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -14,7 +14,7 @@ import { renderEventAccounts } from '@/views.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents, purifyEvent } from '@/storages/hydrate.ts'; -import { createEvent, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; +import { createEvent, paginated, paginationSchema, parseBody, updateListEvent } from '@/utils/api.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { lookupPubkey } from '@/utils/lookup.ts'; import { addTag, deleteTag } from '@/utils/tags.ts'; @@ -322,6 +322,33 @@ const rebloggedByController: AppController = (c) => { return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]); }; +const quotesController: AppController = async (c) => { + const id = c.req.param('id'); + const params = paginationSchema.parse(c.req.query()); + const store = await Storages.db(); + + const [event] = await store.query([{ ids: [id], kinds: [1] }]); + if (!event) { + return c.json({ error: 'Event not found.' }, 404); + } + + const quotes = await store + .query([{ kinds: [1], '#q': [event.id], ...params }]) + .then((events) => hydrateEvents({ events, store })); + + const viewerPubkey = await c.get('signer')?.getPublicKey(); + + const statuses = await Promise.all( + quotes.map((event) => renderStatus(event, { viewerPubkey })), + ); + + if (!statuses.length) { + return c.json([]); + } + + return paginated(c, quotes, statuses); +}; + /** https://docs.joinmastodon.org/methods/statuses/#bookmark */ const bookmarkController: AppController = async (c) => { const pubkey = await c.get('signer')?.getPublicKey()!; @@ -487,6 +514,7 @@ export { favouriteController, favouritedByController, pinController, + quotesController, rebloggedByController, reblogStatusController, statusController, diff --git a/src/db/DittoTables.ts b/src/db/DittoTables.ts index 65bc426..d9e320d 100644 --- a/src/db/DittoTables.ts +++ b/src/db/DittoTables.ts @@ -21,6 +21,7 @@ interface EventStatsRow { replies_count: number; reposts_count: number; reactions_count: number; + quotes_count: number; reactions: string; } diff --git a/src/db/migrations/024_event_stats_quotes_count.ts b/src/db/migrations/024_event_stats_quotes_count.ts new file mode 100644 index 0000000..f62baf5 --- /dev/null +++ b/src/db/migrations/024_event_stats_quotes_count.ts @@ -0,0 +1,12 @@ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + await db.schema + .alterTable('event_stats') + .addColumn('quotes_count', 'integer', (col) => col.notNull().defaultTo(0)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('event_stats').dropColumn('quotes_count').execute(); +} diff --git a/src/interfaces/DittoEvent.ts b/src/interfaces/DittoEvent.ts index b9f95e4..6f3e1d2 100644 --- a/src/interfaces/DittoEvent.ts +++ b/src/interfaces/DittoEvent.ts @@ -11,6 +11,7 @@ export interface AuthorStats { export interface EventStats { replies_count: number; reposts_count: number; + quotes_count: number; reactions: Record; } diff --git a/src/storages/hydrate.ts b/src/storages/hydrate.ts index 1f56590..f7049c9 100644 --- a/src/storages/hydrate.ts +++ b/src/storages/hydrate.ts @@ -297,6 +297,7 @@ async function gatherEventStats(events: DittoEvent[]): Promise, event: NostrEvent, x: number): Promise { await updateAuthorStats(kysely, event.pubkey, ({ notes_count }) => ({ notes_count: Math.max(0, notes_count + x) })); - const inReplyToId = findReplyTag(event.tags)?.[1]; - if (inReplyToId) { + const replyId = findReplyTag(event.tags)?.[1]; + const quoteId = findQuoteTag(event.tags)?.[1]; + + if (replyId) { await updateEventStats( kysely, - inReplyToId, + replyId, ({ replies_count }) => ({ replies_count: Math.max(0, replies_count + x) }), ); } + + if (quoteId) { + await updateEventStats( + kysely, + quoteId, + ({ quotes_count }) => ({ quotes_count: Math.max(0, quotes_count + x) }), + ); + } } /** Update stats for kind 3 event. */ @@ -208,6 +218,7 @@ export async function updateEventStats( replies_count: 0, reposts_count: 0, reactions_count: 0, + quotes_count: 0, reactions: '{}', }; diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index bf71b01..f7c9b54 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -125,6 +125,7 @@ async function renderStatus(event: DittoEvent, opts: RenderStatusOpts): Promise< pleroma: { emoji_reactions: reactions, expires_at: !isNaN(expiresAt.getTime()) ? expiresAt.toISOString() : undefined, + quotes_count: event.event_stats?.quotes_count ?? 0, }, }; }