feat: Per-user renote mute (#10249)

* feat: per-user renote muting

From FoundKey/c414f24a2c https://akkoma.dev/FoundKeyGang/FoundKey

* Update ja-JP.yml

* Delete renote-muting.ts

* rename

* fix ids

* lint

* fix

* Update CHANGELOG.md

* リノートをミュートしたユーザー一覧を見れるように

* 🎨

* add test

* fix test

---------

Co-authored-by: Hélène <pleroma-dev@helene.moe>
This commit is contained in:
syuilo
2023-03-08 08:56:09 +09:00
committed by GitHub
parent 8bf6911d4b
commit 4c2f7c64cc
43 changed files with 683 additions and 26 deletions

View File

@@ -224,6 +224,9 @@ import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
import * as ep___mute_delete from './endpoints/mute/delete.js';
import * as ep___mute_list from './endpoints/mute/list.js';
import * as ep___renoteMute_create from './endpoints/renote-mute/create.js';
import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js';
import * as ep___renoteMute_list from './endpoints/renote-mute/list.js';
import * as ep___my_apps from './endpoints/my/apps.js';
import * as ep___notes from './endpoints/notes.js';
import * as ep___notes_children from './endpoints/notes/children.js';
@@ -545,6 +548,9 @@ const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: e
const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default };
const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default };
const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default };
const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default };
const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default };
const $renoteMute_list: Provider = { provide: 'ep:renote-mute/list', useClass: ep___renoteMute_list.default };
const $my_apps: Provider = { provide: 'ep:my/apps', useClass: ep___my_apps.default };
const $notes: Provider = { provide: 'ep:notes', useClass: ep___notes.default };
const $notes_children: Provider = { provide: 'ep:notes/children', useClass: ep___notes_children.default };
@@ -870,6 +876,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$mute_create,
$mute_delete,
$mute_list,
$renoteMute_create,
$renoteMute_delete,
$renoteMute_list,
$my_apps,
$notes,
$notes_children,
@@ -1189,6 +1198,9 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$mute_create,
$mute_delete,
$mute_list,
$renoteMute_create,
$renoteMute_delete,
$renoteMute_list,
$my_apps,
$notes,
$notes_children,

View File

@@ -3,17 +3,17 @@ import { Inject, Injectable } from '@nestjs/common';
import Redis from 'ioredis';
import * as websocket from 'websocket';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository } from '@/models/index.js';
import type { UsersRepository, BlockingsRepository, ChannelFollowingsRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, RenoteMutingsRepository } from '@/models/index.js';
import type { Config } from '@/config.js';
import { NoteReadService } from '@/core/NoteReadService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js';
import { AuthenticateService } from './AuthenticateService.js';
import MainStreamConnection from './stream/index.js';
import { ChannelsService } from './stream/ChannelsService.js';
import type { ParsedUrlQuery } from 'querystring';
import type * as http from 'node:http';
import { bindThis } from '@/decorators.js';
@Injectable()
export class StreamingApiServerService {
@@ -33,6 +33,9 @@ export class StreamingApiServerService {
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@@ -84,6 +87,7 @@ export class StreamingApiServerService {
const main = new MainStreamConnection(
this.followingsRepository,
this.mutingsRepository,
this.renoteMutingsRepository,
this.blockingsRepository,
this.channelFollowingsRepository,
this.userProfilesRepository,

View File

@@ -224,6 +224,9 @@ import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
import * as ep___mute_delete from './endpoints/mute/delete.js';
import * as ep___mute_list from './endpoints/mute/list.js';
import * as ep___renoteMute_create from './endpoints/renote-mute/create.js';
import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js';
import * as ep___renoteMute_list from './endpoints/renote-mute/list.js';
import * as ep___my_apps from './endpoints/my/apps.js';
import * as ep___notes from './endpoints/notes.js';
import * as ep___notes_children from './endpoints/notes/children.js';
@@ -543,6 +546,9 @@ const eps = [
['mute/create', ep___mute_create],
['mute/delete', ep___mute_delete],
['mute/list', ep___mute_list],
['renote-mute/create', ep___renoteMute_create],
['renote-mute/delete', ep___renoteMute_delete],
['renote-mute/list', ep___renoteMute_list],
['my/apps', ep___my_apps],
['notes', ep___notes],
['notes/children', ep___notes_children],

View File

@@ -89,6 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
}
if (ps.withFiles) {

View File

@@ -107,6 +107,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {

View File

@@ -95,6 +95,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateMutedNoteQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
if (me) this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View File

@@ -93,6 +93,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => {

View File

@@ -0,0 +1,99 @@
import { Inject, Injectable } from '@nestjs/common';
import ms from 'ms';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { IdService } from '@/core/IdService.js';
import type { RenoteMutingsRepository } from '@/models/index.js';
import type { RenoteMuting } from '@/models/entities/RenoteMuting.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'write:mutes',
limit: {
duration: ms('1hour'),
max: 20,
},
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '5e0a5dff-1e94-4202-87ae-4d9c89eb2271',
},
muteeIsYourself: {
message: 'Mutee is yourself.',
code: 'MUTEE_IS_YOURSELF',
id: '37285718-52f7-4aef-b7de-c38b8e8a8420',
},
alreadyMuting: {
message: 'You are already muting that user.',
code: 'ALREADY_MUTING',
id: 'ccfecbe4-1f1c-4fc2-8a3d-c3ffee61cb7b',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
private globalEventService: GlobalEventService,
private getterService: GetterService,
private idService: IdService,
) {
super(meta, paramDef, async (ps, me) => {
const muter = me;
// 自分自身
if (me.id === ps.userId) {
throw new ApiError(meta.errors.muteeIsYourself);
}
// Get mutee
const mutee = await getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
// Check if already muting
const exist = await this.renoteMutingsRepository.findOneBy({
muterId: muter.id,
muteeId: mutee.id,
});
if (exist != null) {
throw new ApiError(meta.errors.alreadyMuting);
}
// Create mute
await this.renoteMutingsRepository.insert({
id: this.idService.genId(),
createdAt: new Date(),
muterId: muter.id,
muteeId: mutee.id,
} as RenoteMuting);
// publishUserEvent(user.id, 'mute', mutee);
});
}
}

View File

@@ -0,0 +1,87 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RenoteMutingsRepository } from '@/models/index.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'write:mutes',
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: '9b6728cf-638c-4aa1-bedb-e07d8101474d',
},
muteeIsYourself: {
message: 'Mutee is yourself.',
code: 'MUTEE_IS_YOURSELF',
id: '619b1314-0850-4597-a242-e245f3da42af',
},
notMuting: {
message: 'You are not muting that user.',
code: 'NOT_MUTING',
id: '2e4ef874-8bf0-4b4b-b069-4598f6d05817',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
},
required: ['userId'],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
private globalEventService: GlobalEventService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
const muter = me;
// Check if the mutee is yourself
if (me.id === ps.userId) {
throw new ApiError(meta.errors.muteeIsYourself);
}
// Get mutee
const mutee = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
// Check not muting
const exist = await this.renoteMutingsRepository.findOneBy({
muterId: muter.id,
muteeId: mutee.id,
});
if (exist == null) {
throw new ApiError(meta.errors.notMuting);
}
// Delete mute
await this.renoteMutingsRepository.delete({
id: exist.id,
});
// publishUserEvent(user.id, 'unmute', mutee);
});
}
}

View File

@@ -0,0 +1,57 @@
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { RenoteMutingsRepository } from '@/models/index.js';
import { QueryService } from '@/core/QueryService.js';
import { RenoteMutingEntityService } from '@/core/entities/RenoteMutingEntityService.js';
import { DI } from '@/di-symbols.js';
export const meta = {
tags: ['account'],
requireCredential: true,
kind: 'read:mutes',
res: {
type: 'array',
optional: false, nullable: false,
items: {
type: 'object',
optional: false, nullable: false,
ref: 'RenoteMuting',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 30 },
sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' },
},
required: [],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
private renoteMutingEntityService: RenoteMutingEntityService,
private queryService: QueryService,
) {
super(meta, paramDef, async (ps, me) => {
const query = this.queryService.makePaginationQuery(this.renoteMutingsRepository.createQueryBuilder('muting'), ps.sinceId, ps.untilId)
.andWhere('muting.muterId = :meId', { meId: me.id });
const mutings = await query
.take(ps.limit)
.getMany();
return await this.renoteMutingEntityService.packMany(mutings, me);
});
}
}

View File

@@ -50,6 +50,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
isRenoteMuted: {
type: 'boolean',
optional: false, nullable: false,
},
},
},
{
@@ -91,6 +95,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
isRenoteMuted: {
type: 'boolean',
optional: false, nullable: false,
},
},
},
},

View File

@@ -27,6 +27,10 @@ export default abstract class Channel {
return this.connection.muting;
}
protected get renoteMuting() {
return this.connection.renoteMuting;
}
protected get blocking() {
return this.connection.blocking;
}

View File

@@ -39,6 +39,8 @@ class AntennaChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
this.connection.cacheNote(note);
this.send('note', note);

View File

@@ -51,6 +51,8 @@ class ChannelChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
this.connection.cacheNote(note);
this.send('note', note);

View File

@@ -68,6 +68,8 @@ class GlobalTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、

View File

@@ -49,6 +49,8 @@ class HashtagChannel extends Channel {
if (isUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
this.connection.cacheNote(note);

View File

@@ -75,6 +75,8 @@ class HomeTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、

View File

@@ -86,6 +86,8 @@ class HybridTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、

View File

@@ -65,6 +65,8 @@ class LocalTimelineChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
// 流れてきたNoteがミュートすべきNoteだったら無視する
// TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある)
// 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、

View File

@@ -93,6 +93,8 @@ class UserListChannel extends Channel {
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する
if (isUserRelated(note, this.blocking)) return;
if (note.renote && !note.text && isUserRelated(note, this.renoteMuting)) return;
this.send('note', note);
}

View File

@@ -1,6 +1,6 @@
import type { User } from '@/models/entities/User.js';
import type { Channel as ChannelModel } from '@/models/entities/Channel.js';
import type { FollowingsRepository, MutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js';
import type { FollowingsRepository, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, ChannelFollowingsRepository, BlockingsRepository } from '@/models/index.js';
import type { AccessToken } from '@/models/entities/AccessToken.js';
import type { UserProfile } from '@/models/entities/UserProfile.js';
import type { Packed } from '@/misc/schema.js';
@@ -22,6 +22,7 @@ export default class Connection {
public userProfile?: UserProfile | null;
public following: Set<User['id']> = new Set();
public muting: Set<User['id']> = new Set();
public renoteMuting: Set<User['id']> = new Set();
public blocking: Set<User['id']> = new Set(); // "被"blocking
public followingChannels: Set<ChannelModel['id']> = new Set();
public token?: AccessToken;
@@ -34,6 +35,7 @@ export default class Connection {
constructor(
private followingsRepository: FollowingsRepository,
private mutingsRepository: MutingsRepository,
private renoteMutingsRepository: RenoteMutingsRepository,
private blockingsRepository: BlockingsRepository,
private channelFollowingsRepository: ChannelFollowingsRepository,
private userProfilesRepository: UserProfilesRepository,
@@ -66,6 +68,7 @@ export default class Connection {
if (this.user) {
this.updateFollowing();
this.updateMuting();
this.updateRenoteMuting();
this.updateBlocking();
this.updateFollowingChannels();
this.updateUserProfile();
@@ -93,6 +96,7 @@ export default class Connection {
this.muting.delete(data.body.id);
break;
// TODO: renote mute events
// TODO: block events
case 'followChannel':
@@ -342,6 +346,18 @@ export default class Connection {
this.muting = new Set<string>(mutings.map(x => x.muteeId));
}
@bindThis
private async updateRenoteMuting() {
const renoteMutings = await this.renoteMutingsRepository.find({
where: {
muterId: this.user!.id,
},
select: ['muteeId'],
});
this.renoteMuting = new Set<string>(renoteMutings.map(x => x.muteeId));
}
@bindThis
private async updateBlocking() { // ここでいうBlockingは被Blockingの意
const blockings = await this.blockingsRepository.find({