mirror of
https://github.com/versia-pub/server.git
synced 2026-03-13 05:49:16 +01:00
feat(api): ✨ Add Emoji Reactions
This commit is contained in:
parent
70974d3c35
commit
9722b94eae
17 changed files with 755 additions and 7 deletions
179
api/api/v1/statuses/[id]/reactions/[name].test.ts
Normal file
179
api/api/v1/statuses/[id]/reactions/[name].test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
api/api/v1/statuses/[id]/reactions/[name].ts
Normal file
218
api/api/v1/statuses/[id]/reactions/[name].ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
76
api/api/v1/statuses/[id]/reactions/index.test.ts
Normal file
76
api/api/v1/statuses/[id]/reactions/index.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
49
api/api/v1/statuses/[id]/reactions/index.ts
Normal file
49
api/api/v1/statuses/[id]/reactions/index.ts
Normal 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);
|
||||
},
|
||||
),
|
||||
);
|
||||
Loading…
Add table
Add a link
Reference in a new issue