Compare commits

..
12 Commits
Author SHA1 Message Date
jaredandClaude Sonnet 5 34a3352e21 docs(readme): add per-OS desktop download table
CI / Build & Quality Checks (push) Successful in 2m48s
CI / Trigger Desktop Build (push) Successful in 6s
Replaces the single generic releases-page link with direct downloads
for Windows (.exe), Linux (AppImage/.deb/.pkg.tar.zst), plus a note on
the webkit2gtk/GStreamer WebRTC dependency needed for calls to work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:32:18 -04:00
jaredandClaude Sonnet 5 3cc5f0cc6a fix(room-nav): drop pointless dynamic import of setRoomNotificationPreference
RoomNavItem.tsx already statically imported getRoomNotificationModeIcon
and RoomNotificationMode from the same module, so the two
`await import('../../hooks/useRoomsNotificationPreferences')` calls
(in unmuteRoom and handleMuteFor) never achieved real code-splitting —
verified by building and grepping dist/assets: setRoomNotificationPreference
landed in the same eager entry chunk regardless, since Rolldown can't
split a module already reachable via a static import elsewhere. Just
import it statically alongside its siblings instead. No behavior
change — confirmed via 3 independent investigations before starting
and 3 independent reviews of this diff before committing.

Closes LotusGuild/cinny#5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:29:29 -04:00
jaredandClaude Sonnet 5 fd93339ad4 feat(explore): feature the Homelab space instead of its individual rooms
CI / Build & Quality Checks (push) Successful in 2m21s
CI / Trigger Desktop Build (push) Successful in 12s
Swaps #homelabbing/#proxmox for their parent #homelab:codestorm.net
space, so browsing it surfaces the whole space rather than two
hand-picked children.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:14:24 -04:00
jaredandClaude Sonnet 5 b1ecb0c46b feat(explore): feature Lotus Guild Space + favorite rooms, add matrixrooms.info directory
CI / Build & Quality Checks (push) Successful in 3m46s
CI / Trigger Desktop Build (push) Successful in 15s
Populates the previously-empty featuredCommunities block so the Explore
tab's Featured page shows the Lotus Guild Space and a few community
favorites by default, and adds matrixrooms.info as a browsable server
in the Explore sidebar (it speaks enough of the Matrix federation API
to serve as an aggregated public-room search across the network).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:07:41 -04:00
jared 4656f08802 Revert "ci: re-enable npm/node_modules cache — runner cache network fixed"
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Trigger Desktop Build (push) Successful in 12s
This reverts commit a631e90ea2.
2026-08-02 23:23:52 -04:00
jaredandClaude Opus 4.8 a631e90ea2 ci: re-enable npm/node_modules cache — runner cache network fixed
CI / Build & Quality Checks (push) Canceled after 4m44s
CI / Trigger Desktop Build (push) Canceled after 0s
The act_runner cache server is now reachable from job containers: jobs were
landing on isolated per-job docker networks and couldn't reach the runner's
cache server on docker0 (getCacheEntry ETIMEDOUT, ~5 min wasted/build). Fixed
runner-side by putting the runner + all job containers on a shared dedicated
network (`act-cache-net`, runner at 172.30.0.2) and pointing cache.host at it —
verified a container on that network reaches the cache port.

Restores `cache: npm` on Setup Node and the actions/cache node_modules step
(restore + save-on-miss-and-success). Reverts 10270b75 now that the underlying
network issue is resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 23:18:00 -04:00
jaredandClaude Opus 4.8 10270b75ca ci: drop npm/node_modules cache — runner cache server is unreachable
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Trigger Desktop Build (push) Successful in 13s
The act_runner's internal cache server (172.17.0.2:46367) can't be reached
from job containers: `setup-node` with `cache: npm` spends ~4m42s on
`getCacheEntry failed: connect ETIMEDOUT` every build, then reports "npm cache
is not found" — ~5 min of pure cost for zero caching. The `actions/cache`
node_modules steps added in 79258668 would hit the same dead server and hang
too, so they're removed here as well.

Removing the cache usage reclaims ~5 min/build with no loss (nothing was being
cached). The fast-gates-before-build reorder is kept. Re-enable caching once
the runner's cache server is reachable from job containers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:26:26 -04:00
jaredandClaude Opus 4.8 7925866868 ci: cache node_modules + run fast gates first; enable lint-staged hook
CI (.gitea/workflows/ci.yml):
- Cache node_modules keyed on package-lock + .node-version (actions/cache
  restore/save). An unchanged lockfile now skips `npm ci` (extraction +
  postinstall folds patch) and just restores the tree. Save runs only on a
  cache miss and only when install succeeded (`success()`), so a failed
  `npm ci` can't poison the cache. setup-node's existing `cache: npm` still
  warms the download cache on the miss path.
- Run prettier/eslint/typecheck/tests BEFORE the ~minutes-long build so a
  format/lint/type/test error fails in seconds instead of after the build.

DX (.husky/pre-commit):
- Enable the pre-commit hook (`npx lint-staged`). husky + lint-staged were
  already installed with a config (eslint + `prettier --write` on staged
  files), just commented out — so formatting kept reaching CI. It's now
  auto-applied on commit. (typecheck left out of the hook — too slow per commit.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:05:50 -04:00
jaredandClaude Opus 4.8 d5cfb663b9 chore(issues): point the issue chooser at this repo, not upstream Cinny
CI / Build & Quality Checks (push) Successful in 10m54s
CI / Trigger Desktop Build (push) Successful in 5s
The forked-in Cinny issue setup funneled reporters to the upstream project:
- config.yml disabled blank issues and linked to cinnyapp/cinny GitHub
  Discussions
- preapproved.md was Cinny's "do not open an issue, use Discussions"
  template (Gitea has no Discussions anyway)

Replace it with a Lotus setup: enable blank issues, drop the upstream
contact link, and add Bug Report + Feature Request templates. Now
/issues/new/choose offers Lotus templates and files against this repo.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 20:48:59 -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
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
16 changed files with 286 additions and 80 deletions
+30 -26
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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
+15
View File
@@ -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.
-9
View File
@@ -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
View File
@@ -1,3 +1 @@
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
npx lint-staged
+27 -1
View File
@@ -129,7 +129,16 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
### Download
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
Operating System | Download
---|---
Windows | [Get the installer (.exe)](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe)
Linux (AppImage, any distro) | [Get the AppImage](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.AppImage)
Linux (Debian/Ubuntu) | [Get the .deb](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb)
Linux (Arch/CachyOS/EndeavourOS) | [Get the .pkg.tar.zst](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst) — install with `pacman -U LotusChat-x86_64.pkg.tar.zst`
All Linux builds need `webkit2gtk-4.1` and, for calls to work, GStreamer's `good`/`bad`/`ugly`/`libav` plugin sets (the pacman package pulls these in automatically; on the AppImage/.deb, install them via your package manager if joining a call shows "browser does not support WebRTC").
See the full [Releases page on code.lotusguild.org](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases) for signatures and older builds.
### SmartScreen Warning (Windows)
@@ -167,6 +176,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**
+3 -3
View File
@@ -4,9 +4,9 @@
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
"rooms": ["#jellyfin:matrix.org"],
"servers": ["matrixrooms.info"]
},
"hashRouter": {
"enabled": false,
+1 -4
View File
@@ -66,6 +66,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import {
getRoomNotificationModeIcon,
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
@@ -298,8 +299,6 @@ export function saveMuteTimers(timers: MuteTimerEntry[]): void {
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
await setRoomNotificationPreference(
mx,
roomId,
@@ -392,8 +391,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const handleMuteFor = useCallback(
async (durationMs: number | null) => {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
await setRoomNotificationPreference(
mx,
+3 -1
View File
@@ -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;
+3 -1
View File
@@ -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;
+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(), '/public/res/Lotus.png');
/**
* Absolute URL the OIDC provider redirects back to after authorization.
*
+11 -4
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,
@@ -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() {
+3 -1
View File
@@ -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>
+97
View File
@@ -27,6 +27,8 @@ import {
isNotificationEvent,
isVerificationFlowEvent,
unreadIsOnlyVerification,
readReceiptCoversTail,
roomHasUnreadThread,
roomHaveNotification,
getUnreadInfo,
getRoomIconSrc,
@@ -478,6 +480,12 @@ const textMsg = (id: string) =>
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
@@ -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 });
});
// --- 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,
+62 -21
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
@@ -254,6 +259,17 @@ export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
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
@@ -262,16 +278,7 @@ export const isVerificationFlowEvent = (mEvent: MatrixEvent): boolean => {
// auto-reads a genuine unread message.
export const unreadIsOnlyVerification = (room: Room, userId: string | null): boolean => {
if (!userId) return false;
// A real unread THREAD reply also drives the room's notification count, and
// `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;
if (roomHasUnreadThread(room)) return false;
const readUpToId = room.getEventReadUpTo(userId);
const liveEvents = room.getLiveTimeline().getEvents();
let sawVerification = false;
@@ -285,6 +292,34 @@ export const unreadIsOnlyVerification = (room: Room, userId: string | null): boo
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);
@@ -334,16 +369,18 @@ export const getUnreadInfo = (
const resolvedTotal = highlight > total ? highlight : total;
// Suppress a room whose entire unread span is a completed device verification:
// the SDK/server Total stays > 0 forever for the trailing verification-request
// message (default DM push rule, no recency gate), but it isn't real unread. A
// highlight is never a verification request, so only the highlight-free case is
// guarded. Requires `mx` (backward-compatible for callers/tests without it).
// 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())
(unreadIsOnlyVerification(room, mx.getUserId()) || readReceiptCoversTail(room, mx.getUserId()))
) {
return { roomId: room.roomId, highlight: 0, total: 0 };
}
@@ -371,14 +408,18 @@ export const getUnreadInfos = (
// 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 — and
// NOT when the only unread is a completed device verification (roomHaveUnread
// can fall through to `true` for a still-encrypted/off-window verification
// tail, which getUnreadInfo above already suppressed to {0,0}).
// 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()))
(roomHaveUnread(mx, room) &&
!unreadIsOnlyVerification(room, mx.getUserId()) &&
!readReceiptCoversTail(room, mx.getUserId()))
) {
unread.push(info);
}