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; }