From f15b6f79c0dc2ede9353bea94c21b7b4f38b4c81 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 14 Mar 2025 23:29:20 -0500 Subject: [PATCH 1/4] Implement custom emojis --- packages/ditto/app.ts | 4 +- .../ditto/routes/customEmojisRoute.test.ts | 67 +++++++++++++++ packages/ditto/routes/customEmojisRoute.ts | 86 +++++++++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 packages/ditto/routes/customEmojisRoute.test.ts create mode 100644 packages/ditto/routes/customEmojisRoute.ts 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; From b1a1ace0ac8675aa76aefffca84399bad040160d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 14 Mar 2025 23:58:42 -0500 Subject: [PATCH 2/4] Refactor getCustomEmojis function, support emoji categories --- .../ditto/routes/customEmojisRoute.test.ts | 2 + packages/ditto/routes/customEmojisRoute.ts | 51 +++++++++++++------ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/packages/ditto/routes/customEmojisRoute.test.ts b/packages/ditto/routes/customEmojisRoute.test.ts index 6feac4cc..4d52540a 100644 --- a/packages/ditto/routes/customEmojisRoute.test.ts +++ b/packages/ditto/routes/customEmojisRoute.test.ts @@ -57,11 +57,13 @@ Deno.test('customEmojisRoute', async (t) => { url: 'https://soapbox.pub/favicon.ico', static_url: 'https://soapbox.pub/favicon.ico', visible_in_picker: true, + category: 'soapbox', }, { shortcode: 'ditto', url: 'https://ditto.pub/favicon.ico', static_url: 'https://ditto.pub/favicon.ico', visible_in_picker: true, + category: 'soapbox', }]); }); }); diff --git a/packages/ditto/routes/customEmojisRoute.ts b/packages/ditto/routes/customEmojisRoute.ts index 32c7a3d0..997c9a45 100644 --- a/packages/ditto/routes/customEmojisRoute.ts +++ b/packages/ditto/routes/customEmojisRoute.ts @@ -1,6 +1,6 @@ import { userMiddleware } from '@ditto/mastoapi/middleware'; import { DittoRoute } from '@ditto/mastoapi/router'; -import { NostrFilter } from '@nostrify/nostrify'; +import { NostrFilter, NRelay } from '@nostrify/nostrify'; const route = new DittoRoute(); @@ -13,18 +13,42 @@ interface MastodonCustomEmoji { } route.get('/', userMiddleware(), async (c) => { - const { relay, user, signal } = c.var; + const { user } = c.var; const pubkey = await user.signer.getPublicKey(); + const emojis = await getCustomEmojis(pubkey, c.var); + + return c.json([...emojis.entries()].map(([shortcode, data]): MastodonCustomEmoji => { + return { + shortcode, + url: data.url.toString(), + static_url: data.url.toString(), + visible_in_picker: true, + category: data.category, + }; + })); +}); + +interface GetCustomEmojisOpts { + relay: NRelay; + signal?: AbortSignal; +} + +async function getCustomEmojis( + pubkey: string, + opts: GetCustomEmojisOpts, +): Promise> { + const { relay, signal } = opts; + + const emojis = new Map(); const [emojiList] = await relay.query([{ kinds: [10030], authors: [pubkey] }], { signal }); if (!emojiList) { - return c.json([]); + return emojis; } const a = new Set(); - const emojis = new Map(); for (const tag of emojiList.tags) { if (tag[0] === 'emoji') { @@ -32,7 +56,7 @@ route.get('/', userMiddleware(), async (c) => { if (!emojis.has(shortcode)) { try { - emojis.set(shortcode, new URL(url)); + emojis.set(shortcode, { url: new URL(url) }); } catch { // continue } @@ -56,15 +80,17 @@ route.get('/', userMiddleware(), async (c) => { } if (!filters.length) { - return c.json([]); + return new Map(); } for (const event of await relay.query(filters, { signal })) { + const d = event.tags.find(([name]) => name === 'd')?.[1]; + for (const [t, shortcode, url] of event.tags) { if (t === 'emoji') { if (!emojis.has(shortcode)) { try { - emojis.set(shortcode, new URL(url)); + emojis.set(shortcode, { url: new URL(url), category: d }); } catch { // continue } @@ -73,14 +99,7 @@ route.get('/', userMiddleware(), async (c) => { } } - return c.json([...emojis.entries()].map(([shortcode, url]): MastodonCustomEmoji => { - return { - shortcode, - url: url.toString(), - static_url: url.toString(), - visible_in_picker: true, - }; - })); -}); + return emojis; +} export default route; From 28275b76116138e481fa87213d53b2a6401f14be Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 15 Mar 2025 00:16:52 -0500 Subject: [PATCH 3/4] Insert "emoji" tags into statuses --- packages/ditto/controllers/api/statuses.ts | 18 +++++ packages/ditto/routes/customEmojisRoute.ts | 76 +--------------------- packages/ditto/utils/custom-emoji.ts | 74 +++++++++++++++++++++ 3 files changed, 94 insertions(+), 74 deletions(-) create mode 100644 packages/ditto/utils/custom-emoji.ts diff --git a/packages/ditto/controllers/api/statuses.ts b/packages/ditto/controllers/api/statuses.ts index 53fab57a..116ad2de 100644 --- a/packages/ditto/controllers/api/statuses.ts +++ b/packages/ditto/controllers/api/statuses.ts @@ -16,6 +16,7 @@ import { lookupPubkey } from '@/utils/lookup.ts'; import { languageSchema } from '@/schema.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { assertAuthenticated, createEvent, parseBody, updateListEvent } from '@/utils/api.ts'; +import { getCustomEmojis } from '@/utils/custom-emoji.ts'; import { getInvoice, getLnurl } from '@/utils/lnurl.ts'; import { purifyEvent } from '@/utils/purify.ts'; import { getZapSplits } from '@/utils/zap-split.ts'; @@ -190,6 +191,23 @@ const createStatusController: AppController = async (c) => { } } + const shortcodes = new Set(); + + for (const [, shortcode] of data.status?.matchAll(/(? { })); }); -interface GetCustomEmojisOpts { - relay: NRelay; - signal?: AbortSignal; -} - -async function getCustomEmojis( - pubkey: string, - opts: GetCustomEmojisOpts, -): Promise> { - const { relay, signal } = opts; - - const emojis = new Map(); - - const [emojiList] = await relay.query([{ kinds: [10030], authors: [pubkey] }], { signal }); - - if (!emojiList) { - return emojis; - } - - const a = new Set(); - - for (const tag of emojiList.tags) { - if (tag[0] === 'emoji') { - const [, shortcode, url] = tag; - - if (!emojis.has(shortcode)) { - try { - emojis.set(shortcode, { url: 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 new Map(); - } - - for (const event of await relay.query(filters, { signal })) { - const d = event.tags.find(([name]) => name === 'd')?.[1]; - - for (const [t, shortcode, url] of event.tags) { - if (t === 'emoji') { - if (!emojis.has(shortcode)) { - try { - emojis.set(shortcode, { url: new URL(url), category: d }); - } catch { - // continue - } - } - } - } - } - - return emojis; -} - export default route; diff --git a/packages/ditto/utils/custom-emoji.ts b/packages/ditto/utils/custom-emoji.ts new file mode 100644 index 00000000..8dbcd7a8 --- /dev/null +++ b/packages/ditto/utils/custom-emoji.ts @@ -0,0 +1,74 @@ +import type { NostrFilter, NRelay } from '@nostrify/nostrify'; + +interface GetCustomEmojisOpts { + relay: NRelay; + signal?: AbortSignal; +} + +export async function getCustomEmojis( + pubkey: string, + opts: GetCustomEmojisOpts, +): Promise> { + const { relay, signal } = opts; + + const emojis = new Map(); + + const [emojiList] = await relay.query([{ kinds: [10030], authors: [pubkey] }], { signal }); + + if (!emojiList) { + return emojis; + } + + const a = new Set(); + + for (const tag of emojiList.tags) { + if (tag[0] === 'emoji') { + const [, shortcode, url] = tag; + + if (!emojis.has(shortcode)) { + try { + emojis.set(shortcode, { url: 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 new Map(); + } + + for (const event of await relay.query(filters, { signal })) { + const d = event.tags.find(([name]) => name === 'd')?.[1]; + + for (const [t, shortcode, url] of event.tags) { + if (t === 'emoji') { + if (!emojis.has(shortcode)) { + try { + emojis.set(shortcode, { url: new URL(url), category: d }); + } catch { + // continue + } + } + } + } + } + + return emojis; +} From 974e07981ed34a7d7f40840d17de1577994526ec Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 15 Mar 2025 00:19:50 -0500 Subject: [PATCH 4/4] getCustomEmojis: new Map() -> emojis --- packages/ditto/utils/custom-emoji.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ditto/utils/custom-emoji.ts b/packages/ditto/utils/custom-emoji.ts index 8dbcd7a8..062cb46a 100644 --- a/packages/ditto/utils/custom-emoji.ts +++ b/packages/ditto/utils/custom-emoji.ts @@ -51,7 +51,7 @@ export async function getCustomEmojis( } if (!filters.length) { - return new Map(); + return emojis; } for (const event of await relay.query(filters, { signal })) {