This commit is contained in:
おさむのひと 2025-04-27 14:17:30 +09:00 committed by GitHub
commit 98e32898bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
51 changed files with 1557 additions and 33 deletions

26
locales/index.d.ts vendored
View File

@ -11581,6 +11581,32 @@ export interface Locale extends ILocale {
*/
"serverHostPlaceholder": string;
};
"_noteMuting": {
/**
*
*/
"noteMuting": string;
/**
*
*/
"muteNote": string;
/**
*
*/
"unmuteNote": string;
/**
*
*/
"notMutedNote": string;
/**
*
*/
"labelSuffix": string;
/**
*
*/
"unmuteCaption": string;
};
}
declare const locales: {
[lang: string]: Locale;

View File

@ -3098,3 +3098,11 @@ _search:
pleaseEnterServerHost: "サーバーのホストを入力してください"
pleaseSelectUser: "ユーザーを選択してください"
serverHostPlaceholder: "例: misskey.example.com"
_noteMuting:
noteMuting: "ミュートしたノート"
muteNote: "ノートをミュート"
unmuteNote: "ノートのミュートを解除"
notMutedNote: "このノートはミュートされていません"
labelSuffix: "のノート"
unmuteCaption: "ミュートを解除したノートを再表示するにはタイムラインの再読み込みが必要です。"

View File

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NoteMuting1739882320354 {
name = 'NoteMuting1739882320354'
async up(queryRunner) {
await queryRunner.query(`
CREATE TABLE "note_muting" (
"id" varchar(32) NOT NULL,
"userId" varchar(32) NOT NULL,
"noteId" varchar(32) NOT NULL,
"expiresAt" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_note_muting_id" PRIMARY KEY ("id"),
CONSTRAINT "FK_note_muting_userId" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
CONSTRAINT "FK_note_muting_noteId" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION
);
CREATE INDEX "IDX_note_muting_userId" ON "note_muting" ("userId");
CREATE INDEX "IDX_note_muting_noteId" ON "note_muting" ("noteId");
CREATE INDEX "IDX_note_muting_expiresAt" ON "note_muting" ("expiresAt");
CREATE UNIQUE INDEX "IDX_note_muting_userId_noteId_unique" ON note_muting ("userId", "noteId");
`);
}
async down(queryRunner) {
await queryRunner.query(`
DROP INDEX "IDX_note_muting_userId_noteId_unique";
DROP INDEX "IDX_note_muting_expiresAt";
DROP INDEX "IDX_note_muting_noteId";
DROP INDEX "IDX_note_muting_userId";
DROP TABLE "note_muting";
`);
}
}

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 $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
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 };
@ -335,6 +337,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
NoteMutingService,
NotificationService,
PollService,
SystemAccountService,
@ -481,6 +484,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
$NoteMutingService,
$NotificationService,
$PollService,
$SystemAccountService,
@ -628,6 +632,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
NoteMutingService,
NotificationService,
PollService,
SystemAccountService,
@ -773,6 +778,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
$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, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import { MiAvatarDecoration, MiNoteMuting, MiChatMessage, MiChatRoom, 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';
@ -260,6 +260,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.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
return query.limit(pagination.limit).getMany();
}

View File

@ -0,0 +1,182 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
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, NotesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
@Injectable()
export class NoteMutingService implements OnApplicationShutdown {
public static NoSuchNoteError = class extends Error {
};
public static NotMutedError = 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.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteMutingsRepository)
private noteMutingsRepository: NoteMutingsRepository,
private idService: IdService,
private globalEventService: GlobalEventService,
private queryService: QueryService,
) {
this.redisForSub.on('message', this.onMessage);
this.cache = new RedisKVCache<Set<MiNoteMuting['noteId']>>(this.redisClient, 'noteMutings', {
// 使用頻度が高く使用される期間も長いためキャッシュの有効期限切れ→再取得が頻発すると思われる。
// よって、有効期限を長めに設定して再取得の頻度を抑えるキャッシュの鮮度はRedisイベント経由で保たれているので問題ないはず
lifetime: 1000 * 60 * 60 * 24, // 1d
memoryCacheLifetime: 1000 * 60 * 60 * 24, // 1d
fetcher: async (userId) => {
return this.noteMutingsRepository.createQueryBuilder('noteMuting')
.select('noteMuting.noteId')
.where('noteMuting.userId = :userId', { userId })
.getRawMany<{ noteMuting_noteId: string }>()
.then((results) => new Set(results.map(x => x.noteMuting_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(
params: {
userId: MiNoteMuting['userId'],
sinceId?: MiNoteMuting['id'] | null,
untilId?: MiNoteMuting['id'] | null,
},
opts?: {
limit?: number;
offset?: number;
joinUser?: boolean;
joinNote?: boolean;
},
): Promise<MiNoteMuting[]> {
const q = this.queryService.makePaginationQuery(this.noteMutingsRepository.createQueryBuilder('noteMuting'), params.sinceId, params.untilId);
q.where('noteMuting.userId = :userId', { userId: params.userId });
if (opts?.joinUser) {
q.leftJoinAndSelect('noteMuting.user', 'user');
}
if (opts?.joinNote) {
q.leftJoinAndSelect('noteMuting.note', 'note');
}
q.orderBy('noteMuting.id', 'DESC');
const limit = opts?.limit ?? 10;
q.limit(limit);
if (opts?.offset) {
q.offset(opts.offset);
}
return q.getMany();
}
@bindThis
public async getMutingNoteIdsSet(userId: MiNoteMuting['userId']): Promise<Set<string>> {
return this.cache.fetch(userId);
}
@bindThis
public async isMuting(userId: MiNoteMuting['userId'], noteId: MiNoteMuting['noteId']): Promise<boolean> {
return this.cache.fetch(userId).then(noteIds => noteIds.has(noteId));
}
@bindThis
public async create(
params: Pick<MiNoteMuting, 'userId' | 'noteId' | 'expiresAt'>,
): Promise<void> {
if (!await this.notesRepository.existsBy({ id: params.noteId })) {
throw new NoteMutingService.NoSuchNoteError();
}
const id = this.idService.gen();
const result = await this.noteMutingsRepository.insertOne({
id,
...params,
});
this.globalEventService.publishInternalEvent('noteMuteCreated', result);
}
@bindThis
public async delete(userId: MiNoteMuting['userId'], noteId: MiNoteMuting['noteId']): Promise<void> {
const value = await this.noteMutingsRepository.findOne({ where: { userId, noteId } });
if (!value) {
throw new NoteMutingService.NotMutedError();
}
await this.noteMutingsRepository.delete(value.id);
this.globalEventService.publishInternalEvent('noteMuteDeleted', value);
}
@bindThis
public async cleanupExpiredMutes(): Promise<void> {
const now = new Date();
const noteMutings = await this.noteMutingsRepository.createQueryBuilder('noteMuting')
.select(['noteMuting.id', 'noteMuting.userId'])
.where('noteMuting.expiresAt < :now', { now })
.andWhere('noteMuting.expiresAt IS NOT NULL')
.getRawMany<{ noteMuting_id: MiNoteMuting['id'], noteMuting_userId: MiNoteMuting['id'] }>();
await this.noteMutingsRepository.delete(noteMutings.map(x => x.noteMuting_id));
for (const id of [...new Set(noteMutings.map(x => x.noteMuting_userId))]) {
// 同時多発的なDBアクセスが発生することを避けるため1回ごとにawaitする
await this.cache.refresh(id);
}
}
@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'),
pollsRepository: Symbol('pollsRepository'),

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,
MiPage,
@ -128,6 +129,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>),
@ -540,6 +547,7 @@ const $reversiGamesRepository: Provider = {
$appsRepository,
$avatarDecorationsRepository,
$noteFavoritesRepository,
$noteMutingsRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$pollsRepository,
@ -616,6 +624,7 @@ const $reversiGamesRepository: Provider = {
$appsRepository,
$avatarDecorationsRepository,
$noteFavoritesRepository,
$noteMutingsRepository,
$noteThreadMutingsRepository,
$noteReactionsRepository,
$pollsRepository,

View File

@ -56,6 +56,7 @@ import { MiModerationLog } from '@/models/ModerationLog.js';
import { MiMuting } from '@/models/Muting.js';
import { MiNote } from '@/models/Note.js';
import { MiNoteFavorite } from '@/models/NoteFavorite.js';
import { MiNoteMuting } from '@/models/NoteMuting.js';
import { MiNoteReaction } from '@/models/NoteReaction.js';
import { MiNoteThreadMuting } from '@/models/NoteThreadMuting.js';
import { MiPage } from '@/models/Page.js';
@ -190,6 +191,7 @@ export {
MiNote,
MiNoteFavorite,
MiNoteReaction,
MiNoteMuting,
MiNoteThreadMuting,
MiPage,
MiPageLike,
@ -268,6 +270,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 PagesRepository = Repository<MiPage> & MiRepository<MiPage>;
export type PageLikesRepository = Repository<MiPageLike> & MiRepository<MiPageLike>;

View File

@ -7,6 +7,7 @@
import pg from 'pg';
import { DataSource, Logger, type QueryRunner } from 'typeorm';
import * as highlight from 'cli-highlight';
import { MiNoteMuting } from '@/models/NoteMuting.js';
import { entities as charts } from '@/core/chart/entities.js';
import { Config } from '@/config.js';
import MisskeyLogger from '@/logger.js';
@ -210,6 +211,7 @@ export const entities = [
MiNoteFavorite,
MiNoteReaction,
MiNoteThreadMuting,
MiNoteMuting,
MiPage,
MiPageLike,
MiGalleryPost,

View File

@ -10,6 +10,7 @@ import type { MutingsRepository } from '@/models/_.js';
import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js';
import { UserMutingService } from '@/core/UserMutingService.js';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import { QueueLoggerService } from '../QueueLoggerService.js';
import type * as Bull from 'bullmq';
@ -22,6 +23,7 @@ export class CheckExpiredMutingsProcessorService {
private mutingsRepository: MutingsRepository,
private userMutingService: UserMutingService,
private noteMutingService: NoteMutingService,
private queueLoggerService: QueueLoggerService,
) {
this.logger = this.queueLoggerService.logger.createSubLogger('check-expired-mutings');
@ -41,6 +43,8 @@ export class CheckExpiredMutingsProcessorService {
await this.userMutingService.unmute(expired);
}
await this.noteMutingService.cleanupExpiredMutes();
this.logger.succ('All expired mutings checked.');
}
}

View File

@ -7,6 +7,7 @@ import { EventEmitter } from 'events';
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
import * as WebSocket from 'ws';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import { DI } from '@/di-symbols.js';
import type { UsersRepository, MiAccessToken } from '@/models/_.js';
import { NotificationService } from '@/core/NotificationService.js';
@ -39,6 +40,7 @@ export class StreamingApiServerService {
private notificationService: NotificationService,
private usersService: UserService,
private channelFollowingService: ChannelFollowingService,
private noteMutingService: NoteMutingService,
) {
}
@ -97,7 +99,9 @@ export class StreamingApiServerService {
this.notificationService,
this.cacheService,
this.channelFollowingService,
user, app,
this.noteMutingService,
user,
app,
);
await stream.init();

View File

@ -330,6 +330,9 @@ 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/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

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

View File

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

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 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: {
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,
) {
super(meta, paramDef, async (ps, me) => {
try {
await this.noteMutingService.create({
userId: me.id,
noteId: ps.noteId,
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
});
} catch (e) {
if (e instanceof NoteMutingService.NoSuchNoteError) {
throw new ApiError(meta.errors.noSuchNote);
} else {
throw e;
}
}
});
}
}

View File

@ -0,0 +1,53 @@
/*
* 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: {
notMuted: {
message: 'Not muted.',
code: 'NOT_MUTED',
id: '6ad3b6c9-f173-60f7-b558-5eea13896254',
httpStatusCode: 400,
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
noteId: { type: 'string', format: 'misskey:id' },
},
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,
) {
super(meta, paramDef, async (ps, me) => {
try {
await this.noteMutingService.delete(me.id, ps.noteId);
} catch (e) {
if (e instanceof NoteMutingService.NotMutedError) {
throw new ApiError(meta.errors.notMuted);
} else {
throw e;
}
}
});
}
}

View File

@ -0,0 +1,69 @@
/*
* 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: '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: {
sinceId: { type: 'string', format: 'misskey:id', nullable: true },
untilId: { type: 'string', format: 'misskey:id', nullable: true },
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
offset: { type: 'integer' },
},
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(
{ userId: me.id },
{
joinNote: true,
limit: ps.limit,
offset: ps.offset,
});
// 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?.toISOString(),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
note: packedNotes.get(m.noteId)!,
}));
});
}
}

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.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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.generateMutedUserQueryForNotes(query, me);
if (me) this.queryService.generateBlockedUserQueryForNotes(query, me);
if (me) {
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
try {
if (ps.tag) {

View File

@ -7,6 +7,7 @@ import { Inject, Injectable } from '@nestjs/common';
import type { NotesRepository, NoteThreadMutingsRepository, NoteFavoritesRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { DI } from '@/di-symbols.js';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
export const meta = {
tags: ['notes'],
@ -26,6 +27,10 @@ export const meta = {
type: 'boolean',
optional: false, nullable: false,
},
isMutedNote: {
type: 'boolean',
optional: false, nullable: false,
},
},
},
} as const;
@ -49,11 +54,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.noteFavoritesRepository)
private noteFavoritesRepository: NoteFavoritesRepository,
private noteMutingService: NoteMutingService,
) {
super(meta, paramDef, async (ps, me) => {
const note = await this.notesRepository.findOneByOrFail({ id: ps.noteId });
const [favorite, threadMuting] = await Promise.all([
const [favorite, threadMuting, isMutedNote] = await Promise.all([
this.noteFavoritesRepository.count({
where: {
userId: me.id,
@ -68,11 +75,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
},
take: 1,
}),
this.noteMutingService.isMuting(me.id, note.id),
]);
return {
isFavorited: favorite !== 0,
isMutedThread: threadMuting !== 0,
isMutedNote,
};
});
}

View File

@ -202,6 +202,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
this.queryService.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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.generateMutedUserQueryForNotes(query, me);
this.queryService.generateBlockedUserQueryForNotes(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.generateMutedUserQueryForNotes(query, me, { id: ps.userId });
this.queryService.generateBlockedUserQueryForNotes(query, me);
this.queryService.generateMutedNoteQuery(query, me);
}
if (ps.withFiles) {

View File

@ -15,6 +15,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';
@ -40,6 +41,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(
@ -47,6 +49,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,
@ -58,13 +61,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;
@ -73,6 +77,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

@ -512,6 +512,28 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('ノートミュートが機能する', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
await api('following/create', { userId: bob.id }, alice);
await setTimeout(1000);
const bobNote = await post(bob, { text: 'hi' });
await waitForPushToTl();
// ミュート前はノートが表示される
const res1 = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true);
// ノートをミュート
await api('notes/muting/create', { noteId: bobNote.id }, alice);
await setTimeout(1000);
// ミュート後はノートが表示されない
const res2 = await api('notes/timeline', { limit: 100 }, alice);
assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('FTT: ローカルユーザーの HTL にはプッシュされる', async () => {
const [alice, bob, carol] = await Promise.all([signup(), signup(), signup()]);
@ -744,7 +766,27 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10);
}, 1000 * 30);
test.concurrent('ノートミュートが機能する', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
await waitForPushToTl();
// ミュート前はノートが表示される
const res1 = await api('notes/local-timeline', { limit: 100 }, alice);
assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true);
// ノートをミュート
await api('notes/muting/create', { noteId: bobNote.id }, alice);
await setTimeout(1000);
// ミュート後はノートが表示されない
const res2 = await api('notes/local-timeline', { limit: 100 }, alice);
assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false);
});
});
describe('Social TL', () => {
@ -955,7 +997,27 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10);
}, 1000 * 30);
test.concurrent('ノートミュートが機能する', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
await waitForPushToTl();
// ミュート前はノートが表示される
const res1 = await api('notes/hybrid-timeline', { limit: 100 }, alice);
assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true);
// ノートをミュート
await api('notes/muting/create', { noteId: bobNote.id }, alice);
await setTimeout(1000);
// ミュート後はノートが表示されない
const res2 = await api('notes/hybrid-timeline', { limit: 100 }, alice);
assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false);
});
});
describe('User List TL', () => {
@ -1168,7 +1230,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10);
}, 1000 * 30);
test.concurrent('リスインしているユーザーの自身宛ての visibility: specified なノートが含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
@ -1201,6 +1263,29 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote.id), false);
});
test.concurrent('ノートミュートが機能する', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const list = await api('users/lists/create', { name: 'list' }, alice).then(res => res.body);
await api('users/lists/push', { listId: list.id, userId: bob.id }, alice);
await setTimeout(1000);
const bobNote = await post(bob, { text: 'hi' });
await waitForPushToTl();
// ミュート前はノートが表示される
const res1 = await api('notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true);
// ノートをミュート
await api('notes/muting/create', { noteId: bobNote.id }, alice);
await setTimeout(1000);
// ミュート後はノートが表示されない
const res2 = await api('notes/user-list-timeline', { listId: list.id }, alice);
assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false);
});
});
describe('User TL', () => {
@ -1327,7 +1412,7 @@ describe('Timelines', () => {
assert.strictEqual(res.body.some(note => note.id === bobNote1.id), false);
assert.strictEqual(res.body.some(note => note.id === bobNote2.id), true);
}, 1000 * 10);
}, 1000 * 30);
test.concurrent('[withChannelNotes: true] チャンネル投稿が含まれる', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
@ -1451,6 +1536,26 @@ describe('Timelines', () => {
const res = await api('users/notes', { userId: alice.id, sinceId: noteSince.id, untilId: noteUntil.id });
assert.deepStrictEqual(res.body, [note3, note2, note1]);
});
test.concurrent('ノートミュートが機能する', async () => {
const [alice, bob] = await Promise.all([signup(), signup()]);
const bobNote = await post(bob, { text: 'hi' });
await waitForPushToTl();
// ミュート前はノートが表示される
const res1 = await api('users/notes', { userId: bob.id }, alice);
assert.strictEqual(res1.body.some(note => note.id === bobNote.id), true);
// ノートをミュート
await api('notes/muting/create', { noteId: bobNote.id }, alice);
await setTimeout(1000);
// ミュート後はノートが表示されない
const res2 = await api('users/notes', { userId: bob.id }, alice);
assert.strictEqual(res2.body.some(note => note.id === bobNote.id), false);
});
});
// TODO: リノートミュート済みユーザーのテスト

View File

@ -0,0 +1,369 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, jest, expect, beforeAll, beforeEach, afterEach, afterAll, test } from '@jest/globals';
import { Test, TestingModule } from '@nestjs/testing';
import { randomString } from '../utils.js';
import { NoteMutingService } from '@/core/note/NoteMutingService.js';
import {
MiNoteMuting,
MiNote,
MiUser,
NoteMutingsRepository,
NotesRepository,
UsersRepository,
} from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { GlobalModule } from '@/GlobalModule.js';
import { CoreModule } from '@/core/CoreModule.js';
import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { QueryService } from '@/core/QueryService.js';
process.env.NODE_ENV = 'test';
describe('NoteMutingService', () => {
let app: TestingModule;
let service: NoteMutingService;
// --------------------------------------------------------------------------------------
let notesRepository: NotesRepository;
let noteMutingsRepository: NoteMutingsRepository;
let usersRepository: UsersRepository;
let idService: IdService;
let globalEventService: GlobalEventService;
let queryService: QueryService;
// --------------------------------------------------------------------------------------
// Helper function to create a user
async function createUser(data: Partial<MiUser> = {}): Promise<MiUser> {
const user = {
id: idService.gen(),
username: randomString(),
usernameLower: randomString().toLowerCase(),
host: null,
...data,
};
return await usersRepository.insert(user)
.then(x => usersRepository.findOneByOrFail(x.identifiers[0]));
}
// Helper function to create a note
async function createNote(data: Partial<MiNote> = {}): Promise<MiNote> {
return await notesRepository.insert({
id: idService.gen(),
userId: data.userId ?? (await createUser()).id,
text: randomString(),
visibility: 'public',
...data,
})
.then(x => notesRepository.findOneByOrFail(x.identifiers[0]));
}
// Helper function to create a note muting
async function createNoteMuting(data: Partial<MiNoteMuting> = {}): Promise<MiNoteMuting> {
const id = idService.gen();
const noteMuting = {
id,
userId: data.userId || (await createUser()).id,
noteId: data.noteId || (await createNote()).id,
expiresAt: null,
...data,
};
return await noteMutingsRepository.insert(noteMuting)
.then(x => noteMutingsRepository.findOneByOrFail(x.identifiers[0]));
}
// --------------------------------------------------------------------------------------
beforeAll(async () => {
app = await Test
.createTestingModule({
imports: [
GlobalModule,
CoreModule,
],
})
.compile();
service = app.get(NoteMutingService);
idService = app.get(IdService);
queryService = app.get(QueryService);
globalEventService = app.get(GlobalEventService);
notesRepository = app.get(DI.notesRepository);
noteMutingsRepository = app.get(DI.noteMutingsRepository);
usersRepository = app.get(DI.usersRepository);
app.enableShutdownHooks();
});
beforeEach(async () => {
// Clean database before each test
await noteMutingsRepository.delete({});
await notesRepository.delete({});
await usersRepository.delete({});
});
afterEach(async () => {
// Clean database after each test
await noteMutingsRepository.delete({});
await notesRepository.delete({});
await usersRepository.delete({});
});
afterAll(async () => {
await app.close();
});
// --------------------------------------------------------------------------------------
describe('create', () => {
test('should create a note muting', async () => {
// Create a user and a note
const user = await createUser();
const note = await createNote();
// Create a note muting
await service.create({
userId: user.id,
noteId: note.id,
expiresAt: null,
});
// Verify the note muting was created
const noteMuting = await noteMutingsRepository.findOneBy({
userId: user.id,
noteId: note.id,
});
expect(noteMuting).not.toBeNull();
expect(noteMuting?.userId).toBe(user.id);
expect(noteMuting?.noteId).toBe(note.id);
});
test('should throw NoSuchNoteError if note does not exist', async () => {
// Create a user
const user = await createUser();
const nonexistentNoteId = idService.gen();
// Attempt to create a note muting with a non-existent note
await expect(service.create({
userId: user.id,
noteId: nonexistentNoteId,
expiresAt: null,
})).rejects.toThrow(NoteMutingService.NoSuchNoteError);
// Verify no note muting was created
const noteMuting = await noteMutingsRepository.findOneBy({
userId: user.id,
noteId: nonexistentNoteId,
});
expect(noteMuting).toBeNull();
});
});
describe('delete', () => {
test('should delete a note muting', async () => {
// Create a user, note, and note muting
const user = await createUser();
const note = await createNote();
const noteMuting = await createNoteMuting({
userId: user.id,
noteId: note.id,
});
// Verify the note muting exists
const beforeDelete = await noteMutingsRepository.findOneBy({
userId: user.id,
noteId: note.id,
});
expect(beforeDelete).not.toBeNull();
// Delete the note muting
await service.delete(user.id, note.id);
// Verify the note muting was deleted
const afterDelete = await noteMutingsRepository.findOneBy({
userId: user.id,
noteId: note.id,
});
expect(afterDelete).toBeNull();
});
test('should throw NotMutedError if muting does not exist', async () => {
// Create a user and note
const user = await createUser();
const note = await createNote();
// Attempt to delete a non-existent note muting
await expect(service.delete(user.id, note.id)).rejects.toThrow(NoteMutingService.NotMutedError);
});
});
describe('isMuting', () => {
test('should return true if user is muting the note', async () => {
// Create a user, note, and note muting
const user = await createUser();
const note = await createNote();
await createNoteMuting({
userId: user.id,
noteId: note.id,
});
// Check if the user is muting the note
const result = await service.isMuting(user.id, note.id);
expect(result).toBe(true);
});
test('should return false if user is not muting the note', async () => {
// Create a user and note, but no muting
const user = await createUser();
const note = await createNote();
// Check if the user is muting the note
const result = await service.isMuting(user.id, note.id);
expect(result).toBe(false);
});
});
describe('getMutingNoteIdsSet', () => {
test('should return a set of muted note IDs', async () => {
// Create a user and multiple notes
const user = await createUser();
const note1 = await createNote();
const note2 = await createNote();
const note3 = await createNote();
// Create note mutings for two of the notes
await createNoteMuting({
userId: user.id,
noteId: note1.id,
});
await createNoteMuting({
userId: user.id,
noteId: note2.id,
});
// Get the set of muted note IDs
const result = await service.getMutingNoteIdsSet(user.id);
// Verify the result is a Set containing the muted note IDs
expect(result).toBeInstanceOf(Set);
expect(result.has(note1.id)).toBe(true);
expect(result.has(note2.id)).toBe(true);
expect(result.has(note3.id)).toBe(false);
});
});
describe('listByUserId', () => {
test('should return a list of note mutings for a user', async () => {
// Create a user and multiple notes
const user = await createUser();
const note1 = await createNote();
const note2 = await createNote();
// Create note mutings
const muting1 = await createNoteMuting({
userId: user.id,
noteId: note1.id,
});
const muting2 = await createNoteMuting({
userId: user.id,
noteId: note2.id,
});
// Get the list of note mutings
const result = await service.listByUserId({ userId: user.id });
// Verify the result contains the expected mutings
expect(result).toHaveLength(2);
expect(result.map(m => m.id).sort()).toEqual([muting1.id, muting2.id].sort());
expect(result.map(m => m.noteId).sort()).toEqual([note1.id, note2.id].sort());
});
});
describe('cleanupExpiredMutes', () => {
test('should delete expired mutes', async () => {
// Create users and notes
const user1 = await createUser();
const user2 = await createUser();
const note1 = await createNote();
const note2 = await createNote();
const note3 = await createNote();
// Set the expiration date to 1 hour ago
const expiredDate = new Date();
expiredDate.setHours(expiredDate.getHours() - 1);
// Set the expiration date to 1 hour in the future
const futureDate = new Date();
futureDate.setHours(futureDate.getHours() + 1);
// Create expired note mutings
const expiredMuting1 = await createNoteMuting({
userId: user1.id,
noteId: note1.id,
expiresAt: expiredDate,
});
const expiredMuting2 = await createNoteMuting({
userId: user1.id,
noteId: note2.id,
expiresAt: expiredDate,
});
const expiredMuting3 = await createNoteMuting({
userId: user2.id,
noteId: note3.id,
expiresAt: expiredDate,
});
// Create non-expired note muting
const activeMuting = await createNoteMuting({
userId: user2.id,
noteId: note1.id,
expiresAt: futureDate,
});
// Create permanent note muting (no expiration)
const permanentMuting = await createNoteMuting({
userId: user2.id,
noteId: note2.id,
expiresAt: null,
});
// Verify all mutings exist before cleanup
expect(await noteMutingsRepository.findOneBy({ id: expiredMuting1.id })).not.toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: expiredMuting2.id })).not.toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: expiredMuting3.id })).not.toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: activeMuting.id })).not.toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: permanentMuting.id })).not.toBeNull();
// Run cleanup
await service.cleanupExpiredMutes();
// Verify expired mutings are deleted and others remain
expect(await noteMutingsRepository.findOneBy({ id: expiredMuting1.id })).toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: expiredMuting2.id })).toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: expiredMuting3.id })).toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: activeMuting.id })).not.toBeNull();
expect(await noteMutingsRepository.findOneBy({ id: permanentMuting.id })).not.toBeNull();
// Verify cache is updated by checking isMuting
expect(await service.isMuting(user1.id, note1.id)).toBe(false);
expect(await service.isMuting(user1.id, note2.id)).toBe(false);
expect(await service.isMuting(user2.id, note3.id)).toBe(false);
expect(await service.isMuting(user2.id, note1.id)).toBe(true);
expect(await service.isMuting(user2.id, note2.id)).toBe(true);
});
});
});

View File

@ -0,0 +1,92 @@
<!--
SPDX-FileCopyrightText: syuilo and misskey-project
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkPagination ref="pagingComponent" :pagination="noteMutingPagination">
<template #empty>
<div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/>
<div>{{ i18n.ts.nothing }}</div>
</div>
</template>
<template #default="{ items }">
<MkFolder v-for="item in (items as entities.NotesMutingListResponse)" :key="item.id" style="margin-bottom: 1rem;">
<template #label>
<div>
<span>[{{ i18n.ts.expiration }}: </span>
<MkTime v-if="item.expiresAt" :time="item.expiresAt" mode="absolute"/>
<span v-else>{{ i18n.ts.none }}</span>
<span>] </span>
<span>
{{ ((item.note.user.name) ? item.note.user.name + ` (@${item.note.user.username})` : `@${item.note.user.username}`) }}
</span>
<span>
{{ i18n.ts._noteMuting.labelSuffix }}
</span>
</div>
</template>
<template #default>
<MkNoteSub :note="item.note"/>
</template>
<template #footer>
<div style="display: flex; flex-direction: column" class="_gaps">
<MkButton :danger="true" @click="onClickUnmuteNote(item.note.id)">{{ i18n.ts._noteMuting.unmuteNote }}</MkButton>
<span :class="$style.caption">{{ i18n.ts._noteMuting.unmuteCaption }}</span>
</div>
</template>
</MkFolder>
</template>
</MkPagination>
</template>
<script lang="ts" setup>
import { entities } from 'misskey-js';
import { shallowRef } from 'vue';
import type { Paging } from '@/components/MkPagination.vue';
import MkButton from '@/components/MkButton.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkNoteSub from '@/components/MkNoteSub.vue';
import MkPagination from '@/components/MkPagination.vue';
import { i18n } from '@/i18n';
import { infoImageUrl } from '@/instance';
import * as os from '@/os';
const noteMutingPagination: Paging = {
endpoint: 'notes/muting/list',
limit: 10,
};
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
async function onClickUnmuteNote(noteId: string) {
await os.apiWithDialog(
'notes/muting/delete',
{
noteId,
},
undefined,
{
'6ad3b6c9-f173-60f7-b558-5eea13896254': {
title: i18n.ts.error,
text: i18n.ts._noteMuting.notMutedNote,
},
},
);
pagingComponent.value?.reload();
}
</script>
<style lang="scss" module>
.caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--MI_THEME-fgTransparentWeak);
}
</style>

View File

@ -171,6 +171,18 @@ SPDX-License-Identifier: AGPL-3.0-only
</MkPagination>
</MkFolder>
</SearchMarker>
<SearchMarker
:label="i18n.ts._noteMuting.noteMuting"
:keywords="['mute', 'note']"
>
<MkFolder>
<template #icon><i class="ti ti-ban"></i></template>
<template #label>{{ 'ミュートしたノート' }}</template>
<XNoteMute/>
</MkFolder>
</SearchMarker>
</div>
</div>
</SearchMarker>
@ -180,6 +192,7 @@ SPDX-License-Identifier: AGPL-3.0-only
import { ref, computed, watch } from 'vue';
import XInstanceMute from './mute-block.instance-mute.vue';
import XWordMute from './mute-block.word-mute.vue';
import XNoteMute from './mute-block.note-mute.vue';
import MkPagination from '@/components/MkPagination.vue';
import { userPage } from '@/filters/user.js';
import { i18n } from '@/i18n.js';

View File

@ -234,6 +234,70 @@ export function getNoteMenu(props: {
});
}
async function toggleNoteMute(mute: boolean) {
if (!mute) {
await os.apiWithDialog(
'notes/muting/delete',
{
noteId: appearNote.id,
},
undefined,
{
'6ad3b6c9-f173-60f7-b558-5eea13896254': {
title: i18n.ts.error,
text: i18n.ts._noteMuting.notMutedNote,
},
},
);
} else {
const { canceled, result: period } = await os.select({
title: i18n.ts.mutePeriod,
items: [{
value: 'indefinitely', text: i18n.ts.indefinitely,
}, {
value: 'tenMinutes', text: i18n.ts.tenMinutes,
}, {
value: 'oneHour', text: i18n.ts.oneHour,
}, {
value: 'oneDay', text: i18n.ts.oneDay,
}, {
value: 'oneWeek', text: i18n.ts.oneWeek,
}],
default: 'indefinitely',
});
if (canceled) return;
const expiresAt = period === 'indefinitely'
? null
: period === 'tenMinutes'
? Date.now() + (1000 * 60 * 10)
: period === 'oneHour'
? Date.now() + (1000 * 60 * 60)
: period === 'oneDay'
? Date.now() + (1000 * 60 * 60 * 24)
: period === 'oneWeek'
? Date.now() + (1000 * 60 * 60 * 24 * 7)
: null;
await os.apiWithDialog(
'notes/muting/create',
{
noteId: appearNote.id,
expiresAt,
},
undefined,
{
'a58e7999-f6d3-1780-a688-f43661719662': {
title: i18n.ts.error,
text: i18n.ts._noteMuting.noNotes,
},
},
).then(() => {
props.isDeleted.value = true;
});
}
}
function copyContent(): void {
copyToClipboard(appearNote.text);
}
@ -379,6 +443,16 @@ export function getNoteMenu(props: {
action: () => toggleThreadMute(true),
}));
menuItems.push(statePromise.then(state => state.isMutedNote ? {
icon: 'ti ti-message',
text: i18n.ts._noteMuting.unmuteNote,
action: () => toggleNoteMute(false),
} : {
icon: 'ti ti-message-off',
text: i18n.ts._noteMuting.muteNote,
action: () => toggleNoteMute(true),
}));
if (appearNote.userId === $i.id) {
if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
menuItems.push({

View File

@ -1971,6 +1971,10 @@ declare namespace entities {
NotesLocalTimelineResponse,
NotesMentionsRequest,
NotesMentionsResponse,
NotesMutingCreateRequest,
NotesMutingDeleteRequest,
NotesMutingListRequest,
NotesMutingListResponse,
NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse,
NotesPollsVoteRequest,
@ -3031,6 +3035,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 NotesMutingListRequest = operations['notes___muting___list']['requestBody']['content']['application/json'];
// @public (undocumented)
type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['content']['application/json'];
// @public (undocumented)
type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json'];

View File

@ -15,7 +15,8 @@
"openapi-typescript": "6.7.6",
"ts-case-convert": "2.1.0",
"tsx": "4.19.3",
"typescript": "5.8.2"
"typescript": "5.8.2",
"eslint": "9.22.0"
},
"files": [
"built"

View File

@ -3648,6 +3648,39 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* 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.
*

View File

@ -503,6 +503,10 @@ import type {
NotesLocalTimelineResponse,
NotesMentionsRequest,
NotesMentionsResponse,
NotesMutingCreateRequest,
NotesMutingDeleteRequest,
NotesMutingListRequest,
NotesMutingListResponse,
NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse,
NotesPollsVoteRequest,
@ -969,6 +973,9 @@ 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: NotesMutingListRequest; res: NotesMutingListResponse };
'notes/polls/recommendation': { req: NotesPollsRecommendationRequest; res: NotesPollsRecommendationResponse };
'notes/polls/vote': { req: NotesPollsVoteRequest; res: EmptyResponse };
'notes/reactions': { req: NotesReactionsRequest; res: NotesReactionsResponse };

View File

@ -506,6 +506,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 NotesMutingListRequest = operations['notes___muting___list']['requestBody']['content']['application/json'];
export type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['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'];

View File

@ -3150,6 +3150,33 @@ export type paths = {
*/
post: operations['notes___mentions'];
};
'/notes/muting/create': {
/**
* notes/muting/create
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
post: operations['notes___muting___create'];
};
'/notes/muting/delete': {
/**
* notes/muting/delete
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
post: operations['notes___muting___delete'];
};
'/notes/muting/list': {
/**
* notes/muting/list
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:account*
*/
post: operations['notes___muting___list'];
};
'/notes/polls/recommendation': {
/**
* notes/polls/recommendation
@ -25142,6 +25169,181 @@ export type operations = {
};
};
};
/**
* notes/muting/create
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
notes___muting___create: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
noteId: string;
expiresAt?: number | null;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Too many requests */
429: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* notes/muting/delete
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:account*
*/
notes___muting___delete: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
noteId: string;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* notes/muting/list
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *read:account*
*/
notes___muting___list: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
sinceId?: string | null;
/** Format: misskey:id */
untilId?: string | null;
/** @default 10 */
limit?: number;
offset?: number;
};
};
};
responses: {
/** @description OK (with results) */
200: {
content: {
'application/json': ({
id: string;
/** Format: date-time */
expiresAt: string | null;
note: components['schemas']['Note'];
})[];
};
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* notes/polls/recommendation
* @description No description provided.
@ -25766,6 +25968,7 @@ export type operations = {
'application/json': {
isFavorited: boolean;
isMutedThread: boolean;
isMutedNote: boolean;
};
};
};

View File

@ -1389,6 +1389,9 @@ importers:
'@typescript-eslint/parser':
specifier: 8.29.0
version: 8.29.0(eslint@9.22.0)(typescript@5.8.2)
eslint:
specifier: 9.22.0
version: 9.22.0
openapi-types:
specifier: 12.1.3
version: 12.1.3
@ -4303,9 +4306,6 @@ packages:
'@types/eslint@7.29.0':
resolution: {integrity: sha512-VNcvioYDH8/FxaeTKkM4/TiTwt6pBV9E3OfGmvaw8tPl0rrHCJ4Ll15HRT+pMiFAf/MLQvAzC+6RzUMEL9Ceng==}
'@types/estree@1.0.6':
resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==}
'@types/estree@1.0.7':
resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==}
@ -10850,6 +10850,9 @@ packages:
vue-component-type-helpers@2.0.16:
resolution: {integrity: sha512-qisL/iAfdO++7w+SsfYQJVPj6QKvxp4i1MMxvsNO41z/8zu3KuAw9LkhKUfP/kcOWGDxESp+pQObWppXusejCA==}
vue-component-type-helpers@2.2.10:
resolution: {integrity: sha512-iDUO7uQK+Sab2tYuiP9D1oLujCWlhHELHMgV/cB13cuGbG4qwkLHvtfWb6FzvxrIOPDnU0oHsz2MlQjhYDeaHA==}
vue-component-type-helpers@2.2.8:
resolution: {integrity: sha512-4bjIsC284coDO9om4HPA62M7wfsTvcmZyzdfR0aUlFXqq4tXxM1APyXpNVxPC8QazKw9OhmZNHBVDA6ODaZsrA==}
@ -14445,7 +14448,7 @@ snapshots:
ts-dedent: 2.2.0
type-fest: 2.19.0
vue: 3.5.13(typescript@5.8.3)
vue-component-type-helpers: 2.2.8
vue-component-type-helpers: 2.2.10
'@stylistic/eslint-plugin@2.13.0(eslint@9.22.0)(typescript@5.8.2)':
dependencies:
@ -14775,8 +14778,6 @@ snapshots:
'@types/estree': 1.0.7
'@types/json-schema': 7.0.15
'@types/estree@1.0.6': {}
'@types/estree@1.0.7': {}
'@types/express-serve-static-core@4.17.33':
@ -17483,7 +17484,7 @@ snapshots:
eslint@9.22.0:
dependencies:
'@eslint-community/eslint-utils': 4.4.0(eslint@9.22.0)
'@eslint-community/eslint-utils': 4.6.1(eslint@9.22.0)
'@eslint-community/regexpp': 4.12.1
'@eslint/config-array': 0.19.2
'@eslint/config-helpers': 0.1.0
@ -17494,7 +17495,7 @@ snapshots:
'@humanfs/node': 0.16.6
'@humanwhocodes/module-importer': 1.0.1
'@humanwhocodes/retry': 0.4.2
'@types/estree': 1.0.6
'@types/estree': 1.0.7
'@types/json-schema': 7.0.15
ajv: 6.12.6
chalk: 4.1.2
@ -22699,6 +22700,8 @@ snapshots:
vue-component-type-helpers@2.0.16: {}
vue-component-type-helpers@2.2.10: {}
vue-component-type-helpers@2.2.8: {}
vue-demi@0.14.7(vue@3.5.13(typescript@5.8.3)):