From a624aeebe3024418514721eaeea6c6753a006d94 Mon Sep 17 00:00:00 2001 From: naskya Date: Sat, 8 Apr 2023 05:44:36 +0000 Subject: [PATCH] feat: per-user boost muting (#9825) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cherry-picked from FoundKey/c414f24a2c ([commit](https://akkoma.dev/FoundKeyGang/FoundKey/commit/c414f24a2c123774246c7eca65edda4d3afaf8b3)) This allows us to hide specified users' boosts from the timelines (the boosts will still be visible on their user page). Co-authored-by: Hélène Co-authored-by: naskya Reviewed-on: https://codeberg.org/calckey/calckey/pulls/9825 Co-authored-by: naskya Co-committed-by: naskya --- locales/en-US.yml | 2 + locales/ja-JP.yml | 2 + locales/zh-CN.yml | 2 + locales/zh-TW.yml | 2 + .../1665091090561-add-renote-muting.js | 16 ++++ packages/backend/src/db/postgre.ts | 2 + packages/backend/src/misc/schema.ts | 2 + .../src/models/entities/renote-muting.ts | 42 ++++++++++ packages/backend/src/models/index.ts | 2 + .../src/models/repositories/renote-muting.ts | 31 ++++++++ .../backend/src/models/repositories/user.ts | 9 +++ .../src/models/schema/renote-muting.ts | 26 +++++++ packages/backend/src/models/schema/user.ts | 4 + .../common/generated-muted-renote-query.ts | 22 ++++++ packages/backend/src/server/api/endpoints.ts | 6 ++ .../api/endpoints/notes/global-timeline.ts | 2 + .../api/endpoints/notes/hybrid-timeline.ts | 2 + .../api/endpoints/notes/local-timeline.ts | 2 + .../server/api/endpoints/notes/timeline.ts | 2 + .../api/endpoints/renote-mute/create.ts | 78 +++++++++++++++++++ .../api/endpoints/renote-mute/delete.ts | 73 +++++++++++++++++ .../server/api/endpoints/renote-mute/list.ts | 43 ++++++++++ .../server/api/endpoints/users/relation.ts | 8 ++ .../backend/src/server/api/stream/channel.ts | 4 + .../src/server/api/stream/channels/antenna.ts | 2 + .../src/server/api/stream/channels/channel.ts | 2 + .../api/stream/channels/global-timeline.ts | 2 + .../src/server/api/stream/channels/hashtag.ts | 2 + .../api/stream/channels/home-timeline.ts | 2 + .../api/stream/channels/hybrid-timeline.ts | 2 + .../api/stream/channels/local-timeline.ts | 2 + .../server/api/stream/channels/user-list.ts | 2 + .../backend/src/server/api/stream/index.ts | 15 ++++ packages/calckey-js/src/api.types.ts | 3 + packages/calckey-js/src/entities.ts | 1 + packages/client/src/scripts/get-user-menu.ts | 17 +++- 36 files changed, 435 insertions(+), 1 deletion(-) create mode 100644 packages/backend/migration/1665091090561-add-renote-muting.js create mode 100644 packages/backend/src/models/entities/renote-muting.ts create mode 100644 packages/backend/src/models/repositories/renote-muting.ts create mode 100644 packages/backend/src/models/schema/renote-muting.ts create mode 100644 packages/backend/src/server/api/common/generated-muted-renote-query.ts create mode 100644 packages/backend/src/server/api/endpoints/renote-mute/create.ts create mode 100644 packages/backend/src/server/api/endpoints/renote-mute/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/renote-mute/list.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index 9a5d35d81..a1b998d8a 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -121,6 +121,8 @@ unmarkAsSensitive: "Unmark as NSFW" enterFileName: "Enter filename" mute: "Mute" unmute: "Unmute" +renoteMute: "Mute boosts" +renoteUnmute: "Unmute boosts" block: "Block" unblock: "Unblock" suspend: "Suspend" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d553f9eab..0f1c700fd 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -121,6 +121,8 @@ unmarkAsSensitive: "閲覧注意を解除する" enterFileName: "ファイル名を入力" mute: "ミュート" unmute: "ミュート解除" +renoteMute: "ブーストをミュート" +renoteUnmute: "ブーストのミュートを解除" block: "ブロック" unblock: "ブロック解除" suspend: "凍結" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index aa2700c57..ea9d6371f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -116,6 +116,8 @@ unmarkAsSensitive: "取消标记为敏感内容" enterFileName: "请输入文件名" mute: "屏蔽" unmute: "解除屏蔽" +renoteMute: "屏蔽转帖" +renoteUnmute: "解除屏蔽转帖" block: "拉黑" unblock: "取消拉黑" suspend: "冻结" diff --git a/locales/zh-TW.yml b/locales/zh-TW.yml index 46be6f8e1..4471444e9 100644 --- a/locales/zh-TW.yml +++ b/locales/zh-TW.yml @@ -116,6 +116,8 @@ unmarkAsSensitive: "取消標記為敏感內容" enterFileName: "請輸入檔案名稱" mute: "靜音" unmute: "解除靜音" +renoteMute: "靜音轉發貼文" +renoteUnmute: "解除靜音轉發貼文" block: "封鎖" unblock: "解除封鎖" suspend: "凍結" diff --git a/packages/backend/migration/1665091090561-add-renote-muting.js b/packages/backend/migration/1665091090561-add-renote-muting.js new file mode 100644 index 000000000..d2ed2bd2e --- /dev/null +++ b/packages/backend/migration/1665091090561-add-renote-muting.js @@ -0,0 +1,16 @@ + +export class addRenoteMuting1665091090561 { + constructor() { + this.name = 'addRenoteMuting1665091090561'; + } + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "renote_muting" ("id" character varying(32) NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL, "muteeId" character varying(32) NOT NULL, "muterId" character varying(32) NOT NULL, CONSTRAINT "PK_renoteMuting_id" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_createdAt" ON "muting" ("createdAt") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muteeId" ON "muting" ("muteeId") `); + await queryRunner.query(`CREATE INDEX "IDX_renote_muting_muterId" ON "muting" ("muterId") `); + } + + async down(queryRunner) { + } +} diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index f95bd2594..bdeb910e8 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -23,6 +23,7 @@ import { Meta } from "@/models/entities/meta.js"; import { Following } from "@/models/entities/following.js"; import { Instance } from "@/models/entities/instance.js"; import { Muting } from "@/models/entities/muting.js"; +import { RenoteMuting } from "@/models/entities/renote-muting.js"; import { SwSubscription } from "@/models/entities/sw-subscription.js"; import { Blocking } from "@/models/entities/blocking.js"; import { UserList } from "@/models/entities/user-list.js"; @@ -136,6 +137,7 @@ export const entities = [ Following, FollowRequest, Muting, + RenoteMuting, Blocking, Note, NoteFavorite, diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index 35637e6ed..7eaeb92e0 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -16,6 +16,7 @@ import { packedDriveFileSchema } from "@/models/schema/drive-file.js"; import { packedDriveFolderSchema } from "@/models/schema/drive-folder.js"; import { packedFollowingSchema } from "@/models/schema/following.js"; import { packedMutingSchema } from "@/models/schema/muting.js"; +import { packedRenoteMutingSchema } from "@/models/schema/renote-muting.js"; import { packedBlockingSchema } from "@/models/schema/blocking.js"; import { packedNoteReactionSchema } from "@/models/schema/note-reaction.js"; import { packedHashtagSchema } from "@/models/schema/hashtag.js"; @@ -51,6 +52,7 @@ export const refs = { DriveFolder: packedDriveFolderSchema, Following: packedFollowingSchema, Muting: packedMutingSchema, + RenoteMuting: packedRenoteMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, Page: packedPageSchema, diff --git a/packages/backend/src/models/entities/renote-muting.ts b/packages/backend/src/models/entities/renote-muting.ts new file mode 100644 index 000000000..423a94c55 --- /dev/null +++ b/packages/backend/src/models/entities/renote-muting.ts @@ -0,0 +1,42 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from "typeorm"; +import { id } from "../id.js"; +import { User } from "./user.js"; + +@Entity() +@Index(["muterId", "muteeId"], { unique: true }) +export class RenoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column("timestamp with time zone", { + comment: "The created date of the Muting.", + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: "The mutee user ID.", + }) + public muteeId: User["id"]; + + @ManyToOne(type => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public mutee: User | null; + + @Index() + @Column({ + ...id(), + comment: "The muter user ID.", + }) + public muterId: User["id"]; + + @ManyToOne(type => User, { + onDelete: "CASCADE", + }) + @JoinColumn() + public muter: User | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 98f6705f4..f68166c17 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -27,6 +27,7 @@ import { UserGroupJoining } from "./entities/user-group-joining.js"; import { UserGroupInvitationRepository } from "./repositories/user-group-invitation.js"; import { FollowRequestRepository } from "./repositories/follow-request.js"; import { MutingRepository } from "./repositories/muting.js"; +import { RenoteMutingRepository } from "./repositories/renote-muting.js"; import { BlockingRepository } from "./repositories/blocking.js"; import { NoteReactionRepository } from "./repositories/note-reaction.js"; import { NotificationRepository } from "./repositories/notification.js"; @@ -102,6 +103,7 @@ export const DriveFolders = DriveFolderRepository; export const Notifications = NotificationRepository; export const Metas = db.getRepository(Meta); export const Mutings = MutingRepository; +export const RenoteMutings = RenoteMutingRepository; export const Blockings = BlockingRepository; export const SwSubscriptions = db.getRepository(SwSubscription); export const Hashtags = HashtagRepository; diff --git a/packages/backend/src/models/repositories/renote-muting.ts b/packages/backend/src/models/repositories/renote-muting.ts new file mode 100644 index 000000000..a39c69738 --- /dev/null +++ b/packages/backend/src/models/repositories/renote-muting.ts @@ -0,0 +1,31 @@ +import { db } from "@/db/postgre.js"; +import { Packed } from "@/misc/schema.js"; +import { RenoteMuting } from "@/models/entities/renote-muting.js"; +import { User } from "@/models/entities/user.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { Users } from "../index.js"; + +export const RenoteMutingRepository = db.getRepository(RenoteMuting).extend({ + async pack( + src: RenoteMuting["id"] | RenoteMuting, + me?: { id: User["id"] } | null | undefined, + ): Promise> { + const muting = typeof src === "object" ? src : await this.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: Users.pack(muting.muteeId, me, { + detail: true, + }), + }); + }, + + packMany( + mutings: any[], + me: { id: User["id"] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + }, +}); diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 27b0c78d6..f6fcdae29 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -28,6 +28,7 @@ import { Instances, MessagingMessages, Mutings, + RenoteMutings, Notes, NoteUnreads, Notifications, @@ -171,6 +172,13 @@ export const UserRepository = db.getRepository(User).extend({ }, take: 1, }).then((n) => n > 0), + isRenoteMuted: RenoteMutings.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then((n) => n > 0), }); }, @@ -585,6 +593,7 @@ export const UserRepository = db.getRepository(User).extend({ isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable< diff --git a/packages/backend/src/models/schema/renote-muting.ts b/packages/backend/src/models/schema/renote-muting.ts new file mode 100644 index 000000000..f60b10567 --- /dev/null +++ b/packages/backend/src/models/schema/renote-muting.ts @@ -0,0 +1,26 @@ +export const packedRenoteMutingSchema = { + type: "object", + properties: { + id: { + type: "string", + optional: false, nullable: false, + format: "id", + example: "xxxxxxxxxx", + }, + createdAt: { + type: "string", + optional: false, nullable: false, + format: "date-time", + }, + muteeId: { + type: "string", + optional: false, nullable: false, + format: "id", + }, + mutee: { + type: "object", + optional: false, nullable: false, + ref: "UserDetailed", + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index 7f7689165..952999583 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -335,6 +335,10 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: true, }, + isRenoteMuted: { + type: "boolean", + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/server/api/common/generated-muted-renote-query.ts b/packages/backend/src/server/api/common/generated-muted-renote-query.ts new file mode 100644 index 000000000..e11f3edaf --- /dev/null +++ b/packages/backend/src/server/api/common/generated-muted-renote-query.ts @@ -0,0 +1,22 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { RenoteMutings } from "@/models/index.js"; + +export function generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: User["id"] }): void { + const mutingQuery = RenoteMutings.createQueryBuilder("renote_muting") + .select("renote_muting.muteeId") + .where("renote_muting.muterId = :muterId", { muterId: me.id }); + + q.andWhere(new Brackets(qb => { + qb + .where(new Brackets(qb => { + qb.where("note.renoteId IS NOT NULL"); + qb.andWhere("note.text IS NULL"); + qb.andWhere(`note.userId NOT IN (${ mutingQuery.getQuery() })`); + })) + .orWhere("note.renoteId IS NULL") + .orWhere("note.text IS NOT NULL"); + })); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3bedd1af3..3f82eb7a7 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -231,6 +231,9 @@ import * as ep___miauth_genToken from "./endpoints/miauth/gen-token.js"; import * as ep___mute_create from "./endpoints/mute/create.js"; import * as ep___mute_delete from "./endpoints/mute/delete.js"; import * as ep___mute_list from "./endpoints/mute/list.js"; +import * as ep___renote_mute_create from "./endpoints/renote-mute/create.js"; +import * as ep___renote_mute_delete from "./endpoints/renote-mute/delete.js"; +import * as ep___renote_mute_list from "./endpoints/renote-mute/list.js"; import * as ep___my_apps from "./endpoints/my/apps.js"; import * as ep___notes from "./endpoints/notes.js"; import * as ep___notes_children from "./endpoints/notes/children.js"; @@ -626,6 +629,9 @@ const eps = [ ["ping", ep___ping], ["pinned-users", ep___pinnedUsers], ["recommended-instances", ep___recommendedInstances], + ["renote-mute/create", ep___renote_mute_create], + ["renote-mute/delete", ep___renote_mute_delete], + ["renote-mute/list", ep___renote_mute_list], ["custom-motd", ep___customMOTD], ["custom-splash-icons", ep___customSplashIcons], ["latest-version", ep___latestVersion], diff --git a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts index a7aaf9b8f..077a1ad5e 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -8,6 +8,7 @@ import { generateMutedUserQuery } from "../../common/generate-muted-user-query.j import { generateRepliesQuery } from "../../common/generate-replies-query.js"; import { generateMutedNoteQuery } from "../../common/generate-muted-note-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; export const meta = { tags: ["notes"], @@ -86,6 +87,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedUserQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index af9f670d9..3c171278b 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -11,6 +11,7 @@ import { generateRepliesQuery } from "../../common/generate-replies-query.js"; import { generateMutedNoteQuery } from "../../common/generate-muted-note-query.js"; import { generateChannelQuery } from "../../common/generate-channel-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; export const meta = { tags: ["notes"], @@ -103,6 +104,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedUserQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); if (ps.includeMyRenotes === false) { query.andWhere( diff --git a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts index e1e8a9ae2..cec371c8d 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -11,6 +11,7 @@ import { generateRepliesQuery } from "../../common/generate-replies-query.js"; import { generateMutedNoteQuery } from "../../common/generate-muted-note-query.js"; import { generateChannelQuery } from "../../common/generate-channel-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; export const meta = { tags: ["notes"], @@ -96,6 +97,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user) generateMutedUserQuery(query, user); if (user) generateMutedNoteQuery(query, user); if (user) generateBlockedUserQuery(query, user); + if (user) generateMutedUserRenotesQueryForNotes(query, user); if (ps.withFiles) { query.andWhere("note.fileIds != '{}'"); diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 0d6ec2f46..f85c0cfd3 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -9,6 +9,7 @@ import { generateRepliesQuery } from "../../common/generate-replies-query.js"; import { generateMutedNoteQuery } from "../../common/generate-muted-note-query.js"; import { generateChannelQuery } from "../../common/generate-channel-query.js"; import { generateBlockedUserQuery } from "../../common/generate-block-query.js"; +import { generateMutedUserRenotesQueryForNotes } from "../../common/generated-muted-renote-query.js"; export const meta = { tags: ["notes"], @@ -95,6 +96,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedUserQuery(query, user); generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); + generateMutedUserRenotesQueryForNotes(query, user); if (ps.includeMyRenotes === false) { query.andWhere( diff --git a/packages/backend/src/server/api/endpoints/renote-mute/create.ts b/packages/backend/src/server/api/endpoints/renote-mute/create.ts new file mode 100644 index 000000000..8f76e618b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,78 @@ +import { genId } from "@/misc/gen-id.js"; +import { RenoteMutings } from "@/models/index.js"; +import { RenoteMuting } from "@/models/entities/renote-muting.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:mutes", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "6fef56f3-e765-4957-88e5-c6f65329b8a5", + }, + + muteeIsYourself: { + message: "Mutee is yourself.", + code: "MUTEE_IS_YOURSELF", + id: "a4619cb2-5f23-484b-9301-94c903074e10", + }, + + alreadyMuting: { + message: "You are already muting that user.", + code: "ALREADY_MUTING", + id: "7e7359cb-160c-4956-b08f-4d1c653cd007", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + + // 自分自身 + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check if already muting + const exist = await RenoteMutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await RenoteMutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as RenoteMuting); + + // publishUserEvent(user.id, "mute", mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/delete.ts b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts new file mode 100644 index 000000000..f5caad345 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,73 @@ +import { RenoteMutings } from "@/models/index.js"; +import define from "../../define.js"; +import { ApiError } from "../../error.js"; +import { getUser } from "../../common/getters.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "write:mutes", + + errors: { + noSuchUser: { + message: "No such user.", + code: "NO_SUCH_USER", + id: "b851d00b-8ab1-4a56-8b1b-e24187cb48ef", + }, + + muteeIsYourself: { + message: "Mutee is yourself.", + code: "MUTEE_IS_YOURSELF", + id: "f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9", + }, + + notMuting: { + message: "You are not muting that user.", + code: "NOT_MUTING", + id: "5467d020-daa9-4553-81e1-135c0c35a96d", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + userId: { type: "string", format: "misskey:id" }, + }, + required: ["userId"], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, user) => { + const muter = user; + + // Check if the mutee is yourself + if (user.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getUser(ps.userId).catch(e => { + if (e.id === "15348ddd-432d-49c2-8a5a-8069753becff") throw new ApiError(meta.errors.noSuchUser); + throw e; + }); + + // Check not muting + const exist = await RenoteMutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await RenoteMutings.delete({ + id: exist.id, + }); + + // publishUserEvent(user.id, "unmute", mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/renote-mute/list.ts b/packages/backend/src/server/api/endpoints/renote-mute/list.ts new file mode 100644 index 000000000..fd6964bba --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,43 @@ +import { RenoteMutings } from "@/models/index.js"; +import define from "../../define.js"; +import { makePaginationQuery } from "../../common/make-pagination-query.js"; + +export const meta = { + tags: ["account"], + + requireCredential: true, + + kind: "read:mutes", + + res: { + type: "array", + optional: false, nullable: false, + items: { + type: "object", + optional: false, nullable: false, + ref: "RenoteMuting", + }, + }, +} as const; + +export const paramDef = { + type: "object", + properties: { + limit: { type: "integer", minimum: 1, maximum: 100, default: 30 }, + sinceId: { type: "string", format: "misskey:id" }, + untilId: { type: "string", format: "misskey:id" }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +export default define(meta, paramDef, async (ps, me) => { + const query = makePaginationQuery(RenoteMutings.createQueryBuilder("muting"), ps.sinceId, ps.untilId) + .andWhere("muting.muterId = :meId", { meId: me.id }); + + const mutings = await query + .take(ps.limit) + .getMany(); + + return await RenoteMutings.packMany(mutings, me); +}); diff --git a/packages/backend/src/server/api/endpoints/users/relation.ts b/packages/backend/src/server/api/endpoints/users/relation.ts index 01f39396d..b1803b01e 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -57,6 +57,10 @@ export const meta = { optional: false, nullable: false, }, + isRenoteMuted: { + type: "boolean", + optional: false, nullable: false, + }, }, }, { @@ -107,6 +111,10 @@ export const meta = { optional: false, nullable: false, }, + isRenoteMuted: { + type: "boolean", + optional: false, nullable: false, + }, }, }, }, diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 93dbdc426..fc8e0ce35 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -30,6 +30,10 @@ export default abstract class Channel { return this.connection.muting; } + protected get renoteMuting() { + return this.connection.renoteMuting; + } + protected get blocking() { return this.connection.blocking; } diff --git a/packages/backend/src/server/api/stream/channels/antenna.ts b/packages/backend/src/server/api/stream/channels/antenna.ts index 8e0d08110..7ad7353a6 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -34,6 +34,8 @@ export default class extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.connection.cacheNote(note); this.send("note", note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index d2a2269a4..ad20adeaf 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -36,6 +36,8 @@ export default class extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.connection.cacheNote(note); this.send("note", note); diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index a99b3cbc1..39dc599c1 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -56,6 +56,8 @@ export default class extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 9d7b518d9..66265fe4e 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -36,6 +36,8 @@ export default class extends Channel { if (isUserRelated(note, this.muting)) return; // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index c51a0fc2b..1e6f91ee4 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -54,6 +54,8 @@ export default class extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index 3e4a8a4e1..3038374ec 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -71,6 +71,8 @@ export default class extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 56689aae9..9cea11b22 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -48,6 +48,8 @@ export default class extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 diff --git a/packages/backend/src/server/api/stream/channels/user-list.ts b/packages/backend/src/server/api/stream/channels/user-list.ts index f63776c9e..234505fa0 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -56,6 +56,8 @@ export default class extends Channel { // 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する if (isUserRelated(note, this.blocking)) return; + if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return; + this.send("note", note); } diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index 03cecf542..055fe200b 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -7,6 +7,7 @@ import { Users, Followings, Mutings, + RenoteMutings, UserProfiles, ChannelFollowings, Blockings, @@ -36,6 +37,7 @@ export default class Connection { public userProfile?: UserProfile | null; public following: Set = new Set(); public muting: Set = new Set(); + public renoteMuting: Set = new Set(); public blocking: Set = new Set(); // "被"blocking public followingChannels: Set = new Set(); public token?: AccessToken; @@ -80,6 +82,7 @@ export default class Connection { if (this.user) { this.updateFollowing(); this.updateMuting(); + this.updateRenoteMuting(); this.updateBlocking(); this.updateFollowingChannels(); this.updateUserProfile(); @@ -114,6 +117,7 @@ export default class Connection { this.muting.delete(data.body.id); break; + // TODO: renote mute events // TODO: block events case "followChannel": @@ -564,6 +568,17 @@ export default class Connection { this.muting = new Set(mutings.map((x) => x.muteeId)); } + private async updateRenoteMuting() { + const renoteMutings = await RenoteMutings.find({ + where: { + muterId: this.user!.id, + }, + select: ["muteeId"], + }); + + this.renoteMuting = new Set(renoteMutings.map((x) => x.muteeId)); + } + private async updateBlocking() { // ここでいうBlockingは被Blockingの意 const blockings = await Blockings.find({ diff --git a/packages/calckey-js/src/api.types.ts b/packages/calckey-js/src/api.types.ts index bbe781e92..e406b865a 100644 --- a/packages/calckey-js/src/api.types.ts +++ b/packages/calckey-js/src/api.types.ts @@ -765,6 +765,9 @@ export type Endpoints = { "mute/create": { req: TODO; res: TODO }; "mute/delete": { req: { userId: User["id"] }; res: null }; "mute/list": { req: TODO; res: TODO }; + "renote-mute/create": { req: TODO; res: TODO; }; + "renote-mute/delete": { req: { userId: User['id'] }; res: null; }; + "renote-mute/list": { req: TODO; res: TODO; }; // my "my/apps": { req: TODO; res: TODO }; diff --git a/packages/calckey-js/src/entities.ts b/packages/calckey-js/src/entities.ts index b15dd8fca..bf881df2f 100644 --- a/packages/calckey-js/src/entities.ts +++ b/packages/calckey-js/src/entities.ts @@ -53,6 +53,7 @@ export type UserDetailed = UserLite & { isLocked: boolean; isModerator: boolean; isMuted: boolean; + isRenoteMuted: boolean; isSilenced: boolean; isSuspended: boolean; lang: string | null; diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts index 44dbac961..b128419f1 100644 --- a/packages/client/src/scripts/get-user-menu.ts +++ b/packages/client/src/scripts/get-user-menu.ts @@ -117,7 +117,15 @@ export function getUserMenu(user, router: Router = mainRouter) { } } - async function toggleBlock() { + async function toggleRenoteMute(): Promise { + os.apiWithDialog(user.isRenoteMuted ? "renote-mute/delete" : "renote-mute/create", { + userId: user.id, + }).then(() => { + user.isRenoteMuted = !user.isRenoteMuted; + }); + } + + async function toggleBlock(): Promise { if ( !(await getConfirmed( user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm, @@ -261,6 +269,13 @@ export function getUserMenu(user, router: Router = mainRouter) { if ($i && meId !== user.id) { menu = menu.concat([ null, + { + icon: user.isRenoteMuted + ? "ph-eye ph-bold ph-lg" + : "ph-eye-slash ph-bold ph-lg", + text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute, + action: toggleRenoteMute, + }, { icon: user.isMuted ? "ph-eye ph-bold ph-lg"