From 03c64c296be3f09e7154e917e52a5684ab79e28d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E3=82=AA=E3=82=B9=E3=82=AB=E3=83=BC=E3=80=81?= Date: Wed, 6 Nov 2024 20:32:41 +0900 Subject: [PATCH] feat: notification muting --- locales/en-US.yml | 3 + locales/index.d.ts | 12 +++ locales/ja-JP.yml | 3 + locales/ko-KR.yml | 3 + packages/backend/src/core/CacheService.ts | 20 +++++ .../backend/src/core/NotificationService.ts | 2 +- .../backend/src/core/UserMutingService.ts | 16 +++- .../entities/NotificationEntityService.ts | 10 +-- .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../src/server/api/endpoints/mute/edit.ts | 88 +++++++++++++++++++ .../src/pages/settings/mute-block.vue | 18 +++- packages/misskey-js/etc/misskey-js.api.md | 4 + .../misskey-js/src/autogen/apiClientJSDoc.ts | 11 +++ packages/misskey-js/src/autogen/endpoint.ts | 2 + packages/misskey-js/src/autogen/entities.ts | 1 + packages/misskey-js/src/autogen/types.ts | 64 ++++++++++++++ 17 files changed, 253 insertions(+), 10 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/mute/edit.ts diff --git a/locales/en-US.yml b/locales/en-US.yml index dadd3286a..06e9970a1 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1331,6 +1331,9 @@ normalize: "Normalize" normalizeConfirm: "After normalization, the account will be irreversible. Are you sure you want to do this?" normalizeDescription: "Normalization is a feature for bulk data wiping and account suspension of users, which has a similar effect to deleting an account and closes all reports after it is run. Please note that this is an irreversible action." useNormalization: "Show the Normalize menu" +alsoMuteNotification: "Also mute notifications from this user" +muteNotification: "Mute notifications" +unmuteNotification: "Unmute notifications" _bubbleGame: howToPlay: "How to play" hold: "Hold" diff --git a/locales/index.d.ts b/locales/index.d.ts index 75312f9a9..e35f7f348 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -5496,6 +5496,18 @@ export interface Locale extends ILocale { * 選択した項目のみ許可 */ "consentSelected": string; + /** + * このユーザーが送信する通知も一緒にミュートする + */ + "alsoMuteNotification": string; + /** + * 通知をミュートする + */ + "muteNotification": string; + /** + * 通知をミュート解除する + */ + "unmuteNotification": string; "_bubbleGame": { /** * 遊び方 diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index d2bc485af..979dda73f 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1367,6 +1367,9 @@ pleaseConsentToTracking: "{host}は[プライバシーポリシー]({privacyPoli consentEssential: "必須項目のみ許可" consentAll: "全て許可" consentSelected: "選択した項目のみ許可" +alsoMuteNotification: "このユーザーが送信する通知も一緒にミュートする" +muteNotification: "通知をミュートする" +unmuteNotification: "通知をミュート解除する" _bubbleGame: howToPlay: "遊び方" diff --git a/locales/ko-KR.yml b/locales/ko-KR.yml index 88a70dcf5..be5d35218 100644 --- a/locales/ko-KR.yml +++ b/locales/ko-KR.yml @@ -1356,6 +1356,9 @@ pleaseConsentToTracking: "{host}는 [개인정보 처리방침]({privacyPolicyUr consentEssential: "필수 항목만 허용" consentAll: "모두 허용" consentSelected: "선택한 항목만 허용" +alsoMuteNotification: "이 유저가 보내는 알림도 같이 뮤트하기" +muteNotification: "알림을 뮤트하기" +unmuteNotification: "알림 뮤트를 해제하기" _bubbleGame: howToPlay: "설명" diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts index d008e7ec5..ba81c5712 100644 --- a/packages/backend/src/core/CacheService.ts +++ b/packages/backend/src/core/CacheService.ts @@ -21,6 +21,8 @@ export class CacheService implements OnApplicationShutdown { public localUserByIdCache: MemoryKVCache; public uriPersonCache: MemoryKVCache; public userProfileCache: RedisKVCache; + public userMutingsWithNotificationCache: RedisKVCache>; + public userMutingsWithoutNotificationCache: RedisKVCache>; public userMutingsCache: RedisKVCache>; public userBlockingCache: RedisKVCache>; public userBlockedCache: RedisKVCache>; // NOTE: 「被」Blockキャッシュ @@ -77,6 +79,22 @@ export class CacheService implements OnApplicationShutdown { fromRedisConverter: (value) => new Set(JSON.parse(value)), }); + this.userMutingsWithoutNotificationCache = new RedisKVCache>(this.redisClient, 'userMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key, withNotification: false }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + + this.userMutingsWithNotificationCache = new RedisKVCache>(this.redisClient, 'userMutings', { + lifetime: 1000 * 60 * 30, // 30m + memoryCacheLifetime: 1000 * 60, // 1m + fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key, withNotification: true }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))), + toRedisConverter: (value) => JSON.stringify(Array.from(value)), + fromRedisConverter: (value) => new Set(JSON.parse(value)), + }); + this.userBlockingCache = new RedisKVCache>(this.redisClient, 'userBlocking', { lifetime: 1000 * 60 * 30, // 30m memoryCacheLifetime: 1000 * 60, // 1m @@ -188,6 +206,8 @@ export class CacheService implements OnApplicationShutdown { this.uriPersonCache.dispose(); this.userProfileCache.dispose(); this.userMutingsCache.dispose(); + this.userMutingsWithoutNotificationCache.dispose(); + this.userMutingsWithNotificationCache.dispose(); this.userBlockingCache.dispose(); this.userBlockedCache.dispose(); this.renoteMutingsCache.dispose(); diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 624e5dea4..80b2bd726 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -106,7 +106,7 @@ export class NotificationService implements OnApplicationShutdown { return null; } - const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); + const mutings = await this.cacheService.userMutingsWithNotificationCache.fetch(notifieeId); if (mutings.has(notifierId)) { return null; } diff --git a/packages/backend/src/core/UserMutingService.ts b/packages/backend/src/core/UserMutingService.ts index 06643be5f..853e069ec 100644 --- a/packages/backend/src/core/UserMutingService.ts +++ b/packages/backend/src/core/UserMutingService.ts @@ -24,15 +24,18 @@ export class UserMutingService { } @bindThis - public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise { + public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null, withNotification = true): Promise { await this.mutingsRepository.insert({ id: this.idService.gen(), expiresAt: expiresAt ?? null, muterId: user.id, muteeId: target.id, + withNotification: withNotification, }); this.cacheService.userMutingsCache.refresh(user.id); + this.cacheService.userMutingsWithNotificationCache.refresh(user.id); + this.cacheService.userMutingsWithoutNotificationCache.refresh(user.id); } @bindThis @@ -46,6 +49,17 @@ export class UserMutingService { const muterIds = [...new Set(mutings.map(m => m.muterId))]; for (const muterId of muterIds) { this.cacheService.userMutingsCache.refresh(muterId); + this.cacheService.userMutingsWithNotificationCache.refresh(muterId); + this.cacheService.userMutingsWithoutNotificationCache.refresh(muterId); } } + + @bindThis + public async editMute(muting: MiMuting, withNotification: boolean): Promise { + await this.mutingsRepository.update(muting.id, { withNotification: withNotification }); + + this.cacheService.userMutingsCache.refresh(muting.muterId); + this.cacheService.userMutingsWithNotificationCache.refresh(muting.muterId); + this.cacheService.userMutingsWithoutNotificationCache.refresh(muting.muterId); + } } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index a580ce44d..6770bcd05 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -269,12 +269,12 @@ export class NotificationEntityService implements OnModuleInit { */ #validateNotifier ( notification: T, - userIdsWhoMeMuting: Set, + userIdsWhoMeMutingWithNotification: Set, userMutedInstances: Set, notifiers: MiUser[], ): boolean { if (!('notifierId' in notification)) return true; - if (userIdsWhoMeMuting.has(notification.notifierId)) return false; + if (userIdsWhoMeMutingWithNotification.has(notification.notifierId)) return false; const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null; @@ -303,10 +303,10 @@ export class NotificationEntityService implements OnModuleInit { meId: MiUser['id'], ): Promise { const [ - userIdsWhoMeMuting, + userIdsWhoMeMutingWithNotification, userMutedInstances, ] = (await Promise.allSettled([ - this.cacheService.userMutingsCache.fetch(meId), + this.cacheService.userMutingsWithNotificationCache.fetch(meId), this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)), ])).map(result => result.status === 'fulfilled' ? result.value : new Set()); @@ -316,7 +316,7 @@ export class NotificationEntityService implements OnModuleInit { }) : []; return ((await Promise.allSettled(notifications.map(async (notification) => { - const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers); + const isValid = this.#validateNotifier(notification, userIdsWhoMeMutingWithNotification, userMutedInstances, notifiers); return isValid ? notification : null; }))).filter(result => result.status === 'fulfilled' && isNotNull(result.value)) .map(result => (result as PromiseFulfilledResult).value)); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index ec0489d77..35e2bd01b 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -281,6 +281,7 @@ import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; +import * as ep___mute_edit from './endpoints/mute/edit.js'; import * as ep___mute_list from './endpoints/mute/list.js'; import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; @@ -680,6 +681,7 @@ const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default }; const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default }; const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default }; +const $mute_edit: Provider = { provide: 'ep:mute/edit', useClass: ep___mute_edit.default }; const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default }; const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default }; const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default }; @@ -1083,6 +1085,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $miauth_genToken, $mute_create, $mute_delete, + $mute_edit, $mute_list, $renoteMute_create, $renoteMute_delete, @@ -1480,6 +1483,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $miauth_genToken, $mute_create, $mute_delete, + $mute_edit, $mute_list, $renoteMute_create, $renoteMute_delete, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index ba0e069ae..c9ea45e44 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -281,6 +281,7 @@ import * as ep___emoji from './endpoints/emoji.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; import * as ep___mute_create from './endpoints/mute/create.js'; import * as ep___mute_delete from './endpoints/mute/delete.js'; +import * as ep___mute_edit from './endpoints/mute/edit.js'; import * as ep___mute_list from './endpoints/mute/list.js'; import * as ep___renoteMute_create from './endpoints/renote-mute/create.js'; import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js'; @@ -678,6 +679,7 @@ const eps = [ ['miauth/gen-token', ep___miauth_genToken], ['mute/create', ep___mute_create], ['mute/delete', ep___mute_delete], + ['mute/edit', ep___mute_edit], ['mute/list', ep___mute_list], ['renote-mute/create', ep___renoteMute_create], ['renote-mute/delete', ep___renoteMute_delete], diff --git a/packages/backend/src/server/api/endpoints/mute/edit.ts b/packages/backend/src/server/api/endpoints/mute/edit.ts new file mode 100644 index 000000000..34b5f113b --- /dev/null +++ b/packages/backend/src/server/api/endpoints/mute/edit.ts @@ -0,0 +1,88 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { MutingsRepository } from '@/models/_.js'; +import { DI } from '@/di-symbols.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { UserMutingService } from '@/core/UserMutingService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['account'], + + requireCredential: true, + requireRolePolicy: 'canUpdateContent', + + kind: 'write:mutes', + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: 'b851d00b-8ab1-4a56-8b1b-e24187cb48ef', + }, + + muteeIsYourself: { + message: 'Mutee is yourself.', + code: 'MUTEE_IS_YOURSELF', + id: 'f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9', + }, + + notMuting: { + message: 'You are not muting that user.', + code: 'NOT_MUTING', + id: '5467d020-daa9-4553-81e1-135c0c35a96d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + userId: { type: 'string', format: 'misskey:id' }, + withNotification: { type: 'boolean', nullable: false }, + }, + required: ['userId', 'withNotification'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userMutingService: UserMutingService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + const muter = me; + + // Check if the mutee is yourself + if (me.id === ps.userId) { + throw new ApiError(meta.errors.muteeIsYourself); + } + + // Get mutee + const mutee = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check not muting + const exist = await this.mutingsRepository.findOneBy({ + muterId: muter.id, + muteeId: mutee.id, + }); + + if (exist == null) { + throw new ApiError(meta.errors.notMuting); + } + + await this.userMutingService.editMute(exist, ps.withNotification); + }); + } +} diff --git a/packages/frontend/src/pages/settings/mute-block.vue b/packages/frontend/src/pages/settings/mute-block.vue index 855e9594b..31f7b321f 100644 --- a/packages/frontend/src/pages/settings/mute-block.vue +++ b/packages/frontend/src/pages/settings/mute-block.vue @@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only - +
Muted at:
@@ -216,8 +216,20 @@ async function unrenoteMute(user, ev) { }], ev.currentTarget ?? ev.target); } -async function unmute(user, ev) { - os.popupMenu([{ +async function editMute(muting, ev) { + os.popupMenu([...(muting.withNotification === false ? [{ + icon: 'ti ti-bell', + text: i18n.ts.muteNotification, + action: async () => { + await os.apiWithDialog('mute/edit', { userId: muting.mutee.id, withNotification: true }); + }, + }] : [{ + icon: 'ti ti-bell-off', + text: i18n.ts.unmuteNotification, + action: async () => { + await os.apiWithDialog('mute/edit', { userId: muting.mutee.id, withNotification: false }); + }, + }]), { text: i18n.ts.unmute, icon: 'ti ti-x', action: async () => { diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 6bff127e8..d4db5a0fc 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -1623,6 +1623,7 @@ declare namespace entities { MiauthGenTokenResponse, MuteCreateRequest, MuteDeleteRequest, + MuteEditRequest, MuteListRequest, MuteListResponse, RenoteMuteCreateRequest, @@ -2565,6 +2566,9 @@ type MuteDeleteRequest = operations['mute___delete']['requestBody']['content'][' // @public (undocumented) export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"]; +// @public (undocumented) +type MuteEditRequest = operations['mute___edit']['requestBody']['content']['application/json']; + // @public (undocumented) type MuteListRequest = operations['mute___list']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 0dadb2215..e2a8d6b22 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -3067,6 +3067,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:mutes* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index ec628df84..79f753373 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -403,6 +403,7 @@ import type { MiauthGenTokenResponse, MuteCreateRequest, MuteDeleteRequest, + MuteEditRequest, MuteListRequest, MuteListResponse, RenoteMuteCreateRequest, @@ -868,6 +869,7 @@ export type Endpoints = { 'miauth/gen-token': { req: MiauthGenTokenRequest; res: MiauthGenTokenResponse }; 'mute/create': { req: MuteCreateRequest; res: EmptyResponse }; 'mute/delete': { req: MuteDeleteRequest; res: EmptyResponse }; + 'mute/edit': { req: MuteEditRequest; res: EmptyResponse }; 'mute/list': { req: MuteListRequest; res: MuteListResponse }; 'renote-mute/create': { req: RenoteMuteCreateRequest; res: EmptyResponse }; 'renote-mute/delete': { req: RenoteMuteDeleteRequest; res: EmptyResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index c12b128db..daaabf252 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -406,6 +406,7 @@ export type MiauthGenTokenRequest = operations['miauth___gen-token']['requestBod export type MiauthGenTokenResponse = operations['miauth___gen-token']['responses']['200']['content']['application/json']; export type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json']; export type MuteDeleteRequest = operations['mute___delete']['requestBody']['content']['application/json']; +export type MuteEditRequest = operations['mute___edit']['requestBody']['content']['application/json']; export type MuteListRequest = operations['mute___list']['requestBody']['content']['application/json']; export type MuteListResponse = operations['mute___list']['responses']['200']['content']['application/json']; export type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index b2e342c26..fcd23b20f 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -2652,6 +2652,15 @@ export type paths = { */ post: operations['mute___delete']; }; + '/mute/edit': { + /** + * mute/edit + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:mutes* + */ + post: operations['mute___edit']; + }; '/mute/list': { /** * mute/list @@ -22915,6 +22924,8 @@ export type operations = { userId: string; /** @description A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute. */ expiresAt?: number | null; + /** @default true */ + withNotification?: boolean; }; }; }; @@ -23013,6 +23024,59 @@ export type operations = { }; }; }; + /** + * mute/edit + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:mutes* + */ + mute___edit: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + userId: string; + withNotification: boolean; + }; + }; + }; + 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']; + }; + }; + }; + }; /** * mute/list * @description No description provided.