diff --git a/packages/ditto/app.ts b/packages/ditto/app.ts index d656644a..6432668c 100644 --- a/packages/ditto/app.ts +++ b/packages/ditto/app.ts @@ -147,6 +147,7 @@ import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts'; import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts'; import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts'; 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 { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; @@ -520,8 +521,9 @@ app.delete('/api/v1/pleroma/admin/users/tag', userMiddleware({ role: 'admin' }), app.patch('/api/v1/pleroma/admin/users/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController); app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController); +app.route('/api/v1/custom_emojis', customEmojisRoute); + // Not (yet) implemented. -app.get('/api/v1/custom_emojis', emptyArrayController); app.get('/api/v1/filters', emptyArrayController); app.get('/api/v1/domain_blocks', emptyArrayController); app.get('/api/v1/conversations', emptyArrayController); diff --git a/packages/ditto/routes/customEmojisRoute.test.ts b/packages/ditto/routes/customEmojisRoute.test.ts new file mode 100644 index 00000000..6feac4cc --- /dev/null +++ b/packages/ditto/routes/customEmojisRoute.test.ts @@ -0,0 +1,67 @@ +import { TestApp } from '@ditto/mastoapi/test'; +import { NSecSigner } from '@nostrify/nostrify'; +import { genEvent } from '@nostrify/nostrify/test'; +import { assertEquals } from '@std/assert'; +import { generateSecretKey } from 'nostr-tools'; + +import route from './customEmojisRoute.ts'; + +Deno.test('customEmojisRoute', async (t) => { + await using test = new TestApp(route); + const { relay } = test.var; + + const sk = generateSecretKey(); + const user = test.user({ relay, signer: new NSecSigner(sk) }); + const pubkey = await user.signer.getPublicKey(); + + await t.step('no emojis', async () => { + const response = await test.api.get('/'); + const body = await response.json(); + + assertEquals(response.status, 200); + assertEquals(body, []); + }); + + await t.step('with emoji packs', async () => { + const pack = genEvent({ + kind: 30030, + tags: [ + ['d', 'soapbox'], + ['emoji', 'soapbox', 'https://soapbox.pub/favicon.ico'], + ['emoji', 'ditto', 'https://ditto.pub/favicon.ico'], + ], + }, sk); + + const list = genEvent({ + kind: 10030, + tags: [ + ['a', `30030:${pubkey}:soapbox`], + ['emoji', 'gleasonator', 'https://gleasonator.dev/favicon.ico'], + ], + }, sk); + + await relay.event(pack); + await relay.event(list); + + const response = await test.api.get('/'); + const body = await response.json(); + + assertEquals(response.status, 200); + assertEquals(body, [{ + shortcode: 'gleasonator', + url: 'https://gleasonator.dev/favicon.ico', + static_url: 'https://gleasonator.dev/favicon.ico', + visible_in_picker: true, + }, { + shortcode: 'soapbox', + url: 'https://soapbox.pub/favicon.ico', + static_url: 'https://soapbox.pub/favicon.ico', + visible_in_picker: true, + }, { + shortcode: 'ditto', + url: 'https://ditto.pub/favicon.ico', + static_url: 'https://ditto.pub/favicon.ico', + visible_in_picker: true, + }]); + }); +}); diff --git a/packages/ditto/routes/customEmojisRoute.ts b/packages/ditto/routes/customEmojisRoute.ts new file mode 100644 index 00000000..32c7a3d0 --- /dev/null +++ b/packages/ditto/routes/customEmojisRoute.ts @@ -0,0 +1,86 @@ +import { userMiddleware } from '@ditto/mastoapi/middleware'; +import { DittoRoute } from '@ditto/mastoapi/router'; +import { NostrFilter } from '@nostrify/nostrify'; + +const route = new DittoRoute(); + +interface MastodonCustomEmoji { + shortcode: string; + url: string; + static_url: string; + visible_in_picker: boolean; + category?: string; +} + +route.get('/', userMiddleware(), async (c) => { + const { relay, user, signal } = c.var; + + const pubkey = await user.signer.getPublicKey(); + + const [emojiList] = await relay.query([{ kinds: [10030], authors: [pubkey] }], { signal }); + + if (!emojiList) { + return c.json([]); + } + + const a = new Set(); + const emojis = new Map(); + + for (const tag of emojiList.tags) { + if (tag[0] === 'emoji') { + const [, shortcode, url] = tag; + + if (!emojis.has(shortcode)) { + try { + emojis.set(shortcode, new URL(url)); + } catch { + // continue + } + } + } + + if (tag[0] === 'a') { + a.add(tag[1]); + } + } + + const filters: NostrFilter[] = []; + + for (const addr of a) { + const match = addr.match(/^30030:([0-9a-f]{64}):(.+)$/); + + if (match) { + const [, pubkey, d] = match; + filters.push({ kinds: [30030], authors: [pubkey], '#d': [d] }); + } + } + + if (!filters.length) { + return c.json([]); + } + + for (const event of await relay.query(filters, { signal })) { + for (const [t, shortcode, url] of event.tags) { + if (t === 'emoji') { + if (!emojis.has(shortcode)) { + try { + emojis.set(shortcode, new URL(url)); + } catch { + // continue + } + } + } + } + } + + return c.json([...emojis.entries()].map(([shortcode, url]): MastodonCustomEmoji => { + return { + shortcode, + url: url.toString(), + static_url: url.toString(), + visible_in_picker: true, + }; + })); +}); + +export default route;