mirror of
https://github.com/versia-pub/server.git
synced 2026-01-26 04:06:01 +01:00
feat(api): ✨ Add Emoji Reactions
This commit is contained in:
parent
70974d3c35
commit
9722b94eae
|
|
@ -1,3 +1,11 @@
|
||||||
|
# `0.9.0` (upcoming)
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis.
|
||||||
|
|
||||||
# `0.8.0` • Federation 2: Electric Boogaloo
|
# `0.8.0` • Federation 2: Electric Boogaloo
|
||||||
|
|
||||||
## Backwards Compatibility
|
## Backwards Compatibility
|
||||||
|
|
|
||||||
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);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
3
bun.lock
3
bun.lock
|
|
@ -55,6 +55,7 @@
|
||||||
"sonic-channel": "^1.3.1",
|
"sonic-channel": "^1.3.1",
|
||||||
"string-comparison": "^1.3.0",
|
"string-comparison": "^1.3.0",
|
||||||
"stringify-entities": "^4.0.4",
|
"stringify-entities": "^4.0.4",
|
||||||
|
"unicode-emoji-json": "^0.8.0",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"xss": "^1.0.15",
|
"xss": "^1.0.15",
|
||||||
|
|
@ -1263,6 +1264,8 @@
|
||||||
|
|
||||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||||
|
|
||||||
|
"unicode-emoji-json": ["unicode-emoji-json@0.8.0", "", {}, "sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g=="],
|
||||||
|
|
||||||
"unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
|
"unist-util-is": ["unist-util-is@6.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw=="],
|
||||||
|
|
||||||
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Status } from "@versia/client/schemas";
|
import type { NoteReactionWithAccounts, Status } from "@versia/client/schemas";
|
||||||
import { db, Instance } from "@versia/kit/db";
|
import { db, Instance, type Reaction } from "@versia/kit/db";
|
||||||
import {
|
import {
|
||||||
EmojiToNote,
|
EmojiToNote,
|
||||||
Likes,
|
Likes,
|
||||||
|
|
@ -54,6 +54,7 @@ type NoteTypeWithRelations = NoteType & {
|
||||||
reblogged: boolean;
|
reblogged: boolean;
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
liked: boolean;
|
liked: boolean;
|
||||||
|
reactions: Omit<typeof Reaction.$type, "note" | "author">[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type NoteTypeWithoutRecursiveRelations = Omit<
|
export type NoteTypeWithoutRecursiveRelations = Omit<
|
||||||
|
|
@ -698,7 +699,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
edited_at: data.updatedAt
|
edited_at: data.updatedAt
|
||||||
? new Date(data.updatedAt).toISOString()
|
? new Date(data.updatedAt).toISOString()
|
||||||
: null,
|
: null,
|
||||||
reactions: [],
|
reactions: this.getReactions(userFetching ?? undefined).map(
|
||||||
|
// Remove account_ids
|
||||||
|
(r) => ({
|
||||||
|
...r,
|
||||||
|
account_ids: undefined,
|
||||||
|
}),
|
||||||
|
),
|
||||||
text: data.contentSource,
|
text: data.contentSource,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -915,4 +922,67 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
|
||||||
|
|
||||||
return viewableDescendants;
|
return viewableDescendants;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get reactions for this note grouped by emoji name
|
||||||
|
* @param user - The user requesting reactions (to determine 'me' field)
|
||||||
|
* @returns Array of reactions grouped by emoji name with counts and account IDs
|
||||||
|
*/
|
||||||
|
public getReactions(
|
||||||
|
user?: User,
|
||||||
|
): z.infer<typeof NoteReactionWithAccounts>[] {
|
||||||
|
// Group reactions by emoji name (either emojiText for Unicode or formatted shortcode for custom)
|
||||||
|
const groupedReactions = new Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
count: number;
|
||||||
|
me: boolean;
|
||||||
|
account_ids: string[];
|
||||||
|
}
|
||||||
|
>();
|
||||||
|
|
||||||
|
for (const reaction of this.data.reactions) {
|
||||||
|
let emojiName: string;
|
||||||
|
|
||||||
|
// Determine emoji name based on type
|
||||||
|
if (reaction.emojiText) {
|
||||||
|
emojiName = reaction.emojiText;
|
||||||
|
} else if (reaction.emoji) {
|
||||||
|
emojiName = `:${reaction.emoji.shortcode}:`;
|
||||||
|
} else {
|
||||||
|
continue; // Skip invalid reactions
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize group if it doesn't exist
|
||||||
|
if (!groupedReactions.has(emojiName)) {
|
||||||
|
groupedReactions.set(emojiName, {
|
||||||
|
count: 0,
|
||||||
|
me: false,
|
||||||
|
account_ids: [],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = groupedReactions.get(emojiName);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
group.count += 1;
|
||||||
|
group.account_ids.push(reaction.authorId);
|
||||||
|
|
||||||
|
// Check if current user reacted with this emoji
|
||||||
|
if (user && reaction.authorId === user.id) {
|
||||||
|
group.me = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert map to array format
|
||||||
|
return Array.from(groupedReactions.entries()).map(([name, data]) => ({
|
||||||
|
name,
|
||||||
|
count: data.count,
|
||||||
|
me: data.me,
|
||||||
|
account_ids: data.account_ids,
|
||||||
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import { db, Emoji, Instance, type Note, User } from "@versia/kit/db";
|
||||||
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
|
import { type Notes, Reactions, type Users } from "@versia/kit/tables";
|
||||||
import { randomUUIDv7 } from "bun";
|
import { randomUUIDv7 } from "bun";
|
||||||
import {
|
import {
|
||||||
|
and,
|
||||||
desc,
|
desc,
|
||||||
eq,
|
eq,
|
||||||
type InferInsertModel,
|
type InferInsertModel,
|
||||||
type InferSelectModel,
|
type InferSelectModel,
|
||||||
inArray,
|
inArray,
|
||||||
|
isNull,
|
||||||
type SQL,
|
type SQL,
|
||||||
} from "drizzle-orm";
|
} from "drizzle-orm";
|
||||||
import { config } from "~/config.ts";
|
import { config } from "~/config.ts";
|
||||||
|
|
@ -156,6 +158,32 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
||||||
return this.data.id;
|
return this.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static fromEmoji(
|
||||||
|
emoji: Emoji | string,
|
||||||
|
author: User,
|
||||||
|
note: Note,
|
||||||
|
): Promise<Reaction | null> {
|
||||||
|
if (emoji instanceof Emoji) {
|
||||||
|
return Reaction.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Reactions.authorId, author.id),
|
||||||
|
eq(Reactions.noteId, note.id),
|
||||||
|
isNull(Reactions.emojiText),
|
||||||
|
eq(Reactions.emojiId, emoji.id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Reaction.fromSql(
|
||||||
|
and(
|
||||||
|
eq(Reactions.authorId, author.id),
|
||||||
|
eq(Reactions.noteId, note.id),
|
||||||
|
eq(Reactions.emojiText, emoji),
|
||||||
|
isNull(Reactions.emojiId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public getUri(baseUrl: URL): URL {
|
public getUri(baseUrl: URL): URL {
|
||||||
return this.data.uri
|
return this.data.uri
|
||||||
? new URL(this.data.uri)
|
? new URL(this.data.uri)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,13 @@ import type {
|
||||||
Source,
|
Source,
|
||||||
Status as StatusSchema,
|
Status as StatusSchema,
|
||||||
} from "@versia/client/schemas";
|
} from "@versia/client/schemas";
|
||||||
import { db, Media, Notification, PushSubscription } from "@versia/kit/db";
|
import {
|
||||||
|
db,
|
||||||
|
Media,
|
||||||
|
Notification,
|
||||||
|
PushSubscription,
|
||||||
|
Reaction,
|
||||||
|
} from "@versia/kit/db";
|
||||||
import {
|
import {
|
||||||
EmojiToUser,
|
EmojiToUser,
|
||||||
Likes,
|
Likes,
|
||||||
|
|
@ -732,6 +738,48 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an emoji reaction to a note
|
||||||
|
* @param note - The note to react to
|
||||||
|
* @param emoji - The emoji to react with (Emoji object for custom emojis, or Unicode emoji)
|
||||||
|
* @returns The created reaction
|
||||||
|
*/
|
||||||
|
public async react(note: Note, emoji: Emoji | string): Promise<void> {
|
||||||
|
const existingReaction = await Reaction.fromEmoji(emoji, this, note);
|
||||||
|
|
||||||
|
if (existingReaction) {
|
||||||
|
return; // Reaction already exists, don't create duplicate
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the reaction
|
||||||
|
await Reaction.insert({
|
||||||
|
id: randomUUIDv7(),
|
||||||
|
authorId: this.id,
|
||||||
|
noteId: note.id,
|
||||||
|
emojiText: emoji instanceof Emoji ? null : emoji,
|
||||||
|
emojiId: emoji instanceof Emoji ? emoji.id : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Handle federation and notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove an emoji reaction from a note
|
||||||
|
* @param note - The note to remove reaction from
|
||||||
|
* @param emoji - The emoji to remove reaction for (Emoji object for custom emojis, or Unicode emoji)
|
||||||
|
*/
|
||||||
|
public async unreact(note: Note, emoji: Emoji | string): Promise<void> {
|
||||||
|
const reactionToDelete = await Reaction.fromEmoji(emoji, this, note);
|
||||||
|
|
||||||
|
if (!reactionToDelete) {
|
||||||
|
return; // Reaction doesn't exist, nothing to delete
|
||||||
|
}
|
||||||
|
|
||||||
|
await reactionToDelete.delete();
|
||||||
|
|
||||||
|
// TODO: Handle federation and notifications
|
||||||
|
}
|
||||||
|
|
||||||
public async notify(
|
public async notify(
|
||||||
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog",
|
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog",
|
||||||
relatedUser: User,
|
relatedUser: User,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,16 @@ export const findManyNotes = async (
|
||||||
media: true,
|
media: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
reactions: {
|
||||||
|
with: {
|
||||||
|
emoji: {
|
||||||
|
with: {
|
||||||
|
instance: true,
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
emojis: {
|
emojis: {
|
||||||
with: {
|
with: {
|
||||||
emoji: {
|
emoji: {
|
||||||
|
|
@ -71,6 +81,16 @@ export const findManyNotes = async (
|
||||||
media: true,
|
media: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
reactions: {
|
||||||
|
with: {
|
||||||
|
emoji: {
|
||||||
|
with: {
|
||||||
|
instance: true,
|
||||||
|
media: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
emojis: {
|
emojis: {
|
||||||
with: {
|
with: {
|
||||||
emoji: {
|
emoji: {
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,7 @@ export const ReactionRelations = relations(Reactions, ({ one }) => ({
|
||||||
note: one(Notes, {
|
note: one(Notes, {
|
||||||
fields: [Reactions.noteId],
|
fields: [Reactions.noteId],
|
||||||
references: [Notes.id],
|
references: [Notes.id],
|
||||||
|
relationName: "NoteToReactions",
|
||||||
}),
|
}),
|
||||||
author: one(Users, {
|
author: one(Users, {
|
||||||
fields: [Reactions.authorId],
|
fields: [Reactions.authorId],
|
||||||
|
|
@ -508,6 +509,9 @@ export const NotesRelations = relations(Notes, ({ many, one }) => ({
|
||||||
relationName: "NoteToReblogs",
|
relationName: "NoteToReblogs",
|
||||||
}),
|
}),
|
||||||
notifications: many(Notifications),
|
notifications: many(Notifications),
|
||||||
|
reactions: many(Reactions, {
|
||||||
|
relationName: "NoteToReactions",
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const Instances = pgTable("Instances", {
|
export const Instances = pgTable("Instances", {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ in
|
||||||
|
|
||||||
pnpmDeps = pnpm.fetchDeps {
|
pnpmDeps = pnpm.fetchDeps {
|
||||||
inherit (finalAttrs) pname version src pnpmInstallFlags;
|
inherit (finalAttrs) pname version src pnpmInstallFlags;
|
||||||
hash = "sha256-bY0QfLYREeKn8ROupQdjOUv9t4+6HKLsTXOolzNCuU4=";
|
hash = "sha256-5kzx6Iqs+VZHCgnIiyxyO0069kFDfY8Xynhh4svATDE=";
|
||||||
};
|
};
|
||||||
|
|
||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
|
|
|
||||||
|
|
@ -130,6 +130,7 @@
|
||||||
"sonic-channel": "^1.3.1",
|
"sonic-channel": "^1.3.1",
|
||||||
"string-comparison": "^1.3.0",
|
"string-comparison": "^1.3.0",
|
||||||
"stringify-entities": "^4.0.4",
|
"stringify-entities": "^4.0.4",
|
||||||
|
"unicode-emoji-json": "^0.8.0",
|
||||||
"uqr": "^0.1.2",
|
"uqr": "^0.1.2",
|
||||||
"web-push": "^3.6.7",
|
"web-push": "^3.6.7",
|
||||||
"xss": "^1.0.15",
|
"xss": "^1.0.15",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ export { TermsOfService } from "./schemas/tos.ts";
|
||||||
export {
|
export {
|
||||||
Challenge,
|
Challenge,
|
||||||
NoteReaction,
|
NoteReaction,
|
||||||
|
NoteReactionWithAccounts,
|
||||||
Role,
|
Role,
|
||||||
SSOConfig,
|
SSOConfig,
|
||||||
} from "./schemas/versia.ts";
|
} from "./schemas/versia.ts";
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,21 @@ export const NoteReaction = z
|
||||||
ref: "NoteReaction",
|
ref: "NoteReaction",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* Versia Server API extension */
|
||||||
|
export const NoteReactionWithAccounts = NoteReaction.extend({
|
||||||
|
account_ids: z.array(Id).openapi({
|
||||||
|
description: "Array of user IDs who reacted with this emoji.",
|
||||||
|
example: [
|
||||||
|
"1d0185bc-d949-4ff5-8a15-1d691b256489",
|
||||||
|
"d9de4aeb-4591-424d-94ec-659f958aa23d",
|
||||||
|
"1f0c4eb9-a742-4c82-96c9-697a39831cd1",
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}).openapi({
|
||||||
|
description: "Information about a reaction to a note with account IDs.",
|
||||||
|
ref: "NoteReactionWithAccounts",
|
||||||
|
});
|
||||||
|
|
||||||
/* Versia Server API extension */
|
/* Versia Server API extension */
|
||||||
export const SSOConfig = z.object({
|
export const SSOConfig = z.object({
|
||||||
forced: z.boolean().openapi({
|
forced: z.boolean().openapi({
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,11 @@ import type {
|
||||||
import type { Tag } from "../schemas/tag.ts";
|
import type { Tag } from "../schemas/tag.ts";
|
||||||
import type { Token } from "../schemas/token.ts";
|
import type { Token } from "../schemas/token.ts";
|
||||||
import type { TermsOfService } from "../schemas/tos.ts";
|
import type { TermsOfService } from "../schemas/tos.ts";
|
||||||
import type { Challenge, Role } from "../schemas/versia.ts";
|
import type {
|
||||||
|
Challenge,
|
||||||
|
NoteReactionWithAccounts,
|
||||||
|
Role,
|
||||||
|
} from "../schemas/versia.ts";
|
||||||
import { BaseClient, type Output } from "./base.ts";
|
import { BaseClient, type Output } from "./base.ts";
|
||||||
import { DEFAULT_SCOPE, NO_REDIRECT } from "./constants.ts";
|
import { DEFAULT_SCOPE, NO_REDIRECT } from "./constants.ts";
|
||||||
|
|
||||||
|
|
@ -217,7 +221,7 @@ export class Client extends BaseClient {
|
||||||
emoji: string,
|
emoji: string,
|
||||||
extra?: RequestInit,
|
extra?: RequestInit,
|
||||||
): Promise<Output<z.infer<typeof Status>>> {
|
): Promise<Output<z.infer<typeof Status>>> {
|
||||||
return this.post<z.infer<typeof Status>>(
|
return this.put<z.infer<typeof Status>>(
|
||||||
`/api/v1/statuses/${id}/reactions/${emoji}`,
|
`/api/v1/statuses/${id}/reactions/${emoji}`,
|
||||||
undefined,
|
undefined,
|
||||||
extra,
|
extra,
|
||||||
|
|
@ -2090,6 +2094,22 @@ export class Client extends BaseClient {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/statuses/:id/reactions
|
||||||
|
*
|
||||||
|
* @param id The target status id.
|
||||||
|
* @return Array of reactions with accounts.
|
||||||
|
*/
|
||||||
|
public getStatusReactions(
|
||||||
|
id: string,
|
||||||
|
extra?: RequestInit,
|
||||||
|
): Promise<Output<z.infer<typeof NoteReactionWithAccounts>[]>> {
|
||||||
|
return this.get<z.infer<typeof NoteReactionWithAccounts>[]>(
|
||||||
|
`/api/v1/statuses/${id}/reactions`,
|
||||||
|
extra,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/v1/featured_tags/suggestions
|
* GET /api/v1/featured_tags/suggestions
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,9 @@ importers:
|
||||||
stringify-entities:
|
stringify-entities:
|
||||||
specifier: ^4.0.4
|
specifier: ^4.0.4
|
||||||
version: 4.0.4
|
version: 4.0.4
|
||||||
|
unicode-emoji-json:
|
||||||
|
specifier: ^0.8.0
|
||||||
|
version: 0.8.0
|
||||||
uqr:
|
uqr:
|
||||||
specifier: ^0.1.2
|
specifier: ^0.1.2
|
||||||
version: 0.1.2
|
version: 0.1.2
|
||||||
|
|
@ -3172,6 +3175,9 @@ packages:
|
||||||
undici-types@6.21.0:
|
undici-types@6.21.0:
|
||||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||||
|
|
||||||
|
unicode-emoji-json@0.8.0:
|
||||||
|
resolution: {integrity: sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==}
|
||||||
|
|
||||||
unist-util-is@6.0.0:
|
unist-util-is@6.0.0:
|
||||||
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
|
resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==}
|
||||||
|
|
||||||
|
|
@ -6106,6 +6112,8 @@ snapshots:
|
||||||
|
|
||||||
undici-types@6.21.0: {}
|
undici-types@6.21.0: {}
|
||||||
|
|
||||||
|
unicode-emoji-json@0.8.0: {}
|
||||||
|
|
||||||
unist-util-is@6.0.0:
|
unist-util-is@6.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/unist': 3.0.3
|
'@types/unist': 3.0.3
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue