mirror of
https://github.com/versia-pub/server.git
synced 2026-04-27 12:49:16 +02:00
Some checks failed
CodeQL Scan / Analyze (push) Failing after 0s
Deploy Docs to GitHub Pages / build (push) Failing after 0s
Deploy Docs to GitHub Pages / Deploy (push) Has been skipped
Nix Build / check (push) Failing after 0s
Test Publish / build (client) (push) Failing after 0s
Test Publish / build (sdk) (push) Failing after 0s
Build Docker Images / lint (push) Has been cancelled
Build Docker Images / check (push) Has been cancelled
Build Docker Images / tests (push) Has been cancelled
Build Docker Images / detect-circular (push) Has been cancelled
Mirror to Codeberg / Mirror (push) Has been cancelled
Build Docker Images / build (server, Dockerfile, ${{ github.repository_owner }}/server) (push) Has been cancelled
Build Docker Images / build (worker, Worker.Dockerfile, ${{ github.repository_owner }}/worker) (push) Has been cancelled
329 lines
9.5 KiB
TypeScript
329 lines
9.5 KiB
TypeScript
import * as VersiaEntities from "@versia/sdk/entities";
|
|
import { config } from "@versia-server/config";
|
|
import { randomUUIDv7 } from "bun";
|
|
import {
|
|
and,
|
|
desc,
|
|
eq,
|
|
type InferInsertModel,
|
|
type InferSelectModel,
|
|
inArray,
|
|
isNull,
|
|
type SQL,
|
|
} from "drizzle-orm";
|
|
import { db } from "../tables/db.ts";
|
|
import {
|
|
type Instances,
|
|
type Notes,
|
|
Reactions,
|
|
type Users,
|
|
} from "../tables/schema.ts";
|
|
import { BaseInterface } from "./base.ts";
|
|
import { Emoji } from "./emoji.ts";
|
|
import { Instance } from "./instance.ts";
|
|
import type { Note } from "./note.ts";
|
|
import type { User } from "./user.ts";
|
|
|
|
type ReactionType = InferSelectModel<typeof Reactions> & {
|
|
emoji: typeof Emoji.$type | null;
|
|
author: InferSelectModel<typeof Users> & {
|
|
instance: InferSelectModel<typeof Instances> | null;
|
|
};
|
|
note: InferSelectModel<typeof Notes> & {
|
|
author: InferSelectModel<typeof Users> & {
|
|
instance: InferSelectModel<typeof Instances> | null;
|
|
};
|
|
};
|
|
};
|
|
|
|
export class Reaction extends BaseInterface<typeof Reactions, ReactionType> {
|
|
public static $type: ReactionType;
|
|
|
|
public async reload(): Promise<void> {
|
|
const reloaded = await Reaction.fromId(this.data.id);
|
|
|
|
if (!reloaded) {
|
|
throw new Error("Failed to reload reaction");
|
|
}
|
|
|
|
this.data = reloaded.data;
|
|
}
|
|
|
|
public static async fromId(id: string | null): Promise<Reaction | null> {
|
|
if (!id) {
|
|
return null;
|
|
}
|
|
|
|
return await Reaction.fromSql(eq(Reactions.id, id));
|
|
}
|
|
|
|
public static async fromIds(ids: string[]): Promise<Reaction[]> {
|
|
return await Reaction.manyFromSql(inArray(Reactions.id, ids));
|
|
}
|
|
|
|
public static async fromSql(
|
|
sql: SQL<unknown> | undefined,
|
|
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
|
|
): Promise<Reaction | null> {
|
|
const found = await db.query.Reactions.findFirst({
|
|
where: sql,
|
|
with: {
|
|
emoji: {
|
|
with: {
|
|
instance: true,
|
|
media: true,
|
|
},
|
|
},
|
|
author: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
note: {
|
|
with: {
|
|
author: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
orderBy,
|
|
});
|
|
|
|
if (!found) {
|
|
return null;
|
|
}
|
|
return new Reaction(found);
|
|
}
|
|
|
|
public static async manyFromSql(
|
|
sql: SQL<unknown> | undefined,
|
|
orderBy: SQL<unknown> | undefined = desc(Reactions.id),
|
|
limit?: number,
|
|
offset?: number,
|
|
extra?: Parameters<typeof db.query.Reactions.findMany>[0],
|
|
): Promise<Reaction[]> {
|
|
const found = await db.query.Reactions.findMany({
|
|
where: sql,
|
|
orderBy,
|
|
limit,
|
|
offset,
|
|
with: {
|
|
...extra?.with,
|
|
emoji: {
|
|
with: {
|
|
instance: true,
|
|
media: true,
|
|
},
|
|
},
|
|
author: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
note: {
|
|
with: {
|
|
author: {
|
|
with: {
|
|
instance: true,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
return found.map((s) => new Reaction(s));
|
|
}
|
|
|
|
public async update(
|
|
newReaction: Partial<ReactionType>,
|
|
): Promise<ReactionType> {
|
|
await db
|
|
.update(Reactions)
|
|
.set(newReaction)
|
|
.where(eq(Reactions.id, this.id));
|
|
|
|
const updated = await Reaction.fromId(this.data.id);
|
|
|
|
if (!updated) {
|
|
throw new Error("Failed to update reaction");
|
|
}
|
|
|
|
this.data = updated.data;
|
|
return updated.data;
|
|
}
|
|
|
|
public save(): Promise<ReactionType> {
|
|
return this.update(this.data);
|
|
}
|
|
|
|
public async delete(ids?: string[]): Promise<void> {
|
|
if (Array.isArray(ids)) {
|
|
await db.delete(Reactions).where(inArray(Reactions.id, ids));
|
|
} else {
|
|
await db.delete(Reactions).where(eq(Reactions.id, this.id));
|
|
}
|
|
}
|
|
|
|
public static async insert(
|
|
data: InferInsertModel<typeof Reactions>,
|
|
): Promise<Reaction> {
|
|
// Needs one of emojiId or emojiText, but not both
|
|
if (!(data.emojiId || data.emojiText)) {
|
|
throw new Error("EmojiID or emojiText is required");
|
|
}
|
|
|
|
if (data.emojiId && data.emojiText) {
|
|
throw new Error("Cannot have both emojiId and emojiText");
|
|
}
|
|
|
|
const inserted = (
|
|
await db.insert(Reactions).values(data).returning()
|
|
)[0];
|
|
|
|
const reaction = await Reaction.fromId(inserted.id);
|
|
|
|
if (!reaction) {
|
|
throw new Error("Failed to insert reaction");
|
|
}
|
|
|
|
return reaction;
|
|
}
|
|
|
|
public get id(): string {
|
|
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(): URL {
|
|
const domain = this.data.note.author.instance?.domain
|
|
? new URL(`https://${this.data.note.author.instance.domain}`)
|
|
: config.http.base_url;
|
|
|
|
return new URL(
|
|
`/.versia/v0.6/entities/${encodeURIComponent(
|
|
"pub.versia:reactions/Reaction",
|
|
)}/${this.id}`,
|
|
domain,
|
|
);
|
|
}
|
|
|
|
public get local(): boolean {
|
|
return this.data.author.instanceId === null;
|
|
}
|
|
|
|
public hasCustomEmoji(): boolean {
|
|
return !!this.data.emoji || !this.data.emojiText;
|
|
}
|
|
|
|
public toVersia(): VersiaEntities.Reaction {
|
|
if (!this.local) {
|
|
throw new Error("Cannot convert a non-local reaction to Versia");
|
|
}
|
|
|
|
let noteReference = this.data.note.id;
|
|
|
|
if (this.data.note.author.instance) {
|
|
noteReference = `${this.data.note.author.instance.domain}:${this.data.note.remoteId}`;
|
|
}
|
|
|
|
return new VersiaEntities.Reaction(
|
|
{
|
|
type: "pub.versia:reactions/Reaction",
|
|
author: this.data.author.id,
|
|
created_at: this.data.createdAt.toISOString(),
|
|
id: this.id,
|
|
object: noteReference,
|
|
content: this.hasCustomEmoji()
|
|
? `:${this.data.emoji?.shortcode}:`
|
|
: this.data.emojiText || "",
|
|
extensions: this.hasCustomEmoji()
|
|
? {
|
|
"pub.versia:custom_emojis": {
|
|
emojis: [
|
|
new Emoji(
|
|
this.data.emoji as typeof Emoji.$type,
|
|
).toVersia(),
|
|
],
|
|
},
|
|
}
|
|
: undefined,
|
|
},
|
|
this.data.author.instance?.domain ?? config.http.base_url.hostname,
|
|
);
|
|
}
|
|
|
|
public toVersiaUnreact(): VersiaEntities.Delete {
|
|
return new VersiaEntities.Delete(
|
|
{
|
|
type: "Delete",
|
|
created_at: new Date().toISOString(),
|
|
author: this.data.authorId,
|
|
deleted_type: "pub.versia:reactions/Reaction",
|
|
deleted: this.id,
|
|
},
|
|
this.data.author.instance?.domain ?? config.http.base_url.hostname,
|
|
);
|
|
}
|
|
|
|
public static async fromVersia(
|
|
reactionToConvert: VersiaEntities.Reaction,
|
|
author: User,
|
|
note: Note,
|
|
): Promise<Reaction> {
|
|
if (author.local) {
|
|
throw new Error("Cannot process a reaction from a local user");
|
|
}
|
|
|
|
const emojiEntity =
|
|
reactionToConvert.data.extensions?.["pub.versia:custom_emojis"]
|
|
?.emojis[0];
|
|
const emoji = emojiEntity
|
|
? await Emoji.fetchFromRemote(
|
|
emojiEntity,
|
|
new Instance(
|
|
author.data.instance as NonNullable<
|
|
(typeof User.$type)["instance"]
|
|
>,
|
|
),
|
|
)
|
|
: null;
|
|
|
|
return Reaction.insert({
|
|
id: randomUUIDv7(),
|
|
remoteId: reactionToConvert.data.id,
|
|
authorId: author.id,
|
|
noteId: note.id,
|
|
emojiId: emoji ? emoji.id : null,
|
|
emojiText: emoji ? null : reactionToConvert.data.content,
|
|
});
|
|
}
|
|
}
|