From cbc256b7ce10dec6a706c2c1399b5b26001c22c9 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:02:40 +0900 Subject: [PATCH 01/28] add channel_muting table and entities --- .../1718015380000-add-channel-muting.js | 37 +++++++++++++++ packages/backend/src/di-symbols.ts | 1 + packages/backend/src/models/ChannelMuting.ts | 46 +++++++++++++++++++ .../backend/src/models/RepositoryModule.ts | 9 ++++ packages/backend/src/models/_.ts | 8 ++-- packages/backend/src/postgres.ts | 2 + 6 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 packages/backend/migration/1718015380000-add-channel-muting.js create mode 100644 packages/backend/src/models/ChannelMuting.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..a7913b3738 --- /dev/null +++ b/packages/backend/migration/1718015380000-add-channel-muting.js @@ -0,0 +1,37 @@ +/* + * 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"); + `); + } + + async down(queryRunner) { + await queryRunner.query(` + 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/di-symbols.ts b/packages/backend/src/di-symbols.ts index 271082b4ff..22202e0993 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -69,6 +69,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/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/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/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, From 94ededa68ded8e9f2585236db6944ff3e6e5e781 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:36:30 +0900 Subject: [PATCH 02/28] add channel_muting services --- .../backend/src/core/ChannelMutingService.ts | 106 ++++++++++++++++++ packages/backend/src/core/CoreModule.ts | 6 + .../backend/src/core/GlobalEventService.ts | 2 + .../src/server/api/stream/Connection.ts | 60 +++++++--- .../backend/src/server/api/stream/channel.ts | 6 +- 5 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 packages/backend/src/core/ChannelMutingService.ts diff --git a/packages/backend/src/core/ChannelMutingService.ts b/packages/backend/src/core/ChannelMutingService.ts new file mode 100644 index 0000000000..52fae75bd4 --- /dev/null +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -0,0 +1,106 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; +import { DI } from '@/di-symbols.js'; +import type { ChannelMutingRepository, MiChannel, 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 userMutingChannelsCache: RedisKVCache>; + + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + @Inject(DI.redisForSub) + private redisForSub: Redis.Redis, + @Inject(DI.channelMutingRepository) + private channelMutingRepository: ChannelMutingRepository, + private idService: IdService, + private globalEventService: GlobalEventService, + ) { + this.userMutingChannelsCache = 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); + } + + @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, + }); + } + + @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 + 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.userMutingChannelsCache.refresh(body.userId).then(); + break; + } + case 'unmuteChannel': { + this.userMutingChannelsCache.delete(body.userId).then(); + break; + } + } + } + } + + @bindThis + public dispose(): void { + this.userMutingChannelsCache.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 b5b34487ec..6cc9292ffc 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -12,6 +12,7 @@ import { } from '@/core/entities/AbuseReportNotificationRecipientEntityService.js'; import { AbuseReportNotificationService } from '@/core/AbuseReportNotificationService.js'; import { SystemWebhookService } from '@/core/SystemWebhookService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { AccountMoveService } from './AccountMoveService.js'; import { AccountUpdateService } from './AccountUpdateService.js'; import { AiService } from './AiService.js'; @@ -215,6 +216,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 }; @@ -361,6 +363,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChannelMutingService, RegistryApiService, ReversiService, @@ -503,6 +506,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChannelMutingService, $RegistryApiService, $ReversiService, @@ -646,6 +650,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting FanoutTimelineService, FanoutTimelineEndpointService, ChannelFollowingService, + ChannelMutingService, RegistryApiService, ReversiService, @@ -787,6 +792,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $FanoutTimelineService, $FanoutTimelineEndpointService, $ChannelFollowingService, + $ChannelMutingService, $RegistryApiService, $ReversiService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index a70743bed2..b86b1d0182 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -240,6 +240,8 @@ export interface InternalEventTypes { metaUpdated: 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/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index 41c0feccc7..e667f86604 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 type { ChannelsService } from './ChannelsService.js'; import type { EventEmitter } from 'events'; import type Channel from './channel.js'; @@ -33,10 +34,12 @@ 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(); public userMutedInstances: Set = new Set(); + public userMutedChannels: Set = new Set(); private fetchIntervalId: NodeJS.Timeout | null = null; constructor( @@ -45,7 +48,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, ) { @@ -56,10 +59,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.userMutingChannelsCache.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), @@ -67,6 +79,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; @@ -112,16 +125,37 @@ export default class Connection { const { type, body } = obj; switch (type) { - case 'readNotification': this.onReadNotification(body); break; - case 'subNote': this.onSubscribeNote(body); break; - case 's': this.onSubscribeNote(body); break; // alias - case 'sr': this.onSubscribeNote(body); this.readNote(body); break; - case 'unsubNote': this.onUnsubscribeNote(body); break; - case 'un': this.onUnsubscribeNote(body); break; // alias - case 'connect': this.onChannelConnectRequested(body); break; - case 'disconnect': this.onChannelDisconnectRequested(body); break; - case 'channel': this.onChannelMessageRequested(body); break; - case 'ch': this.onChannelMessageRequested(body); break; // alias + case 'readNotification': + this.onReadNotification(body); + break; + case 'subNote': + this.onSubscribeNote(body); + break; + case 's': + this.onSubscribeNote(body); + break; // alias + case 'sr': + this.onSubscribeNote(body); + this.readNote(body); + break; + case 'unsubNote': + this.onUnsubscribeNote(body); + break; + case 'un': + this.onUnsubscribeNote(body); + break; // alias + case 'connect': + this.onChannelConnectRequested(body); + break; + case 'disconnect': + this.onChannelDisconnectRequested(body); + break; + case 'channel': + this.onChannelMessageRequested(body); + break; + case 'ch': + this.onChannelMessageRequested(body); + break; // alias } } diff --git a/packages/backend/src/server/api/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index a267d27fba..da6cf39889 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -6,7 +6,7 @@ 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 type { Packed } from '@/misc/json-schema.js'; import type Connection from './Connection.js'; @@ -54,6 +54,10 @@ export default abstract class Channel { return this.connection.followingChannels; } + protected get mutingChannels() { + return this.connection.mutingChannels; + } + protected get subscriber() { return this.connection.subscriber; } From 7d7c2d4dafe32b2620bb2373b8c09e8a17129fa3 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Tue, 11 Jun 2024 21:13:31 +0900 Subject: [PATCH 03/28] =?UTF-8?q?=E3=82=BF=E3=82=A4=E3=83=A0=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E5=8F=96=E5=BE=97=E5=87=A6=E7=90=86=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E7=B5=84=E3=81=BF=E8=BE=BC=E3=81=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1718015380000-add-channel-muting.js | 5 + .../backend/src/core/ChannelMutingService.ts | 63 +++++++- .../src/core/FanoutTimelineEndpointService.ts | 7 + .../backend/src/core/NoteCreateService.ts | 1 + .../src/core/entities/ChannelEntityService.ts | 137 ++++++++++++++---- .../backend/src/misc/is-channel-related.ts | 33 +++++ packages/backend/src/models/Note.ts | 7 + .../backend/src/server/api/EndpointsModule.ts | 12 ++ packages/backend/src/server/api/endpoints.ts | 6 + .../api/endpoints/channels/mute/create.ts | 90 ++++++++++++ .../api/endpoints/channels/mute/delete.ts | 73 ++++++++++ .../api/endpoints/channels/mute/list.ts | 49 +++++++ .../api/endpoints/notes/hybrid-timeline.ts | 17 ++- .../server/api/endpoints/notes/timeline.ts | 35 ++++- .../api/stream/channels/home-timeline.ts | 6 +- .../api/stream/channels/hybrid-timeline.ts | 12 +- .../backend/test/unit/NoteCreateService.ts | 1 + packages/backend/test/unit/misc/is-renote.ts | 1 + 18 files changed, 512 insertions(+), 43 deletions(-) create mode 100644 packages/backend/src/misc/is-channel-related.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/mute/create.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/mute/delete.ts create mode 100644 packages/backend/src/server/api/endpoints/channels/mute/list.ts diff --git a/packages/backend/migration/1718015380000-add-channel-muting.js b/packages/backend/migration/1718015380000-add-channel-muting.js index a7913b3738..e2592dce7a 100644 --- a/packages/backend/migration/1718015380000-add-channel-muting.js +++ b/packages/backend/migration/1718015380000-add-channel-muting.js @@ -20,11 +20,16 @@ export class AddChannelMuting1718015380000 { ); 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" diff --git a/packages/backend/src/core/ChannelMutingService.ts b/packages/backend/src/core/ChannelMutingService.ts index 52fae75bd4..4917f80c37 100644 --- a/packages/backend/src/core/ChannelMutingService.ts +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -6,7 +6,7 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; import { DI } from '@/di-symbols.js'; -import type { ChannelMutingRepository, MiChannel, MiUser } from '@/models/_.js'; +import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiUser } from '@/models/_.js'; import { IdService } from '@/core/IdService.js'; import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js'; import { bindThis } from '@/decorators.js'; @@ -21,6 +21,8 @@ export class ChannelMutingService { 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, @@ -40,6 +42,61 @@ export class ChannelMutingService { this.redisForSub.on('message', this.onMessage); } + /** + * ミュートしているチャンネルの一覧を取得する. + * @param params + * @param [opts] + * @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?: { + joinUser?: boolean; + joinBannerFile?: boolean; + }, + ): Promise { + 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(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 params + * @param params.requestUserId + */ + @bindThis + public async isMuted(params: { + requestUserId: MiUser['id'], + targetChannelId: MiChannel['id'], + }): Promise { + const mutedChannels = await this.userMutingChannelsCache.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'], @@ -59,6 +116,10 @@ export class ChannelMutingService { }); } + /** + * チャンネルのミュートを解除する. + * @param params + */ @bindThis public async unmute(params: { requestUserId: MiUser['id'], diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index b05af99c5e..b7534d6cb4 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; + excludeMutedChannels?: 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.excludeMutedChannels ? this.channelMutingService.userMutingChannelsCache.fetch(me.id) : Promise.resolve(new Set()), ]); 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.excludeMutedChannels && isChannelRelated(note, userMutedChannels)) return false; return parentFilter(note); }; diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index a2c3aaa701..cef8f6828e 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -434,6 +434,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/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 1ba7ca8e57..71676865c6 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -4,36 +4,39 @@ */ 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, + 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 { isNotNull } from '@/misc/is-not-null.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.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.driveFilesRepository) private driveFilesRepository: DriveFilesRepository, - private noteEntityService: NoteEntityService, private driveFileEntityService: DriveFileEntityService, private idService: IdService, @@ -45,31 +48,50 @@ export class ChannelEntityService { src: MiChannel['id'] | MiChannel, me?: { id: MiUser['id'] } | null | undefined, detailed?: boolean, + opts?: { + bannerFiles?: Map; + followings?: Set; + favorites?: 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 isFavorite = 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; + isFavorite = 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), - }, - }) : []; + 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(isNotNull) + : await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) }) + ), + ); + } return { id: channel.id, @@ -78,7 +100,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, @@ -89,7 +111,7 @@ export class ChannelEntityService { ...(me ? { isFollowing, - isFavorited, + isFavorite, hasUnreadNote: false, // 後方互換性のため } : {}), @@ -98,5 +120,62 @@ 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 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, + pinnedNotes, + }))); + } } 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..2494410ae5 --- /dev/null +++ b/packages/backend/src/misc/is-channel-related.ts @@ -0,0 +1,33 @@ +/* + * 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) { + // チャンネル投稿じゃなければ無条件でOK + return true; + } + + if (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/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/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 41576bedaa..8933808168 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -124,6 +124,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'; @@ -507,6 +510,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 }; @@ -894,6 +900,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, @@ -1275,6 +1284,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/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 3dfb7fdad4..91da4e02f7 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -130,6 +130,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'; @@ -511,6 +514,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/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..10d8ac882c --- /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 if already 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/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index 5acc9706d3..f6a7aa1c6c 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -19,6 +19,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MetaService } from '@/core/MetaService.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 = { @@ -47,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; @@ -87,6 +88,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private queryService: QueryService, private userFollowingService: UserFollowingService, + private channelMutingService: ChannelMutingService, private metaService: MetaService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { @@ -152,6 +154,7 @@ export default class extends Endpoint { // eslint- useDbFallback: serverSettings.enableFanoutTimelineDbFallback, alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ untilId, sinceId, @@ -188,6 +191,9 @@ export default class extends Endpoint { // eslint- followerId: me.id, }, }); + const mutingChannelIds = (followingChannels.length > 0) + ? await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)) + : []; const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { @@ -217,6 +223,15 @@ export default class extends Endpoint { // eslint- query.andWhere('note.channelId IS NULL'); } + if (mutingChannelIds.length > 0) { + // ミュートしてるチャンネルは含めない + query.andWhere(new Brackets(qb => { + qb + .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }) + .andWhere('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/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 8b87908bd3..9fa9abc903 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 } from '@/models/_.js'; +import type { NotesRepository, ChannelFollowingsRepository, ChannelMutingRepository } 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'; @@ -17,6 +17,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js'; import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; +import { ChannelMutingService } from '@/core/ChannelMutingService.js'; export const meta = { tags: ['notes'], @@ -68,6 +69,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, + private channelMutingService: ChannelMutingService, private queryService: QueryService, private metaService: MetaService, ) { @@ -112,6 +114,7 @@ export default class extends Endpoint { // eslint- redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { if (!Object.hasOwn(followings, note.reply.userId)) return false; @@ -146,6 +149,9 @@ export default class extends Endpoint { // eslint- followerId: me.id, }, }); + const mutingChannelIds = (followingChannels.length > 0) + ? await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)) + : []; //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -163,7 +169,7 @@ export default class extends Endpoint { // eslint- 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 }); @@ -171,9 +177,11 @@ 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 }); + query.andWhere(new Brackets(qb => { + qb + .andWhere('note.channelId IS NULL') + .andWhere('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds }); + })); } else if (followingChannels.length > 0) { // チャンネルフォローのみ(ユーザーフォローなし) const followingChannelIds = followingChannels.map(x => x.followeeId); @@ -184,9 +192,20 @@ export default class extends Endpoint { // eslint- })); } 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 }); + })); + } + + if (mutingChannelIds.length > 0) { + // ミュートしてるチャンネルは含めない + query.andWhere(new Brackets(qb => { + qb + .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }) + .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); } query.andWhere(new Brackets(qb => { 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 878a3180cb..f450336914 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -8,6 +8,7 @@ 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 { isChannelRelated } from '@/misc/is-channel-related.js'; import Channel, { type MiChannelService } from '../channel.js'; class HomeTimelineChannel extends Channel { @@ -43,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; + // そのチャンネルをフォローしていない or そのチャンネル(リノート・引用リノート含む)はミュートしている + if (!this.followingChannels.has(note.channelId) || isChannelRelated(note, this.mutingChannels)) { + 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 575d23d53c..7cd7bcf56d 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -10,6 +10,7 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; +import { isChannelRelated } from '@/misc/is-channel-related.js'; import Channel, { type MiChannelService } from '../channel.js'; class HybridTimelineChannel extends Channel { @@ -55,12 +56,14 @@ class HybridTimelineChannel extends Channel { // チャンネルの投稿ではなく、自分自身の投稿 または // チャンネルの投稿ではなく、その投稿のユーザーをフォローしている または // チャンネルの投稿ではなく、全体公開のローカルの投稿 または - // フォローしているチャンネルの投稿 の場合だけ + // フォローしているチャンネルの投稿 または + // ミュートしていないチャンネルの投稿(リノート・引用リノートもチェック対象)の場合だけ 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)) + (note.channelId != null && this.followingChannels.has(note.channelId)) || + (note.channelId != null && !isChannelRelated(note, this.mutingChannels)) )) return; if (note.visibility === 'followers') { @@ -82,7 +85,10 @@ class HybridTimelineChannel extends Channel { } } - if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) return; + // 純粋なリノート(引用リノートでないリノート)の場合 + if (isRenotePacked(note) && !isQuotePacked(note) && !this.withRenotes) { + return; + } if (this.user && note.renoteId && !note.text) { if (note.renote && Object.keys(note.renote.reactions).length > 0) { 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', () => { From a46fefd43cf39ac968f68211717c8bb8eba197db Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Tue, 11 Jun 2024 21:16:03 +0900 Subject: [PATCH 04/28] =?UTF-8?q?misskey-js=E3=81=AE=E5=9E=8B=E3=81=A8?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=82=BF=E3=83=BC=E3=83=95=E3=82=A7=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E7=94=9F=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/misskey-js/etc/misskey-js.api.md | 12 ++ .../misskey-js/src/autogen/apiClientJSDoc.ts | 33 ++++ packages/misskey-js/src/autogen/endpoint.ts | 6 + packages/misskey-js/src/autogen/entities.ts | 3 + packages/misskey-js/src/autogen/types.ts | 179 ++++++++++++++++++ 5 files changed, 233 insertions(+) diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index bea89f2a7c..b81c22e905 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -815,6 +815,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']; @@ -1358,6 +1367,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 e799d4a0c5..237b17e1ff 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1313,6 +1313,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 20c8509d4c..6fa85e7840 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -172,6 +172,9 @@ import type { ChannelsMyFavoritesResponse, ChannelsSearchRequest, ChannelsSearchResponse, + ChannelsMuteCreateRequest, + ChannelsMuteDeleteRequest, + ChannelsMuteListResponse, ChartsActiveUsersRequest, ChartsActiveUsersResponse, ChartsApRequestRequest, @@ -692,6 +695,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 357b5e9eaf..e2e57b1573 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -175,6 +175,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 72aca4dee2..8859667df1 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1086,6 +1086,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 @@ -12132,6 +12159,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. From fdf2b8cd0b34983275eef5318c25c04ec0af50ed Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Tue, 11 Jun 2024 21:21:00 +0900 Subject: [PATCH 05/28] =?UTF-8?q?Channel=E3=82=B9=E3=82=AD=E3=83=BC?= =?UTF-8?q?=E3=83=9E=E3=81=AB=E3=83=9F=E3=83=A5=E3=83=BC=E3=83=88=E6=83=85?= =?UTF-8?q?=E5=A0=B1=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/core/entities/ChannelEntityService.ts | 24 ++++++++++++++++++- .../backend/src/models/json-schema/channel.ts | 4 ++++ packages/misskey-js/src/autogen/types.ts | 1 + 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 71676865c6..bae3d5c117 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -8,7 +8,7 @@ import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { ChannelFavoritesRepository, - ChannelFollowingsRepository, + ChannelFollowingsRepository, ChannelMutingRepository, ChannelsRepository, DriveFilesRepository, MiDriveFile, @@ -33,6 +33,8 @@ export class ChannelEntityService { private channelFollowingsRepository: ChannelFollowingsRepository, @Inject(DI.channelFavoritesRepository) private channelFavoritesRepository: ChannelFavoritesRepository, + @Inject(DI.channelMutingRepository) + private channelMutingRepository: ChannelMutingRepository, @Inject(DI.notesRepository) private notesRepository: NotesRepository, @Inject(DI.driveFilesRepository) @@ -52,6 +54,7 @@ export class ChannelEntityService { bannerFiles?: Map; followings?: Set; favorites?: Set; + muting?: Set; pinnedNotes?: Map; }, ): Promise> { @@ -65,6 +68,7 @@ export class ChannelEntityService { let isFollowing = false; let isFavorite = false; + let isMuting = false; if (me) { isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({ where: { @@ -79,6 +83,13 @@ export class ChannelEntityService { channelId: channel.id, }, }); + + isMuting = opts?.muting?.has(channel.id) ?? await this.channelMutingRepository.exists({ + where: { + userId: me.id, + channelId: channel.id, + }, + }); } const pinnedNotes = Array.of(); @@ -112,6 +123,7 @@ export class ChannelEntityService { ...(me ? { isFollowing, isFavorite, + isMuting, hasUnreadNote: false, // 後方互換性のため } : {}), @@ -162,6 +174,15 @@ export class ChannelEntityService { .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: { @@ -174,6 +195,7 @@ export class ChannelEntityService { bannerFiles, followings, favorites, + muting, pinnedNotes, }))); } 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/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index 8859667df1..80a68ac8b4 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -4542,6 +4542,7 @@ export type components = { allowRenoteToExternal: boolean; isFollowing?: boolean; isFavorited?: boolean; + isMuting?: boolean; pinnedNotes?: components['schemas']['Note'][]; }; QueueCount: { From de238b70d758949153da8972262a40f555d98ed2 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Tue, 11 Jun 2024 21:51:53 +0900 Subject: [PATCH 06/28] =?UTF-8?q?=E3=83=95=E3=83=AD=E3=83=B3=E3=83=88?= =?UTF-8?q?=E3=82=A8=E3=83=B3=E3=83=89=E3=81=AE=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/frontend/src/pages/channel.vue | 82 ++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/packages/frontend/src/pages/channel.vue b/packages/frontend/src/pages/channel.vue index a895df76e8..d185ae0911 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', From fa8d90548405dc99ef1aa198f45fb8c04df6979c Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Tue, 11 Jun 2024 21:51:59 +0900 Subject: [PATCH 07/28] =?UTF-8?q?=E6=9D=A1=E4=BB=B6=E3=81=8C=E9=80=86?= =?UTF-8?q?=E3=81=A0=E3=81=A3=E3=81=9F=E3=81=AE=E3=82=92=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/server/api/endpoints/channels/mute/delete.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/channels/mute/delete.ts b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts index 10d8ac882c..79abeebe99 100644 --- a/packages/backend/src/server/api/endpoints/channels/mute/delete.ts +++ b/packages/backend/src/server/api/endpoints/channels/mute/delete.ts @@ -55,12 +55,12 @@ export default class extends Endpoint { // eslint- throw new ApiError(meta.errors.noSuchChannel); } - // Check if already muting + // Check muting const exist = await this.channelMutingService.isMuted({ requestUserId: me.id, targetChannelId: targetChannel.id, }); - if (exist) { + if (!exist) { throw new ApiError(meta.errors.notMuting); } From ae485ed56808dc3e21d3b9340be96196b6834f26 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Wed, 12 Jun 2024 06:53:26 +0900 Subject: [PATCH 08/28] =?UTF-8?q?=E6=9C=9F=E9=99=90=E5=88=87=E3=82=8C?= =?UTF-8?q?=E3=83=9F=E3=83=A5=E3=83=BC=E3=83=88=E3=82=92=E6=8E=83=E9=99=A4?= =?UTF-8?q?=E3=81=99=E3=82=8B=E6=A9=9F=E8=83=BD=E3=82=92=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/ChannelMutingService.ts | 45 ++++++++++++++++++- .../CheckExpiredMutingsProcessorService.ts | 6 ++- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/core/ChannelMutingService.ts b/packages/backend/src/core/ChannelMutingService.ts index 4917f80c37..041ac19305 100644 --- a/packages/backend/src/core/ChannelMutingService.ts +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -5,8 +5,9 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; +import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; -import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiUser } from '@/models/_.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'; @@ -64,7 +65,7 @@ export class ChannelMutingService { .where('channel_muting.userId = :userId', { userId: params.requestUserId }) .andWhere(qb => { qb.where('channel_muting.expiresAt IS NULL') - .orWhere('channel_muting.expiresAt > :now:', { now: new Date() }); + .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); }); if (opts?.joinUser) { @@ -78,6 +79,32 @@ export class ChannelMutingService { 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 @@ -136,6 +163,20 @@ export class ChannelMutingService { }); } + /** + * 期限切れのチャンネルミュート情報を削除する. + */ + @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.userMutingChannelsCache.refresh(userId).then(); + } + } + @bindThis private async onMessage(_: string, data: string): Promise { const obj = JSON.parse(data); 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.'); } } From a56c680136b50166347da97e475fdf5b446f8e94 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Wed, 12 Jun 2024 06:59:53 +0900 Subject: [PATCH 09/28] =?UTF-8?q?TL=E3=81=AE=E6=8A=BD=E5=87=BA=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E8=AA=BF=E7=AF=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/endpoints/notes/hybrid-timeline.ts | 27 ++++++++++++------- .../server/api/endpoints/notes/timeline.ts | 23 +++++++++++----- 2 files changed, 35 insertions(+), 15 deletions(-) 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 f6a7aa1c6c..af92ae4849 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -5,7 +5,7 @@ import { Brackets } from 'typeorm'; import { Inject, Injectable } from '@nestjs/common'; -import type { NotesRepository, ChannelFollowingsRepository } from '@/models/_.js'; +import type { ChannelFollowingsRepository, NotesRepository } from '@/models/_.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import ActiveUsersChart from '@/core/chart/charts/active-users.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; @@ -77,10 +77,8 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) private channelFollowingsRepository: ChannelFollowingsRepository, - private noteEntityService: NoteEntityService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, @@ -191,9 +189,7 @@ export default class extends Endpoint { // eslint- followerId: me.id, }, }); - const mutingChannelIds = (followingChannels.length > 0) - ? await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)) - : []; + const mutingChannelIds = await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)); const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) .andWhere(new Brackets(qb => { @@ -224,11 +220,24 @@ export default class extends Endpoint { // eslint- } if (mutingChannelIds.length > 0) { - // ミュートしてるチャンネルは含めない query.andWhere(new Brackets(qb => { qb - .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }) - .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + // ミュートしてるチャンネルは含めない + .where(new Brackets(qb2 => { + qb2 + .andWhere(new Brackets(qb3 => { + qb3 + .andWhere('note.channelId IS NOT NULL') + .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })) + .andWhere(new Brackets(qb3 => { + qb3 + .andWhere('note.renoteChannelId IS NOT NULL') + .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + })) + // チャンネルの投稿ではない + .orWhere('note.channelId IS NULL'); })); } diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 9fa9abc903..38b27da1ed 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -149,9 +149,7 @@ export default class extends Endpoint { // eslint- followerId: me.id, }, }); - const mutingChannelIds = (followingChannels.length > 0) - ? await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)) - : []; + const mutingChannelIds = await this.channelMutingService.list({ requestUserId: me.id }).then(x => x.map(x => x.id)); //#region Construct query const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'), ps.sinceId, ps.untilId) @@ -200,11 +198,24 @@ export default class extends Endpoint { // eslint- } if (mutingChannelIds.length > 0) { - // ミュートしてるチャンネルは含めない query.andWhere(new Brackets(qb => { qb - .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }) - .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + // ミュートしてるチャンネルは含めない + .where(new Brackets(qb2 => { + qb2 + .andWhere(new Brackets(qb3 => { + qb3 + .andWhere('note.channelId IS NOT NULL') + .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })) + .andWhere(new Brackets(qb3 => { + qb3 + .andWhere('note.renoteChannelId IS NOT NULL') + .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); + })); + })) + // チャンネルの投稿ではない + .orWhere('note.channelId IS NULL'); })); } From b5ccf1b4843a9a48e67273dbf91af03b43c425d3 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Wed, 12 Jun 2024 07:16:52 +0900 Subject: [PATCH 10/28] =?UTF-8?q?=E5=90=8D=E5=89=8D=E3=81=AE=E5=A4=89?= =?UTF-8?q?=E6=9B=B4=E3=81=A8=E5=A4=89=E6=9B=B4=E4=B8=8D=E8=A6=81=E3=81=AE?= =?UTF-8?q?=E5=B7=AE=E5=88=86=E3=82=92=E3=83=AD=E3=83=BC=E3=83=AB=E3=83=90?= =?UTF-8?q?=E3=83=83=E3=82=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/core/ChannelMutingService.ts | 14 +++--- .../src/core/FanoutTimelineEndpointService.ts | 2 +- .../src/server/api/stream/Connection.ts | 44 +++++-------------- .../api/stream/channels/hybrid-timeline.ts | 2 +- 4 files changed, 20 insertions(+), 42 deletions(-) diff --git a/packages/backend/src/core/ChannelMutingService.ts b/packages/backend/src/core/ChannelMutingService.ts index 041ac19305..6347972392 100644 --- a/packages/backend/src/core/ChannelMutingService.ts +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -15,7 +15,7 @@ import { RedisKVCache } from '@/misc/cache.js'; @Injectable() export class ChannelMutingService { - public userMutingChannelsCache: RedisKVCache>; + public mutingChannelsCache: RedisKVCache>; constructor( @Inject(DI.redis) @@ -29,7 +29,7 @@ export class ChannelMutingService { private idService: IdService, private globalEventService: GlobalEventService, ) { - this.userMutingChannelsCache = new RedisKVCache>(this.redisClient, 'channelMutingChannels', { + this.mutingChannelsCache = new RedisKVCache>(this.redisClient, 'channelMutingChannels', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m fetcher: (userId) => this.channelMutingRepository.find({ @@ -115,7 +115,7 @@ export class ChannelMutingService { requestUserId: MiUser['id'], targetChannelId: MiChannel['id'], }): Promise { - const mutedChannels = await this.userMutingChannelsCache.get(params.requestUserId); + const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId); return (mutedChannels?.has(params.targetChannelId) ?? false); } @@ -173,7 +173,7 @@ export class ChannelMutingService { const userIds = [...new Set(expiredMutings.map(x => x.userId))]; for (const userId of userIds) { - this.userMutingChannelsCache.refresh(userId).then(); + this.mutingChannelsCache.refresh(userId).then(); } } @@ -185,11 +185,11 @@ export class ChannelMutingService { const { type, body } = obj.message as GlobalEvents['internal']['payload']; switch (type) { case 'muteChannel': { - this.userMutingChannelsCache.refresh(body.userId).then(); + this.mutingChannelsCache.refresh(body.userId).then(); break; } case 'unmuteChannel': { - this.userMutingChannelsCache.delete(body.userId).then(); + this.mutingChannelsCache.delete(body.userId).then(); break; } } @@ -198,7 +198,7 @@ export class ChannelMutingService { @bindThis public dispose(): void { - this.userMutingChannelsCache.dispose(); + this.mutingChannelsCache.dispose(); } @bindThis diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index b7534d6cb4..fcf6b5f84b 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -111,7 +111,7 @@ export class FanoutTimelineEndpointService { 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.excludeMutedChannels ? this.channelMutingService.userMutingChannelsCache.fetch(me.id) : Promise.resolve(new Set()), + ps.excludeMutedChannels ? this.channelMutingService.mutingChannelsCache.fetch(me.id) : Promise.resolve(new Set()), ]); const parentFilter = filter; diff --git a/packages/backend/src/server/api/stream/Connection.ts b/packages/backend/src/server/api/stream/Connection.ts index e667f86604..63d13c04d9 100644 --- a/packages/backend/src/server/api/stream/Connection.ts +++ b/packages/backend/src/server/api/stream/Connection.ts @@ -39,7 +39,6 @@ export default class Connection { public userIdsWhoBlockingMe: Set = new Set(); public userIdsWhoMeMutingRenotes: Set = new Set(); public userMutedInstances: Set = new Set(); - public userMutedChannels: Set = new Set(); private fetchIntervalId: NodeJS.Timeout | null = null; constructor( @@ -71,7 +70,7 @@ export default class Connection { this.cacheService.userProfileCache.fetch(this.user.id), this.cacheService.userFollowingsCache.fetch(this.user.id), this.channelFollowingService.userFollowingChannelsCache.fetch(this.user.id), - this.channelMutingService.userMutingChannelsCache.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), @@ -125,37 +124,16 @@ export default class Connection { const { type, body } = obj; switch (type) { - case 'readNotification': - this.onReadNotification(body); - break; - case 'subNote': - this.onSubscribeNote(body); - break; - case 's': - this.onSubscribeNote(body); - break; // alias - case 'sr': - this.onSubscribeNote(body); - this.readNote(body); - break; - case 'unsubNote': - this.onUnsubscribeNote(body); - break; - case 'un': - this.onUnsubscribeNote(body); - break; // alias - case 'connect': - this.onChannelConnectRequested(body); - break; - case 'disconnect': - this.onChannelDisconnectRequested(body); - break; - case 'channel': - this.onChannelMessageRequested(body); - break; - case 'ch': - this.onChannelMessageRequested(body); - break; // alias + case 'readNotification': this.onReadNotification(body); break; + case 'subNote': this.onSubscribeNote(body); break; + case 's': this.onSubscribeNote(body); break; // alias + case 'sr': this.onSubscribeNote(body); this.readNote(body); break; + case 'unsubNote': this.onUnsubscribeNote(body); break; + case 'un': this.onUnsubscribeNote(body); break; // alias + case 'connect': this.onChannelConnectRequested(body); break; + case 'disconnect': this.onChannelDisconnectRequested(body); break; + case 'channel': this.onChannelMessageRequested(body); break; + case 'ch': this.onChannelMessageRequested(body); break; // alias } } 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 7cd7bcf56d..3b6e678e0a 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -63,7 +63,7 @@ class HybridTimelineChannel extends Channel { (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)) || - (note.channelId != null && !isChannelRelated(note, this.mutingChannels)) + (note.channelId != null && isChannelRelated(note, this.mutingChannels)) )) return; if (note.visibility === 'followers') { From efee424096f61e8fd5ce1240db59091bf7fee706 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Wed, 12 Jun 2024 07:17:01 +0900 Subject: [PATCH 11/28] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=BC=8F=E3=82=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/src/server/api/StreamingApiServerService.ts | 3 +++ 1 file changed, 3 insertions(+) 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, ); From 491541fed11a251ecdf8c2031214364c4f62aab4 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Wed, 12 Jun 2024 07:49:54 +0900 Subject: [PATCH 12/28] =?UTF-8?q?isChannelRelated=E3=81=AE=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=AB=E8=AA=A4=E3=82=8A=E3=81=8C=E3=81=82=E3=81=A3?= =?UTF-8?q?=E3=81=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backend/src/misc/is-channel-related.ts | 9 ++--- .../api/stream/channels/hybrid-timeline.ts | 35 ++++++++++++------- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/packages/backend/src/misc/is-channel-related.ts b/packages/backend/src/misc/is-channel-related.ts index 2494410ae5..4b6614cd28 100644 --- a/packages/backend/src/misc/is-channel-related.ts +++ b/packages/backend/src/misc/is-channel-related.ts @@ -14,12 +14,7 @@ import { Packed } from '@/misc/json-schema.js'; * @param channelIds 確認対象のチャンネルID一覧 */ export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set): boolean { - if (!note.channelId) { - // チャンネル投稿じゃなければ無条件でOK - return true; - } - - if (channelIds.has(note.channelId)) { + if (note.channelId && channelIds.has(note.channelId)) { return true; } @@ -27,7 +22,7 @@ export function isChannelRelated(note: MiNote | Packed<'Note'>, channelIds: Set< return true; } - // NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない + // NOTE: リプライはchannelIdのチェックだけでOKなはずなので見てない(チャンネルのノートにチャンネル外からのリプライまたはその逆はないはずなので) return false; } 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 3b6e678e0a..6bd9f2a68b 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -53,18 +53,29 @@ 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)) || - (note.channelId != null && isChannelRelated(note, this.mutingChannels)) - )) return; + if (!note.channelId) { + // 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする) + // - 自分自身の投稿 + // - その投稿のユーザーをフォローしている + // - 全体公開のローカルの投稿 + if (!( + isMe || + Object.hasOwn(this.following, note.userId) || + (note.user.host == null && note.visibility === 'public') + )) { + return; + } + } else { + // 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする) + // - ミュートしていないチャンネルの投稿(リノート・引用リノートもチェック対象) + // - フォローしているチャンネルの投稿 + if (isChannelRelated(note, this.mutingChannels)) { + return; + } + if (!this.followingChannels.has(note.channelId)) { + return; + } + } if (note.visibility === 'followers') { if (!isMe && !Object.hasOwn(this.following, note.userId)) return; From f7f9df878bb30488d3b34f6aa8fe0b14ff8f30e9 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Thu, 27 Jun 2024 06:36:39 +0900 Subject: [PATCH 13/28] =?UTF-8?q?[wip]=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/test/e2e/timelines.ts | 447 ++++++++++++++++++++++++- 1 file changed, 442 insertions(+), 5 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index f6cc2bac28..0c937fc212 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -3,13 +3,16 @@ * 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 { entities } from 'misskey-js'; import { Redis } from 'ioredis'; import { loadConfig } from '@/config.js'; -import { api, post, randomString, sendEnvUpdateRequest, signup, sleep, uploadUrl } from '../utils.js'; +import { api, post, randomString, sendEnvUpdateRequest, signup, sleep, uploadUrl, UserToken } from '../utils.js'; function genHost() { return randomString() + '.example.com'; @@ -21,6 +24,18 @@ function waitForPushToTl() { let redisForTimelines: Redis; +async function createChannel(name: string, user: UserToken): Promise { + return (await api('channels/create', { name }, user)).body; +} + +function followChannel(channelId: string, user: UserToken) { + return api('channels/follow', { channelId }, user); +} + +function muteChannel(channelId: string, user: UserToken) { + return api('channels/mute/create', { channelId }, user); +} + describe('Timelines', () => { beforeAll(() => { redisForTimelines = new Redis(loadConfig().redisForTimelines); @@ -113,7 +128,12 @@ describe('Timelines', () => { await api('following/update', { userId: bob.id, withReplies: true }, alice); await sleep(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(); @@ -168,7 +188,12 @@ describe('Timelines', () => { await api('following/update', { userId: bob.id, withReplies: true }, alice); await sleep(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(); @@ -452,7 +477,12 @@ describe('Timelines', () => { 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 +513,12 @@ describe('Timelines', () => { 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(); @@ -491,6 +526,140 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); + + describe('Channel', () => { + test.concurrent('フォローしていないチャンネルのノートは含まれない', 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.concurrent('フォローしているチャンネルのノートが含まれる', 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.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', 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.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時は含まれる', 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.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', 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.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', 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.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', 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.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', 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); + }); + }); }); describe('Local TL', () => { @@ -672,6 +841,140 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }, 1000 * 10); + + describe('Channel', () => { + test.concurrent('フォローしていないチャンネルのノートは含まれない', 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.concurrent('フォローしていてもチャンネルのノートは含まれない', 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.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', 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.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時も含まれない', 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.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', 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.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', 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.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', 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.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', 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); + }); + }); }); describe('Social TL', () => { @@ -812,6 +1115,140 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote1.id), false); assert.strictEqual(res.body.some((note: any) => note.id === bobNote2.id), true); }, 1000 * 10); + + describe('Channel', () => { + test.concurrent('フォローしていないチャンネルのノートは含まれない', 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.concurrent('フォローしているチャンネルのノートが含まれる', 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.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', 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.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時は含まれる', 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.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', 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.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', 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.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', 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.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', 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); + }); + }); }); describe('User List TL', () => { From 28fdf1b9a60fab91c3a7ce87de6e06c9c8f44b7a Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sat, 29 Jun 2024 09:42:34 +0900 Subject: [PATCH 14/28] =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=A8=E6=A4=9C=E5=87=BA=E3=81=97=E3=81=9F?= =?UTF-8?q?=E4=B8=8D=E5=82=99=E3=81=AE=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/jest.config.unit.cjs | 1 + .../src/core/ChannelFollowingService.ts | 48 +- .../backend/src/core/ChannelMutingService.ts | 48 +- .../server/api/endpoints/antennas/notes.ts | 18 + .../api/endpoints/notes/hybrid-timeline.ts | 41 +- .../api/endpoints/notes/local-timeline.ts | 21 +- .../server/api/endpoints/notes/timeline.ts | 55 +- .../api/endpoints/notes/user-list-timeline.ts | 14 + .../src/server/api/endpoints/roles/notes.ts | 18 + .../src/server/api/endpoints/users/notes.ts | 31 +- packages/backend/test/e2e/antennas.ts | 21 + packages/backend/test/e2e/timelines.ts | 1283 +++++++++++++++-- packages/backend/test/jest.setup.unit.cjs | 9 + .../test/unit/ChannelFollowingService.ts | 235 +++ .../backend/test/unit/ChannelMutingService.ts | 334 +++++ 15 files changed, 1974 insertions(+), 203 deletions(-) create mode 100644 packages/backend/test/jest.setup.unit.cjs create mode 100644 packages/backend/test/unit/ChannelFollowingService.ts create mode 100644 packages/backend/test/unit/ChannelMutingService.ts 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/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 index 6347972392..bf5b848d44 100644 --- a/packages/backend/src/core/ChannelMutingService.ts +++ b/packages/backend/src/core/ChannelMutingService.ts @@ -5,7 +5,7 @@ import { Inject, Injectable } from '@nestjs/common'; import Redis from 'ioredis'; -import { In } from 'typeorm'; +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'; @@ -47,6 +47,7 @@ export class ChannelMutingService { * ミュートしているチャンネルの一覧を取得する. * @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しない). */ @@ -56,27 +57,42 @@ export class ChannelMutingService { requestUserId: MiUser['id'], }, opts?: { + idOnly?: boolean; joinUser?: boolean; joinBannerFile?: boolean; }, ): Promise { - 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(qb => { - qb.where('channel_muting.expiresAt IS NULL') - .orWhere('channel_muting.expiresAt > :now', { now: new Date() }); - }); + 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() }); + })); - if (opts?.joinUser) { - q.innerJoinAndSelect('channel.user', 'user'); + 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(); } - - if (opts?.joinBannerFile) { - q.leftJoinAndSelect('channel.banner', 'drive_file'); - } - - return q.getMany(); } /** 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/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts index af92ae4849..5e1bca3e99 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -21,6 +21,7 @@ import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ApiError } from '../../error.js'; +import { ChannelFollowingService } from "@/core/ChannelFollowingService.js"; export const meta = { tags: ['notes'], @@ -77,16 +78,14 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.notesRepository) private notesRepository: NotesRepository, - @Inject(DI.channelFollowingsRepository) - private channelFollowingsRepository: ChannelFollowingsRepository, private noteEntityService: NoteEntityService, private roleService: RoleService, private activeUsersChart: ActiveUsersChart, private idService: IdService, - private cacheService: CacheService, private queryService: QueryService, private userFollowingService: UserFollowingService, private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private metaService: MetaService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, ) { @@ -184,12 +183,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 }).then(x => x.map(x => x.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 => { @@ -208,9 +208,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'); @@ -221,23 +219,8 @@ export default class extends Endpoint { // eslint- if (mutingChannelIds.length > 0) { query.andWhere(new Brackets(qb => { - qb - // ミュートしてるチャンネルは含めない - .where(new Brackets(qb2 => { - qb2 - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.channelId IS NOT NULL') - .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })) - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.renoteChannelId IS NOT NULL') - .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); - })) - // チャンネルの投稿ではない - .orWhere('note.channelId IS NULL'); + qb.orWhere('note.renoteChannelId IS NULL'); + qb.orWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); })); } 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 be82b5a8a7..5fe1a27047 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -18,6 +18,7 @@ import { MetaService } from '@/core/MetaService.js'; import { MiLocalUser } from '@/models/User.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { ChannelMutingService } from "@/core/ChannelMutingService.js"; export const meta = { tags: ['notes'], @@ -77,6 +78,7 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, private metaService: MetaService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -123,6 +125,7 @@ export default class extends Endpoint { // eslint- : ['localTimeline'], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ untilId, sinceId, @@ -159,9 +162,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 38b27da1ed..ad5c53f16b 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, ChannelMutingRepository } from '@/models/_.js'; +import type { NotesRepository } 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'; @@ -18,6 +18,7 @@ import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; +import { ChannelFollowingService } from '@/core/ChannelFollowingService.js'; export const meta = { tags: ['notes'], @@ -60,9 +61,6 @@ 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, @@ -70,6 +68,7 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private userFollowingService: UserFollowingService, private channelMutingService: ChannelMutingService, + private channelFollowingService: ChannelFollowingService, private queryService: QueryService, private metaService: MetaService, ) { @@ -144,12 +143,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 }).then(x => x.map(x => x.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) @@ -159,10 +159,9 @@ 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 => { @@ -179,12 +178,18 @@ export default class extends Endpoint { // eslint- 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 (followingChannels.length > 0) { + } else if (followingChannelIds.length > 0) { // チャンネルフォローのみ(ユーザーフォローなし) - const followingChannelIds = followingChannels.map(x => x.followeeId); query.andWhere(new Brackets(qb => { qb + // renoteChannelIdは見る必要が無い + // ・HTLに流れてくるチャンネル=フォローしているチャンネル + // ・HTLにフォロー外のチャンネルが流れるのは、フォローしているユーザがそのチャンネル投稿をリノートした場合のみ + // つまり、ユーザフォローしてない前提のこのブロックでは見る必要が無い .where('note.channelId IN (:...followingChannelIds)', { followingChannelIds }) .orWhere('note.userId = :meId', { meId: me.id }); })); @@ -197,28 +202,6 @@ export default class extends Endpoint { // eslint- })); } - if (mutingChannelIds.length > 0) { - query.andWhere(new Brackets(qb => { - qb - // ミュートしてるチャンネルは含めない - .where(new Brackets(qb2 => { - qb2 - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.channelId IS NOT NULL') - .andWhere('note.channelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })) - .andWhere(new Brackets(qb3 => { - qb3 - .andWhere('note.renoteChannelId IS NOT NULL') - .andWhere('note.renoteChannelId NOT IN (:...mutingChannelIds)', { mutingChannelIds }); - })); - })) - // チャンネルの投稿ではない - .orWhere('note.channelId IS NULL'); - })); - } - query.andWhere(new Brackets(qb => { qb .where('note.replyId IS NULL') // 返信ではない 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 43877e61ef..0483a03a8b 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 @@ -17,6 +17,7 @@ import { MiLocalUser } from '@/models/User.js'; import { MetaService } from '@/core/MetaService.js'; import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js'; import { ApiError } from '../../error.js'; +import { ChannelMutingService } from "@/core/ChannelMutingService.js"; export const meta = { tags: ['notes', 'lists'], @@ -85,6 +86,7 @@ export default class extends Endpoint { // eslint- private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private queryService: QueryService, private metaService: MetaService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -128,6 +130,7 @@ export default class extends Endpoint { // eslint- redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, { untilId, sinceId, @@ -191,6 +194,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 cc76c12f1d..16bf01b215 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -17,6 +17,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'], @@ -69,13 +70,13 @@ export default class extends Endpoint { // eslint- constructor( @Inject(DI.notesRepository) private notesRepository: NotesRepository, - private noteEntityService: NoteEntityService, private queryService: QueryService, private cacheService: CacheService, private idService: IdService, private fanoutTimelineEndpointService: FanoutTimelineEndpointService, private metaService: MetaService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -127,6 +128,7 @@ export default class extends Endpoint { // eslint- excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, + excludeMutedChannels: true, noteFilter: note => { if (note.channel?.isSensitive && !isSelf) return false; if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; @@ -158,6 +160,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) @@ -170,14 +177,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/test/e2e/antennas.ts b/packages/backend/test/e2e/antennas.ts index 6ac14cd8dc..36f596bbba 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 () => { @@ -570,6 +577,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 0c937fc212..f3db48412d 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: AGPL-3.0-only */ +// noinspection JSUnusedLocalSymbols /* eslint-disable @typescript-eslint/no-explicit-any */ // How to run: @@ -12,7 +13,18 @@ import * as assert from 'assert'; import { entities } from 'misskey-js'; import { Redis } from 'ioredis'; import { loadConfig } from '@/config.js'; -import { api, post, randomString, sendEnvUpdateRequest, signup, sleep, uploadUrl, UserToken } from '../utils.js'; +import { afterEach, beforeAll } from '@jest/globals'; +import { + api, + initTestDb, + post, + randomString, + sendEnvUpdateRequest, + signup, + sleep, + uploadUrl, + UserToken, +} from '../utils.js'; function genHost() { return randomString() + '.example.com'; @@ -24,25 +36,77 @@ 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; } -function followChannel(channelId: string, user: UserToken) { - return api('channels/follow', { channelId }, user); +async function followChannel(channelId: string, user: UserToken) { + return await api('channels/follow', { channelId }, user); } -function muteChannel(channelId: string, user: UserToken) { - return api('channels/mute/create', { 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 sleep(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); + + // FTT無効の状態で見たいときはコメントアウトを外す + await api('admin/update-meta', { enableFanoutTimeline: false }, root); + await sleep(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' }); @@ -55,7 +119,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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); @@ -71,7 +135,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -88,7 +152,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -104,7 +168,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -121,7 +185,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -143,7 +207,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); @@ -160,7 +224,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); @@ -180,7 +244,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => note.id === carolNote.id).text, 'hi'); }); - 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); @@ -203,7 +267,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -219,7 +283,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -235,7 +299,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -249,7 +313,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()]); await api('following/create', { userId: bob.id }, alice); @@ -265,7 +329,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -283,7 +347,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -301,7 +365,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -315,7 +379,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -332,7 +396,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -350,7 +414,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -365,7 +429,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -380,7 +444,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -404,7 +468,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -419,7 +483,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -432,7 +496,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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); @@ -447,7 +511,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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] }); @@ -459,7 +523,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -473,7 +537,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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] }); @@ -493,7 +557,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] }); @@ -509,7 +573,7 @@ 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] }); @@ -528,7 +592,7 @@ describe('Timelines', () => { }); describe('Channel', () => { - test.concurrent('フォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -543,7 +607,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているチャンネルのノートが含まれる', async () => { + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -559,7 +623,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -575,7 +639,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時は含まれる', async () => { + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -592,7 +656,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -608,7 +672,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -625,7 +689,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -642,7 +706,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -659,11 +723,151 @@ describe('Timelines', () => { 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); + }); }); }); 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' }); @@ -677,7 +881,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -691,7 +895,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -705,7 +909,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -718,7 +922,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -731,7 +935,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); @@ -747,7 +951,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -763,7 +967,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -780,7 +984,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -798,7 +1002,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -814,7 +1018,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -827,7 +1031,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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'); @@ -843,7 +1047,7 @@ describe('Timelines', () => { }, 1000 * 10); describe('Channel', () => { - test.concurrent('フォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -858,7 +1062,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしていてもチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -874,7 +1078,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -890,7 +1094,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時も含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -907,7 +1111,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -923,7 +1127,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -940,7 +1144,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -957,7 +1161,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -974,11 +1178,151 @@ describe('Timelines', () => { 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' }); @@ -990,7 +1334,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1002,7 +1346,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1016,7 +1360,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1032,7 +1376,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('他人の他人への返信が含まれない', async () => { + test('他人の他人への返信が含まれない', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); const carolNote = await post(carol, { text: 'hi' }); @@ -1046,7 +1390,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1058,7 +1402,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1073,7 +1417,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1088,7 +1432,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1101,7 +1445,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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'); @@ -1117,7 +1461,7 @@ describe('Timelines', () => { }, 1000 * 10); describe('Channel', () => { - test.concurrent('フォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1132,7 +1476,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているチャンネルのノートが含まれる', async () => { + test('チャンネルフォロー + ユーザ未フォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1148,7 +1492,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1164,7 +1508,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('フォローしているユーザがフォローしているチャンネルでノートした時は含まれる', async () => { + test('チャンネルフォロー + ユーザフォロー = TLに流れる', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1181,7 +1525,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), true); }); - test.concurrent('チャンネルミュート中であり、かつフォローしていないチャンネルのノートは含まれない', async () => { + test('チャンネル未フォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1197,7 +1541,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているチャンネルのノートは含まれない', async () => { + test('チャンネルフォロー + ユーザ未フォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); const channel = await createChannel('channel', bob); @@ -1214,7 +1558,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であり、フォローしているユーザがフォローしていないチャンネルでノートした時は含まれない', async () => { + test('チャンネル未フォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1231,7 +1575,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => note.id === bobNote.id), false); }); - test.concurrent('チャンネルミュート中であれば、フォローしているユーザがフォローしているチャンネルでノートした時は含まれない', async () => { + test('チャンネルフォロー + ユーザフォロー + チャンネルミュート = TLに流れない', async () => { const [alice, bob] = await Promise.all([signup(), signup()]); await api('following/create', { userId: bob.id }, alice); @@ -1248,11 +1592,151 @@ describe('Timelines', () => { 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); @@ -1267,7 +1751,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1282,7 +1766,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1297,7 +1781,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1313,7 +1797,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1330,7 +1814,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1347,7 +1831,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1364,7 +1848,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1381,7 +1865,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1397,7 +1881,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1414,7 +1898,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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); @@ -1430,7 +1914,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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); @@ -1446,7 +1930,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1463,7 +1947,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1479,7 +1963,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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); @@ -1494,10 +1978,316 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1509,7 +2299,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1521,7 +2311,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1536,7 +2326,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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' }); @@ -1549,7 +2339,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.find((note: any) => 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); @@ -1562,7 +2352,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1577,7 +2367,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1592,7 +2382,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1607,7 +2397,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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'); @@ -1622,7 +2412,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1635,7 +2425,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1648,7 +2438,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1661,7 +2451,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1676,7 +2466,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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); @@ -1694,7 +2484,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1706,7 +2496,7 @@ describe('Timelines', () => { assert.strictEqual(res.body.some((note: any) => 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' }); @@ -1719,7 +2509,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' }); @@ -1731,7 +2521,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' }); @@ -1744,6 +2534,271 @@ 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); + }); + }); }); // 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..896f9e0b9d --- /dev/null +++ b/packages/backend/test/jest.setup.unit.cjs @@ -0,0 +1,9 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +module.exports = async () => { + // DBはUTC(っぽい)ので、テスト側も合わせておく + process.env.TZ = 'UTC'; +}; 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..5870732e67 --- /dev/null +++ b/packages/backend/test/unit/ChannelMutingService.ts @@ -0,0 +1,334 @@ +/* + * 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 { sleep } from "../utils.js"; + +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 sleep(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 sleep(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); + }); + }); +}); From a685336a8b2ad81e9e298644b4e2fff938000b81 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sun, 30 Jun 2024 22:20:46 +0900 Subject: [PATCH 15/28] fix test --- packages/backend/src/core/entities/ChannelEntityService.ts | 3 +-- packages/backend/test/e2e/timelines.ts | 4 ++-- packages/backend/test/jest.setup.unit.cjs | 1 + 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index bae3d5c117..1a789bc434 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -20,7 +20,6 @@ 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 { isNotNull } from '@/misc/is-not-null.js'; import { DriveFileEntityService } from './DriveFileEntityService.js'; import { NoteEntityService } from './NoteEntityService.js'; @@ -98,7 +97,7 @@ export class ChannelEntityService { ...( opts?.pinnedNotes // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - ? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(isNotNull) + ? channel.pinnedNoteIds.map(it => opts.pinnedNotes!.get(it)).filter(it => it != null) : await this.notesRepository.findBy({ id: In(channel.pinnedNoteIds) }) ), ); diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index f3db48412d..2afdbb203c 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -3,7 +3,6 @@ * SPDX-License-Identifier: AGPL-3.0-only */ -// noinspection JSUnusedLocalSymbols /* eslint-disable @typescript-eslint/no-explicit-any */ // How to run: @@ -12,8 +11,8 @@ import * as assert from 'assert'; import { entities } from 'misskey-js'; import { Redis } from 'ioredis'; -import { loadConfig } from '@/config.js'; import { afterEach, beforeAll } from '@jest/globals'; +import { loadConfig } from '@/config.js'; import { api, initTestDb, @@ -91,6 +90,7 @@ describe('Timelines', () => { beforeAll(async () => { redisForTimelines = new Redis(loadConfig().redisForTimelines); + root = await signup({ username: 'root' }); // FTT無効の状態で見たいときはコメントアウトを外す await api('admin/update-meta', { enableFanoutTimeline: false }, root); diff --git a/packages/backend/test/jest.setup.unit.cjs b/packages/backend/test/jest.setup.unit.cjs index 896f9e0b9d..dd879c81c8 100644 --- a/packages/backend/test/jest.setup.unit.cjs +++ b/packages/backend/test/jest.setup.unit.cjs @@ -6,4 +6,5 @@ module.exports = async () => { // DBはUTC(っぽい)ので、テスト側も合わせておく process.env.TZ = 'UTC'; + process.env.NODE_ENV = 'test'; }; From 555476135466ea179a3f6e762a6adcb625ba931e Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sun, 30 Jun 2024 22:24:53 +0900 Subject: [PATCH 16/28] fix CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a28c9ef64..95c35f58db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### General - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 +- Feat: チャンネルミュート機能の実装 #10649 - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 From ac728dd3fb7f775c081d682e5780706ff612dd53 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sun, 30 Jun 2024 22:25:27 +0900 Subject: [PATCH 17/28] =?UTF-8?q?=E9=80=9A=E5=B8=B8=E3=81=AFFTT=E3=81=AB?= =?UTF-8?q?=E3=81=97=E3=81=A6=E3=81=8A=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/backend/test/e2e/timelines.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 2afdbb203c..c5e94fa755 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -93,8 +93,8 @@ describe('Timelines', () => { root = await signup({ username: 'root' }); // FTT無効の状態で見たいときはコメントアウトを外す - await api('admin/update-meta', { enableFanoutTimeline: false }, root); - await sleep(1000); + // await api('admin/update-meta', { enableFanoutTimeline: false }, root); + // await sleep(1000); }); afterEach(async () => { From 3514c9f68c4381dc086ba82637f6299d9805eebb Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Tue, 2 Jul 2024 08:19:04 +0900 Subject: [PATCH 18/28] =?UTF-8?q?=E5=AE=9F=E8=A3=85=E5=BF=98=E3=82=8C?= =?UTF-8?q?=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/api/endpoints/channels/timeline.ts | 19 +++++++++++ .../backend/src/server/api/stream/channel.ts | 4 +++ .../src/server/api/stream/channels/channel.ts | 34 +++++++++++++++++-- .../api/stream/channels/home-timeline.ts | 4 +-- .../api/stream/channels/hybrid-timeline.ts | 4 --- 5 files changed, 57 insertions(+), 8 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 8c55673590..195a4410ca 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -15,6 +15,8 @@ import { CacheService } from '@/core/CacheService.js'; import { MetaService } from '@/core/MetaService.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 = { @@ -71,6 +73,7 @@ export default class extends Endpoint { // eslint- private cacheService: CacheService, private activeUsersChart: ActiveUsersChart, private metaService: MetaService, + private channelMutingService: ChannelMutingService, ) { super(meta, paramDef, async (ps, me) => { const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null); @@ -92,6 +95,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, @@ -101,6 +107,11 @@ export default class extends Endpoint { // eslint- useDbFallback: true, redisTimelines: [`channelTimeline:${channel.id}`], excludePureRenotes: false, + noteFilter: note => { + // 共通機能を使うと見ているチャンネルそのものもミュートしてしまうので閲覧中のチャンネル以外を除く形にする + if (note.channelId === channel.id) return true; + return !isChannelRelated(note, mutingChannelIds); + }, dbFallback: async (untilId, sinceId, limit) => { return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me); }, @@ -125,6 +136,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/stream/channel.ts b/packages/backend/src/server/api/stream/channel.ts index da6cf39889..7a83cf63f9 100644 --- a/packages/backend/src/server/api/stream/channel.ts +++ b/packages/backend/src/server/api/stream/channel.ts @@ -8,6 +8,7 @@ import { isInstanceMuted } from '@/misc/is-instance-muted.js'; import { isUserRelated } from '@/misc/is-user-related.js'; import { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; import type { Packed } from '@/misc/json-schema.js'; +import { isChannelRelated } from '@/misc/is-channel-related.js'; import type Connection from './Connection.js'; /** @@ -77,6 +78,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 140dd3dd9b..7f24c347c3 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -7,7 +7,9 @@ import { Injectable } from '@nestjs/common'; 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 { isQuotePacked, isRenotePacked } from '@/misc/is-renote.js'; +import { isInstanceMuted } from '@/misc/is-instance-muted.js'; +import { isUserRelated } from '@/misc/is-user-related.js'; import Channel, { type MiChannelService } from '../channel.js'; class ChannelChannel extends Channel { @@ -18,7 +20,6 @@ class ChannelChannel extends Channel { constructor( private noteEntityService: NoteEntityService, - id: string, connection: Channel['connection'], ) { @@ -52,6 +53,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 f450336914..5fe1ce6ee0 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -44,8 +44,8 @@ class HomeTimelineChannel extends Channel { if (this.withFiles && (note.fileIds == null || note.fileIds.length === 0)) return; if (note.channelId) { - // そのチャンネルをフォローしていない or そのチャンネル(リノート・引用リノート含む)はミュートしている - if (!this.followingChannels.has(note.channelId) || isChannelRelated(note, this.mutingChannels)) { + // そのチャンネルをフォローしていない + if (!this.followingChannels.has(note.channelId)) { return; } } else { 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 6bd9f2a68b..c706f87158 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -67,11 +67,7 @@ class HybridTimelineChannel extends Channel { } } else { // 以下の条件に該当するノートのみ後続処理に通す(ので、以下のif文は該当しないノートをすべて弾くようにする) - // - ミュートしていないチャンネルの投稿(リノート・引用リノートもチェック対象) // - フォローしているチャンネルの投稿 - if (isChannelRelated(note, this.mutingChannels)) { - return; - } if (!this.followingChannels.has(note.channelId)) { return; } From 50e1ee18fa55e7270bd83c38cc5eae0c8559570a Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:18:07 +0900 Subject: [PATCH 19/28] fix merge --- packages/backend/test/e2e/timelines.ts | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 7df65a5986..7ab8dcf46e 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -13,17 +13,8 @@ import { setTimeout } from 'node:timers/promises'; import { entities } from 'misskey-js'; import { Redis } from 'ioredis'; import { afterEach, beforeAll } from '@jest/globals'; +import { api, initTestDb, post, randomString, sendEnvUpdateRequest, signup, uploadUrl, UserToken } from '../utils.js'; import { loadConfig } from '@/config.js'; -import { - api, - initTestDb, - post, - randomString, - sendEnvUpdateRequest, - signup, - uploadUrl, - UserToken, -} from '../utils.js'; function genHost() { return randomString() + '.example.com'; @@ -59,7 +50,7 @@ async function pushList(listId: string, pushUserIds: string[] = [], user: UserTo for (const userId of pushUserIds) { await api('users/lists/push', { listId, userId }, user); } - await sleep(500); + await setTimeout(500); } async function createRole(name: string, user: UserToken): Promise { From b78aa56dcd6001489a41af55bbab1e7a87f2c866 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Wed, 3 Jul 2024 07:22:41 +0900 Subject: [PATCH 20/28] fix merge --- .../backend/test/unit/ChannelMutingService.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/backend/test/unit/ChannelMutingService.ts b/packages/backend/test/unit/ChannelMutingService.ts index 5870732e67..462f63f9c4 100644 --- a/packages/backend/test/unit/ChannelMutingService.ts +++ b/packages/backend/test/unit/ChannelMutingService.ts @@ -14,15 +14,17 @@ import { IdService } from '@/core/IdService.js'; import { ChannelMutingService } from '@/core/ChannelMutingService.js'; import { ChannelMutingRepository, - ChannelsRepository, DriveFilesRepository, + ChannelsRepository, + DriveFilesRepository, MiChannel, - MiChannelMuting, MiDriveFile, + MiChannelMuting, + MiDriveFile, MiUser, UserProfilesRepository, UsersRepository, } from '@/models/_.js'; import { DI } from '@/di-symbols.js'; -import { sleep } from "../utils.js"; +import { setTimeout } from 'node:timers/promises'; describe('ChannelMutingService', () => { let app: TestingModule; @@ -130,8 +132,8 @@ describe('ChannelMutingService', () => { 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 }); + channel2 = await createChannel({ name: 'channel2', userId: alice.id, bannerId: driveFile2.id }); + channel3 = await createChannel({ name: 'channel3', userId: alice.id, bannerId: driveFile2.id }); }); afterEach(async () => { @@ -269,7 +271,7 @@ describe('ChannelMutingService', () => { await service.mute({ requestUserId: alice.id, targetChannelId: channel1.id }); await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); - await sleep(500); + await setTimeout(500); const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); @@ -279,7 +281,7 @@ describe('ChannelMutingService', () => { test('isMuted: false', async () => { await service.mute({ requestUserId: alice.id, targetChannelId: channel2.id }); - await sleep(500); + await setTimeout(500); const result = await service.isMuted({ requestUserId: alice.id, targetChannelId: channel1.id }); From 915225538b69cd1f5b2c7c444f21e1234e0bcac0 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sat, 6 Jul 2024 22:39:02 +0900 Subject: [PATCH 21/28] add channel tl test --- .../server/api/endpoints/channels/timeline.ts | 2 +- packages/backend/test/e2e/timelines.ts | 187 +++++++++++++++++- 2 files changed, 187 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 195a4410ca..8082f7560d 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -109,7 +109,7 @@ export default class extends Endpoint { // eslint- excludePureRenotes: false, noteFilter: note => { // 共通機能を使うと見ているチャンネルそのものもミュートしてしまうので閲覧中のチャンネル以外を除く形にする - if (note.channelId === channel.id) return true; + if (note.channelId === channel.id && (note.renoteChannelId === null || note.renoteChannelId === channel.id)) return true; return !isChannelRelated(note, mutingChannelIds); }, dbFallback: async (untilId, sinceId, limit) => { diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index 7ab8dcf46e..9185dcc8f4 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -85,7 +85,7 @@ describe('Timelines', () => { // FTT無効の状態で見たいときはコメントアウトを外す // await api('admin/update-meta', { enableFanoutTimeline: false }, root); - // await sleep(1000); + // await setTimeout(1000); }); afterEach(async () => { @@ -2792,6 +2792,191 @@ describe('Timelines', () => { }); }); + 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: ページネーションのテスト }); From 0f574b7fd665f4986766839b1dd583953e5437c1 Mon Sep 17 00:00:00 2001 From: samunohito <46447427+samunohito@users.noreply.github.com> Date: Sun, 7 Jul 2024 11:37:29 +0900 Subject: [PATCH 22/28] fix CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae6ae26167..be52552c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### General - Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705 - Feat: チャンネルミュート機能の実装 #10649 + - チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列) - Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正 - Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題 From b6e22f3ad864d65c1cbee0e15faecea74561a589 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Sat, 5 Oct 2024 18:19:46 +0900 Subject: [PATCH 23/28] remove unused import --- packages/backend/src/server/api/stream/channels/home-timeline.ts | 1 - .../backend/src/server/api/stream/channels/hybrid-timeline.ts | 1 - 2 files changed, 2 deletions(-) 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 29f3401c33..825f71180d 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -8,7 +8,6 @@ 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 { isChannelRelated } from '@/misc/is-channel-related.js'; import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; 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 b4359e8c77..836097ad36 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -10,7 +10,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js'; -import { isChannelRelated } from '@/misc/is-channel-related.js'; import type { JsonObject } from '@/misc/json-value.js'; import Channel, { type MiChannelService } from '../channel.js'; From 986e7edc1dd84d3e9fdd0102934147449f712e14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:18:52 +0900 Subject: [PATCH 24/28] fix lint --- packages/backend/src/core/WebhookTestService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts index 4c45b95a64..3bfcf97ec6 100644 --- a/packages/backend/src/core/WebhookTestService.ts +++ b/packages/backend/src/core/WebhookTestService.ts @@ -133,6 +133,7 @@ function generateDummyNote(override?: Partial): MiNote { replyUserHost: null, renoteUserId: null, renoteUserHost: null, + renoteChannelId: null, ...override, }; } From 54db85afff3a77f7a444e4c24713b313d1646fc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Sat, 5 Oct 2024 19:56:04 +0900 Subject: [PATCH 25/28] fix test --- packages/backend/test/e2e/timelines.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/backend/test/e2e/timelines.ts b/packages/backend/test/e2e/timelines.ts index b5b41512ab..7cac8a1254 100644 --- a/packages/backend/test/e2e/timelines.ts +++ b/packages/backend/test/e2e/timelines.ts @@ -236,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); @@ -875,7 +875,7 @@ describe('Timelines', () => { }); }); - test.concurrent('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { + test('FTT: ローカルユーザーの HTL にはプッシュされる', async () => { const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]); await api('following/create', { @@ -897,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', { @@ -1066,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); @@ -1439,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); @@ -1457,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); @@ -1477,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); @@ -1551,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); From 4827fbdd1044c65613441e0dbcbb175140de1eab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Fri, 15 Nov 2024 22:03:21 +0900 Subject: [PATCH 26/28] fix favorite -> favorited --- packages/backend/src/core/entities/ChannelEntityService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/core/entities/ChannelEntityService.ts b/packages/backend/src/core/entities/ChannelEntityService.ts index 1a789bc434..9ee918bea3 100644 --- a/packages/backend/src/core/entities/ChannelEntityService.ts +++ b/packages/backend/src/core/entities/ChannelEntityService.ts @@ -66,7 +66,7 @@ export class ChannelEntityService { } let isFollowing = false; - let isFavorite = false; + let isFavorited = false; let isMuting = false; if (me) { isFollowing = opts?.followings?.has(channel.id) ?? await this.channelFollowingsRepository.exists({ @@ -76,7 +76,7 @@ export class ChannelEntityService { }, }); - isFavorite = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({ + isFavorited = opts?.favorites?.has(channel.id) ?? await this.channelFavoritesRepository.exists({ where: { userId: me.id, channelId: channel.id, @@ -121,7 +121,7 @@ export class ChannelEntityService { ...(me ? { isFollowing, - isFavorite, + isFavorited, isMuting, hasUnreadNote: false, // 後方互換性のため } : {}), From d95543d05b89579e2a48fd7c62e2ff956b3a2bdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Fri, 15 Nov 2024 22:26:33 +0900 Subject: [PATCH 27/28] exclude -> include --- packages/backend/src/core/FanoutTimelineEndpointService.ts | 6 +++--- .../backend/src/server/api/endpoints/channels/timeline.ts | 1 + .../src/server/api/endpoints/notes/hybrid-timeline.ts | 3 +-- .../src/server/api/endpoints/notes/local-timeline.ts | 1 - packages/backend/src/server/api/endpoints/notes/timeline.ts | 1 - .../src/server/api/endpoints/notes/user-list-timeline.ts | 3 +-- packages/backend/src/server/api/endpoints/users/notes.ts | 1 - 7 files changed, 6 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/core/FanoutTimelineEndpointService.ts b/packages/backend/src/core/FanoutTimelineEndpointService.ts index fcf6b5f84b..87bc5e79f0 100644 --- a/packages/backend/src/core/FanoutTimelineEndpointService.ts +++ b/packages/backend/src/core/FanoutTimelineEndpointService.ts @@ -35,7 +35,7 @@ type TimelineOptions = { excludeNoFiles?: boolean; excludeReplies?: boolean; excludePureRenotes: boolean; - excludeMutedChannels?: boolean; + includeMutedChannels?: boolean; dbFallback: (untilId: string | null, sinceId: string | null, limit: number) => Promise, }; @@ -111,7 +111,7 @@ export class FanoutTimelineEndpointService { 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.excludeMutedChannels ? this.channelMutingService.mutingChannelsCache.fetch(me.id) : Promise.resolve(new Set()), + ps.includeMutedChannels ? Promise.resolve(new Set()) : this.channelMutingService.mutingChannelsCache.fetch(me.id), ]); const parentFilter = filter; @@ -120,7 +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.excludeMutedChannels && isChannelRelated(note, userMutedChannels)) return false; + if (!ps.includeMutedChannels && isChannelRelated(note, userMutedChannels)) return false; return parentFilter(note); }; diff --git a/packages/backend/src/server/api/endpoints/channels/timeline.ts b/packages/backend/src/server/api/endpoints/channels/timeline.ts index 04d83c58ce..b818282341 100644 --- a/packages/backend/src/server/api/endpoints/channels/timeline.ts +++ b/packages/backend/src/server/api/endpoints/channels/timeline.ts @@ -104,6 +104,7 @@ 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; 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 2a190e48a7..d514f5bb43 100644 --- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -19,8 +19,8 @@ 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'; -import { ChannelFollowingService } from "@/core/ChannelFollowingService.js"; export const meta = { tags: ['notes'], @@ -159,7 +159,6 @@ export default class extends Endpoint { // eslint- useDbFallback: this.serverSettings.enableFanoutTimelineDbFallback, alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, - excludeMutedChannels: true, noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; 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 bf569593da..9c4812cbbf 100644 --- a/packages/backend/src/server/api/endpoints/notes/local-timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/local-timeline.ts @@ -122,7 +122,6 @@ export default class extends Endpoint { // eslint- : ['localTimeline'], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, - excludeMutedChannels: true, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb({ untilId, sinceId, diff --git a/packages/backend/src/server/api/endpoints/notes/timeline.ts b/packages/backend/src/server/api/endpoints/notes/timeline.ts index 0235d39685..b745167ec8 100644 --- a/packages/backend/src/server/api/endpoints/notes/timeline.ts +++ b/packages/backend/src/server/api/endpoints/notes/timeline.ts @@ -112,7 +112,6 @@ export default class extends Endpoint { // eslint- redisTimelines: ps.withFiles ? [`homeTimelineWithFiles:${me.id}`] : [`homeTimeline:${me.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, - excludeMutedChannels: true, noteFilter: note => { if (note.reply && note.reply.visibility === 'followers') { if (!Object.hasOwn(followings, note.reply.userId) && note.reply.userId !== me.id) return false; 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 fd683891f4..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,8 +14,8 @@ 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'; -import { ChannelMutingService } from "@/core/ChannelMutingService.js"; export const meta = { tags: ['notes', 'lists'], @@ -127,7 +127,6 @@ export default class extends Endpoint { // eslint- redisTimelines: ps.withFiles ? [`userListTimelineWithFiles:${list.id}`] : [`userListTimeline:${list.id}`], alwaysIncludeMyNotes: true, excludePureRenotes: !ps.withRenotes, - excludeMutedChannels: true, dbFallback: async (untilId, sinceId, limit) => await this.getFromDb(list, { untilId, sinceId, diff --git a/packages/backend/src/server/api/endpoints/users/notes.ts b/packages/backend/src/server/api/endpoints/users/notes.ts index ed77ca75a0..72561dfa92 100644 --- a/packages/backend/src/server/api/endpoints/users/notes.ts +++ b/packages/backend/src/server/api/endpoints/users/notes.ts @@ -133,7 +133,6 @@ export default class extends Endpoint { // eslint- excludeReplies: ps.withChannelNotes && !ps.withReplies, // userTimelineWithChannel may include replies excludeNoFiles: ps.withChannelNotes && ps.withFiles, // userTimelineWithChannel may include notes without files excludePureRenotes: !ps.withRenotes, - excludeMutedChannels: true, noteFilter: note => { if (note.channel?.isSensitive && !isSelf) return false; if (note.visibility === 'specified' && (!me || (me.id !== note.userId && !note.visibleUserIds.some(v => v === me.id)))) return false; From acba767fe0c31e20b6a914775978fc4de6236716 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=81=8A=E3=81=95=E3=82=80=E3=81=AE=E3=81=B2=E3=81=A8?= <46447427+samunohito@users.noreply.github.com> Date: Fri, 15 Nov 2024 22:30:54 +0900 Subject: [PATCH 28/28] fix CHANGELOG.md --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ae28693b..01e0e9338d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ ### General - Feat: コンテンツの表示にログインを必須にできるように - Feat: 過去のノートを非公開化/フォロワーのみ表示可能にできるように +- Feat: チャンネルミュート機能の実装 #10649 + - チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列) - Enhance: 依存関係の更新 - Enhance: l10nの更新 @@ -96,8 +98,6 @@ - Feat: サーバー初期設定時に初期パスワードを設定できるように - Feat: 通報にモデレーションノートを残せるように - Feat: 通報の解決種別を設定できるように -- Feat: チャンネルミュート機能の実装 #10649 - - チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列) - Enhance: 通報の解決と転送を個別に行えるように - Enhance: セキュリティ向上のため、サインイン時もCAPTCHAを求めるようになりました - Enhance: 依存関係の更新