Compare commits

..
20 Commits
Author SHA1 Message Date
nathan 3f37f69a13 i hate npm audit
CI / Build & Quality Checks (pull_request) Successful in 11m36s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 22:21:32 -04:00
nathan be004411c3 added another package back in
CI / Build & Quality Checks (pull_request) Successful in 10m51s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 21:58:24 -04:00
nathan f207da2344 fix ci prettier mistake
CI / Build & Quality Checks (pull_request) Successful in 10m57s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 21:39:17 -04:00
nathan b90c3b7313 Merge remote-tracking branch 'origin/lotus' into sw-fix
CI / Build & Quality Checks (pull_request) Failing after 6m7s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 21:10:35 -04:00
nathan c8475f07aa added restriction to lint only ts,js files
CI / Build & Quality Checks (pull_request) Failing after 5m57s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 21:03:43 -04:00
nathan 9b10d7d1c2 added prettier auto-fix to pipeline
CI / Build & Quality Checks (pull_request) Canceled after 1m19s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 21:02:01 -04:00
nathan a1bdaf65f6 removed unused eslint-disables, ran prettier fix
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 21:00:25 -04:00
nathan 0656e6ecb0 removed eslint unused ignore lines, config handles ignoring it now
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 20:55:42 -04:00
nathan 89ba05f462 removed warning for 'any' as a type from eslint
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 20:49:34 -04:00
nathan 2d5e90f715 linter doesnt like void
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 20:48:08 -04:00
nathan f1e4ef657c removed commitizen, added eslint package back
CI / Build & Quality Checks (pull_request) Canceled after 0s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 20:46:11 -04:00
nathan c1ec68e73e Merge remote-tracking branch 'origin/lotus' into sw-fix
CI / Build & Quality Checks (pull_request) Canceled after 2m37s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 20:30:14 -04:00
nathan f6d66af280 Merge remote-tracking branch 'origin/image-path-changes' into sw-fix
CI / Build & Quality Checks (pull_request) Failing after 6m1s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 20:06:44 -04:00
nathan 0aef410f60 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Successful in 10m45s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:01:05 -04:00
nathan 50bb509484 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:00:41 -04:00
nathan b2376513fd fixed retarded linter problem
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:42:37 -04:00
nathan 149ea96957 removed test change
CI / Build & Quality Checks (pull_request) Failing after 6m1s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:37:30 -04:00
nathan 093f10db5c fix problem registering service worker when in a development environment
CI / Build & Quality Checks (pull_request) Canceled after 1m37s
CI / Trigger Desktop Build (pull_request) Canceled after 0s
2026-08-02 18:34:54 -04:00
nathan 6a0037ecec read me updates with instructions for running a dev server
CI / Build & Quality Checks (pull_request) Failing after 5m54s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:04:25 -04:00
nathan.vititoe 2bbd390a3b image path changes, for dev setup, needs testing on 'prod'
CI / Build & Quality Checks (pull_request) Failing after 6m0s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 16:35:11 -04:00
53 changed files with 732 additions and 1019 deletions
+28 -30
View File
@@ -30,13 +30,8 @@ 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
@@ -57,36 +52,39 @@ jobs:
sleep $((attempt * 15))
done
# ── 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/. ──
# ── Critical gate — if this fails, nothing deploys ──────────────────
- 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 Check and Fix
run: |
npx prettier --write .
npm run check:prettier
# ── Security (informational — findings shouldn't block a deploy) ─────
- name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev
+3 -1
View File
@@ -1 +1,3 @@
npx lint-staged
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
+13 -13
View File
@@ -175,19 +175,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes
| Theme | Window | Effect |
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | Synthwave CRT: neon perspective grid framing the timeline (faded through the chat column), broken horizon line, rolling scanlines, pixel sparkles, bottom-right "1UP / INSERT COIN" HUD |
| 🚀 Deep Space Week | Oct 4–10 | Violet void with drifting magenta/cyan nebula clouds, two-depth parallax starfield (~60 twinkling stars + 6 hero gleams), slow galaxy spiral, occasional comet streaks |
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
| Theme | Window | Effect |
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 Deep Space Week | Oct 4–10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation
+2 -4
View File
@@ -372,11 +372,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** since the 2026-09 sync — was v0.20.1); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
**Publish a new version (CI on tag push; needs the `GITEA_NPM_TOKEN` secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.1`, tag `v0.25.0-lotus.1` created locally, unpublished until pushed), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
+35 -13
View File
@@ -13,7 +13,38 @@ The source code is licensed under [AGPLv3](LICENSE), the same license as the ups
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
---
## Development Environment Setup
#### Getting correct Node version
- Ensure you have the correct version of node installed, specified in `.node-version`
- Use this command from the terminal to install nvm
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
```
- Reload your terminal shell using (for Ubuntu):
```bash
source ~/.bashrc
```
- Install the specified Node version
```bash
NODE_VERSION="$(tr -d '[:space:]' < .node-version)"
nvm install "$NODE_VERSION"
nvm use "$NODE_VERSION"
```
- verify the correct version was installed by running
```bash
node --version
```
and comparing the output to what is listed in `.node-version`
#### Install npm packages
- To install the npm packages listed in `package.json` run:
```bash
npm i
```
### Start Development Server
```bash
npm run start
```
You should now have an active development server at `localhost:8080`, where you can make changes to the code and see the UI update in real time
## Features
### Messaging
@@ -22,7 +53,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms)
- Schedule messages to send at a specific time
- Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand
@@ -129,16 +160,7 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
### Download
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.
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
### SmartScreen Warning (Windows)
@@ -196,7 +218,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`; `0.25.0-lotus.1` is built and tagged on the fork, pending publish — source at
(`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks.
+3 -3
View File
@@ -4,9 +4,9 @@
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
"rooms": ["#jellyfin:matrix.org"],
"servers": ["matrixrooms.info"]
"spaces": [],
"rooms": [],
"servers": []
},
"hashRouter": {
"enabled": false,
+1 -1
View File
@@ -109,7 +109,7 @@ export default [
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
],
'@typescript-eslint/no-shadow': 'error',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-explicit-any': 'off',
// jsx-a11y — media captions not required for this app
'jsx-a11y/media-has-caption': 'off',
+453 -365
View File
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -12,13 +12,12 @@
"build": "vite build",
"preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/*",
"check:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
"check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test $(find src -name '*.test.ts')",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs",
"sync:decorations": "node scripts/syncDecorations.mjs"
},
@@ -26,11 +25,6 @@
"*.{ts,tsx,js,jsx}": "eslint",
"*": "prettier --ignore-unknown --write"
},
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
},
"keywords": [],
"author": "Ajay Bura",
"license": "AGPL-3.0-only",
@@ -43,7 +37,7 @@
"@fontsource-variable/inter": "5.2.8",
"@giphy/js-fetch-api": "5.8.0",
"@giphy/js-types": "5.1.0",
"@giphy/js-util": "5.2.0",
"@giphy/js-util": "2.0.0",
"@giphy/react-components": "10.1.2",
"@sapphi-red/web-noise-suppressor": "0.3.5",
"@tanstack/react-query": "5.100.13",
@@ -95,7 +89,7 @@
"react-i18next": "17.0.8",
"react-range": "1.10.0",
"react-router-dom": "7.15.1",
"sanitize-html": "2.17.4",
"sanitize-html": "2.17.6",
"slate": "0.124.1",
"slate-dom": "0.124.1",
"slate-history": "0.113.1",
@@ -130,6 +124,7 @@
"cz-conventional-changelog": "3.3.0",
"eslint": "9.39.4",
"eslint-config-airbnb": "19.0.4",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2",
@@ -149,5 +144,8 @@
"dompurify": ">=3.3.4"
},
"js-cookie": ">=3.0.6"
},
"allowScripts": {
"esbuild@0.28.1": true
}
}
+2 -9
View File
@@ -58,7 +58,6 @@ import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
@@ -411,8 +410,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const { microphone, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
// against the component unmounting during that await.
@@ -569,15 +566,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
// forcing camera+mic+sound on — every other join path does this, and
// Answer was skipping it, publishing the camera with no prescreen.
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
startCall(room, { microphone, video: cameraOnJoin && video, sound });
startCall(room, { microphone: true, video, sound: true });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
[startCall, navigateRoom],
);
if (!callInfo) return null;
-1
View File
@@ -34,7 +34,6 @@ export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
return (
<Wrapper
// KaTeX output is generated by our own render call (trusted-safe).
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
);
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
@@ -84,15 +84,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return (
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
// Size container so overlays can scale/hide fixed-size details (e.g.
// Arcade's HUD text) with `cqw` instead of rendering clipped in a swatch.
containerType: 'inline-size',
}}
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
>
{buildOverlayContent(theme, true)}
</div>
@@ -102,12 +102,12 @@ export const animSparkleTwinkle = keyframes({
* Opacity + a hair of scale for a CRT bloom feel.
*/
export const animCoinBlink = keyframes({
'0%': { opacity: '0.85', transform: 'scale(1)' },
'6%': { opacity: '1', transform: 'scale(1.015)' },
'12%': { opacity: '0.85', transform: 'scale(1)' },
'49%': { opacity: '0.85', transform: 'scale(1)' },
'50%': { opacity: '0', transform: 'scale(1)' },
'100%': { opacity: '0', transform: 'scale(1)' },
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
});
/**
+69 -103
View File
@@ -46,10 +46,6 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
// Strokes use vector-effect=non-scaling-stroke so a line is ~1px whether the
// tile is stretched across a 2000px plane (preserveAspectRatio=none would
// otherwise fatten the verticals ~4x) or squeezed into the 76px settings
// swatch (where scaled strokes disappeared entirely).
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
// lines fanning toward a top-center vanishing point. The plane is then placed
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
@@ -62,7 +58,7 @@ function gridDataUri(): string {
rows.forEach((y) => {
lines.push(
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
`stroke-width='1.4' stroke-opacity='0.9'/>`,
);
});
// Vertical lines fanning out from the top-center vanishing point.
@@ -71,7 +67,7 @@ function gridDataUri(): string {
const botX = 300 + i * 95; // wide at the foreground
lines.push(
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
`stroke-width='1.4' stroke-opacity='0.8'/>`,
);
}
const svg =
@@ -109,13 +105,6 @@ const RESTING_SPARKLES: ReadonlyArray<{
const GRID_URI = gridDataUri();
// HUD text size: 11px on any real viewport, 0px (invisible) inside anything
// narrower than ~330px. `cqw` resolves against the nearest size container —
// the settings swatch (`SeasonalPreview` sets container-type) — and falls back
// to the viewport width when there is no container, i.e. the full-screen
// overlay. clamp(0, 100cqw - 320px, 11px) → 76px swatch: 0px; 1440px app: 11px.
const HUD_FONT_SIZE = 'clamp(0px, calc(100cqw - 320px), 11px)';
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo<Sparkle[]>(() => {
@@ -145,9 +134,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.12) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.16) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)',
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
].join(','),
contain: 'layout paint style',
}}
@@ -158,59 +147,41 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
a vanishing point at the top (the horizon). It lives in the lower
half of the screen — the "floor". The inner plane scrolls upward by
one tile via transform translateY, which reads as the grid flowing
toward the viewer. Pure transform; never background-position.
Two masks are nested (multiple mask-images on one element union by
default, and `mask-composite: intersect` isn't universal yet): the
outer wrapper fades the lattice through the central column where the
message timeline lives, so it frames the chat instead of striping
the text; the inner box fades it in from the horizon. */}
toward the viewer. Pure transform; never background-position. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
maskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
WebkitMaskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
opacity: reduced ? 0.4 : 0.46,
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
opacity: reduced ? 0.5 : 0.62,
contain: 'layout paint style',
}}
>
<div
style={{
position: 'absolute',
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
contain: 'layout paint style',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
/>
</div>
/>
</div>
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
@@ -226,7 +197,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
height: '34%',
transform: 'translate(-50%, -50%)',
backgroundImage:
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.16) 0%, oklch(0.65 0.18 330 / 0.08) 40%, transparent 72%)',
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
contain: 'layout paint style',
}}
/>
@@ -238,10 +209,8 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
right: '12%',
top: '38%',
height: '1.5px',
// Bright at the flanks, dropped out through the centre column so the
// rule frames the timeline rather than underlining a message.
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 14%, oklch(0.92 0.10 320 / 0.95) 22%, transparent 34%, transparent 66%, oklch(0.92 0.10 320 / 0.95) 78%, ${NEON_CYAN} 86%, transparent 100%)`,
opacity: 0.4,
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
opacity: 0.55,
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
}}
/>
@@ -304,7 +273,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0,
overflow: 'hidden',
mixBlendMode: 'multiply',
opacity: 0.32,
opacity: 0.5,
contain: 'layout paint style',
}}
>
@@ -316,7 +285,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
top: '-8px',
bottom: '-8px',
backgroundImage:
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.45) 0px, oklch(0.10 0.04 300 / 0.45) 1px, transparent 1px, transparent 4px)',
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
}}
@@ -340,54 +309,51 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
}}
/>
{/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN"
blip, stacked bottom-right. That corner is the one spot that is
clear in every layout (below the members list, or the empty right
end of the read-receipt strip) — top-left collided with the space
bar and bottom-centre sat on the composer. Static scene shows both
steady (no blink). The font-size clamp collapses the text to nothing
when the host is narrower than ~330px, so the 76px settings swatch
never shows clipped glyphs. */}
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
Static scene shows it steady (no blink). */}
<div
aria-hidden="true"
style={{
position: 'absolute',
right: '14px',
bottom: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '3px',
bottom: '5%',
left: '50%',
transform: 'translateX(-50%)',
fontFamily: '"Courier New", monospace',
fontSize: HUD_FONT_SIZE,
fontSize: '12px',
fontWeight: 700,
lineHeight: 1,
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
<div
style={{
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div>
<div
style={{
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
INSERT COIN
</div>
INSERT COIN
</div>
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
very low opacity so it reads as ambient chrome, not UI. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '2.5%',
left: '2%',
fontFamily: '"Courier New", monospace',
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div>
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
@@ -97,8 +97,8 @@ function makeStars(count: number, seedBase: number): Star[] {
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(22, 2000), []);
const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
const heroStars = useMemo<HeroStar[]>(
() =>
@@ -144,7 +144,7 @@ export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: '-6%',
contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
backgroundImage: [
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
@@ -164,7 +164,7 @@ export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: reduced ? undefined : 'transform, opacity',
willChange: 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
+1 -2
View File
@@ -74,8 +74,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
aria-label={enabled ? 'Undeafen' : 'Deafen'}
outlined
>
<Icon
@@ -58,7 +58,6 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
const submitAccountData: AccountDataSubmitCallback = useCallback(
async (type, content) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.setRoomAccountData(room.roomId, type as any, content);
},
[mx, room.roomId],
@@ -55,7 +55,6 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
const [submitState, submit] = useAsyncCallback(
useCallback(
async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
},
[mx, room.roomId],
@@ -31,7 +31,6 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
},
[mx, room.roomId],
@@ -46,7 +46,6 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
const content = event.getContent();
if (POLL_START_TYPES.includes(evType)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
if (!poll) return null;
const qBody =
@@ -57,7 +56,6 @@ const extractText = (event: MatrixEvent): ExtractedText | null => {
.map(
(a) =>
((a['m.text'] as Array<{ body: string }> | undefined)?.[0]?.body ??
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(a['org.matrix.msc3381.poll.answer'] as any)?.body ??
'') as string,
)
@@ -104,7 +102,6 @@ const rowToResultItem = (row: SearchCacheRow): ResultItem => {
};
return {
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
};
@@ -227,7 +224,6 @@ export const useLocalMessageSearch = () => {
};
memoryItems.push({
rank: 0,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
event: syntheticEvent as any,
context: { events_before: [], events_after: [], profile_info: {} },
});
@@ -146,7 +146,6 @@ export const useMessageSearch = (params: MessageSearchParams) => {
...(fromTs !== undefined && { from_ts: fromTs }),
...(toTs !== undefined && { to_ts: toTs }),
...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
include_state: false,
order_by: order as SearchOrderBy.Recent,
+46 -3
View File
@@ -6,7 +6,7 @@ import React, {
useRef,
useState,
} from 'react';
import { Room } from 'matrix-js-sdk';
import { MatrixClient, Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
@@ -66,10 +66,8 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import {
getRoomNotificationModeIcon,
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
@@ -275,6 +273,49 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
);
}
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// 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,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
type RoomNavItemMenuProps = {
room: Room;
requestClose: () => void;
@@ -351,6 +392,8 @@ 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,
@@ -1,20 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
});
test('does not reset when the user switched to All messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false);
});
test('does not reset when the user switched to Special messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false);
});
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
-88
View File
@@ -1,88 +0,0 @@
import { IPushRule, IPushRules, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData } from '../../utils/room';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import {
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
// Unset if the room's notification mode is still Mute at expiry time. If the user
// manually changed it (e.g. to All messages) while the timer was pending, leave
// their choice alone — just let the stale timer entry get dropped.
export function shouldResetMuteOnUnmute(currentMode: RoomNotificationMode): boolean {
return currentMode === RoomNotificationMode.Mute;
}
// Reads the room's live notification mode straight from account data push rules,
// mirroring useRoomsNotificationPreferences' per-room derivation, without needing
// the React hook (this runs from plain timers/effects, not components).
export function getLiveRoomNotificationMode(
mx: MatrixClient,
roomId: string,
): RoomNotificationMode {
const pushRules = getAccountData(mx, AccountDataEvent.PushRules)?.getContent<IPushRules>();
const global = pushRules?.global;
const overrideRule = global?.override?.find((rule: IPushRule) => rule.rule_id === roomId);
if (overrideRule && getNotificationMode(overrideRule.actions) === NotificationMode.OFF) {
return RoomNotificationMode.Mute;
}
const roomRule = global?.room?.find((rule: IPushRule) => rule.rule_id === roomId);
if (roomRule) {
return getNotificationMode(roomRule.actions) === NotificationMode.OFF
? RoomNotificationMode.SpecialMessages
: RoomNotificationMode.AllMessages;
}
return RoomNotificationMode.Unset;
}
// 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.
// Only resets the mode if it is still Mute — otherwise a manual change made
// during the mute window (e.g. switching to "All messages") would silently get
// reverted when the stale timer fires.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const currentMode = getLiveRoomNotificationMode(mx, roomId);
if (shouldResetMuteOnUnmute(currentMode)) {
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
@@ -341,7 +341,6 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
variant="Primary"
/>
<Box direction="Column" gap="0">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label
htmlFor="allow-ip-literals"
style={{ cursor: canEdit ? 'pointer' : 'default' }}
+24 -58
View File
@@ -105,7 +105,6 @@ import {
settingsAtom,
} from '../../state/settings';
import {
buildCompressedUploadItem,
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
@@ -245,11 +244,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
// Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms:
// MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the
// SDK's encryption pipeline, so scheduling in an E2EE room would leak the body.
const showSchedule =
(composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted;
// Schedule-send is hidden in thread mode (v1 reduction).
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
@@ -489,29 +485,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const compressedFile = new File([compressionResult.blob], compressedName, {
type: compressedType,
});
// Compression re-encodes the image, so in an encrypted room the new
// bytes must be encrypted before upload (and the event must carry the
// *new* encInfo) — reusing the original's encInfo would publish the
// image in the clear and yield an undecryptable attachment.
const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined;
const uploadRes = encrypted
? await mx.uploadContent(encrypted.file)
: await mx.uploadContent(compressedFile, {
name: compressedName,
type: compressedType,
});
const uploadRes = await mx.uploadContent(compressedFile, {
name: compressedName,
type: compressedType,
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) {
// Delete the pre-uploaded original so only one copy lives on the server.
tryDeleteMxcContent(mx, upload.mxc);
mxc = compressedMxc;
// Synthetic fileItem referring to the compressed file so
// getImageMsgContent picks up the correct dimensions, type and encInfo.
const compressedItem = buildCompressedUploadItem(
fileItem,
compressedFile,
encrypted,
);
// Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = {
...fileItem,
file: compressedFile,
originalFile: compressedFile,
};
return getImageMsgContent(mx, compressedItem, mxc);
}
}
@@ -708,14 +697,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, [editor, isMarkdown, mx, roomId, replyDraft]);
const handleScheduleClick = useCallback(() => {
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
// open the modal for an encrypted room even if the button somehow renders.
if (isEncrypted) return;
// Pre-fill from editor if there's content; open blank if editor is empty.
const content = buildCurrentTextContent();
setScheduleContent(content);
setScheduleOpen(true);
}, [buildCurrentTextContent, isEncrypted]);
}, [buildCurrentTextContent]);
const handleScheduled = useCallback(
(delayId: string, sendAt: number, content: IContent) => {
@@ -837,38 +823,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return;
}
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
const baseContent = {
const uploadRes = await mx.uploadContent(
new File([blob], 'image.gif', { type: 'image/gif' }),
{ type: 'image/gif', name: 'image.gif', includeFilename: false },
);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: MsgType.Image,
body: 'image.gif',
url: mxcUrl,
info: { mimetype: 'image/gif', w, h, size: blob.size },
};
// Mirror the attachment/voice paths: in an encrypted room the media
// itself must be encrypted, otherwise the homeserver (and anyone with
// the mxc URI) can see the GIF even though the event body is encrypted.
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(gifFile);
const uploadRes = await mx.uploadContent(encBlob);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: mxcUrl },
} as any);
} else {
const uploadRes = await mx.uploadContent(gifFile, {
type: 'image/gif',
name: 'image.gif',
includeFilename: false,
});
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
url: mxcUrl,
} as any);
}
});
} catch (e) {
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
if (!alive()) return;
@@ -878,7 +844,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false);
}
},
[mx, room, roomId, threadRootId, alive],
[mx, roomId, threadRootId, alive],
);
const handleStickerSelect = useCallback(
@@ -318,7 +318,6 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const results = await Promise.allSettled(
ids.map((id) => {
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
// Send the optional comment first so it reads as a note above the
// forwarded content. The room counts as failed if either send rejects.
@@ -327,7 +326,6 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
const needsComment = commentBody && !commentSentRef.current.has(id);
const step = needsComment
? mx
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
.then(() => {
commentSentRef.current.add(id);
@@ -1390,7 +1390,6 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.Send} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).resendEvent(mEvent, room);
closeMenu();
}}
@@ -1409,7 +1408,6 @@ export const Message = React.memo(
after={<Icon size="100" src={Icons.Cross} />}
radii="300"
onClick={() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mx as any).cancelPendingEvent(mEvent);
closeMenu();
}}
@@ -187,7 +187,6 @@ export const MessageEditor = as<'div', MessageEditorProps>(
rel_type: RelationType.Replace,
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
}
@@ -236,7 +235,6 @@ export const MessageEditor = as<'div', MessageEditorProps>(
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendMessage(roomId, content as any);
}, [
mx,
@@ -1,73 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { buildCompressedUploadItem } from './msgContent';
import { TUploadItem } from '../../state/room/roomInputDrafts';
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
// (if any) the resulting m.image event carries. Getting this wrong either leaks
// a plaintext image into an E2EE room or produces an undecryptable attachment.
const enc = (tag: string): EncryptedAttachmentInfo =>
({
v: 'v2',
key: { alg: 'A256CTR', k: tag },
iv: `iv-${tag}`,
hashes: { sha256: `sha-${tag}` },
}) as unknown as EncryptedAttachmentInfo;
const fakeFile = (name: string, size: number): File =>
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
({
file: fakeFile('photo.png', 900),
originalFile: fakeFile('photo.png', 900),
encInfo,
metadata: { markedAsSpoiler: false, compressImage: true },
}) as unknown as TUploadItem;
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
const compressed = fakeFile('photo.jpg', 300);
const item = buildCompressedUploadItem(makeItem(), compressed);
assert.equal(item.file, compressed);
assert.equal(item.originalFile, compressed);
assert.equal(item.encInfo, undefined);
});
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
const compressed = fakeFile('photo.jpg', 300);
const encryptedBlob = fakeFile('photo.jpg', 320);
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
file: encryptedBlob,
encInfo: enc('compressed'),
});
// The ciphertext is what gets uploaded; the plaintext stays available for
// dimensions/blurhash only.
assert.equal(item.file, encryptedBlob);
assert.equal(item.originalFile, compressed);
assert.deepEqual(item.encInfo, enc('compressed'));
assert.notDeepEqual(item.encInfo, enc('original'));
});
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
// stale encInfo (that is the bug this helper exists to prevent).
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
assert.equal(item.encInfo, undefined);
});
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
const base = makeItem();
base.metadata.caption = 'a caption';
base.metadata.markedAsSpoiler = true;
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
assert.equal(item.metadata.caption, 'a caption');
assert.equal(item.metadata.markedAsSpoiler, true);
});
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
// msgContent.test.ts builders, which share the same shape.
-23
View File
@@ -1,6 +1,5 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME,
@@ -44,28 +43,6 @@ const generateThumbnailContent = async (
return thumbnailContent;
};
/**
* Build the synthetic upload item for a *re-encoded* (compressed) image.
*
* The compressed bytes are a brand new payload, so the item must never inherit
* the original's `encInfo` — that key/iv/sha256 describes the pre-compression
* ciphertext and would make receivers fail to decrypt. In an encrypted room the
* caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in
* an unencrypted room both are omitted and the item carries no `encInfo` at all.
*/
export const buildCompressedUploadItem = (
item: TUploadItem,
compressedFile: File,
encrypted?: { file: File; encInfo: EncryptedAttachmentInfo },
): TUploadItem => ({
...item,
// `file` is what gets uploaded/described, `originalFile` is the plaintext used
// for dimensions + blurhash.
file: encrypted?.file ?? compressedFile,
originalFile: compressedFile,
encInfo: encrypted?.encInfo,
});
export const getImageMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
@@ -564,7 +564,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
mx.sendEvent(
room.roomId,
thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode),
);
@@ -60,7 +60,6 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
clientApi.stop();
iframe.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, room.roomId, widget.id, widget.templateUrl]);
if (blocked) {
@@ -84,7 +84,6 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
data: {},
};
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id);
setAdding(false);
} catch (e) {
@@ -96,7 +95,6 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const handleRemove = (id: string) => {
if (viewingId === id) setViewingId(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
};
@@ -18,7 +18,6 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import {
getNotificationModeActions,
getNotificationModeOptionsFromActions,
NotificationMode,
useNotificationModeActions,
} from '../../../hooks/useNotificationMode';
@@ -132,13 +131,7 @@ type RuleModeSwitcherProps = {
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
const mx = useMatrixClient();
// Preserve any `highlight`/custom sound tweak already on the rule — otherwise
// switching mode here rebuilds actions from scratch and silently drops them.
const options = useMemo(
() => getNotificationModeOptionsFromActions(pushRule.actions),
[pushRule.actions],
);
const getModeActions = useNotificationModeActions(options);
const getModeActions = useNotificationModeActions();
const handleChange = useCallback(
async (mode: NotificationMode) => {
-42
View File
@@ -1,42 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PushRuleActionName, TweakName } from 'matrix-js-sdk';
import { getNotificationModeOptionsFromActions } from './useNotificationMode';
test('getNotificationModeOptionsFromActions: no tweaks -> no highlight, no sound value', () => {
const options = getNotificationModeOptionsFromActions([PushRuleActionName.Notify]);
assert.deepEqual(options, { soundValue: undefined, highlight: false });
});
test('getNotificationModeOptionsFromActions: picks up highlight: true', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.equal(options.highlight, true);
});
test('getNotificationModeOptionsFromActions: highlight: false is not treated as set', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: false },
]);
assert.equal(options.highlight, false);
});
test('getNotificationModeOptionsFromActions: picks up a custom sound value', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
]);
assert.equal(options.soundValue, 'ping.ogg');
});
test('getNotificationModeOptionsFromActions: picks up both tweaks together', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.deepEqual(options, { soundValue: 'ping.ogg', highlight: true });
});
-23
View File
@@ -49,29 +49,6 @@ export const getNotificationModeActions = (
return actions;
};
// Derive the options that would reproduce an existing rule's tweaks, so a mode
// switch rebuilds actions on top of them instead of silently dropping a
// `highlight` tweak (or a custom sound) that isn't part of the mode itself.
export const getNotificationModeOptionsFromActions = (
actions: PushRuleAction[],
): NotificationModeOptions => {
const soundTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Sound,
);
const highlightTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Highlight,
);
return {
soundValue:
soundTweak && typeof soundTweak === 'object' && typeof soundTweak.value === 'string'
? soundTweak.value
: undefined,
highlight:
!!highlightTweak && typeof highlightTweak === 'object' && highlightTweak.value !== false,
};
};
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
export const useNotificationModeActions = (
options?: NotificationModeOptions,
+1 -7
View File
@@ -44,13 +44,7 @@ export function getLocalRoomNamesContent(
mx: ReturnType<typeof useMatrixClient>,
): LocalRoomNamesContent {
const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
if (
raw &&
typeof raw === 'object' &&
'rooms' in raw &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
typeof (raw as any).rooms === 'object'
) {
if (raw && typeof raw === 'object' && 'rooms' in raw && typeof (raw as any).rooms === 'object') {
return raw as LocalRoomNamesContent;
}
return { rooms: {} };
+3 -3
View File
@@ -51,7 +51,7 @@ import {
MuteTimerEntry,
loadMuteTimers,
unmuteRoom,
} from '../../features/room-nav/muteTimers';
} from '../../features/room-nav/RoomNavItem';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
@@ -70,12 +70,12 @@ import {
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications';
// 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');
// Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites.
const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
function SystemEmojiFeature() {
-2
View File
@@ -25,10 +25,8 @@ export const setMarkedUnread = (
unread: boolean,
): Promise<unknown> =>
Promise.all([
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, AccountDataEvent.MarkedUnread as any, { unread }),
// Best-effort mirror for older servers; never fail the primary write on it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.setRoomAccountData(roomId, UNSTABLE_MARKED_UNREAD as any, { unread }).catch(() => undefined),
]);
-2
View File
@@ -13,7 +13,6 @@ export function getAccountData<T>(
mx: MatrixClient,
eventType: AccountDataEvent | string,
): T | undefined {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const event = (mx as any).getAccountData(eventType) as MatrixEvent | undefined;
return event?.getContent() as T | undefined;
}
@@ -23,6 +22,5 @@ export function setAccountData<T>(
eventType: AccountDataEvent | string,
content: T,
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (mx as any).setAccountData(eventType, content);
}
-2
View File
@@ -70,7 +70,6 @@ export async function buildModelNode(
model: DenoiseModelId,
): Promise<DenoiseNode> {
if (model === 'dtln') {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(/* @vite-ignore */ `${BASE}workadventure/audio-worklet.js`);
const handle = await mod.createNoiseSuppressionAudioWorklet(ctx, { bypassUntilReady: true });
return { node: handle.node, dispose: () => handle.dispose() };
@@ -81,7 +80,6 @@ export async function buildModelNode(
// deepfilternet/v2/... Override its cdnUrl to our absolute base so nothing
// hits the upstream CDN. DeepFilterNet3Core builds the worklet node directly.
const dfnBase = new URL(`${BASE}deepfilternet`, window.location.href).href;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod: any = await import(/* @vite-ignore */ `${BASE}deepfilternet/index.esm.js`);
const core = new mod.DeepFilterNet3Core({
sampleRate: sampleRateFor(model),
-5
View File
@@ -44,12 +44,10 @@ test('onTabPress fires only on Tab', () => {
test('preventScrollWithArrowKey prevents default only on arrows', () => {
const up = evt('ArrowUp', 38);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preventScrollWithArrowKey(up as any);
assert.equal(up.prevented, true);
const a = evt('a', 65);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
preventScrollWithArrowKey(a as any);
assert.equal(a.prevented, false);
});
@@ -95,14 +93,12 @@ test('stopPropagation: stops unless an editable element is focused', () => {
// nothing focused → stops, returns true
withActive(null);
let k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), true);
assert.equal(k.wasStopped(), true);
// input focused → does not stop, returns false
withActive({ nodeName: 'INPUT', getAttribute: () => null });
k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), false);
assert.equal(k.wasStopped(), false);
@@ -112,6 +108,5 @@ test('stopPropagation: stops unless an editable element is focused', () => {
getAttribute: (a: string) => (a === 'contenteditable' ? 'true' : null),
});
k = makeKeyEvt();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal(stopPropagation(k.ev as any), false);
});
@@ -22,7 +22,6 @@ const makeMx = (
if (opts.forgetRejects) throw new Error('forget failed');
return {};
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any;
return { mx, calls };
};
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { M_POLL_KIND_DISCLOSED } from 'matrix-js-sdk';
// Pure helpers for poll display. matrix-js-sdk 41.7.0's PollStartEvent /
-1
View File
@@ -58,7 +58,6 @@ export function sendStateEvent<T extends object>(
content: T,
stateKey = '',
): Promise<ISendEventResponse> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return mx.sendStateEvent(roomId, eventType as any, content, stateKey);
}
@@ -1,49 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { scheduleMessage } from './scheduledMessages';
// MSC4140 delayed events are PUT as a plaintext m.room.message, so scheduling
// must be refused outright for encrypted rooms — the composer hides the button,
// this guard stops any other caller from regressing it.
const makeMx = (encrypted: boolean | undefined) => {
const calls: unknown[][] = [];
const mx = {
getRoom: (_roomId: string) =>
encrypted === undefined ? null : { hasEncryptionStateEvent: () => encrypted },
http: {
authedRequest: (...args: unknown[]) => {
calls.push(args);
return Promise.resolve({ delay_id: 'delay-1' });
},
},
};
return { mx: mx as never, calls };
};
test('scheduleMessage throws and sends nothing for an encrypted room', async () => {
const { mx, calls } = makeMx(true);
await assert.rejects(
() => scheduleMessage(mx, '!enc:lotusguild.org', { body: 'secret' }, Date.now() + 60_000),
/encrypted rooms/,
);
assert.equal(calls.length, 0);
});
test('scheduleMessage still sends for an unencrypted room', async () => {
const { mx, calls } = makeMx(false);
const delayId = await scheduleMessage(
mx,
'!plain:lotusguild.org',
{ body: 'hi' },
Date.now() + 60_000,
);
assert.equal(delayId, 'delay-1');
assert.equal(calls.length, 1);
});
test('scheduleMessage sends when the room is unknown to the client', async () => {
const { mx, calls } = makeMx(undefined);
await scheduleMessage(mx, '!unknown:lotusguild.org', { body: 'hi' }, Date.now() + 60_000);
assert.equal(calls.length, 1);
});
-7
View File
@@ -12,13 +12,6 @@ export async function scheduleMessage(
content: IContent,
sendAtMs: number,
): Promise<string> {
// MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing
// the SDK's encryptEventIfNeeded pipeline — the body would land on the server
// (and later in the timeline) in the clear. Refuse rather than leak; the
// composer also hides the Schedule button in encrypted rooms.
if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) {
throw new Error('Scheduled messages are not supported in encrypted rooms.');
}
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
+28 -6
View File
@@ -22,18 +22,40 @@ document.body.classList.add(configClass, varsClass);
// Register Service Worker
if ('serviceWorker' in navigator) {
const swUrl =
import.meta.env.MODE === 'production'
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
const isProduction = import.meta.env.PROD;
const swUrl = isProduction
? `${trimTrailingSlash(import.meta.env.BASE_URL)}/sw.js`
: `/dev-sw.js?dev-sw`;
const sendSessionToSW = () => {
const session = getFallbackSession();
pushSessionToSW(session?.baseUrl, session?.accessToken);
};
navigator.serviceWorker.register(swUrl).then(sendSessionToSW);
navigator.serviceWorker.ready.then(sendSessionToSW);
const registerServiceWorker = async () => {
try {
const registration = await navigator.serviceWorker.register(
swUrl,
isProduction
? undefined
: {
type: 'module',
scope: '/',
},
);
sendSessionToSW();
await navigator.serviceWorker.ready;
sendSessionToSW();
console.info('Service worker registered:', registration.scope);
} catch (error) {
console.error('Service worker registration failed:', error);
}
};
registerServiceWorker();
navigator.serviceWorker.addEventListener('message', (ev) => {
const { type } = ev.data ?? {};
-1
View File
@@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { globalStyle, keyframes, style } from '@vanilla-extract/css';
const glitch1 = keyframes({
+1 -1
View File
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
export default defineConfig({
appType: 'spa',
publicDir: false,
publicDir: './public/res',
base: buildConfig.base,
server: {
port: 8080,