diff --git a/CHANGELOG.md b/CHANGELOG.md index d084006562c..34c8b3123aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善 - Fix: センシティブメディア自動検出周りの依存関係・ファイルの解決に失敗する問題を修正 - Fix: フォロワー限定投稿を指名投稿で引用した際に、引用した投稿の公開範囲が意図せず変更される問題を修正 +- Fix: 通知ページに表示されない通知 (解決済みフォローリクエスト・削除済みノート等) がバッジの未読カウントに残り、「全件既読」以外の通常操作で消えなくなる問題を修正 (#17427) ## 2026.5.4 diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 996f0bad2e8..ee261788c2c 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -49,7 +49,9 @@ import type { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { ChatService } from '@/core/ChatService.js'; import type { OnModuleInit } from '@nestjs/common'; +import type { MiNotification } from '@/models/Notification.js'; import type { NoteEntityService } from './NoteEntityService.js'; +import type { NotificationEntityService } from './NotificationEntityService.js'; import type { PageEntityService } from './PageEntityService.js'; import { toArray } from '@/misc/prelude/array.js'; @@ -87,6 +89,7 @@ export type UserRelation = { export class UserEntityService implements OnModuleInit { private apPersonService: ApPersonService; private noteEntityService: NoteEntityService; + private notificationEntityService: NotificationEntityService; private pageEntityService: PageEntityService; private customEmojiService: CustomEmojiService; private announcementService: AnnouncementService; @@ -143,6 +146,7 @@ export class UserEntityService implements OnModuleInit { onModuleInit() { this.apPersonService = this.moduleRef.get('ApPersonService'); this.noteEntityService = this.moduleRef.get('NoteEntityService'); + this.notificationEntityService = this.moduleRef.get('NotificationEntityService'); this.pageEntityService = this.moduleRef.get('PageEntityService'); this.customEmojiService = this.moduleRef.get('CustomEmojiService'); this.announcementService = this.moduleRef.get('AnnouncementService'); @@ -342,21 +346,33 @@ export class UserEntityService implements OnModuleInit { const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`); - if (!latestReadNotificationId) { - response.unreadCount = await this.redisClient.xlen(`notificationTimeline:${userId}`); - } else { - const latestNotificationIdsRes = await this.redisClient.xrevrange( + // 未読範囲の Stream エントリを取得する。 + // latestReadNotificationId が無い (一度も既読化していない) 場合は Stream 全体が未読範囲。 + // 既読位置がある場合は exclusive 比較で「既読位置より新しい」エントリだけ拾う。 + const notificationsRes = latestReadNotificationId + ? await this.redisClient.xrevrange( + `notificationTimeline:${userId}`, + '+', + '(' + latestReadNotificationId, + ) + : await this.redisClient.xrevrange( `notificationTimeline:${userId}`, '+', - latestReadNotificationId, + '-', ); - response.unreadCount = (latestNotificationIdsRes.length - 1 >= 0) ? latestNotificationIdsRes.length - 1 : 0; - } + if (notificationsRes.length === 0) return response; - if (response.unreadCount > 0) { - response.hasUnread = true; - } + // Stream 上のエントリ数をそのまま未読カウントにすると、 + // packMany で除外される通知 (削除済みノート・サスペンドされた notifier・ + // 解決済みフォローリクエスト・削除済みロール等) も含めてしまい、 + // 「通知ページには表示されないのにバッジだけ残る」という乖離が発生する (misskey-dev/misskey#17427)。 + // API 表示と一致させるため、packMany と同じバリデータを通した件数を未読数とする。 + const notifications = notificationsRes.map(x => JSON.parse(x[1][1])) as MiNotification[]; + const validNotifications = await this.notificationEntityService.packMany(notifications, userId); + + response.unreadCount = validNotifications.length; + response.hasUnread = response.unreadCount > 0; return response; } diff --git a/packages/backend/test/e2e/notification-unread-count.ts b/packages/backend/test/e2e/notification-unread-count.ts new file mode 100644 index 00000000000..0c4f28ae52e --- /dev/null +++ b/packages/backend/test/e2e/notification-unread-count.ts @@ -0,0 +1,126 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +process.env.NODE_ENV = 'test'; + +import * as assert from 'node:assert'; +import { setTimeout } from 'node:timers/promises'; +import { beforeAll, beforeEach, describe, test } from 'vitest'; +import { api, post, signup } from '../utils.js'; +import type * as misskey from 'misskey-js'; + +// このテストは https://github.com/misskey-dev/misskey/issues/17427 +// 「通知バルーンが消えない問題」の回帰防止。 +// +// 旧 UserEntityService.getNotificationsInfo() は Redis Stream の生エントリ数で +// 未読カウントを返していたため、packMany で除外される通知 (解決済みフォロー +// リクエスト・削除済みノート・サスペンドされた notifier 等) もカウントに含まれ、 +// 「通知ページを開いても何もないのにバッジだけ残る」状態を引き起こしていた。 +// +// 修正後は packMany 相当のバリデータを通した件数を返すため、 +// API レスポンスとバッジカウントは常に一致する。 +describe('Notification unread count consistency (misskey-dev/misskey#17427)', () => { + let alice: misskey.entities.SignupResponse; + let bob: misskey.entities.SignupResponse; + + beforeAll(async () => { + alice = await signup({ username: 'alice_n17427' }); + bob = await signup({ username: 'bob_n17427' }); + }, 1000 * 60 * 2); + + // 各テスト間で Stream エントリ自体を破棄してテスト間の汚染を防ぐ。 + // mark-all-as-read だと latestReadNotificationId を進めるだけで Stream は残る。 + beforeEach(async () => { + await api('notifications/flush', {}, alice); + await api('notifications/flush', {}, bob); + await setTimeout(500); + }); + + test('承認済みフォローリクエスト通知は表示されず、カウントにも含まれない', async () => { + // alice をロックする → bob がフォローするとフォローリクエストになる + await api('i/update', { isLocked: true }, alice); + + // bob → alice フォローリクエスト発生 (alice に receiveFollowRequest 通知) + await api('following/create', { userId: alice.id }, bob); + + // createNotification の setTimeout(2000) 後に unreadNotification が発火するため待機 + await setTimeout(2500); + + // 承認前: 通知が 1 件未読として見える状態 + const beforeAccept = await api('i', {}, alice); + assert.strictEqual(beforeAccept.status, 200); + assert.strictEqual(beforeAccept.body.unreadNotificationsCount, 1, + '承認前: フォローリクエスト通知でカウント 1'); + assert.strictEqual(beforeAccept.body.hasUnreadNotification, true); + + // alice 側で承認 → receiveFollowRequest が解決済みになる & + // 同時に follow 通知が新規発生する (フォロワー追加の通知) + await api('following/requests/accept', { userId: bob.id }, alice); + await setTimeout(200); + + // 承認後の状態を観測する。 + // markAsRead=false で内部の readAllNotification を呼ばないようにする + const afterAcceptI = await api('i', {}, alice); + const notificationsRes = await api('i/notifications', { markAsRead: false }, alice); + assert.strictEqual(notificationsRes.status, 200); + + // receiveFollowRequest 通知は packMany で除外されているはず + const remainingReceiveFollowRequest = notificationsRes.body.filter( + (n: { type: string }) => n.type === 'receiveFollowRequest', + ); + assert.strictEqual(remainingReceiveFollowRequest.length, 0, + '承認後: 解決済みの receiveFollowRequest 通知は API レスポンスに現れない'); + + // 修正の本旨: バッジカウントは API レスポンスの件数と一致する + assert.strictEqual(afterAcceptI.body.unreadNotificationsCount, notificationsRes.body.length, + 'unreadNotificationsCount は packMany フィルタ後の件数と一致する (Stream の生件数ではなく)'); + + // 後片付け + await api('i/update', { isLocked: false }, alice); + await api('following/delete', { userId: alice.id }, bob); + }, 1000 * 30); + + test('mention 通知が来たときは API 件数 = バッジカウントが揃う', async () => { + await post(bob, { text: `@alice_n17427 hi ${Date.now()}` }); + // createNotificationInternal の setTimeout(2000) 経過待ち + await setTimeout(2500); + + const userInfo = await api('i', {}, alice); + const notificationsRes = await api('i/notifications', { markAsRead: false }, alice); + + assert.strictEqual(userInfo.body.unreadNotificationsCount, notificationsRes.body.length, + 'unreadNotificationsCount は packMany フィルタ後の件数と一致する'); + assert.ok(userInfo.body.unreadNotificationsCount >= 1, + 'mention 通知 1 件以上で unreadNotificationsCount >= 1'); + }, 1000 * 30); + + test('元のメンション元ノートが削除されると、その通知はカウントから除外される', async () => { + // bob から alice にメンション → alice に通知が立つ + const bobNote = await post(bob, { text: `@alice_n17427 will be deleted ${Date.now()}` }); + await setTimeout(2500); + + // 通知が立った状態を確認 + const beforeDelete = await api('i', {}, alice); + assert.ok(beforeDelete.body.unreadNotificationsCount >= 1, 'ノート削除前はカウント >= 1'); + + // bob がノートを削除する → packMany で「ノート無し」として除外されるはず + await api('notes/delete', { noteId: bobNote.id }, bob); + await setTimeout(500); + + const afterDelete = await api('i', {}, alice); + const notificationsRes = await api('i/notifications', { markAsRead: false }, alice); + + // API レスポンスから該当通知が消えていることを確認 + const stillVisible = notificationsRes.body.filter( + (n: { type: string; note?: { id: string } }) => n.type === 'mention' && n.note?.id === bobNote.id, + ); + assert.strictEqual(stillVisible.length, 0, + 'ノート削除後: mention 通知は packMany で除外され API レスポンスに現れない'); + + // バッジカウントも同様に減っているはず (修正前は Stream に残っていたのでカウントだけ残る現象が出た) + assert.strictEqual(afterDelete.body.unreadNotificationsCount, notificationsRes.body.length, + 'ノート削除後: unreadNotificationsCount は API レスポンス件数と一致する'); + }, 1000 * 30); +}); diff --git a/packages/backend/test/unit/entities/UserEntityService.ts b/packages/backend/test/unit/entities/UserEntityService.ts index 1b5f9ed874c..094695907d6 100644 --- a/packages/backend/test/unit/entities/UserEntityService.ts +++ b/packages/backend/test/unit/entities/UserEntityService.ts @@ -23,7 +23,10 @@ import { DI } from '@/di-symbols.js'; import { AvatarDecorationService } from '@/core/AvatarDecorationService.js'; import { ApPersonService } from '@/core/activitypub/models/ApPersonService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; +import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { PageEntityService } from '@/core/entities/PageEntityService.js'; +import { RoleEntityService } from '@/core/entities/RoleEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; import { CustomEmojiService } from '@/core/CustomEmojiService.js'; import { AnnouncementService } from '@/core/AnnouncementService.js'; import { RoleService } from '@/core/RoleService.js'; @@ -145,7 +148,10 @@ describe('UserEntityService', () => { UserEntityService, ApPersonService, NoteEntityService, + NotificationEntityService, PageEntityService, + RoleEntityService, + ChatEntityService, CustomEmojiService, AnnouncementService, RoleService,