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

@ -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

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);
},
),
);

View file

@ -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=="],

View file

@ -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,
}));
}
} }

View file

@ -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)

View file

@ -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,

View file

@ -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: {

View file

@ -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", {

View file

@ -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 = [

View file

@ -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",

View file

@ -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";

View file

@ -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({

View file

@ -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
* *

View file

@ -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