diff --git a/CHANGELOG.md b/CHANGELOG.md index a0ea1127c3..18e293dae7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ ### General - Feat: コンテンツの表示にログインを必須にできるように - Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように +- Feat: チャンネルミュート機能の実装 #10649 + - チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列) - Enhance: 依存関係の更新 - Enhance: l10nの更新 - Fix: お知らせ作成時に画像URL入力欄を空欄に変更できないのを修正 ( #14976 ) diff --git a/packages/backend/jest.config.unit.cjs b/packages/backend/jest.config.unit.cjs index aa5992936b..957d0635c1 100644 --- a/packages/backend/jest.config.unit.cjs +++ b/packages/backend/jest.config.unit.cjs @@ -7,6 +7,7 @@ const base = require('./jest.config.cjs') module.exports = { ...base, + globalSetup: "/test/jest.setup.unit.cjs", testMatch: [ "/test/unit/**/*.ts", "/src/**/*.test.ts", diff --git a/packages/backend/migration/1718015380000-add-channel-muting.js b/packages/backend/migration/1718015380000-add-channel-muting.js new file mode 100644 index 0000000000..e2592dce7a --- /dev/null +++ b/packages/backend/migration/1718015380000-add-channel-muting.js @@ -0,0 +1,42 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class AddChannelMuting1718015380000 { + name = 'AddChannelMuting1718015380000' + + async up(queryRunner) { + await queryRunner.query(` + CREATE TABLE "channel_muting" + ( + "id" varchar(32) NOT NULL, + "userId" varchar(32) NOT NULL, + "channelId" varchar(32) NOT NULL, + "expiresAt" timestamp with time zone, + CONSTRAINT "PK_channel_muting_id" PRIMARY KEY ("id"), + CONSTRAINT "FK_channel_muting_userId" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION, + CONSTRAINT "FK_channel_muting_channelId" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION + ); + CREATE INDEX "IDX_channel_muting_userId" ON "channel_muting" ("userId"); + CREATE INDEX "IDX_channel_muting_channelId" ON "channel_muting" ("channelId"); + + ALTER TABLE note ADD "renoteChannelId" varchar(32); + COMMENT ON COLUMN note."renoteChannelId" is '[Denormalized]'; + `); + } + + async down(queryRunner) { + await queryRunner.query(` + ALTER TABLE note DROP COLUMN "renoteChannelId"; + + ALTER TABLE "channel_muting" + DROP CONSTRAINT "FK_channel_muting_userId"; + ALTER TABLE "channel_muting" + DROP CONSTRAINT "FK_channel_muting_channelId"; + DROP INDEX "IDX_channel_muting_userId"; + DROP INDEX "IDX_channel_muting_channelId"; + DROP TABLE "channel_muting"; + `); + } +} diff --git a/packages/backend/src/core/ChannelFollowingService.ts b/packages/backend/src/core/ChannelFollowingService.ts index 12251595e2..d320a5ea36 100644 --- a/packages/backend/src/core/ChannelFollowingService.ts +++ b/packages/backend/src/core/ChannelFollowingService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable, OnModuleInit } from '@nestjs/common'; import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; -import type { ChannelFollowingsRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, ChannelsRepository, MiUser } from '@/models/_.js'; import { MiChannel } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; @@ -23,6 +23,8 @@ export class ChannelFollowingService implements OnModuleInit { private redisClient: Redis.Redis, @Inject(DI.redisForSub) private redisForSub: Redis.Redis, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, private idService: IdService, @@ -45,6 +47,50 @@ export class ChannelFollowingService implements OnModuleInit { onModuleInit() { } + /** + * フォローしているチャンネルの一覧を取得する. + * @param params + * @param [opts] + * @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意. + * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). + * @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない). + */ + @bindThis + public async list( + params: { + requestUserId: MiUser['id'], + }, + opts?: { + idOnly?: boolean; + joinUser?: boolean; + joinBannerFile?: boolean; + }, + ): Promise { + if (opts?.idOnly) { + const q = this.channelFollowingsRepository.createQueryBuilder('channel_following') + .select('channel_following.followeeId') + .where('channel_following.followerId = :userId', { userId: params.requestUserId }); + + return q + .getRawMany<{ channel_following_followeeId: string }>() + .then(xs => xs.map(x => ({ id: x.channel_following_followeeId } as MiChannel))); + } else { + const q = this.channelsRepository.createQueryBuilder('channel') + .innerJoin('channel_following', 'channel_following', 'channel_following.followeeId = channel.id') + .where('channel_following.followerId = :userId', { userId: params.requestUserId }); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel.user', 'user'); + } + + if (opts?.joinBannerFile) { + q.leftJoinAndSelect('channel.banner', 'drive_file'); + } + + return q.getMany(); + } + } + @bindThis public async follow( requestUser: MiLocalUser, diff --git a/packages/backend/src/core/ChannelMutingService.ts b/packages/backend/src/core/ChannelMutingService.ts new file mode 100644 index 0000000000..bf5b848d44 --- /dev/null +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { Brackets, In } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; +import { bindThis } from '@/decorators.js'; +import { RedisKVCache } from '@/misc/cache.js'; + +@Injectable() +export class ChannelMutingService { + public mutingChannelsCache: RedisKVCache>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + @Inject(DI.channelMutingRepository) + private channelMutingRepository: ChannelMutingRepository, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + this.mutingChannelsCache = new RedisKVCache>(this.redisClient, 'channelMutingChannels', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (userId) => this.channelMutingRepository.find({ + where: { userId: userId }, + select: ['channelId'], + }).then(xs => new Set(xs.map(x => x.channelId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.redisForSub.on('message', this.onMessage); + } + + /** + * ミュートしているチャンネルの一覧を取得する. + * @param params + * @param [opts] + * @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意. + * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). + * @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない). + */ + @bindThis + public async list( + params: { + requestUserId: MiUser['id'], + }, + opts?: { + idOnly?: boolean; + joinUser?: boolean; + joinBannerFile?: boolean; + }, + ): Promise { + if (opts?.idOnly) { + const q = this.channelMutingRepository.createQueryBuilder('channel_muting') + .select('channel_muting.channelId') + .where('channel_muting.userId = :userId', { userId: params.requestUserId }) + .andWhere(new Brackets(qb => { + qb.where('channel_muting.expiresAt IS NULL') + .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); + })); + + return q + .getRawMany<{ channel_muting_channelId: string }>() + .then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel))); + } else { + const q = this.channelsRepository.createQueryBuilder('channel') + .innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id') + .where('channel_muting.userId = :userId', { userId: params.requestUserId }) + .andWhere(new Brackets(qb => { + qb.where('channel_muting.expiresAt IS NULL') + .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); + })); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel.user', 'user'); + } + + if (opts?.joinBannerFile) { + q.leftJoinAndSelect('channel.banner', 'drive_file'); + } + + return q.getMany(); + } + } + + /** + * 期限切れのチャンネルミュート情報を取得する. + * + * @param [opts] + * @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない). + * @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない). + */ + public async findExpiredMutings(opts?: { + joinUser?: boolean; + joinChannel?: boolean; + }): Promise { + const now = new Date(); + const q = this.channelMutingRepository.createQueryBuilder('channel_muting') + .where('channel_muting.expiresAt < :now', { now }); + + if (opts?.joinUser) { + q.innerJoinAndSelect('channel_muting.user', 'user'); + } + + if (opts?.joinChannel) { + q.leftJoinAndSelect('channel_muting.channel', 'channel'); + } + + return q.getMany(); + } + + /** + * 既にミュートされているかどうかをキャッシュから取得する. + * @param params + * @param params.requestUserId + */ + @bindThis + public async isMuted(params: { + requestUserId: MiUser['id'], + targetChannelId: MiChannel['id'], + }): Promise { + const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId); + return (mutedChannels?.has(params.targetChannelId) ?? false); + } + + /** + * チャンネルをミュートする. + * @param params + * @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限. + */ + @bindThis + public async mute(params: { + requestUserId: MiUser['id'], + targetChannelId: MiChannel['id'], + expiresAt?: Date | null, + }): Promise { + await this.channelMutingRepository.insert({ + id: this.idService.gen(), + userId: params.requestUserId, + channelId: params.targetChannelId, + expiresAt: params.expiresAt, + }); + + this.globalEventService.publishInternalEvent('muteChannel', { + userId: params.requestUserId, + channelId: params.targetChannelId, + }); + } + + /** + * チャンネルのミュートを解除する. + * @param params + */ + @bindThis + public async unmute(params: { + requestUserId: MiUser['id'], + targetChannelId: MiChannel['id'], + }): Promise { + await this.channelMutingRepository.delete({ + userId: params.requestUserId, + channelId: params.targetChannelId, + }); + + this.globalEventService.publishInternalEvent('unmuteChannel', { + userId: params.requestUserId, + channelId: params.targetChannelId, + }); + } + + /** + * 期限切れのチャンネルミュート情報を削除する. + */ + @bindThis + public async eraseExpiredMutings(): Promise { + const expiredMutings = await this.findExpiredMutings(); + await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) }); + + const userIds = [...new Set(expiredMutings.map(x => x.userId))]; + for (const userId of userIds) { + this.mutingChannelsCache.refresh(userId).then(); + } + } + + @bindThis + private async onMessage(_: string, data: string): Promise { + const obj = JSON.parse(data); + + if (obj.channel === 'internal') { + const { type, body } = obj.message as GlobalEvents['internal']['payload']; + switch (type) { + case 'muteChannel': { + this.mutingChannelsCache.refresh(body.userId).then(); + break; + } + case 'unmuteChannel': { + this.mutingChannelsCache.delete(body.userId).then(); + break; + } + } + } + } + + @bindThis + public dispose(): void { + this.mutingChannelsCache.dispose(); + } + + @bindThis + public onApplicationShutdown(signal?: string | undefined): void { + this.dispose(); + } +} diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 734d135648..db4441c5a3 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -15,6 +15,7 @@ import { SystemWebhookService } from '@/core/SystemWebhookService.js'; import { UserSearchService } from '@/core/UserSearchService.js'; import { WebhookTestService } from '@/core/WebhookTestService.js'; import { FlashService } from '@/core/FlashService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -225,6 +226,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService }; const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService }; const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService }; +const $ChannelMutingService: Provider = { provide: 'ChannelMutingService', useExisting: ChannelMutingService }; const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService }; const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService }; @@ -376,6 +378,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChannelMutingService, RegistryApiService, ReversiService, @@ -523,6 +526,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChannelMutingService, $RegistryApiService, $ReversiService, @@ -671,6 +675,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChannelMutingService, RegistryApiService, ReversiService, @@ -816,6 +821,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChannelMutingService, $RegistryApiService, $ReversiService, diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index b05af99c5e..87bc5e79f0 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -17,6 +17,8 @@ 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 { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { isChannelRelated } from '@/misc/is-channel-related.js'; type TimelineOptions = { untilId: string | null, @@ -33,6 +35,7 @@ type TimelineOptions = { excludeNoFiles?: boolean; excludeReplies?: boolean; excludePureRenotes: boolean; + includeMutedChannels?: boolean; dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, }; @@ -45,6 +48,7 @@ export class FanoutTimelineEndpointService { private noteEntityService: NoteEntityService, private cacheService: CacheService, private fanoutTimelineService: FanoutTimelineService, + private channelMutingService: ChannelMutingService, ) { } @@ -101,11 +105,13 @@ export class FanoutTimelineEndpointService { userIdsWhoMeMutingRenotes, userIdsWhoBlockingMe, userMutedInstances, + userMutedChannels, ] = 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)), + ps.includeMutedChannels ? Promise.resolve(new Set()) : this.channelMutingService.mutingChannelsCache.fetch(me.id), ]); const parentFilter = filter; @@ -114,6 +120,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 (!ps.includeMutedChannels && isChannelRelated(note, userMutedChannels)) return false; return parentFilter(note); }; diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 03646ff566..2b48416a5b 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -244,6 +244,8 @@ export interface InternalEventTypes { metaUpdated: { before?: MiMeta; after: MiMeta; }; followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + muteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; + unmuteChannel: { userId: MiUser['id']; channelId: MiChannel['id']; }; updateUserProfile: MiUserProfile; mute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; }; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 56ddcefd7c..231d9b99d8 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -438,6 +438,7 @@ export class NoteCreateService implements OnApplicationShutdown { replyUserHost: data.reply ? data.reply.userHost : null, renoteUserId: data.renote ? data.renote.userId : null, renoteUserHost: data.renote ? data.renote.userHost : null, + renoteChannelId: data.renote ? data.renote.channelId : null, userHost: user.host, }); diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 555a39f71c..ed489076e3 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -131,6 +131,7 @@ function generateDummyNote(override?: Partial): MiNote { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + renoteChannelId: null, ...override, }; } diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 1ba7ca8e57..9ee918bea3 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -4,36 +4,40 @@ */ import { Inject, Injectable } from '@nestjs/common'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { ChannelFavoritesRepository, ChannelFollowingsRepository, ChannelsRepository, DriveFilesRepository, NotesRepository } from '@/models/_.js'; +import type { + ChannelFavoritesRepository, + ChannelFollowingsRepository, ChannelMutingRepository, + ChannelsRepository, + DriveFilesRepository, + MiDriveFile, + MiNote, + NotesRepository, +} from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; -import type { } from '@/models/Blocking.js'; import type { MiUser } from '@/models/User.js'; import type { MiChannel } from '@/models/Channel.js'; import { bindThis } from '@/decorators.js'; import { IdService } from '@/core/IdService.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; import { NoteEntityService } from './NoteEntityService.js'; -import { In } from 'typeorm'; @Injectable() export class ChannelEntityService { constructor( @Inject(DI.channelsRepository) private channelsRepository: ChannelsRepository, - @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - @Inject(DI.channelFavoritesRepository) private channelFavoritesRepository: ChannelFavoritesRepository, - + @Inject(DI.channelMutingRepository) + private channelMutingRepository: ChannelMutingRepository, @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, @@ -45,31 +49,59 @@ export class ChannelEntityService { src: MiChannel['id'] | MiChannel, me?: { id: MiUser['id'] } | null | undefined, detailed?: boolean, + opts?: { + bannerFiles?: Map; + followings?: Set; + favorites?: Set; + muting?: Set; + pinnedNotes?: Map; + }, ): Promise> { const channel = typeof src === 'object' ? src : await this.channelsRepository.findOneByOrFail({ id: src }); - const meId = me ? me.id : null; - const banner = channel.bannerId ? await this.driveFilesRepository.findOneBy({ id: channel.bannerId }) : null; + let bannerFile: MiDriveFile | null = null; + if (channel.bannerId) { + bannerFile = opts?.bannerFiles?.get(channel.bannerId) + ?? await this.driveFilesRepository.findOneByOrFail({ id: channel.bannerId }); + } - const isFollowing = meId ? await this.channelFollowingsRepository.exists({ - where: { - followerId: meId, - followeeId: channel.id, - }, - }) : false; + let isFollowing = false; + let isFavorited = false; + let isMuting = false; + if (me) { + isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({ + where: { + followerId: me.id, + followeeId: channel.id, + }, + }); - const isFavorited = meId ? await this.channelFavoritesRepository.exists({ - where: { - userId: meId, - channelId: channel.id, - }, - }) : false; + isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({ + where: { + userId: me.id, + channelId: channel.id, + }, + }); - const pinnedNotes = channel.pinnedNoteIds.length > 0 ? await this.notesRepository.find({ - where: { - id: In(channel.pinnedNoteIds), - }, - }) : []; + isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({ + where: { + userId: me.id, + channelId: channel.id, + }, + }); + } + + const pinnedNotes = Array.of(); + if (channel.pinnedNoteIds.length > 0) { + pinnedNotes.push( + ...( + opts?.pinnedNotes + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null) + : await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) }) + ), + ); + } return { id: channel.id, @@ -78,7 +110,7 @@ export class ChannelEntityService { name: channel.name, description: channel.description, userId: channel.userId, - bannerUrl: banner ? this.driveFileEntityService.getPublicUrl(banner) : null, + bannerUrl: bannerFile ? this.driveFileEntityService.getPublicUrl(bannerFile) : null, pinnedNoteIds: channel.pinnedNoteIds, color: channel.color, isArchived: channel.isArchived, @@ -90,6 +122,7 @@ export class ChannelEntityService { ...(me ? { isFollowing, isFavorited, + isMuting, hasUnreadNote: false, // 後方互換性のため } : {}), @@ -98,5 +131,72 @@ export class ChannelEntityService { } : {}), }; } + + @bindThis + public async packMany( + src: MiChannel['id'][] | MiChannel[], + me?: { id: MiUser['id'] } | null | undefined, + detailed?: boolean, + ): Promise[]> { + // IDのみの要素がある場合、DBからオブジェクトを取得して補う + const channels = src.filter(it => typeof it === 'object') as MiChannel[]; + channels.push( + ...(await this.channelsRepository.find({ + where: { + id: In(src.filter(it => typeof it !== 'object') as MiChannel['id'][]), + }, + })), + ); + channels.sort((a, b) => a.id.localeCompare(b.id)); + + const bannerFiles = await this.driveFilesRepository + .findBy({ + id: In(channels.map(it => it.bannerId).filter(it => it != null)), + }) + .then(it => new Map(it.map(it => [it.id, it]))); + + const followings = me + ? await this.channelFollowingsRepository + .findBy({ + followerId: me.id, + followeeId: In(channels.map(it => it.id)), + }) + .then(it => new Set(it.map(it => it.followeeId))) + : new Set(); + + const favorites = me + ? await this.channelFavoritesRepository + .findBy({ + userId: me.id, + channelId: In(channels.map(it => it.id)), + }) + .then(it => new Set(it.map(it => it.channelId))) + : new Set(); + + const muting = me + ? await this.channelMutingRepository + .findBy({ + userId: me.id, + channelId: In(channels.map(it => it.id)), + }) + .then(it => new Set(it.map(it => it.channelId))) + : new Set(); + + const pinnedNotes = await this.notesRepository + .find({ + where: { + id: In(channels.flatMap(it => it.pinnedNoteIds)), + }, + }) + .then(it => new Map(it.map(it => [it.id, it]))); + + return Promise.all(channels.map(it => this.pack(it, me, detailed, { + bannerFiles, + followings, + favorites, + muting, + pinnedNotes, + }))); + } } diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index e599fc7b37..6d307ec74e 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -71,6 +71,7 @@ export const DI = { channelsRepository: Symbol('channelsRepository'), channelFollowingsRepository: Symbol('channelFollowingsRepository'), channelFavoritesRepository: Symbol('channelFavoritesRepository'), + channelMutingRepository: Symbol('channelMutingRepository'), registryItemsRepository: Symbol('registryItemsRepository'), webhooksRepository: Symbol('webhooksRepository'), systemWebhooksRepository: Symbol('systemWebhooksRepository'), diff --git a/packages/backend/src/misc/is-channel-related.ts b/packages/backend/src/misc/is-channel-related.ts new file mode 100644 index 0000000000..4b6614cd28 --- /dev/null +++ b/packages/backend/src/misc/is-channel-related.ts @@ -0,0 +1,28 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { MiNote } from '@/models/Note.js'; +import { Packed } from '@/misc/json-schema.js'; + +/** + * {@link note}が{@link channelIds}のチャンネルに関連するかどうかを判定し、関連する場合はtrueを返します。 + * 関連するというのは、{@link channelIds}のチャンネルに向けての投稿であるか、またはそのチャンネルの投稿をリノート・引用リノートした投稿であるかを指します。 + * + * @param note 確認対象のノート + * @param channelIds 確認対象のチャンネルID一覧 + */ +export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set): boolean { + if (note.channelId && channelIds.has(note.channelId)) { + return true; + } + + if (note.renote != null && note.renote.channelId && channelIds.has(note.renote.channelId)) { + return true; + } + + // NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので) + + return false; +} diff --git a/packages/backend/src/models/ChannelMuting.ts b/packages/backend/src/models/ChannelMuting.ts new file mode 100644 index 0000000000..11ac7e5cef --- /dev/null +++ b/packages/backend/src/models/ChannelMuting.ts @@ -0,0 +1,46 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChannel } from './Channel.js'; + +@Entity('channel_muting') +@Index(['userId', 'channelId'], {}) +export class MiChannelMuting { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Index() + @Column({ + ...id(), + }) + public channelId: MiChannel['id']; + + @ManyToOne(type => MiChannel, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public channel: MiChannel | null; + + @Index() + @Column('timestamp with time zone', { + nullable: true, + }) + public expiresAt: Date | null; +} diff --git a/packages/backend/src/models/Note.ts b/packages/backend/src/models/Note.ts index 9a95c6faab..7741ce47a3 100644 --- a/packages/backend/src/models/Note.ts +++ b/packages/backend/src/models/Note.ts @@ -229,6 +229,13 @@ export class MiNote { comment: '[Denormalized]', }) public renoteUserHost: string | null; + + @Column({ + ...id(), + nullable: true, + comment: '[Denormalized]', + }) + public renoteChannelId: MiChannel['id'] | null; //#endregion constructor(data: Partial) { diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index ea0f88baba..dfcbb7ceb4 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -22,6 +22,7 @@ import { MiChannel, MiChannelFavorite, MiChannelFollowing, + MiChannelMuting, MiClip, MiClipFavorite, MiClipNote, @@ -417,6 +418,12 @@ const $channelFavoritesRepository: Provider = { inject: [DI.db], }; +const $channelMutingRepository: Provider = { + provide: DI.channelMutingRepository, + useFactory: (db: DataSource) => db.getRepository(MiChannelMuting).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $registryItemsRepository: Provider = { provide: DI.registryItemsRepository, useFactory: (db: DataSource) => db.getRepository(MiRegistryItem).extend(miRepository as MiRepository), @@ -554,6 +561,7 @@ const $reversiGamesRepository: Provider = { $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, + $channelMutingRepository, $registryItemsRepository, $webhooksRepository, $systemWebhooksRepository, @@ -625,6 +633,7 @@ const $reversiGamesRepository: Provider = { $channelsRepository, $channelFollowingsRepository, $channelFavoritesRepository, + $channelMutingRepository, $registryItemsRepository, $webhooksRepository, $systemWebhooksRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index c72bdaa727..abfc5b11f2 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -3,13 +3,10 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder, TypeORMError } from 'typeorm'; -import { DriverUtils } from 'typeorm/driver/DriverUtils.js'; +import { FindOneOptions, InsertQueryBuilder, ObjectLiteral, Repository, SelectQueryBuilder } from 'typeorm'; import { RelationCountLoader } from 'typeorm/query-builder/relation-count/RelationCountLoader.js'; import { RelationIdLoader } from 'typeorm/query-builder/relation-id/RelationIdLoader.js'; import { RawSqlResultsToEntityTransformer } from 'typeorm/query-builder/transformer/RawSqlResultsToEntityTransformer.js'; -import { ObjectUtils } from 'typeorm/util/ObjectUtils.js'; -import { OrmUtils } from 'typeorm/util/OrmUtils.js'; import { MiAbuseUserReport } from '@/models/AbuseUserReport.js'; import { MiAbuseReportNotificationRecipient } from '@/models/AbuseReportNotificationRecipient.js'; import { MiAccessToken } from '@/models/AccessToken.js'; @@ -23,6 +20,7 @@ import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; +import { MiChannelMuting } from "@/models/ChannelMuting.js"; import { MiClip } from '@/models/Clip.js'; import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; @@ -138,6 +136,7 @@ export { MiBlocking, MiChannelFollowing, MiChannelFavorite, + MiChannelMuting, MiClip, MiClipNote, MiClipFavorite, @@ -209,6 +208,7 @@ export type AuthSessionsRepository = Repository & MiRepository & MiRepository; export type ChannelFollowingsRepository = Repository & MiRepository; export type ChannelFavoritesRepository = Repository & MiRepository; +export type ChannelMutingRepository = Repository & MiRepository; export type ClipsRepository = Repository & MiRepository; export type ClipNotesRepository = Repository & MiRepository; export type ClipFavoritesRepository = Repository & MiRepository; diff --git a/packages/backend/src/models/json-schema/channel.ts b/packages/backend/src/models/json-schema/channel.ts index d233f7858d..a7966ffdb3 100644 --- a/packages/backend/src/models/json-schema/channel.ts +++ b/packages/backend/src/models/json-schema/channel.ts @@ -80,6 +80,10 @@ export const packedChannelSchema = { type: 'boolean', optional: true, nullable: false, }, + isMuting: { + type: 'boolean', + optional: true, nullable: false, + }, pinnedNotes: { type: 'array', optional: true, nullable: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 251a03c303..ca0ccf601b 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -22,6 +22,7 @@ import { MiAuthSession } from '@/models/AuthSession.js'; import { MiBlocking } from '@/models/Blocking.js'; import { MiChannelFollowing } from '@/models/ChannelFollowing.js'; import { MiChannelFavorite } from '@/models/ChannelFavorite.js'; +import { MiChannelMuting } from "@/models/ChannelMuting.js"; import { MiClip } from '@/models/Clip.js'; import { MiClipNote } from '@/models/ClipNote.js'; import { MiClipFavorite } from '@/models/ClipFavorite.js'; @@ -183,6 +184,7 @@ export const entities = [ MiChannel, MiChannelFollowing, MiChannelFavorite, + MiChannelMuting, MiRegistryItem, MiAd, MiPasswordResetRequest, diff --git a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts index 448fc9c763..e898e6dd48 100644 --- a/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts +++ b/packages/backend/src/queue/processors/CheckExpiredMutingsProcessorService.ts @@ -4,14 +4,13 @@ */ import { Inject, Injectable } from '@nestjs/common'; -import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { MutingsRepository } from '@/models/_.js'; import type Logger from '@/logger.js'; import { bindThis } from '@/decorators.js'; import { UserMutingService } from '@/core/UserMutingService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { QueueLoggerService } from '../QueueLoggerService.js'; -import type * as Bull from 'bullmq'; @Injectable() export class CheckExpiredMutingsProcessorService { @@ -22,6 +21,7 @@ export class CheckExpiredMutingsProcessorService { private mutingsRepository: MutingsRepository, private userMutingService: UserMutingService, + private channelMutingService: ChannelMutingService, private queueLoggerService: QueueLoggerService, ) { this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings'); @@ -41,6 +41,8 @@ export class CheckExpiredMutingsProcessorService { await this.userMutingService.unmute(expired); } + await this.channelMutingService.eraseExpiredMutings(); + this.logger.succ('All expired mutings checked.'); } } diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 5bb194313d..79cf647cb3 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -127,6 +127,9 @@ import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___channels_search from './endpoints/channels/search.js'; +import * as ep___channels_mute_create from './endpoints/channels/mute/create.js'; +import * as ep___channels_mute_delete from './endpoints/channels/mute/delete.js'; +import * as ep___channels_mute_list from './endpoints/channels/mute/list.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -515,6 +518,9 @@ const $channels_favorite: Provider = { provide: 'ep:channels/favorite', useClass const $channels_unfavorite: Provider = { provide: 'ep:channels/unfavorite', useClass: ep___channels_unfavorite.default }; const $channels_myFavorites: Provider = { provide: 'ep:channels/my-favorites', useClass: ep___channels_myFavorites.default }; const $channels_search: Provider = { provide: 'ep:channels/search', useClass: ep___channels_search.default }; +const $channels_mute_create: Provider = { provide: 'ep:channels/mute/create', useClass: ep___channels_mute_create.default }; +const $channels_mute_delete: Provider = { provide: 'ep:channels/mute/delete', useClass: ep___channels_mute_delete.default }; +const $channels_mute_list: Provider = { provide: 'ep:channels/mute/list', useClass: ep___channels_mute_list.default }; const $charts_activeUsers: Provider = { provide: 'ep:charts/active-users', useClass: ep___charts_activeUsers.default }; const $charts_apRequest: Provider = { provide: 'ep:charts/ap-request', useClass: ep___charts_apRequest.default }; const $charts_drive: Provider = { provide: 'ep:charts/drive', useClass: ep___charts_drive.default }; @@ -907,6 +913,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $channels_unfavorite, $channels_myFavorites, $channels_search, + $channels_mute_create, + $channels_mute_delete, + $channels_mute_list, $charts_activeUsers, $charts_apRequest, $charts_drive, @@ -1293,6 +1302,9 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $channels_unfavorite, $channels_myFavorites, $channels_search, + $channels_mute_create, + $channels_mute_delete, + $channels_mute_list, $charts_activeUsers, $charts_apRequest, $charts_drive, diff --git a/packages/backend/src/server/api/StreamingApiServerService.ts b/packages/backend/src/server/api/StreamingApiServerService.ts index b8f448477b..429bc9a179 100644 --- a/packages/backend/src/server/api/StreamingApiServerService.ts +++ b/packages/backend/src/server/api/StreamingApiServerService.ts @@ -16,6 +16,7 @@ import { CacheService } from '@/core/CacheService.js'; import { MiLocalUser } from '@/models/User.js'; import { UserService } from '@/core/UserService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { AuthenticateService, AuthenticationError } from './AuthenticateService.js'; import MainStreamConnection from './stream/Connection.js'; import { ChannelsService } from './stream/ChannelsService.js'; @@ -41,6 +42,7 @@ export class StreamingApiServerService { private notificationService: NotificationService, private usersService: UserService, private channelFollowingService: ChannelFollowingService, + private channelMutingService: ChannelMutingService, ) { } @@ -100,6 +102,7 @@ export class StreamingApiServerService { this.notificationService, this.cacheService, this.channelFollowingService, + this.channelMutingService, user, app, ); diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 15809b2678..ceb3fda96e 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -133,6 +133,9 @@ import * as ep___channels_favorite from './endpoints/channels/favorite.js'; import * as ep___channels_unfavorite from './endpoints/channels/unfavorite.js'; import * as ep___channels_myFavorites from './endpoints/channels/my-favorites.js'; import * as ep___channels_search from './endpoints/channels/search.js'; +import * as ep___channels_mute_create from './endpoints/channels/mute/create.js'; +import * as ep___channels_mute_delete from './endpoints/channels/mute/delete.js'; +import * as ep___channels_mute_list from './endpoints/channels/mute/list.js'; import * as ep___charts_activeUsers from './endpoints/charts/active-users.js'; import * as ep___charts_apRequest from './endpoints/charts/ap-request.js'; import * as ep___charts_drive from './endpoints/charts/drive.js'; @@ -519,6 +522,9 @@ const eps = [ ['channels/unfavorite', ep___channels_unfavorite], ['channels/my-favorites', ep___channels_myFavorites], ['channels/search', ep___channels_search], + ['channels/mute/create', ep___channels_mute_create], + ['channels/mute/delete', ep___channels_mute_delete], + ['channels/mute/list', ep___channels_mute_list], ['charts/active-users', ep___charts_activeUsers], ['charts/ap-request', ep___charts_apRequest], ['charts/drive', ep___charts_drive], diff --git a/packages/backend/src/server/api/endpoints/antennas/notes.ts b/packages/backend/src/server/api/endpoints/antennas/notes.ts index f4dfe1ecc4..49f1df9692 100644 --- a/packages/backend/src/server/api/endpoints/antennas/notes.ts +++ b/packages/backend/src/server/api/endpoints/antennas/notes.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, AntennasRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; @@ -15,6 +16,7 @@ import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js'; import { trackPromise } from '@/misc/promise-tracker.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -74,6 +76,7 @@ export default class extends Endpoint { // eslint- private noteReadService: NoteReadService, private fanoutTimelineService: FanoutTimelineService, private globalEventService: GlobalEventService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -113,6 +116,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // -- ミュートされたチャンネル対策 + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/channels/mute/create.ts b/packages/backend/src/server/api/endpoints/channels/mute/create.ts new file mode 100644 index 0000000000..26ce707c7a --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/mute/create.ts @@ -0,0 +1,90 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; + +export const meta = { + tags: ['channels', 'mute'], + + requireCredential: true, + prohibitMoved: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such Channel.', + code: 'NO_SUCH_CHANNEL', + id: '7174361e-d58f-31d6-2e7c-6fb830786a3f', + }, + + alreadyMuting: { + message: 'You are already muting that user.', + code: 'ALREADY_MUTING_CHANNEL', + id: '5a251978-769a-da44-3e89-3931e43bb592', + }, + + expiresAtIsPast: { + message: 'Cannot set past date to "expiresAt".', + code: 'EXPIRES_AT_IS_PAST', + id: '42b32236-df2c-a45f-fdbf-def67268f749', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + expiresAt: { + type: 'integer', + nullable: true, + description: 'A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute.', + }, + }, + required: ['channelId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelMutingService: ChannelMutingService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check if exists the channel + const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (!targetChannel) { + throw new ApiError(meta.errors.noSuchChannel); + } + + // Check if already muting + const exist = await this.channelMutingService.isMuted({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + }); + if (exist) { + throw new ApiError(meta.errors.alreadyMuting); + } + + // Check if expiresAt is past + if (ps.expiresAt && ps.expiresAt <= Date.now()) { + throw new ApiError(meta.errors.expiresAtIsPast); + } + + await this.channelMutingService.mute({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/mute/delete.ts b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts new file mode 100644 index 0000000000..79abeebe99 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts @@ -0,0 +1,73 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { ChannelsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ApiError } from '@/server/api/error.js'; + +export const meta = { + tags: ['channels', 'mute'], + + requireCredential: true, + prohibitMoved: true, + + kind: 'write:channels', + + errors: { + noSuchChannel: { + message: 'No such Channel.', + code: 'NO_SUCH_CHANNEL', + id: 'e7998769-6e94-d9c2-6b8f-94a527314aba', + }, + + notMuting: { + message: 'You are not muting that channel.', + code: 'NOT_MUTING_CHANNEL', + id: '14d55962-6ea8-d990-1333-d6bef78dc2ab', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + channelId: { type: 'string', format: 'misskey:id' }, + }, + required: ['channelId'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.channelsRepository) + private channelsRepository: ChannelsRepository, + private channelMutingService: ChannelMutingService, + ) { + super(meta, paramDef, async (ps, me) => { + // Check if exists the channel + const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId }); + if (!targetChannel) { + throw new ApiError(meta.errors.noSuchChannel); + } + + // Check muting + const exist = await this.channelMutingService.isMuted({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + }); + if (!exist) { + throw new ApiError(meta.errors.notMuting); + } + + await this.channelMutingService.unmute({ + requestUserId: me.id, + targetChannelId: targetChannel.id, + }); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/mute/list.ts b/packages/backend/src/server/api/endpoints/channels/mute/list.ts new file mode 100644 index 0000000000..74338eea38 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/channels/mute/list.ts @@ -0,0 +1,49 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelEntityService } from '@/core/entities/ChannelEntityService.js'; + +export const meta = { + tags: ['channels', 'mute'], + + requireCredential: true, + prohibitMoved: true, + + kind: 'read:channels', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Channel', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private channelMutingService: ChannelMutingService, + private channelEntityService: ChannelEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const mutings = await this.channelMutingService.list({ + requestUserId: me.id, + }); + return await this.channelEntityService.packMany(mutings, me); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index d4fd75e049..b818282341 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -13,6 +13,8 @@ import { DI } from '@/di-symbols.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { MiLocalUser } from '@/models/User.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { isChannelRelated } from '@/misc/is-channel-related.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -70,6 +72,7 @@ export default class extends Endpoint { // eslint- private queryService: QueryService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private activeUsersChart: ActiveUsersChart, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -89,6 +92,9 @@ export default class extends Endpoint { // eslint- return await this.noteEntityService.packMany(await this.getFromDb({ untilId, sinceId, limit: ps.limit, channelId: channel.id }, me), me); } + const mutingChannelIds = me + ? await this.channelMutingService.mutingChannelsCache.get(me.id) ?? new Set() + : new Set(); return await this.fanoutTimelineEndpointService.timeline({ untilId, sinceId, @@ -98,6 +104,12 @@ export default class extends Endpoint { // eslint- useDbFallback: true, redisTimelines: [`channelTimeline:${channel.id}`], excludePureRenotes: false, + includeMutedChannels: true, + noteFilter: note => { + // 共通機能を使うと見ているチャンネルそのものもミュートしてしまうので閲覧中のチャンネル以外を除く形にする + if (note.channelId === channel.id && (note.renoteChannelId === null || note.renoteChannelId === channel.id)) return true; + return !isChannelRelated(note, mutingChannelIds); + }, dbFallback: async (untilId, sinceId, limit) => { return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); }, @@ -122,6 +134,14 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('note.channel', 'channel'); if (me) { + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => x !== ps.channelId)); + if (mutingChannelIds.length > 0) { + query.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + query.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + } + this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); } 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 aed9065bf9..d514f5bb43 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -18,6 +18,8 @@ import { QueryService } from '@/core/QueryService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -46,7 +48,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; @@ -79,9 +81,6 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private noteEntityService: NoteEntityService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, @@ -89,6 +88,8 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private queryService: QueryService, private userFollowingService: UserFollowingService, + private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { super(meta, paramDef, async (ps, me) => { @@ -196,11 +197,13 @@ export default class extends Endpoint { // eslint- withReplies: boolean, }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); + + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + const followingChannelIds = await this.channelFollowingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x))); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { @@ -219,9 +222,7 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (followingChannels.length > 0) { - const followingChannelIds = followingChannels.map(x => x.followeeId); - + if (followingChannelIds.length > 0) { query.andWhere(new Brackets(qb => { qb.where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); qb.orWhere('note.channelId IS NULL'); @@ -230,6 +231,13 @@ export default class extends Endpoint { // eslint- query.andWhere('note.channelId IS NULL'); } + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + if (!ps.withReplies) { query.andWhere(new Brackets(qb => { 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 0b48f2c78b..9c4812cbbf 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -15,6 +15,7 @@ import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -76,6 +77,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -156,9 +158,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); this.queryService.generateVisibilityQuery(query, me); - if (me) this.queryService.generateMutedUserQuery(query, me); - if (me) this.queryService.generateBlockedUserQuery(query, me); - if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + if (me) { + this.queryService.generateMutedUserQuery(query, me); + this.queryService.generateBlockedUserQuery(query, me); + this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + + const mutedChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutedChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL') + .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds }); + })); + } + } 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 7cb11cc1eb..b745167ec8 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository, MiMeta } from '@/models/_.js'; +import type { NotesRepository, MiMeta } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { QueryService } from '@/core/QueryService.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; @@ -16,6 +16,8 @@ import { CacheService } from '@/core/CacheService.js'; import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; export const meta = { tags: ['notes'], @@ -61,15 +63,14 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, - private noteEntityService: NoteEntityService, private activeUsersChart: ActiveUsersChart, private idService: IdService, private cacheService: CacheService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, + private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private queryService: QueryService, ) { super(meta, paramDef, async (ps, me) => { @@ -140,11 +141,13 @@ export default class extends Endpoint { // eslint- private async getFromDb(ps: { untilId: string | null; sinceId: string | null; limit: number; includeMyRenotes: boolean; includeRenotedMyNotes: boolean; includeLocalRenotes: boolean; withFiles: boolean; withRenotes: boolean; }, me: MiLocalUser) { const followees = await this.userFollowingService.getFollowees(me.id); - const followingChannels = await this.channelFollowingsRepository.find({ - where: { - followerId: me.id, - }, - }); + + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + const followingChannelIds = await this.channelFollowingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id).filter(x => !mutingChannelIds.includes(x))); //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -154,15 +157,14 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); - if (followees.length > 0 && followingChannels.length > 0) { + if (followees.length > 0 && followingChannelIds.length > 0) { // ユーザー・チャンネルともにフォローあり const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb .where(new Brackets(qb2 => { qb2 - .where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) + .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }) .andWhere('note.channelId IS NULL'); })) .orWhere('note.channelId IN (:...followingChannelIds)', { followingChannelIds }); @@ -170,22 +172,32 @@ export default class extends Endpoint { // eslint- } else if (followees.length > 0) { // ユーザーフォローのみ(チャンネルフォローなし) const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)]; - query - .andWhere('note.channelId IS NULL') - .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); - } else if (followingChannels.length > 0) { - // チャンネルフォローのみ(ユーザーフォローなし) - const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb + .andWhere('note.channelId IS NULL') + .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + if (mutingChannelIds.length > 0) { + qb.andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + } + })); + } else if (followingChannelIds.length > 0) { + // チャンネルフォローのみ(ユーザーフォローなし) + query.andWhere(new Brackets(qb => { + qb + // renoteChannelIdは見る必要が無い + // ・HTLに流れてくるチャンネル=フォローしているチャンネル + // ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ + // つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い .where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }) .orWhere('note.userId = :meId', { meId: me.id }); })); } else { // フォローなし - query - .andWhere('note.channelId IS NULL') - .andWhere('note.userId = :meId', { meId: me.id }); + query.andWhere(new Brackets(qb => { + qb + .andWhere('note.channelId IS NULL') + .andWhere('note.userId = :meId', { meId: me.id }); + })); } 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 87f9b322a6..d1bd27cf40 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 @@ -14,6 +14,7 @@ import { IdService } from '@/core/IdService.js'; import { QueryService } from '@/core/QueryService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -84,6 +85,7 @@ export default class extends Endpoint { // eslint- private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -188,6 +190,17 @@ export default class extends Endpoint { // eslint- this.queryService.generateBlockedUserQuery(query, me); this.queryService.generateMutedUserRenotesQueryForNotes(query, me); + // -- ミュートされたチャンネルのリノート対策 + const mutedChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutedChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL') + .orWhere('note.renoteChannelId NOT IN (:...mutedChannelIds)', { mutedChannelIds }); + })); + } + if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { qb.orWhere('note.userId != :meId', { meId: me.id }); diff --git a/packages/backend/src/server/api/endpoints/roles/notes.ts b/packages/backend/src/server/api/endpoints/roles/notes.ts index 71f2782a5d..b8a32ba71c 100644 --- a/packages/backend/src/server/api/endpoints/roles/notes.ts +++ b/packages/backend/src/server/api/endpoints/roles/notes.ts @@ -5,6 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import * as Redis from 'ioredis'; +import { Brackets } from 'typeorm'; import { Endpoint } from '@/server/api/endpoint-base.js'; import type { NotesRepository, RolesRepository } from '@/models/_.js'; import { QueryService } from '@/core/QueryService.js'; @@ -12,6 +13,7 @@ import { DI } from '@/di-symbols.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { IdService } from '@/core/IdService.js'; import { FanoutTimelineService } from '@/core/FanoutTimelineService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; export const meta = { @@ -68,6 +70,7 @@ export default class extends Endpoint { // eslint- private noteEntityService: NoteEntityService, private queryService: QueryService, private fanoutTimelineService: FanoutTimelineService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -101,6 +104,21 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('reply.user', 'replyUser') .leftJoinAndSelect('renote.user', 'renoteUser'); + // -- ミュートされたチャンネル対策 + const mutingChannelIds = await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)); + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.channelId IS NULL'); + qb.orWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); this.queryService.generateMutedUserQuery(query, me); this.queryService.generateBlockedUserQuery(query, me); diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index e9c334057e..72561dfa92 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -16,6 +16,7 @@ import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { FanoutTimelineName } from '@/core/FanoutTimelineService.js'; import { ApiError } from '@/server/api/error.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; export const meta = { tags: ['users', 'notes'], @@ -77,12 +78,12 @@ export default class extends Endpoint { // eslint- @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, private queryService: QueryService, private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -163,6 +164,11 @@ export default class extends Endpoint { // eslint- withFiles: boolean, withRenotes: boolean, }, me: MiLocalUser | null) { + const mutingChannelIds = me + ? await this.channelMutingService + .list({ requestUserId: me.id }, { idOnly: true }) + .then(x => x.map(x => x.id)) + : []; const isSelf = me && (me.id === ps.userId); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -175,14 +181,30 @@ export default class extends Endpoint { // eslint- .leftJoinAndSelect('renote.user', 'renoteUser'); if (ps.withChannelNotes) { - if (!isSelf) query.andWhere(new Brackets(qb => { - qb.orWhere('note.channelId IS NULL'); - qb.orWhere('channel.isSensitive = false'); + query.andWhere(new Brackets(qb => { + if (mutingChannelIds.length > 0) { + qb.andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds: mutingChannelIds }); + } + + if (!isSelf) { + qb.andWhere(new Brackets(qb2 => { + qb2.orWhere('note.channelId IS NULL'); + qb2.orWhere('channel.isSensitive = false'); + })); + } })); } else { query.andWhere('note.channelId IS NULL'); } + // -- ミュートされたチャンネルのリノート対策 + if (mutingChannelIds.length > 0) { + query.andWhere(new Brackets(qb => { + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + } + this.queryService.generateVisibilityQuery(query, me); if (me) { this.queryService.generateMutedUserQuery(query, me, { id: ps.userId }); diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 0fb5238c78..6c0e252742 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -12,8 +12,9 @@ import type { NotificationService } from '@/core/NotificationService.js'; import { bindThis } from '@/decorators.js'; import { CacheService } from '@/core/CacheService.js'; import { MiFollowing, MiUserProfile } from '@/models/_.js'; -import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js'; +import type { GlobalEvents, StreamEventEmitter } from '@/core/GlobalEventService.js'; import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { isJsonObject } from '@/misc/json-value.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import type { ChannelsService } from './ChannelsService.js'; @@ -37,6 +38,7 @@ export default class Connection { public userProfile: MiUserProfile | null = null; public following: Record | undefined> = {}; public followingChannels: Set = new Set(); + public mutingChannels: Set = new Set(); public userIdsWhoMeMuting: Set = new Set(); public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); @@ -49,7 +51,7 @@ export default class Connection { private notificationService: NotificationService, private cacheService: CacheService, private channelFollowingService: ChannelFollowingService, - + private channelMutingService: ChannelMutingService, user: MiUser | null | undefined, token: MiAccessToken | null | undefined, ) { @@ -60,10 +62,19 @@ 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, + mutingChannels, + userIdsWhoMeMuting, + userIdsWhoBlockingMe, + userIdsWhoMeMutingRenotes, + ] = 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.channelMutingService.mutingChannelsCache.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), @@ -71,6 +82,7 @@ export default class Connection { this.userProfile = userProfile; this.following = following; this.followingChannels = followingChannels; + this.mutingChannels = mutingChannels; this.userIdsWhoMeMuting = userIdsWhoMeMuting; this.userIdsWhoBlockingMe = userIdsWhoBlockingMe; this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes; diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index 84cb552369..75cfab9240 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -6,7 +6,8 @@ import { bindThis } from '@/decorators.js'; import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; -import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; +import { isChannelRelated } from '@/misc/is-channel-related.js'; import type { Packed } from '@/misc/json-schema.js'; import type { JsonObject, JsonValue } from '@/misc/json-value.js'; import type Connection from './Connection.js'; @@ -55,6 +56,10 @@ export default abstract class Channel { return this.connection.followingChannels; } + protected get mutingChannels() { + return this.connection.mutingChannels; + } + protected get subscriber() { return this.connection.subscriber; } @@ -74,6 +79,9 @@ export default abstract class Channel { // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + // 流れてきたNoteがミュートしているチャンネルと関わる + if (isChannelRelated(note, this.mutingChannels)) return true; + return false; } diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 7108e0cd6e..32fa28a189 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -8,6 +8,8 @@ import type { Packed } from '@/misc/json-schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; @@ -19,7 +21,6 @@ class ChannelChannel extends Channel { constructor( private noteEntityService: NoteEntityService, - id: string, connection: Channel['connection'], ) { @@ -54,6 +55,35 @@ class ChannelChannel extends Channel { this.send('note', note); } + /* + * ミュートとブロックされてるを処理する + */ + protected override isNoteMutedOrBlocked(note: Packed<'Note'>): boolean { + // 流れてきたNoteがインスタンスミュートしたインスタンスが関わる + if (isInstanceMuted(note, new Set(this.userProfile?.mutedInstances ?? []))) return true; + + // 流れてきたNoteがミュートしているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoMeMuting)) return true; + // 流れてきたNoteがブロックされているユーザーが関わる + if (isUserRelated(note, this.userIdsWhoBlockingMe)) return true; + + // 流れてきたNoteがリノートをミュートしてるユーザが行ったもの + if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true; + + // このソケットで見ているチャンネルがミュートされていたとしても、チャンネルを直接見ている以上は流すようにしたい + // ただし、他のミュートしているチャンネルは流さないようにもしたい + // ノート自体のチャンネルIDはonNoteでチェックしているので、ここではリノートのチャンネルIDをチェックする + if ( + (note.renote) && + (note.renote.channelId !== this.channelId) && + (note.renote.channelId && this.mutingChannels.has(note.renote.channelId)) + ) { + return true; + } + + return false; + } + @bindThis public dispose() { // Unsubscribe events 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 66644ed58c..825f71180d 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -44,7 +44,10 @@ class HomeTimelineChannel extends Channel { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (note.channelId) { - if (!this.followingChannels.has(note.channelId)) return; + // そのチャンネルをフォローしていない + if (!this.followingChannels.has(note.channelId)) { + return; + } } else { // その投稿のユーザーをフォローしていなかったら弾く if (!isMe && !Object.hasOwn(this.following, note.userId)) return; 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 75bd13221f..836097ad36 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -53,16 +53,25 @@ class HybridTimelineChannel extends Channel { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; - // チャンネルの投稿ではなく、自分自身の投稿 または - // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または - // チャンネルの投稿ではなく、全体公開のローカルの投稿 または - // フォローしているチャンネルの投稿 の場合だけ - if (!( - (note.channelId == null && isMe) || - (note.channelId == null && Object.hasOwn(this.following, note.userId)) || - (note.channelId == null && (note.user.host == null && note.visibility === 'public')) || - (note.channelId != null && this.followingChannels.has(note.channelId)) - )) return; + if (!note.channelId) { + // 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする) + // - 自分自身の投稿 + // - その投稿のユーザーをフォローしている + // - 全体公開のローカルの投稿 + if (!( + isMe || + Object.hasOwn(this.following, note.userId) || + (note.user.host == null && note.visibility === 'public') + )) { + return; + } + } else { + // 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする) + // - フォローしているチャンネルの投稿 + if (!this.followingChannels.has(note.channelId)) { + return; + } + } if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; diff --git a/packages/backend/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index a544db955a..c888941f1c 100644 --- a/packages/backend/test/e2e/antennas.ts +++ b/packages/backend/test/e2e/antennas.ts @@ -69,6 +69,9 @@ describe('アンテナ', () => { let userMutingAlice: User; let userMutedByAlice: User; + let testChannel: misskey.entities.Channel; + let testMutedChannel: misskey.entities.Channel; + beforeAll(async () => { root = await signup({ username: 'root' }); alice = await signup({ username: 'alice' }); @@ -120,6 +123,10 @@ describe('アンテナ', () => { userMutedByAlice = await signup({ username: 'userMutedByAlice' }); await post(userMutedByAlice, { text: 'test' }); await api('mute/create', { userId: userMutedByAlice.id }, alice); + + testChannel = (await api('channels/create', { name: 'test' }, root)).body; + testMutedChannel = (await api('channels/create', { name: 'test-muted' }, root)).body; + await api('channels/mute/create', { channelId: testMutedChannel.id }, alice); }, 1000 * 60 * 10); beforeEach(async () => { @@ -593,6 +600,20 @@ describe('アンテナ', () => { { note: (): Promise => post(bob, { text: `${keyword}` }), included: true }, ], }, + { + label: 'チャンネルノートも含む', + parameters: () => ({ src: 'all' }), + posts: [ + { note: (): Promise => post(bob, { text: `test ${keyword}`, channelId: testChannel.id }), included: true }, + ], + }, + { + label: 'ミュートしてるチャンネルは含まない', + parameters: () => ({ src: 'all' }), + posts: [ + { note: (): Promise => post(bob, { text: `test ${keyword}`, channelId: testMutedChannel.id }) }, + ], + }, ])('が取得できること($label)', async ({ parameters, posts }) => { const antenna = await successfulApiCall({ endpoint: 'antennas/create', diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index d12be2a9ac..7cac8a1254 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -3,13 +3,17 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +/* eslint-disable @typescript-eslint/no-explicit-any */ + // How to run: // pnpm jest -- e2e/timelines.ts import * as assert from 'assert'; import { setTimeout } from 'node:timers/promises'; +import { entities } from 'misskey-js'; import { Redis } from 'ioredis'; -import { api, post, randomString, sendEnvUpdateRequest, signup, uploadUrl } from '../utils.js'; +import { afterEach, beforeAll } from '@jest/globals'; +import { api, initTestDb, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, UserToken } from '../utils.js'; import { loadConfig } from '@/config.js'; function genHost() { @@ -22,13 +26,78 @@ function waitForPushToTl() { let redisForTimelines: Redis; +async function renote(noteId: string, user: UserToken): Promise { + return await api('notes/create', { renoteId: noteId }, user).then(it => it.body.createdNote); +} + +async function createChannel(name: string, user: UserToken): Promise { + return (await api('channels/create', { name }, user)).body; +} + +async function followChannel(channelId: string, user: UserToken) { + return await api('channels/follow', { channelId }, user); +} + +async function muteChannel(channelId: string, user: UserToken) { + await api('channels/mute/create', { channelId }, user); +} + +async function createList(name: string, user: UserToken): Promise { + return (await api('users/lists/create', { name }, user)).body; +} + +async function pushList(listId: string, pushUserIds: string[] = [], user: UserToken) { + for (const userId of pushUserIds) { + await api('users/lists/push', { listId, userId }, user); + } + await setTimeout(500); +} + +async function createRole(name: string, user: UserToken): Promise { + return (await api('admin/roles/create', { + name, + description: '', + color: '#000000', + iconUrl: '', + target: 'manual', + condFormula: {}, + isPublic: true, + isModerator: false, + isAdministrator: false, + isExplorable: true, + asBadge: false, + canEditMembersByModerator: false, + displayOrder: 0, + policies: {}, + }, user)).body; +} + +async function assignRole(roleId: string, userId: string, user: UserToken) { + await api('admin/roles/assign', { userId, roleId }, user); +} + describe('Timelines', () => { - beforeAll(() => { + let root: UserToken; + + beforeAll(async () => { redisForTimelines = new Redis(loadConfig().redisForTimelines); + root = await signup({ username: 'root' }); + + // FTT無効の状態で見たいときはコメントアウトを外す + // await api('admin/update-meta', { enableFanoutTimeline: false }, root); + // await setTimeout(1000); + }); + + afterEach(async () => { + // テスト中に作ったノートをきれいにする。 + // ユーザも作っているが、時間差で動く通知系処理などがあり、このタイミングで消すとエラー落ちするので消さない(ノートさえ消えていれば支障はない) + const db = await initTestDb(true); + await db.query('DELETE FROM "note"'); + await db.query('DELETE FROM "channel"'); }); describe('Home TL', () => { - test.concurrent('自分の visibility: followers なノートが含まれる', async () => { + test('自分の visibility: followers なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); @@ -41,7 +110,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); - test.concurrent('フォローしているユーザーのノートが含まれる', async () => { + test('フォローしているユーザーのノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -57,7 +126,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { + test('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -74,7 +143,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { + test('withReplies: false でフォローしているユーザーの他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -90,7 +159,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { + test('withReplies: true でフォローしているユーザーの他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -107,14 +176,19 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーの他人へのDM返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); await api('following/update', { userId: bob.id, withReplies: true }, alice); await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); + const bobNote = await post(bob, { + text: 'hi', + replyId: carolNote.id, + visibility: 'specified', + visibleUserIds: [carolNote.id], + }); await waitForPushToTl(); @@ -124,7 +198,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: carol.id }, bob); @@ -142,7 +216,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -162,7 +236,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === carolNote.id)?.text, 'hi'); }); - test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -180,7 +254,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); }); - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの投稿への visibility: specified な返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -188,7 +262,12 @@ describe('Timelines', () => { await api('following/update', { userId: bob.id, withReplies: true }, alice); await setTimeout(1000); const carolNote = await post(carol, { text: 'hi' }); - const bobNote = await post(bob, { text: 'hi', replyId: carolNote.id, visibility: 'specified', visibleUserIds: [carolNote.id] }); + const bobNote = await post(bob, { + text: 'hi', + replyId: carolNote.id, + visibility: 'specified', + visibleUserIds: [carolNote.id], + }); await waitForPushToTl(); @@ -198,7 +277,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); - test.concurrent('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーのそのユーザー自身への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -214,7 +293,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -230,7 +309,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('自分の他人への返信が含まれる', async () => { + test('自分の他人への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -244,7 +323,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); }); - test.concurrent('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { + test('フォローしているユーザーの他人の投稿のリノートが含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -260,7 +339,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { + test('[withRenotes: false] フォローしているユーザーの他人の投稿のリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -278,7 +357,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { + test('[withRenotes: false] フォローしているユーザーの他人の投稿の引用が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -296,7 +375,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { + test('フォローしているユーザーの他人への visibility: specified なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -310,7 +389,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + test('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -327,7 +406,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -345,7 +424,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { + test('フォローしているリモートユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); @@ -360,7 +439,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + test('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); @@ -375,7 +454,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { + test('[withFiles: true] フォローしているユーザーのファイル付きノートのみ含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -399,7 +478,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote2.id), false); }, 1000 * 10); - test.concurrent('フォローしているユーザーのチャンネル投稿が含まれない', async () => { + test('フォローしているユーザーのチャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -414,7 +493,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('自分の visibility: specified なノートが含まれる', async () => { + test('自分の visibility: specified なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); @@ -427,7 +506,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { + test('フォローしているユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -442,7 +521,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); - test.concurrent('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { + test('フォローしていないユーザーの自身を visibleUserIds に指定した visibility: specified なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); @@ -454,7 +533,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { + test('フォローしているユーザーの自身を visibleUserIds に指定していない visibility: specified なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -468,11 +547,16 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { + test('フォローしていないユーザーからの visibility: specified なノートに返信したときの自身のノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'specified', visibleUserIds: [alice.id] }); - const aliceNote = await post(alice, { text: 'ok', visibility: 'specified', visibleUserIds: [bob.id], replyId: bobNote.id }); + const aliceNote = await post(alice, { + text: 'ok', + visibility: 'specified', + visibleUserIds: [bob.id], + replyId: bobNote.id, + }); await waitForPushToTl(); @@ -483,7 +567,7 @@ describe('Timelines', () => { }); /* TODO - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { + test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); @@ -499,20 +583,299 @@ describe('Timelines', () => { */ // ↑の挙動が理想だけど実装が面倒かも - test.concurrent('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => { + test('自身の visibility: specified なノートへのフォローしていないユーザーからの返信が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified', visibleUserIds: [bob.id] }); - const bobNote = await post(bob, { text: 'ok', visibility: 'specified', visibleUserIds: [alice.id], replyId: aliceNote.id }); + const bobNote = await post(bob, { + text: 'ok', + visibility: 'specified', + visibleUserIds: [alice.id], + replyId: aliceNote.id, + }); await waitForPushToTl(); const res = await api('notes/timeline', { limit: 100 }, alice); - assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { + describe('Channel', () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); + + test('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { @@ -534,7 +897,7 @@ describe('Timelines', () => { assert.strictEqual(bobHTL.includes(carolNote.id), false); }); - test.concurrent('FTT: リモートユーザーの HTL にはプッシュされない', async () => { + test('FTT: リモートユーザーの HTL にはプッシュされない', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); await api('following/create', { @@ -552,7 +915,7 @@ describe('Timelines', () => { }); describe('Local TL', () => { - test.concurrent('visibility: home なノートが含まれない', async () => { + test('visibility: home なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi', visibility: 'home' }); @@ -566,7 +929,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('他人の他人への返信が含まれない', async () => { + test('他人の他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -580,7 +943,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); - test.concurrent('他人のその人自身への返信が含まれる', async () => { + test('他人のその人自身への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote1 = await post(bob, { text: 'hi' }); @@ -594,7 +957,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); - test.concurrent('チャンネル投稿が含まれない', async () => { + test('チャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -607,7 +970,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('リモートユーザーのノートが含まれない', async () => { + test('リモートユーザーのノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); const bobNote = await post(bob, { text: 'hi' }); @@ -620,7 +983,7 @@ describe('Timelines', () => { }); // 含まれても良いと思うけど実装が面倒なので含まれない - test.concurrent('フォローしているユーザーの visibility: home なノートが含まれない', async () => { + test('フォローしているユーザーの visibility: home なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: carol.id }, alice); @@ -636,7 +999,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('ミュートしているユーザーのノートが含まれない', async () => { + test('ミュートしているユーザーのノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('mute/create', { userId: carol.id }, alice); @@ -652,7 +1015,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { + test('フォローしているユーザーが行ったミュートしているユーザーのリノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -669,7 +1032,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーが行ったミュートしているユーザーの投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -687,7 +1050,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), false); }); - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -703,7 +1066,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await setTimeout(1000); @@ -718,7 +1081,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { + test('[withReplies: true] 他人の他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -731,7 +1094,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + test('[withFiles: true] ファイル付きノートのみ含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); @@ -745,10 +1108,284 @@ 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); + + describe('Channel', () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/local-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); }); describe('Social TL', () => { - test.concurrent('ローカルユーザーのノートが含まれる', async () => { + test('ローカルユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -760,7 +1397,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('ローカルユーザーの visibility: home なノートが含まれない', async () => { + test('ローカルユーザーの visibility: home なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'home' }); @@ -772,7 +1409,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { + test('フォローしているローカルユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -786,7 +1423,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしているユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -802,7 +1439,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { + test('withReplies: true でフォローしているユーザーの他人の visibility: followers な投稿への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: carol.id }, bob); @@ -820,7 +1457,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); }); - test.concurrent('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { + test('withReplies: true でフォローしているユーザーの行った別のフォローしているユーザーの visibility: followers な投稿への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -840,7 +1477,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id)?.text, 'hi'); }); - test.concurrent('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { + test('withReplies: true でフォローしているユーザーの自分の visibility: followers な投稿への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -858,7 +1495,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); }); - test.concurrent('他人の他人への返信が含まれない', async () => { + test('他人の他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -872,7 +1509,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === carolNote.id), true); }); - test.concurrent('リモートユーザーのノートが含まれない', async () => { + test('リモートユーザーのノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); const bobNote = await post(bob, { text: 'hi' }); @@ -884,7 +1521,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('フォローしているリモートユーザーのノートが含まれる', async () => { + test('フォローしているリモートユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); @@ -899,7 +1536,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { + test('フォローしているリモートユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup({ host: genHost() })]); await sendEnvUpdateRequest({ key: 'FORCE_FOLLOW_REMOTE_USER_FOR_TESTING', value: 'true' }); @@ -914,7 +1551,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でフォローしていないユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await setTimeout(1000); @@ -929,7 +1566,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('[withReplies: true] 他人の他人への返信が含まれる', async () => { + test('[withReplies: true] 他人の他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -942,7 +1579,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + test('[withFiles: true] ファイル付きノートのみ含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); @@ -956,10 +1593,284 @@ 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); + + describe('Channel', () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + await api('following/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/hybrid-timeline', { limit: 100 }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); }); describe('User List TL', () => { - test.concurrent('リスインしているフォローしていないユーザーのノートが含まれる', async () => { + test('リスインしているフォローしていないユーザーのノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -974,7 +1885,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { + test('リスインしているフォローしていないユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -989,7 +1900,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { + test('リスインしているフォローしていないユーザーの visibility: followers なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1004,7 +1915,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + test('リスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1020,7 +1931,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { + test('リスインしているフォローしていないユーザーのユーザー自身への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1037,7 +1948,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); - test.concurrent('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { + test('withReplies: false でリスインしているフォローしていないユーザーからの自分への返信が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1054,7 +1965,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { + test('withReplies: false でリスインしているフォローしていないユーザーの他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1071,7 +1982,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { + test('withReplies: true でリスインしているフォローしていないユーザーの他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1088,7 +1999,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { + test('リスインしているフォローしているユーザーの visibility: home なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1104,7 +2015,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { + test('リスインしているフォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1121,7 +2032,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); - test.concurrent('リスインしている自分の visibility: followers なノートが含まれる', async () => { + test('リスインしている自分の visibility: followers なノートが含まれる', async () => { const [alice] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1137,7 +2048,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); - test.concurrent('リスインしているユーザーのチャンネルノートが含まれない', async () => { + test('リスインしているユーザーのチャンネルノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -1153,7 +2064,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { + test('[withFiles: true] リスインしているユーザーのファイル付きノートのみ含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1170,7 +2081,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { + test('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1186,7 +2097,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); - test.concurrent('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { + test('リスインしているユーザーの自身宛てではない visibility: specified なノートが含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body); @@ -1201,10 +2112,316 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); + + describe('Channel', () => { + test('チャンネル未フォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてる = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてる = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネル未フォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('チャンネルフォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてない = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてる = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてる = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてない + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネル未フォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + + test('[チャンネル外リノート] チャンネルフォロー + リスインしてる + チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const list = await createList('list', alice); + await pushList(list.id, [bob.id], alice); + + const channel = await createChannel('channel', bob); + await followChannel(channel.id, alice); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('notes/user-list-timeline', { limit: 100, listId: list.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); }); describe('User TL', () => { - test.concurrent('ノートが含まれる', async () => { + test('ノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi' }); @@ -1216,7 +2433,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { + test('フォローしていないユーザーの visibility: followers なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'followers' }); @@ -1228,7 +2445,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { + test('フォローしているユーザーの visibility: followers なノートが含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1243,7 +2460,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === bobNote.id)?.text, 'hi'); }); - test.concurrent('自身の visibility: followers なノートが含まれる', async () => { + test('自身の visibility: followers なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'followers' }); @@ -1256,7 +2473,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find(note => note.id === aliceNote.id)?.text, 'hi'); }); - test.concurrent('チャンネル投稿が含まれない', async () => { + test('チャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -1269,7 +2486,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('[withReplies: false] 他人への返信が含まれない', async () => { + test('[withReplies: false] 他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1284,7 +2501,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); }); - test.concurrent('[withReplies: true] 他人への返信が含まれる', async () => { + test('[withReplies: true] 他人への返信が含まれる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1299,7 +2516,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }); - test.concurrent('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { + test('[withReplies: true] 他人への visibility: specified な返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1314,7 +2531,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), false); }); - test.concurrent('[withFiles: true] ファイル付きノートのみ含まれる', async () => { + test('[withFiles: true] ファイル付きノートのみ含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const file = await uploadUrl(bob, 'https://raw.githubusercontent.com/misskey-dev/assets/main/public/icon.png'); @@ -1329,7 +2546,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true); }, 1000 * 10); - test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { + test('[withChannelNotes: true] チャンネル投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel' }, bob).then(x => x.body); @@ -1342,7 +2559,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { + test('[withChannelNotes: true] 他人が取得した場合センシティブチャンネル投稿が含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await api('channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); @@ -1355,7 +2572,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { + test('[withChannelNotes: true] 自分が取得した場合センシティブチャンネル投稿が含まれる', async () => { const [bob] = await Promise.all([signup()]); const channel = await api('channels/create', { name: 'channel', isSensitive: true }, bob).then(x => x.body); @@ -1368,7 +2585,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), true); }); - test.concurrent('ミュートしているユーザーに関連する投稿が含まれない', async () => { + test('ミュートしているユーザーに関連する投稿が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('mute/create', { userId: carol.id }, alice); @@ -1383,7 +2600,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote.id), false); }); - test.concurrent('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { + test('ミュートしていても userId に指定したユーザーの投稿が含まれる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('mute/create', { userId: bob.id }, alice); @@ -1401,7 +2618,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === bobNote3.id), true); }); - test.concurrent('自身の visibility: specified なノートが含まれる', async () => { + test('自身の visibility: specified なノートが含まれる', async () => { const [alice] = await Promise.all([signup()]); const aliceNote = await post(alice, { text: 'hi', visibility: 'specified' }); @@ -1413,7 +2630,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some(note => note.id === aliceNote.id), true); }); - test.concurrent('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { + test('visibleUserIds に指定されてない visibility: specified なノートが含まれない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const bobNote = await post(bob, { text: 'hi', visibility: 'specified' }); @@ -1426,7 +2643,7 @@ describe('Timelines', () => { }); /** @see https://github.com/misskey-dev/misskey/issues/14000 */ - test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId による絞り込みが正しく動作する', async () => { + test('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId による絞り込みが正しく動作する', async () => { const alice = await signup(); const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); const note1 = await post(alice, { text: '1' }); @@ -1438,7 +2655,7 @@ describe('Timelines', () => { assert.deepStrictEqual(res.body, [note1, note2, note3]); }); - test.concurrent('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId と untilId による絞り込みが正しく動作する', async () => { + test('FTT: sinceId にキャッシュより古いノートを指定しても、sinceId と untilId による絞り込みが正しく動作する', async () => { const alice = await signup(); const noteSince = await post(alice, { text: 'Note where id will be `sinceId`.' }); const note1 = await post(alice, { text: '1' }); @@ -1451,8 +2668,458 @@ describe('Timelines', () => { const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id }); assert.deepStrictEqual(res.body, [note3, note2, note1]); }); + + describe('Channel', () => { + test('チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('users/notes', { userId: bob.id, withChannelNotes: true }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); }); + describe('Role TL', () => { + test('ロールにアサインされているユーザーのノートが含まれる', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + await assignRole(role.id, carol.id, root); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), true); + }); + + test('ロールにアサインされていないユーザーのノートは含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test('自分の他人への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const bobNote = await post(bob, { text: 'hi' }); + const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + + test('他人の自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'hi', replyId: aliceNote.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + + test('ミュートしているユーザのノートは含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + await assignRole(role.id, carol.id, root); + + await api('mute/create', { userId: carol.id }, alice); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + test('こちらをブロックしているユーザのノートは含まれない', async () => { + const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + await assignRole(role.id, carol.id, root); + + await api('blocking/create', { userId: alice.id }, carol); + + const bobNote = await post(bob, { text: 'hi' }); + const carolNote = await post(carol, { text: 'hi' }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + assert.strictEqual(res.body.some((note: any) => note.id === carolNote.id), false); + }); + + describe('Channel', () => { + test('チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('[チャンネル外リノート] チャンネルミュートなし = TLに流れる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('[チャンネル外リノート] チャンネルミュート = TLに流れない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const role = await createRole('role', root); + await assignRole(role.id, alice.id, root); + await assignRole(role.id, bob.id, root); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await renote(bobNote.id, bob); + + await waitForPushToTl(); + + const res = await api('roles/notes', { roleId: role.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); + }); + + describe('Channel TL', () => { + test('閲覧中チャンネルのノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('閲覧中チャンネルとは別チャンネルのノートは含まれない', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + const channel2 = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel2.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('閲覧中チャンネルのノートにリノートが含まれる', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('閲覧中チャンネルとは別チャンネルからのリノートが含まれる', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + const channel2 = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel2.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('閲覧中チャンネルに自分の他人への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const aliceNote = await post(alice, { text: 'hi', replyId: bobNote.id, channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), true); + }); + + test('閲覧中チャンネルに他人の自分への返信が含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi', channelId: channel.id }); + const bobNote = await post(bob, { text: 'ok', replyId: aliceNote.id, channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('閲覧中チャンネルにミュートしているユーザのノートは含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('mute/create', { userId: bob.id }, alice); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('閲覧中チャンネルにこちらをブロックしているユーザのノートは含まれない', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + await api('blocking/create', { userId: alice.id }, bob); + + const channel = await createChannel('channel', bob); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); + }); + + test('閲覧中チャンネルをミュートしていてもノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === aliceNote.id), false); + assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); + }); + + test('閲覧中チャンネルをミュートしていてもリノートが含まれる', async () => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + await muteChannel(channel.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), true); + }); + + test('閲覧中チャンネルとは別チャンネルをミュートしているとき、そのチャンネルからのリノートは含まれない', async() => { + const [alice, bob] = await Promise.all([signup(), signup()]); + + const channel = await createChannel('channel', bob); + const channel2 = await createChannel('channel', bob); + await muteChannel(channel2.id, alice); + + const aliceNote = await post(alice, { text: 'hi' }); + const bobNote = await post(bob, { text: 'ok', channelId: channel2.id }); + const bobRenote = await post(bob, { channelId: channel.id, renoteId: bobNote.id }); + + await waitForPushToTl(); + + const res = await api('channels/timeline', { channelId: channel.id }, alice); + + assert.strictEqual(res.body.some((note: any) => note.id === bobRenote.id), false); + }); + }); // TODO: リノートミュート済みユーザーのテスト // TODO: ページネーションのテスト }); diff --git a/packages/backend/test/jest.setup.unit.cjs b/packages/backend/test/jest.setup.unit.cjs new file mode 100644 index 0000000000..dd879c81c8 --- /dev/null +++ b/packages/backend/test/jest.setup.unit.cjs @@ -0,0 +1,10 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +module.exports = async () => { + // DBはUTC(っぽい)ので、テスト側も合わせておく + process.env.TZ = 'UTC'; + process.env.NODE_ENV = 'test'; +}; diff --git a/packages/backend/test/unit/ChannelFollowingService.ts b/packages/backend/test/unit/ChannelFollowingService.ts new file mode 100644 index 0000000000..9ca4fe0842 --- /dev/null +++ b/packages/backend/test/unit/ChannelFollowingService.ts @@ -0,0 +1,235 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable */ + +import { afterEach, beforeEach, describe, expect } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { + type ChannelFollowingsRepository, + ChannelsRepository, + DriveFilesRepository, + MiChannel, + MiChannelFollowing, + MiDriveFile, + MiUser, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { ChannelFollowingService } from "@/core/ChannelFollowingService.js"; +import { MiLocalUser } from "@/models/User.js"; + +describe('ChannelFollowingService', () => { + let app: TestingModule; + let service: ChannelFollowingService; + let channelsRepository: ChannelsRepository; + let channelFollowingsRepository: ChannelFollowingsRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let driveFilesRepository: DriveFilesRepository; + let idService: IdService; + + let alice: MiLocalUser; + let bob: MiLocalUser; + let channel1: MiChannel; + let channel2: MiChannel; + let channel3: MiChannel; + let driveFile1: MiDriveFile; + let driveFile2: MiDriveFile; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + username: 'username', + usernameLower: 'username', + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createChannel(data: Partial = {}) { + return await channelsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createChannelFollowing(data: Partial = {}) { + return await channelFollowingsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelFollowingsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function fetchChannelFollowing() { + return await channelFollowingsRepository.findBy({}); + } + + async function createDriveFile(data: Partial = {}) { + return await driveFilesRepository + .insert({ + id: idService.gen(), + md5: 'md5', + name: 'name', + size: 0, + type: 'type', + storedInternal: false, + url: 'url', + ...data, + }) + .then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + GlobalEventService, + IdService, + ChannelFollowingService, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(ChannelFollowingService); + idService = app.get(IdService); + channelsRepository = app.get(DI.channelsRepository); + channelFollowingsRepository = app.get(DI.channelFollowingsRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = { ...await createUser({ username: 'alice' }), host: null, uri: null }; + bob = { ...await createUser({ username: 'bob' }), host: null, uri: null }; + driveFile1 = await createDriveFile(); + driveFile2 = await createDriveFile(); + channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id }); + channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id }); + channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id }); + }); + + afterEach(async () => { + await channelFollowingsRepository.delete({}); + await channelsRepository.delete({}); + await userProfilesRepository.delete({}); + await usersRepository.delete({}); + }); + + describe('list', () => { + test('default', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].userId).toBe(alice.id); + expect(followings[0].user).toBeFalsy(); + expect(followings[0].bannerId).toBe(driveFile1.id); + expect(followings[0].banner).toBeFalsy(); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].userId).toBe(alice.id); + expect(followings[1].user).toBeFalsy(); + expect(followings[1].bannerId).toBe(driveFile2.id); + expect(followings[1].banner).toBeFalsy(); + }); + + test('idOnly', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[1].id).toBe(channel2.id); + }); + + test('joinUser', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { joinUser: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].user).toEqual(alice); + expect(followings[0].banner).toBeFalsy(); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].user).toEqual(alice); + expect(followings[1].banner).toBeFalsy(); + }); + + test('joinBannerFile', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + await createChannelFollowing({ followerId: alice.id, followeeId: channel2.id }); + await createChannelFollowing({ followerId: bob.id, followeeId: channel3.id }); + + const followings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true }); + + expect(followings).toHaveLength(2); + expect(followings[0].id).toBe(channel1.id); + expect(followings[0].user).toBeFalsy(); + expect(followings[0].banner).toEqual(driveFile1); + expect(followings[1].id).toBe(channel2.id); + expect(followings[1].user).toBeFalsy(); + expect(followings[1].banner).toEqual(driveFile2); + }); + }); + + describe('follow', () => { + test('default', async () => { + await service.follow(alice, channel1); + + const followings = await fetchChannelFollowing(); + + expect(followings).toHaveLength(1); + expect(followings[0].followeeId).toBe(channel1.id); + expect(followings[0].followerId).toBe(alice.id); + }); + }); + + describe('unfollow', () => { + test('default', async () => { + await createChannelFollowing({ followerId: alice.id, followeeId: channel1.id }); + + await service.unfollow(alice, channel1); + + const followings = await fetchChannelFollowing(); + + expect(followings).toHaveLength(0); + }); + }); +}); diff --git a/packages/backend/test/unit/ChannelMutingService.ts b/packages/backend/test/unit/ChannelMutingService.ts new file mode 100644 index 0000000000..462f63f9c4 --- /dev/null +++ b/packages/backend/test/unit/ChannelMutingService.ts @@ -0,0 +1,336 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable */ + +import { afterEach, beforeEach, describe, expect } from '@jest/globals'; +import { Test, TestingModule } from '@nestjs/testing'; +import { GlobalModule } from '@/GlobalModule.js'; +import { CoreModule } from '@/core/CoreModule.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { IdService } from '@/core/IdService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { + ChannelMutingRepository, + ChannelsRepository, + DriveFilesRepository, + MiChannel, + MiChannelMuting, + MiDriveFile, + MiUser, + UserProfilesRepository, + UsersRepository, +} from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { setTimeout } from 'node:timers/promises'; + +describe('ChannelMutingService', () => { + let app: TestingModule; + let service: ChannelMutingService; + let channelsRepository: ChannelsRepository; + let channelMutingRepository: ChannelMutingRepository; + let usersRepository: UsersRepository; + let userProfilesRepository: UserProfilesRepository; + let driveFilesRepository: DriveFilesRepository; + let idService: IdService; + + let alice: MiUser; + let bob: MiUser; + let channel1: MiChannel; + let channel2: MiChannel; + let channel3: MiChannel; + let driveFile1: MiDriveFile; + let driveFile2: MiDriveFile; + + async function createUser(data: Partial = {}) { + const user = await usersRepository + .insert({ + id: idService.gen(), + username: 'username', + usernameLower: 'username', + ...data, + }) + .then(x => usersRepository.findOneByOrFail(x.identifiers[0])); + + await userProfilesRepository.insert({ + userId: user.id, + }); + + return user; + } + + async function createChannel(data: Partial = {}) { + return await channelsRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelsRepository.findOneByOrFail(x.identifiers[0])); + } + + async function createChannelMuting(data: Partial = {}) { + return await channelMutingRepository + .insert({ + id: idService.gen(), + ...data, + }) + .then(x => channelMutingRepository.findOneByOrFail(x.identifiers[0])); + } + + async function fetchChannelMuting() { + return await channelMutingRepository.findBy({}); + } + + async function createDriveFile(data: Partial = {}) { + return await driveFilesRepository + .insert({ + id: idService.gen(), + md5: 'md5', + name: 'name', + size: 0, + type: 'type', + storedInternal: false, + url: 'url', + ...data, + }) + .then(x => driveFilesRepository.findOneByOrFail(x.identifiers[0])); + } + + beforeAll(async () => { + app = await Test.createTestingModule({ + imports: [ + GlobalModule, + CoreModule, + ], + providers: [ + GlobalEventService, + IdService, + ChannelMutingService, + ], + }).compile(); + + app.enableShutdownHooks(); + + service = app.get(ChannelMutingService); + idService = app.get(IdService); + channelsRepository = app.get(DI.channelsRepository); + channelMutingRepository = app.get(DI.channelMutingRepository); + usersRepository = app.get(DI.usersRepository); + userProfilesRepository = app.get(DI.userProfilesRepository); + driveFilesRepository = app.get(DI.driveFilesRepository); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + alice = await createUser({ username: 'alice' }); + bob = await createUser({ username: 'bob' }); + driveFile1 = await createDriveFile(); + driveFile2 = await createDriveFile(); + channel1 = await createChannel({ name: 'channel1', userId: alice.id, bannerId: driveFile1.id }); + channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id }); + channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id }); + }); + + afterEach(async () => { + await channelMutingRepository.delete({}); + await channelsRepository.delete({}); + await userProfilesRepository.delete({}); + await usersRepository.delete({}); + }); + + describe('list', () => { + test('default', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].userId).toBe(alice.id); + expect(mutings[0].user).toBeFalsy(); + expect(mutings[0].bannerId).toBe(driveFile1.id); + expect(mutings[0].banner).toBeFalsy(); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].userId).toBe(alice.id); + expect(mutings[1].user).toBeFalsy(); + expect(mutings[1].bannerId).toBe(driveFile2.id); + expect(mutings[1].banner).toBeFalsy(); + }); + + test('withoutExpires', async () => { + const now = new Date(); + const past = new Date(now); + const future = new Date(now); + past.setMinutes(past.getMinutes() - 1); + future.setMinutes(future.getMinutes() + 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null }); + await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future }); + + const mutings = await service.list({ requestUserId: alice.id }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel2.id); + expect(mutings[1].id).toBe(channel3.id); + }); + + test('idOnly', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[1].id).toBe(channel2.id); + }); + + test('withoutExpires-idOnly', async () => { + const now = new Date(); + const past = new Date(now); + const future = new Date(now); + past.setMinutes(past.getMinutes() - 1); + future.setMinutes(future.getMinutes() + 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: null }); + await createChannelMuting({ userId: alice.id, channelId: channel3.id, expiresAt: future }); + + const mutings = await service.list({ requestUserId: alice.id }, { idOnly: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel2.id); + expect(mutings[1].id).toBe(channel3.id); + }); + + test('joinUser', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { joinUser: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].user).toEqual(alice); + expect(mutings[0].banner).toBeFalsy(); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].user).toEqual(alice); + expect(mutings[1].banner).toBeFalsy(); + }); + + test('joinBannerFile', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id }); + + const mutings = await service.list({ requestUserId: alice.id }, { joinBannerFile: true }); + + expect(mutings).toHaveLength(2); + expect(mutings[0].id).toBe(channel1.id); + expect(mutings[0].user).toBeFalsy(); + expect(mutings[0].banner).toEqual(driveFile1); + expect(mutings[1].id).toBe(channel2.id); + expect(mutings[1].user).toBeFalsy(); + expect(mutings[1].banner).toEqual(driveFile2); + }); + }); + + describe('findExpiredMutings', () => { + test('default', async () => { + const now = new Date(); + const future = new Date(now); + const past = new Date(now); + future.setMinutes(now.getMinutes() + 1); + past.setMinutes(now.getMinutes() - 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past }); + + const mutings = await service.findExpiredMutings(); + + expect(mutings).toHaveLength(2); + expect(mutings[0].channelId).toBe(channel1.id); + expect(mutings[1].channelId).toBe(channel3.id); + }); + }); + + describe('isMuted', () => { + test('isMuted: true', async () => { + // キャッシュを読むのでServiceの機能を使って登録し、キャッシュを作成する + await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id }); + await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); + + await setTimeout(500); + + const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); + + expect(result).toBe(true); + }); + + test('isMuted: false', async () => { + await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); + + await setTimeout(500); + + const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); + + expect(result).toBe(false); + }); + }); + + describe('mute', () => { + test('default', async () => { + await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id }); + + const muting = await fetchChannelMuting(); + expect(muting).toHaveLength(1); + expect(muting[0].channelId).toBe(channel1.id); + }); + }); + + describe('unmute', () => { + test('default', async () => { + await createChannelMuting({ userId: alice.id, channelId: channel1.id }); + + let muting = await fetchChannelMuting(); + expect(muting).toHaveLength(1); + expect(muting[0].channelId).toBe(channel1.id); + + await service.unmute({ requestUserId: alice.id, targetChannelId: channel1.id }); + + muting = await fetchChannelMuting(); + expect(muting).toHaveLength(0); + }); + }); + + describe('eraseExpiredMutings', () => { + test('default', async () => { + const now = new Date(); + const future = new Date(now); + const past = new Date(now); + future.setMinutes(now.getMinutes() + 1); + past.setMinutes(now.getMinutes() - 1); + + await createChannelMuting({ userId: alice.id, channelId: channel1.id, expiresAt: past }); + await createChannelMuting({ userId: alice.id, channelId: channel2.id, expiresAt: future }); + await createChannelMuting({ userId: bob.id, channelId: channel3.id, expiresAt: past }); + + await service.eraseExpiredMutings(); + + const mutings = await fetchChannelMuting(); + expect(mutings).toHaveLength(1); + expect(mutings[0].channelId).toBe(channel2.id); + }); + }); +}); diff --git a/packages/backend/test/unit/NoteCreateService.ts b/packages/backend/test/unit/NoteCreateService.ts index f2d4c8ffbb..da351e24ab 100644 --- a/packages/backend/test/unit/NoteCreateService.ts +++ b/packages/backend/test/unit/NoteCreateService.ts @@ -60,6 +60,7 @@ describe('NoteCreateService', () => { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + renoteChannelId: null, }; const poll: IPoll = { diff --git a/packages/backend/test/unit/misc/is-renote.ts b/packages/backend/test/unit/misc/is-renote.ts index 0b713e8bf6..baa35fc495 100644 --- a/packages/backend/test/unit/misc/is-renote.ts +++ b/packages/backend/test/unit/misc/is-renote.ts @@ -43,6 +43,7 @@ const base: MiNote = { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + renoteChannelId: null, }; describe('misc:is-renote', () => { diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index b61054118d..d9d4ff1548 100644 --- a/packages/frontend/src/pages/channel.vue +++ b/packages/frontend/src/pages/channel.vue @@ -119,22 +119,25 @@ const featuredPagination = computed(() => ({ })); watch(() => props.channelId, async () => { - channel.value = await misskeyApi('channels/show', { + const _channel = await misskeyApi('channels/show', { channelId: props.channelId, }); - favorited.value = channel.value.isFavorited ?? false; - if (favorited.value || channel.value.isFollowing) { + + favorited.value = _channel.isFavorited ?? false; + if (favorited.value || _channel.isFollowing) { tab.value = 'timeline'; } - if ((favorited.value || channel.value.isFollowing) && channel.value.lastNotedAt) { - const lastReadedAt: number = miLocalStorage.getItemAsJson(`channelLastReadedAt:${channel.value.id}`) ?? 0; - const lastNotedAt = Date.parse(channel.value.lastNotedAt); + if ((favorited.value || _channel.isFollowing) && _channel.lastNotedAt) { + const lastReadedAt: number = miLocalStorage.getItemAsJson(`channelLastReadedAt:${_channel.id}`) ?? 0; + const lastNotedAt = Date.parse(_channel.lastNotedAt); if (lastNotedAt > lastReadedAt) { - miLocalStorage.setItemAsJson(`channelLastReadedAt:${channel.value.id}`, lastNotedAt); + miLocalStorage.setItemAsJson(`channelLastReadedAt:${_channel.id}`, lastNotedAt); } } + + channel.value = _channel; }, { immediate: true }); function edit() { @@ -174,6 +177,53 @@ async function unfavorite() { }); } +async function mute() { + if (!channel.value) return; + const _channel = channel.value; + + 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; + + os.apiWithDialog('channels/mute/create', { + channelId: _channel.id, + expiresAt, + }).then(() => { + _channel.isMuting = true; + }); +} + +async function unmute() { + if (!channel.value) return; + const _channel = channel.value; + + os.apiWithDialog('channels/mute/delete', { + channelId: _channel.id, + }).then(() => { + _channel.isMuting = false; + }); +} + async function search() { if (!channel.value) return; @@ -229,6 +279,24 @@ const headerActions = computed(() => { }); } + if (!channel.value.isMuting) { + headerItems.push({ + icon: 'ti ti-volume', + text: i18n.ts.mute, + handler: async (): Promise => { + await mute(); + }, + }); + } else { + headerItems.push({ + icon: 'ti ti-volume-off', + text: i18n.ts.unmute, + handler: async (): Promise => { + await unmute(); + }, + }); + } + if (($i && $i.id === channel.value.userId) || iAmModerator) { headerItems.push({ icon: 'ti ti-settings', diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 01a3dbbb30..4b25a46601 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -827,6 +827,15 @@ type ChannelsFollowedResponse = operations['channels___followed']['responses'][' // @public (undocumented) type ChannelsFollowRequest = operations['channels___follow']['requestBody']['content']['application/json']; +// @public (undocumented) +type ChannelsMuteCreateRequest = operations['channels___mute___create']['requestBody']['content']['application/json']; + +// @public (undocumented) +type ChannelsMuteDeleteRequest = operations['channels___mute___delete']['requestBody']['content']['application/json']; + +// @public (undocumented) +type ChannelsMuteListResponse = operations['channels___mute___list']['responses']['200']['content']['application/json']; + // @public (undocumented) type ChannelsMyFavoritesResponse = operations['channels___my-favorites']['responses']['200']['content']['application/json']; @@ -1401,6 +1410,9 @@ declare namespace entities { ChannelsMyFavoritesResponse, ChannelsSearchRequest, ChannelsSearchResponse, + ChannelsMuteCreateRequest, + ChannelsMuteDeleteRequest, + ChannelsMuteListResponse, ChartsActiveUsersRequest, ChartsActiveUsersResponse, ChartsApRequestRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 1837f3db4f..8a2cfbb4d8 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1347,6 +1347,39 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:channels* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:channels* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:channels* + */ + 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 cb1f4dbe96..9f51f6d91b 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -176,6 +176,9 @@ import type { ChannelsMyFavoritesResponse, ChannelsSearchRequest, ChannelsSearchResponse, + ChannelsMuteCreateRequest, + ChannelsMuteDeleteRequest, + ChannelsMuteListResponse, ChartsActiveUsersRequest, ChartsActiveUsersResponse, ChartsApRequestRequest, @@ -703,6 +706,9 @@ export type Endpoints = { 'channels/unfavorite': { req: ChannelsUnfavoriteRequest; res: EmptyResponse }; 'channels/my-favorites': { req: EmptyRequest; res: ChannelsMyFavoritesResponse }; 'channels/search': { req: ChannelsSearchRequest; res: ChannelsSearchResponse }; + 'channels/mute/create': { req: ChannelsMuteCreateRequest; res: EmptyResponse }; + 'channels/mute/delete': { req: ChannelsMuteDeleteRequest; res: EmptyResponse }; + 'channels/mute/list': { req: EmptyRequest; res: ChannelsMuteListResponse }; 'charts/active-users': { req: ChartsActiveUsersRequest; res: ChartsActiveUsersResponse }; 'charts/ap-request': { req: ChartsApRequestRequest; res: ChartsApRequestResponse }; 'charts/drive': { req: ChartsDriveRequest; res: ChartsDriveResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index a8f474c25c..17bbc26cda 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -179,6 +179,9 @@ export type ChannelsUnfavoriteRequest = operations['channels___unfavorite']['req export type ChannelsMyFavoritesResponse = operations['channels___my-favorites']['responses']['200']['content']['application/json']; export type ChannelsSearchRequest = operations['channels___search']['requestBody']['content']['application/json']; export type ChannelsSearchResponse = operations['channels___search']['responses']['200']['content']['application/json']; +export type ChannelsMuteCreateRequest = operations['channels___mute___create']['requestBody']['content']['application/json']; +export type ChannelsMuteDeleteRequest = operations['channels___mute___delete']['requestBody']['content']['application/json']; +export type ChannelsMuteListResponse = operations['channels___mute___list']['responses']['200']['content']['application/json']; export type ChartsActiveUsersRequest = operations['charts___active-users']['requestBody']['content']['application/json']; export type ChartsActiveUsersResponse = operations['charts___active-users']['responses']['200']['content']['application/json']; export type ChartsApRequestRequest = operations['charts___ap-request']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 280abba727..030cd93f0d 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1114,6 +1114,33 @@ export type paths = { */ post: operations['channels___search']; }; + '/channels/mute/create': { + /** + * channels/mute/create + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:channels* + */ + post: operations['channels___mute___create']; + }; + '/channels/mute/delete': { + /** + * channels/mute/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:channels* + */ + post: operations['channels___mute___delete']; + }; + '/channels/mute/list': { + /** + * channels/mute/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:channels* + */ + post: operations['channels___mute___list']; + }; '/charts/active-users': { /** * charts/active-users @@ -4591,6 +4618,7 @@ export type components = { allowRenoteToExternal: boolean; isFollowing?: boolean; isFavorited?: boolean; + isMuting?: boolean; pinnedNotes?: components['schemas']['Note'][]; }; QueueCount: { @@ -12431,6 +12459,158 @@ export type operations = { }; }; }; + /** + * channels/mute/create + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:channels* + */ + channels___mute___create: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + channelId: string; + /** @description A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute. */ + 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 Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** + * channels/mute/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:channels* + */ + channels___mute___delete: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + channelId: 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']; + }; + }; + }; + }; + /** + * channels/mute/list + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:channels* + */ + channels___mute___list: { + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['Channel'][]; + }; + }; + /** @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']; + }; + }; + }; + }; /** * charts/active-users * @description No description provided.