Compare commits

...

5 commits

Author SHA1 Message Date
Jesse Wierzbinski 0692aa6efa
fix(federation): 👽 Add Reactions to list of supported extensions
Some checks failed
CodeQL Scan / Analyze (javascript-typescript) (push) Failing after 1s
Build Docker Images / lint (push) Failing after 8s
Build Docker Images / check (push) Failing after 7s
Build Docker Images / tests (push) Failing after 7s
Deploy Docs to GitHub Pages / build (push) Failing after 0s
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been skipped
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been skipped
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Mirror to Codeberg / Mirror (push) Failing after 0s
Nix Build / check (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
2025-05-28 20:42:29 +02:00
Jesse Wierzbinski 15e291b487
chore(packages/client): 🔖 Release 0.2.0-alpha.4 2025-05-28 17:19:04 +02:00
Jesse Wierzbinski 343a507ecc
feat(federation): Federate Reactions 2025-05-28 17:17:03 +02:00
Jesse Wierzbinski fa1dd69e2d
feat(api): Make Reactions API correctly output whether a reaction is remote 2025-05-28 17:07:24 +02:00
Jesse Wierzbinski e0adaca2a2
feat(federation): Add inbound Reaction processing 2025-05-28 16:50:59 +02:00
14 changed files with 329 additions and 16 deletions

View file

@ -95,6 +95,7 @@ The following extensions are currently supported or being worked on:
- `pub.versia:instance_messaging`: Instance Messaging - `pub.versia:instance_messaging`: Instance Messaging
- `pub.versia:likes`: Likes - `pub.versia:likes`: Likes
- `pub.versia:share`: Share - `pub.versia:share`: Share
- `pub.versia:reactions`: Reactions
## API ## API

View file

@ -106,6 +106,7 @@ describe("/api/v1/statuses/:id/reactions/:name", () => {
name: "❤️", name: "❤️",
count: 1, count: 1,
me: false, me: false,
remote: false,
}), }),
); );
expect(data.reactions).toContainEqual( expect(data.reactions).toContainEqual(
@ -113,6 +114,7 @@ describe("/api/v1/statuses/:id/reactions/:name", () => {
name: "😂", name: "😂",
count: 1, count: 1,
me: true, me: true,
remote: false,
}), }),
); );
}); });
@ -133,6 +135,7 @@ describe("/api/v1/statuses/:id/reactions/:name", () => {
name: "👍", name: "👍",
count: 1, count: 1,
me: true, me: true,
remote: false,
}); });
}); });

View file

@ -39,6 +39,7 @@ describe("/api/v1/statuses/:id/reactions", () => {
count: 1, count: 1,
me: true, me: true,
account_ids: [users[1].id], account_ids: [users[1].id],
remote: false,
}); });
// Check for ❤️ reaction // Check for ❤️ reaction
@ -48,6 +49,7 @@ describe("/api/v1/statuses/:id/reactions", () => {
count: 1, count: 1,
me: false, me: false,
account_ids: [users[2].id], account_ids: [users[2].id],
remote: false,
}); });
// Check for 😂 reaction // Check for 😂 reaction
@ -57,6 +59,7 @@ describe("/api/v1/statuses/:id/reactions", () => {
count: 1, count: 1,
me: true, me: true,
account_ids: [users[1].id], account_ids: [users[1].id],
remote: false,
}); });
}); });
@ -71,6 +74,7 @@ describe("/api/v1/statuses/:id/reactions", () => {
// All reactions should have me: false when not authenticated // All reactions should have me: false when not authenticated
for (const reaction of data) { for (const reaction of data) {
expect(reaction.me).toBe(false); expect(reaction.me).toBe(false);
expect(reaction.remote).toBe(false);
} }
}); });
}); });

View file

@ -6,20 +6,23 @@ import {
enableRealRequests, enableRealRequests,
mock, mock,
} from "bun-bagel"; } from "bun-bagel";
import { and, eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { Instance } from "~/classes/database/instance"; import { Instance } from "~/classes/database/instance";
import { Note } from "~/classes/database/note"; import { Note } from "~/classes/database/note";
import { Reaction } from "~/classes/database/reaction";
import { User } from "~/classes/database/user"; import { User } from "~/classes/database/user";
import { config } from "~/config"; import { config } from "~/config";
import { Notes } from "~/drizzle/schema"; import { Notes, Reactions, Users } from "~/drizzle/schema";
import { sign } from "~/packages/sdk/crypto"; import { sign } from "~/packages/sdk/crypto";
import * as VersiaEntities from "~/packages/sdk/entities"; import * as VersiaEntities from "~/packages/sdk/entities";
import { fakeRequest } from "~/tests/utils"; import { fakeRequest, generateClient, getTestUsers } from "~/tests/utils";
const instanceUrl = new URL("https://versia.example.com"); const instanceUrl = new URL("https://versia.example.com");
const noteId = randomUUIDv7(); const noteId = randomUUIDv7();
const userId = randomUUIDv7(); const userId = randomUUIDv7();
const shareId = randomUUIDv7(); const shareId = randomUUIDv7();
const reactionId = randomUUIDv7();
const reaction2Id = randomUUIDv7();
const userKeys = await User.generateKeys(); const userKeys = await User.generateKeys();
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
@ -30,6 +33,7 @@ const privateKey = await crypto.subtle.importKey(
); );
const instanceKeys = await User.generateKeys(); const instanceKeys = await User.generateKeys();
const inboxUrl = new URL("/inbox", config.http.base_url); const inboxUrl = new URL("/inbox", config.http.base_url);
const { users, deleteUsers } = await getTestUsers(1);
disableRealRequests(); disableRealRequests();
@ -99,6 +103,7 @@ afterAll(async () => {
} }
await instance.delete(); await instance.delete();
await deleteUsers();
clearMocks(); clearMocks();
enableRealRequests(); enableRealRequests();
}); });
@ -222,7 +227,187 @@ describe("Inbox Tests", () => {
expect(share).not.toBeNull(); expect(share).not.toBeNull();
}); });
test("should correctly process Delete for Note", async () => { test("should correctly process Reaction", async () => {
const exampleRequest = new VersiaEntities.Reaction({
id: reactionId,
created_at: "2025-04-18T10:32:01.427Z",
uri: new URL(`/reactions/${reactionId}`, instanceUrl).href,
type: "pub.versia:reactions/Reaction",
author: new URL(`/users/${userId}`, instanceUrl).href,
object: new URL(`/notes/${noteId}`, instanceUrl).href,
content: "👍",
});
const signedRequest = await sign(
privateKey,
new URL(exampleRequest.data.author),
new Request(inboxUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "Versia/1.0.0",
},
body: JSON.stringify(exampleRequest.toJSON()),
}),
);
const response = await fakeRequest(inboxUrl, {
method: "POST",
headers: signedRequest.headers,
body: signedRequest.body,
});
expect(response.status).toBe(200);
await sleep(500);
const dbNote = await Note.fromSql(
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
);
if (!dbNote) {
throw new Error("DBNote not found");
}
// Find the remote user who reacted by URI
const remoteUser = await User.fromSql(
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
);
if (!remoteUser) {
throw new Error("Remote user not found");
}
// Check if reaction was created in the database
const reaction = await Reaction.fromSql(
and(
eq(Reactions.noteId, dbNote.id),
eq(Reactions.authorId, remoteUser.id),
eq(Reactions.emojiText, "👍"),
),
);
expect(reaction).not.toBeNull();
// Check if API returns the reaction correctly
await using client = await generateClient(users[1]);
const { data, ok } = await client.getStatusReactions(dbNote.id);
expect(ok).toBe(true);
expect(data).toContainEqual(
expect.objectContaining({
name: "👍",
count: 1,
me: false,
remote: false,
}),
);
});
test("should correctly process Reaction with custom emoji", async () => {
const exampleRequest = new VersiaEntities.Reaction({
id: reaction2Id,
created_at: "2025-04-18T10:32:01.427Z",
uri: new URL(`/reactions/${reaction2Id}`, instanceUrl).href,
type: "pub.versia:reactions/Reaction",
author: new URL(`/users/${userId}`, instanceUrl).href,
object: new URL(`/notes/${noteId}`, instanceUrl).href,
content: ":neocat:",
extensions: {
"pub.versia:custom_emojis": {
emojis: [
{
name: ":neocat:",
url: {
"image/webp": {
hash: {
sha256: "e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649",
},
size: 4664,
width: 256,
height: 256,
remote: true,
content:
"https://cdn.cpluspatch.com/versia-cpp/e06240155d2cb90e8dc05327d023585ab9d47216ff547ad72aaf75c485fe9649/neocat.webp",
},
},
},
],
},
},
});
const signedRequest = await sign(
privateKey,
new URL(exampleRequest.data.author),
new Request(inboxUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
"User-Agent": "Versia/1.0.0",
},
body: JSON.stringify(exampleRequest.toJSON()),
}),
);
const response = await fakeRequest(inboxUrl, {
method: "POST",
headers: signedRequest.headers,
body: signedRequest.body,
});
expect(response.status).toBe(200);
await sleep(500);
const dbNote = await Note.fromSql(
eq(Notes.uri, new URL(`/notes/${noteId}`, instanceUrl).href),
);
if (!dbNote) {
throw new Error("DBNote not found");
}
// Find the remote user who reacted by URI
const remoteUser = await User.fromSql(
eq(Users.uri, new URL(`/users/${userId}`, instanceUrl).href),
);
if (!remoteUser) {
throw new Error("Remote user not found");
}
// Check if reaction was created in the database
const reaction = await Reaction.fromSql(
and(
eq(Reactions.noteId, dbNote.id),
eq(Reactions.authorId, remoteUser.id),
isNull(Reactions.emojiText), // Custom emoji reactions have emojiText as NULL
),
);
expect(reaction).not.toBeNull();
// Check if API returns the reaction correctly
await using client = await generateClient(users[1]);
const { data, ok } = await client.getStatusReactions(dbNote.id);
expect(ok).toBe(true);
expect(data).toContainEqual(
expect.objectContaining({
name: ":neocat@versia.example.com:",
count: 1,
me: false,
remote: true,
}),
);
});
test("should correctly process Delete", async () => {
const deleteId = randomUUIDv7(); const deleteId = randomUUIDv7();
// First check that the note exists in the database // First check that the note exists in the database

View file

@ -49,6 +49,7 @@ export default apiRoute((app) =>
"pub.versia:instance_messaging", "pub.versia:instance_messaging",
"pub.versia:likes", "pub.versia:likes",
"pub.versia:shares", "pub.versia:shares",
"pub.versia:reactions",
], ],
versions: ["0.5.0"], versions: ["0.5.0"],
}, },

View file

@ -948,6 +948,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
{ {
count: number; count: number;
me: boolean; me: boolean;
instance: typeof Instance.$type | null;
account_ids: string[]; account_ids: string[];
} }
>(); >();
@ -958,8 +959,10 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
// Determine emoji name based on type // Determine emoji name based on type
if (reaction.emojiText) { if (reaction.emojiText) {
emojiName = reaction.emojiText; emojiName = reaction.emojiText;
} else if (reaction.emoji) { } else if (reaction.emoji?.instance === null) {
emojiName = `:${reaction.emoji.shortcode}:`; emojiName = `:${reaction.emoji.shortcode}:`;
} else if (reaction.emoji?.instance) {
emojiName = `:${reaction.emoji.shortcode}@${reaction.emoji.instance.baseUrl}:`;
} else { } else {
continue; // Skip invalid reactions continue; // Skip invalid reactions
} }
@ -970,6 +973,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
count: 0, count: 0,
me: false, me: false,
account_ids: [], account_ids: [],
instance: reaction.emoji?.instance ?? null,
}); });
} }
@ -994,6 +998,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
count: data.count, count: data.count,
me: data.me, me: data.me,
account_ids: data.account_ids, account_ids: data.account_ids,
remote: data.instance !== null,
})); }));
} }
} }

View file

@ -236,6 +236,20 @@ export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
}); });
} }
public toVersiaUnreact(): VersiaEntities.Delete {
return new VersiaEntities.Delete({
type: "Delete",
id: crypto.randomUUID(),
created_at: new Date().toISOString(),
author: User.getUri(
this.data.authorId,
this.data.author.uri ? new URL(this.data.author.uri) : null,
).href,
deleted_type: "pub.versia:reactions/Reaction",
deleted: this.getUri(config.http.base_url).href,
});
}
public static async fromVersia( public static async fromVersia(
reactionToConvert: VersiaEntities.Reaction, reactionToConvert: VersiaEntities.Reaction,
author: User, author: User,

View file

@ -752,7 +752,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
} }
// Create the reaction // Create the reaction
await Reaction.insert({ const reaction = await Reaction.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
authorId: this.id, authorId: this.id,
noteId: note.id, noteId: note.id,
@ -760,7 +760,29 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
emojiId: emoji instanceof Emoji ? emoji.id : null, emojiId: emoji instanceof Emoji ? emoji.id : null,
}); });
// TODO: Handle federation and notifications const finalNote = await Note.fromId(note.id, this.id);
if (!finalNote) {
throw new Error("Failed to fetch note after reaction");
}
if (note.author.local) {
// Notify the user that their post has been reacted to
await note.author.notify("reaction", this, finalNote);
}
if (this.local) {
const federatedUsers = await this.federateToFollowers(
reaction.toVersia(),
);
if (
note.remote &&
!federatedUsers.find((u) => u.id === note.author.id)
) {
await this.federateToUser(reaction.toVersia(), note.author);
}
}
} }
/** /**
@ -777,11 +799,45 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
await reactionToDelete.delete(); await reactionToDelete.delete();
// TODO: Handle federation and notifications if (note.author.local) {
// Remove any eventual notifications for this reaction
await db
.delete(Notifications)
.where(
and(
eq(Notifications.accountId, this.id),
eq(Notifications.type, "reaction"),
eq(Notifications.notifiedId, note.data.authorId),
eq(Notifications.noteId, note.id),
),
);
}
if (this.local) {
const federatedUsers = await this.federateToFollowers(
reactionToDelete.toVersiaUnreact(),
);
if (
note.remote &&
!federatedUsers.find((u) => u.id === note.author.id)
) {
await this.federateToUser(
reactionToDelete.toVersiaUnreact(),
note.author,
);
}
}
} }
public async notify( public async notify(
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog", type:
| "mention"
| "follow_request"
| "follow"
| "favourite"
| "reblog"
| "reaction",
relatedUser: User, relatedUser: User,
note?: Note, note?: Note,
): Promise<void> { ): Promise<void> {
@ -801,7 +857,13 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
private async notifyPush( private async notifyPush(
notificationId: string, notificationId: string,
type: "mention" | "follow_request" | "follow" | "favourite" | "reblog", type:
| "mention"
| "follow_request"
| "follow"
| "favourite"
| "reblog"
| "reaction",
relatedUser: User, relatedUser: User,
note?: Note, note?: Note,
): Promise<void> { ): Promise<void> {

View file

@ -1,5 +1,12 @@
import { getLogger, type Logger } from "@logtape/logtape"; import { getLogger, type Logger } from "@logtape/logtape";
import { type Instance, Like, Note, Relationship, User } from "@versia/kit/db"; import {
type Instance,
Like,
Note,
Reaction,
Relationship,
User,
} from "@versia/kit/db";
import { Likes, Notes } from "@versia/kit/tables"; import { Likes, Notes } from "@versia/kit/tables";
import type { SocketAddress } from "bun"; import type { SocketAddress } from "bun";
import { Glob } from "bun"; import { Glob } from "bun";
@ -198,8 +205,9 @@ export class InboxProcessor {
InboxProcessor.processDelete(d), InboxProcessor.processDelete(d),
) )
.on(VersiaEntities.User, (u) => InboxProcessor.processUser(u)) .on(VersiaEntities.User, (u) => InboxProcessor.processUser(u))
.on(VersiaEntities.Share, async (s) => .on(VersiaEntities.Share, (s) => InboxProcessor.processShare(s))
InboxProcessor.processShare(s), .on(VersiaEntities.Reaction, (r) =>
InboxProcessor.processReaction(r),
) )
.sort(() => { .sort(() => {
throw new ApiError(400, "Unknown entity type"); throw new ApiError(400, "Unknown entity type");
@ -209,6 +217,29 @@ export class InboxProcessor {
} }
} }
/**
* Handles Reaction entity processing
*
* @param {VersiaEntities.Reaction} reaction - The Reaction entity to process.
* @returns {Promise<void>}
*/
private static async processReaction(
reaction: VersiaEntities.Reaction,
): Promise<void> {
const author = await User.resolve(new URL(reaction.data.author));
const note = await Note.resolve(new URL(reaction.data.object));
if (!author) {
throw new ApiError(404, "Author not found");
}
if (!note) {
throw new ApiError(404, "Note not found");
}
await Reaction.fromVersia(reaction, author, note);
}
/** /**
* Handles Note entity processing * Handles Note entity processing
* *

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://jsr.io/schema/config-file.v1.json", "$schema": "https://jsr.io/schema/config-file.v1.json",
"name": "@versia/client", "name": "@versia/client",
"version": "0.2.0-alpha.3", "version": "0.2.0-alpha.4",
"exports": { "exports": {
".": "./index.ts", ".": "./index.ts",
"./schemas": "./schemas.ts" "./schemas": "./schemas.ts"

View file

@ -1,7 +1,7 @@
{ {
"name": "@versia/client", "name": "@versia/client",
"displayName": "Versia Client", "displayName": "Versia Client",
"version": "0.2.0-alpha.3", "version": "0.2.0-alpha.4",
"author": { "author": {
"email": "jesse.wierzbinski@lysand.org", "email": "jesse.wierzbinski@lysand.org",
"name": "Jesse Wierzbinski (CPlusPatch)", "name": "Jesse Wierzbinski (CPlusPatch)",

View file

@ -21,6 +21,7 @@ export const Notification = z
"favourite", "favourite",
"poll", "poll",
"update", "update",
"reaction",
"admin.sign_up", "admin.sign_up",
"admin.report", "admin.report",
"severed_relationships", "severed_relationships",

View file

@ -62,6 +62,11 @@ export const NoteReaction = z
description: "Number of users who reacted with this emoji.", description: "Number of users who reacted with this emoji.",
example: 5, example: 5,
}), }),
remote: z.boolean().openapi({
description:
"Whether this reaction is from a remote instance (federated).",
example: false,
}),
me: z.boolean().optional().openapi({ me: z.boolean().optional().openapi({
description: description:
"Whether the current authenticated user reacted with this emoji.", "Whether the current authenticated user reacted with this emoji.",

View file

@ -32,4 +32,5 @@ export type KnownEntity =
| VersiaEntities.Unfollow | VersiaEntities.Unfollow
| VersiaEntities.Delete | VersiaEntities.Delete
| VersiaEntities.Like | VersiaEntities.Like
| VersiaEntities.Share; | VersiaEntities.Share
| VersiaEntities.Reaction;