diff --git a/packages/backend/src/core/NotificationService.ts b/packages/backend/src/core/NotificationService.ts index 32d54d2576..f1df9c2e8b 100644 --- a/packages/backend/src/core/NotificationService.ts +++ b/packages/backend/src/core/NotificationService.ts @@ -144,7 +144,9 @@ export class NotificationService implements OnApplicationShutdown { this.globalEventService.publishMainStream(notifieeId, 'notification', packed); // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する - setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { + // テスト通知の場合は即時発行 + const interval = notification.type === 'test' ? 0 : 2000; + setTimeout(interval, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`); if (latestReadNotificationId && (latestReadNotificationId >= (await redisIdPromise)!)) return; diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index ee67634da5..2fd45c095b 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -15,6 +15,7 @@ import { awaitAll } from '@/misc/prelude/await-all.js'; import { USER_ACTIVE_THRESHOLD, USER_ONLINE_THRESHOLD } from '@/const.js'; import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/User.js'; +import { MiNotification } from '@/models/Notification.js'; import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, UserNotePiningsRepository, UserProfilesRepository, AnnouncementReadsRepository, AnnouncementsRepository, MiUserProfile, RenoteMutingsRepository, UserMemoRepository } from '@/models/_.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; @@ -236,17 +237,34 @@ export class UserEntityService implements OnModuleInit { } @bindThis - public async getHasUnreadNotification(userId: MiUser['id']): Promise { + public async getNotificationsInfo(userId: MiUser['id']): Promise<{ + hasUnread: boolean; + unreadCount: number; + }> { + const response = { + hasUnread: false, + unreadCount: 0, + }; + const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - const latestNotificationIdsRes = await this.redisClient.xrevrange( - `notificationTimeline:${userId}`, - '+', - '-', - 'COUNT', 1); - const latestNotificationId = latestNotificationIdsRes[0]?.[0]; + if (!latestReadNotificationId) { + response.unreadCount = await this.redisClient.xlen(`notificationTimeline:${userId}`); + } else { + const latestNotificationIdsRes = await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + latestReadNotificationId, + ); - return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId); + response.unreadCount = (latestNotificationIdsRes.length - 1 >= 0) ? latestNotificationIdsRes.length - 1 : 0; + } + + if (response.unreadCount > 0) { + response.hasUnread = true; + } + + return response; } @bindThis @@ -328,6 +346,8 @@ export class UserEntityService implements OnModuleInit { const isAdmin = isMe && opts.detail ? this.roleService.isAdministrator(user) : null; const unreadAnnouncements = isMe && opts.detail ? await this.announcementService.getUnreadAnnouncements(user) : null; + const notificationsInfo = isMe && opts.detail ? await this.getNotificationsInfo(user.id) : null; + const falsy = opts.detail ? false : undefined; const packed = { @@ -442,8 +462,9 @@ export class UserEntityService implements OnModuleInit { unreadAnnouncements, hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadChannel: false, // 後方互換性のため - hasUnreadNotification: this.getHasUnreadNotification(user.id), + hasUnreadNotification: notificationsInfo?.hasUnread, hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), + unreadNotificationCount: notificationsInfo?.unreadCount, mutedWords: profile!.mutedWords, mutedInstances: profile!.mutedInstances, mutingNotificationTypes: [], // 後方互換性のため diff --git a/packages/backend/src/models/json-schema/user.ts b/packages/backend/src/models/json-schema/user.ts index 57d2d976ff..4422105e94 100644 --- a/packages/backend/src/models/json-schema/user.ts +++ b/packages/backend/src/models/json-schema/user.ts @@ -371,6 +371,10 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + unreadNotificationCount: { + type: 'number', + nullable: false, optional: false, + }, mutedWords: { type: 'array', nullable: false, optional: false, diff --git a/packages/frontend/src/boot/main-boot.ts b/packages/frontend/src/boot/main-boot.ts index f2af951d63..33e6e93e5d 100644 --- a/packages/frontend/src/boot/main-boot.ts +++ b/packages/frontend/src/boot/main-boot.ts @@ -225,11 +225,18 @@ export async function mainBoot() { }); main.on('readAllNotifications', () => { - updateAccount({ hasUnreadNotification: false }); + updateAccount({ + hasUnreadNotification: false, + unreadNotificationCount: 0, + }); }); main.on('unreadNotification', () => { - updateAccount({ hasUnreadNotification: true }); + const unreadNotificationCount = ($i?.unreadNotificationCount ?? 0) + 1; + updateAccount({ + hasUnreadNotification: true, + unreadNotificationCount, + }); }); main.on('unreadMention', () => { diff --git a/packages/frontend/src/components/MkLaunchPad.vue b/packages/frontend/src/components/MkLaunchPad.vue index 321acc0fbc..96df713a64 100644 --- a/packages/frontend/src/components/MkLaunchPad.vue +++ b/packages/frontend/src/components/MkLaunchPad.vue @@ -11,12 +11,14 @@ SPDX-License-Identifier: AGPL-3.0-only
{{ item.text }}
- + {{ item.indicateValue }} +
@@ -57,6 +59,7 @@ const items = Object.keys(navbarItemDef).filter(k => !menu.includes(k)).map(k => to: def.to, action: def.action, indicate: def.indicated, + indicateValue: def.indicateValue, })); function close() { @@ -116,6 +119,34 @@ function close() { line-height: 1.5em; } + > .indicatorWithValue { + position: absolute; + top: 32px; + left: 16px; + font-size: 8px; + display: inline-flex; + color: var(--fgOnAccent); + font-weight: 700; + background: var(--indicator); + height: 1.5em; + min-width: 1.5em; + align-items: center; + justify-content: center; + border-radius: 99rem; + + + @media (max-width: 500px) { + top: 16px; + left: 8px; + } + + > span { + display: inline-block; + padding: 0 .25em; + line-height: 1.5em; + } + } + > .indicator { position: absolute; top: 32px; diff --git a/packages/frontend/src/components/MkNotifications.vue b/packages/frontend/src/components/MkNotifications.vue index 6ba2e513c5..bd71614f05 100644 --- a/packages/frontend/src/components/MkNotifications.vue +++ b/packages/frontend/src/components/MkNotifications.vue @@ -22,7 +22,7 @@ SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/frontend/src/ui/universal.vue b/packages/frontend/src/ui/universal.vue index cc66bb47a4..befd6ed257 100644 --- a/packages/frontend/src/ui/universal.vue +++ b/packages/frontend/src/ui/universal.vue @@ -27,7 +27,13 @@ SPDX-License-Identifier: AGPL-3.0-only
- +
@@ -446,6 +452,25 @@ $widgets-hide-threshold: 1090px; animation: blink 1s infinite; } +.navButtonIndicateValueIcon { + display: inline-flex; + color: var(--fgOnAccent); + font-weight: 700; + background: var(--navIndicator); + height: 1em; + min-width: 1em; + align-items: center; + justify-content: center; + border-radius: 99rem; + + & > span { + display: inline-block; + padding: 0 .25em; + font-size: .75em; + line-height: 1em; + } +} + .menuDrawerBg { z-index: 1001; } diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index fe913886c7..d26e27d7bf 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -2467,6 +2467,7 @@ type MeDetailed = UserDetailed & { hasUnreadMessagingMessage: boolean; hasUnreadNotification: boolean; hasUnreadSpecifiedNotes: boolean; + unreadNotificationCount: number; hideOnlineStatus: boolean; injectFeaturedNote: boolean; integrations: Record; @@ -2979,8 +2980,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:630:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts -// src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts -// src/entities.ts:596:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts +// src/entities.ts:108:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts +// src/entities.ts:597:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // (No @packageDocumentation comment for this package) diff --git a/packages/misskey-js/src/entities.ts b/packages/misskey-js/src/entities.ts index b60056e21a..96cb296ce9 100644 --- a/packages/misskey-js/src/entities.ts +++ b/packages/misskey-js/src/entities.ts @@ -98,6 +98,7 @@ export type MeDetailed = UserDetailed & { hasUnreadMessagingMessage: boolean; hasUnreadNotification: boolean; hasUnreadSpecifiedNotes: boolean; + unreadNotificationCount: number; hideOnlineStatus: boolean; injectFeaturedNote: boolean; integrations: Record;