mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Merge branch 'custom-emojis' into 'main'
Implement custom emojis See merge request soapbox-pub/ditto!721
This commit is contained in:
commit
ea9b1287ec
5 changed files with 197 additions and 1 deletions
|
|
@ -147,6 +147,7 @@ import { rateLimitMiddleware } from '@/middleware/rateLimitMiddleware.ts';
|
||||||
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
|
import { uploaderMiddleware } from '@/middleware/uploaderMiddleware.ts';
|
||||||
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
|
import { translatorMiddleware } from '@/middleware/translatorMiddleware.ts';
|
||||||
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
|
import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
|
||||||
|
import customEmojisRoute from '@/routes/customEmojisRoute.ts';
|
||||||
import dittoNamesRoute from '@/routes/dittoNamesRoute.ts';
|
import dittoNamesRoute from '@/routes/dittoNamesRoute.ts';
|
||||||
import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts';
|
import pleromaAdminPermissionGroupsRoute from '@/routes/pleromaAdminPermissionGroupsRoute.ts';
|
||||||
import { DittoRelayStore } from '@/storages/DittoRelayStore.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/suggest', userMiddleware({ role: 'admin' }), pleromaAdminSuggestController);
|
||||||
app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController);
|
app.patch('/api/v1/pleroma/admin/users/unsuggest', userMiddleware({ role: 'admin' }), pleromaAdminUnsuggestController);
|
||||||
|
|
||||||
|
app.route('/api/v1/custom_emojis', customEmojisRoute);
|
||||||
|
|
||||||
// Not (yet) implemented.
|
// Not (yet) implemented.
|
||||||
app.get('/api/v1/custom_emojis', emptyArrayController);
|
|
||||||
app.get('/api/v1/filters', emptyArrayController);
|
app.get('/api/v1/filters', emptyArrayController);
|
||||||
app.get('/api/v1/domain_blocks', emptyArrayController);
|
app.get('/api/v1/domain_blocks', emptyArrayController);
|
||||||
app.get('/api/v1/conversations', emptyArrayController);
|
app.get('/api/v1/conversations', emptyArrayController);
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { lookupPubkey } from '@/utils/lookup.ts';
|
||||||
import { languageSchema } from '@/schema.ts';
|
import { languageSchema } from '@/schema.ts';
|
||||||
import { hydrateEvents } from '@/storages/hydrate.ts';
|
import { hydrateEvents } from '@/storages/hydrate.ts';
|
||||||
import { assertAuthenticated, createEvent, parseBody, updateListEvent } from '@/utils/api.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 { getInvoice, getLnurl } from '@/utils/lnurl.ts';
|
||||||
import { purifyEvent } from '@/utils/purify.ts';
|
import { purifyEvent } from '@/utils/purify.ts';
|
||||||
import { getZapSplits } from '@/utils/zap-split.ts';
|
import { getZapSplits } from '@/utils/zap-split.ts';
|
||||||
|
|
@ -190,6 +191,23 @@ const createStatusController: AppController = async (c) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const shortcodes = new Set<string>();
|
||||||
|
|
||||||
|
for (const [, shortcode] of data.status?.matchAll(/(?<!\w):(\w+):(?!\w)/g) ?? []) {
|
||||||
|
shortcodes.add(shortcode);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shortcodes.size) {
|
||||||
|
const emojis = await getCustomEmojis(await user!.signer.getPublicKey(), c.var);
|
||||||
|
|
||||||
|
for (const shortcode of shortcodes) {
|
||||||
|
const emoji = emojis.get(shortcode);
|
||||||
|
if (emoji) {
|
||||||
|
tags.push(['emoji', shortcode, emoji.url.toString()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pubkey = await user!.signer.getPublicKey();
|
const pubkey = await user!.signer.getPublicKey();
|
||||||
const author = pubkey ? await getAuthor(pubkey, c.var) : undefined;
|
const author = pubkey ? await getAuthor(pubkey, c.var) : undefined;
|
||||||
|
|
||||||
|
|
|
||||||
69
packages/ditto/routes/customEmojisRoute.test.ts
Normal file
69
packages/ditto/routes/customEmojisRoute.test.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
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,
|
||||||
|
category: 'soapbox',
|
||||||
|
}, {
|
||||||
|
shortcode: 'ditto',
|
||||||
|
url: 'https://ditto.pub/favicon.ico',
|
||||||
|
static_url: 'https://ditto.pub/favicon.ico',
|
||||||
|
visible_in_picker: true,
|
||||||
|
category: 'soapbox',
|
||||||
|
}]);
|
||||||
|
});
|
||||||
|
});
|
||||||
33
packages/ditto/routes/customEmojisRoute.ts
Normal file
33
packages/ditto/routes/customEmojisRoute.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
import { userMiddleware } from '@ditto/mastoapi/middleware';
|
||||||
|
import { DittoRoute } from '@ditto/mastoapi/router';
|
||||||
|
|
||||||
|
import { getCustomEmojis } from '@/utils/custom-emoji.ts';
|
||||||
|
|
||||||
|
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 { 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,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
export default route;
|
||||||
74
packages/ditto/utils/custom-emoji.ts
Normal file
74
packages/ditto/utils/custom-emoji.ts
Normal file
|
|
@ -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<Map<string, { url: URL; category?: string }>> {
|
||||||
|
const { relay, signal } = opts;
|
||||||
|
|
||||||
|
const emojis = new Map<string, { url: URL; category?: string }>();
|
||||||
|
|
||||||
|
const [emojiList] = await relay.query([{ kinds: [10030], authors: [pubkey] }], { signal });
|
||||||
|
|
||||||
|
if (!emojiList) {
|
||||||
|
return emojis;
|
||||||
|
}
|
||||||
|
|
||||||
|
const a = new Set<string>();
|
||||||
|
|
||||||
|
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 emojis;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue