feat(api): Add Emoji Reactions

This commit is contained in:
Jesse Wierzbinski 2025-05-25 16:11:56 +02:00
parent 70974d3c35
commit 9722b94eae
No known key found for this signature in database
17 changed files with 755 additions and 7 deletions

View file

@ -0,0 +1,179 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
const timeline = (await getTestStatuses(2, users[0])).toReversed();
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/statuses/:id/reactions/:name", () => {
describe("PUT (add reaction)", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.createEmojiReaction(
timeline[0].id,
"👍",
);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should add unicode emoji reaction", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.createEmojiReaction(
timeline[0].id,
"👍",
);
expect(ok).toBe(true);
expect(data.reactions).toContainEqual(
expect.objectContaining({
name: "👍",
count: 1,
me: true,
}),
);
});
test("should add multiple different reactions", async () => {
await using client1 = await generateClient(users[1]);
await using client2 = await generateClient(users[2]);
await client1.createEmojiReaction(timeline[1].id, "❤️");
const { data, ok } = await client2.createEmojiReaction(
timeline[1].id,
"😂",
);
expect(ok).toBe(true);
expect(data.reactions).toHaveLength(2);
expect(data.reactions).toContainEqual(
expect.objectContaining({
name: "❤️",
count: 1,
me: false,
}),
);
expect(data.reactions).toContainEqual(
expect.objectContaining({
name: "😂",
count: 1,
me: true,
}),
);
});
test("should not duplicate reactions from same user", async () => {
await using client = await generateClient(users[1]);
// Add same reaction twice
await client.createEmojiReaction(timeline[1].id, "👍");
const { data, ok } = await client.createEmojiReaction(
timeline[1].id,
"👍",
);
expect(ok).toBe(true);
const thumbsReaction = data.reactions.find((r) => r.name === "👍");
expect(thumbsReaction).toMatchObject({
name: "👍",
count: 1,
me: true,
});
});
test("should return 404 for non-existent status", async () => {
await using client = await generateClient(users[1]);
const { ok, raw } = await client.createEmojiReaction(
"00000000-0000-0000-0000-000000000000",
"👍",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
});
describe("DELETE (remove reaction)", () => {
test("should return 401 if not authenticated", async () => {
await using client = await generateClient();
const { ok, raw } = await client.deleteEmojiReaction(
timeline[0].id,
"👍",
);
expect(ok).toBe(false);
expect(raw.status).toBe(401);
});
test("should remove existing reaction", async () => {
await using client = await generateClient(users[1]);
// First add a reaction
await client.createEmojiReaction(timeline[0].id, "🎉");
// Then remove it
const { data, ok } = await client.deleteEmojiReaction(
timeline[0].id,
"🎉",
);
expect(ok).toBe(true);
expect(data.reactions.find((r) => r.name === "🎉")).toBeUndefined();
});
test("should not fail when removing non-existent reaction", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.deleteEmojiReaction(
timeline[0].id,
"🚀",
);
expect(ok).toBe(true);
expect(data.reactions.find((r) => r.name === "🚀")).toBeUndefined();
});
test("should only remove own reaction", async () => {
await using client1 = await generateClient(users[1]);
await using client2 = await generateClient(users[2]);
// Both users add same reaction
await client1.createEmojiReaction(timeline[0].id, "⭐");
await client2.createEmojiReaction(timeline[0].id, "⭐");
// User 1 removes their reaction
const { data, ok } = await client1.deleteEmojiReaction(
timeline[0].id,
"⭐",
);
expect(ok).toBe(true);
const starReaction = data.reactions.find((r) => r.name === "⭐");
expect(starReaction).toMatchObject({
name: "⭐",
count: 1,
me: false, // Should be false for user 1 now
});
});
test("should return 404 for non-existent status", async () => {
await using client = await generateClient(users[1]);
const { ok, raw } = await client.deleteEmojiReaction(
"00000000-0000-0000-0000-000000000000",
"👍",
);
expect(ok).toBe(false);
expect(raw.status).toBe(404);
});
});
});

View file

@ -0,0 +1,218 @@
import { RolePermission, Status as StatusSchema } from "@versia/client/schemas";
import { Emoji } from "@versia/kit/db";
import { Emojis } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm";
import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod";
import emojis from "unicode-emoji-json/data-ordered-emoji.json" with {
type: "json",
};
import { z } from "zod";
import { apiRoute, auth, handleZodError, withNoteParam } from "@/api";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) => {
app.put(
"/api/v1/statuses/:id/reactions/:name",
describeRoute({
summary: "Add reaction to status",
description: "Add a reaction to a note.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#reaction-add",
},
tags: ["Statuses"],
responses: {
201: {
description: "Reaction added successfully",
content: {
"application/json": {
schema: resolver(StatusSchema),
},
},
},
404: ApiError.noteNotFound().schema,
401: ApiError.missingAuthentication().schema,
422: {
description: "Invalid emoji or already reacted",
content: {
"application/json": {
schema: resolver(ApiError.zodSchema),
},
},
},
},
}),
auth({
auth: true,
permissions: [
RolePermission.ManageOwnReactions,
RolePermission.ViewNotes,
],
}),
withNoteParam,
validator(
"param",
z.object({ name: z.string().min(1) }),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const note = context.get("note");
const emojiName = context.req.param("name");
if (!emojiName) {
throw new ApiError(
422,
"Missing emoji name",
"Emoji name is required in the URL path",
);
}
// Determine if this is a custom emoji or Unicode emoji
let emoji: Emoji | string;
if (emojiName.startsWith(":") && emojiName.endsWith(":")) {
// Custom emoji - find the emoji by shortcode
const shortcode = emojiName.slice(1, -1);
const foundCustomEmoji = await Emoji.fromSql(
and(
eq(Emojis.shortcode, shortcode),
isNull(Emojis.instanceId), // Only local emojis for now
),
);
if (!foundCustomEmoji) {
throw new ApiError(
422,
"Custom emoji not found",
`The custom emoji :${shortcode}: was not found`,
);
}
emoji = foundCustomEmoji;
} else {
// Unicode emoji - check if it's valid
const unicodeEmoji = emojis.find((e) => e === emojiName);
if (!unicodeEmoji) {
throw new ApiError(
422,
"Invalid emoji",
`The emoji "${emojiName}" is not a valid Unicode emoji or custom emoji`,
);
}
emoji = unicodeEmoji;
}
// Use the User react method
try {
await user.react(note, emoji);
// Reload note to get updated reactions
await note.reload(user.id);
return context.json(await note.toApi(user), 201);
} catch {
// If it's already reacted, just return the current status
await note.reload(user.id);
return context.json(await note.toApi(user), 201);
}
},
);
app.delete(
"/api/v1/statuses/:id/reactions/:name",
describeRoute({
summary: "Remove reaction from status",
description: "Remove a reaction from a note.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#reaction-remove",
},
tags: ["Statuses"],
responses: {
200: {
description: "Reaction removed or was not present",
content: {
"application/json": {
schema: resolver(StatusSchema),
},
},
},
404: ApiError.noteNotFound().schema,
401: ApiError.missingAuthentication().schema,
},
}),
auth({
auth: true,
permissions: [
RolePermission.ManageOwnReactions,
RolePermission.ViewNotes,
],
}),
withNoteParam,
validator(
"param",
z.object({ name: z.string().min(1) }),
handleZodError,
),
async (context) => {
const { user } = context.get("auth");
const note = context.get("note");
const emojiName = context.req.param("name");
if (!emojiName) {
throw new ApiError(
422,
"Missing emoji name",
"Emoji name is required in the URL path",
);
}
// Determine if this is a custom emoji or Unicode emoji
let emoji: Emoji | string;
if (emojiName.startsWith(":") && emojiName.endsWith(":")) {
// Custom emoji - find the emoji by shortcode
const shortcode = emojiName.slice(1, -1);
const foundCustomEmoji = await Emoji.fromSql(
and(
eq(Emojis.shortcode, shortcode),
isNull(Emojis.instanceId),
),
);
if (!foundCustomEmoji) {
throw new ApiError(
422,
"Custom emoji not found",
`The custom emoji :${shortcode}: was not found`,
);
}
emoji = foundCustomEmoji;
} else {
// Unicode emoji - check if it's valid
const unicodeEmoji = emojis.find((e) => e === emojiName);
if (!unicodeEmoji) {
throw new ApiError(
422,
"Invalid emoji",
`The emoji "${emojiName}" is not a valid Unicode emoji or custom emoji`,
);
}
emoji = unicodeEmoji;
}
// Use the User unreact method
await user.unreact(note, emoji);
// Reload note to get updated reactions
await note.reload(user.id);
return context.json(await note.toApi(user), 200);
},
);
});

View file

@ -0,0 +1,76 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3);
const timeline = (await getTestStatuses(2, users[0])).toReversed();
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/statuses/:id/reactions", () => {
test("should return empty array when no reactions", async () => {
await using client = await generateClient();
const { data, ok } = await client.getStatusReactions(timeline[0].id);
expect(ok).toBe(true);
expect(data).toEqual([]);
});
test("should return reactions after adding some", async () => {
await using client1 = await generateClient(users[1]);
await using client2 = await generateClient(users[2]);
// Add some reactions
await client1.createEmojiReaction(timeline[0].id, "👍");
await client2.createEmojiReaction(timeline[0].id, "❤️");
await client1.createEmojiReaction(timeline[0].id, "😂");
const { data, ok } = await client1.getStatusReactions(timeline[0].id);
expect(ok).toBe(true);
expect(data).toHaveLength(3);
// Check for 👍 reaction
const thumbsUp = data.find((r) => r.name === "👍");
expect(thumbsUp).toMatchObject({
name: "👍",
count: 1,
me: true,
account_ids: [users[1].id],
});
// Check for ❤️ reaction
const heart = data.find((r) => r.name === "❤️");
expect(heart).toMatchObject({
name: "❤️",
count: 1,
me: false,
account_ids: [users[2].id],
});
// Check for 😂 reaction
const laugh = data.find((r) => r.name === "😂");
expect(laugh).toMatchObject({
name: "😂",
count: 1,
me: true,
account_ids: [users[1].id],
});
});
test("should work without authentication", async () => {
await using client = await generateClient();
const { data, ok } = await client.getStatusReactions(timeline[0].id);
expect(ok).toBe(true);
expect(data).toHaveLength(3);
// All reactions should have me: false when not authenticated
for (const reaction of data) {
expect(reaction.me).toBe(false);
}
});
});

View file

@ -0,0 +1,49 @@
import {
NoteReactionWithAccounts,
RolePermission,
} from "@versia/client/schemas";
import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod";
import { z } from "zod";
import { apiRoute, auth, withNoteParam } from "@/api";
import { ApiError } from "~/classes/errors/api-error";
export default apiRoute((app) =>
app.get(
"/api/v1/statuses/:id/reactions",
describeRoute({
summary: "Get reactions for a status",
description:
"Get a list of all the users who reacted to a note. Only IDs are returned, not full account objects, to improve performance on very popular notes.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/statuses/#reactions",
},
tags: ["Statuses"],
responses: {
200: {
description: "List of reactions and associated users",
content: {
"application/json": {
schema: resolver(z.array(NoteReactionWithAccounts)),
},
},
},
404: ApiError.noteNotFound().schema,
},
}),
auth({
auth: false,
permissions: [RolePermission.ViewNotes],
}),
withNoteParam,
(context) => {
const { user } = context.get("auth");
const note = context.get("note");
// Get reactions for the note using the new method
const reactions = note.getReactions(user ?? undefined);
return context.json(reactions);
},
),
);