fix(notifications/threads): Wave-1 audit fixes (🔴 + web 🟠)
- T1 (🔴): markThreadAsRead no longer receipts the thread ROOT (a 2nd instance of the read-marker-corruption regression — opening a thread whose root is old re-lit the whole room). Extracted to a pure threadReceipt.ts + 5 regression tests. - N1 (🔴): favicon/tab-title unread count now sums only leaf rooms (was double- counting every ancestor-space aggregate in roomToUnread). - N2 (🔴): notifications/sounds dedupe on the event id, not the unread count — fixes "read a DM, next message never notifies again". - T4 (🟠): the thread notification path no longer re-gates on the room count, so an explicit per-thread "All replies" override in a Mentions-only room fires. - N3 (🟠): getUnreadInfos skips phantom {0,0} entries (muted-thread-only rooms no longer light the nav row / pollute unread filters). - N4 (🟠): the Receipt handler recomputes unread instead of blanket-DELETE, so a threaded receipt can't wipe a room's valid main-timeline badge. - T2 (🟠): thread "Jump to Latest" re-anchors the virtual window (was landing on a stale mid/old event). Gates: tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
} from 'matrix-js-sdk';
|
||||
import { focusAssistActiveAtom } from '../../state/focusAssist';
|
||||
import { manualDndAtom } from '../../state/manualDnd';
|
||||
import { roomToUnreadAtom, unreadEqual, unreadInfoToUnread } from '../../state/room/roomToUnread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import LogoSVG from '../../../../public/res/lotus.png';
|
||||
import LogoUnreadSVG from '../../../../public/res/lotus-unread.png';
|
||||
import LogoHighlightSVG from '../../../../public/res/lotus-highlight.png';
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
getUnreadInfo,
|
||||
isNotificationEvent,
|
||||
} from '../../utils/room';
|
||||
import { NotificationType, UnreadInfo } from '../../../types/matrix/room';
|
||||
import { NotificationType } from '../../../types/matrix/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
@@ -96,6 +96,11 @@ function FaviconUpdater() {
|
||||
let totalNotif = 0;
|
||||
let totalHighlight = 0;
|
||||
roomToUnread.forEach((unread) => {
|
||||
// roomToUnread holds BOTH leaf rooms and per-ancestor space aggregates
|
||||
// (leaves have `from === null`, aggregates a Set). Sum only leaves —
|
||||
// otherwise a space-nested room is counted once as the leaf and again in
|
||||
// every ancestor space, inflating the tab title / favicon count.
|
||||
if (unread.from !== null) return;
|
||||
totalNotif += unread.total;
|
||||
totalHighlight += unread.highlight;
|
||||
});
|
||||
@@ -232,7 +237,7 @@ function PresenceUpdater() {
|
||||
|
||||
function MessageNotifications() {
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const unreadCacheRef = useRef<Map<string, UnreadInfo>>(new Map());
|
||||
const lastNotifiedEventRef = useRef<Map<string, string>>(new Map());
|
||||
// Per-thread dedupe: threadId -> last notified eventId.
|
||||
const lastNotifiedThreadRef = useRef<Map<string, string>>(new Map());
|
||||
const mx = useMatrixClient();
|
||||
@@ -367,17 +372,21 @@ function MessageNotifications() {
|
||||
const eventId = mEvent.getId();
|
||||
if (!sender || !eventId) return;
|
||||
|
||||
const unreadInfo = getUnreadInfo(room);
|
||||
const cachedUnreadInfo = unreadCacheRef.current.get(room.roomId);
|
||||
unreadCacheRef.current.set(room.roomId, unreadInfo);
|
||||
// Dedupe on the event id (per room): the same event can re-fire (decryption,
|
||||
// edit, thread repopulation). This replaces the old unread-COUNT dedupe,
|
||||
// which suppressed a genuinely-new message whenever its post-read count
|
||||
// matched the previously-notified count — i.e. "read a DM, next message
|
||||
// never notifies/sounds" (the common one-at-a-time cadence).
|
||||
if (lastNotifiedEventRef.current.get(room.roomId) === eventId) return;
|
||||
|
||||
if (unreadInfo.total === 0) return;
|
||||
if (
|
||||
cachedUnreadInfo &&
|
||||
unreadEqual(unreadInfoToUnread(cachedUnreadInfo), unreadInfoToUnread(unreadInfo))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Main-timeline path respects push rules: don't notify when the room has no
|
||||
// notification count (e.g. a non-mention in a Mentions-only room). The
|
||||
// thread path is already gated by shouldNotifyThreadReply, so it must NOT
|
||||
// re-gate on the room count — otherwise an explicit per-thread "All replies"
|
||||
// override in a Mentions-only room is silently dropped.
|
||||
if (!threadId && getUnreadInfo(room).total === 0) return;
|
||||
|
||||
lastNotifiedEventRef.current.set(room.roomId, eventId);
|
||||
|
||||
const quietActive =
|
||||
focusAssistActive ||
|
||||
|
||||
Reference in New Issue
Block a user