Compare commits

...
Author SHA1 Message Date
nathan 0aef410f60 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Successful in 10m45s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:01:05 -04:00
nathan b2376513fd fixed retarded linter problem
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:42:37 -04:00
nathan.vititoe 2bbd390a3b image path changes, for dev setup, needs testing on 'prod'
CI / Build & Quality Checks (pull_request) Failing after 6m0s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 16:35:11 -04:00
jaredandClaude Opus 4.8 d47032a14f fix(unread): clear rooms whose read receipt already covers the tail
CI / Build & Quality Checks (push) Successful in 10m54s
CI / Trigger Desktop Build (push) Successful in 8s
A room could show a permanent unread that survives every cold start even
though the server considers it fully read (notification_count 0, unthreaded
read receipt at the tail). matrix-js-sdk's fixNotificationCountOnDecryption
only ever INCREMENTS an encrypted room's Total, and addReceipt's auto-clear
fires only when the tail event is the user's own — so a count inflated in an
earlier state (before a receipt covered the tail, e.g. by a since-corrupted
undecryptable event) is never decremented and keeps a genuinely-read room lit.
This is aggravated by mixing threaded-receipt clients (Element X) with
unthreaded ones (Lotus/Cinny), which split the read marker.

Add readReceiptCoversTail(room, userId): walking the live timeline newest→
oldest, if we reach the user's read-receipt event without crossing any
notification-worthy event, the room is genuinely read and a lingering Total is
suppressed to {0,0} in getUnreadInfo / getUnreadInfos. Safe by construction —
a real unread sits AFTER the receipt and stops the walk at isNotificationEvent
— and guarded against unread threads (markAsRead clears threads unconditionally)
and off-window receipts (can't confirm → don't suppress). Self-correcting: a
new message becomes the tail and the walk stops suppressing.

Also recognize polls (m.poll.start / msc3381) as notification events so a
poll-only unread is never walked past (closes a pre-existing gap in the
tail scans), and factor the unread-thread guard into roomHasUnreadThread.

Reviewed by 3 agents (false-suppression safety, unread-system regression,
SDK behavior): no real unread is hidden for any standard content, no
regression to the atom/PUT-DELETE paths, and the fix produces {0,0} for the
target scenario and stays resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:42:32 -04:00
jaredandClaude Opus 4.8 b2678d5c6d fix(unread): stop DM device-verification requests re-lighting as unread
CI / Build & Quality Checks (push) Successful in 11m11s
CI / Trigger Desktop Build (push) Successful in 10s
A completed in-room device-verification request is a plain m.room.message
(msgtype m.key.verification.request) that matches the default DM push rule
with no recency gate, so the server/SDK notification count stays > 0 and the
DM re-lights as unread on every fresh sync until the room is opened twice.

Two-part fix:
- Display suppression: getUnreadInfo/getUnreadInfos return {0,0} for a room
  whose ENTIRE unread span (tail -> read receipt) is verification-flow events,
  via new pure helpers isVerificationFlowEvent + unreadIsOnlyVerification.
  Conservative: never suppresses when the read marker is off-window, the tail
  is still encrypted, or a highlight is present.
- Durable auto-read: useAutoMarkVerificationRead sends a read receipt covering
  the request (the only SDK-durable lever), once per room per session, gated on
  the same verification-only predicate so it can never ack a real message.

unreadIsOnlyVerification also rejects any room with an unread thread, because
markAsRead clears every thread unconditionally — otherwise a verification-only
main timeline with a genuine unread thread reply would be hidden/auto-acked.

Reviewed by 5 agents; the thread-scope guard closes the one bug they found.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 01:18:31 -04:00
11 changed files with 438 additions and 15 deletions
+3 -1
View File
@@ -4,10 +4,12 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import LotusLogo from '../../../../../public/res/Lotus.png';
import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
type MSC1929Contact = {
matrix_id?: string;
+3 -1
View File
@@ -20,7 +20,6 @@ import {
} from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import LotusLogo from '../../../../public/res/Lotus.png';
import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@@ -30,6 +29,9 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) {
+2 -1
View File
@@ -1,8 +1,9 @@
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import LotusLogo from '../../../../../public/res/Lotus.png';
import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
/**
* Absolute URL the OIDC provider redirects back to after authorization.
*
+12 -5
View File
@@ -17,9 +17,6 @@ import { manualDndAtom } from '../../state/manualDnd';
import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
import { isWithinTimeWindow } from '../../utils/timeWindow';
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';
import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
@@ -29,7 +26,13 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { getDirectRoomPath, getHomeRoomPath, getInboxInvitesPath } from '../pathUtils';
import {
getDirectRoomPath,
getHomeRoomPath,
getInboxInvitesPath,
getOriginBaseUrl,
withOriginBaseUrl,
} from '../pathUtils';
import { mDirectAtom } from '../../state/mDirectList';
import {
getMemberName,
@@ -67,6 +70,10 @@ import {
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-highlight.png');
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
@@ -531,7 +538,7 @@ function MessageNotifications() {
// 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;
if (!threadId && getUnreadInfo(room, undefined, mx).total === 0) return;
lastNotifiedEventRef.current.set(dedupeKey, eventId);
+3 -1
View File
@@ -1,8 +1,10 @@
import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page';
import LotusLogo from '../../../../public/res/Lotus.png';
import pkg from '../../../../package.json';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
export function WelcomePage() {
return (
@@ -0,0 +1,58 @@
import { useEffect, useRef } from 'react';
import { MatrixClient, MatrixEvent, MatrixEventEvent, Room } from 'matrix-js-sdk';
import { roomHaveNotification, unreadIsOnlyVerification } from '../../utils/room';
import { markAsRead } from '../../utils/notifications';
/**
* A COMPLETED in-room device-verification request is a plain `m.room.message`
* that permanently keeps a DM's server/SDK notification count > 0 (it matches the
* default DM push rule and there's no recency gate), so the DM re-lights as unread
* on every fresh sync. `getUnreadInfo`'s suppression hides the dot, but the raw
* SDK count stays "dirty" (desktop badge, other consumers) and the SDK re-inflates
* it on every decrypt. The only durable, SDK-supported fix is a read receipt that
* covers the request event.
*
* This hook sends that receipt but ONLY when a room's ENTIRE unread span is
* verification-flow events (`unreadIsOnlyVerification`), so it can never mark a
* real unread message read. It fires at most once per room per session, after the
* tail decrypts (the count is only attributable to the request post-decryption).
* `markAsRead` honours the user's private-read-receipt setting.
*/
export const useAutoMarkVerificationRead = (mx: MatrixClient): void => {
const doneRef = useRef<Set<string>>(new Set());
useEffect(() => {
const done = doneRef.current;
const maybeMark = (room: Room) => {
const { roomId } = room;
if (done.has(roomId)) return;
if (room.getMyMembership() !== 'join') return;
// Only touch rooms the SDK actually counts as notifying...
if (!roomHaveNotification(room)) return;
// ...and only when the whole unread span is a completed verification.
if (!unreadIsOnlyVerification(room, mx.getUserId())) return;
done.add(roomId);
markAsRead(mx, roomId, false).catch(() => {
// Let a later decrypt/sweep retry on transient failure.
done.delete(roomId);
});
};
// Sweep once on mount (after initial sync some verification tails are already
// decrypted), then re-check whenever an event decrypts — the count only
// becomes attributable to the verification request once it's decrypted.
mx.getRooms().forEach(maybeMark);
const onDecrypted = (event: MatrixEvent) => {
const roomId = event.getRoomId();
const room = roomId ? mx.getRoom(roomId) : null;
if (room) maybeMark(room);
};
mx.on(MatrixEventEvent.Decrypted, onDecrypted);
return () => {
mx.removeListener(MatrixEventEvent.Decrypted, onDecrypted);
};
}, [mx]);
};
+2
View File
@@ -7,6 +7,7 @@ import { markedUnreadAtom, useBindMarkedUnreadAtom } from '../room/markedUnread'
import { roomToParentsAtom, useBindRoomToParentsAtom } from '../room/roomToParents';
import { roomIdToTypingMembersAtom, useBindRoomIdToTypingMembersAtom } from '../typingMembers';
import { threadNotificationsAtom, useBindThreadNotificationsAtom } from '../threadNotifications';
import { useAutoMarkVerificationRead } from './useAutoMarkVerificationRead';
export const useBindAtoms = (mx: MatrixClient) => {
useBindMDirectAtom(mx, mDirectAtom);
@@ -16,6 +17,7 @@ export const useBindAtoms = (mx: MatrixClient) => {
useBindThreadNotificationsAtom(mx, threadNotificationsAtom);
useBindRoomToUnreadAtom(mx, roomToUnreadAtom);
useBindMarkedUnreadAtom(mx, markedUnreadAtom);
useAutoMarkVerificationRead(mx);
useBindRoomIdToTypingMembersAtom(mx, roomIdToTypingMembersAtom);
};
+2
View File
@@ -254,6 +254,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
mx,
),
});
};
@@ -332,6 +333,7 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
unreadInfo: getUnreadInfo(
room,
getMutedThreads(threadNotificationsRef.current, room.roomId),
mx,
),
});
},
+231
View File
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {
EventTimeline,
JoinRule,
MatrixClient,
MatrixEvent,
NotificationCountType,
Room,
@@ -24,6 +25,10 @@ import {
isMutedRule,
findMutedRule,
isNotificationEvent,
isVerificationFlowEvent,
unreadIsOnlyVerification,
readReceiptCoversTail,
roomHasUnreadThread,
roomHaveNotification,
getUnreadInfo,
getRoomIconSrc,
@@ -410,6 +415,232 @@ test('getUnreadInfo uses highlight when it exceeds total', () => {
assert.deepEqual(getUnreadInfo(room2), { roomId: '!r:y', highlight: 1, total: 7 });
});
// --- verification-flow unread suppression --------------------------------
test('isVerificationFlowEvent', () => {
// the in-room request (m.room.message + verification msgtype)
assert.equal(
isVerificationFlowEvent(
mockEvent({
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.key.verification.request' }),
}),
),
true,
);
// the handshake events (their own m.key.verification.* types)
['ready', 'start', 'accept', 'key', 'mac', 'done', 'cancel'].forEach((phase) => {
assert.equal(
isVerificationFlowEvent(mockEvent({ getType: () => `m.key.verification.${phase}` })),
true,
);
});
// a normal message is not verification flow
assert.equal(
isVerificationFlowEvent(
mockEvent({ getType: () => 'm.room.message', getContent: () => ({ msgtype: 'm.text' }) }),
),
false,
);
// a still-encrypted event can't be classified → false (conservative)
assert.equal(isVerificationFlowEvent(mockEvent({ getType: () => 'm.room.encrypted' })), false);
});
const mockUnreadRoom = (
events: MatrixEvent[],
readUpToId: string | null,
counts: { total: number; highlight: number } = { total: 0, highlight: 0 },
threadCounts: Record<string, { total: number; highlight: number }> = {},
): Room =>
({
roomId: '!r:x',
getEventReadUpTo: () => readUpToId,
getLiveTimeline: () => ({ getEvents: () => events }),
getUnreadNotificationCount: (type: NotificationCountType) =>
type === NotificationCountType.Total ? counts.total : counts.highlight,
getThreads: () => Object.keys(threadCounts).map((id) => ({ id })),
getThreadUnreadNotificationCount: (threadId: string, type: NotificationCountType) =>
type === NotificationCountType.Total
? (threadCounts[threadId]?.total ?? 0)
: (threadCounts[threadId]?.highlight ?? 0),
}) as unknown as Room;
const mx = { getUserId: () => '@me:x' } as unknown as MatrixClient;
const verifRequest = (id: string) =>
mockEvent({
getId: () => id,
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.key.verification.request' }),
});
const verifPhase = (id: string, phase: string) =>
mockEvent({ getId: () => id, getType: () => `m.key.verification.${phase}` });
const textMsg = (id: string) =>
mockEvent({
getId: () => id,
getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.text', body: 'hi' }),
});
const reactionEv = (id: string) =>
mockEvent({ getId: () => id, getType: () => 'm.reaction', getContent: () => ({}) });
const encryptedEv = (id: string) =>
mockEvent({ getId: () => id, getType: () => 'm.room.encrypted', getContent: () => ({}) });
const pollEv = (id: string) =>
mockEvent({ getId: () => id, getType: () => 'm.poll.start', getContent: () => ({}) });
test('unreadIsOnlyVerification: verification-only unread tail → true', () => {
// timeline oldest→newest: [read msg] then the verification handshake at the tail
const events = [textMsg('$read'), verifPhase('$done', 'done'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), true);
});
test('unreadIsOnlyVerification: a real unread message in the span → false', () => {
// an unread text message sits between the read marker and the verification tail
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('unreadIsOnlyVerification: read marker off-window → false (conservative)', () => {
// the read marker isn't in the loaded timeline
const events = [verifPhase('$done', 'done'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$offwindow'), '@me:x'), false);
});
test('unreadIsOnlyVerification: still-encrypted tail → false (conservative)', () => {
const encryptedTail = mockEvent({ getId: () => '$enc', getType: () => 'm.room.encrypted' });
const events = [textMsg('$read'), encryptedTail];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('unreadIsOnlyVerification: no userId → false', () => {
const events = [textMsg('$read'), verifRequest('$req')];
assert.equal(unreadIsOnlyVerification(mockUnreadRoom(events, '$read'), null), false);
});
test('unreadIsOnlyVerification: verification-only main tail but a real unread THREAD → false', () => {
// markAsRead clears every thread, so a verification-only main timeline must NOT
// count as "only verification" when a thread still has a genuine unread reply.
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(
events,
'$read',
{ total: 2, highlight: 0 },
{
$thread: { total: 1, highlight: 0 },
},
);
assert.equal(unreadIsOnlyVerification(room, '@me:x'), false);
});
test('getUnreadInfo suppresses a verification-only room to {0,0} when mx is passed', () => {
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
// Without mx, the raw count is trusted (backward compatible).
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
// With mx, the verification-only count is suppressed.
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
});
test('getUnreadInfo does NOT suppress a highlight (real mention) even if the tail is a verification', () => {
const events = [textMsg('$read'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 2, highlight: 1 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 1, total: 2 });
});
test('getUnreadInfo does NOT suppress when a real message is unread alongside a verification', () => {
const events = [textMsg('$read'), textMsg('$new'), verifRequest('$req')];
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
});
// --- readReceiptCoversTail (UTD / spurious-count suppression) --------------
test('readReceiptCoversTail: receipt on the tail (a reaction) → true', () => {
// The Cool Kids case: a corrupt/undecryptable event sits BEFORE the read
// receipt, and the receipt itself landed on the trailing reaction.
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), '@me:x'), true);
});
test('readReceiptCoversTail: only non-notifiable events after the receipt → true', () => {
const events = [textMsg('$read'), reactionEv('$r1'), verifRequest('$v')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), true);
});
test('readReceiptCoversTail: a real unread message after the receipt → false', () => {
const events = [textMsg('$read'), reactionEv('$r'), textMsg('$new')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('readReceiptCoversTail: an unread poll after the receipt → false (polls are content)', () => {
const events = [textMsg('$read'), pollEv('$poll')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('isNotificationEvent recognizes polls (MSC3381)', () => {
assert.equal(isNotificationEvent(pollEv('$p')), true);
});
test('readReceiptCoversTail: a still-encrypted message after the receipt → false (conservative)', () => {
const events = [textMsg('$read'), encryptedEv('$enc')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$read'), '@me:x'), false);
});
test('readReceiptCoversTail: receipt off-window → false', () => {
const events = [reactionEv('$r'), textMsg('$new')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$gone'), '@me:x'), false);
});
test('readReceiptCoversTail: no receipt / null user → false', () => {
const events = [textMsg('$read'), reactionEv('$tail')];
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, null), '@me:x'), false);
assert.equal(readReceiptCoversTail(mockUnreadRoom(events, '$tail'), null), false);
});
test('readReceiptCoversTail: a genuine unread THREAD blocks suppression → false', () => {
const events = [textMsg('$read'), reactionEv('$tail')];
const room = mockUnreadRoom(
events,
'$tail',
{ total: 2, highlight: 0 },
{
$thread: { total: 1, highlight: 0 },
},
);
assert.equal(readReceiptCoversTail(room, '@me:x'), false);
});
test('roomHasUnreadThread reflects per-thread counts', () => {
const events = [textMsg('$read')];
assert.equal(roomHasUnreadThread(mockUnreadRoom(events, '$read')), false);
assert.equal(
roomHasUnreadThread(
mockUnreadRoom(
events,
'$read',
{ total: 1, highlight: 0 },
{ $t: { total: 1, highlight: 0 } },
),
),
true,
);
});
test('getUnreadInfo suppresses a spurious count when the read receipt covers the tail', () => {
// Inflated Total=1 (undecryptable event) but the receipt is on the trailing
// reaction → the room is genuinely read; suppress to {0,0}. Without mx the raw
// count is trusted (backward compatible).
const events = [textMsg('$read'), encryptedEv('$corrupt'), reactionEv('$tail')];
const room = mockUnreadRoom(events, '$tail', { total: 1, highlight: 0 });
assert.deepEqual(getUnreadInfo(room), { roomId: '!r:x', highlight: 0, total: 1 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 0 });
});
test('getUnreadInfo does NOT suppress a spurious count when a real message is unread past the receipt', () => {
const events = [textMsg('$read'), textMsg('$new')];
const room = mockUnreadRoom(events, '$read', { total: 1, highlight: 0 });
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 });
});
const mockRoomWithThreadCounts = (
total: number,
highlight: number,
+121 -5
View File
@@ -214,6 +214,11 @@ const NOTIFICATION_EVENT_TYPES = [
'm.room.encrypted',
'm.room.member',
'm.sticker',
// Polls (MSC3381) are real content the server can count toward a room's total.
// Recognizing them keeps a poll-only unread from being walked past by the
// read-receipt/tail scans (roomHaveUnread, readReceiptCoversTail).
'm.poll.start',
'org.matrix.msc3381.poll.start',
];
// In-room device-verification requests are sent as m.room.message with this
// msgtype (the rest of the flow — start/accept/key/mac/done/cancel — uses its own
@@ -237,6 +242,84 @@ export const isNotificationEvent = (mEvent: MatrixEvent) => {
return true;
};
// In-room device verification is a small burst at the tail of a DM: the
// `m.key.verification.request` message plus the `m.key.verification.*` flow
// (ready/start/key/mac/done/cancel). A COMPLETED request keeps the server/SDK
// Total notification count > 0 forever — it's a plain `m.room.message`, so it
// matches the default DM push rule and there's no recency gate — so the DM
// re-lights as unread on every fresh sync.
export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
// getType() returns the CLEAR type once decrypted; while still encrypted we
// can't tell, so this returns false and callers treat that as "not confirmed".
const eType = mEvent.getType();
if (eType.startsWith('m.key.verification.')) return true;
if (eType === 'm.room.message') {
return mEvent.getContent().msgtype === VERIFICATION_REQUEST_MSGTYPE;
}
return false;
};
// True iff the room has any thread carrying a real unread notification. A room's
// server/SDK Total INCLUDES its threads, and `markAsRead` clears every thread
// unconditionally, so any tail-based suppression must bail when a thread is
// genuinely unread — otherwise it would hide (or wrongly ack) a real thread reply.
export const roomHasUnreadThread = (room: Room): boolean =>
room
.getThreads()
.some(
(thread) => room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0,
);
// True iff a room's ENTIRE unread span (tail → the user's read receipt) is
// nothing but verification-flow events — i.e. the only "unread" is a completed
// device verification, not a real message. Conservative: returns false when the
// read marker isn't in the loaded timeline (can't confirm the span) or while the
// tail is still encrypted (undecryptable → unknown), so it never suppresses or
// auto-reads a genuine unread message.
export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => {
if (!userId) return false;
if (roomHasUnreadThread(room)) return false;
const readUpToId = room.getEventReadUpTo(userId);
const liveEvents = room.getLiveTimeline().getEvents();
let sawVerification = false;
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
const event = liveEvents[i];
if (!event) return false;
if (event.getId() === readUpToId) return sawVerification;
if (isNotificationEvent(event) && !isVerificationFlowEvent(event)) return false;
if (isVerificationFlowEvent(event)) sawVerification = true;
}
return false;
};
// True iff the user's read receipt already covers the room's entire notifiable
// tail: walking from the newest live event, we reach the receipt's event without
// crossing any notification-worthy event. In that case there is demonstrably
// nothing real left to read, so a lingering Total > 0 is a spurious SDK count.
// matrix-js-sdk's `fixNotificationCountOnDecryption` only ever INCREMENTS an
// encrypted room's Total, and `addReceipt`'s auto-clear-to-zero only fires when
// the tail event is the user's own — so a count inflated in an earlier state
// (before a receipt covered the tail, e.g. by a since-corrupted/undecryptable
// event) is never decremented and keeps a genuinely-read room lit across cold
// starts. Anchoring on the read receipt is safe: a genuine unread would sit AFTER
// the receipt and stop the walk at `isNotificationEvent`. Conservative: returns
// false when the receipt isn't in the loaded window (can't confirm) or a thread
// is genuinely unread.
export const readReceiptCoversTail = (room: Room, userId: string | null): boolean => {
if (!userId) return false;
if (roomHasUnreadThread(room)) return false;
const readUpToId = room.getEventReadUpTo(userId);
if (!readUpToId) return false;
const liveEvents = room.getLiveTimeline().getEvents();
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
const event = liveEvents[i];
if (!event) return false;
if (event.getId() === readUpToId) return true;
if (isNotificationEvent(event)) return false;
}
return false;
};
export const roomHaveNotification = (room: Room): boolean => {
const total = room.getUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
@@ -263,7 +346,11 @@ export const roomHaveUnread = (mx: MatrixClient, room: Room) => {
return true;
};
export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInfo => {
export const getUnreadInfo = (
room: Room,
mutedThreads?: Set<string>,
mx?: MatrixClient,
): UnreadInfo => {
let total = room.getUnreadNotificationCount(NotificationCountType.Total);
let highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
@@ -280,10 +367,28 @@ export const getUnreadInfo = (room: Room, mutedThreads?: Set<string>): UnreadInf
if (highlight < 0) highlight = 0;
}
const resolvedTotal = highlight > total ? highlight : total;
// Suppress a spurious Total when the room isn't really unread. Two safe cases,
// both requiring `mx` (backward-compatible for callers/tests without it) and a
// highlight-free count (a real mention must never be hidden):
// 1. the entire unread span is a completed device verification, or
// 2. the user's read receipt already covers the whole notifiable tail (the
// SDK re-inflated an encrypted-room count past a receipt that genuinely
// covers everything — e.g. a permanently-undecryptable event).
if (
mx &&
resolvedTotal > 0 &&
highlight === 0 &&
(unreadIsOnlyVerification(room, mx.getUserId()) || readReceiptCoversTail(room, mx.getUserId()))
) {
return { roomId: room.roomId, highlight: 0, total: 0 };
}
return {
roomId: room.roomId,
highlight,
total: highlight > total ? highlight : total,
total: resolvedTotal,
};
};
@@ -298,13 +403,24 @@ export const getUnreadInfos = (
if (roomHaveNotification(room) || roomHaveUnread(mx, room)) {
const mutedThreads = content ? getMutedThreads(content, room.roomId) : undefined;
const info = getUnreadInfo(room, mutedThreads);
const info = getUnreadInfo(room, mutedThreads, mx);
// Skip a phantom {0,0} entry: a room whose ONLY unread is a muted thread has
// roomHaveNotification true (the server room total includes the muted
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
// would still light the nav row + pollute "unread only" filters. Keep it
// only if there's real unread (count > 0) or a genuine unread marker.
if (info.total > 0 || info.highlight > 0 || roomHaveUnread(mx, room)) {
// only if there's real unread (count > 0) or a genuine unread marker. The
// unreadIsOnlyVerification/readReceiptCoversTail guards below mirror the
// getUnreadInfo suppression: roomHaveUnread returning `true` here already
// implies both are false (they only report `true` once the receipt covers
// the tail, exactly where roomHaveUnread returns `false`), so the guards are
// defensive insurance against divergence, not load-bearing.
if (
info.total > 0 ||
info.highlight > 0 ||
(roomHaveUnread(mx, room) &&
!unreadIsOnlyVerification(room, mx.getUserId()) &&
!readReceiptCoversTail(room, mx.getUserId()))
) {
unread.push(info);
}
}
+1 -1
View File
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
export default defineConfig({
appType: 'spa',
publicDir: false,
publicDir: './public/res',
base: buildConfig.base,
server: {
port: 8080,