mirror of
https://github.com/misskey-dev/misskey.git
synced 2026-05-06 01:15:56 +02:00
feat: チャンネルミュートの実装 (#14105)
* add channel_muting table and entities
* add channel_muting services
* タイムライン取得処理への組み込み
* misskey-jsの型とインターフェース生成
* Channelスキーマにミュート情報を追加
* フロントエンドの実装
* 条件が逆だったのを修正
* 期限切れミュートを掃除する機能を実装
* TLの抽出条件調節
* 名前の変更と変更不要の差分をロールバック
* 修正漏れ
* isChannelRelatedの条件に誤りがあった
* [wip] テスト追加
* テストの追加と検出した不備の修正
* fix test
* fix CHANGELOG.md
* 通常はFTTにしておく
* 実装忘れ対応
* fix merge
* fix merge
* add channel tl test
* fix CHANGELOG.md
* remove unused import
* fix lint
* fix test
* fix favorite -> favorited
* exclude -> include
* fix CHANGELOG.md
* fix CHANGELOG.md
* maintenance
* fix CHANGELOG.md
* fix
* fix ci
* regenerate
* fix
* Revert "fix"
This reverts commit 699d50c6ec.
* fixed
---------
Co-authored-by: syuilo <4439005+syuilo@users.noreply.github.com>
This commit is contained in:
@@ -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<typeof meta, typeof paramDef> { // 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,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
|
||||
constructor(
|
||||
@Inject(DI.channelsRepository)
|
||||
private channelsRepository: ChannelsRepository,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Check if exists the channel
|
||||
const targetChannel = await this.channelsRepository.findOneBy({ id: ps.channelId });
|
||||
if (!targetChannel) {
|
||||
throw new ApiError(meta.errors.noSuchChannel);
|
||||
}
|
||||
|
||||
// Check muting
|
||||
const exist = await this.channelMutingService.isMuted({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
if (!exist) {
|
||||
throw new ApiError(meta.errors.notMuting);
|
||||
}
|
||||
|
||||
await this.channelMutingService.unmute({
|
||||
requestUserId: me.id,
|
||||
targetChannelId: targetChannel.id,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<typeof meta, typeof paramDef> { // 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
|
||||
import { MiLocalUser } from '@/models/User.js';
|
||||
import { ChannelMutingService } from '@/core/ChannelMutingService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
@@ -70,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
private queryService: QueryService,
|
||||
private fanoutTimelineEndpointService: FanoutTimelineEndpointService,
|
||||
private activeUsersChart: ActiveUsersChart,
|
||||
private channelMutingService: ChannelMutingService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
|
||||
@@ -98,6 +100,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
useDbFallback: true,
|
||||
redisTimelines: [`channelTimeline:${channel.id}`],
|
||||
excludePureRenotes: false,
|
||||
ignoreAuthorChannelFromMute: true,
|
||||
dbFallback: async (untilId, sinceId, limit) => {
|
||||
return await this.getFromDb({ untilId, sinceId, limit, channelId: channel.id }, me);
|
||||
},
|
||||
@@ -122,6 +125,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
||||
.leftJoinAndSelect('note.channel', 'channel');
|
||||
|
||||
this.queryService.generateBaseNoteFilteringQuery(query, me);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
//#endregion
|
||||
|
||||
return await query.limit(ps.limit).getMany();
|
||||
|
||||
Reference in New Issue
Block a user