Implement custom emojis

This commit is contained in:
Alex Gleason 2025-03-14 23:29:20 -05:00
parent bfe693c2f8
commit f15b6f79c0
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
3 changed files with 156 additions and 1 deletions

View file

@ -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);

View file

@ -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,
}]);
});
});

View file

@ -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<string>();
const emojis = new Map<string, URL>();
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;