Compare commits

..
Author SHA1 Message Date
nathan 6d9ee10926 Merge remote-tracking branch 'origin/lotus' into read-me-update
CI / Build & Quality Checks (pull_request) Failing after 4m59s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 20:29:04 -04:00
f2c356f288 refactor(assets): build logo/favicon URLs via withOriginBaseUrl helper
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Canceled after 0s
Swap the logo/favicon URL constants from the inline
`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...` form to the
repo's existing `withOriginBaseUrl(getOriginBaseUrl(), '/public/res/...')`
helper (already used here for the OIDC callback URL). Functionally equivalent —
same /public/res/ target, resolves in dev and the static-copied prod build — and
it keeps the logo URL absolute and consistent with clientUri for the OIDC
logoUri. No build-config change (publicDir stays false).

Co-authored-by: Nathan Vititoe <nathanvititoe@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:22:58 -04:00
nathan f136082181 Merge remote-tracking branch 'origin/image-path-changes' into read-me-update
CI / Build & Quality Checks (pull_request) Successful in 10m48s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 20:04:59 -04:00
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 6a0037ecec read me updates with instructions for running a dev server
CI / Build & Quality Checks (pull_request) Failing after 5m54s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:04:25 -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 f12e05c510 fix(assets): reference logo/favicon PNGs by URL instead of importing from public/
CI / Build & Quality Checks (push) Successful in 12m7s
CI / Trigger Desktop Build (push) Successful in 7s
Importing images from public/ (`import X from '../../public/res/*.png'`) is
fragile under Vite with `publicDir: false` and can white-screen the dev app —
Vite tries to resolve the public/ path as a module. Switch the five logo/
favicon call sites to the repo's existing BASE_URL URL pattern
(`${trimTrailingSlash(import.meta.env.BASE_URL)}/public/res/...`), matching how
config.json, locales, and the Element Call widget are referenced. Resolves
identically in dev and the static-copied prod build (public/res -> dist/public/res).

Sounds under public/sound/ stay ESM-imported: that folder is not copied to
dist/ by vite-plugin-static-copy, so a URL reference would 404.

Also add a Local Development section to the README (no-backend model, npm ci /
npm start on :8080, which homeserver to log in against, OIDC-on-localhost note).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 15:52:04 -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
9 changed files with 235 additions and 31 deletions
+49 -1
View File
@@ -13,7 +13,38 @@ The source code is licensed under [AGPLv3](LICENSE), the same license as the ups
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0. The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
--- ---
## Development Environment Setup
#### Getting correct Node version
- Ensure you have the correct version of node installed, specified in `.node-version`
- Use this command from the terminal to install nvm
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
```
- Reload your terminal shell using (for Ubuntu):
```bash
source ~/.bashrc
```
- Install the specified Node version
```bash
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
nvm install "$NODE_VERSION"
nvm use "$NODE_VERSION"
```
- verify the correct version was installed by running
```bash
node --version
```
and comparing the output to what is listed in `.node-version`
#### Install npm packages
- To install the npm packages listed in `package.json` run:
```bash
npm i
```
### Start Development Server
```bash
npm run start
```
You should now have an active development server at `localhost:8080`, where you can make changes to the code and see the UI update in real time
## Features ## Features
### Messaging ### Messaging
@@ -167,6 +198,23 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog. See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### Local Development
Lotus Chat is a **pure client — there is no backend of its own to run.** It talks directly to a Matrix homeserver (Synapse) over HTTPS, so the only thing you run locally is the Vite dev server; it connects to a real homeserver for all data. If you were looking for "the backend to pair with it," there isn't one — that's the homeserver.
**Prerequisites:** Node 20+ (CI builds on Node 24) and npm.
```bash
npm ci # deps; @lotusguild/* come from our Gitea npm registry (public read — no auth/token needed)
npm start # Vite dev server → http://localhost:8080
```
The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already in use it falls through to 8081+, so check the "Local:" URL Vite prints on startup. If it boots but the page renders blank, it's almost always a failed module/asset resolution, not a "missing backend" — open the devtools console and read the first error.
**Which homeserver / logging in:** `config.json` sets `defaultHomeserver: 0``matrix.lotusguild.org`, so you sign in with your normal `@you:matrix.lotusguild.org` account. That homeserver is **live production** — anything you send is real, so keep test traffic to a DM with yourself or a throwaway room. To develop fully isolated instead, point `config.json` at a throwaway `matrix.org` account (already in `homeserverList`) or a local Synapse.
- **SSO / OIDC works from localhost.** Login goes through Authelia via OIDC dynamic registration; the provider redirects back to `http://localhost:8080/…` and the client registers that redirect on the fly, so no server-side allow-listing is needed. After the callback you may see a `GET …/_matrix/media/v1/thumbnail/… 404` — that's just a missing avatar thumbnail, **not** a login failure.
### 🔱 Element Call fork ("Lotus Call") — LIVE ### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork** Voice/video channels embed **Element Call**, which is now our **self-built fork**
+3 -1
View File
@@ -4,11 +4,13 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import LotusLogo from '../../../../../public/res/Lotus.png'; import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
import pkg from '../../../../../package.json'; import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
type MSC1929Contact = { type MSC1929Contact = {
matrix_id?: string; matrix_id?: string;
email_address?: string; email_address?: string;
+3 -1
View File
@@ -20,7 +20,7 @@ import {
} from '../../hooks/useClientConfig'; } from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import LotusLogo from '../../../../public/res/Lotus.png'; import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
import { ServerPicker } from './ServerPicker'; import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@@ -31,6 +31,8 @@ import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer'; import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
const currentAuthPath = (pathname: string): string => { const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) { if (matchPath(LOGIN_PATH, pathname)) {
return LOGIN_PATH; return LOGIN_PATH;
+2 -1
View File
@@ -1,8 +1,9 @@
import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk'; import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import LotusLogo from '../../../../../public/res/Lotus.png';
import { OIDC_CALLBACK_PATH } from '../../paths'; import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils'; import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
/** /**
* Absolute URL the OIDC provider redirects back to after authorization. * Absolute URL the OIDC provider redirects back to after authorization.
* *
+15 -4
View File
@@ -17,9 +17,6 @@ import { manualDndAtom } from '../../state/manualDnd';
import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze'; import { isSnoozeActive, notificationSnoozeUntilAtom } from '../../state/notificationSnooze';
import { isWithinTimeWindow } from '../../utils/timeWindow'; import { isWithinTimeWindow } from '../../utils/timeWindow';
import { roomToUnreadAtom } 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';
import NotificationSound from '../../../../public/sound/notification.ogg'; import NotificationSound from '../../../../public/sound/notification.ogg';
import InviteSound from '../../../../public/sound/invite.ogg'; import InviteSound from '../../../../public/sound/invite.ogg';
import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom'; import { notificationPermission, setFavicon, showOsNotification } from '../../utils/dom';
@@ -29,7 +26,13 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList'; import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts'; 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 { mDirectAtom } from '../../state/mDirectList';
import { import {
getMemberName, getMemberName,
@@ -67,8 +70,16 @@ import {
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications'; } 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 // 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. // the async invite-atom population lands first and isn't mistaken for new invites.
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-highlight.png');
const INVITE_NOTIFY_ARM_DELAY_MS = 3000; const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
function SystemEmojiFeature() { function SystemEmojiFeature() {
+3 -1
View File
@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds'; import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
import { Page, PageHero, PageHeroSection } from '../../components/page'; import { Page, PageHero, PageHeroSection } from '../../components/page';
import LotusLogo from '../../../../public/res/Lotus.png'; import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
import pkg from '../../../../package.json'; import pkg from '../../../../package.json';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
export function WelcomePage() { export function WelcomePage() {
return ( return (
<Page> <Page>
+97
View File
@@ -27,6 +27,8 @@ import {
isNotificationEvent, isNotificationEvent,
isVerificationFlowEvent, isVerificationFlowEvent,
unreadIsOnlyVerification, unreadIsOnlyVerification,
readReceiptCoversTail,
roomHasUnreadThread,
roomHaveNotification, roomHaveNotification,
getUnreadInfo, getUnreadInfo,
getRoomIconSrc, getRoomIconSrc,
@@ -478,6 +480,12 @@ const textMsg = (id: string) =>
getType: () => 'm.room.message', getType: () => 'm.room.message',
getContent: () => ({ msgtype: 'm.text', body: 'hi' }), 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', () => { test('unreadIsOnlyVerification: verification-only unread tail → true', () => {
// timeline oldest→newest: [read msg] then the verification handshake at the tail // timeline oldest→newest: [read msg] then the verification handshake at the tail
@@ -544,6 +552,95 @@ test('getUnreadInfo does NOT suppress when a real message is unread alongside a
assert.deepEqual(getUnreadInfo(room, undefined, mx), { roomId: '!r:x', highlight: 0, total: 1 }); 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 = ( const mockRoomWithThreadCounts = (
total: number, total: number,
highlight: number, highlight: number,
+62 -21
View File
@@ -214,6 +214,11 @@ const NOTIFICATION_EVENT_TYPES = [
'm.room.encrypted', 'm.room.encrypted',
'm.room.member', 'm.room.member',
'm.sticker', '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 // 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 // msgtype (the rest of the flow — start/accept/key/mac/done/cancel — uses its own
@@ -254,6 +259,17 @@ export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
return false; 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 // 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 // nothing but verification-flow events — i.e. the only "unread" is a completed
// device verification, not a real message. Conservative: returns false when the // device verification, not a real message. Conservative: returns false when the
@@ -262,16 +278,7 @@ export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
// auto-reads a genuine unread message. // auto-reads a genuine unread message.
export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => { export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => {
if (!userId) return false; if (!userId) return false;
// A real unread THREAD reply also drives the room's notification count, and if (roomHasUnreadThread(room)) return false;
// `markAsRead` clears every thread unconditionally — so a room with ANY unread
// thread is never "only verification": suppressing/auto-reading here would hide
// or wrongly ack a genuine thread reply. Reject before scanning the main timeline.
const hasUnreadThread = room
.getThreads()
.some(
(thread) => room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total) > 0,
);
if (hasUnreadThread) return false;
const readUpToId = room.getEventReadUpTo(userId); const readUpToId = room.getEventReadUpTo(userId);
const liveEvents = room.getLiveTimeline().getEvents(); const liveEvents = room.getLiveTimeline().getEvents();
let sawVerification = false; let sawVerification = false;
@@ -285,6 +292,34 @@ export const unreadIsOnlyVerification = (room: Room, userId: string | null): boo
return false; 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 => { export const roomHaveNotification = (room: Room): boolean => {
const total = room.getUnreadNotificationCount(NotificationCountType.Total); const total = room.getUnreadNotificationCount(NotificationCountType.Total);
const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight); const highlight = room.getUnreadNotificationCount(NotificationCountType.Highlight);
@@ -334,16 +369,18 @@ export const getUnreadInfo = (
const resolvedTotal = highlight > total ? highlight : total; const resolvedTotal = highlight > total ? highlight : total;
// Suppress a room whose entire unread span is a completed device verification: // Suppress a spurious Total when the room isn't really unread. Two safe cases,
// the SDK/server Total stays > 0 forever for the trailing verification-request // both requiring `mx` (backward-compatible for callers/tests without it) and a
// message (default DM push rule, no recency gate), but it isn't real unread. A // highlight-free count (a real mention must never be hidden):
// highlight is never a verification request, so only the highlight-free case is // 1. the entire unread span is a completed device verification, or
// guarded. Requires `mx` (backward-compatible for callers/tests without it). // 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 ( if (
mx && mx &&
resolvedTotal > 0 && resolvedTotal > 0 &&
highlight === 0 && highlight === 0 &&
unreadIsOnlyVerification(room, mx.getUserId()) (unreadIsOnlyVerification(room, mx.getUserId()) || readReceiptCoversTail(room, mx.getUserId()))
) { ) {
return { roomId: room.roomId, highlight: 0, total: 0 }; return { roomId: room.roomId, highlight: 0, total: 0 };
} }
@@ -371,14 +408,18 @@ export const getUnreadInfos = (
// roomHaveNotification true (the server room total includes the muted // roomHaveNotification true (the server room total includes the muted
// thread's count), but getUnreadInfo subtracts it back to zero. Pushing it // thread's count), but getUnreadInfo subtracts it back to zero. Pushing it
// would still light the nav row + pollute "unread only" filters. Keep 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 — and // only if there's real unread (count > 0) or a genuine unread marker. The
// NOT when the only unread is a completed device verification (roomHaveUnread // unreadIsOnlyVerification/readReceiptCoversTail guards below mirror the
// can fall through to `true` for a still-encrypted/off-window verification // getUnreadInfo suppression: roomHaveUnread returning `true` here already
// tail, which getUnreadInfo above already suppressed to {0,0}). // 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 ( if (
info.total > 0 || info.total > 0 ||
info.highlight > 0 || info.highlight > 0 ||
(roomHaveUnread(mx, room) && !unreadIsOnlyVerification(room, mx.getUserId())) (roomHaveUnread(mx, room) &&
!unreadIsOnlyVerification(room, mx.getUserId()) &&
!readReceiptCoversTail(room, mx.getUserId()))
) { ) {
unread.push(info); unread.push(info);
} }
+1 -1
View File
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
export default defineConfig({ export default defineConfig({
appType: 'spa', appType: 'spa',
publicDir: false, publicDir: './public/res',
base: buildConfig.base, base: buildConfig.base,
server: { server: {
port: 8080, port: 8080,