diff --git a/locales/index.d.ts b/locales/index.d.ts index 97505a1605..e1db9cbc87 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -11581,6 +11581,32 @@ export interface Locale extends ILocale { */ "serverHostPlaceholder": string; }; + "_noteMuting": { + /** + * ミュートしたノート + */ + "noteMuting": string; + /** + * ノートをミュート + */ + "muteNote": string; + /** + * ノートのミュートを解除 + */ + "unmuteNote": string; + /** + * このノートはミュートされていません + */ + "notMutedNote": string; + /** + * のノート + */ + "labelSuffix": string; + /** + * ミュートを解除したノートを再表示するにはタイムラインの再読み込みが必要です。 + */ + "unmuteCaption": string; + }; } declare const locales: { [lang: string]: Locale; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 737e69a376..b3b9748c09 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -3098,3 +3098,11 @@ _search: pleaseEnterServerHost: "サーバーのホストを入力してください" pleaseSelectUser: "ユーザーを選択してください" serverHostPlaceholder: "例: misskey.example.com" + +_noteMuting: + noteMuting: "ミュートしたノート" + muteNote: "ノートをミュート" + unmuteNote: "ノートのミュートを解除" + notMutedNote: "このノートはミュートされていません" + labelSuffix: "のノート" + unmuteCaption: "ミュートを解除したノートを再表示するにはタイムラインの再読み込みが必要です。" diff --git a/packages/backend/migration/1739882320354-noteMuting.js b/packages/backend/migration/1739882320354-noteMuting.js new file mode 100644 index 0000000000..1ba0216caf --- /dev/null +++ b/packages/backend/migration/1739882320354-noteMuting.js @@ -0,0 +1,36 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class NoteMuting1739882320354 { + name = 'NoteMuting1739882320354' + + async up(queryRunner) { + await queryRunner.query(` + CREATE TABLE "note_muting" ( + "id" varchar(32) NOT NULL, + "userId" varchar(32) NOT NULL, + "noteId" varchar(32) NOT NULL, + "expiresAt" TIMESTAMP WITH TIME ZONE, + CONSTRAINT "PK_note_muting_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_note_muting_userId" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_note_muting_noteId" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ); + CREATE INDEX "IDX_note_muting_userId" ON "note_muting" ("userId"); + CREATE INDEX "IDX_note_muting_noteId" ON "note_muting" ("noteId"); + CREATE INDEX "IDX_note_muting_expiresAt" ON "note_muting" ("expiresAt"); + CREATE UNIQUE INDEX "IDX_note_muting_userId_noteId_unique" ON note_muting ("userId", "noteId"); + `); + } + + async down(queryRunner) { + await queryRunner.query(` + DROP INDEX "IDX_note_muting_userId_noteId_unique"; + DROP INDEX "IDX_note_muting_expiresAt"; + DROP INDEX "IDX_note_muting_noteId"; + DROP INDEX "IDX_note_muting_userId"; + DROP TABLE "note_muting"; + `); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index d8617e343c..a0136210bc 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -4,6 +4,7 @@ */ import { Module } from '@nestjs/common'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { AbuseReportService } from '@/core/AbuseReportService.js'; import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js'; @@ -185,6 +186,7 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService }; const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService }; const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService }; +const $NoteMutingService: Provider = { provide: 'NoteMutingService', useExisting: NoteMutingService }; const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService }; const $PollService: Provider = { provide: 'PollService', useExisting: PollService }; const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService }; @@ -335,6 +337,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, + NoteMutingService, NotificationService, PollService, SystemAccountService, @@ -481,6 +484,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, + $NoteMutingService, $NotificationService, $PollService, $SystemAccountService, @@ -628,6 +632,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting NoteCreateService, NoteDeleteService, NotePiningService, + NoteMutingService, NotificationService, PollService, SystemAccountService, @@ -773,6 +778,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $NoteCreateService, $NoteDeleteService, $NotePiningService, + $NoteMutingService, $NotificationService, $PollService, $SystemAccountService, diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index ce8cc83dfd..15fe250b43 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -17,6 +17,7 @@ import { isQuote, isRenote } from '@/misc/is-renote.js'; import { CacheService } from '@/core/CacheService.js'; import { isReply } from '@/misc/is-reply.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { NoteMutingService } from './note/NoteMutingService.js'; type TimelineOptions = { untilId: string | null, @@ -45,6 +46,7 @@ export class FanoutTimelineEndpointService { private noteEntityService: NoteEntityService, private cacheService: CacheService, private fanoutTimelineService: FanoutTimelineService, + private noteMutingService: NoteMutingService, ) { } @@ -101,11 +103,13 @@ export class FanoutTimelineEndpointService { userIdsWhoMeMutingRenotes, userIdsWhoBlockingMe, userMutedInstances, + noteMutings, ] = await Promise.all([ this.cacheService.userMutingsCache.fetch(ps.me.id), this.cacheService.renoteMutingsCache.fetch(ps.me.id), this.cacheService.userBlockedCache.fetch(ps.me.id), this.cacheService.userProfileCache.fetch(me.id).then(p => new Set(p.mutedInstances)), + this.noteMutingService.getMutingNoteIdsSet(me.id), ]); const parentFilter = filter; @@ -114,6 +118,7 @@ export class FanoutTimelineEndpointService { if (isUserRelated(note, userIdsWhoMeMuting, ps.ignoreAuthorFromMute)) return false; if (!ps.ignoreAuthorFromMute && isRenote(note) && !isQuote(note) && userIdsWhoMeMutingRenotes.has(note.userId)) return false; if (isInstanceMuted(note, userMutedInstances)) return false; + if (noteMutings.has(note.id)) return false; return parentFilter(note); }; diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 3215b41c8d..8d88200efc 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; -import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; +import { MiAvatarDecoration, MiNoteMuting, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; import type { Config } from '@/config.js'; @@ -260,6 +260,8 @@ export interface InternalEventTypes { unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; }; userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; }; + noteMuteCreated: MiNoteMuting; + noteMuteDeleted: MiNoteMuting; } type EventTypesToEventPayload = EventUnionFromDictionary>>; diff --git a/packages/backend/src/core/QueryService.ts b/packages/backend/src/core/QueryService.ts index 412ab33b3f..9fd04cc629 100644 --- a/packages/backend/src/core/QueryService.ts +++ b/packages/backend/src/core/QueryService.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Brackets, ObjectLiteral } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MiUser } from '@/models/User.js'; -import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js'; +import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, NoteMutingsRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import type { SelectQueryBuilder } from 'typeorm'; @@ -21,12 +21,12 @@ export class QueryService { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.blockingsRepository) private blockingsRepository: BlockingsRepository, + @Inject(DI.noteMutingsRepository) + private noteMutingsRepository: NoteMutingsRepository, + @Inject(DI.noteThreadMutingsRepository) private noteThreadMutingsRepository: NoteThreadMutingsRepository, @@ -110,6 +110,16 @@ export class QueryService { q.setParameters(blockedQuery.getParameters()); } + @bindThis + public generateMutedNoteQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { + const query = this.noteMutingsRepository.createQueryBuilder('noteMuting') + .select('noteMuting.noteId') + .where('noteMuting.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ query.getQuery() })`); + q.setParameters(query.getParameters()); + } + @bindThis public generateMutedNoteThreadQuery(q: SelectQueryBuilder, me: { id: MiUser['id'] }): void { const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted') diff --git a/packages/backend/src/core/SearchService.ts b/packages/backend/src/core/SearchService.ts index aa787c93de..0dd57a8657 100644 --- a/packages/backend/src/core/SearchService.ts +++ b/packages/backend/src/core/SearchService.ts @@ -234,8 +234,11 @@ export class SearchService { } this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); + } return query.limit(pagination.limit).getMany(); } diff --git a/packages/backend/src/core/note/NoteMutingService.ts b/packages/backend/src/core/note/NoteMutingService.ts new file mode 100644 index 0000000000..55d3cdcc25 --- /dev/null +++ b/packages/backend/src/core/note/NoteMutingService.ts @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; +import * as Redis from 'ioredis'; +import { RedisKVCache } from '@/misc/cache.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { bindThis } from '@/decorators.js'; +import { DI } from '@/di-symbols.js'; +import type { MiNoteMuting, NoteMutingsRepository, NotesRepository } from '@/models/_.js'; +import { QueryService } from '@/core/QueryService.js'; + +@Injectable() +export class NoteMutingService implements OnApplicationShutdown { + public static NoSuchNoteError = class extends Error { + }; + public static NotMutedError = class extends Error { + }; + + private cache: RedisKVCache>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + @Inject(DI.noteMutingsRepository) + private noteMutingsRepository: NoteMutingsRepository, + private idService: IdService, + private globalEventService: GlobalEventService, + private queryService: QueryService, + ) { + this.redisForSub.on('message', this.onMessage); + this.cache = new RedisKVCache>(this.redisClient, 'noteMutings', { + // 使用頻度が高く使用される期間も長いためキャッシュの有効期限切れ→再取得が頻発すると思われる。 + // よって、有効期限を長めに設定して再取得の頻度を抑える(キャッシュの鮮度はRedisイベント経由で保たれているので問題ないはず) + lifetime: 1000 * 60 * 60 * 24, // 1d + memoryCacheLifetime: 1000 * 60 * 60 * 24, // 1d + fetcher: async (userId) => { + return this.noteMutingsRepository.createQueryBuilder('noteMuting') + .select('noteMuting.noteId') + .where('noteMuting.userId = :userId', { userId }) + .getRawMany<{ noteMuting_noteId: string }>() + .then((results) => new Set(results.map(x => x.noteMuting_noteId))); + }, + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + if (obj.channel !== 'internal') { + return; + } + + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'noteMuteCreated': { + const noteIds = await this.cache.get(body.userId); + if (noteIds) { + noteIds.add(body.noteId); + } + break; + } + case 'noteMuteDeleted': { + const noteIds = await this.cache.get(body.userId); + if (noteIds) { + noteIds.delete(body.noteId); + } + break; + } + } + } + + @bindThis + public async listByUserId( + params: { + userId: MiNoteMuting['userId'], + sinceId?: MiNoteMuting['id'] | null, + untilId?: MiNoteMuting['id'] | null, + }, + opts?: { + limit?: number; + offset?: number; + joinUser?: boolean; + joinNote?: boolean; + }, + ): Promise { + const q = this.queryService.makePaginationQuery(this.noteMutingsRepository.createQueryBuilder('noteMuting'), params.sinceId, params.untilId); + + q.where('noteMuting.userId = :userId', { userId: params.userId }); + if (opts?.joinUser) { + q.leftJoinAndSelect('noteMuting.user', 'user'); + } + if (opts?.joinNote) { + q.leftJoinAndSelect('noteMuting.note', 'note'); + } + + q.orderBy('noteMuting.id', 'DESC'); + + const limit = opts?.limit ?? 10; + q.limit(limit); + + if (opts?.offset) { + q.offset(opts.offset); + } + + return q.getMany(); + } + + @bindThis + public async getMutingNoteIdsSet(userId: MiNoteMuting['userId']): Promise> { + return this.cache.fetch(userId); + } + + @bindThis + public async isMuting(userId: MiNoteMuting['userId'], noteId: MiNoteMuting['noteId']): Promise { + return this.cache.fetch(userId).then(noteIds => noteIds.has(noteId)); + } + + @bindThis + public async create( + params: Pick, + ): Promise { + if (!await this.notesRepository.existsBy({ id: params.noteId })) { + throw new NoteMutingService.NoSuchNoteError(); + } + + const id = this.idService.gen(); + const result = await this.noteMutingsRepository.insertOne({ + id, + ...params, + }); + + this.globalEventService.publishInternalEvent('noteMuteCreated', result); + } + + @bindThis + public async delete(userId: MiNoteMuting['userId'], noteId: MiNoteMuting['noteId']): Promise { + const value = await this.noteMutingsRepository.findOne({ where: { userId, noteId } }); + if (!value) { + throw new NoteMutingService.NotMutedError(); + } + + await this.noteMutingsRepository.delete(value.id); + this.globalEventService.publishInternalEvent('noteMuteDeleted', value); + } + + @bindThis + public async cleanupExpiredMutes(): Promise { + const now = new Date(); + const noteMutings = await this.noteMutingsRepository.createQueryBuilder('noteMuting') + .select(['noteMuting.id', 'noteMuting.userId']) + .where('noteMuting.expiresAt < :now', { now }) + .andWhere('noteMuting.expiresAt IS NOT NULL') + .getRawMany<{ noteMuting_id: MiNoteMuting['id'], noteMuting_userId: MiNoteMuting['id'] }>(); + + await this.noteMutingsRepository.delete(noteMutings.map(x => x.noteMuting_id)); + + for (const id of [...new Set(noteMutings.map(x => x.noteMuting_userId))]) { + // 同時多発的なDBアクセスが発生することを避けるため1回ごとにawaitする + await this.cache.refresh(id); + } + } + + @bindThis + public dispose(): void { + this.redisForSub.off('message', this.onMessage); + } + + @bindThis + public onApplicationShutdown(): void { + this.dispose(); + } +} diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 77d2838e09..a46e85fc83 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -22,6 +22,7 @@ export const DI = { appsRepository: Symbol('appsRepository'), avatarDecorationsRepository: Symbol('avatarDecorationsRepository'), noteFavoritesRepository: Symbol('noteFavoritesRepository'), + noteMutingsRepository: Symbol('noteMutingsRepository'), noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'), noteReactionsRepository: Symbol('noteReactionsRepository'), pollsRepository: Symbol('pollsRepository'), diff --git a/packages/backend/src/misc/is-muting-note-related.ts b/packages/backend/src/misc/is-muting-note-related.ts new file mode 100644 index 0000000000..b2f2866087 --- /dev/null +++ b/packages/backend/src/misc/is-muting-note-related.ts @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +type NoteCompat = { + id: string; + reply?: NoteCompat | null; + renote?: NoteCompat | null; +}; + +export function isMutingNoteRelated(note: NoteCompat, noteIds: Set) { + if (noteIds.has(note.id)) { + return true; + } + + if (note.reply != null && noteIds.has(note.reply.id)) { + return true; + } + + if (note.renote != null && noteIds.has(note.renote.id)) { + return true; + } + + return false; +} diff --git a/packages/backend/src/models/NoteMuting.ts b/packages/backend/src/models/NoteMuting.ts new file mode 100644 index 0000000000..b2c4cdc346 --- /dev/null +++ b/packages/backend/src/models/NoteMuting.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiNote } from './Note.js'; + +@Entity('note_muting') +export class MiNoteMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Index() + @Column('varchar', { + ...id(), + }) + public noteId: string; + + @ManyToOne(type => MiNote, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public note: MiNote | null; + + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index b7142d91bf..701090e33f 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -40,6 +40,7 @@ import { MiMuting, MiNote, MiNoteFavorite, + MiNoteMuting, MiNoteReaction, MiNoteThreadMuting, MiPage, @@ -128,6 +129,12 @@ const $noteFavoritesRepository: Provider = { inject: [DI.db], }; +const $noteMutingsRepository: Provider = { + provide: DI.noteMutingsRepository, + useFactory: (db: DataSource) => db.getRepository(MiNoteMuting).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $noteThreadMutingsRepository: Provider = { provide: DI.noteThreadMutingsRepository, useFactory: (db: DataSource) => db.getRepository(MiNoteThreadMuting).extend(miRepository as MiRepository), @@ -540,6 +547,7 @@ const $reversiGamesRepository: Provider = { $appsRepository, $avatarDecorationsRepository, $noteFavoritesRepository, + $noteMutingsRepository, $noteThreadMutingsRepository, $noteReactionsRepository, $pollsRepository, @@ -616,6 +624,7 @@ const $reversiGamesRepository: Provider = { $appsRepository, $avatarDecorationsRepository, $noteFavoritesRepository, + $noteMutingsRepository, $noteThreadMutingsRepository, $noteReactionsRepository, $pollsRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index e1ea2a2604..70c6eb1ee8 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -56,6 +56,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js'; import { MiMuting } from '@/models/Muting.js'; import { MiNote } from '@/models/Note.js'; import { MiNoteFavorite } from '@/models/NoteFavorite.js'; +import { MiNoteMuting } from '@/models/NoteMuting.js'; import { MiNoteReaction } from '@/models/NoteReaction.js'; import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js'; import { MiPage } from '@/models/Page.js'; @@ -190,6 +191,7 @@ export { MiNote, MiNoteFavorite, MiNoteReaction, + MiNoteMuting, MiNoteThreadMuting, MiPage, MiPageLike, @@ -268,6 +270,7 @@ export type RenoteMutingsRepository = Repository & MiRepository< export type NotesRepository = Repository & MiRepository; export type NoteFavoritesRepository = Repository & MiRepository; export type NoteReactionsRepository = Repository & MiRepository; +export type NoteMutingsRepository = Repository & MiRepository; export type NoteThreadMutingsRepository = Repository & MiRepository; export type PagesRepository = Repository & MiRepository; export type PageLikesRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index b06895fcc9..10be2fbf4e 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -7,6 +7,7 @@ import pg from 'pg'; import { DataSource, Logger, type QueryRunner } from 'typeorm'; import * as highlight from 'cli-highlight'; +import { MiNoteMuting } from '@/models/NoteMuting.js'; import { entities as charts } from '@/core/chart/entities.js'; import { Config } from '@/config.js'; import MisskeyLogger from '@/logger.js'; @@ -210,6 +211,7 @@ export const entities = [ MiNoteFavorite, MiNoteReaction, MiNoteThreadMuting, + MiNoteMuting, MiPage, MiPageLike, MiGalleryPost, diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index 448fc9c763..5a823f865c 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -10,6 +10,7 @@ import type { MutingsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { UserMutingService } from '@/core/UserMutingService.js'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; import type * as Bull from 'bullmq'; @@ -22,6 +23,7 @@ export class CheckExpiredMutingsProcessorService { private mutingsRepository: MutingsRepository, private userMutingService: UserMutingService, + private noteMutingService: NoteMutingService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); @@ -41,6 +43,8 @@ export class CheckExpiredMutingsProcessorService { await this.userMutingService.unmute(expired); } + await this.noteMutingService.cleanupExpiredMutes(); + this.logger.succ('All expired mutings checked.'); } } diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index 2a4e1fc574..491e2c5d29 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -7,6 +7,7 @@ import { EventEmitter } from 'events'; import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; import * as WebSocket from 'ws'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; import { DI } from '@/di-symbols.js'; import type { UsersRepository, MiAccessToken } from '@/models/_.js'; import { NotificationService } from '@/core/NotificationService.js'; @@ -39,6 +40,7 @@ export class StreamingApiServerService { private notificationService: NotificationService, private usersService: UserService, private channelFollowingService: ChannelFollowingService, + private noteMutingService: NoteMutingService, ) { } @@ -97,7 +99,9 @@ export class StreamingApiServerService { this.notificationService, this.cacheService, this.channelFollowingService, - user, app, + this.noteMutingService, + user, + app, ); await stream.init(); diff --git a/packages/backend/src/server/api/endpoint-list.ts b/packages/backend/src/server/api/endpoint-list.ts index e5170aa2dc..eef69ee186 100644 --- a/packages/backend/src/server/api/endpoint-list.ts +++ b/packages/backend/src/server/api/endpoint-list.ts @@ -330,6 +330,9 @@ export * as 'notes/timeline' from './endpoints/notes/timeline.js'; export * as 'notes/translate' from './endpoints/notes/translate.js'; export * as 'notes/unrenote' from './endpoints/notes/unrenote.js'; export * as 'notes/user-list-timeline' from './endpoints/notes/user-list-timeline.js'; +export * as 'notes/muting/create' from './endpoints/notes/muting/create.js'; +export * as 'notes/muting/delete' from './endpoints/notes/muting/delete.js'; +export * as 'notes/muting/list' from './endpoints/notes/muting/list.js'; export * as 'notifications/create' from './endpoints/notifications/create.js'; export * as 'notifications/flush' from './endpoints/notifications/flush.js'; export * as 'notifications/mark-all-as-read' from './endpoints/notifications/mark-all-as-read.js'; diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index a44eb6720b..dd2f7469e4 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -114,6 +114,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); const notes = await query.getMany(); if (sinceId != null && untilId == null) { diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index cec5f8fd9c..16169e3a81 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -124,6 +124,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); } //#endregion diff --git a/packages/backend/src/server/api/endpoints/clips/notes.ts b/packages/backend/src/server/api/endpoints/clips/notes.ts index 7638aae442..94489023cd 100644 --- a/packages/backend/src/server/api/endpoints/clips/notes.ts +++ b/packages/backend/src/server/api/endpoints/clips/notes.ts @@ -89,6 +89,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); } const notes = await query diff --git a/packages/backend/src/server/api/endpoints/notes/children.ts b/packages/backend/src/server/api/endpoints/notes/children.ts index e73c98282c..aa1bd358fc 100644 --- a/packages/backend/src/server/api/endpoints/notes/children.ts +++ b/packages/backend/src/server/api/endpoints/notes/children.ts @@ -73,6 +73,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); } const notes = await query.limit(ps.limit).getMany(); 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 8d38bb1c65..a502afa11e 100644 --- a/packages/backend/src/server/api/endpoints/notes/global-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/global-timeline.ts @@ -82,6 +82,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(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 99d1c9f19c..c8266f592f 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -46,7 +46,7 @@ export const meta = { bothWithRepliesAndWithFiles: { message: 'Specifying both withReplies and withFiles is not supported', code: 'BOTH_WITH_REPLIES_AND_WITH_FILES', - id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f' + id: 'dfaa3eb7-8002-4cb7-bcc4-1095df46656f', }, }, } as const; @@ -246,6 +246,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(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 97acf2ad39..e8847329dd 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -156,9 +156,12 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); + } if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/packages/backend/src/server/api/endpoints/notes/mentions.ts b/packages/backend/src/server/api/endpoints/notes/mentions.ts index bbb63646e9..e8fb23f84c 100644 --- a/packages/backend/src/server/api/endpoints/notes/mentions.ts +++ b/packages/backend/src/server/api/endpoints/notes/mentions.ts @@ -75,6 +75,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateMutedNoteThreadQuery(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); if (ps.visibility) { query.andWhere('note.visibility = :visibility', { visibility: ps.visibility }); diff --git a/packages/backend/src/server/api/endpoints/notes/muting/create.ts b/packages/backend/src/server/api/endpoints/notes/muting/create.ts new file mode 100644 index 0000000000..549f1550ee --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/muting/create.ts @@ -0,0 +1,63 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + kind: 'write:account', + + limit: { + duration: ms('1hour'), + max: 10, + }, + + errors: { + noSuchNote: { + message: 'No such note.', + code: 'NO_SUCH_NOTE', + id: 'a58e7999-f6d3-1780-a688-f43661719662', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + expiresAt: { type: 'integer', nullable: true }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly noteMutingService: NoteMutingService, + ) { + super(meta, paramDef, async (ps, me) => { + try { + await this.noteMutingService.create({ + userId: me.id, + noteId: ps.noteId, + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, + }); + } catch (e) { + if (e instanceof NoteMutingService.NoSuchNoteError) { + throw new ApiError(meta.errors.noSuchNote); + } else { + throw e; + } + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/muting/delete.ts b/packages/backend/src/server/api/endpoints/notes/muting/delete.ts new file mode 100644 index 0000000000..1887bb95dc --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/muting/delete.ts @@ -0,0 +1,53 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + kind: 'write:account', + + errors: { + notMuted: { + message: 'Not muted.', + code: 'NOT_MUTED', + id: '6ad3b6c9-f173-60f7-b558-5eea13896254', + httpStatusCode: 400, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + noteId: { type: 'string', format: 'misskey:id' }, + }, + required: ['noteId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly noteMutingService: NoteMutingService, + ) { + super(meta, paramDef, async (ps, me) => { + try { + await this.noteMutingService.delete(me.id, ps.noteId); + } catch (e) { + if (e instanceof NoteMutingService.NotMutedError) { + throw new ApiError(meta.errors.notMuted); + } else { + throw e; + } + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/muting/list.ts b/packages/backend/src/server/api/endpoints/notes/muting/list.ts new file mode 100644 index 0000000000..cef38f54af --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notes/muting/list.ts @@ -0,0 +1,69 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; + +export const meta = { + tags: ['notes'], + + requireCredential: true, + + kind: 'read:account', + + res: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + expiresAt: { type: 'string', format: 'date-time', nullable: true }, + note: { type: 'object', ref: 'Note' }, + }, + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + sinceId: { type: 'string', format: 'misskey:id', nullable: true }, + untilId: { type: 'string', format: 'misskey:id', nullable: true }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + offset: { type: 'integer' }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private readonly noteMutingService: NoteMutingService, + private readonly noteEntityService: NoteEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const mutings = await this.noteMutingService.listByUserId( + { userId: me.id }, + { + joinNote: true, + limit: ps.limit, + offset: ps.offset, + }); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const packedNotes = await this.noteEntityService.packMany(mutings.map(m => m.note!)) + .then(res => new Map(res.map(it => [it.id, it]))); + + return mutings.map(m => ({ + id: m.id, + expiresAt: m.expiresAt?.toISOString(), + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + note: packedNotes.get(m.noteId)!, + })); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/notes/renotes.ts b/packages/backend/src/server/api/endpoints/notes/renotes.ts index b34d9261a1..ca349cc1e0 100644 --- a/packages/backend/src/server/api/endpoints/notes/renotes.ts +++ b/packages/backend/src/server/api/endpoints/notes/renotes.ts @@ -72,8 +72,11 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); + } const renotes = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/replies.ts b/packages/backend/src/server/api/endpoints/notes/replies.ts index f36af1a328..4a863810a7 100644 --- a/packages/backend/src/server/api/endpoints/notes/replies.ts +++ b/packages/backend/src/server/api/endpoints/notes/replies.ts @@ -56,8 +56,11 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); + } const timeline = await query.limit(ps.limit).getMany(); diff --git a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts index c45851548a..ee25d35b5d 100644 --- a/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts +++ b/packages/backend/src/server/api/endpoints/notes/search-by-tag.ts @@ -81,8 +81,11 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQueryForNotes(query, me); - if (me) this.queryService.generateBlockedUserQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQueryForNotes(query, me); + this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); + } try { if (ps.tag) { diff --git a/packages/backend/src/server/api/endpoints/notes/state.ts b/packages/backend/src/server/api/endpoints/notes/state.ts index 4c1eb86542..8feb511a71 100644 --- a/packages/backend/src/server/api/endpoints/notes/state.ts +++ b/packages/backend/src/server/api/endpoints/notes/state.ts @@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository, NoteThreadMutingsRepository, NoteFavoritesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; export const meta = { tags: ['notes'], @@ -26,6 +27,10 @@ export const meta = { type: 'boolean', optional: false, nullable: false, }, + isMutedNote: { + type: 'boolean', + optional: false, nullable: false, + }, }, }, } as const; @@ -49,11 +54,13 @@ export default class extends Endpoint { // eslint- @Inject(DI.noteFavoritesRepository) private noteFavoritesRepository: NoteFavoritesRepository, + + private noteMutingService: NoteMutingService, ) { super(meta, paramDef, async (ps, me) => { const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId }); - const [favorite, threadMuting] = await Promise.all([ + const [favorite, threadMuting, isMutedNote] = await Promise.all([ this.noteFavoritesRepository.count({ where: { userId: me.id, @@ -68,11 +75,13 @@ export default class extends Endpoint { // eslint- }, take: 1, }), + this.noteMutingService.isMuting(me.id, note.id), ]); return { isFavorited: favorite !== 0, isMutedThread: threadMuting !== 0, + isMutedNote, }; }); } diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index a88b28892e..5fae3bf36a 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -202,6 +202,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts index 80f1c69b25..123d35a9f6 100644 --- a/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/user-list-timeline.ts @@ -187,6 +187,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 6cd9f80929..f994a023c1 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -104,6 +104,7 @@ export default class extends Endpoint { // eslint- this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQueryForNotes(query, me); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); const notes = await query.getMany(); notes.sort((a, b) => a.id > b.id ? -1 : 1); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index f5b7a07b01..bb1335c5d4 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -187,6 +187,7 @@ export default class extends Endpoint { // eslint- if (me) { this.queryService.generateMutedUserQueryForNotes(query, me, { id: ps.userId }); this.queryService.generateBlockedUserQueryForNotes(query, me); + this.queryService.generateMutedNoteQuery(query, me); } if (ps.withFiles) { diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index c9801d8314..bbc8634c6b 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -15,6 +15,7 @@ import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; import type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; @@ -40,6 +41,7 @@ export default class Connection { public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); public userMutedInstances: Set = new Set(); + public noteMuting: Set = new Set(); private fetchIntervalId: NodeJS.Timeout | null = null; constructor( @@ -47,6 +49,7 @@ export default class Connection { private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, + private noteMutingService: NoteMutingService, user: MiUser | null | undefined, token: MiAccessToken | null | undefined, @@ -58,13 +61,14 @@ export default class Connection { @bindThis public async fetch() { if (this.user == null) return; - const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes] = await Promise.all([ + const [userProfile, following, followingChannels, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes, noteMuting] = await Promise.all([ this.cacheService.userProfileCache.fetch(this.user.id), this.cacheService.userFollowingsCache.fetch(this.user.id), this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id), this.cacheService.userMutingsCache.fetch(this.user.id), this.cacheService.userBlockedCache.fetch(this.user.id), this.cacheService.renoteMutingsCache.fetch(this.user.id), + this.noteMutingService.getMutingNoteIdsSet(this.user.id), ]); this.userProfile = userProfile; this.following = following; @@ -73,6 +77,7 @@ export default class Connection { this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; this.userMutedInstances = new Set(userProfile.mutedInstances); + this.noteMuting = noteMuting; } @bindThis diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 686aea423c..4e26fabff4 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -5,6 +5,7 @@ import { bindThis } from '@/decorators.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isMutingNoteRelated } from '@/misc/is-muting-note-related.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; import type { Packed } from '@/misc/json-schema.js'; @@ -51,6 +52,10 @@ export default abstract class Channel { return this.connection.userMutedInstances; } + protected get noteMuting() { + return this.connection.noteMuting; + } + protected get followingChannels() { return this.connection.followingChannels; } @@ -74,6 +79,9 @@ export default abstract class Channel { // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + // 流れてきたNoteがミュートしているNoteに関わる(ミュートしたノートがリノートされた or リプライがついた時) + if (isMutingNoteRelated(note, this.noteMuting)) return true; + return false; } diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index d6d2cb33f0..8429149524 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -512,6 +512,28 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); + test.concurrent('ノートミュートが機能する', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('following/create', { userId: bob.id }, alice); + await setTimeout(1000); + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + // ミュート前はノートが表示される + const res1 = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true); + + // ノートをミュート + await api('notes/muting/create', { noteId: bobNote.id }, alice); + await setTimeout(1000); + + // ミュート後はノートが表示されない + const res2 = await api('notes/timeline', { limit: 100 }, alice); + assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false); + }); + test.concurrent('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); @@ -744,7 +766,27 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); + }, 1000 * 30); + + test.concurrent('ノートミュートが機能する', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + // ミュート前はノートが表示される + const res1 = await api('notes/local-timeline', { limit: 100 }, alice); + assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true); + + // ノートをミュート + await api('notes/muting/create', { noteId: bobNote.id }, alice); + await setTimeout(1000); + + // ミュート後はノートが表示されない + const res2 = await api('notes/local-timeline', { limit: 100 }, alice); + assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false); + }); }); describe('Social TL', () => { @@ -955,7 +997,27 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); + }, 1000 * 30); + + test.concurrent('ノートミュートが機能する', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + // ミュート前はノートが表示される + const res1 = await api('notes/hybrid-timeline', { limit: 100 }, alice); + assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true); + + // ノートをミュート + await api('notes/muting/create', { noteId: bobNote.id }, alice); + await setTimeout(1000); + + // ミュート後はノートが表示されない + const res2 = await api('notes/hybrid-timeline', { limit: 100 }, alice); + assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false); + }); }); describe('User List TL', () => { @@ -1168,7 +1230,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); + }, 1000 * 30); test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1201,6 +1263,29 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); + + test.concurrent('ノートミュートが機能する', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); + await api('users/lists/push', { listId: list.id, userId: bob.id }, alice); + await setTimeout(1000); + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + // ミュート前はノートが表示される + const res1 = await api('notes/user-list-timeline', { listId: list.id }, alice); + assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true); + + // ノートをミュート + await api('notes/muting/create', { noteId: bobNote.id }, alice); + await setTimeout(1000); + + // ミュート後はノートが表示されない + const res2 = await api('notes/user-list-timeline', { listId: list.id }, alice); + assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false); + }); }); describe('User TL', () => { @@ -1327,7 +1412,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false); assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); - }, 1000 * 10); + }, 1000 * 30); test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); @@ -1451,6 +1536,26 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id }); assert.deepStrictEqual(res.body, [note3, note2, note1]); }); + + test.concurrent('ノートミュートが機能する', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const bobNote = await post(bob, { text: 'hi' }); + + await waitForPushToTl(); + + // ミュート前はノートが表示される + const res1 = await api('users/notes', { userId: bob.id }, alice); + assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true); + + // ノートをミュート + await api('notes/muting/create', { noteId: bobNote.id }, alice); + await setTimeout(1000); + + // ミュート後はノートが表示されない + const res2 = await api('users/notes', { userId: bob.id }, alice); + assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false); + }); }); // TODO: リノートミュート済みユーザーのテスト diff --git a/packages/backend/test/unit/NoteMutingService.ts b/packages/backend/test/unit/NoteMutingService.ts new file mode 100644 index 0000000000..adfcaf9b16 --- /dev/null +++ b/packages/backend/test/unit/NoteMutingService.ts @@ -0,0 +1,369 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, jest, expect, beforeAll, beforeEach, afterEach, afterAll, test } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { randomString } from '../utils.js'; +import { NoteMutingService } from '@/core/note/NoteMutingService.js'; +import { + MiNoteMuting, + MiNote, + MiUser, + NoteMutingsRepository, + NotesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { QueryService } from '@/core/QueryService.js'; + +process.env.NODE_ENV = 'test'; + +describe('NoteMutingService', () => { + let app: TestingModule; + let service: NoteMutingService; + + // -------------------------------------------------------------------------------------- + + let notesRepository: NotesRepository; + let noteMutingsRepository: NoteMutingsRepository; + let usersRepository: UsersRepository; + let idService: IdService; + let globalEventService: GlobalEventService; + let queryService: QueryService; + + // -------------------------------------------------------------------------------------- + + // Helper function to create a user + async function createUser(data: Partial = {}): Promise { + const user = { + id: idService.gen(), + username: randomString(), + usernameLower: randomString().toLowerCase(), + host: null, + ...data, + }; + + return await usersRepository.insert(user) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + } + + // Helper function to create a note + async function createNote(data: Partial = {}): Promise { + return await notesRepository.insert({ + id: idService.gen(), + userId: data.userId ?? (await createUser()).id, + text: randomString(), + visibility: 'public', + ...data, + }) + .then(x => notesRepository.findOneByOrFail(x.identifiers[0])); + } + + // Helper function to create a note muting + async function createNoteMuting(data: Partial = {}): Promise { + const id = idService.gen(); + const noteMuting = { + id, + userId: data.userId || (await createUser()).id, + noteId: data.noteId || (await createNote()).id, + expiresAt: null, + ...data, + }; + + return await noteMutingsRepository.insert(noteMuting) + .then(x => noteMutingsRepository.findOneByOrFail(x.identifiers[0])); + } + + // -------------------------------------------------------------------------------------- + + beforeAll(async () => { + app = await Test + .createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + }) + .compile(); + + service = app.get(NoteMutingService); + idService = app.get(IdService); + queryService = app.get(QueryService); + globalEventService = app.get(GlobalEventService); + notesRepository = app.get(DI.notesRepository); + noteMutingsRepository = app.get(DI.noteMutingsRepository); + usersRepository = app.get(DI.usersRepository); + + app.enableShutdownHooks(); + }); + + beforeEach(async () => { + // Clean database before each test + await noteMutingsRepository.delete({}); + await notesRepository.delete({}); + await usersRepository.delete({}); + }); + + afterEach(async () => { + // Clean database after each test + await noteMutingsRepository.delete({}); + await notesRepository.delete({}); + await usersRepository.delete({}); + }); + + afterAll(async () => { + await app.close(); + }); + + // -------------------------------------------------------------------------------------- + + describe('create', () => { + test('should create a note muting', async () => { + // Create a user and a note + const user = await createUser(); + const note = await createNote(); + + // Create a note muting + await service.create({ + userId: user.id, + noteId: note.id, + expiresAt: null, + }); + + // Verify the note muting was created + const noteMuting = await noteMutingsRepository.findOneBy({ + userId: user.id, + noteId: note.id, + }); + + expect(noteMuting).not.toBeNull(); + expect(noteMuting?.userId).toBe(user.id); + expect(noteMuting?.noteId).toBe(note.id); + }); + + test('should throw NoSuchNoteError if note does not exist', async () => { + // Create a user + const user = await createUser(); + const nonexistentNoteId = idService.gen(); + + // Attempt to create a note muting with a non-existent note + await expect(service.create({ + userId: user.id, + noteId: nonexistentNoteId, + expiresAt: null, + })).rejects.toThrow(NoteMutingService.NoSuchNoteError); + + // Verify no note muting was created + const noteMuting = await noteMutingsRepository.findOneBy({ + userId: user.id, + noteId: nonexistentNoteId, + }); + + expect(noteMuting).toBeNull(); + }); + }); + + describe('delete', () => { + test('should delete a note muting', async () => { + // Create a user, note, and note muting + const user = await createUser(); + const note = await createNote(); + const noteMuting = await createNoteMuting({ + userId: user.id, + noteId: note.id, + }); + + // Verify the note muting exists + const beforeDelete = await noteMutingsRepository.findOneBy({ + userId: user.id, + noteId: note.id, + }); + expect(beforeDelete).not.toBeNull(); + + // Delete the note muting + await service.delete(user.id, note.id); + + // Verify the note muting was deleted + const afterDelete = await noteMutingsRepository.findOneBy({ + userId: user.id, + noteId: note.id, + }); + expect(afterDelete).toBeNull(); + }); + + test('should throw NotMutedError if muting does not exist', async () => { + // Create a user and note + const user = await createUser(); + const note = await createNote(); + + // Attempt to delete a non-existent note muting + await expect(service.delete(user.id, note.id)).rejects.toThrow(NoteMutingService.NotMutedError); + }); + }); + + describe('isMuting', () => { + test('should return true if user is muting the note', async () => { + // Create a user, note, and note muting + const user = await createUser(); + const note = await createNote(); + await createNoteMuting({ + userId: user.id, + noteId: note.id, + }); + + // Check if the user is muting the note + const result = await service.isMuting(user.id, note.id); + + expect(result).toBe(true); + }); + + test('should return false if user is not muting the note', async () => { + // Create a user and note, but no muting + const user = await createUser(); + const note = await createNote(); + + // Check if the user is muting the note + const result = await service.isMuting(user.id, note.id); + + expect(result).toBe(false); + }); + }); + + describe('getMutingNoteIdsSet', () => { + test('should return a set of muted note IDs', async () => { + // Create a user and multiple notes + const user = await createUser(); + const note1 = await createNote(); + const note2 = await createNote(); + const note3 = await createNote(); + + // Create note mutings for two of the notes + await createNoteMuting({ + userId: user.id, + noteId: note1.id, + }); + await createNoteMuting({ + userId: user.id, + noteId: note2.id, + }); + + // Get the set of muted note IDs + const result = await service.getMutingNoteIdsSet(user.id); + + // Verify the result is a Set containing the muted note IDs + expect(result).toBeInstanceOf(Set); + expect(result.has(note1.id)).toBe(true); + expect(result.has(note2.id)).toBe(true); + expect(result.has(note3.id)).toBe(false); + }); + }); + + describe('listByUserId', () => { + test('should return a list of note mutings for a user', async () => { + // Create a user and multiple notes + const user = await createUser(); + const note1 = await createNote(); + const note2 = await createNote(); + + // Create note mutings + const muting1 = await createNoteMuting({ + userId: user.id, + noteId: note1.id, + }); + const muting2 = await createNoteMuting({ + userId: user.id, + noteId: note2.id, + }); + + // Get the list of note mutings + const result = await service.listByUserId({ userId: user.id }); + + // Verify the result contains the expected mutings + expect(result).toHaveLength(2); + expect(result.map(m => m.id).sort()).toEqual([muting1.id, muting2.id].sort()); + expect(result.map(m => m.noteId).sort()).toEqual([note1.id, note2.id].sort()); + }); + }); + + describe('cleanupExpiredMutes', () => { + test('should delete expired mutes', async () => { + // Create users and notes + const user1 = await createUser(); + const user2 = await createUser(); + const note1 = await createNote(); + const note2 = await createNote(); + const note3 = await createNote(); + + // Set the expiration date to 1 hour ago + const expiredDate = new Date(); + expiredDate.setHours(expiredDate.getHours() - 1); + + // Set the expiration date to 1 hour in the future + const futureDate = new Date(); + futureDate.setHours(futureDate.getHours() + 1); + + // Create expired note mutings + const expiredMuting1 = await createNoteMuting({ + userId: user1.id, + noteId: note1.id, + expiresAt: expiredDate, + }); + + const expiredMuting2 = await createNoteMuting({ + userId: user1.id, + noteId: note2.id, + expiresAt: expiredDate, + }); + + const expiredMuting3 = await createNoteMuting({ + userId: user2.id, + noteId: note3.id, + expiresAt: expiredDate, + }); + + // Create non-expired note muting + const activeMuting = await createNoteMuting({ + userId: user2.id, + noteId: note1.id, + expiresAt: futureDate, + }); + + // Create permanent note muting (no expiration) + const permanentMuting = await createNoteMuting({ + userId: user2.id, + noteId: note2.id, + expiresAt: null, + }); + + // Verify all mutings exist before cleanup + expect(await noteMutingsRepository.findOneBy({ id: expiredMuting1.id })).not.toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: expiredMuting2.id })).not.toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: expiredMuting3.id })).not.toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: activeMuting.id })).not.toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: permanentMuting.id })).not.toBeNull(); + + // Run cleanup + await service.cleanupExpiredMutes(); + + // Verify expired mutings are deleted and others remain + expect(await noteMutingsRepository.findOneBy({ id: expiredMuting1.id })).toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: expiredMuting2.id })).toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: expiredMuting3.id })).toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: activeMuting.id })).not.toBeNull(); + expect(await noteMutingsRepository.findOneBy({ id: permanentMuting.id })).not.toBeNull(); + + // Verify cache is updated by checking isMuting + expect(await service.isMuting(user1.id, note1.id)).toBe(false); + expect(await service.isMuting(user1.id, note2.id)).toBe(false); + expect(await service.isMuting(user2.id, note3.id)).toBe(false); + expect(await service.isMuting(user2.id, note1.id)).toBe(true); + expect(await service.isMuting(user2.id, note2.id)).toBe(true); + }); + }); +}); diff --git a/packages/frontend/src/pages/settings/mute-block.note-mute.vue b/packages/frontend/src/pages/settings/mute-block.note-mute.vue new file mode 100644 index 0000000000..639de8e148 --- /dev/null +++ b/packages/frontend/src/pages/settings/mute-block.note-mute.vue @@ -0,0 +1,92 @@ + + + + + + + diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index fc9cd8f892..6194a548d3 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -171,6 +171,18 @@ SPDX-License-Identifier: AGPL-3.0-only + + + + + + + + + @@ -180,6 +192,7 @@ SPDX-License-Identifier: AGPL-3.0-only import { ref, computed, watch } from 'vue'; import XInstanceMute from './mute-block.instance-mute.vue'; import XWordMute from './mute-block.word-mute.vue'; +import XNoteMute from './mute-block.note-mute.vue'; import MkPagination from '@/components/MkPagination.vue'; import { userPage } from '@/filters/user.js'; import { i18n } from '@/i18n.js'; diff --git a/packages/frontend/src/utility/get-note-menu.ts b/packages/frontend/src/utility/get-note-menu.ts index dd8bdf43d7..242c5165d6 100644 --- a/packages/frontend/src/utility/get-note-menu.ts +++ b/packages/frontend/src/utility/get-note-menu.ts @@ -234,6 +234,70 @@ export function getNoteMenu(props: { }); } + async function toggleNoteMute(mute: boolean) { + if (!mute) { + await os.apiWithDialog( + 'notes/muting/delete', + { + noteId: appearNote.id, + }, + undefined, + { + '6ad3b6c9-f173-60f7-b558-5eea13896254': { + title: i18n.ts.error, + text: i18n.ts._noteMuting.notMutedNote, + }, + }, + ); + } else { + const { canceled, result: period } = await os.select({ + title: i18n.ts.mutePeriod, + items: [{ + value: 'indefinitely', text: i18n.ts.indefinitely, + }, { + value: 'tenMinutes', text: i18n.ts.tenMinutes, + }, { + value: 'oneHour', text: i18n.ts.oneHour, + }, { + value: 'oneDay', text: i18n.ts.oneDay, + }, { + value: 'oneWeek', text: i18n.ts.oneWeek, + }], + default: 'indefinitely', + }); + if (canceled) return; + + const expiresAt = period === 'indefinitely' + ? null + : period === 'tenMinutes' + ? Date.now() + (1000 * 60 * 10) + : period === 'oneHour' + ? Date.now() + (1000 * 60 * 60) + : period === 'oneDay' + ? Date.now() + (1000 * 60 * 60 * 24) + : period === 'oneWeek' + ? Date.now() + (1000 * 60 * 60 * 24 * 7) + : null; + + await os.apiWithDialog( + 'notes/muting/create', + { + noteId: appearNote.id, + expiresAt, + }, + undefined, + { + 'a58e7999-f6d3-1780-a688-f43661719662': { + title: i18n.ts.error, + text: i18n.ts._noteMuting.noNotes, + }, + }, + ).then(() => { + props.isDeleted.value = true; + }); + } + } + function copyContent(): void { copyToClipboard(appearNote.text); } @@ -379,6 +443,16 @@ export function getNoteMenu(props: { action: () => toggleThreadMute(true), })); + menuItems.push(statePromise.then(state => state.isMutedNote ? { + icon: 'ti ti-message', + text: i18n.ts._noteMuting.unmuteNote, + action: () => toggleNoteMute(false), + } : { + icon: 'ti ti-message-off', + text: i18n.ts._noteMuting.muteNote, + action: () => toggleNoteMute(true), + })); + if (appearNote.userId === $i.id) { if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) { menuItems.push({ diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index b43906109f..f60dae73ef 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1971,6 +1971,10 @@ declare namespace entities { NotesLocalTimelineResponse, NotesMentionsRequest, NotesMentionsResponse, + NotesMutingCreateRequest, + NotesMutingDeleteRequest, + NotesMutingListRequest, + NotesMutingListResponse, NotesPollsRecommendationRequest, NotesPollsRecommendationResponse, NotesPollsVoteRequest, @@ -3031,6 +3035,18 @@ type NotesMentionsRequest = operations['notes___mentions']['requestBody']['conte // @public (undocumented) type NotesMentionsResponse = operations['notes___mentions']['responses']['200']['content']['application/json']; +// @public (undocumented) +type NotesMutingCreateRequest = operations['notes___muting___create']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesMutingDeleteRequest = operations['notes___muting___delete']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesMutingListRequest = operations['notes___muting___list']['requestBody']['content']['application/json']; + +// @public (undocumented) +type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['content']['application/json']; + // @public (undocumented) type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/generator/package.json b/packages/misskey-js/generator/package.json index 290b01ee50..30f5c0e6dd 100644 --- a/packages/misskey-js/generator/package.json +++ b/packages/misskey-js/generator/package.json @@ -15,7 +15,8 @@ "openapi-typescript": "6.7.6", "ts-case-convert": "2.1.0", "tsx": "4.19.3", - "typescript": "5.8.2" + "typescript": "5.8.2", + "eslint": "9.22.0" }, "files": [ "built" diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index b607c93e1e..0ab92f26f3 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3648,6 +3648,39 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 6390314429..5bc88384c5 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -503,6 +503,10 @@ import type { NotesLocalTimelineResponse, NotesMentionsRequest, NotesMentionsResponse, + NotesMutingCreateRequest, + NotesMutingDeleteRequest, + NotesMutingListRequest, + NotesMutingListResponse, NotesPollsRecommendationRequest, NotesPollsRecommendationResponse, NotesPollsVoteRequest, @@ -969,6 +973,9 @@ export type Endpoints = { 'notes/hybrid-timeline': { req: NotesHybridTimelineRequest; res: NotesHybridTimelineResponse }; 'notes/local-timeline': { req: NotesLocalTimelineRequest; res: NotesLocalTimelineResponse }; 'notes/mentions': { req: NotesMentionsRequest; res: NotesMentionsResponse }; + 'notes/muting/create': { req: NotesMutingCreateRequest; res: EmptyResponse }; + 'notes/muting/delete': { req: NotesMutingDeleteRequest; res: EmptyResponse }; + 'notes/muting/list': { req: NotesMutingListRequest; res: NotesMutingListResponse }; 'notes/polls/recommendation': { req: NotesPollsRecommendationRequest; res: NotesPollsRecommendationResponse }; 'notes/polls/vote': { req: NotesPollsVoteRequest; res: EmptyResponse }; 'notes/reactions': { req: NotesReactionsRequest; res: NotesReactionsResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index f814d7b3da..71b8aa0214 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -506,6 +506,10 @@ export type NotesLocalTimelineRequest = operations['notes___local-timeline']['re export type NotesLocalTimelineResponse = operations['notes___local-timeline']['responses']['200']['content']['application/json']; export type NotesMentionsRequest = operations['notes___mentions']['requestBody']['content']['application/json']; export type NotesMentionsResponse = operations['notes___mentions']['responses']['200']['content']['application/json']; +export type NotesMutingCreateRequest = operations['notes___muting___create']['requestBody']['content']['application/json']; +export type NotesMutingDeleteRequest = operations['notes___muting___delete']['requestBody']['content']['application/json']; +export type NotesMutingListRequest = operations['notes___muting___list']['requestBody']['content']['application/json']; +export type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['content']['application/json']; export type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json']; export type NotesPollsRecommendationResponse = operations['notes___polls___recommendation']['responses']['200']['content']['application/json']; export type NotesPollsVoteRequest = operations['notes___polls___vote']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 9da5540bc1..ad45535219 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -3150,6 +3150,33 @@ export type paths = { */ post: operations['notes___mentions']; }; + '/notes/muting/create': { + /** + * notes/muting/create + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + post: operations['notes___muting___create']; + }; + '/notes/muting/delete': { + /** + * notes/muting/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + post: operations['notes___muting___delete']; + }; + '/notes/muting/list': { + /** + * notes/muting/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + post: operations['notes___muting___list']; + }; '/notes/polls/recommendation': { /** * notes/polls/recommendation @@ -25142,6 +25169,181 @@ export type operations = { }; }; }; + /** + * notes/muting/create + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + notes___muting___create: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + noteId: string; + expiresAt?: number | null; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Too many requests */ + 429: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * notes/muting/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:account* + */ + notes___muting___delete: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + noteId: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * notes/muting/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:account* + */ + notes___muting___list: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + sinceId?: string | null; + /** Format: misskey:id */ + untilId?: string | null; + /** @default 10 */ + limit?: number; + offset?: number; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': ({ + id: string; + /** Format: date-time */ + expiresAt: string | null; + note: components['schemas']['Note']; + })[]; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * notes/polls/recommendation * @description No description provided. @@ -25766,6 +25968,7 @@ export type operations = { 'application/json': { isFavorited: boolean; isMutedThread: boolean; + isMutedNote: boolean; }; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 820d041852..5eb1b0be88 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1389,6 +1389,9 @@ importers: '@typescript-eslint/parser': specifier: 8.29.0 version: 8.29.0(eslint@9.22.0)(typescript@5.8.2) + eslint: + specifier: 9.22.0 + version: 9.22.0 openapi-types: specifier: 12.1.3 version: 12.1.3 @@ -4303,9 +4306,6 @@ packages: '@types/eslint@7.29.0': resolution: {integrity: sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==} - '@types/estree@1.0.6': - resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} - '@types/estree@1.0.7': resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} @@ -10850,6 +10850,9 @@ packages: vue-component-type-helpers@2.0.16: resolution: {integrity: sha512-qisL/iAfdO++7w+SsfYQJVPj6QKvxp4i1MMxvsNO41z/8zu3KuAw9LkhKUfP/kcOWGDxESp+pQObWppXusejCA==} + vue-component-type-helpers@2.2.10: + resolution: {integrity: sha512-iDUO7uQK+Sab2tYuiP9D1oLujCWlhHELHMgV/cB13cuGbG4qwkLHvtfWb6FzvxrIOPDnU0oHsz2MlQjhYDeaHA==} + vue-component-type-helpers@2.2.8: resolution: {integrity: sha512-4bjIsC284coDO9om4HPA62M7wfsTvcmZyzdfR0aUlFXqq4tXxM1APyXpNVxPC8QazKw9OhmZNHBVDA6ODaZsrA==} @@ -14445,7 +14448,7 @@ snapshots: ts-dedent: 2.2.0 type-fest: 2.19.0 vue: 3.5.13(typescript@5.8.3) - vue-component-type-helpers: 2.2.8 + vue-component-type-helpers: 2.2.10 '@stylistic/eslint-plugin@2.13.0(eslint@9.22.0)(typescript@5.8.2)': dependencies: @@ -14775,8 +14778,6 @@ snapshots: '@types/estree': 1.0.7 '@types/json-schema': 7.0.15 - '@types/estree@1.0.6': {} - '@types/estree@1.0.7': {} '@types/express-serve-static-core@4.17.33': @@ -17483,7 +17484,7 @@ snapshots: eslint@9.22.0: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.22.0) + '@eslint-community/eslint-utils': 4.6.1(eslint@9.22.0) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.19.2 '@eslint/config-helpers': 0.1.0 @@ -17494,7 +17495,7 @@ snapshots: '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 '@humanwhocodes/retry': 0.4.2 - '@types/estree': 1.0.6 + '@types/estree': 1.0.7 '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 @@ -22699,6 +22700,8 @@ snapshots: vue-component-type-helpers@2.0.16: {} + vue-component-type-helpers@2.2.10: {} + vue-component-type-helpers@2.2.8: {} vue-demi@0.14.7(vue@3.5.13(typescript@5.8.3)):