Compare commits
11
Commits
15d85f52c4
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4656f08802 | ||
|
|
a631e90ea2 | ||
|
|
10270b75ca | ||
|
|
7925866868 | ||
|
|
d5cfb663b9 | ||
|
|
f2c356f288 | ||
|
|
f12e05c510 | ||
|
|
d47032a14f | ||
|
|
b2678d5c6d | ||
|
|
1176bea0ee | ||
|
|
477df4ae32 |
+30
-26
@@ -30,8 +30,13 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.node-version'
|
||||
cache: npm
|
||||
|
||||
# No npm / node_modules cache: the act_runner's internal cache server is
|
||||
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
|
||||
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
|
||||
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
|
||||
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
|
||||
# node_modules step) once the runner's cache server is reachable from jobs.
|
||||
- name: Install dependencies
|
||||
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||
@@ -52,37 +57,36 @@ jobs:
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
|
||||
# error fails in seconds instead of after the ~minutes-long build. All are
|
||||
# hard gates — any failure fails the job and blocks the deploy. The tree is
|
||||
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
|
||||
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
|
||||
# deploy without CI; a later normal push surfaces any introduced issue here
|
||||
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
||||
# eslint gates on errors only (existing no-explicit-any warnings stay
|
||||
# informational — check:eslint has no --max-warnings).
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
|
||||
- name: ESLint
|
||||
run: npm run check:eslint
|
||||
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
|
||||
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
|
||||
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||
# A failure blocks the deploy.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
|
||||
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
|
||||
# formatted), so these gate real regressions instead of relying on local
|
||||
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
|
||||
# introduce upstream type/lint/format issues; that path deploys without
|
||||
# CI, but a subsequent normal push would surface the failure here — fix
|
||||
# forward (or briefly re-soften a gate) rather than let it deploy broken.
|
||||
# eslint gates on errors only (existing `no-explicit-any` warnings stay
|
||||
# informational — `check:eslint` has no --max-warnings).
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
|
||||
- name: ESLint
|
||||
run: npm run check:eslint
|
||||
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
|
||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||
- name: Audit (high/critical)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report something that isn't working in Lotus Chat
|
||||
title: ''
|
||||
labels: bug
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what went wrong.
|
||||
|
||||
**Steps to reproduce**
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. See error
|
||||
|
||||
**Expected behavior**
|
||||
What you expected to happen instead.
|
||||
|
||||
**Client info**
|
||||
|
||||
- Lotus Chat version (Settings → Help & About):
|
||||
- Platform: Web / Desktop (Windows / macOS / Linux)
|
||||
- Browser + version (if web):
|
||||
|
||||
**Screenshots / logs**
|
||||
If applicable, add screenshots or the browser devtools console output.
|
||||
@@ -1,5 +1 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Features, Bug Reports, Questions
|
||||
url: https://github.com/cinnyapp/cinny/discussions/new/choose
|
||||
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
|
||||
blank_issues_enabled: true
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Suggest an idea or improvement for Lotus Chat
|
||||
title: ''
|
||||
labels: enhancement
|
||||
---
|
||||
|
||||
**What would you like?**
|
||||
A clear and concise description of the feature or change.
|
||||
|
||||
**Why / use case**
|
||||
What problem does it solve, or what does it make better?
|
||||
|
||||
**Alternatives considered**
|
||||
Any workarounds or other approaches you've thought about.
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
name: Pre-Discussed and Approved Topics
|
||||
about: |-
|
||||
Only for topics already discussed and approved in the GitHub Discussions section.
|
||||
---
|
||||
|
||||
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
|
||||
|
||||
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
npx lint-staged
|
||||
|
||||
@@ -143,6 +143,10 @@ The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the
|
||||
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
|
||||
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
|
||||
|
||||
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
|
||||
|
||||
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
|
||||
|
||||
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
|
||||
|
||||
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
|
||||
|
||||
@@ -167,6 +167,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.
|
||||
|
||||
### 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
|
||||
|
||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||
|
||||
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
|
||||
});
|
||||
|
||||
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||
Transforms.select(editor, selectRange);
|
||||
Transforms.insertNodes(editor, element);
|
||||
Transforms.collapse(editor, {
|
||||
edge: 'end',
|
||||
});
|
||||
// Wrap the whole sequence: on a stale autocomplete range (the document changed
|
||||
// between the menu opening and the pick) `insertNodes` — not `select`, which is
|
||||
// lazy in this Slate version — can throw. This runs inside the pick's event
|
||||
// handler, so an escape wouldn't hit the error boundary, but keep it contained.
|
||||
try {
|
||||
Transforms.select(editor, selectRange);
|
||||
Transforms.insertNodes(editor, element);
|
||||
Transforms.collapse(editor, { edge: 'end' });
|
||||
} catch {
|
||||
/* stale range — the pick is a no-op rather than an uncaught error */
|
||||
}
|
||||
};
|
||||
|
||||
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
||||
// Defer to the next tick so React can flush any pending void-element DOM
|
||||
// updates (e.g. after inserting a mention) before Slate resolves cursor
|
||||
// positions via ReactEditor.toDOMNode — otherwise Slate throws
|
||||
// "Cannot resolve a DOM node from slate node".
|
||||
// Move the caret out of the just-inserted inline void and land it in a real
|
||||
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
|
||||
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
|
||||
// INSIDE the void's inner text node; insertText there is a no-op, blocked by
|
||||
// Slate's void guard). The space then lands in a real text node.
|
||||
// Doing this in the same commit (vs the old deferred setTimeout) means the
|
||||
// caret never sits on the void's zero-width edge on a racy tick — that edge's
|
||||
// DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase
|
||||
// selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped
|
||||
// the composer error boundary. Both ops are pure model transforms (no DOM
|
||||
// resolution), so running them synchronously is safe.
|
||||
Transforms.move(editor);
|
||||
if (withSpace) editor.insertText(' ');
|
||||
// Re-assert focus next tick (a pick usually keeps the editor focused). Guarded
|
||||
// because ReactEditor.focus resolves the DOM; with the caret now in a real text
|
||||
// node this is safe, but stay defensive against a mid-flight editor.
|
||||
setTimeout(() => {
|
||||
ReactEditor.focus(editor);
|
||||
Transforms.move(editor);
|
||||
if (withSpace) editor.insertText(' ');
|
||||
try {
|
||||
ReactEditor.focus(editor);
|
||||
} catch {
|
||||
// The editor DOM can be mid-flight (autocomplete just closed / re-render
|
||||
// landed). The element is already inserted, so skip the focus nudge.
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { Box, Button, Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Transforms } from 'slate';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
<>
|
||||
{canMessage && (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
onReset={() => {
|
||||
// The composer crash is a transient bad-selection render
|
||||
// (e.g. after an autocomplete insert); the draft content is
|
||||
// intact. Clear the selection so the remounted composer can
|
||||
// render — the user clicks in to continue, no page refresh.
|
||||
try {
|
||||
Transforms.deselect(editor);
|
||||
} catch {
|
||||
/* editor already in a safe state */
|
||||
}
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<RoomInputPlaceholder
|
||||
role="alert"
|
||||
style={{ padding: config.space.S200 }}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="200"
|
||||
>
|
||||
<Text align="Center">
|
||||
Message composer encountered an error. Try refreshing.
|
||||
</Text>
|
||||
<Text align="Center">The message composer hit a snag.</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
<Text size="B300">Reload composer</Text>
|
||||
</Button>
|
||||
</RoomInputPlaceholder>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<RoomInput
|
||||
room={room}
|
||||
|
||||
@@ -4,11 +4,13 @@ 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 { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
||||
import pkg from '../../../../../package.json';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||
|
||||
type MSC1929Contact = {
|
||||
matrix_id?: string;
|
||||
email_address?: string;
|
||||
|
||||
@@ -20,7 +20,7 @@ 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 { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
||||
import { ServerPicker } from './ServerPicker';
|
||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||
@@ -31,6 +31,8 @@ import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
||||
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||
|
||||
const currentAuthPath = (pathname: string): string => {
|
||||
if (matchPath(LOGIN_PATH, pathname)) {
|
||||
return LOGIN_PATH;
|
||||
|
||||
@@ -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(), '/public/res/Lotus.png');
|
||||
|
||||
/**
|
||||
* Absolute URL the OIDC provider redirects back to after authorization.
|
||||
*
|
||||
|
||||
@@ -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,
|
||||
@@ -69,6 +72,10 @@ import {
|
||||
|
||||
// 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 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;
|
||||
|
||||
function SystemEmojiFeature() {
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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 { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
||||
import pkg from '../../../../package.json';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
||||
|
||||
export function WelcomePage() {
|
||||
return (
|
||||
<Page>
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user