Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a85a48704 | ||
|
|
f3119e3dc2 | ||
|
|
cecf65a3a1 | ||
|
|
02592ed43c | ||
|
|
6bd2903de1 | ||
|
|
7c52027afb | ||
|
|
2344c8273e | ||
|
|
6e4c4bc795 | ||
|
|
e447fdc0f3 | ||
|
|
4dd0e6637d | ||
|
|
dfccaec9dc | ||
|
|
9019d7c21e | ||
|
|
eef1d14492 | ||
|
|
2b66dcc08c | ||
|
|
dac74f098e | ||
|
|
d4d1b4957f | ||
|
|
c4aa1567d7 | ||
|
|
a6ddafb446 | ||
|
|
6c1a9942b0 | ||
|
|
4bea48959e | ||
|
|
34a3352e21 | ||
|
|
3cc5f0cc6a | ||
|
|
fd93339ad4 | ||
|
|
b1ecb0c46b | ||
|
|
4656f08802 | ||
|
|
a631e90ea2 | ||
|
|
10270b75ca | ||
|
|
7925866868 |
+34
-34
@@ -30,17 +30,22 @@ 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
|
||||
# 5 times with backoff before failing the build.
|
||||
# 3 times with backoff before failing the build.
|
||||
run: |
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 10000
|
||||
npm config set fetch-retry-maxtimeout 60000
|
||||
npm config set fetch-timeout 300000
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
npm config set fetch-timeout 600000
|
||||
for attempt in 1 2 3; do
|
||||
echo "npm ci attempt $attempt…"
|
||||
npm ci && break
|
||||
@@ -52,41 +57,36 @@ jobs:
|
||||
sleep $((attempt * 15))
|
||||
done
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
|
||||
# error fails in seconds instead of after the ~minutes-long build. All are
|
||||
# hard gates — any failure fails the job and blocks the deploy. The tree is
|
||||
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
|
||||
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
|
||||
# deploy without CI; a later normal push surfaces any introduced issue here
|
||||
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
||||
# eslint gates on errors only (existing no-explicit-any warnings stay
|
||||
# informational — check:eslint has no --max-warnings).
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
|
||||
- name: ESLint
|
||||
run: npm run check:eslint
|
||||
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
|
||||
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
|
||||
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
|
||||
- name: Build
|
||||
run: npm run build
|
||||
env:
|
||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||
VITE_APP_VERSION: ${{ github.sha }}
|
||||
|
||||
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||
# A failure blocks the deploy.
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
|
||||
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
|
||||
# formatted), so these gate real regressions instead of relying on local
|
||||
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
|
||||
# introduce upstream type/lint/format issues; that path deploys without
|
||||
# CI, but a subsequent normal push would surface the failure here — fix
|
||||
# forward (or briefly re-soften a gate) rather than let it deploy broken.
|
||||
# eslint gates on errors only (existing `no-explicit-any` warnings stay
|
||||
# informational — `check:eslint` has no --max-warnings).
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
|
||||
- name: ESLint
|
||||
run: npm run check:eslint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Prettier Check and Fix
|
||||
run: |
|
||||
npx prettier --write .
|
||||
npm run check:prettier
|
||||
continue-on-error: true
|
||||
|
||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||
- name: Audit (high/critical)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
|
||||
+1
-3
@@ -1,3 +1 @@
|
||||
# These are commented until we enable lint and typecheck
|
||||
# npx tsc -p tsconfig.json --noEmit
|
||||
# npx lint-staged
|
||||
npx lint-staged
|
||||
|
||||
Vendored
+1
-15
@@ -1,19 +1,5 @@
|
||||
{
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"js/ts.tsdk.path": "node_modules/typescript/lib",
|
||||
"prettier.requireConfig": true,
|
||||
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
||||
+14
-14
@@ -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 | 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 |
|
||||
| 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 |
|
||||
|
||||
### Implementation
|
||||
|
||||
@@ -742,7 +742,7 @@ never leaves it.
|
||||
|
||||
### Message Search Date Range
|
||||
|
||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds); server results are filtered client-side by `origin_server_ts` (they are not Matrix filter fields), matching the local encrypted-room search
|
||||
- A chip shows the active date range with an **×** button to clear it
|
||||
|
||||
### Encrypted Search Cache (P4-8, opt-in)
|
||||
|
||||
+4
-2
@@ -372,9 +372,11 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
|
||||
|
||||
### Element Call fork — operational reference
|
||||
|
||||
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/`).
|
||||
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/`).
|
||||
|
||||
**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.
|
||||
**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`, published by CI 2026-09-12 — the first CI publish; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), 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.25.0-lotus.1`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
|
||||
|
||||
**`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):
|
||||
|
||||
|
||||
@@ -13,38 +13,7 @@ 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
|
||||
@@ -53,7 +22,7 @@ You should now have an active development server at `localhost:8080`, where you
|
||||
- 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
|
||||
- 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)
|
||||
- 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
|
||||
@@ -160,7 +129,16 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
|
||||
Operating System | Download
|
||||
---|---
|
||||
Windows | [Get the installer (.exe)](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe)
|
||||
Linux (AppImage, any distro) | [Get the AppImage](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.AppImage)
|
||||
Linux (Debian/Ubuntu) | [Get the .deb](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb)
|
||||
Linux (Arch/CachyOS/EndeavourOS) | [Get the .pkg.tar.zst](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst) — install with `pacman -U LotusChat-x86_64.pkg.tar.zst`
|
||||
|
||||
All Linux builds need `webkit2gtk-4.1` and, for calls to work, GStreamer's `good`/`bad`/`ugly`/`libav` plugin sets (the pacman package pulls these in automatically; on the AppImage/.deb, install them via your package manager if joining a call shows "browser does not support WebRTC").
|
||||
|
||||
See the full [Releases page on code.lotusguild.org](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases) for signatures and older builds.
|
||||
|
||||
### SmartScreen Warning (Windows)
|
||||
|
||||
@@ -218,7 +196,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`, source at
|
||||
(`@lotusguild/element-call-embedded` `0.25.0-lotus.1`, upstream base v0.25.0, 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.
|
||||
|
||||
+40
-42
@@ -19,11 +19,10 @@
|
||||
*
|
||||
* Any failure falls back to the unprocessed mic so calls never break.
|
||||
*/
|
||||
// TODO: MAKE THIS A TS FILE
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
let params;
|
||||
var params;
|
||||
try {
|
||||
params = new URLSearchParams(window.location.search);
|
||||
if (params.get('lotusDenoise') !== 'ml') return;
|
||||
@@ -34,31 +33,31 @@
|
||||
// Derive the parent origin for postMessage targetOrigin from the parentUrl
|
||||
// widget param (a full URL) so denoise-status messages aren't broadcast with
|
||||
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
|
||||
let targetOrigin;
|
||||
var targetOrigin;
|
||||
try {
|
||||
let parentUrl = params.get('parentUrl');
|
||||
var parentUrl = params.get('parentUrl');
|
||||
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
|
||||
} catch (e) {
|
||||
targetOrigin = window.location.origin;
|
||||
}
|
||||
|
||||
let md = navigator.mediaDevices;
|
||||
var md = navigator.mediaDevices;
|
||||
if (!md || typeof md.getUserMedia !== 'function') return;
|
||||
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
|
||||
|
||||
let ASSET_BASE = './denoise/';
|
||||
var ASSET_BASE = './denoise/';
|
||||
|
||||
let MODEL = params.get('lotusModel') || 'rnnoise';
|
||||
var MODEL = params.get('lotusModel') || 'rnnoise';
|
||||
// DTLN (@workadventure) targets 16 kHz and does not resample internally, so
|
||||
// its whole graph runs in a 16 kHz context; RNNoise/Speex (sapphi) and
|
||||
// DeepFilterNet 3 are 48 kHz fullband. The processed MediaStreamTrack is
|
||||
// published to LiveKit either way (WebRTC/Opus resamples as needed).
|
||||
let SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
|
||||
let USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
|
||||
let USE_GATE = params.get('lotusGate') === 'true';
|
||||
let GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
|
||||
var SAMPLE_RATE = MODEL === 'dtln' ? 16000 : 48000;
|
||||
var USE_NATIVE_NS = params.get('lotusNativeNS') === 'true';
|
||||
var USE_GATE = params.get('lotusGate') === 'true';
|
||||
var GATE_THRESHOLD = parseFloat(params.get('lotusGateThreshold') || '-45');
|
||||
|
||||
let PROCESSORS = {
|
||||
var PROCESSORS = {
|
||||
rnnoise: {
|
||||
name: '@sapphi-red/web-noise-suppressor/rnnoise',
|
||||
script: 'rnnoiseWorklet.js',
|
||||
@@ -92,9 +91,9 @@
|
||||
},
|
||||
};
|
||||
|
||||
let origGetUserMedia = md.getUserMedia.bind(md);
|
||||
let wasmPromises = {};
|
||||
let ctxPromise = null;
|
||||
var origGetUserMedia = md.getUserMedia.bind(md);
|
||||
var wasmPromises = {};
|
||||
var ctxPromise = null;
|
||||
|
||||
function checkSimd() {
|
||||
try {
|
||||
@@ -113,12 +112,12 @@
|
||||
|
||||
function loadWasm(modelId) {
|
||||
if (wasmPromises[modelId]) return wasmPromises[modelId];
|
||||
let p = PROCESSORS[modelId];
|
||||
var p = PROCESSORS[modelId];
|
||||
if (!p || !p.wasm) return Promise.resolve(null);
|
||||
|
||||
wasmPromises[modelId] = (modelId === 'rnnoise' ? checkSimd() : Promise.resolve(false)).then(
|
||||
function (simd) {
|
||||
let file = simd && p.simdWasm ? p.simdWasm : p.wasm;
|
||||
var file = simd && p.simdWasm ? p.simdWasm : p.wasm;
|
||||
return fetch(ASSET_BASE + file).then(function (r) {
|
||||
if (!r.ok) {
|
||||
if (simd && p.simdWasm)
|
||||
@@ -138,7 +137,7 @@
|
||||
function getContext() {
|
||||
if (!ctxPromise) {
|
||||
ctxPromise = (function () {
|
||||
let ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
var ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
if (ctx.sampleRate !== SAMPLE_RATE) {
|
||||
try {
|
||||
ctx.close();
|
||||
@@ -147,7 +146,7 @@
|
||||
}
|
||||
// Load worklet modules. DTLN registers its own processor via the
|
||||
// dynamic-imported helper (see buildMlNode), so it needs nothing here.
|
||||
let scripts = [];
|
||||
var scripts = [];
|
||||
if (MODEL === 'rnnoise' || MODEL === 'speex') scripts.push(PROCESSORS[MODEL].script);
|
||||
if (USE_GATE) scripts.push(PROCESSORS.gate.script);
|
||||
|
||||
@@ -170,7 +169,7 @@
|
||||
return ctxPromise;
|
||||
}
|
||||
|
||||
let hasNotifiedActive = false;
|
||||
var hasNotifiedActive = false;
|
||||
|
||||
// Build the ML denoise AudioWorkletNode. RNNoise/Speex are flat sapphi
|
||||
// worklets we instantiate directly with the fetched WASM binary. DTLN comes
|
||||
@@ -188,9 +187,9 @@
|
||||
if (MODEL === 'deepfilternet') {
|
||||
// Resolve an absolute self-hosted base so the package's cdnUrl override
|
||||
// fetches our vendored df_bg.wasm + ONNX model (never the upstream CDN).
|
||||
let dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
|
||||
var dfnBase = new URL(ASSET_BASE + 'deepfilternet', window.location.href).href;
|
||||
return import(ASSET_BASE + PROCESSORS.deepfilternet.esm).then(function (mod) {
|
||||
let core = new mod.DeepFilterNet3Core({
|
||||
var core = new mod.DeepFilterNet3Core({
|
||||
sampleRate: SAMPLE_RATE,
|
||||
noiseReductionLevel: 80,
|
||||
assetConfig: { cdnUrl: dfnBase },
|
||||
@@ -213,8 +212,7 @@
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
let node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
||||
var node = new AudioWorkletNode(ctx, PROCESSORS[MODEL].name, {
|
||||
channelCount: 1,
|
||||
numberOfInputs: 1,
|
||||
numberOfOutputs: 1,
|
||||
@@ -232,21 +230,21 @@
|
||||
}
|
||||
|
||||
function processStream(stream) {
|
||||
let audioTracks = stream.getAudioTracks();
|
||||
var audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) return Promise.resolve(stream);
|
||||
|
||||
return Promise.all([loadWasm(MODEL), getContext()])
|
||||
.then(function (res) {
|
||||
let wasmBinary = res[0];
|
||||
let ctx = res[1];
|
||||
var wasmBinary = res[0];
|
||||
var ctx = res[1];
|
||||
|
||||
let source = ctx.createMediaStreamSource(stream);
|
||||
let dest = ctx.createMediaStreamDestination();
|
||||
let head = source;
|
||||
var source = ctx.createMediaStreamSource(stream);
|
||||
var dest = ctx.createMediaStreamDestination();
|
||||
var head = source;
|
||||
|
||||
// 1. Optional Noise Gate
|
||||
if (USE_GATE) {
|
||||
let gateNode = new AudioWorkletNode(ctx, PROCESSORS.gate.name, {
|
||||
var gateNode = new AudioWorkletNode(ctx, PROCESSORS.gate.name, {
|
||||
processorOptions: {
|
||||
openThreshold: GATE_THRESHOLD,
|
||||
closeThreshold: GATE_THRESHOLD - 5,
|
||||
@@ -260,7 +258,7 @@
|
||||
|
||||
// 2. ML Processor
|
||||
return buildMlNode(ctx, wasmBinary).then(function (ml) {
|
||||
let mlNode = ml.node;
|
||||
var mlNode = ml.node;
|
||||
head.connect(mlNode);
|
||||
mlNode.connect(dest);
|
||||
|
||||
@@ -268,15 +266,15 @@
|
||||
// the track handoff — audio flows via bypassUntilReady meanwhile.
|
||||
if (ml.ready && typeof ml.ready.then === 'function') {
|
||||
ml.ready.catch(function (err) {
|
||||
let m = err instanceof Error ? err.message : String(err);
|
||||
var m = err instanceof Error ? err.message : String(err);
|
||||
console.error('[lotus-denoise] ' + MODEL + ' init failed:', m);
|
||||
});
|
||||
}
|
||||
|
||||
let origTrack = audioTracks[0];
|
||||
let processedTrack = dest.stream.getAudioTracks()[0];
|
||||
var origTrack = audioTracks[0];
|
||||
var processedTrack = dest.stream.getAudioTracks()[0];
|
||||
|
||||
let torndown = false;
|
||||
var torndown = false;
|
||||
function cleanup() {
|
||||
if (torndown) return;
|
||||
torndown = true;
|
||||
@@ -295,7 +293,7 @@
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
let rawStop = processedTrack.stop.bind(processedTrack);
|
||||
var rawStop = processedTrack.stop.bind(processedTrack);
|
||||
processedTrack.stop = function () {
|
||||
cleanup();
|
||||
rawStop();
|
||||
@@ -321,7 +319,7 @@
|
||||
);
|
||||
}
|
||||
|
||||
let out = new MediaStream();
|
||||
var out = new MediaStream();
|
||||
out.addTrack(processedTrack);
|
||||
stream.getVideoTracks().forEach(function (t) {
|
||||
out.addTrack(t);
|
||||
@@ -330,7 +328,7 @@
|
||||
});
|
||||
})
|
||||
.catch(function (e) {
|
||||
let msg = e instanceof Error ? e.message : String(e);
|
||||
var msg = e instanceof Error ? e.message : String(e);
|
||||
console.error('[lotus-denoise] Setup failed:', msg);
|
||||
window.parent.postMessage(
|
||||
{ type: 'lotus-denoise-status', active: false, error: msg },
|
||||
@@ -341,10 +339,10 @@
|
||||
}
|
||||
|
||||
navigator.mediaDevices.getUserMedia = function (constraints) {
|
||||
let wantsAudio = !!(constraints && constraints.audio);
|
||||
let effective = constraints;
|
||||
var wantsAudio = !!(constraints && constraints.audio);
|
||||
var effective = constraints;
|
||||
if (wantsAudio) {
|
||||
let audioC =
|
||||
var audioC =
|
||||
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
||||
audioC.noiseSuppression = USE_NATIVE_NS;
|
||||
audioC.channelCount = 1;
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@
|
||||
"allowCustomHomeservers": true,
|
||||
"featuredCommunities": {
|
||||
"openAsDefault": false,
|
||||
"spaces": [],
|
||||
"rooms": [],
|
||||
"servers": []
|
||||
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
|
||||
"rooms": ["#jellyfin:matrix.org"],
|
||||
"servers": ["matrixrooms.info"]
|
||||
},
|
||||
"hashRouter": {
|
||||
"enabled": false,
|
||||
|
||||
+1
-1
@@ -109,7 +109,7 @@ export default [
|
||||
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_', caughtErrorsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-shadow': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
|
||||
// jsx-a11y — media captions not required for this app
|
||||
'jsx-a11y/media-has-caption': 'off',
|
||||
|
||||
Generated
+4761
-1870
File diff suppressed because it is too large
Load Diff
+23
-15
@@ -12,12 +12,13 @@
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "npm run check:eslint && npm run check:prettier",
|
||||
"check:eslint": "eslint \"src/**/*.{js,jsx,ts,tsx}\"",
|
||||
"check:eslint": "eslint src/*",
|
||||
"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"
|
||||
},
|
||||
@@ -25,6 +26,11 @@
|
||||
"*.{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",
|
||||
@@ -35,22 +41,24 @@
|
||||
"@eslint/eslintrc": "3.3.5",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@fontsource-variable/inter": "5.2.8",
|
||||
"@giphy/js-fetch-api": "5.8.0",
|
||||
"@giphy/js-types": "5.1.0",
|
||||
"@giphy/js-util": "2.0.0",
|
||||
"@giphy/js-util": "5.2.0",
|
||||
"@giphy/react-components": "10.1.2",
|
||||
"@sapphi-red/web-noise-suppressor": "0.3.5",
|
||||
"@tanstack/react-query": "5.100.13",
|
||||
"@tanstack/react-query-devtools": "5.100.13",
|
||||
"@tanstack/react-virtual": "3.13.25",
|
||||
"@workadventure/noise-suppression": "0.1.1",
|
||||
"@workadventure/noise-suppression": "0.0.4",
|
||||
"await-to-js": "3.0.0",
|
||||
"badwords-list": "2.0.1-4",
|
||||
"blurhash": "2.0.5",
|
||||
"browser-encrypt-attachment": "0.3.0",
|
||||
"chroma-js": "3.2.0",
|
||||
"classnames": "2.5.1",
|
||||
"dateformat": "5.0.3",
|
||||
"dayjs": "1.11.20",
|
||||
"deepfilternet3-noise-filter": "1.3.0",
|
||||
"deepfilternet3-noise-filter": "1.2.1",
|
||||
"domhandler": "6.0.1",
|
||||
"emojibase": "17.0.0",
|
||||
"emojibase-data": "17.0.0",
|
||||
@@ -67,13 +75,12 @@
|
||||
"is-hotkey": "0.2.0",
|
||||
"jotai": "2.20.0",
|
||||
"jsqr": "1.4.0",
|
||||
"katex": "0.16.47",
|
||||
"katex": "0.16.11",
|
||||
"linkify-react": "4.3.3",
|
||||
"linkifyjs": "4.3.3",
|
||||
"matrix-js-sdk": "41.7.0",
|
||||
"matrix-widget-api": "1.17.0",
|
||||
"millify": "6.1.0",
|
||||
"oidc-client-ts": "3.5.0",
|
||||
"pdfjs-dist": "5.7.284",
|
||||
"prismjs": "1.30.0",
|
||||
"qrcode": "1.5.4",
|
||||
@@ -87,8 +94,8 @@
|
||||
"react-google-recaptcha": "3.1.0",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-range": "1.10.0",
|
||||
"react-router": "8.3.0",
|
||||
"sanitize-html": "2.17.6",
|
||||
"react-router-dom": "7.15.1",
|
||||
"sanitize-html": "2.17.4",
|
||||
"slate": "0.124.1",
|
||||
"slate-dom": "0.124.1",
|
||||
"slate-history": "0.113.1",
|
||||
@@ -98,12 +105,13 @@
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.20.1-lotus.1",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
"@types/chroma-js": "3.1.2",
|
||||
"@types/file-saver": "2.0.7",
|
||||
"@types/is-hotkey": "0.1.10",
|
||||
"@types/katex": "0.16.8",
|
||||
"@types/node": "25.9.1",
|
||||
"@types/prismjs": "1.26.6",
|
||||
"@types/qrcode": "1.5.6",
|
||||
@@ -111,24 +119,28 @@
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react-google-recaptcha": "2.1.9",
|
||||
"@types/sanitize-html": "2.16.1",
|
||||
"@types/ua-parser-js": "0.7.39",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"@vanilla-extract/css": "1.20.1",
|
||||
"@vanilla-extract/recipes": "0.5.7",
|
||||
"@vanilla-extract/vite-plugin": "5.2.2",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"buffer": "6.0.3",
|
||||
"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",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"husky": "9.1.7",
|
||||
"lint-staged": "17.0.5",
|
||||
"prettier": "3.8.3",
|
||||
"tsx": "4.22.4",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.2.0",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-pwa": "1.3.0",
|
||||
"vite-plugin-static-copy": "4.1.0"
|
||||
},
|
||||
@@ -137,9 +149,5 @@
|
||||
"dompurify": ">=3.3.4"
|
||||
},
|
||||
"js-cookie": ">=3.0.6"
|
||||
},
|
||||
"allowScripts": {
|
||||
"protobufjs": true,
|
||||
"esbuild": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import { matchPath, useLocation, useNavigate } from 'react-router';
|
||||
import { matchPath, useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
getDirectPath,
|
||||
getExplorePath,
|
||||
|
||||
@@ -58,6 +58,7 @@ 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';
|
||||
@@ -410,6 +411,8 @@ 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.
|
||||
@@ -566,11 +569,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleAnswer = useCallback(
|
||||
(room: Room, video: boolean) => {
|
||||
startCall(room, { microphone: true, video, sound: true });
|
||||
// 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 });
|
||||
setCallInfo(undefined);
|
||||
navigateRoom(room.roomId);
|
||||
},
|
||||
[startCall, navigateRoom],
|
||||
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
|
||||
);
|
||||
|
||||
if (!callInfo) return null;
|
||||
|
||||
@@ -34,6 +34,7 @@ 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,3 +1,4 @@
|
||||
/* 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';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps, forwardRef } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { as } from 'folds';
|
||||
import * as css from './styles.css';
|
||||
|
||||
|
||||
@@ -84,7 +84,15 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
|
||||
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',
|
||||
}}
|
||||
>
|
||||
{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: '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)' },
|
||||
'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)' },
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -46,6 +46,10 @@ 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
|
||||
@@ -58,7 +62,7 @@ function gridDataUri(): string {
|
||||
rows.forEach((y) => {
|
||||
lines.push(
|
||||
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
|
||||
`stroke-width='1.4' stroke-opacity='0.9'/>`,
|
||||
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
|
||||
);
|
||||
});
|
||||
// Vertical lines fanning out from the top-center vanishing point.
|
||||
@@ -67,7 +71,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.4' stroke-opacity='0.8'/>`,
|
||||
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
|
||||
);
|
||||
}
|
||||
const svg =
|
||||
@@ -105,6 +109,13 @@ 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[]>(() => {
|
||||
@@ -134,9 +145,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.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%)',
|
||||
'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%)',
|
||||
].join(','),
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
@@ -147,41 +158,59 @@ 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. */}
|
||||
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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
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%)',
|
||||
opacity: reduced ? 0.5 : 0.62,
|
||||
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,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
<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 3px oklch(0.55 0.22 320 / 0.6))',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
||||
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',
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<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
|
||||
@@ -197,7 +226,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.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
|
||||
'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%)',
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
/>
|
||||
@@ -209,8 +238,10 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
right: '12%',
|
||||
top: '38%',
|
||||
height: '1.5px',
|
||||
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,
|
||||
// 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,
|
||||
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
|
||||
}}
|
||||
/>
|
||||
@@ -273,7 +304,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
inset: 0,
|
||||
overflow: 'hidden',
|
||||
mixBlendMode: 'multiply',
|
||||
opacity: 0.5,
|
||||
opacity: 0.32,
|
||||
contain: 'layout paint style',
|
||||
}}
|
||||
>
|
||||
@@ -285,7 +316,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
top: '-8px',
|
||||
bottom: '-8px',
|
||||
backgroundImage:
|
||||
'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)',
|
||||
'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)',
|
||||
willChange: reduced ? undefined : 'transform',
|
||||
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
|
||||
}}
|
||||
@@ -309,51 +340,54 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
|
||||
Static scene shows it steady (no blink). */}
|
||||
{/* 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. */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '5%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
right: '14px',
|
||||
bottom: '8px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-end',
|
||||
gap: '3px',
|
||||
fontFamily: '"Courier New", monospace',
|
||||
fontSize: '12px',
|
||||
fontSize: HUD_FONT_SIZE,
|
||||
fontWeight: 700,
|
||||
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)',
|
||||
lineHeight: 1,
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: reduced ? 0.6 : undefined,
|
||||
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||||
}}
|
||||
>
|
||||
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
|
||||
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>
|
||||
</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(16, 1000), []);
|
||||
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
|
||||
const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []);
|
||||
const nearStars = useMemo<Star[]>(() => makeStars(22, 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.16)',
|
||||
backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
|
||||
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: 'transform, opacity',
|
||||
willChange: reduced ? undefined : 'transform, opacity',
|
||||
opacity: reduced ? 0.5 : undefined,
|
||||
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
|
||||
animation: reduced
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Button, color, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
||||
import { VerificationStatus } from '../../hooks/useDeviceVerificationStatus';
|
||||
@@ -214,24 +214,56 @@ function UserPrivateNotes({ userId }: { userId: string }) {
|
||||
const [draft, setDraft] = useState(() => getNote(userId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
// True while the user has unsaved local edits — prevents the store-sync
|
||||
// effect below from reacting to the echo of our own save and reverting text
|
||||
// typed after the debounce fired but before that save's account-data echo
|
||||
// landed (mirrors statusDirtyRef in Profile.tsx's ProfileStatus).
|
||||
const dirtyRef = useRef(false);
|
||||
// Latest draft/userId, kept current on every render so the unmount cleanup
|
||||
// can flush a pending save without capturing a stale closure.
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
const userIdRef = useRef(userId);
|
||||
userIdRef.current = userId;
|
||||
const setNoteRef = useRef(setNote);
|
||||
setNoteRef.current = setNote;
|
||||
const prevUserIdRef = useRef(userId);
|
||||
|
||||
// Sync if account data arrives after mount
|
||||
// Sync if account data arrives after mount, but never while there are
|
||||
// unsaved local edits (including our own save's in-flight echo).
|
||||
useEffect(() => {
|
||||
if (prevUserIdRef.current !== userId) {
|
||||
prevUserIdRef.current = userId;
|
||||
dirtyRef.current = false;
|
||||
}
|
||||
if (dirtyRef.current) return;
|
||||
setDraft(getNote(userId));
|
||||
}, [getNote, userId]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
dirtyRef.current = true;
|
||||
setDraft(val);
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
dirtyRef.current = false;
|
||||
setSaving(true);
|
||||
await setNote(userId, val);
|
||||
setSaving(false);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(saveTimer.current), []);
|
||||
useEffect(
|
||||
() => () => {
|
||||
clearTimeout(saveTimer.current);
|
||||
// Flush a still-pending debounced save instead of dropping it (e.g. the
|
||||
// profile panel closes within the 800ms debounce window).
|
||||
if (dirtyRef.current) {
|
||||
setNoteRef.current(userIdRef.current, draftRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
|
||||
|
||||
|
||||
@@ -171,6 +171,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== pttKey || e.repeat) return;
|
||||
// [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't
|
||||
// hijack OS/app chords (e.g. Cmd+Space) that happen to share the code.
|
||||
// Shift is allowed through — Shift+Space is a harmless combo for PTT.
|
||||
if (e.ctrlKey || e.altKey || e.metaKey) return;
|
||||
const target = e.target as HTMLElement;
|
||||
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
|
||||
const isEditable = (el: HTMLElement): boolean => {
|
||||
@@ -185,7 +189,23 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
return false;
|
||||
};
|
||||
if (isEditable(target)) return;
|
||||
e.preventDefault();
|
||||
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still
|
||||
// engages the mic, but the key's default action (activating the control)
|
||||
// is left alone so keyboard users can still Tab+Space the call buttons.
|
||||
const isInteractive = (el: HTMLElement): boolean => {
|
||||
const tag = el.tagName;
|
||||
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
|
||||
let node: HTMLElement | null = el;
|
||||
while (node && node !== el.ownerDocument.body) {
|
||||
const role = node.getAttribute('role');
|
||||
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
|
||||
return true;
|
||||
}
|
||||
node = node.parentElement;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
if (!isInteractive(target)) e.preventDefault();
|
||||
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
|
||||
// doesn't treat this transient unmute as a user-initiated undeafen.
|
||||
callEmbed.control.pttActive = true;
|
||||
@@ -256,6 +276,10 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.code !== deafenKey) return;
|
||||
if (e.repeat) return;
|
||||
// [Gitea #23] Ignore the deafen key with any modifier held — with the
|
||||
// default 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that
|
||||
// shouldn't also toggle deafen (and previously got preventDefault()ed).
|
||||
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
|
||||
if (isEditable(e.target as HTMLElement)) return;
|
||||
e.preventDefault();
|
||||
callEmbed.control.toggleSound();
|
||||
|
||||
@@ -74,7 +74,8 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
||||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||||
aria-pressed={enabled}
|
||||
outlined
|
||||
>
|
||||
<Icon
|
||||
|
||||
@@ -58,6 +58,7 @@ 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,6 +55,7 @@ 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,6 +31,7 @@ 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],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Box, Button, color, config, Icon, Icons, Input, Spinner, Switch, Text } from 'folds';
|
||||
import React, { FormEventHandler, useCallback, useState } from 'react';
|
||||
import { ICreateRoomStateEvent, MatrixError, Preset, Visibility } from 'matrix-js-sdk';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettingTile } from '../../components/setting-tile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { addRoomIdToMDirect, isUserId } from '../../utils/matrix';
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from
|
||||
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { JoinRule, RestrictedAllowType, Room } from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
DECORATION_CATEGORIES,
|
||||
ALL_DECORATIONS,
|
||||
decorationUrl,
|
||||
isValidDecorationSlug,
|
||||
} from './avatarDecorations';
|
||||
|
||||
test('decorationUrl builds a CDN png url from the slug', () => {
|
||||
@@ -66,3 +67,20 @@ test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
|
||||
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`);
|
||||
});
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: accepts a real catalog slug', () => {
|
||||
assert.equal(isValidDecorationSlug('joystick'), true);
|
||||
assert.equal(isValidDecorationSlug('lotus_flower'), true);
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: rejects a path-traversal string', () => {
|
||||
assert.equal(isValidDecorationSlug('../../anything'), false);
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: rejects a slug carrying a query string', () => {
|
||||
assert.equal(isValidDecorationSlug('joystick?u=probe'), false);
|
||||
});
|
||||
|
||||
test('isValidDecorationSlug: rejects an empty string', () => {
|
||||
assert.equal(isValidDecorationSlug(''), false);
|
||||
});
|
||||
|
||||
@@ -188,6 +188,19 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
|
||||
(c) => c.decorations,
|
||||
);
|
||||
|
||||
const DECORATION_SLUGS = new Set(ALL_DECORATIONS.map((d) => d.slug));
|
||||
|
||||
/**
|
||||
* Whether `slug` is a known catalog decoration. `io.lotus.avatar_decoration`
|
||||
* is a free-form MSC4133 profile field set by a remote user (and their
|
||||
* homeserver), and its value is interpolated verbatim into `decorationUrl`
|
||||
* — so anything not in the catalog (path traversal, a query string, an
|
||||
* oversized value) must be rejected before it reaches a URL.
|
||||
*/
|
||||
export function isValidDecorationSlug(slug: string): boolean {
|
||||
return DECORATION_SLUGS.has(slug);
|
||||
}
|
||||
|
||||
export function decorationUrl(slug: string): string {
|
||||
return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
|
||||
@@ -36,6 +36,7 @@ import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { getStateEvent } from '../../utils/room';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
MessageSearchParams,
|
||||
@@ -316,12 +317,21 @@ export function MessageSearch({
|
||||
getNextPageParam: (lastPage) => lastPage.nextToken,
|
||||
});
|
||||
|
||||
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the
|
||||
// server results and the local/encrypted-cache results, so the filter chips
|
||||
// narrow the whole UI consistently rather than only the server section.
|
||||
// Shared client-side post-filter (date range + msgtype + pinned) applied to
|
||||
// BOTH the server results and the local/encrypted-cache results, so the
|
||||
// filter chips narrow the whole UI consistently rather than only the
|
||||
// server section. The date range must be enforced here because the Matrix
|
||||
// search API has no timestamp filter fields (see useMessageSearch.ts); the
|
||||
// local/encrypted path already filters in-range before this runs, so this
|
||||
// is a no-op there and only actually trims the server section.
|
||||
const applyResultFilters = useCallback(
|
||||
(allGroups: ResultGroup[]): ResultGroup[] => {
|
||||
const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
|
||||
const inDateRange = filterGroupsByDateRange(
|
||||
allGroups,
|
||||
msgSearchParams.fromTs,
|
||||
msgSearchParams.toTs,
|
||||
);
|
||||
const byMsgType = filterGroupsByMsgType(inDateRange, msgTypeFilters);
|
||||
if (!pinnedOnly) return byMsgType;
|
||||
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here
|
||||
// (where `mx` is available); the pure helper only consumes the predicate.
|
||||
@@ -343,7 +353,7 @@ export function MessageSearch({
|
||||
};
|
||||
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
|
||||
},
|
||||
[msgTypeFilters, pinnedOnly, mx],
|
||||
[msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx],
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
|
||||
@@ -46,6 +46,7 @@ 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 =
|
||||
@@ -56,6 +57,7 @@ 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,
|
||||
)
|
||||
@@ -102,6 +104,7 @@ 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: {} },
|
||||
};
|
||||
@@ -224,6 +227,7 @@ 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: {} },
|
||||
});
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
|
||||
import {
|
||||
filterGroupsByDateRange,
|
||||
filterGroupsByMsgType,
|
||||
filterGroupsByPinned,
|
||||
ResultGroup,
|
||||
} from './useMessageSearch';
|
||||
|
||||
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
|
||||
// (event.content.msgtype, event.event_id, group.roomId).
|
||||
@@ -9,6 +14,11 @@ const item = (msgtype: string | undefined, eventId: string) => ({
|
||||
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
|
||||
context: {},
|
||||
});
|
||||
const tsItem = (eventId: string, ts: number) => ({
|
||||
rank: 1,
|
||||
event: { event_id: eventId, origin_server_ts: ts, content: {} },
|
||||
context: {},
|
||||
});
|
||||
const mkGroups = (
|
||||
...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
|
||||
): ResultGroup[] => groups as unknown as ResultGroup[];
|
||||
@@ -48,6 +58,33 @@ test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
|
||||
assert.equal(out[0].items[0].event.event_id, '$2');
|
||||
});
|
||||
|
||||
test('filterGroupsByDateRange: no bounds returns groups unchanged', () => {
|
||||
const groups = mkGroups({ roomId: '!r1', items: [tsItem('$1', 100)] });
|
||||
assert.equal(filterGroupsByDateRange(groups, undefined, undefined), groups);
|
||||
});
|
||||
|
||||
test('filterGroupsByDateRange: keeps only items within an inclusive range', () => {
|
||||
const groups = mkGroups({
|
||||
roomId: '!r1',
|
||||
items: [tsItem('$1', 50), tsItem('$2', 100), tsItem('$3', 150), tsItem('$4', 200)],
|
||||
});
|
||||
const out = filterGroupsByDateRange(groups, 100, 150);
|
||||
assert.deepEqual(
|
||||
out[0].items.map((i) => i.event.event_id),
|
||||
['$2', '$3'],
|
||||
);
|
||||
});
|
||||
|
||||
test('filterGroupsByDateRange: drops groups left empty and supports one-sided bounds', () => {
|
||||
const groups = mkGroups(
|
||||
{ roomId: '!r1', items: [tsItem('$1', 50)] },
|
||||
{ roomId: '!r2', items: [tsItem('$2', 500)] },
|
||||
);
|
||||
const out = filterGroupsByDateRange(groups, 100, undefined);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].roomId, '!r2');
|
||||
});
|
||||
|
||||
test('filterGroupsByPinned: disabled returns groups unchanged', () => {
|
||||
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
|
||||
assert.equal(
|
||||
|
||||
@@ -71,6 +71,31 @@ export const filterGroupsByPinned = (
|
||||
.filter((group) => group.items.length > 0);
|
||||
};
|
||||
|
||||
/** Inclusive-range predicate, mirrored from `inRange` in useLocalMessageSearch.ts. */
|
||||
export const inTsRange = (ts: number, fromTs?: number, toTs?: number): boolean =>
|
||||
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
|
||||
|
||||
/**
|
||||
* Filter result groups to items whose `origin_server_ts` falls within
|
||||
* [fromTs, toTs] (inclusive, either bound optional). The Matrix search API
|
||||
* has no timestamp filter fields, so server results must be post-filtered
|
||||
* here — the same predicate the local/encrypted search already applies.
|
||||
* Now-empty groups are dropped.
|
||||
*/
|
||||
export const filterGroupsByDateRange = (
|
||||
groups: ResultGroup[],
|
||||
fromTs?: number,
|
||||
toTs?: number,
|
||||
): ResultGroup[] => {
|
||||
if (fromTs === undefined && toTs === undefined) return groups;
|
||||
return groups
|
||||
.map((group) => ({
|
||||
...group,
|
||||
items: group.items.filter((item) => inTsRange(item.event.origin_server_ts, fromTs, toTs)),
|
||||
}))
|
||||
.filter((group) => group.items.length > 0);
|
||||
};
|
||||
|
||||
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
|
||||
const groups: ResultGroup[] = [];
|
||||
|
||||
@@ -119,7 +144,9 @@ export type MessageSearchParams = {
|
||||
};
|
||||
export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
const mx = useMatrixClient();
|
||||
const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
|
||||
// fromTs/toTs are intentionally not sent to the server (see comment below) —
|
||||
// callers post-filter results with filterGroupsByDateRange instead.
|
||||
const { term, order, rooms, senders, containsUrl } = params;
|
||||
|
||||
const searchMessages = useCallback(
|
||||
async (nextBatch?: string) => {
|
||||
@@ -142,10 +169,12 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
limit,
|
||||
rooms,
|
||||
senders,
|
||||
// from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
|
||||
...(fromTs !== undefined && { from_ts: fromTs }),
|
||||
...(toTs !== undefined && { to_ts: toTs }),
|
||||
// `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not
|
||||
// Matrix filter fields and the homeserver silently drops them, so the
|
||||
// date range is instead enforced client-side (see filterGroupsByDateRange).
|
||||
// contains_url is a valid spec field not yet in SDK types.
|
||||
...(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,
|
||||
@@ -160,7 +189,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
|
||||
});
|
||||
return parseSearchResult(r);
|
||||
},
|
||||
[mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
|
||||
[mx, term, order, rooms, senders, containsUrl],
|
||||
);
|
||||
|
||||
return searchMessages;
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -42,7 +42,6 @@ import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../componen
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
||||
import { setAccountData } from '../../utils/accountData';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
@@ -66,14 +65,16 @@ 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';
|
||||
import {
|
||||
LOCAL_ROOM_NAMES_KEY,
|
||||
getLocalRoomNamesContent,
|
||||
setLocalRoomName,
|
||||
useHasLocalRoomName,
|
||||
useLocalRoomName,
|
||||
} from '../../hooks/useRoomMeta';
|
||||
@@ -136,22 +137,16 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
||||
const handleSave = useCallback(() => {
|
||||
const newName = inputRef.current?.value.trim() ?? '';
|
||||
if (newName.length > 255) return;
|
||||
const existing = getLocalRoomNamesContent(mx);
|
||||
if (newName === '') {
|
||||
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
} else {
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
|
||||
rooms: { ...existing.rooms, [room.roomId]: newName },
|
||||
});
|
||||
}
|
||||
// Routed through the shared write queue (setLocalRoomName) instead of a
|
||||
// read-modify-write against the SDK's local cache, which stays stale
|
||||
// until the /sync echo lands and would otherwise let a second rename
|
||||
// clobber a still-in-flight first rename.
|
||||
setLocalRoomName(mx, room.roomId, newName);
|
||||
onClose();
|
||||
}, [mx, room.roomId, onClose]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
const existing = getLocalRoomNamesContent(mx);
|
||||
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
setLocalRoomName(mx, room.roomId, '');
|
||||
onClose();
|
||||
}, [mx, room.roomId, onClose]);
|
||||
|
||||
@@ -273,49 +268,6 @@ 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;
|
||||
@@ -392,8 +344,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
|
||||
const handleMuteFor = useCallback(
|
||||
async (durationMs: number | null) => {
|
||||
const { setRoomNotificationPreference } =
|
||||
await import('../../hooks/useRoomsNotificationPreferences');
|
||||
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
|
||||
await setRoomNotificationPreference(
|
||||
mx,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
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));
|
||||
}
|
||||
@@ -24,6 +24,14 @@ const POLICY_USER_EVENT = 'm.policy.rule.user';
|
||||
const POLICY_ROOM_EVENT = 'm.policy.rule.room';
|
||||
const POLICY_SERVER_EVENT = 'm.policy.rule.server';
|
||||
|
||||
// Legacy, unstable-prefixed event types still emitted by Draupnir/Mjolnir
|
||||
// policy lists that predate MSC stabilization (or haven't migrated). Queried
|
||||
// alongside the stable types and merged/de-duped so those lists don't show
|
||||
// as falsely empty.
|
||||
const LEGACY_POLICY_USER_EVENT = 'org.matrix.mjolnir.rule.user';
|
||||
const LEGACY_POLICY_ROOM_EVENT = 'org.matrix.mjolnir.rule.room';
|
||||
const LEGACY_POLICY_SERVER_EVENT = 'org.matrix.mjolnir.rule.server';
|
||||
|
||||
type PolicyRuleContent = {
|
||||
entity?: string;
|
||||
reason?: string;
|
||||
@@ -76,6 +84,23 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
|
||||
.filter((entry) => entry.entity !== '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge policy entries from the stable and legacy event types for a rule
|
||||
* kind, de-duplicating by entity+recommendation so a room that emits both a
|
||||
* stable and a legacy rule for the same target isn't double-listed.
|
||||
*/
|
||||
export function dedupePolicyEntries(entries: PolicyEntry[]): PolicyEntry[] {
|
||||
const seen = new Set<string>();
|
||||
const result: PolicyEntry[] = [];
|
||||
entries.forEach((entry) => {
|
||||
const key = `${entry.entity} ${entry.recommendation}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
result.push(entry);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Entry row ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
|
||||
@@ -201,9 +226,24 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
|
||||
setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
|
||||
setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)));
|
||||
setUserEntries(
|
||||
dedupePolicyEntries([
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)),
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_USER_EVENT)),
|
||||
]),
|
||||
);
|
||||
setRoomEntries(
|
||||
dedupePolicyEntries([
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)),
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_ROOM_EVENT)),
|
||||
]),
|
||||
);
|
||||
setServerEntries(
|
||||
dedupePolicyEntries([
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_EVENT)),
|
||||
...extractPolicyEntries(getRoomPolicyEvents(room, LEGACY_POLICY_SERVER_EVENT)),
|
||||
]),
|
||||
);
|
||||
setLoadedRoomId(roomId);
|
||||
setError(undefined);
|
||||
}, [mx, roomIdInput]);
|
||||
|
||||
@@ -341,6 +341,7 @@ 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' }}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Box, Text, TooltipProvider, Tooltip, Icon, Icons, IconButton, toRem } from 'folds';
|
||||
import { Page, PageHeader } from '../../components/page';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
|
||||
@@ -25,10 +25,12 @@ import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
interface PollCreatorProps {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
/** Set when the composer is inside a thread so the poll lands in that thread. */
|
||||
threadRootId?: string;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps) {
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(440);
|
||||
const [question, setQuestion] = useState('');
|
||||
@@ -85,7 +87,9 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
|
||||
'\n',
|
||||
);
|
||||
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||
// Pass the thread id explicitly (like the sticker path in RoomInput); the
|
||||
// legacy 3-arg form always resolves to the main timeline.
|
||||
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
|
||||
'm.poll': {
|
||||
question: { 'm.text': trimmedQuestion },
|
||||
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Line } from 'folds';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { RoomView } from './RoomView';
|
||||
|
||||
@@ -105,6 +105,7 @@ import {
|
||||
settingsAtom,
|
||||
} from '../../state/settings';
|
||||
import {
|
||||
buildCompressedUploadItem,
|
||||
getAudioMsgContent,
|
||||
getFileMsgContent,
|
||||
getImageMsgContent,
|
||||
@@ -244,8 +245,11 @@ 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).
|
||||
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
|
||||
// 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;
|
||||
const composerButtonOrder = useMemo(
|
||||
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
|
||||
[composerToolbarButtons?.order],
|
||||
@@ -394,27 +398,47 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
try {
|
||||
const stored = localStorage.getItem(`draft-msg-${draftKey}`);
|
||||
if (stored) {
|
||||
const nodes = JSON.parse(stored);
|
||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||
Transforms.insertFragment(editor, nodes);
|
||||
// Mirror the restored draft into the atom so the draft indicator
|
||||
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||
// after a page reload — not only on same-session room re-entry.
|
||||
setMsgDraft(nodes);
|
||||
const parsed = JSON.parse(stored);
|
||||
// [Gitea #41] Only restore a draft this same account wrote. A legacy
|
||||
// draft (stored as a bare array, pre-dating user-scoping) or one
|
||||
// written by a different userId is foreign — drop it rather than
|
||||
// risk pre-filling another account's unsent text into the composer.
|
||||
const foreign =
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
parsed.userId !== mx.getUserId();
|
||||
if (foreign) {
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
} else {
|
||||
const nodes = parsed.nodes;
|
||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||
Transforms.insertFragment(editor, nodes);
|
||||
// Mirror the restored draft into the atom so the draft indicator
|
||||
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||
// after a page reload — not only on same-session room re-entry.
|
||||
setMsgDraft(nodes);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed stored draft
|
||||
}
|
||||
}
|
||||
}, [editor, msgDraft, draftKey, setMsgDraft]);
|
||||
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (!isEmptyEditor(editor)) {
|
||||
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
|
||||
setMsgDraft(parsedDraft);
|
||||
localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
|
||||
// [Gitea #41] Tag the persisted draft with the writing user's id so a
|
||||
// different account logging into this browser can't have it hydrated
|
||||
// into their composer (see useHydrateMsgDrafts / clearPlaintextCaches).
|
||||
localStorage.setItem(
|
||||
`draft-msg-${draftKey}`,
|
||||
JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }),
|
||||
);
|
||||
} else {
|
||||
setMsgDraft([]);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
@@ -422,7 +446,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
},
|
||||
[draftKey, editor, setMsgDraft],
|
||||
[draftKey, editor, setMsgDraft, mx],
|
||||
);
|
||||
|
||||
const handleFileMetadata = useCallback(
|
||||
@@ -485,22 +509,29 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const compressedFile = new File([compressionResult.blob], compressedName, {
|
||||
type: compressedType,
|
||||
});
|
||||
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||
name: 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 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;
|
||||
// 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,
|
||||
};
|
||||
// Synthetic fileItem referring to the compressed file so
|
||||
// getImageMsgContent picks up the correct dimensions, type and encInfo.
|
||||
const compressedItem = buildCompressedUploadItem(
|
||||
fileItem,
|
||||
compressedFile,
|
||||
encrypted,
|
||||
);
|
||||
return getImageMsgContent(mx, compressedItem, mxc);
|
||||
}
|
||||
}
|
||||
@@ -697,11 +728,14 @@ 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]);
|
||||
}, [buildCurrentTextContent, isEncrypted]);
|
||||
|
||||
const handleScheduled = useCallback(
|
||||
(delayId: string, sendAt: number, content: IContent) => {
|
||||
@@ -823,18 +857,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
return;
|
||||
}
|
||||
|
||||
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, {
|
||||
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
|
||||
const baseContent = {
|
||||
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;
|
||||
@@ -844,7 +898,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
if (alive()) setGifUploading(false);
|
||||
}
|
||||
},
|
||||
[mx, roomId, threadRootId, alive],
|
||||
[mx, room, roomId, threadRootId, alive],
|
||||
);
|
||||
|
||||
const handleStickerSelect = useCallback(
|
||||
@@ -1446,7 +1500,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||
{pollOpen && (
|
||||
<PollCreator
|
||||
room={room}
|
||||
roomId={roomId}
|
||||
threadRootId={threadRootId}
|
||||
onClose={() => setPollOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{scheduleOpen && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
Button,
|
||||
} from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { PageHeader } from '../../components/page';
|
||||
|
||||
@@ -318,6 +318,7 @@ 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.
|
||||
@@ -326,6 +327,7 @@ 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,6 +1390,7 @@ 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();
|
||||
}}
|
||||
@@ -1408,6 +1409,7 @@ 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,6 +187,7 @@ 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);
|
||||
}
|
||||
|
||||
@@ -235,6 +236,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}, [
|
||||
mx,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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.
|
||||
@@ -1,5 +1,6 @@
|
||||
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,
|
||||
@@ -43,6 +44,28 @@ 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,
|
||||
|
||||
@@ -116,6 +116,8 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
const editor = useEditor();
|
||||
const thread = useThreadInstance(room, threadId);
|
||||
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
|
||||
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
|
||||
useKeyDown(
|
||||
@@ -157,7 +159,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
}
|
||||
if (!latestId || latestId === lastReadEventIdRef.current) return;
|
||||
lastReadEventIdRef.current = latestId;
|
||||
markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
|
||||
markThreadAsRead(mx, thread, hideActivity || privateReadReceipts).catch(() => {
|
||||
// Allow a retry on the next event if the receipt POST failed.
|
||||
if (lastReadEventIdRef.current === latestId) {
|
||||
lastReadEventIdRef.current = undefined;
|
||||
@@ -171,7 +173,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
thread.off(ThreadEvent.NewReply, markRead);
|
||||
thread.off(RoomEvent.Timeline, markRead);
|
||||
};
|
||||
}, [mx, thread, privateReadReceipts]);
|
||||
}, [mx, thread, privateReadReceipts, hideActivity]);
|
||||
|
||||
return (
|
||||
<Box
|
||||
|
||||
@@ -545,9 +545,19 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
[room, thread, setReplyDraft, editor],
|
||||
);
|
||||
|
||||
// Non-thread relations (reactions, edits) that target the thread root live only in
|
||||
// the room's main timeline set (matrix-js-sdk Room.eventShouldLiveIn), so lookups
|
||||
// for the root must use the room set instead of the thread set.
|
||||
const getRelationTimelineSet = useCallback(
|
||||
(eventId: string) =>
|
||||
eventId === thread.id ? room.getUnfilteredTimelineSet() : thread.getUnfilteredTimelineSet(),
|
||||
[room, thread],
|
||||
);
|
||||
|
||||
const handleReactionToggle = useCallback(
|
||||
(targetEventId: string, key: string, shortcode?: string) => {
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const isRoot = targetEventId === thread.id;
|
||||
const timelineSet = getRelationTimelineSet(targetEventId);
|
||||
const relations = getEventReactions(timelineSet, targetEventId);
|
||||
const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
|
||||
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
|
||||
@@ -563,12 +573,14 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
|
||||
mx.sendEvent(
|
||||
room.roomId,
|
||||
thread.id,
|
||||
// A reaction on the root is a main-timeline event, not a thread reply.
|
||||
isRoot ? null : thread.id,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
MessageEvent.Reaction as any,
|
||||
getReactionContent(targetEventId, key, rShortcode),
|
||||
);
|
||||
},
|
||||
[mx, room, thread],
|
||||
[mx, room, thread, getRelationTimelineSet],
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
@@ -714,7 +726,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
): ReactNode => {
|
||||
const mEventId = mEvent.getId();
|
||||
if (!mEventId) return null;
|
||||
const timelineSet = thread.getUnfilteredTimelineSet();
|
||||
const timelineSet = getRelationTimelineSet(mEventId);
|
||||
const reactionRelations = getEventReactions(timelineSet, mEventId);
|
||||
const reactions = reactionRelations?.getSortedAnnotationsByKey();
|
||||
const hasReactions = !!reactions && reactions.length > 0;
|
||||
@@ -782,7 +794,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
);
|
||||
},
|
||||
[
|
||||
thread,
|
||||
room,
|
||||
messageSpacing,
|
||||
messageLayout,
|
||||
@@ -809,6 +820,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
lotusTerminal,
|
||||
mx,
|
||||
renderMessageContent,
|
||||
getRelationTimelineSet,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ 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,6 +84,7 @@ 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) {
|
||||
@@ -95,6 +96,7 @@ 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);
|
||||
};
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ import { playCallJoinSound } from '../../../utils/callSounds';
|
||||
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
|
||||
import { DenoiseTester } from './DenoiseTester';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
import { isBindableCallKey } from '../../../utils/callKeybind';
|
||||
|
||||
/**
|
||||
* P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
|
||||
@@ -1474,8 +1475,12 @@ function Privacy() {
|
||||
);
|
||||
}
|
||||
|
||||
function useKeyBind(setter: (code: string) => void) {
|
||||
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that
|
||||
// collides with the other call key (`otherKey`), so a rebind can never trap
|
||||
// keyboard focus in-call or silently double-bind PTT and deafen to the same key.
|
||||
function useKeyBind(setter: (code: string) => void, otherKey?: string) {
|
||||
const [listening, setListening] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
|
||||
|
||||
useEffect(
|
||||
@@ -1487,19 +1492,28 @@ function useKeyBind(setter: (code: string) => void) {
|
||||
|
||||
const startListening = useCallback(() => {
|
||||
if (listening) return;
|
||||
setError(null);
|
||||
setListening(true);
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
e.preventDefault();
|
||||
if (e.code !== 'Escape') setter(e.code);
|
||||
if (e.code === 'Escape') {
|
||||
// Escape always cancels the rebind without changing the key.
|
||||
} else if (!isBindableCallKey(e.code)) {
|
||||
setError('That key can’t be bound — it’s needed for keyboard navigation.');
|
||||
} else if (otherKey && e.code === otherKey) {
|
||||
setError('That key is already bound to the other call shortcut.');
|
||||
} else {
|
||||
setter(e.code);
|
||||
}
|
||||
setListening(false);
|
||||
window.removeEventListener('keydown', onKey, true);
|
||||
listenerRef.current = null;
|
||||
};
|
||||
listenerRef.current = onKey;
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
}, [listening, setter]);
|
||||
}, [listening, setter, otherKey]);
|
||||
|
||||
return { listening, startListening };
|
||||
return { listening, startListening, error };
|
||||
}
|
||||
|
||||
const keyLabel = (code: string) =>
|
||||
@@ -1556,8 +1570,8 @@ function Calls() {
|
||||
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
|
||||
};
|
||||
|
||||
const pttBind = useKeyBind(setPttKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey);
|
||||
const pttBind = useKeyBind(setPttKey, deafenKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey, pttKey);
|
||||
|
||||
const mlSupported = isMLDenoiseSupported();
|
||||
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
|
||||
@@ -1823,7 +1837,7 @@ function Calls() {
|
||||
{pttMode && (
|
||||
<SettingTile
|
||||
title="PTT Key"
|
||||
description="Press a key to bind it as your push-to-talk key."
|
||||
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'}
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
@@ -1841,7 +1855,9 @@ function Calls() {
|
||||
)}
|
||||
<SettingTile
|
||||
title="Push to Deafen"
|
||||
description="Toggle speaker mute during a call. Press Escape to cancel rebind."
|
||||
description={
|
||||
deafenBind.error ?? 'Toggle speaker mute during a call. Press Escape to cancel rebind.'
|
||||
}
|
||||
after={
|
||||
<Button
|
||||
size="300"
|
||||
|
||||
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import {
|
||||
getNotificationModeActions,
|
||||
getNotificationModeOptionsFromActions,
|
||||
NotificationMode,
|
||||
useNotificationModeActions,
|
||||
} from '../../../hooks/useNotificationMode';
|
||||
@@ -131,7 +132,13 @@ type RuleModeSwitcherProps = {
|
||||
|
||||
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
|
||||
const mx = useMatrixClient();
|
||||
const getModeActions = useNotificationModeActions();
|
||||
// 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 handleChange = useCallback(
|
||||
async (mode: NotificationMode) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMatch } from 'react-router';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import { getCreatePath } from '../../pages/pathUtils';
|
||||
|
||||
export const useCreateSelected = (): boolean => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMatch } from 'react-router';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import { getDirectCreatePath, getDirectPath } from '../../pages/pathUtils';
|
||||
|
||||
export const useDirectSelected = (): boolean => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMatch, useParams } from 'react-router';
|
||||
import { useMatch, useParams } from 'react-router-dom';
|
||||
import { getExploreFeaturedPath, getExplorePath } from '../../pages/pathUtils';
|
||||
|
||||
export const useExploreSelected = (): boolean => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMatch } from 'react-router';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import {
|
||||
getHomeCreatePath,
|
||||
getHomeJoinPath,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMatch } from 'react-router';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import {
|
||||
getInboxInvitesPath,
|
||||
getInboxNotificationsPath,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { getRoomSearchParams } from '../../pages/pathSearchParam';
|
||||
import { decodeSearchParamValueArray } from '../../pages/pathUtils';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../useMatrixClient';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMatch, useParams } from 'react-router';
|
||||
import { useMatch, useParams } from 'react-router-dom';
|
||||
import { getCanonicalAliasRoomId, isRoomAlias } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../useMatrixClient';
|
||||
import { getSpaceLobbyPath, getSpaceSearchPath } from '../../pages/pathUtils';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MatrixError, Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { isValidDecorationSlug } from '../features/lotus/avatarDecorations';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
|
||||
@@ -51,7 +52,10 @@ function fetchDecoration(
|
||||
// all fields (incl. custom MSC4133 ones); read the decoration out of it.
|
||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
const rawVal = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
// The remote profile field is free-form and attacker-controlled; only
|
||||
// accept it when it names a real catalog decoration (see decorationUrl).
|
||||
const val = rawVal && isValidDecorationSlug(rawVal) ? rawVal : null;
|
||||
cache.set(userId, val);
|
||||
return val;
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useRoomNavigate } from './useRoomNavigate';
|
||||
import { isRoomId } from '../utils/matrix';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { Descendant } from 'slate';
|
||||
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
|
||||
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
/**
|
||||
* On startup, pre-fill the per-room message-draft atoms from their localStorage
|
||||
@@ -13,11 +13,19 @@ import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
|
||||
* (identical content), so composer restore is unaffected.
|
||||
*
|
||||
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
|
||||
*
|
||||
* [Gitea #41] Drafts are stored as `{ userId, nodes }` (RoomInput's persist
|
||||
* path) so a draft written by a different account never gets hydrated into the
|
||||
* currently logged-in user's session. A legacy draft (stored as a bare array,
|
||||
* pre-dating user-scoping) has no userId to check, so it's treated as foreign
|
||||
* and dropped rather than trusted.
|
||||
*/
|
||||
export function useHydrateMsgDrafts(): void {
|
||||
const store = useStore();
|
||||
const mx = useMatrixClient();
|
||||
|
||||
useEffect(() => {
|
||||
const userId = mx.getUserId();
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(localStorage);
|
||||
@@ -34,7 +42,19 @@ export function useHydrateMsgDrafts(): void {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return;
|
||||
const nodes = JSON.parse(stored) as Descendant[];
|
||||
const parsed = JSON.parse(stored);
|
||||
const foreign =
|
||||
!parsed ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed) ||
|
||||
parsed.userId !== userId;
|
||||
if (foreign) {
|
||||
// Another account's (or a pre-scoping legacy) draft — never hydrate it,
|
||||
// and drop it so it can't resurface for the next login either.
|
||||
localStorage.removeItem(key);
|
||||
return;
|
||||
}
|
||||
const nodes = parsed.nodes;
|
||||
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
|
||||
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
|
||||
}
|
||||
@@ -42,5 +62,5 @@ export function useHydrateMsgDrafts(): void {
|
||||
// Ignore a malformed stored draft.
|
||||
}
|
||||
});
|
||||
}, [store]);
|
||||
}, [store, mx]);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactEventHandler, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useRoomNavigate } from './useRoomNavigate';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { isRoomId, isUserId } from '../utils/matrix';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useNavToActivePathAtom } from '../state/hooks/navToActivePath';
|
||||
|
||||
export const useNavToActivePathMapper = (navId: string) => {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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 });
|
||||
});
|
||||
@@ -49,6 +49,29 @@ 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,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import { getLocalRoomNamesContent, setLocalRoomName } from './useRoomMeta';
|
||||
|
||||
// Minimal fake client. Mirrors the real SDK behavior that matters here:
|
||||
// setAccountData resolves WITHOUT updating what getAccountData returns — the
|
||||
// local cache only updates once the /sync echo is delivered via the
|
||||
// AccountData listener. This is exactly the staleness that let two
|
||||
// back-to-back renames clobber each other before the fix (issue #17).
|
||||
const makeFakeMx = () => {
|
||||
const accountData: Record<string, unknown> = {};
|
||||
const listeners: Array<(e: { getType: () => string; getContent: () => unknown }) => void> = [];
|
||||
const setAccountDataCalls: Array<{ type: string; content: unknown }> = [];
|
||||
|
||||
const mx = {
|
||||
getAccountData: (type: string) => {
|
||||
const content = accountData[type];
|
||||
return content ? { getContent: () => content } : undefined;
|
||||
},
|
||||
setAccountData: (type: string, content: unknown) => {
|
||||
setAccountDataCalls.push({ type, content });
|
||||
// Deliberately do NOT update `accountData` here — the real SDK doesn't
|
||||
// either. It only updates on the emitted echo below.
|
||||
return Promise.resolve();
|
||||
},
|
||||
on: (_event: unknown, h: (e: { getType: () => string; getContent: () => unknown }) => void) => {
|
||||
listeners.push(h);
|
||||
},
|
||||
removeListener: (
|
||||
_event: unknown,
|
||||
h: (e: { getType: () => string; getContent: () => unknown }) => void,
|
||||
) => {
|
||||
const i = listeners.indexOf(h);
|
||||
if (i >= 0) listeners.splice(i, 1);
|
||||
},
|
||||
};
|
||||
|
||||
const emitEcho = (type: string, content: unknown) => {
|
||||
accountData[type] = content;
|
||||
listeners.forEach((h) => h({ getType: () => type, getContent: () => content }));
|
||||
};
|
||||
|
||||
return {
|
||||
mx: mx as unknown as MatrixClient,
|
||||
emitEcho,
|
||||
setAccountDataCalls,
|
||||
};
|
||||
};
|
||||
|
||||
test('back-to-back renames of different rooms both survive with no echo in between', async () => {
|
||||
const { mx } = makeFakeMx();
|
||||
|
||||
// Rename room A, then room B, before either write's /sync echo has landed —
|
||||
// the exact scenario from issue #17.
|
||||
const writeA = setLocalRoomName(mx, '!a:example.org', 'Room A renamed');
|
||||
const writeB = setLocalRoomName(mx, '!b:example.org', 'Room B renamed');
|
||||
await Promise.all([writeA, writeB]);
|
||||
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
assert.deepEqual(content.rooms, {
|
||||
'!a:example.org': 'Room A renamed',
|
||||
'!b:example.org': 'Room B renamed',
|
||||
});
|
||||
});
|
||||
|
||||
test("writes are serialized: the second write computes from the first write's result", async () => {
|
||||
const { mx, setAccountDataCalls } = makeFakeMx();
|
||||
|
||||
await Promise.all([
|
||||
setLocalRoomName(mx, '!a:example.org', 'A'),
|
||||
setLocalRoomName(mx, '!b:example.org', 'B'),
|
||||
]);
|
||||
|
||||
// The last PUT to the server must carry both renames — proof the second
|
||||
// write's compute() saw the first write's in-memory result rather than a
|
||||
// stale snapshot from before it landed.
|
||||
const lastCall = setAccountDataCalls[setAccountDataCalls.length - 1];
|
||||
assert.deepEqual(lastCall.content, {
|
||||
rooms: { '!a:example.org': 'A', '!b:example.org': 'B' },
|
||||
});
|
||||
});
|
||||
|
||||
test('clearing a local name removes only that room', async () => {
|
||||
const { mx } = makeFakeMx();
|
||||
|
||||
await setLocalRoomName(mx, '!a:example.org', 'A');
|
||||
await setLocalRoomName(mx, '!b:example.org', 'B');
|
||||
await setLocalRoomName(mx, '!a:example.org', '');
|
||||
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
assert.deepEqual(content.rooms, { '!b:example.org': 'B' });
|
||||
});
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { useStateEvent } from './useStateEvent';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { getAccountData } from '../utils/accountData';
|
||||
import { createAccountDataListStore } from './createAccountDataListStore';
|
||||
|
||||
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
|
||||
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
|
||||
@@ -40,73 +40,72 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
|
||||
|
||||
export type LocalRoomNamesContent = { rooms: Record<string, string> };
|
||||
|
||||
type LocalRoomNamesMap = Record<string, string>;
|
||||
|
||||
// Shared, concurrency-safe store. See createAccountDataListStore for why the
|
||||
// snapshot + write queue must be module-scoped: setAccountData does not update
|
||||
// the SDK's local cache (it only resolves once the /sync echo lands), so a
|
||||
// plain read-modify-write against getAccountData can lose a rename that is
|
||||
// still in flight when a second rename is issued (fixed: back-to-back renames
|
||||
// of different rooms no longer clobber each other).
|
||||
const roomNamesStore = createAccountDataListStore<LocalRoomNamesMap, LocalRoomNamesContent>({
|
||||
eventType: LOCAL_ROOM_NAMES_KEY,
|
||||
read: (content) =>
|
||||
content && typeof content === 'object' && typeof content.rooms === 'object'
|
||||
? content.rooms
|
||||
: {},
|
||||
write: (rooms) => ({ rooms }),
|
||||
});
|
||||
|
||||
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 && typeof (raw as any).rooms === 'object') {
|
||||
return raw as LocalRoomNamesContent;
|
||||
}
|
||||
return { rooms: {} };
|
||||
return { rooms: roomNamesStore.getLatest(mx) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Set (or clear, when `name` is empty) the local display name for a room.
|
||||
* Routed through the shared write queue so back-to-back renames of different
|
||||
* rooms are always computed from the latest snapshot instead of a stale one.
|
||||
*/
|
||||
export function setLocalRoomName(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomId: string,
|
||||
name: string,
|
||||
): Promise<void> {
|
||||
return roomNamesStore.enqueueWrite(mx, (current) => {
|
||||
if (!name) {
|
||||
const { [roomId]: _removed, ...rest } = current;
|
||||
return rest;
|
||||
}
|
||||
return { ...current, [roomId]: name };
|
||||
});
|
||||
}
|
||||
|
||||
export const useLocalRoomName = (room: Room): string => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const getLocalName = useCallback((): string => {
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
return content.rooms[room.roomId] ?? room.name;
|
||||
}, [mx, room]);
|
||||
|
||||
const [name, setName] = useState(getLocalName);
|
||||
const localNames = roomNamesStore.useValue(mx);
|
||||
const [name, setName] = useState(room.name);
|
||||
|
||||
useEffect(() => {
|
||||
setName(getLocalName());
|
||||
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
|
||||
setName(getLocalName());
|
||||
};
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
setName(room.name);
|
||||
|
||||
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
|
||||
setName(getLocalName());
|
||||
setName(room.name);
|
||||
};
|
||||
room.on(RoomEvent.Name, handleRoomNameChange);
|
||||
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||
room.removeListener(RoomEvent.Name, handleRoomNameChange);
|
||||
};
|
||||
}, [mx, room, getLocalName]);
|
||||
}, [room]);
|
||||
|
||||
return name;
|
||||
return localNames[room.roomId] ?? name;
|
||||
};
|
||||
|
||||
export const useHasLocalRoomName = (roomId: string): boolean => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const check = useCallback((): boolean => {
|
||||
const content = getLocalRoomNamesContent(mx);
|
||||
return !!content.rooms[roomId];
|
||||
}, [mx, roomId]);
|
||||
|
||||
const [hasLocal, setHasLocal] = useState(check);
|
||||
|
||||
useEffect(() => {
|
||||
setHasLocal(check());
|
||||
|
||||
const handleAccountData = (event: MatrixEvent) => {
|
||||
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
|
||||
setHasLocal(check());
|
||||
};
|
||||
mx.on(ClientEvent.AccountData, handleAccountData);
|
||||
return () => {
|
||||
mx.removeListener(ClientEvent.AccountData, handleAccountData);
|
||||
};
|
||||
}, [mx, check]);
|
||||
|
||||
return hasLocal;
|
||||
const localNames = roomNamesStore.useValue(mx);
|
||||
return !!localNames[roomId];
|
||||
};
|
||||
|
||||
export type RoomTopicContent = {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback } from 'react';
|
||||
import { NavigateOptions, useNavigate } from 'react-router';
|
||||
import { NavigateOptions, useNavigate } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { getCanonicalAliasOrRoomId } from '../utils/matrix';
|
||||
import {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useTauriEvent } from './useTauri';
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
toRem,
|
||||
TooltipContainerProvider,
|
||||
} from 'folds';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { useMatch } from 'react-router';
|
||||
import { useMatch } from 'react-router-dom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { DIRECT_PATH, EXPLORE_PATH, HOME_PATH, INBOX_PATH, SPACE_PATH } from './paths';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useRouteError, isRouteErrorResponse } from 'react-router';
|
||||
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
|
||||
import { Box, Button, config, Text, toRem } from 'folds';
|
||||
|
||||
export function RouteError() {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
createHashRouter,
|
||||
createRoutesFromElements,
|
||||
redirect,
|
||||
} from 'react-router';
|
||||
} from 'react-router-dom';
|
||||
import { RoomSkeleton } from '../components/RoomSkeleton';
|
||||
import { LobbySkeleton } from '../components/LobbySkeleton';
|
||||
import { AuthSkeleton } from '../components/AuthSkeleton';
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { Box, Header, Scroll, Spinner, Text, color } from 'folds';
|
||||
import { Outlet, generatePath, matchPath, useLocation, useNavigate, useParams } from 'react-router';
|
||||
import {
|
||||
Outlet,
|
||||
generatePath,
|
||||
matchPath,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { AuthFooter } from './AuthFooter';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, color } from 'folds';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { SSOAction } from 'matrix-js-sdk';
|
||||
import { useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Link } from 'react-router';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||
import { EMAIL_REGEX } from '../../../utils/regex';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import to from 'await-to-js';
|
||||
import { LoginRequest, LoginResponse, MatrixError, createClient } from 'matrix-js-sdk';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ClientConfig, clientAllowedServer } from '../../../hooks/useClientConfig';
|
||||
import { autoDiscovery, specVersions } from '../../../cs-api';
|
||||
import { ErrorCode } from '../../../cs-errorcode';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text, color } from 'folds';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { SSOAction } from 'matrix-js-sdk';
|
||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||
import { RegisterFlowStatus, useAuthFlows } from '../../../hooks/useAuthFlows';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
RegisterResponse,
|
||||
} from 'matrix-js-sdk';
|
||||
import { useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { LoginPathSearchParams } from '../../paths';
|
||||
import { ErrorCode } from '../../../cs-errorcode';
|
||||
import {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { AuthDict, AuthType, MatrixError, createClient } from 'matrix-js-sdk';
|
||||
import { useAutoDiscoveryInfo } from '../../../hooks/useAutoDiscoveryInfo';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Box, Text } from 'folds';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { getLoginPath } from '../../pathUtils';
|
||||
import { useAuthServer } from '../../../hooks/useAuthServer';
|
||||
import { PasswordResetForm } from './PasswordResetForm';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import React, { ReactNode, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ClientEvent,
|
||||
ClientEventHandlerMap,
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
MuteTimerEntry,
|
||||
loadMuteTimers,
|
||||
unmuteRoom,
|
||||
} from '../../features/room-nav/RoomNavItem';
|
||||
} from '../../features/room-nav/muteTimers';
|
||||
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() {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from 'folds';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { factoryRoomIdByActivity } from '../../../utils/sort';
|
||||
import {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { Box, Icon, IconButton, Icons, Scroll } from 'folds';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getDirectCreateSearchParams } from '../../pathSearchParam';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { FormEventHandler, useCallback, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Avatar,
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
@@ -6,7 +6,7 @@ import React, {
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { useParams } from 'react-router';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSelectedRoom } from '../../../hooks/router/useSelectedRoom';
|
||||
import { IsDirectRoomProvider, RoomProvider } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
INotification,
|
||||
INotificationsResponse,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import { Box, config, Icon, Icons, Menu, PopOut, RectCords, Text } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Box, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text, config, toRem } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Icon, Icons } from 'folds';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { SidebarAvatar, SidebarItem, SidebarItemTooltip } from '../../../components/sidebar';
|
||||
import { useExploreSelected } from '../../../hooks/router/useExploreSelected';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user