This commit is contained in:
おさむのひと 2025-02-24 20:01:45 +09:00
parent 9b71319fb2
commit 776e90f28a
16 changed files with 279 additions and 174 deletions

View File

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

View File

@ -19,11 +19,15 @@ export class NoteMuting1739882320354 {
); );
CREATE INDEX "IDX_note_muting_userId" ON "note_muting" ("userId"); 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_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) { async down(queryRunner) {
await queryRunner.query(` 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_noteId";
DROP INDEX "IDX_note_muting_userId"; DROP INDEX "IDX_note_muting_userId";
DROP TABLE "note_muting"; DROP TABLE "note_muting";

View File

@ -5,11 +5,14 @@ import { GlobalEvents, GlobalEventService } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MiNoteMuting, NoteMutingsRepository } from '@/models/_.js'; import type { MiNoteMuting, NoteMutingsRepository, NotesRepository } from '@/models/_.js';
import { QueryService } from '@/core/QueryService.js';
@Injectable() @Injectable()
export class NoteMutingService implements OnApplicationShutdown { export class NoteMutingService implements OnApplicationShutdown {
public static NoSuchItemError = class extends Error { public static NoSuchNoteError = class extends Error {
};
public static NotMutedError = class extends Error {
}; };
private cache: RedisKVCache<Set<string>>; private cache: RedisKVCache<Set<string>>;
@ -19,16 +22,27 @@ export class NoteMutingService implements OnApplicationShutdown {
private redisClient: Redis.Redis, private redisClient: Redis.Redis,
@Inject(DI.redisForSub) @Inject(DI.redisForSub)
private redisForSub: Redis.Redis, private redisForSub: Redis.Redis,
@Inject(DI.notesRepository)
private notesRepository: NotesRepository,
@Inject(DI.noteMutingsRepository) @Inject(DI.noteMutingsRepository)
private noteMutingsRepository: NoteMutingsRepository, private noteMutingsRepository: NoteMutingsRepository,
private idService: IdService, private idService: IdService,
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private queryService: QueryService,
) { ) {
this.redisForSub.on('message', this.onMessage); this.redisForSub.on('message', this.onMessage);
this.cache = new RedisKVCache<Set<string>>(this.redisClient, 'noteMutings', { this.cache = new RedisKVCache<Set<MiNoteMuting['noteId']>>(this.redisClient, 'noteMutings', {
lifetime: 1000 * 60 * 30, // 30m // 使用頻度が高く使用される期間も長いためキャッシュの有効期限切れ→再取得が頻発すると思われる。
memoryCacheLifetime: 1000 * 60, // 1m // よって、有効期限を長めに設定して再取得の頻度を抑えるキャッシュの鮮度はRedisイベント経由で保たれているので問題ないはず
fetcher: (userId) => this.listByUserId(userId).then(xs => new Set(xs.map(x => x.noteId))), 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)), toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)), fromRedisConverter: (value) => new Set(JSON.parse(value)),
}); });
@ -62,15 +76,21 @@ export class NoteMutingService implements OnApplicationShutdown {
@bindThis @bindThis
public async listByUserId( public async listByUserId(
userId: MiNoteMuting['userId'], params: {
userId: MiNoteMuting['userId'],
sinceId?: MiNoteMuting['id'] | null,
untilId?: MiNoteMuting['id'] | null,
},
opts?: { opts?: {
limit?: number;
offset?: number;
joinUser?: boolean; joinUser?: boolean;
joinNote?: boolean; joinNote?: boolean;
}, },
): Promise<MiNoteMuting[]> { ): Promise<MiNoteMuting[]> {
const q = this.noteMutingsRepository.createQueryBuilder('noteMuting'); const q = this.queryService.makePaginationQuery(this.noteMutingsRepository.createQueryBuilder('noteMuting'), params.sinceId, params.untilId);
q.where('noteMuting.userId = :userId', { userId }); q.where('noteMuting.userId = :userId', { userId: params.userId });
if (opts?.joinUser) { if (opts?.joinUser) {
q.leftJoinAndSelect('noteMuting.user', 'user'); q.leftJoinAndSelect('noteMuting.user', 'user');
} }
@ -78,6 +98,15 @@ export class NoteMutingService implements OnApplicationShutdown {
q.leftJoinAndSelect('noteMuting.note', 'note'); 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(); return q.getMany();
} }
@ -87,19 +116,18 @@ export class NoteMutingService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async get(id: MiNoteMuting['id']): Promise<MiNoteMuting> { public async isMuting(userId: MiNoteMuting['userId'], noteId: MiNoteMuting['noteId']): Promise<boolean> {
const result = await this.noteMutingsRepository.findOne({ where: { id } }); return this.cache.fetch(userId).then(noteIds => noteIds.has(noteId));
if (!result) {
throw new NoteMutingService.NoSuchItemError();
}
return result;
} }
@bindThis @bindThis
public async create( public async create(
params: Pick<MiNoteMuting, 'userId' | 'noteId' | 'expiresAt'>, params: Pick<MiNoteMuting, 'userId' | 'noteId' | 'expiresAt'>,
): Promise<void> { ): Promise<void> {
if (!await this.notesRepository.existsBy({ id: params.noteId })) {
throw new NoteMutingService.NoSuchNoteError();
}
const id = this.idService.gen(); const id = this.idService.gen();
const result = await this.noteMutingsRepository.insertOne({ const result = await this.noteMutingsRepository.insertOne({
id, id,
@ -110,25 +138,31 @@ export class NoteMutingService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async update( public async delete(userId: MiNoteMuting['userId'], noteId: MiNoteMuting['noteId']): Promise<void> {
id: MiNoteMuting['id'], const value = await this.noteMutingsRepository.findOne({ where: { userId, noteId } });
params: Partial<Pick<MiNoteMuting, 'expiresAt'>>, if (!value) {
): Promise<void> { throw new NoteMutingService.NotMutedError();
await this.noteMutingsRepository.update(id, params); }
// 現状、ミュート設定の有無しかキャッシュしていないので更新時はイベントを発行しない。 await this.noteMutingsRepository.delete(value.id);
// 他に細かい設定が登場した場合はキャッシュの型をSetからMapに変えつつ、イベントを発行するようにする。 this.globalEventService.publishInternalEvent('noteMuteDeleted', value);
} }
@bindThis @bindThis
public async delete(id: MiNoteMuting['id']): Promise<void> { public async cleanupExpiredMutes(): Promise<void> {
const value = await this.noteMutingsRepository.findOne({ where: { id } }); const now = new Date();
if (!value) { const noteMutings = await this.noteMutingsRepository.createQueryBuilder('noteMuting')
return; .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(id); await this.noteMutingsRepository.delete(noteMutings.map(x => x.noteMuting_id));
this.globalEventService.publishInternalEvent('noteMuteDeleted', value);
for (const id of [...new Set(noteMutings.map(x => x.noteMuting_userId))]) {
// 同時多発的なDBアクセスが発生することを避けるため1回ごとにawaitする
await this.cache.refresh(id);
}
} }
@bindThis @bindThis

View File

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

View File

@ -326,7 +326,6 @@ export * as 'notes/translate' from './endpoints/notes/translate.js';
export * as 'notes/unrenote' from './endpoints/notes/unrenote.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/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/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/delete' from './endpoints/notes/muting/delete.js';
export * as 'notes/muting/list' from './endpoints/notes/muting/list.js'; export * as 'notes/muting/list' from './endpoints/notes/muting/list.js';
export * as 'notifications/create' from './endpoints/notifications/create.js'; export * as 'notifications/create' from './endpoints/notifications/create.js';

View File

@ -44,19 +44,21 @@ export const paramDef = {
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor( constructor(
private readonly noteMutingService: NoteMutingService, private readonly noteMutingService: NoteMutingService,
private readonly getterService: GetterService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const note = await this.getterService.getNote(ps.noteId).catch(err => { try {
if (err.id === '9725d0ce-ba28-4dde-95a7-2cbb2c15de24') throw new ApiError(meta.errors.noSuchNote); await this.noteMutingService.create({
throw err; userId: me.id,
}); noteId: ps.noteId,
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
await this.noteMutingService.create({ });
userId: me.id, } catch (e) {
noteId: note.id, if (e instanceof NoteMutingService.NoSuchNoteError) {
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null, throw new ApiError(meta.errors.noSuchNote);
}); } else {
throw e;
}
}
}); });
} }
} }

View File

@ -16,10 +16,11 @@ export const meta = {
kind: 'write:account', kind: 'write:account',
errors: { errors: {
noSuchItem: { notMuted: {
message: 'No such item.', message: 'Not muted.',
code: 'NO_SUCH_ITEM', code: 'NOT_MUTED',
id: '6ad3b6c9-f173-60f7-b558-5eea13896254', id: '6ad3b6c9-f173-60f7-b558-5eea13896254',
httpStatusCode: 400,
}, },
}, },
} as const; } as const;
@ -27,9 +28,9 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: { properties: {
id: { type: 'string', format: 'misskey:id' }, noteId: { type: 'string', format: 'misskey:id' },
}, },
required: ['id'], required: ['noteId'],
} as const; } as const;
@Injectable() @Injectable()
@ -37,19 +38,16 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
constructor( constructor(
private readonly noteMutingService: NoteMutingService, private readonly noteMutingService: NoteMutingService,
) { ) {
super(meta, paramDef, async (ps) => { super(meta, paramDef, async (ps, me) => {
try { try {
// Existence check await this.noteMutingService.delete(me.id, ps.noteId);
await this.noteMutingService.get(ps.id);
} catch (e) { } catch (e) {
if (e instanceof NoteMutingService.NoSuchItemError) { if (e instanceof NoteMutingService.NotMutedError) {
throw new ApiError(meta.errors.noSuchItem); throw new ApiError(meta.errors.notMuted);
} else {
throw e;
} }
throw e;
} }
await this.noteMutingService.delete(ps.id);
}); });
} }
} }

View File

@ -16,18 +16,13 @@ export const meta = {
kind: 'read:account', kind: 'read:account',
res: { res: {
type: 'object', type: 'array',
properties: { items: {
notes: { type: 'object',
type: 'array', properties: {
items: { id: { type: 'string' },
type: 'object', expiresAt: { type: 'string', format: 'date-time', nullable: true },
properties: { note: { type: 'object', ref: 'Note' },
id: { type: 'string' },
expiresAt: { type: 'string', format: 'date-time', nullable: true },
note: { type: 'object', ref: 'Note' },
},
},
}, },
}, },
}, },
@ -35,7 +30,12 @@ export const meta = {
export const paramDef = { export const paramDef = {
type: 'object', type: 'object',
properties: {}, 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: [], required: [],
} as const; } as const;
@ -46,7 +46,13 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
private readonly noteEntityService: NoteEntityService, private readonly noteEntityService: NoteEntityService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
const mutings = await this.noteMutingService.listByUserId(me.id, { joinNote: true }); 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 // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const packedNotes = await this.noteEntityService.packMany(mutings.map(m => m.note!)) const packedNotes = await this.noteEntityService.packMany(mutings.map(m => m.note!))
@ -54,7 +60,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
return mutings.map(m => ({ return mutings.map(m => ({
id: m.id, id: m.id,
expiresAt: m.expiresAt, expiresAt: m.expiresAt?.toISOString(),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
note: packedNotes.get(m.noteId)!, note: packedNotes.get(m.noteId)!,
})); }));

View File

@ -1,64 +0,0 @@
/*
* 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

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

View File

@ -4,7 +4,7 @@ SPDX-License-Identifier: AGPL-3.0-only
--> -->
<template> <template>
<MkPagination :pagination="blockingPagination"> <MkPagination ref="pagingComponent" :pagination="noteMutingPagination">
<template #empty> <template #empty>
<div class="_fullinfo"> <div class="_fullinfo">
<img :src="infoImageUrl" class="_ghost"/> <img :src="infoImageUrl" class="_ghost"/>
@ -13,36 +13,80 @@ SPDX-License-Identifier: AGPL-3.0-only
</template> </template>
<template #default="{ items }"> <template #default="{ items }">
<div class="_gaps_s"> <MkFolder v-for="item in (items as entities.NotesMutingListResponse)" :key="item.id" style="margin-bottom: 1rem;">
<div v-for="item in items" :key="item.blockee.id" :class="[$style.userItem, { [$style.userItemOpend]: expandedBlockItems.includes(item.id) }]"> <template #label>
<div :class="$style.userItemMain"> <div>
<MkA :class="$style.userItemMainBody" :to="userPage(item.blockee)"> <span>[{{ i18n.ts.expiration }}: </span>
<MkUserCardMini :user="item.blockee"/> <MkTime v-if="item.expiresAt" :time="item.expiresAt" mode="absolute"/>
</MkA> <span v-else>{{ i18n.ts.none }}</span>
<button class="_button" :class="$style.userToggle" @click="toggleBlockItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button> <span>] </span>
<button class="_button" :class="$style.remove" @click="unblock(item.blockee, $event)"><i class="ti ti-x"></i></button> <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> </div>
<div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub"> </template>
<div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div>
<div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div> <template #default>
<div v-else>Period: {{ i18n.ts.indefinitely }}</div> <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> </div>
</div> </template>
</div> </MkFolder>
</template> </template>
</MkPagination> </MkPagination>
</template> </template>
<script lang="ts" setup> <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 MkPagination from '@/components/MkPagination.vue';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import { userPage } from '@/filters/user';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import { infoImageUrl } from '@/instance'; import { infoImageUrl } from '@/instance';
import * as os from '@/os';
const noteMutingPagination = { const noteMutingPagination: Paging = {
endpoint: 'notes/muting/list' as const, endpoint: 'notes/muting/list',
limit: 10, 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> </script>
<style lang="scss" module>
.caption {
font-size: 0.85em;
padding: 8px 0 0 0;
color: var(--MI_THEME-fgTransparentWeak);
}
</style>

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 { function copyContent(): void {
copyToClipboard(appearNote.text); copyToClipboard(appearNote.text);
} }
@ -379,6 +443,16 @@ export function getNoteMenu(props: {
action: () => toggleThreadMute(true), 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 (appearNote.userId === $i.id) {
if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) { if (($i.pinnedNoteIds ?? []).includes(appearNote.id)) {
menuItems.push({ menuItems.push({

View File

@ -1692,8 +1692,8 @@ declare namespace entities {
NotesMentionsResponse, NotesMentionsResponse,
NotesMutingCreateRequest, NotesMutingCreateRequest,
NotesMutingDeleteRequest, NotesMutingDeleteRequest,
NotesMutingListRequest,
NotesMutingListResponse, NotesMutingListResponse,
NotesMutingUpdateRequest,
NotesPollsRecommendationRequest, NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse, NotesPollsRecommendationResponse,
NotesPollsVoteRequest, NotesPollsVoteRequest,
@ -2751,10 +2751,10 @@ type NotesMutingCreateRequest = operations['notes___muting___create']['requestBo
type NotesMutingDeleteRequest = operations['notes___muting___delete']['requestBody']['content']['application/json']; type NotesMutingDeleteRequest = operations['notes___muting___delete']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['content']['application/json']; type NotesMutingListRequest = operations['notes___muting___list']['requestBody']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type NotesMutingUpdateRequest = operations['notes___muting___update']['requestBody']['content']['application/json']; type NotesMutingListResponse = operations['notes___muting___list']['responses']['200']['content']['application/json'];
// @public (undocumented) // @public (undocumented)
type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json']; type NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json'];

View File

@ -3365,17 +3365,6 @@ declare module '../api.js' {
/** /**
* No description provided. * 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* * **Credential required**: *Yes* / **Permission**: *read:account*
*/ */
request<E extends 'notes/polls/recommendation', P extends Endpoints[E]['req']>( request<E extends 'notes/polls/recommendation', P extends Endpoints[E]['req']>(

View File

@ -451,8 +451,8 @@ import type {
NotesMentionsResponse, NotesMentionsResponse,
NotesMutingCreateRequest, NotesMutingCreateRequest,
NotesMutingDeleteRequest, NotesMutingDeleteRequest,
NotesMutingListRequest,
NotesMutingListResponse, NotesMutingListResponse,
NotesMutingUpdateRequest,
NotesPollsRecommendationRequest, NotesPollsRecommendationRequest,
NotesPollsRecommendationResponse, NotesPollsRecommendationResponse,
NotesPollsVoteRequest, NotesPollsVoteRequest,
@ -892,8 +892,7 @@ export type Endpoints = {
'notes/mentions': { req: NotesMentionsRequest; res: NotesMentionsResponse }; 'notes/mentions': { req: NotesMentionsRequest; res: NotesMentionsResponse };
'notes/muting/create': { req: NotesMutingCreateRequest; res: EmptyResponse }; 'notes/muting/create': { req: NotesMutingCreateRequest; res: EmptyResponse };
'notes/muting/delete': { req: NotesMutingDeleteRequest; res: EmptyResponse }; 'notes/muting/delete': { req: NotesMutingDeleteRequest; res: EmptyResponse };
'notes/muting/list': { req: EmptyRequest; res: NotesMutingListResponse }; 'notes/muting/list': { req: NotesMutingListRequest; res: NotesMutingListResponse };
'notes/muting/update': { req: NotesMutingUpdateRequest; res: EmptyResponse };
'notes/polls/recommendation': { req: NotesPollsRecommendationRequest; res: NotesPollsRecommendationResponse }; 'notes/polls/recommendation': { req: NotesPollsRecommendationRequest; res: NotesPollsRecommendationResponse };
'notes/polls/vote': { req: NotesPollsVoteRequest; res: EmptyResponse }; 'notes/polls/vote': { req: NotesPollsVoteRequest; res: EmptyResponse };
'notes/reactions': { req: NotesReactionsRequest; res: NotesReactionsResponse }; 'notes/reactions': { req: NotesReactionsRequest; res: NotesReactionsResponse };

View File

@ -454,8 +454,8 @@ export type NotesMentionsRequest = operations['notes___mentions']['requestBody']
export type NotesMentionsResponse = operations['notes___mentions']['responses']['200']['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 NotesMutingCreateRequest = operations['notes___muting___create']['requestBody']['content']['application/json'];
export type NotesMutingDeleteRequest = operations['notes___muting___delete']['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 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 NotesPollsRecommendationRequest = operations['notes___polls___recommendation']['requestBody']['content']['application/json'];
export type NotesPollsRecommendationResponse = operations['notes___polls___recommendation']['responses']['200']['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']; export type NotesPollsVoteRequest = operations['notes___polls___vote']['requestBody']['content']['application/json'];