diff --git a/README.md b/README.md
index f6b868cf3..b137c6a3f 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,7 @@
+This repository includes [bite.patch](https://iceshrimp.dev/mia/withdrawal/src/branch/main/patches/bite.patch), which changes the database schema. Please contact @mia@void.rehab before migrating from iceshrimp-bite to another software, including back to vanilla Iceshrimp and to Iceshrimp.NET.
+
+---
+
Iceshrimp is a decentralized and federated social networking service, implementing the ActivityPub standard.
It was forked from Calckey Firefish (itself a fork of Misskey) in mid-2023, to focus on stability, performance and usability instead of new features.
diff --git a/locales/en-US.yml b/locales/en-US.yml
index 02a2c6aef..f4857f036 100644
--- a/locales/en-US.yml
+++ b/locales/en-US.yml
@@ -1132,6 +1132,11 @@ openInMainColumn: "Open in main column"
searchNotLoggedIn_1: "You have to be authenticated in order to use full text search."
searchNotLoggedIn_2: "However, you can search using hashtags, and search users."
searchEmptyQuery: "Please enter a search term."
+bite: "Bite"
+biteBack: "Bite back"
+bittenBack: "Bitten back"
+bitYou: "bit you"
+bitYouBack: "bit you back"
_sensitiveMediaDetection:
description: "Reduces the effort of server moderation through automatically recognizing
diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts
index 3e3f3a289..8d78521af 100644
--- a/packages/backend/src/db/postgre.ts
+++ b/packages/backend/src/db/postgre.ts
@@ -76,6 +76,7 @@ import { OAuthApp } from "@/models/entities/oauth-app.js";
import { OAuthToken } from "@/models/entities/oauth-token.js";
import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js";
import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js";
+import { Bite } from "@/models/entities/bite.js";
const sqlLogger = dbLogger.createSubLogger("sql", "gray", false);
class MyCustomLogger implements Logger {
@@ -179,6 +180,7 @@ export const entities = [
OAuthToken,
HtmlNoteCacheEntry,
HtmlUserCacheEntry,
+ Bite,
...charts,
];
diff --git a/packages/backend/src/migration/1705528046452-federated-bite.ts b/packages/backend/src/migration/1705528046452-federated-bite.ts
new file mode 100644
index 000000000..90d31c5b0
--- /dev/null
+++ b/packages/backend/src/migration/1705528046452-federated-bite.ts
@@ -0,0 +1,45 @@
+import { MigrationInterface, QueryRunner } from "typeorm";
+
+export class FederatedBite1705528046452 implements MigrationInterface {
+ name = 'FederatedBite1705528046452'
+
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`CREATE TYPE "public"."bite_targettype_enum" AS ENUM('user', 'bite')`);
+ await queryRunner.query(`CREATE TABLE "bite" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "uri" character varying(512), "userId" character varying(32) NOT NULL, "targetType" "public"."bite_targettype_enum" NOT NULL, "targetUserId" character varying(32), "targetBiteId" character varying(32), "replied" boolean NOT NULL DEFAULT true, CONSTRAINT "CHK_c3a20c5756ccff3133f8927500" CHECK ("targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL), CONSTRAINT "PK_1887f3f621a4a7655a1b78bfd66" PRIMARY KEY ("id")); COMMENT ON COLUMN "bite"."uri" IS 'null if local'`);
+ await queryRunner.query(`ALTER TABLE "notification" ADD "biteId" character varying(32)`);
+ await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum" RENAME TO "user_profile_mutingnotificationtypes_enum_old"`);
+ await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`);
+ await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
+ await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum"[]`);
+ await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
+ await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum_old"`);
+ await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_8d00aa79e157364ac1f60c15098" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9" FOREIGN KEY ("targetUserId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "bite" ADD CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9" FOREIGN KEY ("targetBiteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TABLE "notification" ADD CONSTRAINT "FK_c54844158c1eead7042e7ca4c83" FOREIGN KEY ("biteId") REFERENCES "bite"("id") ON DELETE CASCADE ON UPDATE NO ACTION`);
+ await queryRunner.query(`ALTER TYPE "public"."notification_type_enum" RENAME TO "notification_type_enum_old"`);
+ await queryRunner.query(`CREATE TYPE "public"."notification_type_enum" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app', 'bite')`);
+ await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum" USING "type"::"text"::"public"."notification_type_enum"`);
+ await queryRunner.query(`DROP TYPE "public"."notification_type_enum_old"`);
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "notification" DROP CONSTRAINT "FK_c54844158c1eead7042e7ca4c83"`);
+ await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_5d5f68610583f2e0b6785d3c0e9"`);
+ await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_a646fbbeb6efa2531c75fec46b9"`);
+ await queryRunner.query(`ALTER TABLE "bite" DROP CONSTRAINT "FK_8d00aa79e157364ac1f60c15098"`);
+ await queryRunner.query(`CREATE TYPE "public"."user_profile_mutingnotificationtypes_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
+ await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" DROP DEFAULT`);
+ await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" TYPE "public"."user_profile_mutingnotificationtypes_enum_old"[] USING "mutingNotificationTypes"::"text"::"public"."user_profile_mutingnotificationtypes_enum_old"[]`);
+ await queryRunner.query(`ALTER TABLE "user_profile" ALTER COLUMN "mutingNotificationTypes" SET DEFAULT '{}'`);
+ await queryRunner.query(`DROP TYPE "public"."user_profile_mutingnotificationtypes_enum"`);
+ await queryRunner.query(`ALTER TYPE "public"."user_profile_mutingnotificationtypes_enum_old" RENAME TO "user_profile_mutingnotificationtypes_enum"`);
+ await queryRunner.query(`ALTER TABLE "notification" DROP COLUMN "biteId"`);
+ await queryRunner.query(`DROP TABLE "bite"`);
+ await queryRunner.query(`DROP TYPE "public"."bite_targettype_enum"`);
+ await queryRunner.query(`CREATE TYPE "public"."notification_type_enum_old" AS ENUM('follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app')`);
+ await queryRunner.query(`ALTER TABLE "notification" ALTER COLUMN "type" TYPE "public"."notification_type_enum_old" USING "type"::"text"::"public"."notification_type_enum_old"`);
+ await queryRunner.query(`DROP TYPE "public"."notification_type_enum"`);
+ await queryRunner.query(`ALTER TYPE "public"."notification_type_enum_old" RENAME TO "notification_type_enum"`);
+ }
+}
diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts
index 6e03d30d9..317856c89 100644
--- a/packages/backend/src/misc/schema.ts
+++ b/packages/backend/src/misc/schema.ts
@@ -31,6 +31,7 @@ import { packedQueueCountSchema } from "@/models/schema/queue.js";
import { packedGalleryPostSchema } from "@/models/schema/gallery-post.js";
import { packedEmojiSchema } from "@/models/schema/emoji.js";
import { packedNoteEdit } from "@/models/schema/note-edit.js";
+import { packedBiteSchema } from "@/models/schema/bite.js";
export const refs = {
UserLite: packedUserLiteSchema,
@@ -65,6 +66,7 @@ export const refs = {
FederationInstance: packedFederationInstanceSchema,
GalleryPost: packedGalleryPostSchema,
Emoji: packedEmojiSchema,
+ Bite: packedBiteSchema,
};
export type Packed = SchemaType;
diff --git a/packages/backend/src/models/entities/bite.ts b/packages/backend/src/models/entities/bite.ts
new file mode 100644
index 000000000..ee7e3aea1
--- /dev/null
+++ b/packages/backend/src/models/entities/bite.ts
@@ -0,0 +1,56 @@
+import { Check, Column, Entity, ManyToOne, PrimaryColumn } from "typeorm";
+import { id } from "../id.js";
+import { User } from "./user.js";
+
+@Entity()
+@Check(`"targetUserId" IS NOT NULL OR "targetBiteId" IS NOT NULL`)
+export class Bite {
+ @PrimaryColumn(id())
+ public id: string;
+
+ @Column("timestamp with time zone")
+ public createdAt: Date;
+
+ @Column("varchar", {
+ length: 512,
+ nullable: true,
+ comment: "null if local",
+ })
+ public uri: string | null;
+
+ @Column(id())
+ public userId: string;
+
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ })
+ public user: User;
+
+ @Column("enum", {
+ enum: ["user", "bite"],
+ })
+ public targetType: "user" | "bite";
+
+ @Column({ ...id(), nullable: true })
+ public targetUserId: string | null;
+
+ @ManyToOne(() => User, {
+ onDelete: "CASCADE",
+ nullable: true,
+ })
+ public targetUser: User | null;
+
+ @Column({ ...id(), nullable: true })
+ public targetBiteId: string | null;
+
+ @ManyToOne(() => Bite, {
+ onDelete: "CASCADE",
+ nullable: true,
+ })
+ public targetBite: Bite | null;
+
+ @Column("boolean", {
+ default: true,
+ })
+ public replied: boolean;
+}
diff --git a/packages/backend/src/models/entities/notification.ts b/packages/backend/src/models/entities/notification.ts
index da23f7d3e..f8619a655 100644
--- a/packages/backend/src/models/entities/notification.ts
+++ b/packages/backend/src/models/entities/notification.ts
@@ -13,6 +13,7 @@ import { FollowRequest } from "./follow-request.js";
import { UserGroupInvitation } from "./user-group-invitation.js";
import { AccessToken } from "./access-token.js";
import { notificationTypes } from "@/types.js";
+import { Bite } from "./bite.js";
@Entity()
export class Notification {
@@ -181,4 +182,12 @@ export class Notification {
})
@JoinColumn()
public appAccessToken: AccessToken | null;
+
+ @Column({ ...id(), nullable: true })
+ public biteId: Bite["id"] | null;
+
+ @ManyToOne((type) => Bite, {
+ onDelete: "CASCADE", nullable: true
+ })
+ public bite: Bite | null;
}
diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts
index 2f229689b..4ca2666f7 100644
--- a/packages/backend/src/models/index.ts
+++ b/packages/backend/src/models/index.ts
@@ -70,6 +70,7 @@ import { OAuthToken } from "@/models/entities/oauth-token.js";
import { UserProfileRepository } from "@/models/repositories/user-profile.js";
import { HtmlNoteCacheEntry } from "@/models/entities/html-note-cache-entry.js";
import { HtmlUserCacheEntry } from "@/models/entities/html-user-cache-entry.js";
+import { BiteRespository } from "./repositories/bite.js";
export const Announcements = db.getRepository(Announcement);
export const AnnouncementReads = db.getRepository(AnnouncementRead);
@@ -138,3 +139,4 @@ export const OAuthApps = db.getRepository(OAuthApp);
export const OAuthTokens = db.getRepository(OAuthToken);
export const HtmlUserCacheEntries = db.getRepository(HtmlUserCacheEntry);
export const HtmlNoteCacheEntries = db.getRepository(HtmlNoteCacheEntry);
+export const Bites = BiteRespository;
diff --git a/packages/backend/src/models/repositories/bite.ts b/packages/backend/src/models/repositories/bite.ts
new file mode 100644
index 000000000..18b242c54
--- /dev/null
+++ b/packages/backend/src/models/repositories/bite.ts
@@ -0,0 +1,71 @@
+import { db } from "@/db/postgre.js";
+import { Bite } from "../entities/bite.js";
+import { Packed } from "@/misc/schema.js";
+import { Bites, Users } from "../index.js";
+import { User } from "../entities/user.js";
+import { awaitAll } from "@/prelude/await-all.js";
+import config from "@/config/index.js";
+
+export const BiteRespository = db.getRepository(Bite).extend({
+ async pack(
+ src: Bite | Bite["id"],
+ me?: { id: User["id"] } | null | undefined,
+ ): Promise> {
+ const bite =
+ typeof src === "object" ? src : await this.findOneByOrFail({ id: src });
+ return await awaitAll({
+ id: bite.id,
+ user: Users.pack(bite.user ?? bite.userId, me, { detail: false }),
+ targetType: bite.targetType,
+ target: this.packTarget(bite, me),
+ replied: bite.replied,
+ });
+ },
+
+ async packTarget(
+ bite: Bite,
+ me?: { id: User["id"] } | null | undefined,
+ ): Promise | Packed<"Bite">> {
+ switch (bite.targetType) {
+ case "user":
+ return await Users.pack(bite.targetUser ?? bite.targetUserId!, me, {
+ detail: false,
+ });
+ case "bite":
+ return await this.pack(bite.targetBite ?? bite.targetBiteId!, me);
+ }
+ },
+
+ async targetUri(bite: Bite): Promise {
+ switch (bite.targetType) {
+ case "user": {
+ bite.targetUser =
+ bite.targetUser ??
+ (await Users.findOneOrFail({ where: { id: bite.targetUserId! } }));
+ return (
+ bite.targetUser.uri || `${config.url}/users/${bite.targetUserId}`
+ );
+ }
+ case "bite": {
+ bite.targetBite =
+ bite.targetBite ??
+ (await Bites.findOneOrFail({ where: { id: bite.targetBiteId! } }));
+ return (
+ bite.targetBite.uri || `${config.url}/bites/${bite.targetBiteId}`
+ );
+ }
+ }
+ },
+
+ async targetUserId(bite: Bite): Promise {
+ switch (bite.targetType) {
+ case "user":
+ return bite.targetUserId!;
+ case "bite":
+ bite.targetBite =
+ bite.targetBite ??
+ (await Bites.findOneByOrFail({ id: bite.targetBiteId! }));
+ return bite.targetBite.userId;
+ }
+ },
+});
diff --git a/packages/backend/src/models/repositories/notification.ts b/packages/backend/src/models/repositories/notification.ts
index 9c649cc53..79526fe10 100644
--- a/packages/backend/src/models/repositories/notification.ts
+++ b/packages/backend/src/models/repositories/notification.ts
@@ -14,6 +14,7 @@ import {
UserGroupInvitations,
AccessTokens,
NoteReactions,
+ Bites,
} from "../index.js";
export const NotificationRepository = db.getRepository(Notification).extend({
@@ -143,6 +144,11 @@ export const NotificationRepository = db.getRepository(Notification).extend({
icon: notification.customIcon || token?.iconUrl,
}
: {}),
+ ...(notification.type === "bite"
+ ? {
+ bite: notification.bite ?? await Bites.findOneBy({ id: notification.biteId! }),
+ }
+ : {}),
});
},
diff --git a/packages/backend/src/models/schema/bite.ts b/packages/backend/src/models/schema/bite.ts
new file mode 100644
index 000000000..2314ed6bc
--- /dev/null
+++ b/packages/backend/src/models/schema/bite.ts
@@ -0,0 +1,31 @@
+export const packedBiteSchema = {
+ type: "object",
+ properties: {
+ id: {
+ type: "string",
+ format: "id",
+ optional: false,
+ nullable: false,
+ },
+ user: {
+ type: "object",
+ ref: "UserLite",
+ },
+ targetType: {
+ type: "string",
+ enum: ["user", "bite"],
+ },
+ target: {
+ oneOf: [
+ {
+ type: "object",
+ ref: "UserLite",
+ },
+ {
+ type: "object",
+ ref: "Bite",
+ },
+ ],
+ },
+ },
+} as const;
diff --git a/packages/backend/src/models/schema/notification.ts b/packages/backend/src/models/schema/notification.ts
index 97fd16339..cb6bc47fe 100644
--- a/packages/backend/src/models/schema/notification.ts
+++ b/packages/backend/src/models/schema/notification.ts
@@ -75,5 +75,11 @@ export const packedNotificationSchema = {
optional: true,
nullable: true,
},
+ bite: {
+ type: "object",
+ ref: "Bite",
+ optional: true,
+ nullable: true,
+ },
},
} as const;
diff --git a/packages/backend/src/remote/activitypub/kernel/bite.ts b/packages/backend/src/remote/activitypub/kernel/bite.ts
new file mode 100644
index 000000000..ecce5cc7e
--- /dev/null
+++ b/packages/backend/src/remote/activitypub/kernel/bite.ts
@@ -0,0 +1,79 @@
+import { CacheableRemoteUser } from "@/models/entities/user.js";
+import { IActivity, IBite } from "../type.js";
+import Resolver from "../resolver.js";
+import { fetchPerson } from "../models/person.js";
+import config from "@/config/index.js";
+import { genId } from "@/misc/gen-id.js";
+import { createBite } from "@/services/create-bite.js";
+import { Bite } from "@/models/entities/bite.js";
+
+export default async (
+ actor: CacheableRemoteUser,
+ bite: IBite,
+): Promise => {
+ if (actor.uri !== bite.actor) {
+ return "skip: actor uri mismatch";
+ }
+
+ if (bite.id === null) {
+ return "skip: bite id not specified";
+ }
+
+ const resolver = new Resolver();
+ const biteActor = await fetchPerson(bite.actor, resolver);
+ if (biteActor === null) {
+ return "skip: biteActor is null";
+ }
+ if (!bite.target.startsWith(`${config.url}/`)) {
+ return "skip: target is not local";
+ }
+
+ const localId = genId();
+ const fields = {
+ id: localId,
+ userId: biteActor.id,
+ replied: false,
+ } as any;
+
+ const parts = bite.target.split("/");
+ const targetDbId = parts.pop();
+ const targetPathType = parts.pop();
+
+ let targetType: Bite["targetType"];
+ let targetId;
+
+ if (targetPathType === "users") {
+ targetType = "user";
+ targetId = targetDbId;
+ } else if (targetPathType === "bites") {
+ targetType = "bite";
+ targetId = targetDbId;
+ } else {
+ // fallback for unknown object types
+ targetType = "user";
+ if (bite.to !== undefined) {
+ const to = Array.isArray(bite.to) ? bite.to[0] : bite.to;
+ targetId = (to as string).split("/").pop();
+ } else {
+ const biteTarget = await resolver.resolve(bite.target);
+ const targetActor =
+ (biteTarget as IActivity).actor || biteTarget.attributedTo;
+ const targetActorId =
+ typeof targetActor === "string" ? targetActor : (targetActor as any).id;
+ if (!targetActorId.startsWith(`${config.url}/`)) {
+ return "skip: indirect target is not local";
+ }
+ targetId = targetActorId.split("/").pop();
+ }
+ }
+
+ await createBite(
+ biteActor,
+ targetType,
+ targetId,
+ bite.id!,
+ bite.published ? new Date(bite.published) : null,
+ );
+
+ return "ok";
+};
diff --git a/packages/backend/src/remote/activitypub/kernel/index.ts b/packages/backend/src/remote/activitypub/kernel/index.ts
index 58e354a51..f618ebe4f 100644
--- a/packages/backend/src/remote/activitypub/kernel/index.ts
+++ b/packages/backend/src/remote/activitypub/kernel/index.ts
@@ -19,6 +19,7 @@ import {
isFlag,
isMove,
getApId,
+ isBite,
} from "../type.js";
import { apLogger } from "../logger.js";
import Resolver from "../resolver.js";
@@ -37,6 +38,7 @@ import remove from "./remove/index.js";
import block from "./block/index.js";
import flag from "./flag/index.js";
import move from "./move/index.js";
+import bite from "./bite.js";
import type { IObject } from "../type.js";
import { extractDbHost } from "@/misc/convert-host.js";
import { shouldBlockInstance } from "@/misc/should-block-instance.js";
@@ -105,6 +107,8 @@ async function performOneActivity(
await flag(actor, activity);
} else if (isMove(activity)) {
await move(actor, activity);
+ } else if (isBite(activity)) {
+ await bite(actor, activity);
} else {
apLogger.warn(`unrecognized activity type: ${(activity as any).type}`);
}
diff --git a/packages/backend/src/remote/activitypub/renderer/bite.ts b/packages/backend/src/remote/activitypub/renderer/bite.ts
new file mode 100644
index 000000000..9630b6467
--- /dev/null
+++ b/packages/backend/src/remote/activitypub/renderer/bite.ts
@@ -0,0 +1,12 @@
+import config from "@/config/index.js";
+import { Bites } from "@/models/index.js";
+import { Bite } from "@/models/entities/bite.js";
+
+export default async (bite: Bite) => ({
+ id: `${config.url}/bites/${bite.id}`,
+ type: "Bite",
+ actor: `${config.url}/users/${bite.userId}`,
+ target: await Bites.targetUri(bite),
+ published: bite.createdAt.toISOString(),
+ to: await Bites.targetUserId(bite),
+});
diff --git a/packages/backend/src/remote/activitypub/renderer/index.ts b/packages/backend/src/remote/activitypub/renderer/index.ts
index a688b1194..5fe14f0db 100644
--- a/packages/backend/src/remote/activitypub/renderer/index.ts
+++ b/packages/backend/src/remote/activitypub/renderer/index.ts
@@ -47,6 +47,8 @@ export const renderActivity = (x: any): IActivity | null => {
fedibird: "http://fedibird.com/ns#",
// vcard
vcard: "http://www.w3.org/2006/vcard/ns#",
+ // delusion
+ Bite: "https://ns.mia.jetzt/as#Bite",
},
],
},
diff --git a/packages/backend/src/remote/activitypub/resolver.ts b/packages/backend/src/remote/activitypub/resolver.ts
index 223350cae..444c0ab8e 100644
--- a/packages/backend/src/remote/activitypub/resolver.ts
+++ b/packages/backend/src/remote/activitypub/resolver.ts
@@ -13,6 +13,7 @@ import {
NoteReactions,
Polls,
Users,
+ Bites,
} from "@/models/index.js";
import { parseUri } from "./db-resolver.js";
import renderNote from "@/remote/activitypub/renderer/note.js";
@@ -24,6 +25,7 @@ import { renderActivity } from "@/remote/activitypub/renderer/index.js";
import renderFollow from "@/remote/activitypub/renderer/follow.js";
import { shouldBlockInstance } from "@/misc/should-block-instance.js";
import { apLogger } from "@/remote/activitypub/logger.js";
+import renderBite from "@/remote/activitypub/renderer/bite.js";
export default class Resolver {
private history: Set;
@@ -181,6 +183,10 @@ export default class Resolver {
).then(([follower, followee]) =>
renderActivity(renderFollow(follower, followee, url)),
);
+ case "bites":
+ return Bites.findOneByOrFail({ id: parsed.id }).then((bite) =>
+ renderActivity(renderBite(bite)),
+ );
default:
throw new Error(`resolveLocal: type ${type} unhandled`);
}
diff --git a/packages/backend/src/remote/activitypub/type.ts b/packages/backend/src/remote/activitypub/type.ts
index 7b54b2320..d7ba1bdc1 100644
--- a/packages/backend/src/remote/activitypub/type.ts
+++ b/packages/backend/src/remote/activitypub/type.ts
@@ -322,6 +322,12 @@ export interface IMove extends IActivity {
target: IObject | string;
}
+export interface IBite extends IActivity {
+ type: "Bite";
+ actor: string;
+ target: string;
+}
+
export const isCreate = (object: IObject): object is ICreate =>
getApType(object) === "Create";
export const isDelete = (object: IObject): object is IDelete =>
@@ -354,3 +360,5 @@ export const isFlag = (object: IObject): object is IFlag =>
getApType(object) === "Flag";
export const isMove = (object: IObject): object is IMove =>
getApType(object) === "Move";
+export const isBite = (object: IObject): object is IBite =>
+ getApType(object) === "Bite";
diff --git a/packages/backend/src/server/activitypub.ts b/packages/backend/src/server/activitypub.ts
index 9df6a0c7d..eb3065777 100644
--- a/packages/backend/src/server/activitypub.ts
+++ b/packages/backend/src/server/activitypub.ts
@@ -16,6 +16,7 @@ import {
Emojis,
NoteReactions,
FollowRequests,
+ Bites,
} from "@/models/index.js";
import type { ILocalUser, User } from "@/models/entities/user.js";
import { renderLike } from "@/remote/activitypub/renderer/like.js";
@@ -35,6 +36,7 @@ import Outbox, { packActivity } from "./activitypub/outbox.js";
import { serverLogger } from "./index.js";
import config from "@/config/index.js";
import Koa from "koa";
+import renderBite from "@/remote/activitypub/renderer/bite.js";
// Init router
const router = new Router();
@@ -480,4 +482,34 @@ router.get("/follows/:followRequestId", async (ctx: Router.RouterContext) => {
setResponseType(ctx);
});
+// bite
+router.get("/bites/:biteId", async (ctx: Router.RouterContext) => {
+ tickFetch();
+ const verify = await checkFetch(ctx.req);
+ if (verify !== 200) {
+ ctx.status = verify;
+ return;
+ }
+
+ const bite = await Bites.findOne({
+ where: { id: ctx.params.biteId },
+ relations: ["targetUser", "targetBite"],
+ });
+
+ if (bite === null) {
+ ctx.status = 404;
+ return;
+ }
+
+ const meta = await fetchMeta();
+ if (meta.secureMode || meta.privateMode) {
+ ctx.set("Cache-Control", "private, max-age=0, must-revalidate");
+ } else {
+ ctx.set("Cache-Control", "public, max-age=180");
+ }
+
+ ctx.body = renderActivity(await renderBite(bite));
+ setResponseType(ctx);
+});
+
export default router;
diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts
index 430a0496e..bf12e4f83 100644
--- a/packages/backend/src/server/api/endpoints.ts
+++ b/packages/backend/src/server/api/endpoints.ts
@@ -334,6 +334,8 @@ import * as ep___users_show from "./endpoints/users/show.js";
import * as ep___users_stats from "./endpoints/users/stats.js";
import * as ep___fetchRss from "./endpoints/fetch-rss.js";
import * as ep___admin_driveCapOverride from "./endpoints/admin/drive-capacity-override.js";
+import * as ep___bites_create from "./endpoints/bites/create.js";
+import * as ep___bites_show from "./endpoints/bites/show.js";
//Iceshrimp Move
import * as ep___i_move from "./endpoints/i/move.js";
@@ -682,6 +684,8 @@ const eps = [
["admin/drive-capacity-override", ep___admin_driveCapOverride],
["fetch-rss", ep___fetchRss],
["get-sounds", ep___sounds],
+ ["bites/create", ep___bites_create],
+ ["bites/show", ep___bites_show],
];
export interface IEndpointMeta {
diff --git a/packages/backend/src/server/api/endpoints/bites/create.ts b/packages/backend/src/server/api/endpoints/bites/create.ts
new file mode 100644
index 000000000..c4e28dee2
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/bites/create.ts
@@ -0,0 +1,37 @@
+import { Bites } from "@/models/index.js";
+import define from "../../define.js";
+import { createBite } from "@/services/create-bite.js";
+import { MINUTE } from "@/const.js";
+
+export const meta = {
+ tags: ["bites"],
+
+ requireCredential: true,
+
+ limit: {
+ duration: MINUTE,
+ max: 30,
+ },
+
+ res: {
+ type: "object",
+ optional: false,
+ nullable: false,
+ ref: "Bite",
+ },
+} as const;
+
+export const paramDef = {
+ type: "object",
+ properties: {
+ targetType: { type: "string", enum: ["user", "bite"] },
+ targetId: { type: "string", format: "misskey:id" },
+ },
+ required: ["targetType", "targetId"],
+} as const;
+
+export default define(meta, paramDef, async (ps, user) => {
+ const biteId = await createBite(user, ps.targetType, ps.targetId);
+
+ return await Bites.pack(biteId, user);
+});
diff --git a/packages/backend/src/server/api/endpoints/bites/show.ts b/packages/backend/src/server/api/endpoints/bites/show.ts
new file mode 100644
index 000000000..533496dc2
--- /dev/null
+++ b/packages/backend/src/server/api/endpoints/bites/show.ts
@@ -0,0 +1,25 @@
+import { Bites } from "@/models/index.js";
+import define from "../../define.js";
+
+export const meta = {
+ tags: ["bites"],
+
+ res: {
+ type: "object",
+ optional: false,
+ nullable: false,
+ ref: "Bite",
+ },
+} as const;
+
+export const paramDef = {
+ type: "object",
+ properties: {
+ biteId: { type: "string", format: "misskey:id" },
+ },
+ required: ["biteId"],
+} as const;
+
+export default define(meta, paramDef, async (ps, user) => {
+ return await Bites.pack(ps.biteId, user);
+});
diff --git a/packages/backend/src/services/create-bite.ts b/packages/backend/src/services/create-bite.ts
new file mode 100644
index 000000000..dea96e20d
--- /dev/null
+++ b/packages/backend/src/services/create-bite.ts
@@ -0,0 +1,74 @@
+import { genId } from "@/misc/gen-id.js";
+import { Bites, Users } from "@/models/index.js";
+import { Bite } from "@/models/entities/bite.js";
+import { User } from "@/models/entities/user.js";
+import { renderActivity } from "@/remote/activitypub/renderer/index.js";
+import renderBite from "@/remote/activitypub/renderer/bite.js";
+import { deliverToUser } from "@/remote/activitypub/deliver-manager.js";
+import { createNotification } from "./create-notification.js";
+
+export async function createBite(
+ sender: User,
+ targetType: Bite["targetType"],
+ targetId: string,
+ remoteUri: Bite["uri"] = null,
+ createdAt: Date | null = null,
+): Promise {
+ const id = genId();
+
+ const insert = {
+ id,
+ createdAt: createdAt ?? new Date(),
+ userId: sender.id,
+ targetType,
+ replied: false,
+ uri: remoteUri,
+ } as any;
+
+ switch (targetType) {
+ case "user":
+ insert.targetUserId = targetId;
+ break;
+ case "bite":
+ insert.targetBiteId = targetId;
+ break;
+ }
+
+ await Bites.insert(insert);
+
+ const bite = await Bites.findOneOrFail({
+ where: { id },
+ relations: ["targetUser", "targetBite"],
+ });
+
+ let deliverTarget: User;
+
+ switch (targetType) {
+ case "user":
+ deliverTarget = bite.targetUser!;
+ break;
+ case "bite":
+ await Bites.update({ id: bite.targetBiteId! }, { replied: true });
+ deliverTarget =
+ bite.targetBite!.user ??
+ (await Users.findOneByOrFail({ id: bite.targetBite!.userId }));
+ break;
+ }
+
+ if (Users.isLocalUser(sender) && Users.isRemoteUser(deliverTarget)) {
+ await deliverToUser(
+ sender,
+ renderActivity(await renderBite(bite)),
+ deliverTarget,
+ );
+ }
+
+ if (Users.isLocalUser(deliverTarget)) {
+ await createNotification(deliverTarget.id, "bite", {
+ notifierId: sender.id,
+ biteId: bite.id,
+ });
+ }
+
+ return id;
+}
diff --git a/packages/backend/src/services/create-notification.ts b/packages/backend/src/services/create-notification.ts
index 9bfd1a7bf..d89cb8549 100644
--- a/packages/backend/src/services/create-notification.ts
+++ b/packages/backend/src/services/create-notification.ts
@@ -25,7 +25,7 @@ export async function createNotification(
if (
data.notifierId &&
- ["mention", "reply", "renote", "quote", "reaction"].includes(type)
+ ["mention", "reply", "renote", "quote", "reaction", "bite"].includes(type)
) {
const notifier = await Users.findOneBy({ id: data.notifierId });
// suppress if the notifier does not exist or is silenced.
diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts
index b77b5afb4..2204d2ff0 100644
--- a/packages/backend/src/types.ts
+++ b/packages/backend/src/types.ts
@@ -11,6 +11,7 @@ export const notificationTypes = [
"followRequestAccepted",
"groupInvited",
"app",
+ "bite",
] as const;
export const noteVisibilities = [
diff --git a/packages/client/src/components/MkBiteButton.vue b/packages/client/src/components/MkBiteButton.vue
new file mode 100644
index 000000000..6af0969e5
--- /dev/null
+++ b/packages/client/src/components/MkBiteButton.vue
@@ -0,0 +1,124 @@
+
+
+
+
+
+
+
diff --git a/packages/client/src/components/MkNotification.vue b/packages/client/src/components/MkNotification.vue
index bf5b873b5..cec021b7a 100644
--- a/packages/client/src/components/MkNotification.vue
+++ b/packages/client/src/components/MkNotification.vue
@@ -222,6 +222,21 @@
:hideMenu="true"
/>
+ {{
+ notification.bite.targetType === 'user'
+ ? i18n.ts.bitYou
+ : i18n.ts.bitYouBack
+ }}
+