From 4c2f7c64cc2b70bd7b686e9ece1ebbc30eeab511 Mon Sep 17 00:00:00 2001 From: syuilo Date: Wed, 8 Mar 2023 08:56:09 +0900 Subject: [PATCH] feat: Per-user renote mute (#10249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: per-user renote muting From FoundKey/c414f24a2c https://akkoma.dev/FoundKeyGang/FoundKey * Update ja-JP.yml * Delete renote-muting.ts * rename * fix ids * lint * fix * Update CHANGELOG.md * リノートをミュートしたユーザー一覧を見れるように * :art: * add test * fix test --------- Co-authored-by: Hélène --- CHANGELOG.md | 1 + locales/ja-JP.yml | 2 + .../1665091090561-add-renote-muting.js | 16 +++ packages/backend/src/core/CoreModule.ts | 6 ++ packages/backend/src/core/QueryService.ts | 26 ++++- .../entities/RenoteMutingEntityService.ts | 47 +++++++++ .../src/core/entities/UserEntityService.ts | 13 ++- packages/backend/src/di-symbols.ts | 1 + packages/backend/src/misc/schema.ts | 2 + .../backend/src/models/RepositoryModule.ts | 10 +- .../src/models/entities/RenoteMuting.ts | 42 ++++++++ packages/backend/src/models/index.ts | 3 + .../src/models/schema/renote-muting.ts | 26 +++++ packages/backend/src/models/schema/user.ts | 4 + packages/backend/src/postgres.ts | 2 + .../backend/src/server/api/EndpointsModule.ts | 12 +++ .../server/api/StreamingApiServerService.ts | 8 +- packages/backend/src/server/api/endpoints.ts | 6 ++ .../api/endpoints/notes/global-timeline.ts | 1 + .../api/endpoints/notes/hybrid-timeline.ts | 1 + .../api/endpoints/notes/local-timeline.ts | 1 + .../server/api/endpoints/notes/timeline.ts | 1 + .../api/endpoints/renote-mute/create.ts | 99 +++++++++++++++++++ .../api/endpoints/renote-mute/delete.ts | 87 ++++++++++++++++ .../server/api/endpoints/renote-mute/list.ts | 57 +++++++++++ .../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 | 18 +++- packages/backend/test/e2e/block.ts | 6 +- packages/backend/test/e2e/endpoints.ts | 6 +- packages/backend/test/e2e/mute.ts | 12 +-- packages/backend/test/e2e/renote-mute.ts | 85 ++++++++++++++++ packages/backend/test/utils.ts | 4 +- .../src/pages/settings/mute-block.vue | 64 +++++++++++- .../frontend/src/scripts/get-user-menu.ts | 12 +++ 43 files changed, 683 insertions(+), 26 deletions(-) create mode 100644 packages/backend/migration/1665091090561-add-renote-muting.js create mode 100644 packages/backend/src/core/entities/RenoteMutingEntityService.ts create mode 100644 packages/backend/src/models/entities/RenoteMuting.ts create mode 100644 packages/backend/src/models/schema/renote-muting.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 create mode 100644 packages/backend/test/e2e/renote-mute.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ee5ecfd..f2dbc2bb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ You should also include the user name that made the change. ## 13.x.x (unreleased) ### Improvements +- ユーザーごとにRenoteをミュートできるように - enhance(client): DM作成時にメンションも含むように ### Bugfixes diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index e441055cb..47e2af713 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -122,6 +122,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/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 491d8ab11..1fd2d1500 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -82,6 +82,7 @@ import { HashtagEntityService } from './entities/HashtagEntityService.js'; import { InstanceEntityService } from './entities/InstanceEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; +import { RenoteMutingEntityService } from './entities/RenoteMutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; import { NoteFavoriteEntityService } from './entities/NoteFavoriteEntityService.js'; import { NoteReactionEntityService } from './entities/NoteReactionEntityService.js'; @@ -203,6 +204,7 @@ const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useEx const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; +const $RenoteMutingEntityService: Provider = { provide: 'RenoteMutingEntityService', useExisting: RenoteMutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; const $NoteFavoriteEntityService: Provider = { provide: 'NoteFavoriteEntityService', useExisting: NoteFavoriteEntityService }; const $NoteReactionEntityService: Provider = { provide: 'NoteReactionEntityService', useExisting: NoteReactionEntityService }; @@ -325,6 +327,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -442,6 +445,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, @@ -559,6 +563,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting InstanceEntityService, ModerationLogEntityService, MutingEntityService, + RenoteMutingEntityService, NoteEntityService, NoteFavoriteEntityService, NoteReactionEntityService, @@ -675,6 +680,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $InstanceEntityService, $ModerationLogEntityService, $MutingEntityService, + $RenoteMutingEntityService, $NoteEntityService, $NoteFavoriteEntityService, $NoteReactionEntityService, diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index c334d749e..0cee2076b 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -2,7 +2,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { User } from '@/models/entities/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository } from '@/models/index.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, MutedNotesRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -29,6 +29,9 @@ export class QueryService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, ) { } @@ -269,5 +272,24 @@ export class QueryService { q.setParameters({ meId: me.id }); } } -} + @bindThis + public generateMutedUserRenotesQueryForNotes(q: SelectQueryBuilder, me: { id: User['id'] }): void { + const mutingQuery = this.renoteMutingsRepository.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/core/entities/RenoteMutingEntityService.ts b/packages/backend/src/core/entities/RenoteMutingEntityService.ts new file mode 100644 index 000000000..66ee7305a --- /dev/null +++ b/packages/backend/src/core/entities/RenoteMutingEntityService.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { bindThis } from '@/decorators.js'; +import { UserEntityService } from './UserEntityService.js'; + +@Injectable() +export class RenoteMutingEntityService { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private userEntityService: UserEntityService, + ) { + } + + @bindThis + public async pack( + src: RenoteMuting['id'] | RenoteMuting, + me?: { id: User['id'] } | null | undefined, + ): Promise> { + const muting = typeof src === 'object' ? src : await this.renoteMutingsRepository.findOneByOrFail({ id: src }); + + return await awaitAll({ + id: muting.id, + createdAt: muting.createdAt.toISOString(), + muteeId: muting.muteeId, + mutee: this.userEntityService.pack(muting.muteeId, me, { + detail: true, + }), + }); + } + + @bindThis + public packMany( + mutings: any[], + me: { id: User['id'] }, + ) { + return Promise.all(mutings.map(x => this.pack(x, me))); + } +} + diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 363564321..e7aa885f3 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -78,6 +78,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, @@ -195,6 +198,13 @@ export class UserEntityService implements OnModuleInit { }, take: 1, }).then(n => n > 0), + isRenoteMuted: this.renoteMutingsRepository.count({ + where: { + muterId: me, + muteeId: target, + }, + take: 1, + }).then(n => n > 0), }); } @@ -493,6 +503,7 @@ export class UserEntityService implements OnModuleInit { isBlocking: relation.isBlocking, isBlocked: relation.isBlocked, isMuted: relation.isMuted, + isRenoteMuted: relation.isRenoteMuted, } : {}), } as Promiseable> as Promiseable>; diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 05603093b..187f930ac 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -36,6 +36,7 @@ export const DI = { notificationsRepository: Symbol('notificationsRepository'), metasRepository: Symbol('metasRepository'), mutingsRepository: Symbol('mutingsRepository'), + renoteMutingsRepository: Symbol('renoteMutingsRepository'), blockingsRepository: Symbol('blockingsRepository'), swSubscriptionsRepository: Symbol('swSubscriptionsRepository'), hashtagsRepository: Symbol('hashtagsRepository'), diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index 6a0802f8a..0681cdb67 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -15,6 +15,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'; @@ -48,6 +49,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/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 311f875ba..d29b07b02 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -190,6 +190,12 @@ const $mutingsRepository: Provider = { inject: [DI.db], }; +const $renoteMutingsRepository: Provider = { + provide: DI.renoteMutingsRepository, + useFactory: (db: DataSource) => db.getRepository(RenoteMuting), + inject: [DI.db], +}; + const $blockingsRepository: Provider = { provide: DI.blockingsRepository, useFactory: (db: DataSource) => db.getRepository(Blocking), @@ -423,6 +429,7 @@ const $roleAssignmentsRepository: Provider = { $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, @@ -489,6 +496,7 @@ const $roleAssignmentsRepository: Provider = { $notificationsRepository, $metasRepository, $mutingsRepository, + $renoteMutingsRepository, $blockingsRepository, $swSubscriptionsRepository, $hashtagsRepository, diff --git a/packages/backend/src/models/entities/RenoteMuting.ts b/packages/backend/src/models/entities/RenoteMuting.ts new file mode 100644 index 000000000..2f803a5fa --- /dev/null +++ b/packages/backend/src/models/entities/RenoteMuting.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 25ed9b89d..4acb958b0 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -26,6 +26,7 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; @@ -93,6 +94,7 @@ export { ModerationLog, MutedNote, Muting, + RenoteMuting, Note, NoteFavorite, NoteReaction, @@ -159,6 +161,7 @@ export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; export type MutedNotesRepository = Repository; export type MutingsRepository = Repository; +export type RenoteMutingsRepository = Repository; export type NotesRepository = Repository; export type NoteFavoritesRepository = Repository; export type NoteReactionsRepository = Repository; 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..69ed8510d --- /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 c390018b4..8c61ee1f5 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -234,6 +234,10 @@ export const packedUserDetailedNotMeOnlySchema = { type: 'boolean', nullable: false, optional: true, }, + isRenoteMuted: { + type: 'boolean', + nullable: false, optional: true, + }, //#endregion }, } as const; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index c2ee14b0f..741985f3a 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -34,6 +34,7 @@ import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; import { Muting } from '@/models/entities/Muting.js'; +import { RenoteMuting } from '@/models/entities/RenoteMuting.js'; import { Note } from '@/models/entities/Note.js'; import { NoteFavorite } from '@/models/entities/NoteFavorite.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js'; @@ -139,6 +140,7 @@ export const entities = [ Following, FollowRequest, Muting, + RenoteMuting, Blocking, Note, NoteFavorite, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index d3e2219bd..272464959 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -224,6 +224,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___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_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'; @@ -545,6 +548,9 @@ const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: e const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; +const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; +const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; +const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default }; const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default }; const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default }; const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default }; @@ -870,6 +876,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, @@ -1189,6 +1198,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $mute_create, $mute_delete, $mute_list, + $renoteMute_create, + $renoteMute_delete, + $renoteMute_list, $my_apps, $notes, $notes_children, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 487eef2d5..13526f277 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -3,17 +3,17 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import * as websocket from 'websocket'; import { DI } from '@/di-symbols.js'; -import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js'; +import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { NotificationService } from '@/core/NotificationService.js'; +import { bindThis } from '@/decorators.js'; import { AuthenticateService } from './AuthenticateService.js'; import MainStreamConnection from './stream/index.js'; import { ChannelsService } from './stream/ChannelsService.js'; import type { ParsedUrlQuery } from 'querystring'; import type * as http from 'node:http'; -import { bindThis } from '@/decorators.js'; @Injectable() export class StreamingApiServerService { @@ -33,6 +33,9 @@ export class StreamingApiServerService { @Inject(DI.mutingsRepository) private mutingsRepository: MutingsRepository, + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, @@ -84,6 +87,7 @@ export class StreamingApiServerService { const main = new MainStreamConnection( this.followingsRepository, this.mutingsRepository, + this.renoteMutingsRepository, this.blockingsRepository, this.channelFollowingsRepository, this.userProfilesRepository, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 4f521148e..1f01865e0 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -224,6 +224,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___renoteMute_create from './endpoints/renote-mute/create.js'; +import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; +import * as ep___renoteMute_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'; @@ -543,6 +546,9 @@ const eps = [ ['mute/create', ep___mute_create], ['mute/delete', ep___mute_delete], ['mute/list', ep___mute_list], + ['renote-mute/create', ep___renoteMute_create], + ['renote-mute/delete', ep___renoteMute_delete], + ['renote-mute/list', ep___renoteMute_list], ['my/apps', ep___my_apps], ['notes', ep___notes], ['notes/children', ep___notes_children], 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 5d0cdc3fc..9118d3393 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -89,6 +89,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); } 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 2819abb12..3802ae540 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -107,6 +107,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { 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 18ed6d4e2..381001695 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -95,6 +95,7 @@ export default class extends Endpoint { if (me) this.queryService.generateMutedUserQuery(query, me); if (me) this.queryService.generateMutedNoteQuery(query, me); if (me) this.queryService.generateBlockedUserQuery(query, me); + if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); 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 e6de087c4..5ce436ee1 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -93,6 +93,7 @@ export default class extends Endpoint { this.queryService.generateMutedUserQuery(query, me); this.queryService.generateMutedNoteQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { 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..051a005b6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/create.ts @@ -0,0 +1,99 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { IdService } from '@/core/IdService.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import type { RenoteMuting } from '@/models/entities/RenoteMuting.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + limit: { + duration: ms('1hour'), + max: 20, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '37285718-52f7-4aef-b7de-c38b8e8a8420', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING', + id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b', + }, + }, +} 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 +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + private idService: IdService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // 自分自身 + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check if already muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist != null) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Create mute + await this.renoteMutingsRepository.insert({ + id: this.idService.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..51a895fb7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/delete.ts @@ -0,0 +1,87 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + + kind: 'write:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '9b6728cf-638c-4aa1-bedb-e07d8101474d', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: '619b1314-0850-4597-a242-e245f3da42af', + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817', + }, + }, +} 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 +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private globalEventService: GlobalEventService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // Check if the mutee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not muting + const exist = await this.renoteMutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + // Delete mute + await this.renoteMutingsRepository.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..b2d7addb6 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/renote-mute/list.ts @@ -0,0 +1,57 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { RenoteMutingsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js'; +import { DI } from '@/di-symbols.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 +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.renoteMutingsRepository) + private renoteMutingsRepository: RenoteMutingsRepository, + + private renoteMutingEntityService: RenoteMutingEntityService, + private queryService: QueryService, + ) { + super(meta, paramDef, async (ps, me) => { + const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId) + .andWhere('muting.muterId = :meId', { meId: me.id }); + + const mutings = await query + .take(ps.limit) + .getMany(); + + return await this.renoteMutingEntityService.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 ac9104bf9..3267c1884 100644 --- a/packages/backend/src/server/api/endpoints/users/relation.ts +++ b/packages/backend/src/server/api/endpoints/users/relation.ts @@ -50,6 +50,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isRenoteMuted: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, { @@ -91,6 +95,10 @@ export const meta = { type: 'boolean', 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 3e67880b4..32935325a 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -27,6 +27,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 18604d94f..e2a42fbfe 100644 --- a/packages/backend/src/server/api/stream/channels/antenna.ts +++ b/packages/backend/src/server/api/stream/channels/antenna.ts @@ -39,6 +39,8 @@ class AntennaChannel 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 f5ef1d110..1234738ce 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -51,6 +51,8 @@ class ChannelChannel 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 b8c0076ed..ab439a171 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -68,6 +68,8 @@ class GlobalTimelineChannel 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 00f8d8ecd..63a2dd5b3 100644 --- a/packages/backend/src/server/api/stream/channels/hashtag.ts +++ b/packages/backend/src/server/api/stream/channels/hashtag.ts @@ -49,6 +49,8 @@ class HashtagChannel 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 04a9f2968..678fbe12d 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -75,6 +75,8 @@ class HomeTimelineChannel 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 ab52aabb3..e33a28049 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -86,6 +86,8 @@ class HybridTimelineChannel 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 d8532c477..341c4e32c 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -65,6 +65,8 @@ class LocalTimelineChannel 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 7254d0a6d..e7899245b 100644 --- a/packages/backend/src/server/api/stream/channels/user-list.ts +++ b/packages/backend/src/server/api/stream/channels/user-list.ts @@ -93,6 +93,8 @@ class UserListChannel 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 d3056aca5..0a4fd8393 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -1,6 +1,6 @@ import type { User } from '@/models/entities/User.js'; import type { Channel as ChannelModel } from '@/models/entities/Channel.js'; -import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; +import type { FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js'; import type { AccessToken } from '@/models/entities/AccessToken.js'; import type { UserProfile } from '@/models/entities/UserProfile.js'; import type { Packed } from '@/misc/schema.js'; @@ -22,6 +22,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; @@ -34,6 +35,7 @@ export default class Connection { constructor( private followingsRepository: FollowingsRepository, private mutingsRepository: MutingsRepository, + private renoteMutingsRepository: RenoteMutingsRepository, private blockingsRepository: BlockingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository, private userProfilesRepository: UserProfilesRepository, @@ -66,6 +68,7 @@ export default class Connection { if (this.user) { this.updateFollowing(); this.updateMuting(); + this.updateRenoteMuting(); this.updateBlocking(); this.updateFollowingChannels(); this.updateUserProfile(); @@ -93,6 +96,7 @@ export default class Connection { this.muting.delete(data.body.id); break; + // TODO: renote mute events // TODO: block events case 'followChannel': @@ -342,6 +346,18 @@ export default class Connection { this.muting = new Set(mutings.map(x => x.muteeId)); } + @bindThis + private async updateRenoteMuting() { + const renoteMutings = await this.renoteMutingsRepository.find({ + where: { + muterId: this.user!.id, + }, + select: ['muteeId'], + }); + + this.renoteMuting = new Set(renoteMutings.map(x => x.muteeId)); + } + @bindThis private async updateBlocking() { // ここでいうBlockingは被Blockingの意 const blockings = await this.blockingsRepository.find({ diff --git a/packages/backend/test/e2e/block.ts b/packages/backend/test/e2e/block.ts index 4e9030f85..5fee2b93a 100644 --- a/packages/backend/test/e2e/block.ts +++ b/packages/backend/test/e2e/block.ts @@ -70,9 +70,9 @@ describe('Block', () => { // TODO: ユーザーリストから除外されるテスト test('タイムライン(LTL)にブロックされているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, bob); diff --git a/packages/backend/test/e2e/endpoints.ts b/packages/backend/test/e2e/endpoints.ts index 42bdc5f24..c13009373 100644 --- a/packages/backend/test/e2e/endpoints.ts +++ b/packages/backend/test/e2e/endpoints.ts @@ -206,7 +206,7 @@ describe('Endpoints', () => { describe('notes/reactions/create', () => { test('リアクションできる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: bobPost.id, @@ -224,7 +224,7 @@ describe('Endpoints', () => { }); test('自分の投稿にもリアクションできる', async () => { - const myPost = await post(alice); + const myPost = await post(alice, { text: 'hi' }); const res = await api('/notes/reactions/create', { noteId: myPost.id, @@ -235,7 +235,7 @@ describe('Endpoints', () => { }); test('二重にリアクションすると上書きされる', async () => { - const bobPost = await post(bob); + const bobPost = await post(bob, { text: 'hi' }); await api('/notes/reactions/create', { noteId: bobPost.id, diff --git a/packages/backend/test/e2e/mute.ts b/packages/backend/test/e2e/mute.ts index 6654a290b..5811d6baf 100644 --- a/packages/backend/test/e2e/mute.ts +++ b/packages/backend/test/e2e/mute.ts @@ -76,9 +76,9 @@ describe('Mute', () => { describe('Timeline', () => { test('タイムラインにミュートしているユーザーの投稿が含まれない', async () => { - const aliceNote = await post(alice); - const bobNote = await post(bob); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const res = await api('/notes/local-timeline', {}, alice); @@ -90,8 +90,8 @@ describe('Mute', () => { }); test('タイムラインにミュートしているユーザーの投稿のRenoteが含まれない', async () => { - const aliceNote = await post(alice); - const carolNote = await post(carol); + const aliceNote = await post(alice, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); const bobNote = await post(bob, { renoteId: carolNote.id, }); @@ -108,7 +108,7 @@ describe('Mute', () => { describe('Notification', () => { test('通知にミュートしているユーザーの通知が含まれない(リアクション)', async () => { - const aliceNote = await post(alice); + const aliceNote = await post(alice, { text: 'hi' }); await react(bob, aliceNote, 'like'); await react(carol, aliceNote, 'like'); diff --git a/packages/backend/test/e2e/renote-mute.ts b/packages/backend/test/e2e/renote-mute.ts new file mode 100644 index 000000000..32c8ebe2c --- /dev/null +++ b/packages/backend/test/e2e/renote-mute.ts @@ -0,0 +1,85 @@ +process.env.NODE_ENV = 'test'; + +import * as assert from 'assert'; +import { signup, api, post, react, startServer, waitFire } from '../utils.js'; +import type { INestApplicationContext } from '@nestjs/common'; + +describe('Renote Mute', () => { + let p: INestApplicationContext; + + // alice mutes carol + let alice: any; + let bob: any; + let carol: any; + + beforeAll(async () => { + p = await startServer(); + alice = await signup({ username: 'alice' }); + bob = await signup({ username: 'bob' }); + carol = await signup({ username: 'carol' }); + }, 1000 * 60 * 2); + + afterAll(async () => { + await p.close(); + }); + + test('ミュート作成', async () => { + const res = await api('/renote-mute/create', { + userId: carol.id, + }, alice); + + assert.strictEqual(res.status, 204); + }); + + test('タイムラインにリノートミュートしているユーザーのリノートが含まれない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('タイムラインにリノートミュートしているユーザーの引用が含まれる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + const carolRenote = await post(carol, { renoteId: bobNote.id, text: 'kore' }); + const carolNote = await post(carol, { text: 'hi' }); + + const res = await api('/notes/local-timeline', {}, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(Array.isArray(res.body), true); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolRenote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('ストリームにリノートミュートしているユーザーのリノートが流れない', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, false); + }); + + test('ストリームにリノートミュートしているユーザーの引用が流れる', async () => { + const bobNote = await post(bob, { text: 'hi' }); + + const fired = await waitFire( + alice, 'localTimeline', + () => api('notes/create', { renoteId: bobNote.id, text: 'kore' }, carol), + msg => msg.type === 'note' && msg.body.userId === carol.id, + ); + + assert.strictEqual(fired, true); + }); +}); diff --git a/packages/backend/test/utils.ts b/packages/backend/test/utils.ts index 8203e4935..37e5ae10d 100644 --- a/packages/backend/test/utils.ts +++ b/packages/backend/test/utils.ts @@ -57,9 +57,7 @@ export const signup = async (params?: any): Promise => { }; export const post = async (user: any, params?: misskey.Endpoints['notes/create']['req']): Promise => { - const q = Object.assign({ - text: 'test', - }, params); + const q = params; const res = await api('notes/create', q, user); diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 577337868..3d0463f70 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -1,10 +1,40 @@