Merge branch 'custom-reacts' into 'main'

Support custom emoji reactions

See merge request soapbox-pub/ditto!724
This commit is contained in:
Alex Gleason 2025-03-15 22:21:09 +00:00
commit a6b58b50cf
20 changed files with 505 additions and 188 deletions

View file

@ -87,7 +87,6 @@ import {
} from '@/controllers/api/pleroma.ts'; } from '@/controllers/api/pleroma.ts';
import { preferencesController } from '@/controllers/api/preferences.ts'; import { preferencesController } from '@/controllers/api/preferences.ts';
import { getSubscriptionController, pushSubscribeController } from '@/controllers/api/push.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 { relayController } from '@/controllers/nostr/relay.ts';
import { import {
adminReportController, adminReportController,
@ -150,6 +149,7 @@ import { logiMiddleware } from '@/middleware/logiMiddleware.ts';
import customEmojisRoute from '@/routes/customEmojisRoute.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 pleromaStatusesRoute from '@/routes/pleromaStatusesRoute.ts';
import { DittoRelayStore } from '@/storages/DittoRelayStore.ts'; import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
export interface AppEnv extends DittoEnv { 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.get('/api/v1/push/subscription', userMiddleware(), getSubscriptionController);
app.post('/api/v1/push/subscription', userMiddleware({ verify: true }), pushSubscribeController); app.post('/api/v1/push/subscription', userMiddleware({ verify: true }), pushSubscribeController);
app.get('/api/v1/pleroma/statuses/:id{[0-9a-f]{64}}/reactions', reactionsController); app.route('/api/v1/pleroma/statuses', pleromaStatusesRoute);
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.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController); app.get('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), configController);
app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController); app.post('/api/v1/pleroma/admin/config', userMiddleware({ role: 'admin' }), updateConfigController);

View file

@ -9,6 +9,7 @@ const features = [
'exposable_reactions', 'exposable_reactions',
'mastodon_api', 'mastodon_api',
'mastodon_api_streaming', 'mastodon_api_streaming',
'pleroma_custom_emoji_reactions',
'pleroma_emoji_reactions', 'pleroma_emoji_reactions',
'quote_posting', 'quote_posting',
'v2_suggestions', 'v2_suggestions',

View file

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

View file

@ -633,7 +633,7 @@ const zappedByController: AppController = async (c) => {
const { db, relay } = c.var; const { db, relay } = c.var;
const id = c.req.param('id'); 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') const zaps = await db.kysely.selectFrom('event_zaps')
.selectAll() .selectAll()

View file

@ -9,7 +9,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
export const suggestionsV1Controller: AppController = async (c) => { export const suggestionsV1Controller: AppController = async (c) => {
const { signal } = c.var; 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 suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
const accounts = suggestions.map(({ account }) => account); const accounts = suggestions.map(({ account }) => account);
return paginatedList(c, { offset, limit }, accounts); return paginatedList(c, { offset, limit }, accounts);
@ -17,7 +17,7 @@ export const suggestionsV1Controller: AppController = async (c) => {
export const suggestionsV2Controller: AppController = async (c) => { export const suggestionsV2Controller: AppController = async (c) => {
const { signal } = c.var; 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 suggestions = await renderV2Suggestions(c, { offset, limit }, signal);
return paginatedList(c, { offset, limit }, suggestions); return paginatedList(c, { offset, limit }, suggestions);
}; };

View file

@ -124,7 +124,7 @@ async function getTrendingLinks(conf: DittoConf, relay: NStore): Promise<Trendin
const trendingStatusesController: AppController = async (c) => { const trendingStatusesController: AppController = async (c) => {
const { conf, relay } = c.var; 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([{ const [label] = await relay.query([{
kinds: [1985], kinds: [1985],

View file

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

View file

@ -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<string>();
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<string, DittoEvent[]>);
const results = await Promise.all(
Object.entries(byEmojiKey).map(async ([key, events]) => {
let name: string = key;
let url: string | undefined;
// Custom emojis: `<shortcode>:<url>`
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;

View file

@ -16,7 +16,7 @@ Deno.test('req streaming', async () => {
const controller = new AbortController(); const controller = new AbortController();
const promise = (async () => { 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); msgs.push(msg);
} }
})(); })();

View file

@ -22,7 +22,7 @@ export async function createTestDB(opts?: { pure?: boolean }) {
conf, conf,
timeout: conf.db.timeouts.default, timeout: conf.db.timeouts.default,
pure: opts?.pure ?? false, pure: opts?.pure ?? false,
notify: true, notify: false,
}); });
return { return {

View file

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

View file

@ -70,3 +70,24 @@ export async function getCustomEmojis(
return emojis; 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 };
}
}

View file

@ -141,16 +141,24 @@ Deno.test('updateStats with kind 7 increments reactions count', async () => {
const { kysely, relay } = test; const { kysely, relay } = test;
const note = genEvent({ kind: 1 }); const note = genEvent({ kind: 1 });
await updateStats({ ...test, event: note });
await relay.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: '😂', 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); const stats = await getEventStats(kysely, note.id);
assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1 })); assertEquals(stats!.reactions, JSON.stringify({ '+': 1, '😂': 1, 'ditto:https://ditto.pub/favicon.ico': 1 }));
assertEquals(stats!.reactions_count, 2); assertEquals(stats!.reactions_count, 3);
}); });
Deno.test('updateStats with kind 5 decrements reactions count', async () => { Deno.test('updateStats with kind 5 decrements reactions count', async () => {

View file

@ -7,6 +7,7 @@ import { z } from 'zod';
import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; import { findQuoteTag, findReplyTag, getTagSet } from '@/utils/tags.ts';
import type { DittoConf } from '@ditto/conf'; import type { DittoConf } from '@ditto/conf';
import { parseEmojiInput } from '@/utils/custom-emoji.ts';
interface UpdateStatsOpts { interface UpdateStatsOpts {
conf: DittoConf; conf: DittoConf;
@ -154,14 +155,39 @@ async function handleEvent7(opts: UpdateStatsOpts): Promise<void> {
const { kysely, event, x = 1 } = opts; const { kysely, event, x = 1 } = opts;
const id = event.tags.findLast(([name]) => name === 'e')?.[1]; const id = event.tags.findLast(([name]) => name === 'e')?.[1];
const emoji = event.content; const result = parseEmojiInput(event.content);
if (!id || !result) return;
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;
}
}
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;
}
if (id && emoji && (['+', '-'].includes(emoji) || /^\p{RGI_Emoji}$/v.test(emoji))) {
await updateEventStats(kysely, id, ({ reactions }) => { await updateEventStats(kysely, id, ({ reactions }) => {
const data: Record<string, number> = JSON.parse(reactions); const data: Record<string, number> = JSON.parse(reactions);
// Increment or decrement the emoji count. // Increment or decrement the emoji count.
data[emoji] = (data[emoji] ?? 0) + x; data[key] = (data[key] ?? 0) + x;
// Remove reactions with a count of 0 or less. // Remove reactions with a count of 0 or less.
for (const key of Object.keys(data)) { for (const key of Object.keys(data)) {
@ -179,7 +205,6 @@ async function handleEvent7(opts: UpdateStatsOpts): Promise<void> {
}; };
}); });
} }
}
/** Update stats for kind 9735 event. */ /** Update stats for kind 9735 event. */
async function handleEvent9735(opts: UpdateStatsOpts): Promise<void> { async function handleEvent9735(opts: UpdateStatsOpts): Promise<void> {

View file

@ -41,7 +41,7 @@ async function renderEventAccounts(c: AppContext, filters: NostrFilter[], opts?:
} }
async function renderAccounts(c: AppContext, pubkeys: string[]) { 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 authors = pubkeys.reverse().slice(offset, offset + limit);
const { relay, signal } = c.var; const { relay, signal } = c.var;

View file

@ -91,11 +91,32 @@ async function renderStatus(
const subject = event.tags.find(([name]) => name === 'subject'); const subject = event.tags.find(([name]) => name === 'subject');
/** Pleroma emoji reactions object. */ /** Pleroma emoji reactions object. */
const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => { const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [key, count]) => {
if (['+', '-'].includes(emoji)) return acc; if (['+', '-'].includes(key)) return acc; // skip basic reactions (treat as likes/dislikes in Mastodon API)
acc.push({ name: emoji, count, me: reactionEvent?.content === emoji });
// Custom emoji reactions: `<shortcode>:<url>`
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; return acc;
}, [] as { name: string; count: number; me: boolean }[]); } catch {
// fallthrough
}
// Native emojis: `🚀`
acc.push({ name: key, count, me: reactionEvent?.content === key });
return acc;
}, [] as { name: string; count: number; me: boolean; url?: string }[]);
const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000);

View file

@ -1,5 +1,5 @@
import { paginated, paginatedList } from '../pagination/paginate.ts'; 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 { DittoMiddleware } from '@ditto/mastoapi/router';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
@ -19,19 +19,26 @@ type HeaderRecord = Record<string, string | string[]>;
type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response; type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response;
type ListPaginateFn = (params: ListPagination, 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. */ /** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
// @ts-ignore Types are right. // @ts-ignore Types are right.
export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>; export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
export function paginationMiddleware( export function paginationMiddleware(
type: 'list', opts: PaginationMiddlewareOpts & { type: 'list' },
): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>; ): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>;
export function paginationMiddleware( export function paginationMiddleware(
type?: string, opts?: PaginationMiddlewareOpts,
): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
export function paginationMiddleware(
opts: PaginationMiddlewareOpts = {},
): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> { ): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> {
return async (c, next) => { return async (c, next) => {
const { relay } = c.var; const { relay } = c.var;
const pagination = paginationSchema.parse(c.req.query()); const pagination = paginationSchema(opts).parse(c.req.query());
const { const {
max_id: maxId, max_id: maxId,
@ -59,7 +66,7 @@ export function paginationMiddleware(
} }
} }
if (type === 'list') { if (opts.type === 'list') {
c.set('pagination', { c.set('pagination', {
limit: pagination.limit, limit: pagination.limit,
offset: pagination.offset, offset: pagination.offset,

View file

@ -3,7 +3,7 @@ import { assertEquals } from '@std/assert';
import { paginationSchema } from './schema.ts'; import { paginationSchema } from './schema.ts';
Deno.test('paginationSchema', () => { Deno.test('paginationSchema', () => {
const pagination = paginationSchema.parse({ const pagination = paginationSchema().parse({
limit: '10', limit: '10',
offset: '20', offset: '20',
max_id: '1', max_id: '1',
@ -21,3 +21,8 @@ Deno.test('paginationSchema', () => {
until: 4, until: 4,
}); });
}); });
Deno.test('paginationSchema with custom limit', () => {
const pagination = paginationSchema({ limit: 100 }).parse({});
assertEquals(pagination.limit, 100);
});

View file

@ -9,8 +9,20 @@ export interface Pagination {
offset: number; offset: number;
} }
export interface PaginationSchemaOpts {
limit?: number;
max?: number;
}
/** Schema to parse pagination query params. */ /** Schema to parse pagination query params. */
export const paginationSchema: z.ZodType<Pagination> = z.object({ export function paginationSchema(opts: PaginationSchemaOpts = {}): z.ZodType<Pagination> {
let { limit = 20, max = 40 } = opts;
if (limit > max) {
max = limit;
}
return z.object({
max_id: z.string().transform((val) => { max_id: z.string().transform((val) => {
if (!val.includes('-')) return val; if (!val.includes('-')) return val;
return val.split('-')[1]; return val.split('-')[1];
@ -18,6 +30,7 @@ export const paginationSchema: z.ZodType<Pagination> = z.object({
min_id: z.string().optional().catch(undefined), min_id: z.string().optional().catch(undefined),
since: z.coerce.number().nonnegative().optional().catch(undefined), since: z.coerce.number().nonnegative().optional().catch(undefined),
until: 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)), limit: z.coerce.number().catch(limit).transform((value) => Math.min(Math.max(value, 0), max)),
offset: z.coerce.number().nonnegative().catch(0), offset: z.coerce.number().nonnegative().catch(0),
}) as z.ZodType<Pagination>; }) as z.ZodType<Pagination>;
}

View file

@ -95,10 +95,11 @@ export class TestApp extends DittoApp implements AsyncDisposable {
return user; return user;
} }
createUser(sk?: Uint8Array): User { createUser(sk: Uint8Array = generateSecretKey()): User & { sk: Uint8Array } {
return { return {
relay: this.opts.relay, 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, { return await this.request(path, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body), body: body ? JSON.stringify(body) : undefined,
}); });
}, },
put: async (path: string, body?: unknown): Promise<Response> => {
return await this.request(path, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: body ? JSON.stringify(body) : undefined,
});
},
delete: async (path: string): Promise<Response> => {
return await this.request(path, { method: 'DELETE' });
},
}; };
async [Symbol.asyncDispose](): Promise<void> { async [Symbol.asyncDispose](): Promise<void> {