From f62ad3ed3eccfd242b2d1f1e25f00276f2bfff77 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 2 Nov 2023 15:57:55 +0900 Subject: [PATCH] feat: notification grouping Resolve #12211 --- CHANGELOG.md | 3 +- locales/index.d.ts | 4 + locales/ja-JP.yml | 4 + .../backend/src/core/NoteCreateService.ts | 19 +- .../backend/src/core/NotificationService.ts | 13 +- .../backend/src/core/UserFollowingService.ts | 1 - .../entities/NotificationEntityService.ts | 162 ++++++++++++++-- packages/backend/src/models/Notification.ts | 110 ++++++++--- .../src/models/json-schema/notification.ts | 31 ++- .../backend/src/server/api/EndpointsModule.ts | 4 + packages/backend/src/server/api/endpoints.ts | 2 + .../api/endpoints/i/notifications-grouped.ts | 178 ++++++++++++++++++ .../server/api/endpoints/i/notifications.ts | 6 +- .../api/endpoints/notifications/create.ts | 4 +- packages/backend/src/types.ts | 6 + .../src/components/MkNotification.vue | 82 +++++++- .../src/components/MkNotifications.vue | 11 +- .../frontend/src/pages/settings/general.vue | 3 + packages/frontend/src/store.ts | 4 + 19 files changed, 581 insertions(+), 66 deletions(-) create mode 100644 packages/backend/src/server/api/endpoints/i/notifications-grouped.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d00c960c95e2..38ef92e9530b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,8 @@ - Feat: プラグイン・テーマを外部サイトから直接インストールできるようになりました - 外部サイトでの実装が必要です。詳細は Misskey Hub をご覧ください https://misskey-hub.net/docs/advanced/publish-on-your-website.html -- Enhance: スワイプしてタイムラインを再読込できるように +- Feat: 通知をグルーピングして表示するオプション(オプトアウト) +- Feat: スワイプしてタイムラインを再読込できるように - PCの場合は右上のボタンからでも再読込できます - Enhance: タイムラインの自動更新を無効にできるように - Enhance: コードのシンタックスハイライトエンジンをShikiに変更 diff --git a/locales/index.d.ts b/locales/index.d.ts index 8bc073d1e5c6..eb279830879d 100644 --- a/locales/index.d.ts +++ b/locales/index.d.ts @@ -1157,6 +1157,7 @@ export interface Locale { "refreshing": string; "pullDownToRefresh": string; "disableStreamingTimeline": string; + "useGroupedNotifications": string; "_announcement": { "forExistingUsers": string; "forExistingUsersDescription": string; @@ -2200,6 +2201,9 @@ export interface Locale { "checkNotificationBehavior": string; "sendTestNotification": string; "notificationWillBeDisplayedLikeThis": string; + "reactedBySomeUsers": string; + "renotedBySomeUsers": string; + "followedBySomeUsers": string; "_types": { "all": string; "note": string; diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 035cecd25ad6..5e711fcdf404 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1154,6 +1154,7 @@ releaseToRefresh: "離してリロード" refreshing: "リロード中" pullDownToRefresh: "引っ張ってリロード" disableStreamingTimeline: "タイムラインのリアルタイム更新を無効にする" +useGroupedNotifications: "通知をグルーピングして表示する" _announcement: forExistingUsers: "既存ユーザーのみ" @@ -2114,6 +2115,9 @@ _notification: checkNotificationBehavior: "通知の表示を確かめる" sendTestNotification: "テスト通知を送信する" notificationWillBeDisplayedLikeThis: "通知はこのように表示されます" + reactedBySomeUsers: "{n}人がリアクションしました" + renotedBySomeUsers: "{n}人がリノートしました" + followedBySomeUsers: "{n}人にフォローされました" _types: all: "すべて" diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts index 6caa3d463c14..acd11a9fa76d 100644 --- a/packages/backend/src/core/NoteCreateService.ts +++ b/packages/backend/src/core/NoteCreateService.ts @@ -100,17 +100,14 @@ class NotificationManager { } @bindThis - public async deliver() { + public async notify() { for (const x of this.queue) { - // ミュート情報を取得 - const mentioneeMutes = await this.mutingsRepository.findBy({ - muterId: x.target, - }); - - const mentioneesMutedUserIds = mentioneeMutes.map(m => m.muteeId); - - // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する - if (!mentioneesMutedUserIds.includes(this.notifier.id)) { + if (x.reason === 'renote') { + this.notificationService.createNotification(x.target, 'renote', { + noteId: this.note.id, + targetNoteId: this.note.renoteId!, + }, this.notifier.id); + } else { this.notificationService.createNotification(x.target, x.reason, { noteId: this.note.id, }, this.notifier.id); @@ -642,7 +639,7 @@ export class NoteCreateService implements OnApplicationShutdown { } } - nm.deliver(); + nm.notify(); //#region AP deliver if (this.userEntityService.isLocalUser(user)) { diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 7c3672c67a58..ad7be83e5b40 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -19,6 +19,7 @@ import { IdService } from '@/core/IdService.js'; import { CacheService } from '@/core/CacheService.js'; import type { Config } from '@/config.js'; import { UserListService } from '@/core/UserListService.js'; +import type { FilterUnionByProperty } from '@/types.js'; @Injectable() export class NotificationService implements OnApplicationShutdown { @@ -73,10 +74,10 @@ export class NotificationService implements OnApplicationShutdown { } @bindThis - public async createNotification( + public async createNotification( notifieeId: MiUser['id'], - type: MiNotification['type'], - data: Omit, 'notifierId'>, + type: T, + data: Omit, 'type' | 'id' | 'createdAt' | 'notifierId'>, notifierId?: MiUser['id'] | null, ): Promise { const profile = await this.cacheService.userProfileCache.fetch(notifieeId); @@ -128,9 +129,11 @@ export class NotificationService implements OnApplicationShutdown { id: this.idService.gen(), createdAt: new Date(), type: type, - notifierId: notifierId, + ...(notifierId ? { + notifierId, + } : {}), ...data, - } as MiNotification; + } as any as FilterUnionByProperty; const redisIdPromise = this.redisClient.xadd( `notificationTimeline:${notifieeId}`, diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts index 4d7e14f683ad..bd7f2980217b 100644 --- a/packages/backend/src/core/UserFollowingService.ts +++ b/packages/backend/src/core/UserFollowingService.ts @@ -509,7 +509,6 @@ export class UserFollowingService implements OnModuleInit { // 通知を作成 this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { - followRequestId: followRequest.id, }, follower.id); } diff --git a/packages/backend/src/core/entities/NotificationEntityService.ts b/packages/backend/src/core/entities/NotificationEntityService.ts index 9542815bd7c1..f74594ff0c97 100644 --- a/packages/backend/src/core/entities/NotificationEntityService.ts +++ b/packages/backend/src/core/entities/NotificationEntityService.ts @@ -9,18 +9,19 @@ import { In } from 'typeorm'; import { DI } from '@/di-symbols.js'; import type { FollowRequestsRepository, NotesRepository, MiUser, UsersRepository } from '@/models/_.js'; import { awaitAll } from '@/misc/prelude/await-all.js'; -import type { MiNotification } from '@/models/Notification.js'; +import type { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; import type { MiNote } from '@/models/Note.js'; import type { Packed } from '@/misc/json-schema.js'; import { bindThis } from '@/decorators.js'; import { isNotNull } from '@/misc/is-not-null.js'; -import { notificationTypes } from '@/types.js'; +import { FilterUnionByProperty, notificationTypes } from '@/types.js'; import type { OnModuleInit } from '@nestjs/common'; import type { CustomEmojiService } from '../CustomEmojiService.js'; import type { UserEntityService } from './UserEntityService.js'; import type { NoteEntityService } from './NoteEntityService.js'; const NOTE_REQUIRED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded'] as (typeof notificationTypes[number])[]); +const NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES = new Set(['note', 'mention', 'reply', 'renote', 'renote:grouped', 'quote', 'reaction', 'reaction:grouped', 'pollEnded']); @Injectable() export class NotificationEntityService implements OnModuleInit { @@ -66,17 +67,17 @@ export class NotificationEntityService implements OnModuleInit { }, ): Promise> { const notification = src; - const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( + const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( hint?.packedNotes != null ? hint.packedNotes.get(notification.noteId) - : this.noteEntityService.pack(notification.noteId!, { id: meId }, { + : this.noteEntityService.pack(notification.noteId, { id: meId }, { detail: true, }) ) : undefined; - const userIfNeed = notification.notifierId != null ? ( + const userIfNeed = 'notifierId' in notification ? ( hint?.packedUsers != null ? hint.packedUsers.get(notification.notifierId) - : this.userEntityService.pack(notification.notifierId!, { id: meId }, { + : this.userEntityService.pack(notification.notifierId, { id: meId }, { detail: false, }) ) : undefined; @@ -85,7 +86,7 @@ export class NotificationEntityService implements OnModuleInit { id: notification.id, createdAt: new Date(notification.createdAt).toISOString(), type: notification.type, - userId: notification.notifierId, + userId: 'notifierId' in notification ? notification.notifierId : undefined, ...(userIfNeed != null ? { user: userIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(notification.type === 'reaction' ? { @@ -111,7 +112,7 @@ export class NotificationEntityService implements OnModuleInit { let validNotifications = notifications; - const noteIds = validNotifications.map(x => x.noteId).filter(isNotNull); + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); const notes = noteIds.length > 0 ? await this.notesRepository.find({ where: { id: In(noteIds) }, relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], @@ -121,9 +122,9 @@ export class NotificationEntityService implements OnModuleInit { }); const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); - validNotifications = validNotifications.filter(x => x.noteId == null || packedNotes.has(x.noteId)); + validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); - const userIds = validNotifications.map(x => x.notifierId).filter(isNotNull); + const userIds = validNotifications.map(x => 'notifierId' in x ? x.notifierId : null).filter(isNotNull); const users = userIds.length > 0 ? await this.usersRepository.find({ where: { id: In(userIds) }, }) : []; @@ -133,10 +134,10 @@ export class NotificationEntityService implements OnModuleInit { const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); // 既に解決されたフォローリクエストの通知を除外 - const followRequestNotifications = validNotifications.filter(x => x.type === 'receiveFollowRequest'); + const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); if (followRequestNotifications.length > 0) { const reqs = await this.followRequestsRepository.find({ - where: { followerId: In(followRequestNotifications.map(x => x.notifierId!)) }, + where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, }); validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); } @@ -146,4 +147,141 @@ export class NotificationEntityService implements OnModuleInit { packedUsers, }))); } + + @bindThis + public async packGrouped( + src: MiGroupedNotification, + meId: MiUser['id'], + // eslint-disable-next-line @typescript-eslint/ban-types + options: { + + }, + hint?: { + packedNotes: Map>; + packedUsers: Map>; + }, + ): Promise> { + const notification = src; + const noteIfNeed = NOTE_REQUIRED_GROUPED_NOTIFICATION_TYPES.has(notification.type) && 'noteId' in notification ? ( + hint?.packedNotes != null + ? hint.packedNotes.get(notification.noteId) + : this.noteEntityService.pack(notification.noteId, { id: meId }, { + detail: true, + }) + ) : undefined; + const userIfNeed = 'notifierId' in notification ? ( + hint?.packedUsers != null + ? hint.packedUsers.get(notification.notifierId) + : this.userEntityService.pack(notification.notifierId, { id: meId }, { + detail: false, + }) + ) : undefined; + + if (notification.type === 'reaction:grouped') { + const reactions = await Promise.all(notification.reactions.map(async reaction => { + const user = hint?.packedUsers != null + ? hint.packedUsers.get(reaction.userId)! + : await this.userEntityService.pack(reaction.userId, { id: meId }, { + detail: false, + }); + return { + user, + reaction: reaction.reaction, + }; + })); + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + note: noteIfNeed, + reactions, + }); + } else if (notification.type === 'renote:grouped') { + const users = await Promise.all(notification.userIds.map(userId => { + const user = hint?.packedUsers != null + ? hint.packedUsers.get(userId) + : this.userEntityService.pack(userId!, { id: meId }, { + detail: false, + }); + return user; + })); + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + note: noteIfNeed, + users, + }); + } + + return await awaitAll({ + id: notification.id, + createdAt: new Date(notification.createdAt).toISOString(), + type: notification.type, + userId: 'notifierId' in notification ? notification.notifierId : undefined, + ...(userIfNeed != null ? { user: userIfNeed } : {}), + ...(noteIfNeed != null ? { note: noteIfNeed } : {}), + ...(notification.type === 'reaction' ? { + reaction: notification.reaction, + } : {}), + ...(notification.type === 'achievementEarned' ? { + achievement: notification.achievement, + } : {}), + ...(notification.type === 'app' ? { + body: notification.customBody, + header: notification.customHeader, + icon: notification.customIcon, + } : {}), + }); + } + + @bindThis + public async packGroupedMany( + notifications: MiGroupedNotification[], + meId: MiUser['id'], + ) { + if (notifications.length === 0) return []; + + let validNotifications = notifications; + + const noteIds = validNotifications.map(x => 'noteId' in x ? x.noteId : null).filter(isNotNull); + const notes = noteIds.length > 0 ? await this.notesRepository.find({ + where: { id: In(noteIds) }, + relations: ['user', 'reply', 'reply.user', 'renote', 'renote.user'], + }) : []; + const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { + detail: true, + }); + const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); + + validNotifications = validNotifications.filter(x => !('noteId' in x) || packedNotes.has(x.noteId)); + + const userIds = []; + for (const notification of validNotifications) { + if ('notifierId' in notification) userIds.push(notification.notifierId); + if (notification.type === 'reaction:grouped') userIds.push(...notification.reactions.map(x => x.userId)); + if (notification.type === 'renote:grouped') userIds.push(...notification.userIds); + } + const users = userIds.length > 0 ? await this.usersRepository.find({ + where: { id: In(userIds) }, + }) : []; + const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, { + detail: false, + }); + const packedUsers = new Map(packedUsersArray.map(p => [p.id, p])); + + // 既に解決されたフォローリクエストの通知を除外 + const followRequestNotifications = validNotifications.filter((x): x is FilterUnionByProperty => x.type === 'receiveFollowRequest'); + if (followRequestNotifications.length > 0) { + const reqs = await this.followRequestsRepository.find({ + where: { followerId: In(followRequestNotifications.map(x => x.notifierId)) }, + }); + validNotifications = validNotifications.filter(x => (x.type !== 'receiveFollowRequest') || reqs.some(r => r.followerId === x.notifierId)); + } + + return await Promise.all(validNotifications.map(x => this.packGrouped(x, meId, {}, { + packedNotes, + packedUsers, + }))); + } } diff --git a/packages/backend/src/models/Notification.ts b/packages/backend/src/models/Notification.ts index c0a9df2e23b2..1d5fc124e2ff 100644 --- a/packages/backend/src/models/Notification.ts +++ b/packages/backend/src/models/Notification.ts @@ -10,30 +10,73 @@ import { MiFollowRequest } from './FollowRequest.js'; import { MiAccessToken } from './AccessToken.js'; export type MiNotification = { + type: 'note'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'follow'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'mention'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'reply'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'renote'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; + targetNoteId: MiNote['id']; +} | { + type: 'quote'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'reaction'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; + reaction: string; +} | { + type: 'pollEnded'; + id: string; + createdAt: string; + notifierId: MiUser['id']; + noteId: MiNote['id']; +} | { + type: 'receiveFollowRequest'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'followRequestAccepted'; + id: string; + createdAt: string; + notifierId: MiUser['id']; +} | { + type: 'achievementEarned'; + id: string; + createdAt: string; + achievement: string; +} | { + type: 'app'; id: string; - - // RedisのためDateではなくstring createdAt: string; - - /** - * 通知の送信者(initiator) - */ - notifierId: MiUser['id'] | null; - - /** - * 通知の種類。 - */ - type: typeof notificationTypes[number]; - - noteId: MiNote['id'] | null; - - followRequestId: MiFollowRequest['id'] | null; - - reaction: string | null; - - choice: number | null; - - achievement: string | null; /** * アプリ通知のbody @@ -56,4 +99,25 @@ export type MiNotification = { * アプリ通知のアプリ(のトークン) */ appAccessTokenId: MiAccessToken['id'] | null; -} +} | { + type: 'test'; + id: string; + createdAt: string; +}; + +export type MiGroupedNotification = MiNotification | { + type: 'reaction:grouped'; + id: string; + createdAt: string; + noteId: MiNote['id']; + reactions: { + userId: string; + reaction: string; + }[]; +} | { + type: 'renote:grouped'; + id: string; + createdAt: string; + noteId: MiNote['id']; + userIds: string[]; +}; diff --git a/packages/backend/src/models/json-schema/notification.ts b/packages/backend/src/models/json-schema/notification.ts index 2c434913da1e..27db3bb62c09 100644 --- a/packages/backend/src/models/json-schema/notification.ts +++ b/packages/backend/src/models/json-schema/notification.ts @@ -12,7 +12,6 @@ export const packedNotificationSchema = { type: 'string', optional: false, nullable: false, format: 'id', - example: 'xxxxxxxxxx', }, createdAt: { type: 'string', @@ -22,7 +21,7 @@ export const packedNotificationSchema = { type: { type: 'string', optional: false, nullable: false, - enum: [...notificationTypes], + enum: [...notificationTypes, 'reaction:grouped', 'renote:grouped'], }, user: { type: 'object', @@ -63,5 +62,33 @@ export const packedNotificationSchema = { type: 'string', optional: true, nullable: true, }, + reactions: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'object', + properties: { + user: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, + reaction: { + type: 'string', + optional: false, nullable: false, + }, + }, + required: ['user', 'reaction'], + }, + }, + }, + users: { + type: 'array', + optional: true, nullable: true, + items: { + type: 'object', + ref: 'UserLite', + optional: false, nullable: false, + }, }, } as const; diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 376226be698a..3f8a46d8556f 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -217,6 +217,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; +import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; @@ -574,6 +575,7 @@ const $i_importMuting: Provider = { provide: 'ep:i/import-muting', useClass: ep_ const $i_importUserLists: Provider = { provide: 'ep:i/import-user-lists', useClass: ep___i_importUserLists.default }; const $i_importAntennas: Provider = { provide: 'ep:i/import-antennas', useClass: ep___i_importAntennas.default }; const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep___i_notifications.default }; +const $i_notificationsGrouped: Provider = { provide: 'ep:i/notifications-grouped', useClass: ep___i_notificationsGrouped.default }; const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; @@ -935,6 +937,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importUserLists, $i_importAntennas, $i_notifications, + $i_notificationsGrouped, $i_pageLikes, $i_pages, $i_pin, @@ -1290,6 +1293,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_importUserLists, $i_importAntennas, $i_notifications, + $i_notificationsGrouped, $i_pageLikes, $i_pages, $i_pin, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 8be91469be00..e87e1df59171 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -217,6 +217,7 @@ import * as ep___i_importMuting from './endpoints/i/import-muting.js'; import * as ep___i_importUserLists from './endpoints/i/import-user-lists.js'; import * as ep___i_importAntennas from './endpoints/i/import-antennas.js'; import * as ep___i_notifications from './endpoints/i/notifications.js'; +import * as ep___i_notificationsGrouped from './endpoints/i/notifications-grouped.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; @@ -572,6 +573,7 @@ const eps = [ ['i/import-user-lists', ep___i_importUserLists], ['i/import-antennas', ep___i_importAntennas], ['i/notifications', ep___i_notifications], + ['i/notifications-grouped', ep___i_notificationsGrouped], ['i/page-likes', ep___i_pageLikes], ['i/pages', ep___i_pages], ['i/pin', ep___i_pin], diff --git a/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts new file mode 100644 index 000000000000..4ea94b07f64e --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/notifications-grouped.ts @@ -0,0 +1,178 @@ +/* + * SPDX-FileCopyrightText: syuilo and other misskey contributors + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Brackets, In } from 'typeorm'; +import * as Redis from 'ioredis'; +import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository } from '@/models/_.js'; +import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { NoteReadService } from '@/core/NoteReadService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; +import { NotificationService } from '@/core/NotificationService.js'; +import { DI } from '@/di-symbols.js'; +import { IdService } from '@/core/IdService.js'; +import { MiGroupedNotification, MiNotification } from '@/models/Notification.js'; + +export const meta = { + tags: ['account', 'notifications'], + + requireCredential: true, + + limit: { + duration: 30000, + max: 30, + }, + + kind: 'read:notifications', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'Notification', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + markAsRead: { type: 'boolean', default: true }, + // 後方互換のため、廃止された通知タイプも受け付ける + includeTypes: { type: 'array', items: { + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + } }, + excludeTypes: { type: 'array', items: { + type: 'string', enum: [...notificationTypes, ...obsoleteNotificationTypes], + } }, + }, + required: [], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + @Inject(DI.redis) + private redisClient: Redis.Redis, + + @Inject(DI.notesRepository) + private notesRepository: NotesRepository, + + private idService: IdService, + private notificationEntityService: NotificationEntityService, + private notificationService: NotificationService, + private noteReadService: NoteReadService, + ) { + super(meta, paramDef, async (ps, me) => { + const EXTRA_LIMIT = 100; + + // includeTypes が空の場合はクエリしない + if (ps.includeTypes && ps.includeTypes.length === 0) { + return []; + } + // excludeTypes に全指定されている場合はクエリしない + if (notificationTypes.every(type => ps.excludeTypes?.includes(type))) { + return []; + } + + const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; + + const limit = (ps.limit + EXTRA_LIMIT) + (ps.untilId ? 1 : 0) + (ps.sinceId ? 1 : 0); // untilIdに指定したものも含まれるため+1 + const notificationsRes = await this.redisClient.xrevrange( + `notificationTimeline:${me.id}`, + ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+', + ps.sinceId ? this.idService.parse(ps.sinceId).date.getTime() : '-', + 'COUNT', limit); + + if (notificationsRes.length === 0) { + return []; + } + + let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId && x !== ps.sinceId) as MiNotification[]; + + if (includeTypes && includeTypes.length > 0) { + notifications = notifications.filter(notification => includeTypes.includes(notification.type)); + } else if (excludeTypes && excludeTypes.length > 0) { + notifications = notifications.filter(notification => !excludeTypes.includes(notification.type)); + } + + if (notifications.length === 0) { + return []; + } + + // Mark all as read + if (ps.markAsRead) { + this.notificationService.readAllNotification(me.id); + } + + // grouping + let groupedNotifications = [notifications[0]] as MiGroupedNotification[]; + for (let i = 1; i < notifications.length; i++) { + const notification = notifications[i]; + const prev = notifications[i - 1]; + let prevGroupedNotification = groupedNotifications.at(-1)!; + + if (prev.type === 'reaction' && notification.type === 'reaction' && prev.noteId === notification.noteId) { + if (prevGroupedNotification.type !== 'reaction:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'reaction:grouped', + id: '', + createdAt: prev.createdAt, + noteId: prev.noteId!, + reactions: [{ + userId: prev.notifierId!, + reaction: prev.reaction!, + }], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + (prevGroupedNotification as FilterUnionByProperty).reactions.push({ + userId: notification.notifierId!, + reaction: notification.reaction!, + }); + prevGroupedNotification.id = notification.id; + continue; + } + if (prev.type === 'renote' && notification.type === 'renote' && prev.targetNoteId === notification.targetNoteId) { + if (prevGroupedNotification.type !== 'renote:grouped') { + groupedNotifications[groupedNotifications.length - 1] = { + type: 'renote:grouped', + id: '', + createdAt: notification.createdAt, + noteId: prev.noteId!, + userIds: [prev.notifierId!], + }; + prevGroupedNotification = groupedNotifications.at(-1)!; + } + (prevGroupedNotification as FilterUnionByProperty).userIds.push(notification.notifierId!); + prevGroupedNotification.id = notification.id; + continue; + } + + groupedNotifications.push(notification); + } + + groupedNotifications = groupedNotifications.slice(0, ps.limit); + + const noteIds = groupedNotifications + .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId!); + + if (noteIds.length > 0) { + const notes = await this.notesRepository.findBy({ id: In(noteIds) }); + this.noteReadService.read(me.id, notes); + } + + return await this.notificationEntityService.packGroupedMany(groupedNotifications, me.id); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/i/notifications.ts b/packages/backend/src/server/api/endpoints/i/notifications.ts index 91dd72e805c1..039fd9454cdc 100644 --- a/packages/backend/src/server/api/endpoints/i/notifications.ts +++ b/packages/backend/src/server/api/endpoints/i/notifications.ts @@ -7,7 +7,7 @@ import { Brackets, In } from 'typeorm'; import * as Redis from 'ioredis'; import { Inject, Injectable } from '@nestjs/common'; import type { NotesRepository } from '@/models/_.js'; -import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; +import { obsoleteNotificationTypes, notificationTypes, FilterUnionByProperty } from '@/types.js'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { NoteReadService } from '@/core/NoteReadService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; @@ -113,8 +113,8 @@ export default class extends Endpoint { // eslint- } const noteIds = notifications - .filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)) - .map(notification => notification.noteId!); + .filter((notification): notification is FilterUnionByProperty => ['mention', 'reply', 'quote'].includes(notification.type)) + .map(notification => notification.noteId); if (noteIds.length > 0) { const notes = await this.notesRepository.findBy({ id: In(noteIds) }); diff --git a/packages/backend/src/server/api/endpoints/notifications/create.ts b/packages/backend/src/server/api/endpoints/notifications/create.ts index 19bc6fa8d7ce..7c6a97916030 100644 --- a/packages/backend/src/server/api/endpoints/notifications/create.ts +++ b/packages/backend/src/server/api/endpoints/notifications/create.ts @@ -42,8 +42,8 @@ export default class extends Endpoint { // eslint- this.notificationService.createNotification(user.id, 'app', { appAccessTokenId: token ? token.id : null, customBody: ps.body, - customHeader: ps.header ?? token?.name, - customIcon: ps.icon ?? token?.iconUrl, + customHeader: ps.header ?? token?.name ?? null, + customIcon: ps.icon ?? token?.iconUrl ?? null, }); }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 69224360b3c6..e6dfeb6f8c30 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -249,3 +249,9 @@ export type Serialized = { ? Serialized : T[K]; }; + +export type FilterUnionByProperty< + Union, + Property extends string | number | symbol, + Condition +> = Union extends Record ? Union : never; diff --git a/packages/frontend/src/components/MkNotification.vue b/packages/frontend/src/components/MkNotification.vue index c50723621623..ff20bc591fd1 100644 --- a/packages/frontend/src/components/MkNotification.vue +++ b/packages/frontend/src/components/MkNotification.vue @@ -9,9 +9,11 @@ SPDX-License-Identifier: AGPL-3.0-only +
+
- +
@@ -52,16 +53,18 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts._notification.achievementEarned }} {{ i18n.ts._notification.testNotification }} + {{ i18n.t('_notification.reactedBySomeUsers', { n: notification.reactions.length }) }} + {{ i18n.t('_notification.renotedBySomeUsers', { n: notification.users.length }) }} {{ notification.header }}
- + - + @@ -102,6 +105,24 @@ SPDX-License-Identifier: AGPL-3.0-only + +
+
+ +
+ +
+
+
+
+
+ +
+
@@ -181,6 +202,29 @@ useTooltip(reactionRef, (showing) => { display: block; width: 100%; height: 100%; +} + +.icon_reactionGroup, +.icon_renoteGroup { + display: grid; + align-items: center; + justify-items: center; + width: 80%; + height: 80%; + font-size: 15px; + border-radius: 100%; + color: #fff; +} + +.icon_reactionGroup { + background: #e99a0b; +} + +.icon_renoteGroup { + background: #36d298; +} + +.icon_app { border-radius: 6px; } @@ -305,6 +349,36 @@ useTooltip(reactionRef, (showing) => { flex: 1; } +.reactionsItem { + display: inline-block; + position: relative; + width: 38px; + height: 38px; + margin-top: 8px; + margin-right: 8px; +} + +.reactionsItemAvatar { + width: 100%; + height: 100%; +} + +.reactionsItemReaction { + position: absolute; + z-index: 1; + bottom: -2px; + right: -2px; + width: 20px; + height: 20px; + box-sizing: border-box; + border-radius: 100%; + background: var(--panel); + box-shadow: 0 0 0 3px var(--panel); + font-size: 11px; + text-align: center; + color: #fff; +} + @container (max-width: 600px) { .root { padding: 16px; diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 896f97a48ddf..8d99e440e141 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only @@ -32,6 +32,7 @@ import { $i } from '@/account.js'; import { i18n } from '@/i18n.js'; import { notificationTypes } from '@/const.js'; import { infoImageUrl } from '@/instance.js'; +import { defaultStore } from '@/store.js'; const props = defineProps<{ excludeTypes?: typeof notificationTypes[number][]; @@ -39,7 +40,13 @@ const props = defineProps<{ const pagingComponent = shallowRef>(); -const pagination: Paging = { +const pagination: Paging = defaultStore.state.useGroupedNotifications ? { + endpoint: 'i/notifications-grouped' as const, + limit: 20, + params: computed(() => ({ + excludeTypes: props.excludeTypes ?? undefined, + })), +} : { endpoint: 'i/notifications' as const, limit: 20, params: computed(() => ({ diff --git a/packages/frontend/src/pages/settings/general.vue b/packages/frontend/src/pages/settings/general.vue index 85d038e3d19c..d96c984688e2 100644 --- a/packages/frontend/src/pages/settings/general.vue +++ b/packages/frontend/src/pages/settings/general.vue @@ -88,6 +88,8 @@ SPDX-License-Identifier: AGPL-3.0-only
+ {{ i18n.ts.useGroupedNotifications }} + @@ -255,6 +257,7 @@ const notificationStackAxis = computed(defaultStore.makeGetterSetter('notificati const keepScreenOn = computed(defaultStore.makeGetterSetter('keepScreenOn')); const defaultWithReplies = computed(defaultStore.makeGetterSetter('defaultWithReplies')); const disableStreamingTimeline = computed(defaultStore.makeGetterSetter('disableStreamingTimeline')); +const useGroupedNotifications = computed(defaultStore.makeGetterSetter('useGroupedNotifications')); watch(lang, () => { miLocalStorage.setItem('lang', lang.value as string); diff --git a/packages/frontend/src/store.ts b/packages/frontend/src/store.ts index 803f2f648da0..0f2e642b7b8d 100644 --- a/packages/frontend/src/store.ts +++ b/packages/frontend/src/store.ts @@ -373,6 +373,10 @@ export const defaultStore = markRaw(new Storage('base', { where: 'device', default: false, }, + useGroupedNotifications: { + where: 'device', + default: true, + }, })); // TODO: 他のタブと永続化されたstateを同期