Add bite activity support by mia

This commit is contained in:
Crimekillz 2024-03-26 12:40:19 +01:00
parent 3ca942452f
commit 0e80d5093a
28 changed files with 686 additions and 1 deletions

View File

@ -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

View File

@ -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,
];

View File

@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class FederatedBite1705528046452 implements MigrationInterface {
name = 'FederatedBite1705528046452'
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -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<x extends keyof typeof refs> = SchemaType<typeof refs[x]>;

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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<Packed<"Bite">> {
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<"UserLite"> | 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<string> {
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<User["id"]> {
switch (bite.targetType) {
case "user":
return bite.targetUserId!;
case "bite":
bite.targetBite =
bite.targetBite ??
(await Bites.findOneByOrFail({ id: bite.targetBiteId! }));
return bite.targetBite.userId;
}
},
});

View File

@ -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! }),
}
: {}),
});
},

View File

@ -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;

View File

@ -75,5 +75,11 @@ export const packedNotificationSchema = {
optional: true,
nullable: true,
},
bite: {
type: "object",
ref: "Bite",
optional: true,
nullable: true,
},
},
} as const;

View File

@ -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<string> => {
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";
};

View File

@ -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}`);
}

View File

@ -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),
});

View File

@ -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#",
// mia
Bite: "https://ns.mia.jetzt/as#Bite",
},
],
},

View File

@ -24,6 +24,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";
import { In, IsNull, Not } from "typeorm";
export default class Resolver {
@ -205,6 +206,10 @@ export default class Resolver {
throw new Error("resolveLocal: invalid follow URI");
}
return 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`);
}

View File

@ -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";

View File

@ -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;

View File

@ -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 {

View File

@ -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);
});

View File

@ -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);
});

View File

@ -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<Bite["id"]> {
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;
}

View File

@ -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.

View File

@ -11,6 +11,7 @@ export const notificationTypes = [
"followRequestAccepted",
"groupInvited",
"app",
"bite",
] as const;
export const noteVisibilities = [

View File

@ -0,0 +1,124 @@
<template>
<button class="kpoogebi _button bite-button" :class="{
full,
large,
wait,
active: hasBittenBack,
}" :disabled="wait" @click.stop="onClick" :aria-label="`bite ${user.name || user.username} back`">
<span>{{ i18n.ts.biteBack }}</span><i class="ph-tooth ph-bold ph-lg"></i>
</button>
</template>
<script lang="ts" setup>
import type * as Misskey from "iceshrimp-js";
import * as os from "@/os";
import { i18n } from "@/i18n";
const props = withDefaults(
defineProps<{
user: Misskey.entities.UserLite,
bite: Misskey.entities.Bite,
full: boolean,
large: boolean,
}>(),
{
full: false,
large: false
},
);
let wait = $ref(false);
let hasBittenBack = $ref<boolean>(props.bite.replied);
async function onClick() {
wait = true;
try {
await os.api("bites/create", {
targetType: "bite",
targetId: props.bite.id,
});
hasBittenBack = true;
} finally {
wait = false;
}
}
</script>
<style lang="scss" scoped>
.bite-button {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
color: var(--accent);
border: solid 1px var(--accent);
padding: 0;
font-size: 16px;
width: 2em;
height: 2em;
border-radius: 100px;
background: var(--bg);
vertical-align: middle;
margin-left: 0.5em;
&.full {
padding: 0.2em 0.7em;
width: auto;
font-size: 14px;
}
&.large {
font-size: 16px;
height: 38px;
padding: 0 12px 0 16px;
}
&:not(.full) {
width: 31px;
span {
display: none;
}
}
&:focus-visible {
&:after {
content: "";
pointer-events: none;
position: absolute;
top: -5px;
right: -5px;
bottom: -5px;
left: -5px;
border: 2px solid var(--focus);
border-radius: 32px;
}
}
&.active {
color: var(--fgOnAccent);
background: var(--accent);
&:hover {
background: var(--accentLighten);
border-color: var(--accentLighten);
}
&:active {
background: var(--accentDarken);
border-color: var(--accentDarken);
}
}
&.wait {
cursor: wait !important;
opacity: 0.7;
}
>span {
margin-right: 6px;
}
}
</style>

View File

@ -222,6 +222,21 @@
:hideMenu="true"
/></div
></span>
<span
v-if="notification.type === 'bite'"
class="text"
style="opacity: 0.7">{{
notification.bite.targetType === 'user'
? i18n.ts.bitYou
: i18n.ts.bitYouBack
}}
<div v-if="full">
<MkBiteButton
:user="notification.user"
:bite="notification.bite"
:full="true"
/></div
></span>
<span
v-if="notification.type === 'followRequestAccepted'"
class="text"
@ -277,6 +292,7 @@ import { ref, onMounted, onUnmounted, watch } from "vue";
import * as misskey from "iceshrimp-js";
import XReactionIcon from "@/components/MkReactionIcon.vue";
import MkFollowButton from "@/components/MkFollowButton.vue";
import MkBiteButton from "@/components/MkBiteButton.vue";
import XReactionTooltip from "@/components/MkReactionTooltip.vue";
import { getNoteSummary } from "@/scripts/get-note-summary";
import { notePage } from "@/filters/note";

View File

@ -59,6 +59,13 @@ export function getUserMenu(user, router: Router = mainRouter) {
});
}
async function bite() {
await os.apiWithDialog("bites/create", {
targetType: "user",
targetId: user.id,
});
}
async function toggleMute() {
if (user.isMuted) {
os.apiWithDialog("mute/delete", {
@ -310,6 +317,13 @@ export function getUserMenu(user, router: Router = mainRouter) {
action: inviteGroup,
}
: undefined,
meId !== user.id
? {
icon: "ph-tooth ph-bold ph-lg",
text: i18n.ts.bite,
action: bite,
}
: undefined,
null,
{
icon: user.isRenoteMuted

View File

@ -250,6 +250,12 @@ export type Notification = {
body: string;
icon?: string | null;
}
| {
type: "bite";
user: User;
userId: User["id"];
bite: Bite;
}
);
export type MessagingMessage = {
@ -492,3 +498,10 @@ export type UserSorting =
| "+updatedAt"
| "-updatedAt";
export type OriginType = "combined" | "local" | "remote";
export type Bite = {
id: ID;
user: UserLite,
targetType: "user" | "bite",
target: UserLite | Bite,
};