Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: チャンネルミュートの実装 #14105

Open
wants to merge 35 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
cbc256b
add channel_muting table and entities
samunohito Jun 10, 2024
94ededa
add channel_muting services
samunohito Jun 10, 2024
7d7c2d4
タイムライン取得処理への組み込み
samunohito Jun 11, 2024
a46fefd
misskey-jsの型とインターフェース生成
samunohito Jun 11, 2024
fdf2b8c
Channelスキーマにミュート情報を追加
samunohito Jun 11, 2024
de238b7
フロントエンドの実装
samunohito Jun 11, 2024
fa8d905
条件が逆だったのを修正
samunohito Jun 11, 2024
ae485ed
期限切れミュートを掃除する機能を実装
samunohito Jun 11, 2024
a56c680
TLの抽出条件調節
samunohito Jun 11, 2024
b5ccf1b
名前の変更と変更不要の差分をロールバック
samunohito Jun 11, 2024
efee424
修正漏れ
samunohito Jun 11, 2024
491541f
isChannelRelatedの条件に誤りがあった
samunohito Jun 11, 2024
f7f9df8
[wip] テスト追加
samunohito Jun 26, 2024
28fdf1b
テストの追加と検出した不備の修正
samunohito Jun 29, 2024
a685336
fix test
samunohito Jun 30, 2024
5554761
fix CHANGELOG.md
samunohito Jun 30, 2024
ac728dd
通常はFTTにしておく
samunohito Jun 30, 2024
3514c9f
実装忘れ対応
samunohito Jul 1, 2024
81d883c
Merge branch 'refs/heads/develop' into feature/channel_muting
samunohito Jul 2, 2024
50e1ee1
fix merge
samunohito Jul 2, 2024
b78aa56
fix merge
samunohito Jul 2, 2024
9152255
add channel tl test
samunohito Jul 6, 2024
012e3fe
Merge branch 'develop' into feature/channel_muting
samunohito Jul 7, 2024
0f574b7
fix CHANGELOG.md
samunohito Jul 7, 2024
5595e8f
Merge branch 'develop' into feature/channel_muting
samunohito Oct 5, 2024
b6e22f3
remove unused import
samunohito Oct 5, 2024
ebba83f
Merge branch 'develop' into feature/channel_muting
samunohito Oct 5, 2024
986e7ed
fix lint
samunohito Oct 5, 2024
54db85a
fix test
samunohito Oct 5, 2024
2a2a379
Merge branch 'develop' into feature/channel_muting
samunohito Nov 15, 2024
4827fbd
fix favorite -> favorited
samunohito Nov 15, 2024
d95543d
exclude -> include
samunohito Nov 15, 2024
acba767
fix CHANGELOG.md
samunohito Nov 15, 2024
5437289
Merge branch 'develop' into feature/channel_muting
samunohito Nov 22, 2024
c17aa8e
fix CHANGELOG.md
samunohito Nov 22, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### General
- Feat: 通報を受けた際、または解決した際に、予め登録した宛先に通知を飛ばせるように(mail or webhook) #13705
- Feat: チャンネルミュート機能の実装 #10649
- チャンネルの概要画面の右上からミュートできます(リンクコピー、共有、設定と同列)
- Fix: 配信停止したインスタンス一覧が見れなくなる問題を修正
- Fix: Dockerコンテナの立ち上げ時に`pnpm`のインストールで固まることがある問題

Expand Down
1 change: 1 addition & 0 deletions packages/backend/jest.config.unit.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const base = require('./jest.config.cjs')

module.exports = {
...base,
globalSetup: "<rootDir>/test/jest.setup.unit.cjs",
testMatch: [
"<rootDir>/test/unit/**/*.ts",
"<rootDir>/src/**/*.test.ts",
Expand Down
42 changes: 42 additions & 0 deletions packages/backend/migration/1718015380000-add-channel-muting.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

export class AddChannelMuting1718015380000 {
name = 'AddChannelMuting1718015380000'

async up(queryRunner) {
await queryRunner.query(`
CREATE TABLE "channel_muting"
(
"id" varchar(32) NOT NULL,
"userId" varchar(32) NOT NULL,
"channelId" varchar(32) NOT NULL,
"expiresAt" timestamp with time zone,
CONSTRAINT "PK_channel_muting_id" PRIMARY KEY ("id"),
CONSTRAINT "FK_channel_muting_userId" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_channel_muting_channelId" FOREIGN KEY ("channelId") REFERENCES "channel"("id") ON DELETE CASCADE ON UPDATE NO ACTION
);
CREATE INDEX "IDX_channel_muting_userId" ON "channel_muting" ("userId");
CREATE INDEX "IDX_channel_muting_channelId" ON "channel_muting" ("channelId");

ALTER TABLE note ADD "renoteChannelId" varchar(32);
COMMENT ON COLUMN note."renoteChannelId" is '[Denormalized]';
`);
}

async down(queryRunner) {
await queryRunner.query(`
ALTER TABLE note DROP COLUMN "renoteChannelId";

ALTER TABLE "channel_muting"
DROP CONSTRAINT "FK_channel_muting_userId";
ALTER TABLE "channel_muting"
DROP CONSTRAINT "FK_channel_muting_channelId";
DROP INDEX "IDX_channel_muting_userId";
DROP INDEX "IDX_channel_muting_channelId";
DROP TABLE "channel_muting";
`);
}
}
48 changes: 47 additions & 1 deletion packages/backend/src/core/ChannelFollowingService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -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<MiChannel[]> {
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,
Expand Down
224 changes: 224 additions & 0 deletions packages/backend/src/core/ChannelMutingService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/

import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import { Brackets, In } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { ChannelMutingRepository, ChannelsRepository, MiChannel, MiChannelMuting, MiUser } from '@/models/_.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js';
import { RedisKVCache } from '@/misc/cache.js';

@Injectable()
export class ChannelMutingService {
public mutingChannelsCache: RedisKVCache<Set<string>>;

constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.channelsRepository)
private channelsRepository: ChannelsRepository,
@Inject(DI.channelMutingRepository)
private channelMutingRepository: ChannelMutingRepository,
private idService: IdService,
private globalEventService: GlobalEventService,
) {
this.mutingChannelsCache = new RedisKVCache<Set<string>>(this.redisClient, 'channelMutingChannels', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (userId) => this.channelMutingRepository.find({
where: { userId: userId },
select: ['channelId'],
}).then(xs => new Set(xs.map(x => x.channelId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});

this.redisForSub.on('message', this.onMessage);
}

/**
* ミュートしているチャンネルの一覧を取得する.
* @param params
* @param [opts]
* @param {(boolean|undefined)} [opts.idOnly=false] チャンネルIDのみを取得するかどうか. ID以外のフィールドに値がセットされなくなり、他テーブルとのJOINも一切されなくなるので注意.
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルオーナーのユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinBannerFile=undefined] バナー画像のドライブファイルをJOINするかどうか(falseまたは省略時はJOINしない).
*/
@bindThis
public async list(
params: {
requestUserId: MiUser['id'],
},
opts?: {
idOnly?: boolean;
joinUser?: boolean;
joinBannerFile?: boolean;
},
): Promise<MiChannel[]> {
if (opts?.idOnly) {
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
.select('channel_muting.channelId')
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
.andWhere(new Brackets(qb => {
qb.where('channel_muting.expiresAt IS NULL')
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
}));

return q
.getRawMany<{ channel_muting_channelId: string }>()
.then(xs => xs.map(x => ({ id: x.channel_muting_channelId } as MiChannel)));
} else {
const q = this.channelsRepository.createQueryBuilder('channel')
.innerJoin('channel_muting', 'channel_muting', 'channel_muting.channelId = channel.id')
.where('channel_muting.userId = :userId', { userId: params.requestUserId })
.andWhere(new Brackets(qb => {
qb.where('channel_muting.expiresAt IS NULL')
.orWhere('channel_muting.expiresAt > :now', { now: new Date() });
}));

if (opts?.joinUser) {
q.innerJoinAndSelect('channel.user', 'user');
}

if (opts?.joinBannerFile) {
q.leftJoinAndSelect('channel.banner', 'drive_file');
}

return q.getMany();
}
}

/**
* 期限切れのチャンネルミュート情報を取得する.
*
* @param [opts]
* @param {(boolean|undefined)} [opts.joinUser=undefined] チャンネルミュートを設定したユーザ情報をJOINするかどうか(falseまたは省略時はJOINしない).
* @param {(boolean|undefined)} [opts.joinChannel=undefined] ミュート先のチャンネル情報をJOINするかどうか(falseまたは省略時はJOINしない).
*/
public async findExpiredMutings(opts?: {
joinUser?: boolean;
joinChannel?: boolean;
}): Promise<MiChannelMuting[]> {
const now = new Date();
const q = this.channelMutingRepository.createQueryBuilder('channel_muting')
.where('channel_muting.expiresAt < :now', { now });

if (opts?.joinUser) {
q.innerJoinAndSelect('channel_muting.user', 'user');
}

if (opts?.joinChannel) {
q.leftJoinAndSelect('channel_muting.channel', 'channel');
}

return q.getMany();
}

/**
* 既にミュートされているかどうかをキャッシュから取得する.
* @param params
* @param params.requestUserId
*/
@bindThis
public async isMuted(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
}): Promise<boolean> {
const mutedChannels = await this.mutingChannelsCache.get(params.requestUserId);
return (mutedChannels?.has(params.targetChannelId) ?? false);
}

/**
* チャンネルをミュートする.
* @param params
* @param {(Date|null|undefined)} [params.expiresAt] ミュートの有効期限. nullまたは省略時は無期限.
*/
@bindThis
public async mute(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
expiresAt?: Date | null,
}): Promise<void> {
await this.channelMutingRepository.insert({
id: this.idService.gen(),
userId: params.requestUserId,
channelId: params.targetChannelId,
expiresAt: params.expiresAt,
});

this.globalEventService.publishInternalEvent('muteChannel', {
userId: params.requestUserId,
channelId: params.targetChannelId,
});
}

/**
* チャンネルのミュートを解除する.
* @param params
*/
@bindThis
public async unmute(params: {
requestUserId: MiUser['id'],
targetChannelId: MiChannel['id'],
}): Promise<void> {
await this.channelMutingRepository.delete({
userId: params.requestUserId,
channelId: params.targetChannelId,
});

this.globalEventService.publishInternalEvent('unmuteChannel', {
userId: params.requestUserId,
channelId: params.targetChannelId,
});
}

/**
* 期限切れのチャンネルミュート情報を削除する.
*/
@bindThis
public async eraseExpiredMutings(): Promise<void> {
const expiredMutings = await this.findExpiredMutings();
await this.channelMutingRepository.delete({ id: In(expiredMutings.map(x => x.id)) });

const userIds = [...new Set(expiredMutings.map(x => x.userId))];
for (const userId of userIds) {
this.mutingChannelsCache.refresh(userId).then();
}
}

@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);

if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'muteChannel': {
this.mutingChannelsCache.refresh(body.userId).then();
break;
}
case 'unmuteChannel': {
this.mutingChannelsCache.delete(body.userId).then();
break;
}
}
}
}

@bindThis
public dispose(): void {
this.mutingChannelsCache.dispose();
}

@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
}
Loading
Loading