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,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<typeof Reaction.$type, "note" | "author">[];
};
export type NoteTypeWithoutRecursiveRelations = Omit<
@ -698,7 +699,13 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
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<typeof Notes, NoteTypeWithRelations> {
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 { 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<typeof Reactions, ReactionType> {
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 {
return this.data.uri
? new URL(this.data.uri)

View file

@ -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<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(
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog",
relatedUser: User,