diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index 6e896571..bd86ac51 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -87,7 +87,6 @@ import { } from '@/controllers/api/pleroma.ts'; import { preferencesController } from '@/controllers/api/preferences.ts'; import { getSubscriptionController, pushSubscribeController } from '@/controllers/api/push.ts'; -import { deleteReactionController, reactionController, reactionsController } from '@/controllers/api/reactions.ts'; import { relayController } from '@/controllers/nostr/relay.ts'; import { adminReportController, @@ -150,6 +149,7 @@ import { logiMiddleware } from '@/middleware/logiMiddleware.ts'; import customEmojisRoute from '@/routes/customEmojisRoute.ts'; import dittoNamesRoute from '@/routes/dittoNamesRoute.ts'; import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts'; +import pleromaStatusesRoute from '@/routes/pleromaStatusesRoute.ts'; import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; export interface AppEnv extends DittoEnv { @@ -435,10 +435,7 @@ app.post('/api/v1/markers', userMiddleware({ verify: true }), updateMarkersContr app.get('/api/v1/push/subscription', userMiddleware(), getSubscriptionController); app.post('/api/v1/push/subscription', userMiddleware({ verify: true }), pushSubscribeController); -app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController); -app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', reactionsController); -app.put('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), reactionController); -app.delete('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), deleteReactionController); +app.route('/api/v1/pleroma/statuses', pleromaStatusesRoute); app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController); app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController); diff --git a/packages/ditto/controllers/api/instance.ts b/packages/ditto/controllers/api/instance.ts index a887fe4d..0e07f385 100644 --- a/packages/ditto/controllers/api/instance.ts +++ b/packages/ditto/controllers/api/instance.ts @@ -9,6 +9,7 @@ const features = [ 'exposable_reactions', 'mastodon_api', 'mastodon_api_streaming', + 'pleroma_custom_emoji_reactions', 'pleroma_emoji_reactions', 'quote_posting', 'v2_suggestions', diff --git a/packages/ditto/controllers/api/reactions.ts b/packages/ditto/controllers/api/reactions.ts deleted file mode 100644 index 74e499d4..00000000 --- a/packages/ditto/controllers/api/reactions.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { AppController } from '@/app.ts'; -import { DittoEvent } from '@/interfaces/DittoEvent.ts'; -import { hydrateEvents } from '@/storages/hydrate.ts'; -import { createEvent } from '@/utils/api.ts'; -import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; -import { renderStatus } from '@/views/mastodon/statuses.ts'; - -/** - * React to a status. - * https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji - */ -const reactionController: AppController = async (c) => { - const { relay, user } = c.var; - const id = c.req.param('id'); - const emoji = c.req.param('emoji'); - - if (!/^\p{RGI_Emoji}$/v.test(emoji)) { - return c.json({ error: 'Invalid emoji' }, 400); - } - - const [event] = await relay.query([{ kinds: [1, 20], ids: [id], limit: 1 }]); - - if (!event) { - return c.json({ error: 'Status not found' }, 404); - } - - await createEvent({ - kind: 7, - content: emoji, - created_at: Math.floor(Date.now() / 1000), - tags: [['e', id], ['p', event.pubkey]], - }, c); - - await hydrateEvents({ ...c.var, events: [event] }); - - const status = await renderStatus(relay, event, { viewerPubkey: await user!.signer.getPublicKey() }); - - return c.json(status); -}; - -/** - * Delete reactions to a status. - * https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji - */ -const deleteReactionController: AppController = async (c) => { - const { relay, user } = c.var; - - const id = c.req.param('id'); - const emoji = c.req.param('emoji'); - const pubkey = await user!.signer.getPublicKey(); - - if (!/^\p{RGI_Emoji}$/v.test(emoji)) { - return c.json({ error: 'Invalid emoji' }, 400); - } - - const [event] = await relay.query([ - { kinds: [1, 20], ids: [id], limit: 1 }, - ]); - - if (!event) { - return c.json({ error: 'Status not found' }, 404); - } - - const events = await relay.query([ - { kinds: [7], authors: [pubkey], '#e': [id] }, - ]); - - const tags = events - .filter((event) => event.content === emoji) - .map((event) => ['e', event.id]); - - await createEvent({ - kind: 5, - content: '', - created_at: Math.floor(Date.now() / 1000), - tags, - }, c); - - const status = renderStatus(relay, event, { viewerPubkey: pubkey }); - - return c.json(status); -}; - -/** - * Get an object of emoji to account mappings with accounts that reacted to the post. - * https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions - */ -const reactionsController: AppController = async (c) => { - const { relay, user } = c.var; - - const id = c.req.param('id'); - const pubkey = await user?.signer.getPublicKey(); - const emoji = c.req.param('emoji') as string | undefined; - - if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) { - return c.json({ error: 'Invalid emoji' }, 400); - } - - const events = await relay.query([{ kinds: [7], '#e': [id], limit: 100 }]) - .then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content))) - .then((events) => events.filter((event) => !emoji || event.content === emoji)) - .then((events) => hydrateEvents({ ...c.var, events })); - - /** Events grouped by emoji. */ - const byEmoji = events.reduce((acc, event) => { - const emoji = event.content; - acc[emoji] = acc[emoji] || []; - acc[emoji].push(event); - return acc; - }, {} as Record); - - const results = await Promise.all( - Object.entries(byEmoji).map(async ([name, events]) => { - return { - name, - count: events.length, - me: pubkey && events.some((event) => event.pubkey === pubkey), - accounts: await Promise.all( - events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), - ), - }; - }), - ); - - return c.json(results); -}; - -export { deleteReactionController, reactionController, reactionsController }; diff --git a/packages/ditto/controllers/api/statuses.ts b/packages/ditto/controllers/api/statuses.ts index 116ad2de..e80441b6 100644 --- a/packages/ditto/controllers/api/statuses.ts +++ b/packages/ditto/controllers/api/statuses.ts @@ -633,7 +633,7 @@ const zappedByController: AppController = async (c) => { const { db, relay } = c.var; const id = c.req.param('id'); - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const zaps = await db.kysely.selectFrom('event_zaps') .selectAll() diff --git a/packages/ditto/controllers/api/suggestions.ts b/packages/ditto/controllers/api/suggestions.ts index cb6a8206..e288ad7b 100644 --- a/packages/ditto/controllers/api/suggestions.ts +++ b/packages/ditto/controllers/api/suggestions.ts @@ -9,7 +9,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; export const suggestionsV1Controller: AppController = async (c) => { const { signal } = c.var; - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const suggestions = await renderV2Suggestions(c, { offset, limit }, signal); const accounts = suggestions.map(({ account }) => account); return paginatedList(c, { offset, limit }, accounts); @@ -17,7 +17,7 @@ export const suggestionsV1Controller: AppController = async (c) => { export const suggestionsV2Controller: AppController = async (c) => { const { signal } = c.var; - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const suggestions = await renderV2Suggestions(c, { offset, limit }, signal); return paginatedList(c, { offset, limit }, suggestions); }; diff --git a/packages/ditto/controllers/api/trends.ts b/packages/ditto/controllers/api/trends.ts index 4e5810ed..ca59bd34 100644 --- a/packages/ditto/controllers/api/trends.ts +++ b/packages/ditto/controllers/api/trends.ts @@ -124,7 +124,7 @@ async function getTrendingLinks(conf: DittoConf, relay: NStore): Promise { const { conf, relay } = c.var; - const { limit, offset, until } = paginationSchema.parse(c.req.query()); + const { limit, offset, until } = paginationSchema().parse(c.req.query()); const [label] = await relay.query([{ kinds: [1985], diff --git a/packages/ditto/routes/pleromaStatusesRoute.test.ts b/packages/ditto/routes/pleromaStatusesRoute.test.ts new file mode 100644 index 00000000..15c97341 --- /dev/null +++ b/packages/ditto/routes/pleromaStatusesRoute.test.ts @@ -0,0 +1,105 @@ +import { DittoConf } from '@ditto/conf'; +import { DittoPolyPg } from '@ditto/db'; +import { TestApp } from '@ditto/mastoapi/test'; +import { genEvent, MockRelay } from '@nostrify/nostrify/test'; +import { assertEquals } from '@std/assert'; + +import { DittoPgStore } from '@/storages/DittoPgStore.ts'; +import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; + +import route from './pleromaStatusesRoute.ts'; + +import type { MastodonStatus } from '@ditto/mastoapi/types'; + +Deno.test('Emoji reactions', async (t) => { + await using test = createTestApp(); + const { relay } = test.var; + + const mario = test.createUser(); + const luigi = test.createUser(); + + const note = genEvent({ kind: 1 }); + await relay.event(note); + + await relay.event(genEvent({ kind: 10030, tags: [['emoji', 'ditto', 'https://ditto.pub/favicon.ico']] }, luigi.sk)); + + await t.step('PUT /:id/reactions/:emoji', async () => { + test.user(mario); + + const response = await test.api.put(`/${note.id}/reactions/🚀`); + const json = await response.json(); + + assertEquals(response.status, 200); + assertEquals(json.pleroma.emoji_reactions, [{ name: '🚀', me: true, count: 1 }]); + }); + + await t.step('PUT /:id/reactions/:emoji (custom emoji)', async () => { + test.user(luigi); + + const response = await test.api.put(`/${note.id}/reactions/:ditto:`); + const json: MastodonStatus = await response.json(); + + assertEquals( + json.pleroma.emoji_reactions.sort((a, b) => a.name.localeCompare(b.name)), + [ + { name: '🚀', me: false, count: 1 }, + { name: 'ditto', me: true, count: 1, url: 'https://ditto.pub/favicon.ico' }, + ], + ); + }); + + await t.step('GET /:id/reactions', async () => { + test.user(mario); + + const response = await test.api.get(`/${note.id}/reactions`); + const json = await response.json(); + + (json as MastodonStatus['pleroma']['emoji_reactions']).sort((a, b) => a.name.localeCompare(b.name)); + + const [ + { accounts: [marioAccount] }, + { accounts: [luigiAccount] }, + ] = json; + + assertEquals(response.status, 200); + + assertEquals(json, [ + { name: '🚀', me: true, count: 1, accounts: [marioAccount] }, + { name: 'ditto', me: false, count: 1, accounts: [luigiAccount], url: 'https://ditto.pub/favicon.ico' }, + ]); + }); + + await t.step('DELETE /:id/reactions/:emoji', async () => { + test.user(mario); + + const response = await test.api.delete(`/${note.id}/reactions/🚀`); + const json = await response.json(); + + assertEquals(response.status, 200); + + assertEquals(json.pleroma.emoji_reactions, [ + { name: 'ditto', me: false, count: 1, url: 'https://ditto.pub/favicon.ico' }, + ]); + }); + + await t.step('DELETE /:id/reactions/:emoji (custom emoji)', async () => { + test.user(luigi); + + const response = await test.api.delete(`/${note.id}/reactions/:ditto:`); + const json = await response.json(); + + assertEquals(response.status, 200); + assertEquals(json.pleroma.emoji_reactions, []); + }); +}); + +// TODO: modify `TestApp` itself to avoid this boilerplate. +function createTestApp(): TestApp { + const conf = new DittoConf(Deno.env); + const db = new DittoPolyPg(conf.databaseUrl); + const pool = new MockRelay(); + const store = new DittoPgStore({ conf, db, notify: false }); + const relay = new DittoRelayStore({ conf, db, pool, relay: store }); + + return new TestApp(route, { conf, db, relay }); +} diff --git a/packages/ditto/routes/pleromaStatusesRoute.ts b/packages/ditto/routes/pleromaStatusesRoute.ts new file mode 100644 index 00000000..5d7fad38 --- /dev/null +++ b/packages/ditto/routes/pleromaStatusesRoute.ts @@ -0,0 +1,221 @@ +import { paginationMiddleware, userMiddleware } from '@ditto/mastoapi/middleware'; +import { DittoRoute } from '@ditto/mastoapi/router'; + +import { DittoEvent } from '@/interfaces/DittoEvent.ts'; +import { hydrateEvents } from '@/storages/hydrate.ts'; +import { createEvent } from '@/utils/api.ts'; +import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; +import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { HTTPException } from '@hono/hono/http-exception'; + +import { getCustomEmojis, parseEmojiInput } from '@/utils/custom-emoji.ts'; + +const route = new DittoRoute(); + +/* + * React to a status. + * https://docs.pleroma.social/backend/development/API/pleroma_api/#put-apiv1pleromastatusesidreactionsemoji + */ +route.put('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => { + const { relay, user, conf, signal } = c.var; + + const params = c.req.param(); + const result = parseEmojiParam(params.emoji); + const pubkey = await user.signer.getPublicKey(); + + const [event] = await relay.query([{ ids: [params.id] }], { signal }); + if (!event) { + return c.json({ error: 'Event not found' }, 404); + } + + const tags: string[][] = [ + ['e', event.id, conf.relay, event.pubkey], + ['p', event.pubkey, conf.relay], + ]; + + if (result.type === 'custom') { + const emojis = await getCustomEmojis(pubkey, c.var); + const emoji = emojis.get(result.shortcode); + + if (!emoji) { + return c.json({ error: 'Custom emoji not found' }, 404); + } + + tags.push(['emoji', result.shortcode, emoji.url.href]); + } + + let content: string; + + switch (result.type) { + case 'native': + content = result.native; + break; + case 'custom': + content = `:${result.shortcode}:`; + break; + } + + await createEvent({ kind: 7, content, tags }, c); + await hydrateEvents({ ...c.var, events: [event] }); + + const status = await renderStatus(relay, event, { viewerPubkey: pubkey }); + return c.json(status); +}); + +/* + * Delete reactions to a status. + * https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji + */ +route.delete('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => { + const { relay, user, signal } = c.var; + + const params = c.req.param(); + const pubkey = await user.signer.getPublicKey(); + + const [event] = await relay.query([{ ids: [params.id] }], { signal }); + + if (!event) { + return c.json({ error: 'Status not found' }, 404); + } + + const events = await relay.query([ + { kinds: [7], authors: [pubkey], '#e': [params.id] }, + ], { signal }); + + const e = new Set(); + + for (const { id, content } of events) { + if (content === params.emoji || content === `:${params.emoji}:`) { + e.add(id); + } + } + + if (!e.size) { + return c.json({ error: 'Reaction not found' }, 404); + } + + await createEvent({ + kind: 5, + tags: [...e].map((id) => ['e', id]), + }, c); + + await hydrateEvents({ ...c.var, events: [event] }); + + const status = await renderStatus(relay, event, { viewerPubkey: pubkey }); + return c.json(status); +}); + +/* + * Get an object of emoji to account mappings with accounts that reacted to the post. + * https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions + */ +route.get( + '/:id{[0-9a-f]{64}}/reactions/:emoji?', + paginationMiddleware({ limit: 100 }), + userMiddleware({ required: false }), + async (c) => { + const { relay, user, pagination, paginate } = c.var; + + const params = c.req.param(); + const result = params.emoji ? parseEmojiParam(params.emoji) : undefined; + const pubkey = await user?.signer.getPublicKey(); + + const events = await relay.query([{ kinds: [7], '#e': [params.id], ...pagination }]) + .then((events) => + events.filter((event) => { + if (!result) return true; + + switch (result.type) { + case 'native': + return event.content === result.native; + case 'custom': + return event.content === `:${result.shortcode}:`; + } + }) + ) + .then((events) => hydrateEvents({ ...c.var, events })); + + /** Events grouped by emoji key. */ + const byEmojiKey = events.reduce((acc, event) => { + const result = parseEmojiInput(event.content); + + if (!result || result.type === 'basic') { + return acc; + } + + let url: URL | undefined; + + if (result.type === 'custom') { + const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode); + try { + url = new URL(tag![2]); + } catch { + return acc; + } + } + + let key: string; + switch (result.type) { + case 'native': + key = result.native; + break; + case 'custom': + key = `${result.shortcode}:${url}`; + break; + } + + acc[key] = acc[key] || []; + acc[key].push(event); + + return acc; + }, {} as Record); + + const results = await Promise.all( + Object.entries(byEmojiKey).map(async ([key, events]) => { + let name: string = key; + let url: string | undefined; + + // Custom emojis: `:` + try { + const [shortcode, ...rest] = key.split(':'); + + url = new URL(rest.join(':')).toString(); + name = shortcode; + } catch { + // fallthrough + } + + return { + name, + count: events.length, + me: pubkey && events.some((event) => event.pubkey === pubkey), + accounts: await Promise.all( + events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), + ), + url, + }; + }), + ); + + return paginate(events, results); + }, +); + +/** Determine if the input is a native or custom emoji, returning a structured object or throwing an error. */ +function parseEmojiParam(input: string): + | { type: 'native'; native: string } + | { type: 'custom'; shortcode: string } { + if (/^\w+$/.test(input)) { + input = `:${input}:`; // Pleroma API supports the `emoji` param with or without colons. + } + + const result = parseEmojiInput(input); + + if (!result || result.type === 'basic') { + throw new HTTPException(400, { message: 'Invalid emoji' }); + } + + return result; +} + +export default route; diff --git a/packages/ditto/storages/DittoPgStore.test.ts b/packages/ditto/storages/DittoPgStore.test.ts index d243d0e4..dedde9d6 100644 --- a/packages/ditto/storages/DittoPgStore.test.ts +++ b/packages/ditto/storages/DittoPgStore.test.ts @@ -16,7 +16,7 @@ Deno.test('req streaming', async () => { const controller = new AbortController(); const promise = (async () => { - for await (const msg of relay.req([{ since: 0 }], { signal: controller.signal })) { + for await (const msg of relay.req([{ limit: 0 }], { signal: controller.signal })) { msgs.push(msg); } })(); diff --git a/packages/ditto/test.ts b/packages/ditto/test.ts index 31b31e37..0308c870 100644 --- a/packages/ditto/test.ts +++ b/packages/ditto/test.ts @@ -22,7 +22,7 @@ export async function createTestDB(opts?: { pure?: boolean }) { conf, timeout: conf.db.timeouts.default, pure: opts?.pure ?? false, - notify: true, + notify: false, }); return { diff --git a/packages/ditto/utils/custom-emoji.test.ts b/packages/ditto/utils/custom-emoji.test.ts new file mode 100644 index 00000000..e9093e4e --- /dev/null +++ b/packages/ditto/utils/custom-emoji.test.ts @@ -0,0 +1,10 @@ +import { assertEquals } from '@std/assert'; + +import { parseEmojiInput } from './custom-emoji.ts'; + +Deno.test('parseEmojiInput', () => { + assertEquals(parseEmojiInput('+'), { type: 'basic', value: '+' }); + assertEquals(parseEmojiInput('🚀'), { type: 'native', native: '🚀' }); + assertEquals(parseEmojiInput(':ditto:'), { type: 'custom', shortcode: 'ditto' }); + assertEquals(parseEmojiInput('x'), undefined); +}); diff --git a/packages/ditto/utils/custom-emoji.ts b/packages/ditto/utils/custom-emoji.ts index a7a83a31..a95bacdb 100644 --- a/packages/ditto/utils/custom-emoji.ts +++ b/packages/ditto/utils/custom-emoji.ts @@ -70,3 +70,24 @@ export async function getCustomEmojis( return emojis; } + +/** Determine if the input is a native or custom emoji, returning a structured object or throwing an error. */ +export function parseEmojiInput(input: string): + | { type: 'basic'; value: '+' | '-' } + | { type: 'native'; native: string } + | { type: 'custom'; shortcode: string } + | undefined { + if (input === '+' || input === '-') { + return { type: 'basic', value: input }; + } + + if (/^\p{RGI_Emoji}$/v.test(input)) { + return { type: 'native', native: input }; + } + + const match = input.match(/^:(\w+):$/); + if (match) { + const [, shortcode] = match; + return { type: 'custom', shortcode }; + } +} diff --git a/packages/ditto/utils/stats.test.ts b/packages/ditto/utils/stats.test.ts index 2ebcab94..0fe7b0ae 100644 --- a/packages/ditto/utils/stats.test.ts +++ b/packages/ditto/utils/stats.test.ts @@ -141,16 +141,24 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => { const { kysely, relay } = test; const note = genEvent({ kind: 1 }); - await updateStats({ ...test, event: note }); await relay.event(note); await updateStats({ ...test, event: genEvent({ kind: 7, content: '+', tags: [['e', note.id]] }) }); await updateStats({ ...test, event: genEvent({ kind: 7, content: '😂', tags: [['e', note.id]] }) }); + await updateStats({ + ...test, + event: genEvent({ + kind: 7, + content: ':ditto:', + tags: [['e', note.id], ['emoji', 'ditto', 'https://ditto.pub/favicon.ico']], + }), + }); + const stats = await getEventStats(kysely, note.id); - assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1 })); - assertEquals(stats!.reactions_count, 2); + assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1, 'ditto:https://ditto.pub/favicon.ico': 1 })); + assertEquals(stats!.reactions_count, 3); }); Deno.test('updateStats with kind 5 decrements reactions count', async () => { diff --git a/packages/ditto/utils/stats.ts b/packages/ditto/utils/stats.ts index 922d5dca..576c6e01 100644 --- a/packages/ditto/utils/stats.ts +++ b/packages/ditto/utils/stats.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; import type { DittoConf } from '@ditto/conf'; +import { parseEmojiInput } from '@/utils/custom-emoji.ts'; interface UpdateStatsOpts { conf: DittoConf; @@ -154,31 +155,55 @@ async function handleEvent7(opts: UpdateStatsOpts): Promise { const { kysely, event, x = 1 } = opts; const id = event.tags.findLast(([name]) => name === 'e')?.[1]; - const emoji = event.content; + const result = parseEmojiInput(event.content); - if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) { - await updateEventStats(kysely, id, ({ reactions }) => { - const data: Record = JSON.parse(reactions); + if (!id || !result) return; - // Increment or decrement the emoji count. - data[emoji] = (data[emoji] ?? 0) + x; + let url: URL | undefined; - // Remove reactions with a count of 0 or less. - for (const key of Object.keys(data)) { - if (data[key] < 1) { - delete data[key]; - } - } - - // Total reactions count. - const count = Object.values(data).reduce((result, value) => result + value, 0); - - return { - reactions: JSON.stringify(data), - reactions_count: count, - }; - }); + if (result.type === 'custom') { + const tag = event.tags.find(([name, value]) => name === 'emoji' && value === result.shortcode); + try { + url = new URL(tag![2]); + } catch { + return; + } } + + let key: string; + switch (result.type) { + case 'basic': + key = result.value; + break; + case 'native': + key = result.native; + break; + case 'custom': + key = `${result.shortcode}:${url}`; + break; + } + + await updateEventStats(kysely, id, ({ reactions }) => { + const data: Record = JSON.parse(reactions); + + // Increment or decrement the emoji count. + data[key] = (data[key] ?? 0) + x; + + // Remove reactions with a count of 0 or less. + for (const key of Object.keys(data)) { + if (data[key] < 1) { + delete data[key]; + } + } + + // Total reactions count. + const count = Object.values(data).reduce((result, value) => result + value, 0); + + return { + reactions: JSON.stringify(data), + reactions_count: count, + }; + }); } /** Update stats for kind 9735 event. */ diff --git a/packages/ditto/views.ts b/packages/ditto/views.ts index 79379e6c..030516db 100644 --- a/packages/ditto/views.ts +++ b/packages/ditto/views.ts @@ -41,7 +41,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?: } async function renderAccounts(c: AppContext, pubkeys: string[]) { - const { offset, limit } = paginationSchema.parse(c.req.query()); + const { offset, limit } = paginationSchema().parse(c.req.query()); const authors = pubkeys.reverse().slice(offset, offset + limit); const { relay, signal } = c.var; diff --git a/packages/ditto/views/mastodon/statuses.ts b/packages/ditto/views/mastodon/statuses.ts index a2d4a740..ba2e8d86 100644 --- a/packages/ditto/views/mastodon/statuses.ts +++ b/packages/ditto/views/mastodon/statuses.ts @@ -91,11 +91,32 @@ async function renderStatus( const subject = event.tags.find(([name]) => name === 'subject'); /** Pleroma emoji reactions object. */ - const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => { - if (['+', '-'].includes(emoji)) return acc; - acc.push({ name: emoji, count, me: reactionEvent?.content === emoji }); + const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [key, count]) => { + if (['+', '-'].includes(key)) return acc; // skip basic reactions (treat as likes/dislikes in Mastodon API) + + // Custom emoji reactions: `:` + try { + const [shortcode, ...rest] = key.split(':'); + const url = new URL(rest.join(':')); + const tag = reactionEvent?.tags.find((t) => t[0] === 'emoji' && t[1] === shortcode && t[2] === url.href); + + acc.push({ + name: shortcode, + me: reactionEvent?.content === `:${shortcode}:` && !!tag, + url: url.href, + count, + }); + + return acc; + } catch { + // fallthrough + } + + // Native emojis: `🚀` + acc.push({ name: key, count, me: reactionEvent?.content === key }); + return acc; - }, [] as { name: string; count: number; me: boolean }[]); + }, [] as { name: string; count: number; me: boolean; url?: string }[]); const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); diff --git a/packages/mastoapi/middleware/paginationMiddleware.ts b/packages/mastoapi/middleware/paginationMiddleware.ts index 28a7f1a1..01b222f3 100644 --- a/packages/mastoapi/middleware/paginationMiddleware.ts +++ b/packages/mastoapi/middleware/paginationMiddleware.ts @@ -1,5 +1,5 @@ import { paginated, paginatedList } from '../pagination/paginate.ts'; -import { paginationSchema } from '../pagination/schema.ts'; +import { paginationSchema, type PaginationSchemaOpts } from '../pagination/schema.ts'; import type { DittoMiddleware } from '@ditto/mastoapi/router'; import type { NostrEvent } from '@nostrify/nostrify'; @@ -19,19 +19,26 @@ type HeaderRecord = Record; type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response; type ListPaginateFn = (params: ListPagination, body: object | unknown[], headers?: HeaderRecord) => Response; +interface PaginationMiddlewareOpts extends PaginationSchemaOpts { + type?: string; +} + /** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */ // @ts-ignore Types are right. export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>; export function paginationMiddleware( - type: 'list', + opts: PaginationMiddlewareOpts & { type: 'list' }, ): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>; export function paginationMiddleware( - type?: string, + opts?: PaginationMiddlewareOpts, +): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>; +export function paginationMiddleware( + opts: PaginationMiddlewareOpts = {}, ): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> { return async (c, next) => { const { relay } = c.var; - const pagination = paginationSchema.parse(c.req.query()); + const pagination = paginationSchema(opts).parse(c.req.query()); const { max_id: maxId, @@ -59,7 +66,7 @@ export function paginationMiddleware( } } - if (type === 'list') { + if (opts.type === 'list') { c.set('pagination', { limit: pagination.limit, offset: pagination.offset, diff --git a/packages/mastoapi/pagination/schema.test.ts b/packages/mastoapi/pagination/schema.test.ts index 94be9091..b315e776 100644 --- a/packages/mastoapi/pagination/schema.test.ts +++ b/packages/mastoapi/pagination/schema.test.ts @@ -3,7 +3,7 @@ import { assertEquals } from '@std/assert'; import { paginationSchema } from './schema.ts'; Deno.test('paginationSchema', () => { - const pagination = paginationSchema.parse({ + const pagination = paginationSchema().parse({ limit: '10', offset: '20', max_id: '1', @@ -21,3 +21,8 @@ Deno.test('paginationSchema', () => { until: 4, }); }); + +Deno.test('paginationSchema with custom limit', () => { + const pagination = paginationSchema({ limit: 100 }).parse({}); + assertEquals(pagination.limit, 100); +}); diff --git a/packages/mastoapi/pagination/schema.ts b/packages/mastoapi/pagination/schema.ts index 5647246d..e6b35ce4 100644 --- a/packages/mastoapi/pagination/schema.ts +++ b/packages/mastoapi/pagination/schema.ts @@ -9,15 +9,28 @@ export interface Pagination { offset: number; } +export interface PaginationSchemaOpts { + limit?: number; + max?: number; +} + /** Schema to parse pagination query params. */ -export const paginationSchema: z.ZodType = z.object({ - 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), - limit: z.coerce.number().catch(20).transform((value) => Math.min(Math.max(value, 0), 40)), - offset: z.coerce.number().nonnegative().catch(0), -}) as z.ZodType; +export function paginationSchema(opts: PaginationSchemaOpts = {}): z.ZodType { + let { limit = 20, max = 40 } = opts; + + if (limit > max) { + max = limit; + } + + return z.object({ + 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), + limit: z.coerce.number().catch(limit).transform((value) => Math.min(Math.max(value, 0), max)), + offset: z.coerce.number().nonnegative().catch(0), + }) as z.ZodType; +} diff --git a/packages/mastoapi/test/TestApp.ts b/packages/mastoapi/test/TestApp.ts index a12f48a4..04e1589e 100644 --- a/packages/mastoapi/test/TestApp.ts +++ b/packages/mastoapi/test/TestApp.ts @@ -95,10 +95,11 @@ export class TestApp extends DittoApp implements AsyncDisposable { return user; } - createUser(sk?: Uint8Array): User { + createUser(sk: Uint8Array = generateSecretKey()): User & { sk: Uint8Array } { return { relay: this.opts.relay, - signer: new NSecSigner(sk ?? generateSecretKey()), + signer: new NSecSigner(sk), + sk, }; } @@ -110,9 +111,19 @@ export class TestApp extends DittoApp implements AsyncDisposable { return await this.request(path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + body: body ? JSON.stringify(body) : undefined, }); }, + put: async (path: string, body?: unknown): Promise => { + return await this.request(path, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: body ? JSON.stringify(body) : undefined, + }); + }, + delete: async (path: string): Promise => { + return await this.request(path, { method: 'DELETE' }); + }, }; async [Symbol.asyncDispose](): Promise {