diff --git a/CHANGELOG_engawa.md b/CHANGELOG_engawa.md index d33125c578..de137c5d20 100644 --- a/CHANGELOG_engawa.md +++ b/CHANGELOG_engawa.md @@ -25,12 +25,14 @@ ### Client - ダイスウィジェットを追加 +- 通知を全て削除できるボタンを追加 ### Server - 管理者アカウントを別サーバーに移行できるように - APIドキュメントをRedocからscalarにして軽量化 - fix: OAuthにレートリミットがかかっていない問題 - fix: SQLエスケープが不完全な問題 +- feat: 通知を個別削除するAPI ### Misc diff --git a/locales/en-US.yml b/locales/en-US.yml index 9faf5e3d80..176a8d0882 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -777,6 +777,7 @@ channel: "Channels" create: "Create" notificationSetting: "Notification settings" notificationSettingDesc: "Select the types of notification to display." +notificationFlush: "Flush ALL Notifications" useGlobalSetting: "Use global settings" useGlobalSettingDesc: "If turned on, your account's notification settings will be used. If turned off, individual configurations can be made." other: "Other" diff --git a/locales/index.d.ts b/locales/index.d.ts index 01dafaa97b..71fe8c9016 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -3138,6 +3138,10 @@ export interface Locale extends ILocale { * 表示する通知の種別を選択してください。 */ "notificationSettingDesc": string; + /** + * 通知を全部削除 + */ + "notificationFlush": string; /** * グローバル設定を使う */ diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index a871a276d5..6a4b2c786c 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -779,6 +779,7 @@ channel: "チャンネル" create: "作成" notificationSetting: "通知設定" notificationSettingDesc: "表示する通知の種別を選択してください。" +notificationFlush: "通知を全部削除" useGlobalSetting: "グローバル設定を使う" useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使用されます。オフにすると、個別に設定できるようになります。" other: "その他" diff --git a/locales/ja-KS.yml b/locales/ja-KS.yml index 8f8ac9c717..e301add7b1 100644 --- a/locales/ja-KS.yml +++ b/locales/ja-KS.yml @@ -685,6 +685,7 @@ channel: "チャンネル" create: "作成" notificationSetting: "通知設定" notificationSettingDesc: "出す通知の種類えらんでや。" +notificationFlush: "通知全部消したる" useGlobalSetting: "グローバル設定を使ってや" useGlobalSettingDesc: "オンにすると、アカウントの通知設定が使われるで。オフにすると、別々に設定できるようになるで。" other: "その他" diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index c6d164ae15..53e038983c 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -22,6 +22,7 @@ import type { MiPage } from '@/models/Page.js'; import type { MiWebhook } from '@/models/Webhook.js'; import type { MiSystemWebhook } from '@/models/SystemWebhook.js'; import type { MiMeta } from '@/models/Meta.js'; +import type { MiNotification } from '@/models/Notification.js'; import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js'; import type { Packed } from '@/misc/json-schema.js'; import { DI } from '@/di-symbols.js'; @@ -73,6 +74,7 @@ export interface MainEventTypes { }; readAllNotifications: undefined; notificationFlushed: undefined; + notificationDeleted: MiNotification['id']; unreadNotification: Packed<'Notification'>; unreadMention: MiNote['id']; readAllUnreadMentions: undefined; diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 68ad92f396..7cdcd38f62 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -223,6 +223,26 @@ export class NotificationService implements OnApplicationShutdown { this.globalEventService.publishMainStream(userId, 'notificationFlushed'); } + async #getNotifications(userId: MiUser['id'], notificationId: MiNotification['id']) { + const notificationRes = await this.redisClient.xrange( + `notificationTimeline:${userId}`, + `${this.idService.parse(notificationId).date.getTime() - 1000}-0`, + `${this.idService.parse(notificationId).date.getTime() + 1000}-9999 `, + 'COUNT', 50 + ); + return notificationRes.find(x => JSON.parse(x[1][1]).id === notificationId); + } + + @bindThis + public async deleteNotification(userId: MiUser['id'], notificationId: MiNotification['id']) : Promise { + const targetResId = (await this.#getNotifications(userId, notificationId))?.[0]; + if (!targetResId) return; + + await this.redisClient.xdel(`notificationTimeline:${userId}`, targetResId); + this.globalEventService.publishMainStream(userId, 'notificationDeleted', notificationId); + return notificationId; + } + @bindThis public dispose(): void { this.#shutdownController.abort(); diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index b03fe2e13a..93383c5ace 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -322,6 +322,7 @@ import * as ep___notes_translate from './endpoints/notes/translate.js'; import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; +import * as ep___notifications_delete from './endpoints/notifications/delete.js'; import * as ep___notifications_flush from './endpoints/notifications/flush.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; @@ -737,6 +738,7 @@ const $notes_translate: Provider = { provide: 'ep:notes/translate', useClass: ep const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep___notes_unrenote.default }; const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; +const $notifications_delete: Provider = { provide: 'ep:notifications/delete', useClass: ep___notifications_delete.default }; const $notifications_flush: Provider = { provide: 'ep:notifications/flush', useClass: ep___notifications_flush.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; const $notifications_testNotification: Provider = { provide: 'ep:notifications/test-notification', useClass: ep___notifications_testNotification.default }; @@ -1157,6 +1159,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_unrenote, $notes_userListTimeline, $notifications_create, + $notifications_delete, $notifications_flush, $notifications_markAllAsRead, $notifications_testNotification, @@ -1569,6 +1572,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__ $notes_unrenote, $notes_userListTimeline, $notifications_create, + $notifications_delete, $notifications_flush, $notifications_markAllAsRead, $notifications_testNotification, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 33812a7f6e..ad6025754c 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -327,6 +327,7 @@ import * as ep___notes_translate from './endpoints/notes/translate.js'; import * as ep___notes_unrenote from './endpoints/notes/unrenote.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notifications_create from './endpoints/notifications/create.js'; +import * as ep___notifications_delete from './endpoints/notifications/delete.js'; import * as ep___notifications_flush from './endpoints/notifications/flush.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_testNotification from './endpoints/notifications/test-notification.js'; @@ -740,6 +741,7 @@ const eps = [ ['notes/unrenote', ep___notes_unrenote], ['notes/user-list-timeline', ep___notes_userListTimeline], ['notifications/create', ep___notifications_create], + ['notifications/delete', ep___notifications_delete], ['notifications/flush', ep___notifications_flush], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead], ['notifications/test-notification', ep___notifications_testNotification], diff --git a/packages/backend/src/server/api/endpoints/notifications/delete.ts b/packages/backend/src/server/api/endpoints/notifications/delete.ts new file mode 100644 index 0000000000..fef3236ae7 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/notifications/delete.ts @@ -0,0 +1,48 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { ApiError } from '../../error.js'; + +export const meta = { + tags: ['notification', 'account'], + + requireCredential: true, + + kind: 'write:notifications', + + errors: { + 'noSuchNotification': { + message: 'No such notification', + code: 'NO_SUCH_NOTIFICATION', + id: '4818a20e-3d02-11ef-9c7c-63e2e6b43b02', + }, + }, +} as const; + + +export const paramDef = { + type: 'object', + properties: { + notificationId: { type: 'string', format: 'misskey:id' }, + }, + required: ['notificationId'], +} as const; + +@Injectable() +export default class extends Endpoint { + constructor( + private notificationService: NotificationService, + ) { + super(meta, paramDef, async (ps, me) => { + const res = await this.notificationService.deleteNotification(me.id, ps.notificationId); + if (!res) { + throw new ApiError(meta.errors.noSuchNotification); + } + }); + } +} diff --git a/packages/cherrypick-js/etc/cherrypick-js.api.md b/packages/cherrypick-js/etc/cherrypick-js.api.md index 00dd561bed..313f74c577 100644 --- a/packages/cherrypick-js/etc/cherrypick-js.api.md +++ b/packages/cherrypick-js/etc/cherrypick-js.api.md @@ -624,6 +624,7 @@ export type Channels = { unreadMention: (payload: Note['id']) => void; readAllUnreadMentions: () => void; notificationFlushed: () => void; + notificationDeleted: () => void; unreadSpecifiedNote: (payload: Note['id']) => void; readAllUnreadSpecifiedNotes: () => void; readAllMessagingMessages: () => void; @@ -1716,6 +1717,7 @@ declare namespace entities { NotesUserListTimelineRequest, NotesUserListTimelineResponse, NotificationsCreateRequest, + NotificationsDeleteRequest, PagePushRequest, PagesCreateRequest, PagesCreateResponse, @@ -2832,6 +2834,9 @@ type Notification_2 = components['schemas']['Notification']; // @public (undocumented) type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json']; +// @public (undocumented) +type NotificationsDeleteRequest = operations['notifications___delete']['requestBody']['content']['application/json']; + // @public (undocumented) export const notificationTypes: readonly ["note", "follow", "mention", "reply", "renote", "quote", "reaction", "pollVote", "pollEnded", "receiveFollowRequest", "followRequestAccepted", "groupInvited", "app", "roleAssigned", "achievementEarned"]; diff --git a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts index ee53f38a5c..1f41a02b54 100644 --- a/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts +++ b/packages/cherrypick-js/src/autogen/apiClientJSDoc.ts @@ -3517,6 +3517,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notifications* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/cherrypick-js/src/autogen/endpoint.ts b/packages/cherrypick-js/src/autogen/endpoint.ts index d5a7cf2a77..8a0e7b2c39 100644 --- a/packages/cherrypick-js/src/autogen/endpoint.ts +++ b/packages/cherrypick-js/src/autogen/endpoint.ts @@ -475,6 +475,7 @@ import type { NotesUserListTimelineRequest, NotesUserListTimelineResponse, NotificationsCreateRequest, + NotificationsDeleteRequest, PagePushRequest, PagesCreateRequest, PagesCreateResponse, @@ -936,6 +937,7 @@ export type Endpoints = { 'notes/unrenote': { req: NotesUnrenoteRequest; res: EmptyResponse }; 'notes/user-list-timeline': { req: NotesUserListTimelineRequest; res: NotesUserListTimelineResponse }; 'notifications/create': { req: NotificationsCreateRequest; res: EmptyResponse }; + 'notifications/delete': { req: NotificationsDeleteRequest; res: EmptyResponse }; 'notifications/flush': { req: EmptyRequest; res: EmptyResponse }; 'notifications/mark-all-as-read': { req: EmptyRequest; res: EmptyResponse }; 'notifications/test-notification': { req: EmptyRequest; res: EmptyResponse }; diff --git a/packages/cherrypick-js/src/autogen/entities.ts b/packages/cherrypick-js/src/autogen/entities.ts index 6679099e04..c0a6463354 100644 --- a/packages/cherrypick-js/src/autogen/entities.ts +++ b/packages/cherrypick-js/src/autogen/entities.ts @@ -478,6 +478,7 @@ export type NotesUnrenoteRequest = operations['notes___unrenote']['requestBody'] export type NotesUserListTimelineRequest = operations['notes___user-list-timeline']['requestBody']['content']['application/json']; export type NotesUserListTimelineResponse = operations['notes___user-list-timeline']['responses']['200']['content']['application/json']; export type NotificationsCreateRequest = operations['notifications___create']['requestBody']['content']['application/json']; +export type NotificationsDeleteRequest = operations['notifications___delete']['requestBody']['content']['application/json']; export type PagePushRequest = operations['page-push']['requestBody']['content']['application/json']; export type PagesCreateRequest = operations['pages___create']['requestBody']['content']['application/json']; export type PagesCreateResponse = operations['pages___create']['responses']['200']['content']['application/json']; diff --git a/packages/cherrypick-js/src/autogen/types.ts b/packages/cherrypick-js/src/autogen/types.ts index d401899871..0bd8a16c01 100644 --- a/packages/cherrypick-js/src/autogen/types.ts +++ b/packages/cherrypick-js/src/autogen/types.ts @@ -3036,6 +3036,15 @@ export type paths = { */ post: operations['notifications___create']; }; + '/notifications/delete': { + /** + * notifications/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notifications* + */ + post: operations['notifications___delete']; + }; '/notifications/flush': { /** * notifications/flush @@ -24393,6 +24402,58 @@ export type operations = { }; }; }; + /** + * notifications/delete + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *write:notifications* + */ + notifications___delete: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + notificationId: string; + }; + }; + }; + responses: { + /** @description OK (without any results) */ + 204: { + content: never; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * notifications/flush * @description No description provided. diff --git a/packages/cherrypick-js/src/streaming.types.ts b/packages/cherrypick-js/src/streaming.types.ts index 6b997cad44..cf2591b017 100644 --- a/packages/cherrypick-js/src/streaming.types.ts +++ b/packages/cherrypick-js/src/streaming.types.ts @@ -43,6 +43,7 @@ export type Channels = { unreadMention: (payload: Note['id']) => void; readAllUnreadMentions: () => void; notificationFlushed: () => void; + notificationDeleted: () => void; unreadSpecifiedNote: (payload: Note['id']) => void; readAllUnreadSpecifiedNotes: () => void; readAllMessagingMessages: () => void; diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index ceb0e6b5ca..c9983eac7f 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -83,6 +83,7 @@ let connection: Misskey.ChannelConnection; onMounted(() => { connection = useStream().useChannel('main'); connection.on('notification', onNotification); + connection.on('notificationDeleted', reload); connection.on('notificationFlushed', reload); globalEvents.on('reloadNotification', () => reload()); @@ -92,6 +93,7 @@ onActivated(() => { pagingComponent.value?.reload(); connection = useStream().useChannel('main'); connection.on('notification', onNotification); + connection.on('notificationDeleted', reload); connection.on('notificationFlushed', reload); }); diff --git a/packages/frontend/src/pages/notifications-friendly.vue b/packages/frontend/src/pages/notifications-friendly.vue index c7ca952f50..4f76858415 100644 --- a/packages/frontend/src/pages/notifications-friendly.vue +++ b/packages/frontend/src/pages/notifications-friendly.vue @@ -90,6 +90,12 @@ const headerActions = computed(() => [deviceKind === 'desktop' && !props.disable handler: () => { os.apiWithDialog('notifications/mark-all-as-read'); }, +} : undefined, tab.value === 'all' ? { + text: i18n.ts.notificationFlush, + icon: 'ti ti-trash', + handler: () => { + os.apiWithDialog('notifications/flush'); + } } : undefined].filter(x => x !== undefined)); const headerTabs = computed(() => [{ diff --git a/packages/frontend/src/ui/deck/notifications-column.vue b/packages/frontend/src/ui/deck/notifications-column.vue index 451cc58791..7301ecfaf6 100644 --- a/packages/frontend/src/ui/deck/notifications-column.vue +++ b/packages/frontend/src/ui/deck/notifications-column.vue @@ -39,9 +39,24 @@ function func() { }, 'closed'); } +async function flushNotification() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.resetAreYouSure, + }); + + if (canceled) return; + + os.apiWithDialog('notifications/flush'); +} + const menu = [{ icon: 'ti ti-pencil', text: i18n.ts.notificationSetting, action: func, +}, { + icon: 'ti ti-trash', + text: i18n.ts.notificationFlush, + action: flushNotification, }];