diff --git a/README.md b/README.md index fa532e41..32f4310e 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ うまく動いていそうだったら本家に push されます +- 特定のユーザーのリプライをタイムラインから非表示する機能(「ブーストをミュート」のリプライ版)を追加 - Docker/Podman の環境で `custom` ディレクトリの内容が反映されない不具合を修正 - 画面を下に引いてタイムラインなどを更新する機能を追加(Misskey から取り込み) - 本家にもマージリクエストを出しました ([!10644](https://git.joinfirefish.org/firefish/firefish/-/merge_requests/10644)) diff --git a/locales/en-US.yml b/locales/en-US.yml index e08a7c54..ae58bc3d 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -141,8 +141,10 @@ clickToShowPatterns: "Click to show module patterns" enterFileName: "Enter filename" mute: "Mute" unmute: "Unmute" -renoteMute: "Mute boosts" -renoteUnmute: "Unmute boosts" +renoteMute: "Mute boosts in timelines" +renoteUnmute: "Unmute boosts in timelines" +replyMute: "Mute replies in timelines" +replyUnmute: "Unmute replies in timelines" block: "Block" unblock: "Unblock" suspend: "Suspend" diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index c40363a5..3138986a 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -126,6 +126,8 @@ mute: "ミュート" unmute: "ミュート解除" renoteMute: "ブーストをミュート" renoteUnmute: "ブーストのミュートを解除" +replyMute: "リプライをミュート" +replyUnmute: "リプライのミュートを解除" block: "ブロック" unblock: "ブロック解除" suspend: "凍結" diff --git a/neko/revert.sql b/neko/revert.sql index 0dd37ffc..da5e19ad 100644 --- a/neko/revert.sql +++ b/neko/revert.sql @@ -1,3 +1,6 @@ +-- reply-muting +DROP TABLE "reply_muting"; + -- vervis UPDATE meta SET "repositoryUrl" = 'https://git.joinfirefish.org/firefish/firefish'; diff --git a/packages/backend/migration-neko/1704851359889-add-reply-muting.js b/packages/backend/migration-neko/1704851359889-add-reply-muting.js new file mode 100644 index 00000000..65086a2a --- /dev/null +++ b/packages/backend/migration-neko/1704851359889-add-reply-muting.js @@ -0,0 +1,21 @@ +export class AddReplyMuting1704851359889 { + name = "AddReplyMuting1704851359889"; + + async up(queryRunner) { + await queryRunner.query( + `CREATE TABLE "reply_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_replyMuting_id" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_createdAt" ON "muting" ("createdAt")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_muteeId" ON "muting" ("muteeId")`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_reply_muting_muterId" ON "muting" ("muterId")`, + ); + } + async down(queryRunner) { + await queryRunner.query(`DROP TABLE "reply_muting"`); + } +} diff --git a/packages/backend/src/db/postgre.ts b/packages/backend/src/db/postgre.ts index 79d7ae30..b14f028e 100644 --- a/packages/backend/src/db/postgre.ts +++ b/packages/backend/src/db/postgre.ts @@ -24,6 +24,7 @@ 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 { ReplyMuting } from "@/models/entities/reply-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"; @@ -137,6 +138,7 @@ export const entities = [ FollowRequest, Muting, RenoteMuting, + ReplyMuting, Blocking, Note, NoteEdit, diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index cd9692a6..9d698527 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -17,6 +17,7 @@ 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 { packedReplyMutingSchema } from "@/models/schema/reply-muting.js"; import { packedBlockingSchema } from "@/models/schema/blocking.js"; import { packedNoteReactionSchema } from "@/models/schema/note-reaction.js"; import { packedHashtagSchema } from "@/models/schema/hashtag.js"; @@ -55,6 +56,7 @@ export const refs = { Following: packedFollowingSchema, Muting: packedMutingSchema, RenoteMuting: packedRenoteMutingSchema, + ReplyMuting: packedReplyMutingSchema, Blocking: packedBlockingSchema, Hashtag: packedHashtagSchema, Page: packedPageSchema, diff --git a/packages/backend/src/models/entities/reply-muting.ts b/packages/backend/src/models/entities/reply-muting.ts new file mode 100644 index 00000000..19c2418f --- /dev/null +++ b/packages/backend/src/models/entities/reply-muting.ts @@ -0,0 +1,49 @@ +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 ReplyMuting { + @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 c1f37cf6..8ae12a63 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -26,6 +26,7 @@ import { UserGroupInvitationRepository } from "./repositories/user-group-invitat import { FollowRequestRepository } from "./repositories/follow-request.js"; import { MutingRepository } from "./repositories/muting.js"; import { RenoteMutingRepository } from "./repositories/renote-muting.js"; +import { ReplyMutingRepository } from "./repositories/reply-muting.js"; import { BlockingRepository } from "./repositories/blocking.js"; import { NoteReactionRepository } from "./repositories/note-reaction.js"; import { NotificationRepository } from "./repositories/notification.js"; @@ -103,6 +104,7 @@ export const Notifications = NotificationRepository; export const Metas = db.getRepository(Meta); export const Mutings = MutingRepository; export const RenoteMutings = RenoteMutingRepository; +export const ReplyMutings = ReplyMutingRepository; export const Blockings = BlockingRepository; export const SwSubscriptions = db.getRepository(SwSubscription); export const Hashtags = HashtagRepository; diff --git a/packages/backend/src/models/repositories/reply-muting.ts b/packages/backend/src/models/repositories/reply-muting.ts new file mode 100644 index 00000000..948dc7d0 --- /dev/null +++ b/packages/backend/src/models/repositories/reply-muting.ts @@ -0,0 +1,29 @@ +import { db } from "@/db/postgre.js"; +import { Packed } from "@/misc/schema.js"; +import { ReplyMuting } from "@/models/entities/reply-muting.js"; +import { User } from "@/models/entities/user.js"; +import { awaitAll } from "@/prelude/await-all.js"; +import { Users } from "../index.js"; + +export const ReplyMutingRepository = db.getRepository(ReplyMuting).extend({ + async pack( + src: ReplyMuting["id"] | ReplyMuting, + 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 41299613..c79dd824 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -26,6 +26,7 @@ import { MessagingMessages, Mutings, RenoteMutings, + ReplyMutings, Notes, NoteUnreads, Notifications, @@ -179,6 +180,13 @@ export const UserRepository = db.getRepository(User).extend({ }, take: 1, }).then((n) => n > 0), + isReplyMuted: ReplyMutings.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then((n) => n > 0), }); }, @@ -602,6 +610,7 @@ export const UserRepository = db.getRepository(User).extend({ isBlocked: relation.isBlocked, isMuted: relation.isMuted, isRenoteMuted: relation.isRenoteMuted, + isReplyMuted: relation.isReplyMuted, } : {}), } as Promiseable> as Promiseable< diff --git a/packages/backend/src/models/schema/reply-muting.ts b/packages/backend/src/models/schema/reply-muting.ts new file mode 100644 index 00000000..fa614890 --- /dev/null +++ b/packages/backend/src/models/schema/reply-muting.ts @@ -0,0 +1,30 @@ +export const packedReplyMutingSchema = { + 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 95501110..ba01ab97 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -345,6 +345,11 @@ export const packedUserDetailedNotMeOnlySchema = { nullable: false, optional: true, }, + isReplyMuted: { + type: "boolean", + nullable: false, + optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/server/api/common/generated-muted-reply-query.ts b/packages/backend/src/server/api/common/generated-muted-reply-query.ts new file mode 100644 index 00000000..6aff5ebd --- /dev/null +++ b/packages/backend/src/server/api/common/generated-muted-reply-query.ts @@ -0,0 +1,25 @@ +import { Brackets, SelectQueryBuilder } from "typeorm"; +import { User } from "@/models/entities/user.js"; +import { ReplyMutings } from "@/models/index.js"; + +export function generateMutedUserRepliesQueryForNotes( + q: SelectQueryBuilder, + me: { id: User["id"] }, +): void { + const mutingQuery = ReplyMutings.createQueryBuilder("reply_muting") + .select("reply_muting.muteeId") + .where("reply_muting.muterId = :muterId", { muterId: me.id }); + + q.andWhere( + new Brackets((qb) => { + qb.where( + new Brackets((qb) => { + qb.where("note.replyId IS NOT NULL"); + qb.andWhere(`note.userId NOT IN (${mutingQuery.getQuery()})`); + }), + ).orWhere("note.replyId IS NULL"); + }), + ); + + q.setParameters(mutingQuery.getParameters()); +} diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 693ac0ca..0c5e41a2 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -241,6 +241,9 @@ 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___reply_mute_create from "./endpoints/reply-mute/create.js"; +import * as ep___reply_mute_delete from "./endpoints/reply-mute/delete.js"; +import * as ep___reply_mute_list from "./endpoints/reply-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"; @@ -651,6 +654,9 @@ const eps = [ ["renote-mute/create", ep___renote_mute_create], ["renote-mute/delete", ep___renote_mute_delete], ["renote-mute/list", ep___renote_mute_list], + ["reply-mute/create", ep___reply_mute_create], + ["reply-mute/delete", ep___reply_mute_delete], + ["reply-mute/list", ep___reply_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 fee660dd..018bfb72 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -9,6 +9,7 @@ import { generateRepliesQuery } from "@/server/api/common/generate-replies-query import { generateMutedNoteQuery } from "@/server/api/common/generate-muted-note-query.js"; import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; +import { generateMutedUserRepliesQueryForNotes } from "@/server/api/common/generated-muted-reply-query.js"; export const meta = { tags: ["notes"], @@ -98,6 +99,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); generateMutedUserRenotesQueryForNotes(query, user); + generateMutedUserRepliesQueryForNotes(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 c573a0c8..c9800f2e 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -12,6 +12,7 @@ import { generateMutedNoteQuery } from "@/server/api/common/generate-muted-note- import { generateChannelQuery } from "@/server/api/common/generate-channel-query.js"; import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; +import { generateMutedUserRepliesQueryForNotes } from "@/server/api/common/generated-muted-reply-query.js"; export const meta = { tags: ["notes"], @@ -115,6 +116,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); generateMutedUserRenotesQueryForNotes(query, user); + generateMutedUserRepliesQueryForNotes(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 255deeba..64ca3775 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -12,6 +12,7 @@ import { generateMutedNoteQuery } from "@/server/api/common/generate-muted-note- import { generateChannelQuery } from "@/server/api/common/generate-channel-query.js"; import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; +import { generateMutedUserRepliesQueryForNotes } from "@/server/api/common/generated-muted-reply-query.js"; export const meta = { tags: ["notes"], @@ -108,6 +109,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user) generateMutedNoteQuery(query, user); if (user) generateBlockedUserQuery(query, user); if (user) generateMutedUserRenotesQueryForNotes(query, user); + if (user) generateMutedUserRepliesQueryForNotes(query, user); if (ps.withFiles) { query.andWhere("note.fileIds != '{}'"); diff --git a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts index 9d273393..6840fa1a 100644 --- a/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/recommended-timeline.ts @@ -12,6 +12,7 @@ import { generateMutedNoteQuery } from "@/server/api/common/generate-muted-note- import { generateChannelQuery } from "@/server/api/common/generate-channel-query.js"; import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; +import { generateMutedUserRepliesQueryForNotes } from "@/server/api/common/generated-muted-reply-query.js"; export const meta = { tags: ["notes"], @@ -111,6 +112,7 @@ export default define(meta, paramDef, async (ps, user) => { if (user) generateMutedNoteQuery(query, user); if (user) generateBlockedUserQuery(query, user); if (user) generateMutedUserRenotesQueryForNotes(query, user); + if (user) generateMutedUserRepliesQueryForNotes(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 0f8da62e..eb75f9cf 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -10,6 +10,7 @@ import { generateMutedNoteQuery } from "@/server/api/common/generate-muted-note- import { generateChannelQuery } from "@/server/api/common/generate-channel-query.js"; import { generateBlockedUserQuery } from "@/server/api/common/generate-block-query.js"; import { generateMutedUserRenotesQueryForNotes } from "@/server/api/common/generated-muted-renote-query.js"; +import { generateMutedUserRepliesQueryForNotes } from "@/server/api/common/generated-muted-reply-query.js"; import { ApiError } from "@/server/api/error.js"; export const meta = { @@ -115,6 +116,7 @@ export default define(meta, paramDef, async (ps, user) => { generateMutedNoteQuery(query, user); generateBlockedUserQuery(query, user); generateMutedUserRenotesQueryForNotes(query, user); + generateMutedUserRepliesQueryForNotes(query, user); if (ps.includeMyRenotes === false) { query.andWhere( diff --git a/packages/backend/src/server/api/endpoints/reply-mute/create.ts b/packages/backend/src/server/api/endpoints/reply-mute/create.ts new file mode 100644 index 00000000..defe44ac --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reply-mute/create.ts @@ -0,0 +1,68 @@ +import { genId } from "@/misc/gen-id.js"; +import { ReplyMutings } from "@/models/index.js"; +import { ReplyMuting } from "@/models/entities/reply-muting.js"; +import define from "@/server/api/define.js"; +import { ApiError } from "@/server/api/error.js"; +import { getUser } from "@/server/api/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", + }, + + 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; + + // 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 ReplyMutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await ReplyMutings.insert({ + id: genId(), + createdAt: new Date(), + muterId: muter.id, + muteeId: mutee.id, + } as ReplyMuting); + + // publishUserEvent(user.id, "mute", mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/reply-mute/delete.ts b/packages/backend/src/server/api/endpoints/reply-mute/delete.ts new file mode 100644 index 00000000..f659a17b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reply-mute/delete.ts @@ -0,0 +1,63 @@ +import { ReplyMutings } from "@/models/index.js"; +import define from "@/server/api/define.js"; +import { ApiError } from "@/server/api/error.js"; +import { getUser } from "@/server/api/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", + }, + + 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; + + // 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 ReplyMutings.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await ReplyMutings.delete({ + id: exist.id, + }); + + // publishUserEvent(user.id, "unmute", mutee); +}); diff --git a/packages/backend/src/server/api/endpoints/reply-mute/list.ts b/packages/backend/src/server/api/endpoints/reply-mute/list.ts new file mode 100644 index 00000000..ec3f3ecc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/reply-mute/list.ts @@ -0,0 +1,46 @@ +import { ReplyMutings } from "@/models/index.js"; +import define from "@/server/api/define.js"; +import { makePaginationQuery } from "@/server/api/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: "ReplyMuting", + }, + }, +} 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( + ReplyMutings.createQueryBuilder("muting"), + ps.sinceId, + ps.untilId, + ).andWhere("muting.muterId = :meId", { meId: me.id }); + + const mutings = await query.take(ps.limit).getMany(); + + return await ReplyMutings.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 61cc7611..fef5210e 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -62,6 +62,11 @@ export const meta = { optional: false, nullable: false, }, + isReplyMuted: { + type: "boolean", + optional: false, + nullable: false, + }, }, }, { @@ -117,6 +122,11 @@ export const meta = { optional: false, nullable: false, }, + isReplyMuted: { + 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 fc8e0ce3..7491a503 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -34,6 +34,10 @@ export default abstract class Channel { return this.connection.renoteMuting; } + protected get replyMuting() { + return this.connection.replyMuting; + } + 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 ec5a8b17..dab4b346 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -36,6 +36,7 @@ export default class extends Channel { if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) return; this.connection.cacheNote(note); diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 2ff4e082..510ddb25 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -38,6 +38,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) return; this.connection.cacheNote(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 01250768..0bc82d09 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -62,6 +62,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/hashtag.ts b/packages/backend/src/server/api/stream/channels/hashtag.ts index 011bb088..d2576c9d 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -39,6 +39,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) 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 e77cab1f..4f2d1e0e 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -59,6 +59,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単に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 b9c49c48..306b22ad 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -76,6 +76,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単に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 9362bc45..9ae11699 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -54,6 +54,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) diff --git a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts index 557858d0..12dab8c2 100644 --- a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts @@ -74,6 +74,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) return; // 流れてきたNoteがミュートすべきNoteだったら無視する // TODO: 将来的には、単に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 6408b0ad..b345bc04 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -60,6 +60,7 @@ export default class extends Channel { if (isUserRelated(note, this.blocking)) return; if (note.renote && !note.text && this.renoteMuting.has(note.userId)) return; + if (note.replyId != null && this.replyMuting.has(note.userId)) 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 8463386d..68cddd68 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -8,6 +8,7 @@ import { Followings, Mutings, RenoteMutings, + ReplyMutings, UserProfiles, ChannelFollowings, Blockings, @@ -37,6 +38,7 @@ export default class Connection { public following: Set = new Set(); public muting: Set = new Set(); public renoteMuting: Set = new Set(); + public replyMuting: Set = new Set(); public blocking: Set = new Set(); // "被"blocking public followingChannels: Set = new Set(); public token?: AccessToken; @@ -81,6 +83,7 @@ export default class Connection { this.updateFollowing(); this.updateMuting(); this.updateRenoteMuting(); + this.updateReplyMuting(); this.updateBlocking(); this.updateFollowingChannels(); this.updateUserProfile(); @@ -115,6 +118,7 @@ export default class Connection { break; // TODO: renote mute events + // TODO: reply mute events // TODO: block events case "followChannel": @@ -564,6 +568,17 @@ export default class Connection { this.renoteMuting = new Set(renoteMutings.map((x) => x.muteeId)); } + private async updateReplyMuting() { + const replyMutings = await ReplyMutings.find({ + where: { + muterId: this.user?.id, + }, + select: ["muteeId"], + }); + + this.replyMuting = new Set(replyMutings.map((x) => x.muteeId)); + } + private async updateBlocking() { // ここでいうBlockingは被Blockingの意 const blockings = await Blockings.find({ diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts index 5f1a1e8f..13ca1574 100644 --- a/packages/client/src/scripts/get-user-menu.ts +++ b/packages/client/src/scripts/get-user-menu.ts @@ -127,6 +127,17 @@ export function getUserMenu(user, router: Router = mainRouter) { }); } + async function toggleReplyMute(): Promise { + os.apiWithDialog( + user.isReplyMuted ? "reply-mute/delete" : "reply-mute/create", + { + userId: user.id, + }, + ).then(() => { + user.isReplyMuted = !user.isReplyMuted; + }); + } + async function toggleBlock(): Promise { if ( !(await getConfirmed( @@ -315,6 +326,11 @@ export function getUserMenu(user, router: Router = mainRouter) { text: user.isRenoteMuted ? i18n.ts.renoteUnmute : i18n.ts.renoteMute, action: toggleRenoteMute, }, + { + icon: user.isReplyMuted ? "ph-eye ph-lg" : "ph-eye-slash ph-lg", + text: user.isReplyMuted ? i18n.ts.replyUnmute : i18n.ts.replyMute, + action: toggleReplyMute, + }, ] as any; if (isSignedIn && $i.id !== user.id) { diff --git a/packages/firefish-js/src/api.types.ts b/packages/firefish-js/src/api.types.ts index 626bdaad..8e9d5e3a 100644 --- a/packages/firefish-js/src/api.types.ts +++ b/packages/firefish-js/src/api.types.ts @@ -791,6 +791,9 @@ export type Endpoints = { "renote-mute/create": { req: TODO; res: TODO }; "renote-mute/delete": { req: { userId: User["id"] }; res: null }; "renote-mute/list": { req: TODO; res: TODO }; + "reply-mute/create": { req: TODO; res: TODO }; + "reply-mute/delete": { req: { userId: User["id"] }; res: null }; + "reply-mute/list": { req: TODO; res: TODO }; // my "my/apps": { req: TODO; res: TODO }; diff --git a/packages/firefish-js/src/entities.ts b/packages/firefish-js/src/entities.ts index e0595281..6d434089 100644 --- a/packages/firefish-js/src/entities.ts +++ b/packages/firefish-js/src/entities.ts @@ -58,6 +58,7 @@ export type UserDetailed = UserLite & { isModerator: boolean; isMuted: boolean; isRenoteMuted: boolean; + isReplyMuted: boolean; isSilenced: boolean; isSuspended: boolean; lang: string | null;