mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
Support custom emoji reactions
This commit is contained in:
parent
c40c6e8b30
commit
753413f071
4 changed files with 185 additions and 46 deletions
|
|
@ -9,16 +9,23 @@ import { DittoRelayStore } from '@/storages/DittoRelayStore.ts';
|
||||||
|
|
||||||
import route from './pleromaStatusesRoute.ts';
|
import route from './pleromaStatusesRoute.ts';
|
||||||
|
|
||||||
|
import type { MastodonStatus } from '@ditto/mastoapi/types';
|
||||||
|
|
||||||
Deno.test('Emoji reactions', async (t) => {
|
Deno.test('Emoji reactions', async (t) => {
|
||||||
await using test = createTestApp();
|
await using test = createTestApp();
|
||||||
const { relay } = test.var;
|
const { relay } = test.var;
|
||||||
|
|
||||||
test.user();
|
const mario = test.createUser();
|
||||||
|
const luigi = test.createUser();
|
||||||
|
|
||||||
const note = genEvent({ kind: 1 });
|
const note = genEvent({ kind: 1 });
|
||||||
await relay.event(note);
|
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 () => {
|
await t.step('PUT /:id/reactions/:emoji', async () => {
|
||||||
|
test.user(mario);
|
||||||
|
|
||||||
const response = await test.api.put(`/${note.id}/reactions/🚀`);
|
const response = await test.api.put(`/${note.id}/reactions/🚀`);
|
||||||
const json = await response.json();
|
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 }]);
|
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 () => {
|
await t.step('GET /:id/reactions', async () => {
|
||||||
|
test.user(mario);
|
||||||
|
|
||||||
const response = await test.api.get(`/${note.id}/reactions`);
|
const response = await test.api.get(`/${note.id}/reactions`);
|
||||||
const json = await response.json();
|
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(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 () => {
|
await t.step('DELETE /:id/reactions/:emoji', async () => {
|
||||||
|
test.user(mario);
|
||||||
|
|
||||||
const response = await test.api.delete(`/${note.id}/reactions/🚀`);
|
const response = await test.api.delete(`/${note.id}/reactions/🚀`);
|
||||||
const json = await response.json();
|
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(response.status, 200);
|
||||||
assertEquals(json.pleroma.emoji_reactions, []);
|
assertEquals(json.pleroma.emoji_reactions, []);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
|
||||||
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
import { renderStatus } from '@/views/mastodon/statuses.ts';
|
||||||
import { HTTPException } from '@hono/hono/http-exception';
|
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();
|
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]);
|
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 createEvent({ kind: 7, content, tags }, c);
|
||||||
await hydrateEvents({ ...c.var, events: [event] });
|
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
|
* https://docs.pleroma.social/backend/development/API/pleroma_api/#delete-apiv1pleromastatusesidreactionsemoji
|
||||||
*/
|
*/
|
||||||
route.delete('/:id{[0-9a-f]{64}}/reactions/:emoji', userMiddleware(), async (c) => {
|
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 { relay, user, signal } = c.var;
|
||||||
|
|
||||||
|
const params = c.req.param();
|
||||||
const pubkey = await user.signer.getPublicKey();
|
const pubkey = await user.signer.getPublicKey();
|
||||||
|
|
||||||
if (!/^\p{RGI_Emoji}$/v.test(emoji)) {
|
const [event] = await relay.query([{ ids: [params.id] }], { signal });
|
||||||
return c.json({ error: 'Invalid emoji' }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
const [event] = await relay.query([{ ids: [id] }], { signal });
|
|
||||||
|
|
||||||
if (!event) {
|
if (!event) {
|
||||||
return c.json({ error: 'Status not found' }, 404);
|
return c.json({ error: 'Status not found' }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
const events = await relay.query([
|
const events = await relay.query([
|
||||||
{ kinds: [7], authors: [pubkey], '#e': [id] },
|
{ kinds: [7], authors: [pubkey], '#e': [params.id] },
|
||||||
]);
|
], { signal });
|
||||||
|
|
||||||
const tags = events
|
const e = new Set<string>();
|
||||||
.filter((event) => event.content === emoji)
|
|
||||||
.map((event) => ['e', event.id]);
|
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({
|
await createEvent({
|
||||||
kind: 5,
|
kind: 5,
|
||||||
content: '',
|
tags: [...e].map((id) => ['e', id]),
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
|
||||||
tags,
|
|
||||||
}, c);
|
}, c);
|
||||||
|
|
||||||
|
await hydrateEvents({ ...c.var, events: [event] });
|
||||||
|
|
||||||
const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
|
const status = await renderStatus(relay, event, { viewerPubkey: pubkey });
|
||||||
return c.json(status);
|
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
|
* 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) => {
|
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 { relay, user } = c.var;
|
||||||
|
|
||||||
|
const params = c.req.param();
|
||||||
|
const result = params.emoji ? parseEmojiParam(params.emoji) : undefined;
|
||||||
const pubkey = await user?.signer.getPublicKey();
|
const pubkey = await user?.signer.getPublicKey();
|
||||||
|
|
||||||
if (typeof emoji === 'string' && !/^\p{RGI_Emoji}$/v.test(emoji)) {
|
const events = await relay.query([{ kinds: [7], '#e': [params.id], limit: 100 }])
|
||||||
return c.json({ error: 'Invalid emoji' }, 400);
|
.then((events) =>
|
||||||
}
|
events.filter((event) => {
|
||||||
|
if (!result) return true;
|
||||||
|
|
||||||
const events = await relay.query([{ kinds: [7], '#e': [id], limit: 100 }])
|
switch (result.type) {
|
||||||
.then((events) => events.filter(({ content }) => /^\p{RGI_Emoji}$/v.test(content)))
|
case 'basic':
|
||||||
.then((events) => events.filter((event) => !emoji || event.content === emoji))
|
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 }));
|
.then((events) => hydrateEvents({ ...c.var, events }));
|
||||||
|
|
||||||
/** Events grouped by emoji. */
|
/** Events grouped by emoji key. */
|
||||||
const byEmoji = events.reduce((acc, event) => {
|
const byEmojiKey = events.reduce((acc, event) => {
|
||||||
const emoji = event.content;
|
const result = parseEmojiInput(event.content);
|
||||||
acc[emoji] = acc[emoji] || [];
|
if (!result) return acc;
|
||||||
acc[emoji].push(event);
|
|
||||||
|
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;
|
return acc;
|
||||||
}, {} as Record<string, DittoEvent[]>);
|
}, {} as Record<string, DittoEvent[]>);
|
||||||
|
|
||||||
const results = await Promise.all(
|
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: `<shortcode>:<url>`
|
||||||
|
try {
|
||||||
|
const [shortcode, ...rest] = key.split(':');
|
||||||
|
|
||||||
|
url = new URL(rest.join(':')).toString();
|
||||||
|
name = shortcode;
|
||||||
|
} catch {
|
||||||
|
// fallthrough
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
count: events.length,
|
count: events.length,
|
||||||
|
|
@ -128,6 +193,7 @@ route.get('/:id{[0-9a-f]{64}}/reactions/:emoji?', userMiddleware({ required: fal
|
||||||
accounts: await Promise.all(
|
accounts: await Promise.all(
|
||||||
events.map((event) => event.author ? renderAccount(event.author) : accountFromPubkey(event.pubkey)),
|
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. */
|
/** 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 } {
|
function parseEmojiParam(input: string):
|
||||||
if (/^\p{RGI_Emoji}$/v.test(input)) {
|
| { type: 'basic'; value: '+' | '-' }
|
||||||
return { type: 'native', emoji: input };
|
| { 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;
|
export default route;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue