Compare commits

..

No commits in common. "9eac364e0173e68986e2299945f8506244d6fb45" and "8c0a20a7434dc613b73b4d0e6b07bbfd9924926c" have entirely different histories.

24 changed files with 56 additions and 234 deletions

View file

@ -5,7 +5,6 @@
### API ### API
- [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis. - [x] 🥺 Emoji Reactions are now available! You can react to any note with custom emojis.
- [x] 🔎 Added support for [batch account data API](https://docs.joinmastodon.org/methods/accounts/#index).
# `0.8.0` • Federation 2: Electric Boogaloo # `0.8.0` • Federation 2: Electric Boogaloo

View file

@ -1,52 +0,0 @@
import { afterAll, describe, expect, test } from "bun:test";
import { generateClient, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(5);
afterAll(async () => {
await deleteUsers();
});
describe("/api/v1/accounts", () => {
test("should return accounts", async () => {
await using client = await generateClient();
const { data, ok } = await client.getAccounts(users.map((u) => u.id));
expect(ok).toBe(true);
expect(data).toEqual(
expect.arrayContaining(
users.map((u) =>
expect.objectContaining({
id: u.id,
username: u.data.username,
acct: u.data.username,
}),
),
),
);
});
test("should skip nonexistent accounts", async () => {
await using client = await generateClient();
const { data, ok } = await client.getAccounts([
...users.map((u) => u.id),
"00000000-0000-0000-0000-000000000000",
]);
expect(ok).toBe(true);
expect(data).toEqual(
expect.arrayContaining(
users.map((u) =>
expect.objectContaining({
id: u.id,
username: u.data.username,
acct: u.data.username,
}),
),
),
);
expect(data).toHaveLength(users.length);
});
});

View file

@ -1,4 +1,4 @@
import { Account as AccountSchema, zBoolean } from "@versia/client/schemas"; import { zBoolean } from "@versia/client/schemas";
import { User } from "@versia/kit/db"; import { User } from "@versia/kit/db";
import { Users } from "@versia/kit/tables"; import { Users } from "@versia/kit/tables";
import { and, eq, isNull } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
@ -6,7 +6,7 @@ import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
import ISO6391 from "iso-639-1"; import ISO6391 from "iso-639-1";
import { z } from "zod"; import { z } from "zod";
import { apiRoute, auth, handleZodError, jsonOrForm, qsQuery } from "@/api"; import { apiRoute, auth, handleZodError, jsonOrForm } from "@/api";
import { tempmailDomains } from "@/tempmail"; import { tempmailDomains } from "@/tempmail";
import { ApiError } from "~/classes/errors/api-error"; import { ApiError } from "~/classes/errors/api-error";
import { config } from "~/config.ts"; import { config } from "~/config.ts";
@ -42,67 +42,7 @@ const schema = z.object({
}), }),
}); });
export default apiRoute((app) => { export default apiRoute((app) =>
app.get(
"/api/v1/accounts",
describeRoute({
summary: "Get multiple accounts",
description: "View information about multiple profiles.",
externalDocs: {
url: "https://docs.joinmastodon.org/methods/accounts/#index",
},
tags: ["Accounts"],
responses: {
200: {
description:
"Account records for the requested confirmed and approved accounts. There can be fewer records than requested if the accounts do not exist or are not confirmed.",
content: {
"application/json": {
schema: resolver(z.array(AccountSchema)),
},
},
},
422: ApiError.validationFailed().schema,
},
}),
qsQuery(),
auth({
auth: false,
scopes: [],
challenge: false,
}),
rateLimit(40),
validator(
"query",
z.object({
id: z
.array(AccountSchema.shape.id)
.min(1)
.max(40)
.or(AccountSchema.shape.id.transform((v) => [v]))
.openapi({
description: "The IDs of the Accounts in the database.",
example: [
"f137ce6f-ff5e-4998-b20f-0361ba9be007",
"8424c654-5d03-4a1b-bec8-4e87db811b5d",
],
}),
}),
handleZodError,
),
async (context) => {
const { id: ids } = context.req.valid("query");
// Find accounts by IDs
const accounts = await User.fromIds(ids);
return context.json(
accounts.map((account) => account.toApi()),
200,
);
},
);
app.post( app.post(
"/api/v1/accounts", "/api/v1/accounts",
describeRoute({ describeRoute({
@ -420,5 +360,5 @@ export default apiRoute((app) => {
return context.text("", 200); return context.text("", 200);
}, },
); ),
}); );

View file

@ -71,7 +71,9 @@ export default apiRoute((app) =>
const { user } = context.get("auth"); const { user } = context.get("auth");
// TODO: Implement with_suspended // TODO: Implement with_suspended
const { id: ids } = context.req.valid("query"); const { id } = context.req.valid("query");
const ids = Array.isArray(id) ? id : [id];
const relationships = await Relationship.fromOwnerAndSubjects( const relationships = await Relationship.fromOwnerAndSubjects(
user, user,

View file

@ -76,7 +76,7 @@ export default apiRoute((app) => {
if (timeline.includes("home")) { if (timeline.includes("home")) {
const found = await db.query.Markers.findFirst({ const found = await db.query.Markers.findFirst({
where: (marker): SQL | undefined => where: (marker, { and, eq }): SQL | undefined =>
and( and(
eq(marker.userId, user.id), eq(marker.userId, user.id),
eq(marker.timeline, "home"), eq(marker.timeline, "home"),
@ -102,7 +102,7 @@ export default apiRoute((app) => {
if (timeline.includes("notifications")) { if (timeline.includes("notifications")) {
const found = await db.query.Markers.findFirst({ const found = await db.query.Markers.findFirst({
where: (marker): SQL | undefined => where: (marker, { and, eq }): SQL | undefined =>
and( and(
eq(marker.userId, user.id), eq(marker.userId, user.id),
eq(marker.timeline, "notifications"), eq(marker.timeline, "notifications"),

View file

@ -1,6 +1,6 @@
import { RolePermission, Status as StatusSchema } from "@versia/client/schemas"; import { RolePermission, Status as StatusSchema } from "@versia/client/schemas";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { and, eq, type SQL } from "drizzle-orm"; import type { SQL } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver } from "hono-openapi/zod"; import { resolver } from "hono-openapi/zod";
import { apiRoute, auth, withNoteParam } from "@/api"; import { apiRoute, auth, withNoteParam } from "@/api";
@ -51,7 +51,7 @@ export default apiRoute((app) =>
if ( if (
await db.query.UserToPinnedNotes.findFirst({ await db.query.UserToPinnedNotes.findFirst({
where: (userPinnedNote): SQL | undefined => where: (userPinnedNote, { and, eq }): SQL | undefined =>
and( and(
eq(userPinnedNote.noteId, note.data.id), eq(userPinnedNote.noteId, note.data.id),
eq(userPinnedNote.userId, user.id), eq(userPinnedNote.userId, user.id),

View file

@ -1,37 +1,11 @@
import { afterAll, beforeAll, describe, expect, test } from "bun:test"; import { afterAll, describe, expect, test } from "bun:test";
import { randomUUIDv7 } from "bun";
import { Emoji } from "~/classes/database/emoji";
import { Media } from "~/classes/database/media";
import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils"; import { generateClient, getTestStatuses, getTestUsers } from "~/tests/utils";
const { users, deleteUsers } = await getTestUsers(3); const { users, deleteUsers } = await getTestUsers(3);
const timeline = (await getTestStatuses(2, users[0])).toReversed(); const timeline = (await getTestStatuses(2, users[0])).toReversed();
let emojiMedia: Media;
let customEmoji: Emoji;
beforeAll(async () => {
emojiMedia = await Media.insert({
id: randomUUIDv7(),
content: {
"image/png": {
content: "https://example.com/image.png",
remote: true,
},
},
});
customEmoji = await Emoji.insert({
id: randomUUIDv7(),
shortcode: "test_emoji",
visibleInPicker: true,
mediaId: emojiMedia.id,
});
});
afterAll(async () => { afterAll(async () => {
await deleteUsers(); await deleteUsers();
await customEmoji.delete();
await emojiMedia.delete();
}); });
describe("/api/v1/statuses/:id/reactions/:name", () => { describe("/api/v1/statuses/:id/reactions/:name", () => {
@ -66,29 +40,6 @@ describe("/api/v1/statuses/:id/reactions/:name", () => {
); );
}); });
test("should add custom emoji reaction", async () => {
await using client = await generateClient(users[1]);
const { data, ok } = await client.createEmojiReaction(
timeline[0].id,
`:${customEmoji.data.shortcode}:`,
);
expect(ok).toBe(true);
expect(data.reactions).toContainEqual(
expect.objectContaining({
name: `:${customEmoji.data.shortcode}:`,
count: 1,
me: true,
}),
);
expect(data.emojis).toContainEqual(
expect.objectContaining({
shortcode: customEmoji.data.shortcode,
}),
);
});
test("should add multiple different reactions", async () => { test("should add multiple different reactions", async () => {
await using client1 = await generateClient(users[1]); await using client1 = await generateClient(users[1]);
await using client2 = await generateClient(users[2]); await using client2 = await generateClient(users[2]);

View file

@ -105,12 +105,19 @@ export default apiRoute((app) => {
emoji = unicodeEmoji; emoji = unicodeEmoji;
} }
// Use the User react method
try {
await user.react(note, emoji); await user.react(note, emoji);
// Reload note to get updated reactions // Reload note to get updated reactions
await note.reload(user.id); await note.reload(user.id);
return context.json(await note.toApi(user), 201); 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);
}
}, },
); );
@ -199,6 +206,7 @@ export default apiRoute((app) => {
emoji = unicodeEmoji; emoji = unicodeEmoji;
} }
// Use the User unreact method
await user.unreact(note, emoji); await user.unreact(note, emoji);
// Reload note to get updated reactions // Reload note to get updated reactions

View file

@ -56,7 +56,7 @@ export default apiRoute((app) => {
const { id } = context.req.valid("param"); const { id } = context.req.valid("param");
const userFilter = await db.query.Filters.findFirst({ const userFilter = await db.query.Filters.findFirst({
where: (filter): SQL | undefined => where: (filter, { eq, and }): SQL | undefined =>
and(eq(filter.userId, user.id), eq(filter.id, id)), and(eq(filter.userId, user.id), eq(filter.id, id)),
with: { with: {
keywords: true, keywords: true,
@ -224,7 +224,7 @@ export default apiRoute((app) => {
} }
const updatedFilter = await db.query.Filters.findFirst({ const updatedFilter = await db.query.Filters.findFirst({
where: (filter): SQL | undefined => where: (filter, { eq, and }): SQL | undefined =>
and(eq(filter.userId, user.id), eq(filter.id, id)), and(eq(filter.userId, user.id), eq(filter.id, id)),
with: { with: {
keywords: true, keywords: true,

View file

@ -6,7 +6,7 @@ import {
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { FilterKeywords, Filters } from "@versia/kit/tables"; import { FilterKeywords, Filters } from "@versia/kit/tables";
import { randomUUIDv7 } from "bun"; import { randomUUIDv7 } from "bun";
import { eq, type SQL } from "drizzle-orm"; import type { SQL } from "drizzle-orm";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
import { z } from "zod"; import { z } from "zod";
@ -44,7 +44,8 @@ export default apiRoute((app) => {
const { user } = context.get("auth"); const { user } = context.get("auth");
const userFilters = await db.query.Filters.findMany({ const userFilters = await db.query.Filters.findMany({
where: (filter): SQL | undefined => eq(filter.userId, user.id), where: (filter, { eq }): SQL | undefined =>
eq(filter.userId, user.id),
with: { with: {
keywords: true, keywords: true,
}, },

View file

@ -86,7 +86,7 @@
}, },
"packages/client": { "packages/client": {
"name": "@versia/client", "name": "@versia/client",
"version": "0.2.0-alpha.3", "version": "0.2.0-alpha.1",
"dependencies": { "dependencies": {
"@badgateway/oauth2-client": "^3.0.0", "@badgateway/oauth2-client": "^3.0.0",
"iso-639-1": "^3.1.5", "iso-639-1": "^3.1.5",

View file

@ -261,7 +261,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
{ {
with: { with: {
relationships: { relationships: {
where: (relationship): SQL | undefined => where: (relationship, { eq, and }): SQL | undefined =>
and( and(
eq(relationship.subjectId, this.data.authorId), eq(relationship.subjectId, this.data.authorId),
eq(relationship.following, true), eq(relationship.following, true),
@ -597,7 +597,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
if (this.data.visibility === "private") { if (this.data.visibility === "private") {
return user return user
? !!(await db.query.Relationships.findFirst({ ? !!(await db.query.Relationships.findFirst({
where: (relationship): SQL | undefined => where: (relationship, { and, eq }): SQL | undefined =>
and( and(
eq(relationship.ownerId, user?.id), eq(relationship.ownerId, user?.id),
eq(relationship.subjectId, Notes.authorId), eq(relationship.subjectId, Notes.authorId),
@ -641,18 +641,6 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
); );
} }
const reactions = this.getReactions(userFetching ?? undefined).map(
// Remove account_ids
(r) => ({
...r,
account_ids: undefined,
}),
);
const emojis = data.emojis.concat(
data.reactions.map((r) => r.emoji).filter((v) => v !== null),
);
return { return {
id: data.id, id: data.id,
in_reply_to_id: data.replyId || null, in_reply_to_id: data.replyId || null,
@ -664,7 +652,7 @@ export class Note extends BaseInterface<typeof Notes, NoteTypeWithRelations> {
: undefined, : undefined,
card: null, card: null,
content: replacedContent, content: replacedContent,
emojis: emojis.map((emoji) => new Emoji(emoji).toApi()), emojis: data.emojis.map((emoji) => new Emoji(emoji).toApi()),
favourited: data.liked, favourited: data.liked,
favourites_count: data.likeCount, favourites_count: data.likeCount,
media_attachments: (data.attachments ?? []).map((a) => media_attachments: (data.attachments ?? []).map((a) =>
@ -711,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,
}; };
} }

View file

@ -204,7 +204,7 @@ export class Relationship extends BaseInterface<
ownerId: string; ownerId: string;
}): Promise<RelationshipType> { }): Promise<RelationshipType> {
let output = await db.query.Relationships.findFirst({ let output = await db.query.Relationships.findFirst({
where: (rel): SQL | undefined => where: (rel, { and, eq }): SQL | undefined =>
and( and(
eq(rel.ownerId, oppositeTo.subjectId), eq(rel.ownerId, oppositeTo.subjectId),
eq(rel.subjectId, oppositeTo.ownerId), eq(rel.subjectId, oppositeTo.ownerId),

View file

@ -91,7 +91,8 @@ export class Role extends BaseInterface<typeof Roles> {
): Promise<Role[]> { ): Promise<Role[]> {
return ( return (
await db.query.RoleToUsers.findMany({ await db.query.RoleToUsers.findMany({
where: (role): SQL | undefined => eq(role.userId, userId), where: (role, { eq }): SQL | undefined =>
eq(role.userId, userId),
with: { with: {
role: true, role: true,
user: { user: {

View file

@ -464,7 +464,7 @@ export class User extends BaseInterface<typeof Users, UserWithRelations> {
> { > {
// Get all linked accounts // Get all linked accounts
const accounts = await db.query.OpenIdAccounts.findMany({ const accounts = await db.query.OpenIdAccounts.findMany({
where: (User): SQL | undefined => eq(User.userId, this.id), where: (User, { eq }): SQL | undefined => eq(User.userId, this.id),
}); });
return accounts return accounts

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.2",
"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.2",
"author": { "author": {
"email": "jesse.wierzbinski@lysand.org", "email": "jesse.wierzbinski@lysand.org",
"name": "Jesse Wierzbinski (CPlusPatch)", "name": "Jesse Wierzbinski (CPlusPatch)",

View file

@ -703,28 +703,6 @@ export class Client extends BaseClient {
); );
} }
/**
* GET /api/v1/accounts
*
* @param ids The account IDs.
* @return An array of accounts.
*/
public getAccounts(
ids: string[],
extra?: RequestInit,
): Promise<Output<z.infer<typeof Account>[]>> {
const params = new URLSearchParams();
for (const id of ids) {
params.append("id[]", id);
}
return this.get<z.infer<typeof Account>[]>(
`/api/v1/accounts?${params.toString()}`,
extra,
);
}
/** /**
* GET /api/v1/accounts/id * GET /api/v1/accounts/id
* *

View file

@ -156,7 +156,7 @@ export default (plugin: PluginType): void => {
// Check if account is already linked // Check if account is already linked
const account = await db.query.OpenIdAccounts.findFirst({ const account = await db.query.OpenIdAccounts.findFirst({
where: (account): SQL | undefined => where: (account, { eq, and }): SQL | undefined =>
and( and(
eq(account.serverId, sub), eq(account.serverId, sub),
eq(account.issuerId, issuer.id), eq(account.issuerId, issuer.id),
@ -195,7 +195,7 @@ export default (plugin: PluginType): void => {
let userId = ( let userId = (
await db.query.OpenIdAccounts.findFirst({ await db.query.OpenIdAccounts.findFirst({
where: (account): SQL | undefined => where: (account, { eq, and }): SQL | undefined =>
and( and(
eq(account.serverId, sub), eq(account.serverId, sub),
eq(account.issuerId, issuer.id), eq(account.issuerId, issuer.id),

View file

@ -1,6 +1,6 @@
import { RolePermission } from "@versia/client/schemas"; import { RolePermission } from "@versia/client/schemas";
import { db } from "@versia/kit/db"; import { db } from "@versia/kit/db";
import { and, eq, type SQL } from "@versia/kit/drizzle"; import { eq, type SQL } from "@versia/kit/drizzle";
import { OpenIdAccounts } from "@versia/kit/tables"; import { OpenIdAccounts } from "@versia/kit/tables";
import { describeRoute } from "hono-openapi"; import { describeRoute } from "hono-openapi";
import { resolver, validator } from "hono-openapi/zod"; import { resolver, validator } from "hono-openapi/zod";
@ -58,7 +58,7 @@ export default (plugin: PluginType): void => {
} }
const account = await db.query.OpenIdAccounts.findFirst({ const account = await db.query.OpenIdAccounts.findFirst({
where: (account): SQL | undefined => where: (account, { eq, and }): SQL | undefined =>
and( and(
eq(account.userId, user.id), eq(account.userId, user.id),
eq(account.issuerId, issuerId), eq(account.issuerId, issuerId),
@ -127,7 +127,7 @@ export default (plugin: PluginType): void => {
} }
const account = await db.query.OpenIdAccounts.findFirst({ const account = await db.query.OpenIdAccounts.findFirst({
where: (account): SQL | undefined => where: (account, { eq, and }): SQL | undefined =>
and( and(
eq(account.userId, user.id), eq(account.userId, user.id),
eq(account.issuerId, issuerId), eq(account.issuerId, issuerId),

View file

@ -1,5 +1,5 @@
import { type Application, db } from "@versia/kit/db"; import { type Application, db } from "@versia/kit/db";
import { eq, type InferSelectModel, type SQL } from "@versia/kit/drizzle"; import type { InferSelectModel, SQL } from "@versia/kit/drizzle";
import type { OpenIdLoginFlows } from "@versia/kit/tables"; import type { OpenIdLoginFlows } from "@versia/kit/tables";
import { import {
type AuthorizationResponseError, type AuthorizationResponseError,
@ -39,7 +39,7 @@ const getFlow = (
| undefined | undefined
> => { > => {
return db.query.OpenIdLoginFlows.findFirst({ return db.query.OpenIdLoginFlows.findFirst({
where: (flow): SQL | undefined => eq(flow.id, flowId), where: (flow, { eq }): SQL | undefined => eq(flow.id, flowId),
with: { with: {
application: true, application: true,
}, },

View file

@ -26,7 +26,7 @@
//"isolatedDeclarations": true, //"isolatedDeclarations": true,
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"moduleDetection": "force", "moduleDetection": "force",
"target": "ESNext", "target": "esnext",
"jsx": "preserve", "jsx": "preserve",
"lib": ["ESNext", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"composite": true, "composite": true,

View file

@ -170,7 +170,7 @@ export const checkRouteNeedsChallenge = async (
} }
const challenge = await db.query.Challenges.findFirst({ const challenge = await db.query.Challenges.findFirst({
where: (c): SQL | undefined => eq(c.id, challenge_id), where: (c, { eq }): SQL | undefined => eq(c.id, challenge_id),
}); });
if (!challenge) { if (!challenge) {