Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- Enhance: リモートノートクリーニングジョブの削除対象検索処理のパフォーマンス改善
- Fix: センシティブメディア自動検出周りの依存関係・ファイルの解決に失敗する問題を修正
- Fix: フォロワー限定投稿を指名投稿で引用した際に、引用した投稿の公開範囲が意図せず変更される問題を修正
- Fix: 通知ページに表示されない通知 (解決済みフォローリクエスト・削除済みノート等) がバッジの未読カウントに残り、「全件既読」以外の通常操作で消えなくなる問題を修正 (#17427)

## 2026.5.4

Expand Down
36 changes: 26 additions & 10 deletions packages/backend/src/core/entities/UserEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
}
Expand Down
126 changes: 126 additions & 0 deletions packages/backend/test/e2e/notification-unread-count.ts
Original file line number Diff line number Diff line change
@@ -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);
});
6 changes: 6 additions & 0 deletions packages/backend/test/unit/entities/UserEntityService.ts
Comment thread
kakkokari-gtyih marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -145,7 +148,10 @@ describe('UserEntityService', () => {
UserEntityService,
ApPersonService,
NoteEntityService,
NotificationEntityService,
PageEntityService,
RoleEntityService,
ChatEntityService,
CustomEmojiService,
AnnouncementService,
RoleService,
Expand Down
Loading