This commit is contained in:
おさむのひと 2025-02-12 09:45:01 +09:00
parent b37622fa64
commit 1cb2b38dcb
37 changed files with 2950 additions and 2342 deletions

View File

@ -4,6 +4,7 @@
*/
import { Module } from '@nestjs/common';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import { FanoutTimelineEndpointService } from '@/core/FanoutTimelineEndpointService.js';
import { AbuseReportService } from '@/core/AbuseReportService.js';
import { SystemWebhookEntityService } from '@/core/entities/SystemWebhookEntityService.js';
@ -185,6 +186,7 @@ const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NoteMutingService: Provider = { provide: 'NoteMutingService', useExisting: NoteMutingService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
@ -334,6 +336,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteDeleteService,
NotePiningService,
NoteReadService,
NoteMutingService,
NotificationService,
PollService,
SystemAccountService,
@ -479,6 +482,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteDeleteService,
$NotePiningService,
$NoteReadService,
$NoteMutingService,
$NotificationService,
$PollService,
$SystemAccountService,
@ -625,6 +629,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteDeleteService,
NotePiningService,
NoteReadService,
NoteMutingService,
NotificationService,
PollService,
SystemAccountService,
@ -769,6 +774,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteDeleteService,
$NotePiningService,
$NoteReadService,
$NoteMutingService,
$NotificationService,
$PollService,
$SystemAccountService,

View File

@ -17,6 +17,7 @@ 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 { NoteMutingService } from './note/NoteMutingService.js';
type TimelineOptions = {
untilId: string | null,
@ -45,6 +46,7 @@ export class FanoutTimelineEndpointService {
private noteEntityService: NoteEntityService,
private cacheService: CacheService,
private fanoutTimelineService: FanoutTimelineService,
private noteMutingService: NoteMutingService,
) {
}
@ -101,11 +103,13 @@ export class FanoutTimelineEndpointService {
userIdsWhoMeMutingRenotes,
userIdsWhoBlockingMe,
userMutedInstances,
noteMutings,
] = 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)),
this.noteMutingService.getMutingNoteIdsSet(me.id),
]);
const parentFilter = filter;
@ -114,6 +118,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 (noteMutings.has(note.id)) return false;
return parentFilter(note);
};

View File

@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiSystemWebhook } from '@/models/SystemWebhook.js';
import type { MiMeta } from '@/models/Meta.js';
import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import { MiAvatarDecoration, MiNoteMuting, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@ -249,6 +249,8 @@ export interface InternalEventTypes {
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
noteMuteCreated: MiNoteMuting;
noteMuteDeleted: MiNoteMuting;
}
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;

View File

@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { Brackets, ObjectLiteral } from 'typeorm';
import { DI } from '@/di-symbols.js';
import type { MiUser } from '@/models/User.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository } from '@/models/_.js';
import type { UserProfilesRepository, FollowingsRepository, ChannelFollowingsRepository, BlockingsRepository, NoteThreadMutingsRepository, MutingsRepository, RenoteMutingsRepository, NoteMutingsRepository } from '@/models/_.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import type { SelectQueryBuilder } from 'typeorm';
@ -21,12 +21,12 @@ export class QueryService {
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.blockingsRepository)
private blockingsRepository: BlockingsRepository,
@Inject(DI.noteMutingsRepository)
private noteMutingsRepository: NoteMutingsRepository,
@Inject(DI.noteThreadMutingsRepository)
private noteThreadMutingsRepository: NoteThreadMutingsRepository,
@ -110,6 +110,16 @@ export class QueryService {
q.setParameters(blockedQuery.getParameters());
}
@bindThis
public generateMutedNoteQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const query = this.noteMutingsRepository.createQueryBuilder('noteMuting')
.select('noteMuting.noteId')
.where('noteMuting.userId = :userId', { userId: me.id });
q.andWhere(`note.id NOT IN (${ query.getQuery() })`);
q.setParameters(query.getParameters());
}
@bindThis
public generateMutedNoteThreadQuery(q: SelectQueryBuilder<any>, me: { id: MiUser['id'] }): void {
const mutedQuery = this.noteThreadMutingsRepository.createQueryBuilder('threadMuted')

View File

@ -234,8 +234,11 @@ export class SearchService {
}
this.queryService.generateVisibilityQuery(query, me);
if (me) this.queryService.generateMutedUserQuery(query, me);
if (me) this.queryService.generateBlockedUserQuery(query, me);
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
return query.limit(pagination.limit).getMany();
}

View File

@ -0,0 +1,143 @@
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import { RedisKVCache } from '@/misc/cache.js';
import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js';
import type { MiNoteMuting, NoteMutingsRepository } from '@/models/_.js';
@Injectable()
export class NoteMutingService implements OnApplicationShutdown {
public static NoSuchItemError = class extends Error {
};
private cache: RedisKVCache<Set<string>>;
constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.noteMutingsRepository)
private noteMutingsRepository: NoteMutingsRepository,
private idService: IdService,
private globalEventService: GlobalEventService,
) {
this.redisForSub.on('message', this.onMessage);
this.cache = new RedisKVCache<Set<string>>(this.redisClient, 'noteMutings', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (userId) => this.listByUserId(userId).then(xs => new Set(xs.map(x => x.noteId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
}
@bindThis
private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel !== 'internal') {
return;
}
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'noteMuteCreated': {
const noteIds = await this.cache.get(body.userId);
if (noteIds) {
noteIds.add(body.noteId);
}
break;
}
case 'noteMuteDeleted': {
const noteIds = await this.cache.get(body.userId);
if (noteIds) {
noteIds.delete(body.noteId);
}
break;
}
}
}
@bindThis
public async listByUserId(
userId: MiNoteMuting['userId'],
opts?: {
joinUser?: boolean;
joinNote?: boolean;
},
): Promise<MiNoteMuting[]> {
const q = this.noteMutingsRepository.createQueryBuilder('noteMuting');
q.where('noteMuting.userId = :userId', { userId });
if (opts?.joinUser) {
q.leftJoinAndSelect('noteMuting.user', 'user');
}
if (opts?.joinNote) {
q.leftJoinAndSelect('noteMuting.note', 'note');
}
return q.getMany();
}
@bindThis
public async getMutingNoteIdsSet(userId: MiNoteMuting['userId']): Promise<Set<string>> {
return this.cache.fetch(userId);
}
@bindThis
public async get(id: MiNoteMuting['id']): Promise<MiNoteMuting> {
const result = await this.noteMutingsRepository.findOne({ where: { id } });
if (!result) {
throw new NoteMutingService.NoSuchItemError();
}
return result;
}
@bindThis
public async create(
params: Pick<MiNoteMuting, 'userId' | 'noteId' | 'expiresAt'>,
): Promise<void> {
const id = this.idService.gen();
const result = await this.noteMutingsRepository.insertOne({
id,
...params,
});
this.globalEventService.publishInternalEvent('noteMuteCreated', result);
}
@bindThis
public async update(
id: MiNoteMuting['id'],
params: Partial<Pick<MiNoteMuting, 'expiresAt'>>,
): Promise<void> {
await this.noteMutingsRepository.update(id, params);
// 現状、ミュート設定の有無しかキャッシュしていないので更新時はイベントを発行しない。
// 他に細かい設定が登場した場合はキャッシュの型をSetからMapに変えつつ、イベントを発行するようにする。
}
@bindThis
public async delete(id: MiNoteMuting['id']): Promise<void> {
const value = await this.noteMutingsRepository.findOne({ where: { id } });
if (!value) {
return;
}
await this.noteMutingsRepository.delete(id);
this.globalEventService.publishInternalEvent('noteMuteDeleted', value);
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
}
@bindThis
public onApplicationShutdown(): void {
this.dispose();
}
}

View File

@ -22,6 +22,7 @@ export const DI = {
appsRepository: Symbol('appsRepository'),
avatarDecorationsRepository: Symbol('avatarDecorationsRepository'),
noteFavoritesRepository: Symbol('noteFavoritesRepository'),
noteMutingsRepository: Symbol('noteMutingsRepository'),
noteThreadMutingsRepository: Symbol('noteThreadMutingsRepository'),
noteReactionsRepository: Symbol('noteReactionsRepository'),
noteUnreadsRepository: Symbol('noteUnreadsRepository'),

View File

@ -0,0 +1,26 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
type NoteCompat = {
id: string;
reply?: NoteCompat | null;
renote?: NoteCompat | null;
}
export function isMutingNoteRelated(note: NoteCompat, noteIds: Set<string>) {
if (noteIds.has(note.id)) {
return true;
}
if (note.reply != null && noteIds.has(note.reply.id)) {
return true;
}
if (note.renote != null && noteIds.has(note.renote.id)) {
return true;
}
return false;
}

View File

@ -0,0 +1,44 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm';
import { id } from './util/id.js';
import { MiUser } from './User.js';
import { MiNote } from './Note.js';
@Entity('note_muting')
export class MiNoteMuting {
@PrimaryColumn(id())
public id: string;
@Index()
@Column({
...id(),
})
public userId: MiUser['id'];
@ManyToOne(type => MiUser, {
onDelete: 'CASCADE',
})
@JoinColumn()
public user: MiUser | null;
@Index()
@Column('varchar', {
...id(),
})
public noteId: string;
@ManyToOne(type => MiNote, {
onDelete: 'CASCADE',
})
@JoinColumn()
public note: MiNote | null;
@Column('timestamp with time zone', {
nullable: true,
})
public expiresAt: Date | null;
}

View File

@ -40,6 +40,7 @@ import {
MiMuting,
MiNote,
MiNoteFavorite,
MiNoteMuting,
MiNoteReaction,
MiNoteThreadMuting,
MiNoteUnread,
@ -124,6 +125,12 @@ const $noteFavoritesRepository: Provider = {
inject: [DI.db],
};
const $noteMutingsRepository: Provider = {
provide: DI.noteMutingsRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteMuting).extend(miRepository as MiRepository<MiNoteMuting>),
inject: [DI.db],
};
const $noteThreadMutingsRepository: Provider = {
provide: DI.noteThreadMutingsRepository,
useFactory: (db: DataSource) => db.getRepository(MiNoteThreadMuting).extend(miRepository as MiRepository<MiNoteThreadMuting>),
@ -512,6 +519,7 @@ const $reversiGamesRepository: Provider = {
$appsRepository,
$avatarDecorationsRepository,
$noteFavoritesRepository,
$noteMutingsRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteUnreadsRepository,
@ -584,6 +592,7 @@ const $reversiGamesRepository: Provider = {
$appsRepository,
$avatarDecorationsRepository,
$noteFavoritesRepository,
$noteMutingsRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$noteUnreadsRepository,

View File

@ -80,6 +80,7 @@ import { MiFlashLike } from '@/models/FlashLike.js';
import { MiUserListFavorite } from '@/models/UserListFavorite.js';
import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js';
import { MiReversiGame } from '@/models/ReversiGame.js';
import { MiNoteMuting } from './NoteMuting.js';
import type { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js';
export interface MiRepository<T extends ObjectLiteral> {
@ -158,6 +159,7 @@ export {
MiNote,
MiNoteFavorite,
MiNoteReaction,
MiNoteMuting,
MiNoteThreadMuting,
MiNoteUnread,
MiPage,
@ -230,6 +232,7 @@ export type RenoteMutingsRepository = Repository<MiRenoteMuting> & MiRepository<
export type NotesRepository = Repository<MiNote> & MiRepository<MiNote>;
export type NoteFavoritesRepository = Repository<MiNoteFavorite> & MiRepository<MiNoteFavorite>;
export type NoteReactionsRepository = Repository<MiNoteReaction> & MiRepository<MiNoteReaction>;
export type NoteMutingsRepository = Repository<MiNoteMuting> & MiRepository<MiNoteMuting>;
export type NoteThreadMutingsRepository = Repository<MiNoteThreadMuting> & MiRepository<MiNoteThreadMuting>;
export type NoteUnreadsRepository = Repository<MiNoteUnread> & MiRepository<MiNoteUnread>;
export type PagesRepository = Repository<MiPage> & MiRepository<MiPage>;

View File

@ -325,6 +325,10 @@ export * as 'notes/timeline' from './endpoints/notes/timeline.js';
export * as 'notes/translate' from './endpoints/notes/translate.js';
export * as 'notes/unrenote' from './endpoints/notes/unrenote.js';
export * as 'notes/user-list-timeline' from './endpoints/notes/user-list-timeline.js';
export * as 'notes/muting/create' from './endpoints/notes/muting/create.js';
export * as 'notes/muting/update' from './endpoints/notes/muting/update.js';
export * as 'notes/muting/delete' from './endpoints/notes/muting/delete.js';
export * as 'notes/muting/list' from './endpoints/notes/muting/list.js';
export * as 'notifications/create' from './endpoints/notifications/create.js';
export * as 'notifications/flush' from './endpoints/notifications/flush.js';
export * as 'notifications/mark-all-as-read' from './endpoints/notifications/mark-all-as-read.js';

View File

@ -116,6 +116,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
const notes = await query.getMany();
if (sinceId != null && untilId == null) {

View File

@ -124,6 +124,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
//#endregion

View File

@ -89,6 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
const notes = await query

View File

@ -73,6 +73,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (me) {
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
const notes = await query.limit(ps.limit).getMany();

View File

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

View File

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

View File

@ -156,9 +156,12 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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);
this.queryService.generateMutedNoteQuery(query, me);
}
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View File

@ -77,6 +77,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateMutedNoteThreadQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
if (ps.visibility) {
query.andWhere('note.visibility = :visibility', { visibility: ps.visibility });

View File

@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { GetterService } from '@/server/api/GetterService.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'write:account',
limit: {
duration: ms('1hour'),
max: 10,
},
errors: {
noSuchNote: {
message: 'No such note.',
code: 'NO_SUCH_NOTE',
id: 'a58e7999-f6d3-1780-a688-f43661719662',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
expiresAt: { type: 'integer', nullable: true },
},
required: ['noteId'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private readonly noteMutingService: NoteMutingService,
private readonly getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote);
throw err;
});
await this.noteMutingService.create({
userId: me.id,
noteId: note.id,
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
});
});
}
}

View File

@ -0,0 +1,55 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'write:account',
errors: {
noSuchItem: {
message: 'No such item.',
code: 'NO_SUCH_ITEM',
id: '6ad3b6c9-f173-60f7-b558-5eea13896254',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
},
required: ['id'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private readonly noteMutingService: NoteMutingService,
) {
super(meta, paramDef, async (ps) => {
try {
// Existence check
await this.noteMutingService.get(ps.id);
} catch (e) {
if (e instanceof NoteMutingService.NoSuchItemError) {
throw new ApiError(meta.errors.noSuchItem);
}
throw e;
}
await this.noteMutingService.delete(ps.id);
});
}
}

View File

@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'read:account',
res: {
type: 'object',
properties: {
notes: {
type: 'array',
items: {
type: 'object',
properties: {
id: { type: 'string' },
expiresAt: { type: 'string', format: 'date-time', nullable: true },
note: { type: 'object', ref: 'Note' },
},
},
},
},
},
} 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 readonly noteMutingService: NoteMutingService,
private readonly noteEntityService: NoteEntityService,
) {
super(meta, paramDef, async (ps, me) => {
const mutings = await this.noteMutingService.listByUserId(me.id, { joinNote: true });
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const packedNotes = await this.noteEntityService.packMany(mutings.map(m => m.note!))
.then(res => new Map(res.map(it => [it.id, it])));
return mutings.map(m => ({
id: m.id,
expiresAt: m.expiresAt,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
note: packedNotes.get(m.noteId)!,
}));
});
}
}

View File

@ -0,0 +1,64 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Injectable } from '@nestjs/common';
import ms from 'ms';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { ApiError } from '../../../error.js';
export const meta = {
tags: ['notes'],
requireCredential: true,
kind: 'write:account',
limit: {
duration: ms('1hour'),
max: 10,
},
errors: {
noSuchItem: {
message: 'No such item.',
code: 'NO_SUCH_ITEM',
id: '502ce7a1-d8b0-7094-78e2-ff5b8190efc9',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
id: { type: 'string', format: 'misskey:id' },
expiresAt: { type: 'integer', nullable: true },
},
required: ['id'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
private readonly noteMutingService: NoteMutingService,
) {
super(meta, paramDef, async (ps, me) => {
try {
// Existence check
await this.noteMutingService.get(ps.id);
} catch (e) {
if (e instanceof NoteMutingService.NoSuchItemError) {
throw new ApiError(meta.errors.noSuchItem);
}
throw e;
}
await this.noteMutingService.update(ps.id, {
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
});
});
}
}

View File

@ -72,8 +72,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
const renotes = await query.limit(ps.limit).getMany();

View File

@ -56,8 +56,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
const timeline = await query.limit(ps.limit).getMany();

View File

@ -81,8 +81,11 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // 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.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
try {
if (ps.tag) {

View File

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

View File

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

View File

@ -104,6 +104,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateVisibilityQuery(query, me);
this.queryService.generateMutedUserQuery(query, me);
this.queryService.generateBlockedUserQuery(query, me);
this.queryService.generateMutedNoteQuery(query, me);
const notes = await query.getMany();
notes.sort((a, b) => a.id > b.id ? -1 : 1);

View File

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

View File

@ -16,6 +16,7 @@ import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService
import { ChannelFollowingService } from '@/core/ChannelFollowingService.js';
import { isJsonObject } from '@/misc/json-value.js';
import type { JsonObject, JsonValue } from '@/misc/json-value.js';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events';
import type Channel from './channel.js';
@ -41,6 +42,7 @@ export default class Connection {
public userIdsWhoBlockingMe: Set<string> = new Set();
public userIdsWhoMeMutingRenotes: Set<string> = new Set();
public userMutedInstances: Set<string> = new Set();
public noteMuting: Set<string> = new Set();
private fetchIntervalId: NodeJS.Timeout | null = null;
constructor(
@ -49,6 +51,7 @@ export default class Connection {
private notificationService: NotificationService,
private cacheService: CacheService,
private channelFollowingService: ChannelFollowingService,
private noteMutingService: NoteMutingService,
user: MiUser | null | undefined,
token: MiAccessToken | null | undefined,
@ -60,13 +63,14 @@ 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, userIdsWhoMeMuting, userIdsWhoBlockingMe, userIdsWhoMeMutingRenotes, noteMuting] = 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.cacheService.userMutingsCache.fetch(this.user.id),
this.cacheService.userBlockedCache.fetch(this.user.id),
this.cacheService.renoteMutingsCache.fetch(this.user.id),
this.noteMutingService.getMutingNoteIdsSet(this.user.id),
]);
this.userProfile = userProfile;
this.following = following;
@ -75,6 +79,7 @@ export default class Connection {
this.userIdsWhoBlockingMe = userIdsWhoBlockingMe;
this.userIdsWhoMeMutingRenotes = userIdsWhoMeMutingRenotes;
this.userMutedInstances = new Set(userProfile.mutedInstances);
this.noteMuting = noteMuting;
}
@bindThis

View File

@ -5,6 +5,7 @@
import { bindThis } from '@/decorators.js';
import { isInstanceMuted } from '@/misc/is-instance-muted.js';
import { isMutingNoteRelated } from '@/misc/is-muting-note-related.js';
import { isUserRelated } from '@/misc/is-user-related.js';
import { isRenotePacked, isQuotePacked } from '@/misc/is-renote.js';
import type { Packed } from '@/misc/json-schema.js';
@ -51,6 +52,10 @@ export default abstract class Channel {
return this.connection.userMutedInstances;
}
protected get noteMuting() {
return this.connection.noteMuting;
}
protected get followingChannels() {
return this.connection.followingChannels;
}
@ -74,6 +79,9 @@ export default abstract class Channel {
// 流れてきたNoteがリートをミュートしてるユーザが行ったもの
if (isRenotePacked(note) && !isQuotePacked(note) && this.userIdsWhoMeMutingRenotes.has(note.user.id)) return true;
// 流れてきたNoteがミュートしているNoteに関わるミュートしたートがリートされた or リプライがついた時)
if (isMutingNoteRelated(note, this.noteMuting)) return true;
return false;
}

View File

@ -1690,6 +1690,10 @@ declare namespace entities {
NotesLocalTimelineResponse,
NotesMentionsRequest,
NotesMentionsResponse,
NotesMutingCreateRequest,
NotesMutingDeleteRequest,
NotesMutingListResponse,
NotesMutingUpdateRequest,
NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse,
NotesPollsVoteRequest,
@ -2740,6 +2744,18 @@ type NotesMentionsRequest = operations['notes___mentions']['requestBody']['conte
// @public (undocumented)
type NotesMentionsResponse = operations['notes___mentions']['responses']['200']['content']['application/json'];
// @public (undocumented)
type NotesMutingCreateRequest = operations['notes___muting___create']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesMutingDeleteRequest = operations['notes___muting___delete']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['content']['application/json'];
// @public (undocumented)
type NotesMutingUpdateRequest = operations['notes___muting___update']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json'];

View File

@ -3332,6 +3332,50 @@ declare module '../api.js' {
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
request<E extends 'notes/muting/create', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
request<E extends 'notes/muting/delete', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:account*
*/
request<E extends 'notes/muting/list', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
request<E extends 'notes/muting/update', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:account*
*/
request<E extends 'notes/polls/recommendation', P extends Endpoints[E]['req']>(

View File

@ -449,6 +449,10 @@ import type {
NotesLocalTimelineResponse,
NotesMentionsRequest,
NotesMentionsResponse,
NotesMutingCreateRequest,
NotesMutingDeleteRequest,
NotesMutingListResponse,
NotesMutingUpdateRequest,
NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse,
NotesPollsVoteRequest,
@ -886,6 +890,10 @@ export type Endpoints = {
'notes/hybrid-timeline': { req: NotesHybridTimelineRequest; res: NotesHybridTimelineResponse };
'notes/local-timeline': { req: NotesLocalTimelineRequest; res: NotesLocalTimelineResponse };
'notes/mentions': { req: NotesMentionsRequest; res: NotesMentionsResponse };
'notes/muting/create': { req: NotesMutingCreateRequest; res: EmptyResponse };
'notes/muting/delete': { req: NotesMutingDeleteRequest; res: EmptyResponse };
'notes/muting/list': { req: EmptyRequest; res: NotesMutingListResponse };
'notes/muting/update': { req: NotesMutingUpdateRequest; res: EmptyResponse };
'notes/polls/recommendation': { req: NotesPollsRecommendationRequest; res: NotesPollsRecommendationResponse };
'notes/polls/vote': { req: NotesPollsVoteRequest; res: EmptyResponse };
'notes/reactions': { req: NotesReactionsRequest; res: NotesReactionsResponse };

View File

@ -452,6 +452,10 @@ export type NotesLocalTimelineRequest = operations['notes___local-timeline']['re
export type NotesLocalTimelineResponse = operations['notes___local-timeline']['responses']['200']['content']['application/json'];
export type NotesMentionsRequest = operations['notes___mentions']['requestBody']['content']['application/json'];
export type NotesMentionsResponse = operations['notes___mentions']['responses']['200']['content']['application/json'];
export type NotesMutingCreateRequest = operations['notes___muting___create']['requestBody']['content']['application/json'];
export type NotesMutingDeleteRequest = operations['notes___muting___delete']['requestBody']['content']['application/json'];
export type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['content']['application/json'];
export type NotesMutingUpdateRequest = operations['notes___muting___update']['requestBody']['content']['application/json'];
export type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json'];
export type NotesPollsRecommendationResponse = operations['notes___polls___recommendation']['responses']['200']['content']['application/json'];
export type NotesPollsVoteRequest = operations['notes___polls___vote']['requestBody']['content']['application/json'];