ui impl
This commit is contained in:
parent
9b71319fb2
commit
776e90f28a
|
@ -3021,3 +3021,10 @@ _search:
|
||||||
pleaseEnterServerHost: "サーバーのホストを入力してください"
|
pleaseEnterServerHost: "サーバーのホストを入力してください"
|
||||||
pleaseSelectUser: "ユーザーを選択してください"
|
pleaseSelectUser: "ユーザーを選択してください"
|
||||||
serverHostPlaceholder: "例: misskey.example.com"
|
serverHostPlaceholder: "例: misskey.example.com"
|
||||||
|
|
||||||
|
_noteMuting:
|
||||||
|
muteNote: "ノートをミュート"
|
||||||
|
unmuteNote: "ノートのミュートを解除"
|
||||||
|
notMutedNote: "このノートはミュートされていません"
|
||||||
|
labelSuffix: "のノート"
|
||||||
|
unmuteCaption: "ミュートを解除したノートを再表示するにはタイムラインの再読み込みが必要です。"
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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(
|
||||||
|
params: {
|
||||||
userId: MiNoteMuting['userId'],
|
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
|
||||||
|
|
|
@ -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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
|
|
|
@ -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);
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.noteMutingService.create({
|
await this.noteMutingService.create({
|
||||||
userId: me.id,
|
userId: me.id,
|
||||||
noteId: note.id,
|
noteId: ps.noteId,
|
||||||
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
|
expiresAt: ps.expiresAt ? new Date(ps.expiresAt) : null,
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof NoteMutingService.NoSuchNoteError) {
|
||||||
|
throw new ApiError(meta.errors.noSuchNote);
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -16,9 +16,6 @@ export const meta = {
|
||||||
kind: 'read:account',
|
kind: 'read:account',
|
||||||
|
|
||||||
res: {
|
res: {
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
notes: {
|
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
|
@ -29,13 +26,16 @@ export const meta = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
|
||||||
},
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
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)!,
|
||||||
}));
|
}));
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
||||||
</div>
|
{{ ((item.note.user.name) ? item.note.user.name + ` (@${item.note.user.username})` : `@${item.note.user.username}`) }}
|
||||||
<div v-if="expandedBlockItems.includes(item.id)" :class="$style.userItemSub">
|
</span>
|
||||||
<div>Blocked at: <MkTime :time="item.createdAt" mode="detail"/></div>
|
<span>
|
||||||
<div v-if="item.expiresAt">Period: {{ new Date(item.expiresAt).toLocaleString() }}</div>
|
{{ i18n.ts._noteMuting.labelSuffix }}
|
||||||
<div v-else>Period: {{ i18n.ts.indefinitely }}</div>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
</MkFolder>
|
||||||
|
</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>
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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'];
|
||||||
|
|
|
@ -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']>(
|
||||||
|
|
|
@ -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 };
|
||||||
|
|
|
@ -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'];
|
||||||
|
|
Loading…
Reference in New Issue