diff --git a/packages/ditto/routes/pleromaStatusesRoute.test.ts b/packages/ditto/routes/pleromaStatusesRoute.test.ts index 09217e88..138edf38 100644 --- a/packages/ditto/routes/pleromaStatusesRoute.test.ts +++ b/packages/ditto/routes/pleromaStatusesRoute.test.ts @@ -9,16 +9,23 @@ 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; - test.user(); + 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(); @@ -26,19 +33,61 @@ Deno.test('Emoji reactions', async (t) => { 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(); - const [{ accounts }] = 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 }]); + + 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, []); }); diff --git a/packages/ditto/routes/pleromaStatusesRoute.ts b/packages/ditto/routes/pleromaStatusesRoute.ts index dc7391b7..1df4d3fd 100644 --- a/packages/ditto/routes/pleromaStatusesRoute.ts +++ b/packages/ditto/routes/pleromaStatusesRoute.ts @@ -8,7 +8,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; import { HTTPException } from '@hono/hono/http-exception'; -import { getCustomEmojis } from '@/utils/custom-emoji.ts'; +import { getCustomEmojis, parseEmojiInput } from '@/utils/custom-emoji.ts'; const route = new DittoRoute(); @@ -44,7 +44,19 @@ route.put('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => tags.push(['emoji', result.shortcode, emoji.url.href]); } - const content = result.type === 'custom' ? `:${result.shortcode}:` : result.emoji; + let content: string; + + switch (result.type) { + case 'basic': + content = result.value; + break; + 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] }); @@ -58,36 +70,40 @@ route.put('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => * https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji */ route.delete('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => { - const { id, emoji } = c.req.param(); const { relay, user, signal } = c.var; + const params = c.req.param(); 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([{ ids: [id] }], { signal }); + 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': [id] }, - ]); + { kinds: [7], authors: [pubkey], '#e': [params.id] }, + ], { signal }); - const tags = events - .filter((event) => event.content === emoji) - .map((event) => ['e', event.id]); + const e = new Set(); + + 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, - content: '', - created_at: Math.floor(Date.now() / 1000), - tags, + 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); }); @@ -97,30 +113,79 @@ route.delete('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) * https://docs.pleroma.social/backend/development/API/pleroma_api/#get-apiv1pleromastatusesidreactions */ route.get('/:id{[0-9a-f]{64}}/reactions/:emoji?', userMiddleware({ required: false }), async (c) => { - const { id, emoji } = c.req.param(); const { relay, user } = c.var; + const params = c.req.param(); + const result = params.emoji ? parseEmojiParam(params.emoji) : undefined; const pubkey = await user?.signer.getPublicKey(); - 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': [params.id], limit: 100 }]) + .then((events) => + events.filter((event) => { + if (!result) return true; - 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)) + switch (result.type) { + case 'basic': + return event.content === result.value; + case 'native': + return event.content === result.native; + case 'custom': + return event.content === `:${result.shortcode}:`; + } + }) + ) .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); + /** Events grouped by emoji key. */ + const byEmojiKey = events.reduce((acc, event) => { + const result = parseEmojiInput(event.content); + if (!result) 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 'basic': + key = result.value; + break; + 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); const results = await Promise.all( - Object.entries(byEmoji).map(async ([name, events]) => { + Object.entries(byEmojiKey).map(async ([key, events]) => { + let name: string = key; + let url: string | undefined; + + // Custom emojis: `:` + try { + const [shortcode, ...rest] = key.split(':'); + + url = new URL(rest.join(':')).toString(); + name = shortcode; + } catch { + // fallthrough + } + return { name, count: events.length, @@ -128,6 +193,7 @@ route.get('/:id{[0-9a-f]{64}}/reactions/:emoji?', userMiddleware({ required: fal accounts: await Promise.all( events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)), ), + url, }; }), ); @@ -136,19 +202,21 @@ route.get('/:id{[0-9a-f]{64}}/reactions/:emoji?', userMiddleware({ required: fal }); /** Determine if the input is a native or custom emoji, returning a structured object or throwing an error. */ -function parseEmojiParam(input: string): { type: 'native'; emoji: string } | { type: 'custom'; shortcode: string } { - if (/^\p{RGI_Emoji}$/v.test(input)) { - return { type: 'native', emoji: input }; +function parseEmojiParam(input: string): + | { type: 'basic'; value: '+' | '-' } + | { 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 match = input.match(/^:?(\w+):?$/); // Pleroma API supports with or without colons. + const result = parseEmojiInput(input); - if (match) { - const [, shortcode] = match; - return { type: 'custom', shortcode }; + if (!result) { + throw new HTTPException(400, { message: 'Invalid emoji' }); } - throw new HTTPException(400, { message: 'Invalid emoji' }); + return result; } export default route; diff --git a/packages/ditto/views/mastodon/statuses.ts b/packages/ditto/views/mastodon/statuses.ts index a2d4a740..ba2e8d86 100644 --- a/packages/ditto/views/mastodon/statuses.ts +++ b/packages/ditto/views/mastodon/statuses.ts @@ -91,11 +91,32 @@ async function renderStatus( const subject = event.tags.find(([name]) => name === 'subject'); /** Pleroma emoji reactions object. */ - const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [emoji, count]) => { - if (['+', '-'].includes(emoji)) return acc; - acc.push({ name: emoji, count, me: reactionEvent?.content === emoji }); + const reactions = Object.entries(event.event_stats?.reactions ?? {}).reduce((acc, [key, count]) => { + if (['+', '-'].includes(key)) return acc; // skip basic reactions (treat as likes/dislikes in Mastodon API) + + // Custom emoji reactions: `:` + 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; + } catch { + // fallthrough + } + + // Native emojis: `🚀` + acc.push({ name: key, count, me: reactionEvent?.content === key }); + return acc; - }, [] as { name: string; count: number; me: boolean }[]); + }, [] as { name: string; count: number; me: boolean; url?: string }[]); const expiresAt = new Date(Number(event.tags.find(([name]) => name === 'expiration')?.[1]) * 1000); diff --git a/packages/mastoapi/test/TestApp.ts b/packages/mastoapi/test/TestApp.ts index 14b8e802..04e1589e 100644 --- a/packages/mastoapi/test/TestApp.ts +++ b/packages/mastoapi/test/TestApp.ts @@ -95,10 +95,11 @@ export class TestApp extends DittoApp implements AsyncDisposable { return user; } - createUser(sk?: Uint8Array): User { + createUser(sk: Uint8Array = generateSecretKey()): User & { sk: Uint8Array } { return { relay: this.opts.relay, - signer: new NSecSigner(sk ?? generateSecretKey()), + signer: new NSecSigner(sk), + sk, }; }