From 9722b94eaef73328e40ecea1c4e5e7850303e561 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Sun, 25 May 2025 16:11:56 +0200 Subject: [PATCH] feat(api): :sparkles: Add Emoji Reactions --- CHANGELOG.md | 8 + .../v1/statuses/[id]/reactions/[name].test.ts | 179 ++++++++++++++ api/api/v1/statuses/[id]/reactions/[name].ts | 218 ++++++++++++++++++ .../v1/statuses/[id]/reactions/index.test.ts | 76 ++++++ api/api/v1/statuses/[id]/reactions/index.ts | 49 ++++ bun.lock | 3 + classes/database/note.ts | 76 +++++- classes/database/reaction.ts | 28 +++ classes/database/user.ts | 50 +++- classes/functions/status.ts | 20 ++ drizzle/schema.ts | 4 + nix/package.nix | 2 +- package.json | 1 + packages/client/schemas.ts | 1 + packages/client/schemas/versia.ts | 15 ++ packages/client/versia/client.ts | 24 +- pnpm-lock.yaml | 8 + 17 files changed, 755 insertions(+), 7 deletions(-) create mode 100644 api/api/v1/statuses/[id]/reactions/[name].test.ts create mode 100644 api/api/v1/statuses/[id]/reactions/[name].ts create mode 100644 api/api/v1/statuses/[id]/reactions/index.test.ts create mode 100644 api/api/v1/statuses/[id]/reactions/index.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6756e174..c0c1aea5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ## Backwards Compatibility diff --git a/api/api/v1/statuses/[id]/reactions/[name].test.ts b/api/api/v1/statuses/[id]/reactions/[name].test.ts new file mode 100644 index 00000000..9773ac3d --- /dev/null +++ b/api/api/v1/statuses/[id]/reactions/[name].test.ts @@ -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); + }); + }); +}); diff --git a/api/api/v1/statuses/[id]/reactions/[name].ts b/api/api/v1/statuses/[id]/reactions/[name].ts new file mode 100644 index 00000000..ddaf8e8d --- /dev/null +++ b/api/api/v1/statuses/[id]/reactions/[name].ts @@ -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); + }, + ); +}); diff --git a/api/api/v1/statuses/[id]/reactions/index.test.ts b/api/api/v1/statuses/[id]/reactions/index.test.ts new file mode 100644 index 00000000..9c5d43ca --- /dev/null +++ b/api/api/v1/statuses/[id]/reactions/index.test.ts @@ -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); + } + }); +}); diff --git a/api/api/v1/statuses/[id]/reactions/index.ts b/api/api/v1/statuses/[id]/reactions/index.ts new file mode 100644 index 00000000..e1b372f9 --- /dev/null +++ b/api/api/v1/statuses/[id]/reactions/index.ts @@ -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); + }, + ), +); diff --git a/bun.lock b/bun.lock index c9e6fe20..ceaeb193 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "sonic-channel": "^1.3.1", "string-comparison": "^1.3.0", "stringify-entities": "^4.0.4", + "unicode-emoji-json": "^0.8.0", "uqr": "^0.1.2", "web-push": "^3.6.7", "xss": "^1.0.15", @@ -1263,6 +1264,8 @@ "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-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], diff --git a/classes/database/note.ts b/classes/database/note.ts index 08985323..5b382879 100644 --- a/classes/database/note.ts +++ b/classes/database/note.ts @@ -1,5 +1,5 @@ -import type { Status } from "@versia/client/schemas"; -import { db, Instance } from "@versia/kit/db"; +import type { NoteReactionWithAccounts, Status } from "@versia/client/schemas"; +import { db, Instance, type Reaction } from "@versia/kit/db"; import { EmojiToNote, Likes, @@ -54,6 +54,7 @@ type NoteTypeWithRelations = NoteType & { reblogged: boolean; muted: boolean; liked: boolean; + reactions: Omit[]; }; export type NoteTypeWithoutRecursiveRelations = Omit< @@ -698,7 +699,13 @@ export class Note extends BaseInterface { edited_at: data.updatedAt ? new Date(data.updatedAt).toISOString() : null, - reactions: [], + reactions: this.getReactions(userFetching ?? undefined).map( + // Remove account_ids + (r) => ({ + ...r, + account_ids: undefined, + }), + ), text: data.contentSource, }; } @@ -915,4 +922,67 @@ export class Note extends BaseInterface { 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[] { + // 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, + })); + } } diff --git a/classes/database/reaction.ts b/classes/database/reaction.ts index 4c80bda4..a64d372d 100644 --- a/classes/database/reaction.ts +++ b/classes/database/reaction.ts @@ -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 { randomUUIDv7 } from "bun"; import { + and, desc, eq, type InferInsertModel, type InferSelectModel, inArray, + isNull, type SQL, } from "drizzle-orm"; import { config } from "~/config.ts"; @@ -156,6 +158,32 @@ export class Reaction extends BaseInterface { return this.data.id; } + public static fromEmoji( + emoji: Emoji | string, + author: User, + note: Note, + ): Promise { + 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 { return this.data.uri ? new URL(this.data.uri) diff --git a/classes/database/user.ts b/classes/database/user.ts index 1602443e..d7987bf4 100644 --- a/classes/database/user.ts +++ b/classes/database/user.ts @@ -6,7 +6,13 @@ import type { Source, Status as StatusSchema, } from "@versia/client/schemas"; -import { db, Media, Notification, PushSubscription } from "@versia/kit/db"; +import { + db, + Media, + Notification, + PushSubscription, + Reaction, +} from "@versia/kit/db"; import { EmojiToUser, Likes, @@ -732,6 +738,48 @@ export class User extends BaseInterface { } } + /** + * 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 { + 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 { + 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( type: "mention" | "follow_request" | "follow" | "favourite" | "reblog", relatedUser: User, diff --git a/classes/functions/status.ts b/classes/functions/status.ts index 3f604fb7..55988fd3 100644 --- a/classes/functions/status.ts +++ b/classes/functions/status.ts @@ -40,6 +40,16 @@ export const findManyNotes = async ( media: true, }, }, + reactions: { + with: { + emoji: { + with: { + instance: true, + media: true, + }, + }, + }, + }, emojis: { with: { emoji: { @@ -71,6 +81,16 @@ export const findManyNotes = async ( media: true, }, }, + reactions: { + with: { + emoji: { + with: { + instance: true, + media: true, + }, + }, + }, + }, emojis: { with: { emoji: { diff --git a/drizzle/schema.ts b/drizzle/schema.ts index fa6875ad..b174f359 100644 --- a/drizzle/schema.ts +++ b/drizzle/schema.ts @@ -166,6 +166,7 @@ export const ReactionRelations = relations(Reactions, ({ one }) => ({ note: one(Notes, { fields: [Reactions.noteId], references: [Notes.id], + relationName: "NoteToReactions", }), author: one(Users, { fields: [Reactions.authorId], @@ -508,6 +509,9 @@ export const NotesRelations = relations(Notes, ({ many, one }) => ({ relationName: "NoteToReblogs", }), notifications: many(Notifications), + reactions: many(Reactions, { + relationName: "NoteToReactions", + }), })); export const Instances = pgTable("Instances", { diff --git a/nix/package.nix b/nix/package.nix index b454955a..5e55803f 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -21,7 +21,7 @@ in pnpmDeps = pnpm.fetchDeps { inherit (finalAttrs) pname version src pnpmInstallFlags; - hash = "sha256-bY0QfLYREeKn8ROupQdjOUv9t4+6HKLsTXOolzNCuU4="; + hash = "sha256-5kzx6Iqs+VZHCgnIiyxyO0069kFDfY8Xynhh4svATDE="; }; nativeBuildInputs = [ diff --git a/package.json b/package.json index 7721e470..38f5f7df 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,7 @@ "sonic-channel": "^1.3.1", "string-comparison": "^1.3.0", "stringify-entities": "^4.0.4", + "unicode-emoji-json": "^0.8.0", "uqr": "^0.1.2", "web-push": "^3.6.7", "xss": "^1.0.15", diff --git a/packages/client/schemas.ts b/packages/client/schemas.ts index 7705836e..a70f3e02 100644 --- a/packages/client/schemas.ts +++ b/packages/client/schemas.ts @@ -44,6 +44,7 @@ export { TermsOfService } from "./schemas/tos.ts"; export { Challenge, NoteReaction, + NoteReactionWithAccounts, Role, SSOConfig, } from "./schemas/versia.ts"; diff --git a/packages/client/schemas/versia.ts b/packages/client/schemas/versia.ts index e49f511a..89a7f6c7 100644 --- a/packages/client/schemas/versia.ts +++ b/packages/client/schemas/versia.ts @@ -73,6 +73,21 @@ export const NoteReaction = z 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 */ export const SSOConfig = z.object({ forced: z.boolean().openapi({ diff --git a/packages/client/versia/client.ts b/packages/client/versia/client.ts index bc134320..5f94e96c 100644 --- a/packages/client/versia/client.ts +++ b/packages/client/versia/client.ts @@ -27,7 +27,11 @@ import type { import type { Tag } from "../schemas/tag.ts"; import type { Token } from "../schemas/token.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 { DEFAULT_SCOPE, NO_REDIRECT } from "./constants.ts"; @@ -217,7 +221,7 @@ export class Client extends BaseClient { emoji: string, extra?: RequestInit, ): Promise>> { - return this.post>( + return this.put>( `/api/v1/statuses/${id}/reactions/${emoji}`, undefined, 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[]>> { + return this.get[]>( + `/api/v1/statuses/${id}/reactions`, + extra, + ); + } + /** * GET /api/v1/featured_tags/suggestions * diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 81b35e24..391eedbf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,6 +161,9 @@ importers: stringify-entities: specifier: ^4.0.4 version: 4.0.4 + unicode-emoji-json: + specifier: ^0.8.0 + version: 0.8.0 uqr: specifier: ^0.1.2 version: 0.1.2 @@ -3172,6 +3175,9 @@ packages: undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + unicode-emoji-json@0.8.0: + resolution: {integrity: sha512-3wDXXvp6YGoKGhS2O2H7+V+bYduOBydN1lnI0uVfr1cIdY02uFFiEH1i3kE5CCE4l6UqbLKVmEFW9USxTAMD1g==} + unist-util-is@6.0.0: resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} @@ -6106,6 +6112,8 @@ snapshots: undici-types@6.21.0: {} + unicode-emoji-json@0.8.0: {} + unist-util-is@6.0.0: dependencies: '@types/unist': 3.0.3