Compare commits
12
Commits
b2678d5c6d
...
lotus
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34a3352e21 | ||
|
|
3cc5f0cc6a | ||
|
|
fd93339ad4 | ||
|
|
b1ecb0c46b | ||
|
|
4656f08802 | ||
|
|
a631e90ea2 | ||
|
|
10270b75ca | ||
|
|
7925866868 | ||
|
|
d5cfb663b9 | ||
|
|
f2c356f288 | ||
|
|
f12e05c510 | ||
|
|
d47032a14f |
+30
-26
@@ -30,8 +30,13 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: '.node-version'
|
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
|
- name: Install dependencies
|
||||||
# Harden against transient registry network failures (ECONNRESET etc.):
|
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||||
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||||
@@ -52,37 +57,36 @@ jobs:
|
|||||||
sleep $((attempt * 15))
|
sleep $((attempt * 15))
|
||||||
done
|
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
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
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) ─────
|
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||||
- name: Audit (high/critical)
|
- name: Audit (high/critical)
|
||||||
run: npm audit --audit-level=high --omit=dev
|
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
|
blank_issues_enabled: true
|
||||||
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.
|
|
||||||
|
|||||||
@@ -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 lint-staged
|
||||||
# npx tsc -p tsconfig.json --noEmit
|
|
||||||
# npx lint-staged
|
|
||||||
|
|||||||
@@ -129,7 +129,16 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
|
|||||||
|
|
||||||
### Download
|
### 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)
|
### 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.
|
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
-3
@@ -4,9 +4,9 @@
|
|||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": [],
|
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
|
||||||
"rooms": [],
|
"rooms": ["#jellyfin:matrix.org"],
|
||||||
"servers": []
|
"servers": ["matrixrooms.info"]
|
||||||
},
|
},
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
|
|||||||
import {
|
import {
|
||||||
getRoomNotificationModeIcon,
|
getRoomNotificationModeIcon,
|
||||||
RoomNotificationMode,
|
RoomNotificationMode,
|
||||||
|
setRoomNotificationPreference,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
|
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
|
// 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.
|
// its persisted timer. Shared by the in-session timer and the boot-time restore.
|
||||||
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
|
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
|
||||||
const { setRoomNotificationPreference } =
|
|
||||||
await import('../../hooks/useRoomsNotificationPreferences');
|
|
||||||
await setRoomNotificationPreference(
|
await setRoomNotificationPreference(
|
||||||
mx,
|
mx,
|
||||||
roomId,
|
roomId,
|
||||||
@@ -392,8 +391,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
|||||||
|
|
||||||
const handleMuteFor = useCallback(
|
const handleMuteFor = useCallback(
|
||||||
async (durationMs: number | null) => {
|
async (durationMs: number | null) => {
|
||||||
const { setRoomNotificationPreference } =
|
|
||||||
await import('../../hooks/useRoomsNotificationPreferences');
|
|
||||||
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
|
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
|
||||||
await setRoomNotificationPreference(
|
await setRoomNotificationPreference(
|
||||||
mx,
|
mx,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -69,6 +72,10 @@ import {
|
|||||||
|
|
||||||
// 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() {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user