Compare commits

..
Author SHA1 Message Date
nathan 0aef410f60 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Successful in 10m45s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:01:05 -04:00
nathan b2376513fd fixed retarded linter problem
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:42:37 -04:00
nathan.vititoe 2bbd390a3b image path changes, for dev setup, needs testing on 'prod'
CI / Build & Quality Checks (pull_request) Failing after 6m0s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 16:35:11 -04:00
127 changed files with 866 additions and 4267 deletions
+41 -49
View File
@@ -30,13 +30,8 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: npm
# No npm / node_modules cache: the act_runner's internal cache server is
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
# node_modules step) once the runner's cache server is reachable from jobs.
- name: Install dependencies - name: Install dependencies
# Harden against transient registry network failures (ECONNRESET etc.): # Harden against transient registry network failures (ECONNRESET etc.):
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to # raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
@@ -57,59 +52,56 @@ jobs:
sleep $((attempt * 15)) sleep $((attempt * 15))
done done
# ── Quality gates run BEFORE the slow build so a format/lint/type/test # ── Critical gate — if this fails, nothing deploys ──────────────────
# 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, plus a warning ratchet (Gitea #97): `check:eslint`
# runs with `--max-warnings 74`, the exact warning count on this tree at
# the time the ratchet was added. New warnings push the count over that
# ceiling and fail the build; fixing an existing warning is free to do
# and should lower the ceiling in the same PR so the count can only go
# down over time, never back up.
- name: Prettier
run: npm run check:prettier
- name: ESLint
run: npm run check:eslint
- name: TypeScript
run: npm run typecheck
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
- name: Build - name: Build
run: npm run build run: npm run build
env: env:
NODE_OPTIONS: '--max_old_space_size=4096' NODE_OPTIONS: '--max_old_space_size=4096'
VITE_APP_VERSION: ${{ github.sha }} VITE_APP_VERSION: ${{ github.sha }}
# ── Boot check — actually loads the built dist/, not just builds it ── # Unit tests are a hard gate too — deterministic pure-logic tests on Node's
- name: Boot check # built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
run: node scripts/boot-check.mjs # A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Security — hard gate. #24 cleared the outstanding advisories (0 # ── Quality gates (hard — a failure fails the job and blocks deploy) ──
# vulnerabilities on this tree, verified with `npm audit --omit=dev`), so # The tree is held clean (typecheck 0, eslint 0 errors, prettier
# there is nothing left this should be soft against. Hard on both # formatted), so these gate real regressions instead of relying on local
# `push` and `pull_request`: a new high/critical advisory should block # runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
# the deploy just as much as it should block the PR. # introduce upstream type/lint/format issues; that path deploys without
# CI, but a subsequent normal push would surface the failure here — fix
# forward (or briefly re-soften a gate) rather than let it deploy broken.
# eslint gates on errors only (existing `no-explicit-any` warnings stay
# informational — `check:eslint` has no --max-warnings).
- name: TypeScript
run: npm run typecheck
- name: ESLint
run: npm run check:eslint
- name: Prettier
run: npm run check:prettier
# ── Security (informational — findings shouldn't block a deploy) ─────
- name: Audit (high/critical) - name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev run: npm audit --audit-level=high --omit=dev
continue-on-error: true
# ── Bundle size budget — hard gate on pull_request, warning on push (a # ── Bundle size report (informational — never blocks a deploy) ───────
# push has already merged; failing it can only delay deploying an - name: Report bundle sizes
# otherwise-good commit, not prevent the regression, so pull_request is continue-on-error: true
# where this should be caught). Budgets live in scripts/bundle-budget.json. run: |
- name: Check bundle size budget echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
continue-on-error: ${{ github.event_name == 'push' }} echo "" >> $GITHUB_STEP_SUMMARY
run: node scripts/check-bundle-size.mjs ${{ github.event_name }} echo "| File | Size | Gzip |" >> $GITHUB_STEP_SUMMARY
echo "|------|------|------|" >> $GITHUB_STEP_SUMMARY
find dist/assets -name "*.js" -not -name "*.map" | sort | while read f; do
name=$(basename "$f")
size=$(du -sh "$f" | cut -f1)
gzip_size=$(gzip -c "$f" | wc -c | awk '{printf "%.1f kB", $1/1024}')
echo "| $name | $size | $gzip_size |" >> $GITHUB_STEP_SUMMARY
done
# ── Desktop build trigger ────────────────────────────────────────────── # ── Desktop build trigger ──────────────────────────────────────────────
# Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or # Gated on `build` succeeding so a broken push (e.g. failing `npm ci` or
-27
View File
@@ -1,27 +0,0 @@
---
name: Bug Report
about: Report something that isn't working in Lotus Chat
title: ''
labels: bug
---
**Describe the bug**
A clear and concise description of what went wrong.
**Steps to reproduce**
1. Go to '...'
2. Click on '...'
3. See error
**Expected behavior**
What you expected to happen instead.
**Client info**
- Lotus Chat version (Settings → Help & About):
- Platform: Web / Desktop (Windows / macOS / Linux)
- Browser + version (if web):
**Screenshots / logs**
If applicable, add screenshots or the browser devtools console output.
+5 -1
View File
@@ -1 +1,5 @@
blank_issues_enabled: true blank_issues_enabled: false
contact_links:
- name: Features, Bug Reports, Questions
url: https://github.com/cinnyapp/cinny/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
-15
View File
@@ -1,15 +0,0 @@
---
name: Feature Request
about: Suggest an idea or improvement for Lotus Chat
title: ''
labels: enhancement
---
**What would you like?**
A clear and concise description of the feature or change.
**Why / use case**
What problem does it solve, or what does it make better?
**Alternatives considered**
Any workarounds or other approaches you've thought about.
+9
View File
@@ -0,0 +1,9 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
+3 -1
View File
@@ -1 +1,3 @@
npx lint-staged # These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
-1
View File
@@ -1 +0,0 @@
24.13.1
+9 -9
View File
@@ -154,13 +154,13 @@ A "Pause Background Animations" toggle is exposed in **Settings → Appearance**
### Animation Improvements (June 2026) ### Animation Improvements (June 2026)
All five animated backgrounds were rewritten for smoother, more organic motion. Each background drives its own single drift/scroll keyframe (no secondary glow or blink layers): All five animated backgrounds were rewritten for smoother, more organic motion:
- **Digital Rain** — column scroll keyframe; stripe opacity increased for better visibility - **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
- **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`−130 px`, `−190 px`, `−260 px`), eliminating the visible seam on loop - **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`−130 px`, `−190 px`, `−260 px`), eliminating the visible seam on loop
- **Grid Pulse** — size breathe keyframe (4 s) - **Grid Pulse** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
- **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth - **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth
- **Fireflies** — position drift keyframe with per-firefly duration/delay so motion stays unsynchronised - **Fireflies** — glow pulse (`animFirefliesGlowKeyframe`, 2.3 s `filter: brightness`) and opacity blink (`animFirefliesBlinkKeyframe`, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
### Files ### Files
@@ -176,7 +176,7 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes ### Themes
| Theme | Window | Effect | | Theme | Window | Effect |
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep | | 🎆 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 | | 🏮 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 | | 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
@@ -184,8 +184,8 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark | | 🃏 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 | | 🌱 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 | | 🍂 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 | | 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 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 | | 🚀 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 | | 🎃 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 | | ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
@@ -742,7 +742,7 @@ never leaves it.
### Message Search Date Range ### Message Search Date Range
- 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 - The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
- A chip shows the active date range with an **×** button to clear it - A chip shows the active date range with an **×** button to clear it
### Encrypted Search Cache (P4-8, opt-in) ### Encrypted Search Cache (P4-8, opt-in)
@@ -1205,7 +1205,7 @@ Hook: `src/app/hooks/usePendingKnocks.ts`
### Room Emoji Prefix ### Room Emoji Prefix
An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name. A leading emoji in a room name is rendered at 1.15× size in the sidebar for visual hierarchy. An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
### Configurable Composer Toolbar (P3-6) ### Configurable Composer Toolbar (P3-6)
+2 -4
View File
@@ -372,11 +372,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
### Element Call fork — operational reference ### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** since the 2026-09 sync — was v0.20.1); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`). Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`. **Publish a new version (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.
**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): **`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):
+4 -29
View File
@@ -22,7 +22,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices) - 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) - See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar - Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms) - Schedule messages to send at a specific time
- Click "edited" on any message to see the full edit history - Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads - Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand - Long messages collapse automatically — click "Read more" to expand
@@ -73,6 +73,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Toggle to pause background animations - Toggle to pause background animations
- Glassmorphism sidebar — frosted glass effect that lets the background show through - Glassmorphism sidebar — frosted glass effect that lets the background show through
- Night Light / blue light filter with an adjustable intensity slider - Night Light / blue light filter with an adjustable intensity slider
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
- Rename any room for yourself only — other members see the original name - Rename any room for yourself only — other members see the original name
- Emoji picker on all room name inputs - Emoji picker on all room name inputs
@@ -128,16 +129,7 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
### Download ### Download
Operating System | Download Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
---|---
Windows | [Get the installer (.exe)](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe)
Linux (AppImage, any distro) | [Get the AppImage](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.AppImage)
Linux (Debian/Ubuntu) | [Get the .deb](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb)
Linux (Arch/CachyOS/EndeavourOS) | [Get the .pkg.tar.zst](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst) — install with `pacman -U LotusChat-x86_64.pkg.tar.zst`
All Linux builds need `webkit2gtk-4.1` and, for calls to work, GStreamer's `good`/`bad`/`ugly`/`libav` plugin sets (the pacman package pulls these in automatically; on the AppImage/.deb, install them via your package manager if joining a call shows "browser does not support WebRTC").
See the full [Releases page on code.lotusguild.org](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases) for signatures and older builds.
### SmartScreen Warning (Windows) ### SmartScreen Warning (Windows)
@@ -175,27 +167,10 @@ The source code lives in `/root/code/cinny`. All changes should be made on the `
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog. See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### Local Development
Lotus Chat is a **pure client — there is no backend of its own to run.** It talks directly to a Matrix homeserver (Synapse) over HTTPS, so the only thing you run locally is the Vite dev server; it connects to a real homeserver for all data. If you were looking for "the backend to pair with it," there isn't one — that's the homeserver.
**Prerequisites:** Node 20+ (CI builds on Node 24) and npm.
```bash
npm ci # deps; @lotusguild/* come from our Gitea npm registry (public read — no auth/token needed)
npm start # Vite dev server → http://localhost:8080
```
The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already in use it falls through to 8081+, so check the "Local:" URL Vite prints on startup. If it boots but the page renders blank, it's almost always a failed module/asset resolution, not a "missing backend" — open the devtools console and read the first error.
**Which homeserver / logging in:** `config.json` sets `defaultHomeserver: 0` → `matrix.lotusguild.org`, so you sign in with your normal `@you:matrix.lotusguild.org` account. That homeserver is **live production** — anything you send is real, so keep test traffic to a DM with yourself or a throwaway room. To develop fully isolated instead, point `config.json` at a throwaway `matrix.org` account (already in `homeserverList`) or a local Synapse.
- **SSO / OIDC works from localhost.** Login goes through Authelia via OIDC dynamic registration; the provider redirects back to `http://localhost:8080/…` and the client registers that redirect on the fly, so no server-side allow-listing is needed. After the callback you may see a `GET …/_matrix/media/v1/thumbnail/… 404` — that's just a missing avatar thumbnail, **not** a login failure.
### 🔱 Element Call fork ("Lotus Call") — LIVE ### 🔱 Element Call fork ("Lotus Call") — LIVE
Voice/video channels embed **Element Call**, which is now our **self-built fork** Voice/video channels embed **Element Call**, which is now our **self-built fork**
(`@lotusguild/element-call-embedded` `0.25.0-lotus.1`, upstream base v0.25.0, source at (`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served `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 same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks. behavior is editable source instead of fragile DOM/widget hacks.
+3 -3
View File
@@ -4,9 +4,9 @@
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"], "spaces": [],
"rooms": ["#jellyfin:matrix.org"], "rooms": [],
"servers": ["matrixrooms.info"] "servers": []
}, },
"hashRouter": { "hashRouter": {
"enabled": false, "enabled": false,
+100 -206
View File
@@ -50,13 +50,13 @@
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"katex": "0.16.47", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0", "matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.17.0", "matrix-widget-api": "1.17.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "6.3.289", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
@@ -69,8 +69,8 @@
"react-google-recaptcha": "3.1.0", "react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-range": "1.10.0", "react-range": "1.10.0",
"react-router-dom": "7.18.3", "react-router-dom": "7.15.1",
"sanitize-html": "2.17.7", "sanitize-html": "2.17.4",
"slate": "0.124.1", "slate": "0.124.1",
"slate-dom": "0.124.1", "slate-dom": "0.124.1",
"slate-history": "0.113.1", "slate-history": "0.113.1",
@@ -80,7 +80,7 @@
"workbox-precaching": "7.4.1" "workbox-precaching": "7.4.1"
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
@@ -2493,6 +2493,20 @@
"uuid": "^9.0.0" "uuid": "^9.0.0"
} }
}, },
"node_modules/@giphy/js-util/node_modules/uuid": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@giphy/react-components": { "node_modules/@giphy/react-components": {
"version": "10.1.2", "version": "10.1.2",
"resolved": "https://registry.npmjs.org/@giphy/react-components/-/react-components-10.1.2.tgz", "resolved": "https://registry.npmjs.org/@giphy/react-components/-/react-components-10.1.2.tgz",
@@ -2680,9 +2694,9 @@
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA==" "integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
}, },
"node_modules/@lotusguild/element-call-embedded": { "node_modules/@lotusguild/element-call-embedded": {
"version": "0.25.0-lotus.1", "version": "0.20.1-lotus.1",
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.1/element-call-embedded-0.25.0-lotus.1.tgz", "resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.20.1-lotus.1/element-call-embedded-0.20.1-lotus.1.tgz",
"integrity": "sha512-tiVC7SD3cS1MMc9GZgzl6H7jvllHhOMvTivYqQT7pRaU4vP2M7IQNqL8KNCyXnXKaZAMIn7OnbI4thnQAC9p8Q==", "integrity": "sha512-hy1KEnFw4MuwvlactUFPPvvtPZh1y56JMK/ehnficUmJNwdJsOhSwThaYp35RZ/ar6RCuiW86yQqlQBOSpZJVQ==",
"dev": true "dev": true
}, },
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": { "node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
@@ -2695,9 +2709,9 @@
} }
}, },
"node_modules/@napi-rs/canvas": { "node_modules/@napi-rs/canvas": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-QviPdJImDi/jMAvBfqaw+19BndMd/sizXVW3NnpMd3VJGz++QXkOHcP9kWR/smHG0hNjHeyuHFyrx/5lD0oNcQ==", "integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"workspaces": [ "workspaces": [
@@ -2711,23 +2725,23 @@
"url": "https://github.com/sponsors/Brooooooklyn" "url": "https://github.com/sponsors/Brooooooklyn"
}, },
"optionalDependencies": { "optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.9", "@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "1.0.9", "@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "1.0.9", "@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.9", "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.9", "@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "1.0.9", "@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.9", "@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "1.0.9", "@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "1.0.9", "@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.9", "@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "1.0.9" "@napi-rs/canvas-win32-x64-msvc": "0.1.100"
} }
}, },
"node_modules/@napi-rs/canvas-android-arm64": { "node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-4LGXk2/0HVzE29K8SzML5WubgCp++B1FH3qgl35XmSZE+lLdr6P9VRQEnZ0MCLZMTSuJP41yyhtoiVNEuvrTIA==", "integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2745,9 +2759,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-darwin-arm64": { "node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-YNdfLBzY0W/Pep9fo2L6RmoNlNksnn05LRnX66W63R3ij58S25QOTcjdtEt2v8+PnCESzqZsYzUo+QPeIR44NA==", "integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2765,9 +2779,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-darwin-x64": { "node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ceZQSknTEcy3dOXoekv59LTCkXjvnLsq+VW5PeNNDHEPQbRS5Ervkm1EaDa7WLAjiYWMLuSQTRTHao1dEX4prg==", "integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2785,9 +2799,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-XhfI0Wwv4llhd6nnWDtY3kQKjq0r+y1i91PlJlJI24ag2U9WrnwbG1qS3+fDLEyouwEVFchiKkTEHozK+5iUNA==", "integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [ "cpu": [
"arm" "arm"
], ],
@@ -2805,9 +2819,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm64-gnu": { "node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-012oiYtKaE7i9oxc8q7nraT7kDOpLcaCmFLzVe9Ty34RHDdoDzbWLrVh827CNxYh/EADX1eSikA3ymLjo/nNuw==", "integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2825,9 +2839,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-arm64-musl": { "node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-Ls5UWYFFn63casTZEczbeyEg3vRDRkv9lscuGwfchtY5yLLQhgOB8SN4YGxmfJ5vTaBwZ2YUxBS3NtmjJmFXdA==", "integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2845,9 +2859,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": { "node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-hLKEGxV7ZiRHqndePTokgDMdBlo/rDfzg7P4p4QIv9pUhuYobnu3R2NIFLCRghG0nwfo+s2sw+c1xZFeCmEAsw==", "integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
@@ -2865,9 +2879,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-x64-gnu": { "node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-6kaz3w0QMy77PDWk6rJ1ksIihdad3qzEyX2o2oGT8GwCaypfT5mhjr8buOO5hstyLxcWXDScuz56RsINLtBPIQ==", "integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2885,9 +2899,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-linux-x64-musl": { "node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-xrGvmS3v55hmZ86ls/kBLVNMUTYio3f6Ik0DireemG994VfPAwiA3ZXA0Uf1bByctkB3NQ1Sfb+H5bkdUnnzfQ==", "integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -2905,9 +2919,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-win32-arm64-msvc": { "node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-yjmVS3ArZeRVCP7jqbPq4rpZa/BhTeI7ELE2XqJg3snICQBDevLZyArxswHkiTnT34KRic33/4fLirrHI+SY8A==", "integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
@@ -2925,9 +2939,9 @@
} }
}, },
"node_modules/@napi-rs/canvas-win32-x64-msvc": { "node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.9", "version": "0.1.100",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.9.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-QlSYQdMQslB81nlABo9wNfQ6npFhE7/O+saCZdqVGueGanyRk4jCogD5EwQenfP3kIq9e+mm6GreQBjX5MrA8g==", "integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [ "cpu": [
"x64" "x64"
], ],
@@ -4994,9 +5008,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.18", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^1.0.0", "balanced-match": "^1.0.0",
@@ -6147,7 +6161,6 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
"integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
"dev": true,
"dependencies": { "dependencies": {
"domelementtype": "^2.3.0", "domelementtype": "^2.3.0",
"domhandler": "^5.0.2", "domhandler": "^5.0.2",
@@ -6161,7 +6174,6 @@
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"domelementtype": "^2.3.0" "domelementtype": "^2.3.0"
@@ -6177,7 +6189,6 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
"integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
"dev": true,
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -6220,7 +6231,6 @@
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
"integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
"dev": true,
"dependencies": { "dependencies": {
"dom-serializer": "^2.0.0", "dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0", "domelementtype": "^2.3.0",
@@ -6234,7 +6244,6 @@
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"domelementtype": "^2.3.0" "domelementtype": "^2.3.0"
@@ -6318,7 +6327,6 @@
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"dev": true,
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
}, },
@@ -8031,7 +8039,6 @@
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
"dev": true,
"funding": [ "funding": [
"https://github.com/fb55/htmlparser2?sponsor=1", "https://github.com/fb55/htmlparser2?sponsor=1",
{ {
@@ -8051,7 +8058,6 @@
"version": "5.0.3", "version": "5.0.3",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
"integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"dependencies": { "dependencies": {
"domelementtype": "^2.3.0" "domelementtype": "^2.3.0"
@@ -8067,7 +8073,6 @@
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"engines": { "engines": {
"node": ">=0.12" "node": ">=0.12"
@@ -9000,19 +9005,9 @@
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.3.2", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@@ -9130,9 +9125,9 @@
} }
}, },
"node_modules/katex": { "node_modules/katex": {
"version": "0.16.47", "version": "0.16.11",
"resolved": "https://registry.npmjs.org/katex/-/katex-0.16.47.tgz", "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.11.tgz",
"integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", "integrity": "sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ==",
"funding": [ "funding": [
"https://opencollective.com/katex", "https://opencollective.com/katex",
"https://github.com/sponsors/katex" "https://github.com/sponsors/katex"
@@ -10207,9 +10202,9 @@
} }
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.19", "version": "3.3.12",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
"integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -10667,15 +10662,15 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfjs-dist": { "node_modules/pdfjs-dist": {
"version": "6.3.289", "version": "5.7.284",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.3.289.tgz", "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.7.284.tgz",
"integrity": "sha512-ZHjSVpDa3D6izMq8/04lvkhkATUmL9px6ChPaXc1k6nU2Mrhlg1/7F0bdUqCwUjw3NsPTfPZsMDUU6ZIcRaeQw==", "integrity": "sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=22.13.0 || >=24" "node": ">=22.13.0 || >=24"
}, },
"optionalDependencies": { "optionalDependencies": {
"@napi-rs/canvas": "^1.0.0" "@napi-rs/canvas": "^0.1.100"
} }
}, },
"node_modules/picocolors": { "node_modules/picocolors": {
@@ -10727,9 +10722,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.28", "version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
"integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
"funding": [ "funding": [
{ {
"type": "opencollective", "type": "opencollective",
@@ -10746,7 +10741,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.18", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -11118,9 +11113,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.18.3", "version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.3.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
"integrity": "sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==", "integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -11140,12 +11135,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.18.3", "version": "7.15.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.3.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
"integrity": "sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==", "integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.18.3" "react-router": "7.15.1"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
@@ -11651,106 +11646,18 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/sanitize-html": { "node_modules/sanitize-html": {
"version": "2.17.7", "version": "2.17.4",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz",
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"deepmerge": "^4.2.2", "deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
"htmlparser2": "^12.0.0", "htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0", "is-plain-object": "^5.0.0",
"launder": "^1.7.1", "launder": "^1.7.1",
"parse-srcset": "^1.0.2", "parse-srcset": "^1.0.2",
"postcss": "^8.3.11" "postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/sanitize-html/node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/sanitize-html/node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/entities": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
"integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
} }
}, },
"node_modules/scheduler": { "node_modules/scheduler": {
@@ -13031,19 +12938,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "dev": true
}, },
"node_modules/uuid": {
"version": "14.0.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.2.tgz",
"integrity": "sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.14", "version": "8.0.14",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz",
+9 -10
View File
@@ -1,18 +1,18 @@
{ {
"name": "lotus-chat", "name": "lotus-chat",
"version": "4.12.3-lotus", "version": "4.12.3-lotus",
"description": "Lotus Chat \u2014 Matrix client for Lotus Guild", "description": "Lotus Chat — Matrix client for Lotus Guild",
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=16.0.0"
}, },
"scripts": { "scripts": {
"start": "vite", "start": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "npm run check:eslint && npm run check:prettier", "lint": "npm run check:eslint && npm run check:prettier",
"check:eslint": "eslint src/* --max-warnings 68", "check:eslint": "eslint src/*",
"check:prettier": "prettier --check .", "check:prettier": "prettier --check .",
"fix:prettier": "prettier --write .", "fix:prettier": "prettier --write .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
@@ -75,13 +75,13 @@
"is-hotkey": "0.2.0", "is-hotkey": "0.2.0",
"jotai": "2.20.0", "jotai": "2.20.0",
"jsqr": "1.4.0", "jsqr": "1.4.0",
"katex": "0.16.47", "katex": "0.16.11",
"linkify-react": "4.3.3", "linkify-react": "4.3.3",
"linkifyjs": "4.3.3", "linkifyjs": "4.3.3",
"matrix-js-sdk": "41.7.0", "matrix-js-sdk": "41.7.0",
"matrix-widget-api": "1.17.0", "matrix-widget-api": "1.17.0",
"millify": "6.1.0", "millify": "6.1.0",
"pdfjs-dist": "6.3.289", "pdfjs-dist": "5.7.284",
"prismjs": "1.30.0", "prismjs": "1.30.0",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"qrcode.react": "4.2.0", "qrcode.react": "4.2.0",
@@ -94,8 +94,8 @@
"react-google-recaptcha": "3.1.0", "react-google-recaptcha": "3.1.0",
"react-i18next": "17.0.8", "react-i18next": "17.0.8",
"react-range": "1.10.0", "react-range": "1.10.0",
"react-router-dom": "7.18.3", "react-router-dom": "7.15.1",
"sanitize-html": "2.17.7", "sanitize-html": "2.17.4",
"slate": "0.124.1", "slate": "0.124.1",
"slate-dom": "0.124.1", "slate-dom": "0.124.1",
"slate-history": "0.113.1", "slate-history": "0.113.1",
@@ -105,7 +105,7 @@
"workbox-precaching": "7.4.1" "workbox-precaching": "7.4.1"
}, },
"devDependencies": { "devDependencies": {
"@lotusguild/element-call-embedded": "0.25.0-lotus.1", "@lotusguild/element-call-embedded": "0.20.1-lotus.1",
"@rollup/plugin-inject": "5.0.5", "@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2", "@rollup/plugin-wasm": "6.2.2",
"@types/chroma-js": "3.1.2", "@types/chroma-js": "3.1.2",
@@ -146,8 +146,7 @@
}, },
"overrides": { "overrides": {
"@giphy/js-util": { "@giphy/js-util": {
"dompurify": ">=3.3.4", "dompurify": ">=3.3.4"
"uuid": ">=11.1.1"
}, },
"js-cookie": ">=3.0.6" "js-cookie": ">=3.0.6"
} }
-148
View File
@@ -1,148 +0,0 @@
#!/usr/bin/env node
// Boot-check (Gitea #92): after `npm run build`, nothing actually loaded the
// built dist/ — a build that produces a broken bundle (bad base path, a 500
// from an asset, a malformed config.json) still went green. This script
// serves dist/ the same way production does (vite preview) and makes a
// handful of real HTTP requests against it, so a broken bundle fails CI
// instead of surfacing after deploy.
//
// No Playwright here: playwright-core is not a project dependency (only the
// browser binary caches happen to be present on this machine), so we don't
// depend on it being installed. Plain fetch is enough to catch the class of
// bug this check exists for — a page/asset/config that doesn't come back.
import { spawn } from 'node:child_process';
const PORT = 4173;
const HOST = '127.0.0.1';
const BASE_URL = `http://${HOST}:${PORT}`;
const BOOT_TIMEOUT_MS = 30_000;
function log(msg) {
console.log(`[boot-check] ${msg}`);
}
function waitForPort(url, timeoutMs) {
const deadline = Date.now() + timeoutMs;
const attempt = async () => {
try {
const res = await fetch(url, { method: 'GET' });
return res;
} catch {
return null;
}
};
return new Promise((resolve, reject) => {
const poll = async () => {
const res = await attempt();
if (res) {
resolve();
return;
}
if (Date.now() > deadline) {
reject(new Error(`Timed out waiting for ${url} to come up`));
return;
}
setTimeout(poll, 300);
};
poll();
});
}
async function assert(condition, message) {
if (!condition) {
throw new Error(`Assertion failed: ${message}`);
}
log(`ok: ${message}`);
}
async function main() {
const preview = spawn(
'npx',
['vite', 'preview', '--port', String(PORT), '--strictPort', '--host', HOST],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
let previewOutput = '';
preview.stdout.on('data', (d) => (previewOutput += d.toString()));
preview.stderr.on('data', (d) => (previewOutput += d.toString()));
const cleanup = () => {
if (!preview.killed) {
preview.kill('SIGTERM');
}
};
process.on('exit', cleanup);
process.on('SIGINT', () => {
cleanup();
process.exit(1);
});
process.on('SIGTERM', () => {
cleanup();
process.exit(1);
});
try {
await waitForPort(BASE_URL, BOOT_TIMEOUT_MS);
// 1. index page loads and contains the SPA mount point.
const indexRes = await fetch(`${BASE_URL}/`);
await assert(indexRes.status === 200, `GET / returns 200 (got ${indexRes.status})`);
const indexHtml = await indexRes.text();
await assert(indexHtml.includes('<div id="root"'), 'GET / contains <div id="root"');
// 2. runtime config is valid JSON.
const configRes = await fetch(`${BASE_URL}/config.json`);
await assert(
configRes.status === 200,
`GET /config.json returns 200 (got ${configRes.status})`,
);
const configText = await configRes.text();
let configJson;
try {
configJson = JSON.parse(configText);
} catch (err) {
throw new Error(`GET /config.json is not valid JSON: ${err.message}`);
}
await assert(
typeof configJson === 'object' && configJson !== null,
'/config.json parses to an object',
);
// 3. the main JS entry referenced from index.html actually loads.
const scriptMatch = indexHtml.match(/<script[^>]+type="module"[^>]+src="([^"]+)"/);
await assert(!!scriptMatch, 'index.html references a module script entry');
const mainScriptUrl = new URL(scriptMatch[1], BASE_URL).toString();
const scriptRes = await fetch(mainScriptUrl);
await assert(
scriptRes.status === 200,
`GET ${scriptMatch[1]} returns 200 (got ${scriptRes.status})`,
);
const scriptContentType = scriptRes.headers.get('content-type') || '';
await assert(
/javascript/.test(scriptContentType),
`GET ${scriptMatch[1]} has a JS content-type (got "${scriptContentType}")`,
);
// 4. Element Call widget bundle is present.
const callRes = await fetch(`${BASE_URL}/public/element-call/index.html`);
await assert(
callRes.status === 200,
`GET /public/element-call/index.html returns 200 (got ${callRes.status})`,
);
log('all checks passed');
} finally {
cleanup();
}
}
main()
.catch((err) => {
console.error(`[boot-check] FAILED: ${err.message}`);
process.exitCode = 1;
})
.finally(() => {
// The killed preview server's stdio pipes can keep the event loop alive
// briefly; force the process down promptly with whatever exit code was set.
process.exit(process.exitCode ?? 0);
});
-5
View File
@@ -1,5 +0,0 @@
{
"$comment": "Gitea #96. Budgets are seeded from the dist/assets gzip sizes on the tree at seed time, +10% headroom. Regenerate deliberately (not just to silence a failure) when a real feature addition grows the bundle: measure the new gzip sizes and bump these with the same +10% margin.",
"totalGzipBytes": 1731120,
"largestChunkGzipBytes": 358317
}
-97
View File
@@ -1,97 +0,0 @@
#!/usr/bin/env node
// Bundle size budget (Gitea #96). The CI "Report bundle sizes" step printed
// numbers with nothing to compare them against, so a bundle could balloon
// silently. This script compares dist/assets gzip sizes against a small
// checked-in budget (scripts/bundle-budget.json) and prints the same report.
//
// Mode is passed via argv: `pull_request` fails the build over budget,
// anything else (e.g. `push`) only warns — a push has already merged, so
// blocking it can't prevent the regression, only delay the deploy of an
// otherwise-good commit; the pull_request gate is where this should be caught.
import { appendFileSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { gzipSync } from 'node:zlib';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const mode = process.argv[2] || 'push';
const distAssetsDir = path.join(__dirname, '..', 'dist', 'assets');
const budgetPath = path.join(__dirname, 'bundle-budget.json');
function formatKb(bytes) {
return `${(bytes / 1024).toFixed(1)} kB`;
}
function main() {
const budget = JSON.parse(readFileSync(budgetPath, 'utf-8'));
const jsFiles = readdirSync(distAssetsDir)
.filter((f) => f.endsWith('.js') && !f.endsWith('.map'))
.sort();
if (jsFiles.length === 0) {
console.error(
`[check-bundle-size] No .js files found in ${distAssetsDir} — did the build run?`,
);
process.exitCode = 1;
return;
}
const rows = jsFiles.map((name) => {
const filePath = path.join(distAssetsDir, name);
const size = statSync(filePath).size;
const gzipSize = gzipSync(readFileSync(filePath)).length;
return { name, size, gzipSize };
});
const totalGzipBytes = rows.reduce((sum, r) => sum + r.gzipSize, 0);
const largest = rows.reduce((max, r) => (r.gzipSize > max.gzipSize ? r : max), rows[0]);
const summaryLines = [
'### Bundle sizes',
'',
'| File | Size | Gzip |',
'|------|------|------|',
...rows.map((r) => `| ${r.name} | ${formatKb(r.size)} | ${formatKb(r.gzipSize)} |`),
'',
`**Total gzip:** ${formatKb(totalGzipBytes)} (budget ${formatKb(budget.totalGzipBytes)})`,
`**Largest chunk gzip:** ${largest.name} — ${formatKb(largest.gzipSize)} (budget ${formatKb(
budget.largestChunkGzipBytes,
)})`,
];
const summaryText = summaryLines.join('\n');
console.log(summaryText);
if (process.env.GITHUB_STEP_SUMMARY) {
// Gitea Actions/act_runner honors the same GITHUB_STEP_SUMMARY convention.
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summaryText}\n`);
}
const overBudget = [];
if (totalGzipBytes > budget.totalGzipBytes) {
overBudget.push(
`total gzip ${formatKb(totalGzipBytes)} exceeds budget ${formatKb(budget.totalGzipBytes)}`,
);
}
if (largest.gzipSize > budget.largestChunkGzipBytes) {
overBudget.push(
`largest chunk (${largest.name}) gzip ${formatKb(largest.gzipSize)} exceeds budget ${formatKb(
budget.largestChunkGzipBytes,
)}`,
);
}
if (overBudget.length > 0) {
const message = `[check-bundle-size] Over budget: ${overBudget.join('; ')}`;
if (mode === 'pull_request') {
console.error(message);
process.exitCode = 1;
} else {
console.warn(`${message} (warning only on "${mode}")`);
}
} else {
console.log('[check-bundle-size] Within budget.');
}
}
main();
+10 -35
View File
@@ -45,8 +45,6 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones'; import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
import { useCallMembersChange, useCallSession } from '../hooks/useCall'; import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds'; import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useCallHotkeys } from '../hooks/useCallHotkeys';
import { useAfkAutoMute } from '../hooks/useAfkAutoMute';
import { useCallQuality } from '../hooks/useCallQuality'; import { useCallQuality } from '../hooks/useCallQuality';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers'; import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
@@ -60,7 +58,6 @@ import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme'; import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion'; import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings'; import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings'; import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room'; import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
@@ -413,8 +410,6 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const [callInfo, setCallInfo] = useState<IncomingCallInfo>(); const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false; const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm); const startCall = useCallStart(dm);
const { microphone, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard // C-L6: handleTimelineEvent awaits decryption before calling setState; guard
// against the component unmounting during that await. // against the component unmounting during that await.
@@ -571,15 +566,11 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleAnswer = useCallback( const handleAnswer = useCallback(
(room: Room, video: boolean) => { (room: Room, video: boolean) => {
// Honour cameraOnJoin and the persisted mic/sound preferences instead of startCall(room, { microphone: true, video, sound: true });
// 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); setCallInfo(undefined);
navigateRoom(room.roomId); navigateRoom(room.roomId);
}, },
[startCall, navigateRoom, microphone, sound, cameraOnJoin], [startCall, navigateRoom],
); );
if (!callInfo) return null; if (!callInfo) return null;
@@ -611,15 +602,9 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
); );
} }
function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) { function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #9] PTT/deafen hotkeys and AFK auto-mute are bound here, for the
// embed's whole lifetime, rather than in CallControls (which only renders
// while the call room is selected) — so they keep working in PiP and behind
// the mobile in-call chat. Both are gated on `joined`.
useCallHotkeys(embed, joined);
useAfkAutoMute(joined ? embed : undefined);
useCallMemberSoundSync(embed); useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed); useCallJoinLeaveSounds(embed);
useCallThemeSync(embed); useCallThemeSync(embed);
@@ -631,22 +616,6 @@ function CallUtils({ embed, joined }: { embed: CallEmbed; joined: boolean }) {
}, [setCallEmbed]), }, [setCallEmbed]),
); );
// [Gitea #58] Warn before an accidental tab close/reload drops the user out
// of a live call. Only armed while actually joined, and torn down on
// hangup/dispose since this effect re-runs when `joined` flips back to false.
useEffect(() => {
if (!joined) return undefined;
const onBeforeUnload = (event: BeforeUnloadEvent) => {
event.preventDefault();
// Legacy browsers require returnValue to be set to show the prompt.
event.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => {
window.removeEventListener('beforeunload', onBeforeUnload);
};
}, [joined]);
return null; return null;
} }
@@ -751,6 +720,12 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
}; };
}, []); }, []);
// Sync pip mode into CallControl so it can adjust behavior accordingly
useEffect(() => {
if (!callEmbed) return;
callEmbed.control.setPipMode(!!pipMode);
}, [pipMode, callEmbed]);
// When entering pip with screenshare active (or screenshare starts while in pip), // When entering pip with screenshare active (or screenshare starts while in pip),
// enable spotlight so the screenshare fills the pip window. // enable spotlight so the screenshare fills the pip window.
// When screenshare ends, release the spotlight we auto-enabled. // When screenshare ends, release the spotlight we auto-enabled.
@@ -1164,7 +1139,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
return ( return (
<CallEmbedContextProvider value={callEmbed}> <CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} joined={joined} />} {callEmbed && <CallUtils embed={callEmbed} />}
<CallEmbedRefContextProvider value={callEmbedRef}> <CallEmbedRefContextProvider value={callEmbedRef}>
<IncomingCallListener callEmbed={callEmbed} joined={joined} /> <IncomingCallListener callEmbed={callEmbed} joined={joined} />
{children} {children}
-7
View File
@@ -209,13 +209,6 @@ type GifPickerProps = {
export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) { export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
// Searches (and every keystroke) go straight to Giphy's API, so the picker
// is opt-in (Settings → Messages) and must not render or fetch until then.
if (!gifPickerEnabled) {
return null;
}
const containerStyle = lotusTerminal const containerStyle = lotusTerminal
? { ? {
+1 -20
View File
@@ -21,31 +21,12 @@ type KaTeXProps = {
* inline (in its error colour) rather than throwing. The HTML returned by * inline (in its error colour) rather than throwing. The HTML returned by
* `renderToString` is produced by our own trusted call from a fixed options * `renderToString` is produced by our own trusted call from a fixed options
* object — it is safe to inject via `dangerouslySetInnerHTML`. * object — it is safe to inject via `dangerouslySetInnerHTML`.
*
* `maxSize`/`maxExpand` cap how large a single glyph (`\\rule`, etc.) or macro
* expansion remote LaTeX can request, and `trust: false` disables commands
* that can embed arbitrary HTML/URLs (e.g. `\\includegraphics`, `\\href`) —
* without these a hostile `$$...$$` from a remote message can DoS or (via
* `trust`) inject unsafe links (Gitea #65). `strict: 'ignore'` keeps unknown-
* but-harmless LaTeX from spamming the console as before. Extremely long
* source is rendered as plain text rather than handed to KaTeX at all.
*/ */
const MAX_LATEX_LENGTH = 5000;
export default function KaTeX({ latex, displayMode = false }: KaTeXProps) { export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
if (latex.length > MAX_LATEX_LENGTH) {
const Plain = displayMode ? 'div' : 'span';
return <Plain>{latex}</Plain>;
}
const html = katex.renderToString(latex, { const html = katex.renderToString(latex, {
displayMode, displayMode,
throwOnError: false, throwOnError: false,
output: 'htmlAndMathml', output: 'htmlAndMathml',
maxSize: 10,
maxExpand: 100,
trust: false,
strict: 'ignore',
}); });
const Wrapper = displayMode ? 'div' : 'span'; const Wrapper = displayMode ? 'div' : 'span';
@@ -53,7 +34,7 @@ export default function KaTeX({ latex, displayMode = false }: KaTeXProps) {
return ( return (
<Wrapper <Wrapper
// KaTeX output is generated by our own render call (trusted-safe). // KaTeX output is generated by our own render call (trusted-safe).
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }} dangerouslySetInnerHTML={{ __html: html }}
/> />
); );
@@ -15,7 +15,6 @@ import {
MessageVerificationRequestContent, MessageVerificationRequestContent,
} from './content'; } from './content';
import { useMessageTranslation } from '../../hooks/useMessageTranslation'; import { useMessageTranslation } from '../../hooks/useMessageTranslation';
import { useReducedMotion } from '../../hooks/useReducedMotion';
import { languageName } from '../../utils/translation/langUtils'; import { languageName } from '../../utils/translation/langUtils';
import { import {
IAudioContent, IAudioContent,
@@ -63,9 +62,8 @@ function CollapsibleBody({ eventId, children }: CollapsibleBodyProps) {
return () => observer.disconnect(); return () => observer.disconnect();
}, []); }, []);
// A one-time matchMedia() read never updated if the OS setting changed mid-session const prefersReducedMotion =
// (Gitea #85); useReducedMotion subscribes to the change event instead. typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const prefersReducedMotion = useReducedMotion();
return ( return (
<div> <div>
@@ -635,13 +633,6 @@ type MLocationProps = {
}; };
export function MLocation({ content }: MLocationProps) { export function MLocation({ content }: MLocationProps) {
const { t } = useTranslation(); const { t } = useTranslation();
// The OpenStreetMap iframe used to mount unconditionally on render, silently
// handing the sender's coordinates (and this client's IP/UA) to
// openstreetmap.org for every location message rendered, autoplay or not.
// Gate it behind an explicit click instead (Gitea #66). There's no
// location-specific auto-load setting in settings.ts to opt back into this,
// so it always requires a click.
const [mapLoaded, setMapLoaded] = useState(false);
// Prefer the legacy top-level geo_uri, but fall back to the MSC3488 extensible // Prefer the legacy top-level geo_uri, but fall back to the MSC3488 extensible
// location block so events from clients that only send the new shape (uri under // location block so events from clients that only send the new shape (uri under
// org.matrix.msc3488.location / m.location) still render instead of appearing // org.matrix.msc3488.location / m.location) still render instead of appearing
@@ -669,7 +660,6 @@ export function MLocation({ content }: MLocationProps) {
return ( return (
<Box direction="Column" alignItems="Start" gap="200"> <Box direction="Column" alignItems="Start" gap="200">
{mapLoaded ? (
<iframe <iframe
title="Location" title="Location"
src={mapSrc} src={mapSrc}
@@ -684,18 +674,6 @@ export function MLocation({ content }: MLocationProps) {
loading="lazy" loading="lazy"
sandbox="allow-scripts" sandbox="allow-scripts"
/> />
) : (
<Button
size="400"
radii="300"
variant="Secondary"
fill="Soft"
onClick={() => setMapLoaded(true)}
before={<Icon src={Icons.Pin} size="50" />}
>
<Text size="B300">{t('Organisms.Message.load_map', 'Load map')}</Text>
</Button>
)}
{description && ( {description && (
<Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}> <Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}>
{description} {description}
@@ -84,15 +84,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return ( return (
<div <div
aria-hidden="true" aria-hidden="true"
style={{ style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
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)} {buildOverlayContent(theme, true)}
</div> </div>
@@ -102,12 +102,12 @@ export const animSparkleTwinkle = keyframes({
* Opacity + a hair of scale for a CRT bloom feel. * Opacity + a hair of scale for a CRT bloom feel.
*/ */
export const animCoinBlink = keyframes({ export const animCoinBlink = keyframes({
'0%': { opacity: '0.85', transform: 'scale(1)' }, '0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'6%': { opacity: '1', transform: 'scale(1.015)' }, '6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
'12%': { opacity: '0.85', transform: 'scale(1)' }, '12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'49%': { opacity: '0.85', transform: 'scale(1)' }, '49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'50%': { opacity: '0', transform: 'scale(1)' }, '50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'100%': { opacity: '0', transform: 'scale(1)' }, '100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
}); });
/** /**
+43 -77
View File
@@ -46,10 +46,6 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)'; const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets). // 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 // 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 // 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 // under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
@@ -62,7 +58,7 @@ function gridDataUri(): string {
rows.forEach((y) => { rows.forEach((y) => {
lines.push( lines.push(
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` + `<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`, `stroke-width='1.4' stroke-opacity='0.9'/>`,
); );
}); });
// Vertical lines fanning out from the top-center vanishing point. // Vertical lines fanning out from the top-center vanishing point.
@@ -71,7 +67,7 @@ function gridDataUri(): string {
const botX = 300 + i * 95; // wide at the foreground const botX = 300 + i * 95; // wide at the foreground
lines.push( lines.push(
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` + `<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`, `stroke-width='1.4' stroke-opacity='0.8'/>`,
); );
} }
const svg = const svg =
@@ -109,13 +105,6 @@ const RESTING_SPARKLES: ReadonlyArray<{
const GRID_URI = gridDataUri(); 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) { export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic sparkle field, computed ONCE. No per-frame state. // Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo<Sparkle[]>(() => { const sparkles = useMemo<Sparkle[]>(() => {
@@ -145,9 +134,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute', position: 'absolute',
inset: 0, inset: 0,
backgroundImage: [ backgroundImage: [
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.12) 0%, transparent 55%)', '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.16) 0%, transparent 60%)', '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.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)', 'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
].join(','), ].join(','),
contain: 'layout paint style', contain: 'layout paint style',
}} }}
@@ -158,27 +147,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
a vanishing point at the top (the horizon). It lives in the lower 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 half of the screen — the "floor". The inner plane scrolls upward by
one tile via transform translateY, which reads as the grid flowing 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 <div
aria-hidden="true" aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
maskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
WebkitMaskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
opacity: reduced ? 0.4 : 0.46,
contain: 'layout paint style',
}}
>
<div
style={{ style={{
position: 'absolute', position: 'absolute',
left: '-25%', left: '-25%',
@@ -190,6 +161,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
perspectiveOrigin: '50% 0%', perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)', maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)', WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
opacity: reduced ? 0.5 : 0.62,
contain: 'layout paint style', contain: 'layout paint style',
}} }}
> >
@@ -205,13 +177,12 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
backgroundImage: GRID_URI, backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y', backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%', backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))', filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
willChange: reduced ? undefined : 'transform', willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`, animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}} }}
/> />
</div> </div>
</div>
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits {/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
where the grid meets the sky, with a thin bright rule on top of it where the grid meets the sky, with a thin bright rule on top of it
@@ -226,7 +197,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
height: '34%', height: '34%',
transform: 'translate(-50%, -50%)', transform: 'translate(-50%, -50%)',
backgroundImage: backgroundImage:
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.16) 0%, oklch(0.65 0.18 330 / 0.08) 40%, transparent 72%)', 'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
contain: 'layout paint style', contain: 'layout paint style',
}} }}
/> />
@@ -238,10 +209,8 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
right: '12%', right: '12%',
top: '38%', top: '38%',
height: '1.5px', height: '1.5px',
// Bright at the flanks, dropped out through the centre column so the background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
// rule frames the timeline rather than underlining a message. opacity: 0.55,
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))', filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
}} }}
/> />
@@ -304,7 +273,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0, inset: 0,
overflow: 'hidden', overflow: 'hidden',
mixBlendMode: 'multiply', mixBlendMode: 'multiply',
opacity: 0.32, opacity: 0.5,
contain: 'layout paint style', contain: 'layout paint style',
}} }}
> >
@@ -316,7 +285,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
top: '-8px', top: '-8px',
bottom: '-8px', bottom: '-8px',
backgroundImage: backgroundImage:
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.45) 0px, oklch(0.10 0.04 300 / 0.45) 1px, transparent 1px, transparent 4px)', 'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
willChange: reduced ? undefined : 'transform', willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`, animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
}} }}
@@ -340,54 +309,51 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
}} }}
/> />
{/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN" {/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
blip, stacked bottom-right. That corner is the one spot that is Static scene shows it steady (no blink). */}
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 <div
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: 'absolute', position: 'absolute',
right: '14px', bottom: '5%',
bottom: '8px', left: '50%',
display: 'flex', transform: 'translateX(-50%)',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '3px',
fontFamily: '"Courier New", monospace', fontFamily: '"Courier New", monospace',
fontSize: HUD_FONT_SIZE, fontSize: '12px',
fontWeight: 700, fontWeight: 700,
lineHeight: 1,
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
<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', letterSpacing: '0.32em',
color: NEON_CYAN, 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)', textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.6 : undefined, opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`, animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}} }}
> >
INSERT COIN INSERT COIN
</div> </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> </div>
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners, {/* 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) { export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger. // Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []); const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(22, 2000), []); const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
const heroStars = useMemo<HeroStar[]>( const heroStars = useMemo<HeroStar[]>(
() => () =>
@@ -144,7 +144,7 @@ export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute', position: 'absolute',
inset: '-6%', inset: '-6%',
contain: 'layout paint style', contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.12 300 / 0.12)', backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
backgroundImage: [ backgroundImage: [
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)', '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%)', 'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
@@ -183,17 +183,15 @@ export function EarthDayOverlay({ reduced }: SeasonalOverlayProps) {
))} ))}
</div> </div>
{/* ── Blue-marble Earth tucked into the bottom-right corner ── {/* ── Blue-marble Earth tucked into the bottom-right corner ── */}
Kept above the composer strip: bottom offset + size keep the
globe's footprint within the top ~90% of the viewport. ── */}
<div <div
aria-hidden="true" aria-hidden="true"
style={{ style={{
position: 'absolute', position: 'absolute',
right: '-4%', right: '-6%',
bottom: '10%', bottom: '-10%',
width: '200px', width: '300px',
height: '200px', height: '300px',
contain: 'layout paint style', contain: 'layout paint style',
willChange: reduced ? undefined : 'transform, opacity', willChange: reduced ? undefined : 'transform, opacity',
transform: reduced ? 'scale(1.02)' : undefined, transform: reduced ? 'scale(1.02)' : undefined,
@@ -164,7 +164,7 @@ export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
height: `${f.height}px`, height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`, backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)', filter: 'blur(14px)',
willChange: reduced ? undefined : 'transform, opacity', willChange: 'transform, opacity',
opacity: reduced ? 0.5 : undefined, opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined, transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced animation: reduced
@@ -73,32 +73,6 @@ const formatClipSeconds = (seconds: number): string => {
return `${m}:${s.toString().padStart(2, '0')}`; return `${m}:${s.toString().padStart(2, '0')}`;
}; };
/**
* [Gitea #31] Pure running-count cap check for `handleFiles`: given how many
* clips already exist (staged uploads included) before this batch starts,
* decide which of the batch's files fit under `max`. Kept pure/exported so the
* "running count, not a stale double-counted closure value" logic can be unit
* tested without a DOM/MatrixClient.
*/
export function acceptClips<T>(
currentCount: number,
files: readonly T[],
max: number,
): { accepted: T[]; rejected: T[] } {
const accepted: T[] = [];
const rejected: T[] = [];
let count = currentCount;
files.forEach((file) => {
if (count >= max) {
rejected.push(file);
} else {
accepted.push(file);
count += 1;
}
});
return { accepted, rejected };
}
type ClipDraft = { type ClipDraft = {
url: string; url: string;
body: string; body: string;
@@ -212,19 +186,11 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
...existing.map((c) => c.shortcode), ...existing.map((c) => c.shortcode),
...uploads.map((u) => u.shortcode), ...uploads.map((u) => u.shortcode),
]); ]);
// [Gitea #31] `clipCount` already includes staged `uploads`, so don't for (let i = 0; i < files.length; i += 1) {
// add `uploads.length` again here (double-counting). And since const file = files[i];
// `setUploads` inside the loop doesn't update this closure's if (clipCount + uploads.length >= SOUNDBOARD_MAX_CLIPS) {
// `clipCount`, track the running total in a local variable that starts throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
// from the real current total instead of re-reading a stale value for }
// every file in the batch.
const { accepted, rejected } = acceptClips(
clipCount,
Array.from(files),
SOUNDBOARD_MAX_CLIPS,
);
for (let i = 0; i < accepted.length; i += 1) {
const file = accepted[i];
if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) { if (file.size > SOUNDBOARD_MAX_CLIP_BYTES) {
throw new Error(`"${file.name}" is too large (max 1 MB).`); throw new Error(`"${file.name}" is too large (max 1 MB).`);
} }
@@ -249,9 +215,6 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
}, },
]); ]);
} }
if (rejected.length > 0) {
throw new Error(`Soundboard is full (max ${SOUNDBOARD_MAX_CLIPS} clips).`);
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Upload failed.'); setError(e instanceof Error ? e.message : 'Upload failed.');
} finally { } finally {
@@ -214,56 +214,24 @@ function UserPrivateNotes({ userId }: { userId: string }) {
const [draft, setDraft] = useState(() => getNote(userId)); const [draft, setDraft] = useState(() => getNote(userId));
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined); 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, but never while there are // Sync if account data arrives after mount
// unsaved local edits (including our own save's in-flight echo).
useEffect(() => { useEffect(() => {
if (prevUserIdRef.current !== userId) {
prevUserIdRef.current = userId;
dirtyRef.current = false;
}
if (dirtyRef.current) return;
setDraft(getNote(userId)); setDraft(getNote(userId));
}, [getNote, userId]); }, [getNote, userId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => { const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value; const val = e.target.value;
dirtyRef.current = true;
setDraft(val); setDraft(val);
clearTimeout(saveTimer.current); clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => { saveTimer.current = setTimeout(async () => {
dirtyRef.current = false;
setSaving(true); setSaving(true);
await setNote(userId, val); await setNote(userId, val);
setSaving(false); setSaving(false);
}, 800); }, 800);
}; };
useEffect( useEffect(() => () => clearTimeout(saveTimer.current), []);
() => () => {
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; const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
+11 -22
View File
@@ -103,8 +103,7 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(bookmark.roomId) ?? undefined; const room = mx.getRoom(bookmark.roomId) ?? undefined;
// E2EE-room bookmarks store no roomName; fall back past the '' placeholder. const displayRoomName = room?.name ?? bookmark.roomName;
const displayRoomName = room?.name || bookmark.roomName || 'Unknown room';
const avatarUrl = room const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined) ? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined; : undefined;
@@ -163,7 +162,7 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: Bookm
style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }} style={{ justifyContent: 'flex-start', height: 'unset', padding: config.space.S200 }}
> >
<Text className={css.BookmarkPreview} size="T200" priority="400"> <Text className={css.BookmarkPreview} size="T200" priority="400">
{preview ?? (bookmark.previewText || 'Message unavailable')} {preview ?? (bookmark.previewText || '(no preview)')}
</Text> </Text>
</Button> </Button>
</Box> </Box>
@@ -174,16 +173,13 @@ type LiveBookmarkItemProps = BookmarkItemProps & { room: Room };
// Renders the same layout as BookmarkItem, but resolves the message body live so // Renders the same layout as BookmarkItem, but resolves the message body live so
// edits (m.replace, applied by useRoomEvent) and redactions are reflected. The // edits (m.replace, applied by useRoomEvent) and redactions are reflected. The
// stored snapshot (previewText) remains the fallback for loading/failed/empty // stored snapshot (previewText) remains the fallback for loading/failed/empty states.
// states; bookmarks from E2EE rooms have no snapshot at all (account data is
// server-readable), so the live event is their only source of text.
function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) { function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItemProps) {
const liveEvent = useRoomEvent(room, bookmark.eventId, () => const liveEvent = useRoomEvent(room, bookmark.eventId, () =>
room.findEventById(bookmark.eventId), room.findEventById(bookmark.eventId),
); );
const snapshot = const snapshot = bookmark.previewText || '(no preview)';
bookmark.previewText || (liveEvent === undefined ? 'Loading…' : 'Message unavailable');
let preview: ReactNode = snapshot; let preview: ReactNode = snapshot;
// undefined (loading) and null (fetch failed / not found) both keep the snapshot. // undefined (loading) and null (fetch failed / not found) both keep the snapshot.
@@ -238,7 +234,7 @@ function RoomGroupHeader({
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const room = mx.getRoom(roomId) ?? undefined; const room = mx.getRoom(roomId) ?? undefined;
const displayRoomName = room?.name || roomName || 'Unknown room'; const displayRoomName = room?.name ?? roomName;
const avatarUrl = room const avatarUrl = room
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined) ? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
: undefined; : undefined;
@@ -331,20 +327,13 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
() => () =>
query.length === 0 query.length === 0
? bookmarks ? bookmarks
: bookmarks.filter((bk) => { : bookmarks.filter(
// E2EE-room bookmarks have no stored text: match against the locally (bk) =>
// cached event body / live room name instead (nothing is fetched here). bk.previewText.toLowerCase().includes(query) ||
const room = mx.getRoom(bk.roomId);
const localBody = room?.findEventById(bk.eventId)?.getContent()?.body;
return (
(bk.previewText?.toLowerCase().includes(query) ?? false) ||
(typeof localBody === 'string' && localBody.toLowerCase().includes(query)) ||
bk.roomName.toLowerCase().includes(query) || bk.roomName.toLowerCase().includes(query) ||
(room?.name.toLowerCase().includes(query) ?? false) || (bk.senderName?.toLowerCase().includes(query) ?? false),
(bk.senderName?.toLowerCase().includes(query) ?? false) ),
); [bookmarks, query],
}),
[mx, bookmarks, query],
); );
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved // Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
+6 -99
View File
@@ -1,25 +1,11 @@
import { import { Box, Chip, Icon, IconButton, Icons, Spinner, Text, Tooltip, TooltipProvider } from 'folds';
Box, import React, { useCallback } from 'react';
Button,
Chip,
color,
config,
Icon,
IconButton,
Icons,
Spinner,
Text,
Tooltip,
TooltipProvider,
} from 'folds';
import React, { useCallback, useEffect, useState } from 'react';
import { useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { StatusDivider } from './components'; import { StatusDivider } from './components';
import { CallEmbed, useCallControlState } from '../../plugins/call'; import { CallEmbed, useCallControlState } from '../../plugins/call';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { MobileTouchTarget } from '../../styles/mobile.css'; import { MobileTouchTarget } from '../../styles/mobile.css';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
type MicrophoneButtonProps = { type MicrophoneButtonProps = {
enabled: boolean; enabled: boolean;
@@ -191,23 +177,6 @@ export function CallControl({
const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control); const { microphone, video, sound, screenshare } = useCallControlState(callEmbed.control);
const setCallEmbed = useSetAtom(callEmbedAtom); const setCallEmbed = useSetAtom(callEmbedAtom);
// [Gitea #26] Apply the same room-level camera/screenshare policy as the
// in-room CallControls bar, so the status bar can't be used to bypass it.
const { allowCamera, allowScreenshare } = useRoomCallPolicy(callEmbed.room);
// Keep a forbidden control visible while its track is still live (so the user
// can stop it); otherwise hide it entirely.
const showCamera = allowCamera || video;
const showScreenshare = allowScreenshare || screenshare;
const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => {
if (!shareConfirm) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setShareConfirm(false);
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [shareConfirm]);
const handleMicrophoneToggle = useCallback( const handleMicrophoneToggle = useCallback(
() => callEmbed.control.toggleMicrophone(), () => callEmbed.control.toggleMicrophone(),
[callEmbed], [callEmbed],
@@ -229,65 +198,7 @@ export function CallControl({
}; };
return ( return (
<Box shrink="No" alignItems="Center" gap="300" style={{ position: 'relative' }}> <Box shrink="No" alignItems="Center" gap="300">
{shareConfirm && (
<>
<div
style={{ position: 'fixed', inset: 0, zIndex: 99 }}
onClick={() => setShareConfirm(false)}
aria-hidden="true"
/>
<Box
style={{
position: 'absolute',
bottom: '110%',
left: 0,
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '0.75rem',
padding: '1rem 1.25rem',
zIndex: 100,
minWidth: '260px',
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
}}
>
<Text size="T300" style={{ fontWeight: 600 }}>
Share your screen?
</Text>
<Text size="T200" style={{ opacity: 0.75 }}>
Your screen will be visible to all participants in this call.
</Text>
<Box gap="200">
<Button
size="300"
variant="Success"
fill="Solid"
radii="300"
onClick={() => {
callEmbed.control.toggleScreenshare();
setShareConfirm(false);
}}
>
<Text size="B300">Share</Text>
</Button>
<Button
size="300"
variant="Secondary"
fill="Soft"
radii="300"
outlined
onClick={() => setShareConfirm(false)}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
</>
)}
<Box alignItems="Inherit" gap="200"> <Box alignItems="Inherit" gap="200">
<MicrophoneButton <MicrophoneButton
enabled={microphone} enabled={microphone}
@@ -299,16 +210,12 @@ export function CallControl({
onToggle={() => callEmbed.control.toggleSound()} onToggle={() => callEmbed.control.toggleSound()}
disabled={!callJoined} disabled={!callJoined}
/> />
{!compact && (showCamera || showScreenshare) && <StatusDivider />} {!compact && <StatusDivider />}
{showCamera && (
<VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} /> <VideoButton enabled={video} onToggle={handleVideoToggle} disabled={!callJoined} />
)} {!compact && (
{!compact && showScreenshare && (
<ScreenShareButton <ScreenShareButton
enabled={screenshare} enabled={screenshare}
onToggle={() => onToggle={() => callEmbed.control.toggleScreenshare()}
screenshare ? callEmbed.control.toggleScreenshare() : setShareConfirm(true)
}
disabled={!callJoined} disabled={!callJoined}
/> />
)} )}
+2 -33
View File
@@ -1,6 +1,6 @@
import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds'; import { Box, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
@@ -12,31 +12,8 @@ import { StackedAvatar } from '../../components/stacked-avatar';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { CallEmbed } from '../../plugins/call/CallEmbed'; import { CallEmbed } from '../../plugins/call/CallEmbed';
import { CallControlEvent } from '../../plugins/call/CallControl';
import * as css from './styles.css'; import * as css from './styles.css';
// [Gitea #56] Subscribes to CallControl's focus pin so the menu can render a
// "Focus camera" / "Unfocus camera" toggle instead of a one-way pin.
function useFocusedUserId(callEmbed?: CallEmbed): string | null {
const control = callEmbed?.control;
const [focusedUserId, setFocusedUserId] = useState<string | null>(control?.focusedUserId ?? null);
useEffect(() => {
if (!control) {
setFocusedUserId(null);
return undefined;
}
setFocusedUserId(control.focusedUserId);
const handleUpdate = () => setFocusedUserId(control.focusedUserId);
control.on(CallControlEvent.StateUpdate, handleUpdate);
return () => {
control.off(CallControlEvent.StateUpdate, handleUpdate);
};
}, [control]);
return focusedUserId;
}
type ParticipantMenuProps = { type ParticipantMenuProps = {
anchor: RectCords; anchor: RectCords;
name: string; name: string;
@@ -56,23 +33,15 @@ function ParticipantMenu({
profileCords, profileCords,
}: ParticipantMenuProps) { }: ParticipantMenuProps) {
const openUserProfile = useOpenUserRoomProfile(); const openUserProfile = useOpenUserRoomProfile();
const focusedUserId = useFocusedUserId(callEmbed);
const isFocused = focusedUserId === userId;
const handleViewProfile = () => { const handleViewProfile = () => {
onClose(); onClose();
openUserProfile(room.roomId, undefined, userId, profileCords, 'Top'); openUserProfile(room.roomId, undefined, userId, profileCords, 'Top');
}; };
// [Gitea #56] Toggle: focusing the already-focused participant clears the
// pin and returns EC to speaker-follows, instead of leaving no way back.
const handleFocusCamera = () => { const handleFocusCamera = () => {
onClose(); onClose();
if (isFocused) {
callEmbed?.control.clearFocusParticipant();
} else {
callEmbed?.control.focusCameraParticipant(userId); callEmbed?.control.focusCameraParticipant(userId);
}
}; };
return ( return (
@@ -109,7 +78,7 @@ function ParticipantMenu({
before={<Icon size="100" src={Icons.VideoCamera} />} before={<Icon size="100" src={Icons.VideoCamera} />}
onClick={handleFocusCamera} onClick={handleFocusCamera}
> >
<Text size="B300">{isFocused ? 'Unfocus camera' : 'Focus camera'}</Text> <Text size="B300">Focus camera</Text>
</MenuItem> </MenuItem>
)} )}
<MenuItem <MenuItem
+135 -7
View File
@@ -1,5 +1,5 @@
import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react'; import React, { MouseEventHandler, useCallback, useEffect, useRef, useState } from 'react';
import { useAtomValue, useSetAtom } from 'jotai'; import { useSetAtom } from 'jotai';
import { import {
Box, Box,
Button, Button,
@@ -39,7 +39,7 @@ import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed'; import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { pttActiveAtom } from '../../hooks/useCallHotkeys'; import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
import { CallSoundboard } from './CallSoundboard'; import { CallSoundboard } from './CallSoundboard';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
@@ -88,6 +88,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } = const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control); useCallControlState(callEmbed.control);
useAfkAutoMute(callEmbed);
const [cords, setCords] = useState<RectCords>(); const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false); const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => { useEffect(() => {
@@ -100,12 +102,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
}, [shareConfirm]); }, [shareConfirm]);
const [pttMode] = useSetting(settingsAtom, 'pttMode'); const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey'); const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled'); const [soundboardEnabled] = useSetting(settingsAtom, 'soundboardEnabled');
// [Gitea #9] PTT/deafen key handling and AFK auto-mute live in useCallHotkeys
// / useAfkAutoMute, mounted from CallEmbedProvider for the embed's lifetime
// (this component only renders while the call room is selected). Only the
// visual PTT chip remains here.
const pttActive = useAtomValue(pttActiveAtom);
// [P5-31] Hard room publish policy — hide controls the server will refuse so // [P5-31] Hard room publish policy — hide controls the server will refuse so
// users don't click dead buttons. Absent/true = allowed. // users don't click dead buttons. Absent/true = allowed.
@@ -118,6 +116,28 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const showCamera = cameraAllowed || video; const showCamera = cameraAllowed || video;
const showScreenshare = screenshareAllowed || screenshare; const showScreenshare = screenshareAllowed || screenshare;
const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled; const showVideoGroup = showCamera || showScreenshare || !!document.fullscreenEnabled;
const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => {
microphoneRef.current = microphone;
}, [microphone]);
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
const pttModeRef = useRef(pttMode);
const micBeforePTTRef = useRef<boolean | null>(null);
useEffect(() => {
if (pttMode && !pttModeRef.current) {
micBeforePTTRef.current = microphoneRef.current;
callEmbed.control.setMicrophone(false);
} else if (!pttMode && pttModeRef.current) {
callEmbed.control.setMicrophone(micBeforePTTRef.current ?? true);
micBeforePTTRef.current = null;
}
pttModeRef.current = pttMode;
}, [pttMode, callEmbed]);
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setCords(evt.currentTarget.getBoundingClientRect()); setCords(evt.currentTarget.getBoundingClientRect());
}; };
@@ -143,6 +163,114 @@ export function CallControls({ callEmbed }: CallControlsProps) {
); );
const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]); const handleVideoToggle = useCallback(() => callEmbed.control.toggleVideo(), [callEmbed]);
const pttActiveRef = useRef(false);
useEffect(() => {
if (!pttMode) return;
const iframeWindow = callEmbed.iframe.contentWindow;
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== pttKey || e.repeat) return;
const target = e.target as HTMLElement;
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
if (isEditable(target)) return;
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;
if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
pttActiveRef.current = true;
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onBlur = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onFocus = () => {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
window.addEventListener('focus', onFocus);
// BUG-9: also wire iframe blur/focus so stuck-mic release works when focus moves to iframe
iframeWindow?.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keyup', onKeyUp);
iframeWindow?.addEventListener('blur', onBlur);
iframeWindow?.addEventListener('focus', onFocus);
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
window.removeEventListener('focus', onFocus);
iframeWindow?.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keyup', onKeyUp);
iframeWindow?.removeEventListener('blur', onBlur);
iframeWindow?.removeEventListener('focus', onFocus);
// BUG-8: if callEmbed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) {
callEmbed.control.pttActive = false;
callEmbed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
}
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
}, [pttMode, pttKey, callEmbed]);
useEffect(() => {
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== deafenKey) return;
if (e.repeat) return;
if (isEditable(e.target as HTMLElement)) return;
e.preventDefault();
callEmbed.control.toggleSound();
};
// C-L4: also bind the EC iframe window so the deafen key works when focus is
// inside the iframe (mirrors the PTT binding above).
const iframeWindow = callEmbed.iframe.contentWindow;
window.addEventListener('keydown', onKeyDown);
iframeWindow?.addEventListener('keydown', onKeyDown);
return () => {
window.removeEventListener('keydown', onKeyDown);
iframeWindow?.removeEventListener('keydown', onKeyDown);
};
}, [callEmbed, deafenKey]);
const [hangupState, hangup] = useAsyncCallback( const [hangupState, hangup] = useAsyncCallback(
useCallback(() => callEmbed.hangup(), [callEmbed]), useCallback(() => callEmbed.hangup(), [callEmbed]),
); );
+9 -5
View File
@@ -18,7 +18,8 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher'; import { LotusDecorationPusher } from '../lotus/LotusDecorationPusher';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull'; import { useStateEvent } from '../../hooks/useStateEvent';
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { CallMemberRenderer } from './CallMemberCard'; import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css'; import * as css from './styles.css';
import { CallControls } from './CallControls'; import { CallControls } from './CallControls';
@@ -113,9 +114,12 @@ function CallPrescreen() {
const callEmbed = useCallEmbed(); const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId; const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
// [Gitea #30] Voice channel user limit (io.lotus.voice_limit), shared with the // Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
// room-nav join path via useVoiceChannelFull so both agree on "full". const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
const { channelFull, current: callMembersCount, max: maxUsers } = useVoiceChannelFull(room); const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull; const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
@@ -140,7 +144,7 @@ function CallPrescreen() {
<Box className={css.PrescreenMessage} alignItems="Center"> <Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall && !hasPermission && <NoPermissionMessage />} {!inOtherCall && !hasPermission && <NoPermissionMessage />}
{!inOtherCall && hasPermission && channelFull && ( {!inOtherCall && hasPermission && channelFull && (
<ChannelFullMessage current={callMembersCount} max={maxUsers} /> <ChannelFullMessage current={callMembers.length} max={maxUsers} />
)} )}
{!inOtherCall && hasPermission && !channelFull && ( {!inOtherCall && hasPermission && !channelFull && (
<JoinMessage <JoinMessage
+1 -2
View File
@@ -74,8 +74,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="400" size="400"
className={MobileTouchTarget} className={MobileTouchTarget}
onClick={() => onToggle()} onClick={() => onToggle()}
aria-label={enabled ? 'Deafen' : 'Undeafen'} aria-label={enabled ? 'Undeafen' : 'Deafen'}
aria-pressed={enabled}
outlined outlined
> >
<Icon <Icon
@@ -55,6 +55,7 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
const [submitState, submit] = useAsyncCallback( const [submitState, submit] = useAsyncCallback(
useCallback( useCallback(
async (next: RoomQualityContent) => { async (next: RoomQualityContent) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next); await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
}, },
[mx, room.roomId], [mx, room.roomId],
@@ -31,7 +31,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {}; const content: RetentionContent = ms > 0 ? { max_lifetime: ms } : {};
// Lotus custom-state convention: cast the type key (RoomRetention isn't a // Lotus custom-state convention: cast the type key (RoomRetention isn't a
// typed key in the SDK's StateEvents map). // 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); await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
}, },
[mx, room.roomId], [mx, room.roomId],
@@ -5,7 +5,6 @@ import {
DECORATION_CATEGORIES, DECORATION_CATEGORIES,
ALL_DECORATIONS, ALL_DECORATIONS,
decorationUrl, decorationUrl,
isValidDecorationSlug,
} from './avatarDecorations'; } from './avatarDecorations';
test('decorationUrl builds a CDN png url from the slug', () => { test('decorationUrl builds a CDN png url from the slug', () => {
@@ -67,20 +66,3 @@ test('slugs use the snake_case charset (lowercase, digits, underscore)', () => {
assert.match(decoration.slug, /^[a-z0-9_]+$/, `bad slug: ${decoration.slug}`); 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,19 +188,6 @@ export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap
(c) => c.decorations, (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 { export function decorationUrl(slug: string): string {
return `${RESOLVED_DECORATION_CDN}/${slug}.png`; return `${RESOLVED_DECORATION_CDN}/${slug}.png`;
} }
@@ -36,7 +36,6 @@ import { mDirectAtom } from '../../state/mDirectList';
import { getStateEvent } from '../../utils/room'; import { getStateEvent } from '../../utils/room';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { import {
filterGroupsByDateRange,
filterGroupsByMsgType, filterGroupsByMsgType,
filterGroupsByPinned, filterGroupsByPinned,
MessageSearchParams, MessageSearchParams,
@@ -317,21 +316,12 @@ export function MessageSearch({
getNextPageParam: (lastPage) => lastPage.nextToken, getNextPageParam: (lastPage) => lastPage.nextToken,
}); });
// Shared client-side post-filter (date range + msgtype + pinned) applied to // Shared client-side post-filter (msgtype + pinned) applied to BOTH the
// BOTH the server results and the local/encrypted-cache results, so the // server results and the local/encrypted-cache results, so the filter chips
// filter chips narrow the whole UI consistently rather than only the // narrow the whole UI consistently rather than only the server section.
// 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( const applyResultFilters = useCallback(
(allGroups: ResultGroup[]): ResultGroup[] => { (allGroups: ResultGroup[]): ResultGroup[] => {
const inDateRange = filterGroupsByDateRange( const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
allGroups,
msgSearchParams.fromTs,
msgSearchParams.toTs,
);
const byMsgType = filterGroupsByMsgType(inDateRange, msgTypeFilters);
if (!pinnedOnly) return byMsgType; if (!pinnedOnly) return byMsgType;
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here // Build a per-room pinned-event lookup. Heavy Matrix reads stay here
// (where `mx` is available); the pure helper only consumes the predicate. // (where `mx` is available); the pure helper only consumes the predicate.
@@ -353,7 +343,7 @@ export function MessageSearch({
}; };
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned); return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
}, },
[msgSearchParams.fromTs, msgSearchParams.toTs, msgTypeFilters, pinnedOnly, mx], [msgTypeFilters, pinnedOnly, mx],
); );
const groups = useMemo(() => { const groups = useMemo(() => {
@@ -1,56 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SearchCacheRow } from '../../utils/searchCache';
// useLocalMessageSearch.ts imports searchCacheEnabledAtom, which touches
// localStorage at module-load time (atomWithLocalStorage reads the initial
// value eagerly). Stub it before a dynamic import — a static import would
// hoist above the stub. Same pattern as state/plaintextCaches.test.ts.
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => {},
removeItem: () => {},
};
const { rowToResultItem } = await import('./useLocalMessageSearch');
const row = (overrides: Partial<SearchCacheRow> = {}): SearchCacheRow => ({
roomId: '!r1',
eventId: '$1',
ts: 100,
sender: '@a',
body: 'hello world',
...overrides,
});
// Gitea #14 — cached rows for a locally-known-redacted event must carry a
// `redacted_because` marker so SearchResultGroup's guard renders the
// "message deleted" placeholder instead of the stale plaintext.
test('rowToResultItem: plain row has no redacted_because marker', () => {
const item = rowToResultItem(row());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).unsigned?.redacted_because, undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).content.body, 'hello world');
});
test('rowToResultItem: redacted=true sets the redacted_because marker', () => {
const item = rowToResultItem(row(), true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.ok((item.event as any).unsigned?.redacted_because);
});
test('rowToResultItem: falls back to pollText when body is empty', () => {
const item = rowToResultItem(row({ body: '', pollText: 'question answer' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
assert.equal((item.event as any).content.body, 'question answer');
});
test('rowToResultItem: carries formattedBody as HTML when present', () => {
const item = rowToResultItem(row({ formattedBody: '<b>hi</b>' }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const content = (item.event as any).content;
assert.equal(content.format, 'org.matrix.custom.html');
assert.equal(content.formatted_body, '<b>hi</b>');
});
@@ -1,4 +1,4 @@
import { EventType, MatrixEvent, RelationType } from 'matrix-js-sdk'; import { EventType, MatrixEvent } from 'matrix-js-sdk';
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
@@ -38,15 +38,13 @@ type ExtractedText = {
const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start']; const POLL_START_TYPES = ['m.poll.start', 'org.matrix.msc3381.poll.start'];
/** /**
* Pull the text we index/search from an event type + content pair. Returns * Pull the text we index/search from a decrypted event's content. Returns
* `null` when there's no searchable text (e.g. stickers). Split out from * `null` for events that carry no searchable text (e.g. stickers).
* `extractText` so an edit's `m.new_content` can be run through the same
* logic as a normal event's content.
*/ */
const extractTextFromContent = ( const extractText = (event: MatrixEvent): ExtractedText | null => {
evType: string, const evType = event.getType();
content: Record<string, unknown>, const content = event.getContent();
): ExtractedText | null => {
if (POLL_START_TYPES.includes(evType)) { if (POLL_START_TYPES.includes(evType)) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any; const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as any;
@@ -76,13 +74,6 @@ const extractTextFromContent = (
return { body, formattedBody, pollText: '' }; return { body, formattedBody, pollText: '' };
}; };
/**
* Pull the text we index/search from a decrypted event's content. Returns
* `null` for events that carry no searchable text (e.g. stickers).
*/
const extractText = (event: MatrixEvent): ExtractedText | null =>
extractTextFromContent(event.getType(), event.getContent());
/** Does the extracted text contain the (already-lowercased) term? */ /** Does the extracted text contain the (already-lowercased) term? */
const matchesTerm = (text: ExtractedText, termLower: string): boolean => const matchesTerm = (text: ExtractedText, termLower: string): boolean =>
text.body.toLowerCase().includes(termLower) || text.body.toLowerCase().includes(termLower) ||
@@ -94,17 +85,8 @@ const rowMatchesTerm = (row: SearchCacheRow, termLower: string): boolean =>
(row.formattedBody ?? '').toLowerCase().includes(termLower) || (row.formattedBody ?? '').toLowerCase().includes(termLower) ||
(row.pollText ?? '').toLowerCase().includes(termLower); (row.pollText ?? '').toLowerCase().includes(termLower);
/** /** Build the synthetic result item a cached row renders as (text message). */
* Build the synthetic result item a cached row renders as (text message). const rowToResultItem = (row: SearchCacheRow): ResultItem => {
*
* `redacted` marks a row whose event we can tell, from the local timeline,
* has since been redacted (the async cache-delete listener in
* `searchCacheInvalidation.ts` may not have caught up yet). It carries a
* `redacted_because` marker on `unsigned` so `SearchResultGroup`'s existing
* guard renders the "message deleted" placeholder instead of the stale
* plaintext (Gitea #14).
*/
export const rowToResultItem = (row: SearchCacheRow, redacted = false): ResultItem => {
const bodyText = row.body || row.pollText || ''; const bodyText = row.body || row.pollText || '';
const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText }; const content: Record<string, unknown> = { msgtype: 'm.text', body: bodyText };
if (row.formattedBody) { if (row.formattedBody) {
@@ -118,7 +100,7 @@ export const rowToResultItem = (row: SearchCacheRow, redacted = false): ResultIt
sender: row.sender, sender: row.sender,
origin_server_ts: row.ts, origin_server_ts: row.ts,
content, content,
unsigned: redacted ? { redacted_because: { content: {} } } : {}, unsigned: {},
}; };
return { return {
rank: 0, rank: 0,
@@ -205,9 +187,8 @@ export const useLocalMessageSearch = () => {
const isMessageLike = const isMessageLike =
evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType); evType === EventType.RoomMessage || POLL_START_TYPES.includes(evType);
// Both modes are restricted to message-like events/stickers — sender-only // Sender-only mode indexes/returns all message types; text mode needs text.
// mode must not surface membership/state/reaction/redaction events (Gitea #62). if (!senderOnlyMode && !isMessageLike && !isSticker) continue;
if (!isMessageLike && !isSticker) continue;
const sender = event.getSender() ?? ''; const sender = event.getSender() ?? '';
const ts = event.getTs(); const ts = event.getTs();
@@ -215,33 +196,7 @@ export const useLocalMessageSearch = () => {
// Persist every indexable (text-bearing) event we scanned, regardless // Persist every indexable (text-bearing) event we scanned, regardless
// of whether it matches the current term — future searches benefit. // of whether it matches the current term — future searches benefit.
if (cacheEnabled && event.getId()) { if (cacheEnabled && text && event.getId()) {
// An edit (`m.replace`) event's own body is just a "* new text"
// fallback. Indexing it under its own event id would leave two
// separate matching rows (the stale pre-edit text and the edit
// fallback) searchable forever. Instead, upsert the *original*
// event's row with the edit's `m.new_content` (Gitea #14).
const editTargetId =
event.getRelation()?.rel_type === RelationType.Replace
? event.getRelation()?.event_id
: undefined;
if (editTargetId) {
const newContent = (event.getContent()['m.new_content'] ?? {}) as Record<
string,
unknown
>;
const editedText = extractTextFromContent(EventType.RoomMessage, newContent);
if (editedText) {
rowsToPersist.push({
roomId,
eventId: editTargetId,
ts: room.findEventById(editTargetId)?.getTs() ?? ts,
sender,
body: editedText.body,
...(editedText.formattedBody ? { formattedBody: editedText.formattedBody } : {}),
});
}
} else if (text) {
rowsToPersist.push({ rowsToPersist.push({
roomId, roomId,
eventId: event.getId() as string, eventId: event.getId() as string,
@@ -252,7 +207,6 @@ export const useLocalMessageSearch = () => {
...(text.pollText ? { pollText: text.pollText } : {}), ...(text.pollText ? { pollText: text.pollText } : {}),
}); });
} }
}
if (senderSet && !senderSet.has(sender)) continue; if (senderSet && !senderSet.has(sender)) continue;
if (!inRange(ts)) continue; if (!inRange(ts)) continue;
@@ -285,12 +239,7 @@ export const useLocalMessageSearch = () => {
if (senderSet && !senderSet.has(row.sender)) return; if (senderSet && !senderSet.has(row.sender)) return;
if (!inRange(row.ts)) return; if (!inRange(row.ts)) return;
if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return; if (!senderOnlyMode && !rowMatchesTerm(row, termLower)) return;
// The cache-delete listener (searchCacheInvalidation.ts) removes a cachedItems.push(rowToResultItem(row));
// row on redaction asynchronously; if the event is still around
// locally we can check for certain and must not surface stale
// plaintext in the meantime (Gitea #14).
const localEvent = room.findEventById(row.eventId);
cachedItems.push(rowToResultItem(row, localEvent?.isRedacted()));
}); });
const items = mergeSearchResults(memoryItems, cachedItems); const items = mergeSearchResults(memoryItems, cachedItems);
@@ -1,11 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
filterGroupsByDateRange,
filterGroupsByMsgType,
filterGroupsByPinned,
ResultGroup,
} from './useMessageSearch';
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read // Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
// (event.content.msgtype, event.event_id, group.roomId). // (event.content.msgtype, event.event_id, group.roomId).
@@ -14,11 +9,6 @@ const item = (msgtype: string | undefined, eventId: string) => ({
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } }, event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
context: {}, context: {},
}); });
const tsItem = (eventId: string, ts: number) => ({
rank: 1,
event: { event_id: eventId, origin_server_ts: ts, content: {} },
context: {},
});
const mkGroups = ( const mkGroups = (
...groups: { roomId: string; items: ReturnType<typeof item>[] }[] ...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
): ResultGroup[] => groups as unknown as ResultGroup[]; ): ResultGroup[] => groups as unknown as ResultGroup[];
@@ -58,33 +48,6 @@ test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
assert.equal(out[0].items[0].event.event_id, '$2'); 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', () => { test('filterGroupsByPinned: disabled returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] }); const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
assert.equal( assert.equal(
@@ -71,31 +71,6 @@ export const filterGroupsByPinned = (
.filter((group) => group.items.length > 0); .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 groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
@@ -144,9 +119,7 @@ export type MessageSearchParams = {
}; };
export const useMessageSearch = (params: MessageSearchParams) => { export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
// fromTs/toTs are intentionally not sent to the server (see comment below) — const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
// callers post-filter results with filterGroupsByDateRange instead.
const { term, order, rooms, senders, containsUrl } = params;
const searchMessages = useCallback( const searchMessages = useCallback(
async (nextBatch?: string) => { async (nextBatch?: string) => {
@@ -169,10 +142,9 @@ export const useMessageSearch = (params: MessageSearchParams) => {
limit, limit,
rooms, rooms,
senders, senders,
// `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not // from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
// Matrix filter fields and the homeserver silently drops them, so the ...(fromTs !== undefined && { from_ts: fromTs }),
// date range is instead enforced client-side (see filterGroupsByDateRange). ...(toTs !== undefined && { to_ts: toTs }),
// contains_url is a valid spec field not yet in SDK types.
...(containsUrl !== undefined && { contains_url: containsUrl }), ...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, } as any,
@@ -189,7 +161,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
}); });
return parseSearchResult(r); return parseSearchResult(r);
}, },
[mx, term, order, rooms, senders, containsUrl], [mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
); );
return searchMessages; return searchMessages;
+60 -31
View File
@@ -6,7 +6,7 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { Room } from 'matrix-js-sdk'; import { MatrixClient, Room } from 'matrix-js-sdk';
import { import {
Avatar, Avatar,
Box, Box,
@@ -42,6 +42,7 @@ import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../componen
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { setAccountData } from '../../utils/accountData';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
@@ -65,22 +66,19 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import { import {
getRoomNotificationModeIcon, getRoomNotificationModeIcon,
RoomNotificationMode, RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences'; } from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators'; import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions'; import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { import {
LOCAL_ROOM_NAMES_KEY,
getLocalRoomNamesContent, getLocalRoomNamesContent,
setLocalRoomName,
useHasLocalRoomName, useHasLocalRoomName,
useLocalRoomName, useLocalRoomName,
} from '../../hooks/useRoomMeta'; } from '../../hooks/useRoomMeta';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { callChatAtom } from '../../state/callEmbed'; import { callChatAtom } from '../../state/callEmbed';
import { createErrorToast, toastQueueAtom } from '../../state/toast'; import { createErrorToast, toastQueueAtom } from '../../state/toast';
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences'; import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
@@ -138,16 +136,22 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
const newName = inputRef.current?.value.trim() ?? ''; const newName = inputRef.current?.value.trim() ?? '';
if (newName.length > 255) return; if (newName.length > 255) return;
// Routed through the shared write queue (setLocalRoomName) instead of a const existing = getLocalRoomNamesContent(mx);
// read-modify-write against the SDK's local cache, which stays stale if (newName === '') {
// until the /sync echo lands and would otherwise let a second rename const { [room.roomId]: _removed, ...rest } = existing.rooms;
// clobber a still-in-flight first rename. setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
setLocalRoomName(mx, room.roomId, newName); } else {
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
rooms: { ...existing.rooms, [room.roomId]: newName },
});
}
onClose(); onClose();
}, [mx, room.roomId, onClose]); }, [mx, room.roomId, onClose]);
const handleClear = useCallback(() => { const handleClear = useCallback(() => {
setLocalRoomName(mx, room.roomId, ''); const existing = getLocalRoomNamesContent(mx);
const { [room.roomId]: _removed, ...rest } = existing.rooms;
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
onClose(); onClose();
}, [mx, room.roomId, onClose]); }, [mx, room.roomId, onClose]);
@@ -269,6 +273,49 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
); );
} }
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
type RoomNavItemMenuProps = { type RoomNavItemMenuProps = {
room: Room; room: Room;
requestClose: () => void; requestClose: () => void;
@@ -345,6 +392,8 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const handleMuteFor = useCallback( const handleMuteFor = useCallback(
async (durationMs: number | null) => { async (durationMs: number | null) => {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
const prevMode = notificationMode ?? RoomNotificationMode.Unset; const prevMode = notificationMode ?? RoomNotificationMode.Unset;
await setRoomNotificationPreference( await setRoomNotificationPreference(
mx, mx,
@@ -692,12 +741,8 @@ function RoomNavItem_({
const callMembers = useCallMembers(callSession); const callMembers = useCallMembers(callSession);
const startCall = useCallStart(direct); const startCall = useCallStart(direct);
const callEmbed = useCallEmbed(); const callEmbed = useCallEmbed();
// [Gitea #30] Same voice-limit check the call prescreen uses, so the sidebar
// second-click join path can't bypass a full channel.
const { channelFull, current: voiceCurrent, max: voiceMax } = useVoiceChannelFull(room);
const callPref = useAtomValue(useCallPreferencesAtom()); const callPref = useAtomValue(useCallPreferencesAtom());
const autoDiscoveryInfo = useAutoDiscoveryInfo(); const autoDiscoveryInfo = useAutoDiscoveryInfo();
const setToast = useSetAtom(toastQueueAtom);
const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => { const handleStartCall: MouseEventHandler<HTMLAnchorElement> = (evt) => {
const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels); const powerLevelsEvent = getStateEvent(room, StateEvent.RoomPowerLevels);
@@ -719,22 +764,6 @@ function RoomNavItem_({
if (callEmbed) { if (callEmbed) {
return; return;
} }
// [Gitea #30] Refuse to start a call into a full voice channel — the
// prescreen already blocks this, but the sidebar second-click join path
// skipped the check entirely.
if (channelFull) {
evt.preventDefault();
setToast(
createErrorToast(
`Channel full (${voiceCurrent}/${voiceMax})`,
Icons.Warning,
'Cannot join',
),
);
return;
}
// Start call in second click // Start call in second click
if (selected) { if (selected) {
evt.preventDefault(); evt.preventDefault();
@@ -1,20 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
});
test('does not reset when the user switched to All messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false);
});
test('does not reset when the user switched to Special messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false);
});
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
-88
View File
@@ -1,88 +0,0 @@
import { IPushRule, IPushRules, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData } from '../../utils/room';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import {
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
// Unset if the room's notification mode is still Mute at expiry time. If the user
// manually changed it (e.g. to All messages) while the timer was pending, leave
// their choice alone — just let the stale timer entry get dropped.
export function shouldResetMuteOnUnmute(currentMode: RoomNotificationMode): boolean {
return currentMode === RoomNotificationMode.Mute;
}
// Reads the room's live notification mode straight from account data push rules,
// mirroring useRoomsNotificationPreferences' per-room derivation, without needing
// the React hook (this runs from plain timers/effects, not components).
export function getLiveRoomNotificationMode(
mx: MatrixClient,
roomId: string,
): RoomNotificationMode {
const pushRules = getAccountData(mx, AccountDataEvent.PushRules)?.getContent<IPushRules>();
const global = pushRules?.global;
const overrideRule = global?.override?.find((rule: IPushRule) => rule.rule_id === roomId);
if (overrideRule && getNotificationMode(overrideRule.actions) === NotificationMode.OFF) {
return RoomNotificationMode.Mute;
}
const roomRule = global?.room?.find((rule: IPushRule) => rule.rule_id === roomId);
if (roomRule) {
return getNotificationMode(roomRule.actions) === NotificationMode.OFF
? RoomNotificationMode.SpecialMessages
: RoomNotificationMode.AllMessages;
}
return RoomNotificationMode.Unset;
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
// Only resets the mode if it is still Mute — otherwise a manual change made
// during the mute window (e.g. switching to "All messages") would silently get
// reverted when the stale timer fires.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const currentMode = getLiveRoomNotificationMode(mx, roomId);
if (shouldResetMuteOnUnmute(currentMode)) {
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
@@ -7,7 +7,6 @@ import { useRoom } from '../../hooks/useRoom';
import { useRoomName } from '../../hooks/useRoomMeta'; import { useRoomName } from '../../hooks/useRoomMeta';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { SequenceCardStyle } from '../common-settings/styles.css'; import { SequenceCardStyle } from '../common-settings/styles.css';
import { RawExportRecord, formatExportBody, resolveMessageEdits } from './exportRoomHistory.utils';
type ExportFormat = 'txt' | 'json' | 'html'; type ExportFormat = 'txt' | 'json' | 'html';
@@ -64,11 +63,15 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null; const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null; const toTs = toDate ? new Date(`${toDate}T23:59:59`).getTime() : null;
const rawRecords: RawExportRecord[] = []; type MsgRecord = {
// mxc/media-file URL for message events that carry one, keyed by eventId - ts: number;
// surfaced in the JSON export since the export never includes the actual sender: string;
// media (see the UI note below the Export button). body: string;
const mediaUrlByEventId = new Map<string, string>(); eventId: string;
msgtype: string;
};
const collected: MsgRecord[] = [];
// timeline.getEvents() returns the entire growing window on every call, // timeline.getEvents() returns the entire growing window on every call,
// so we must deduplicate by eventId to avoid re-adding the same events // so we must deduplicate by eventId to avoid re-adding the same events
// on each pagination step. // on each pagination step.
@@ -110,39 +113,11 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
if (fromTs !== null && ts < fromTs) continue; if (fromTs !== null && ts < fromTs) continue;
if (toTs !== null && ts > toTs) continue; if (toTs !== null && ts > toTs) continue;
const content = ev.getContent(); const content = ev.getContent();
// m.replace (edit) events must not become their own row — collect
// them separately and substitute the new content into the original
// event's row below, instead of adding a stale original + a garbled
// "* new text" duplicate line.
const relatesTo = content['m.relates_to'] as
| { rel_type?: string; event_id?: string }
| undefined;
if (relatesTo?.rel_type === 'm.replace' && relatesTo.event_id) {
const newContent = content['m.new_content'] as { body?: string } | undefined;
const newBody = newContent?.body ?? '';
if (!newBody) continue;
rawRecords.push({
eventId: evId,
ts,
sender: ev.getSender() ?? '',
body: '',
msgtype: '',
editsEventId: relatesTo.event_id,
newBody,
});
continue;
}
const body: string = content.body ?? ''; const body: string = content.body ?? '';
const msgtype: string = content.msgtype ?? ''; const msgtype: string = content.msgtype ?? '';
if (!body) continue; if (!body) continue;
if (ts < oldestTs) oldestTs = ts; if (ts < oldestTs) oldestTs = ts;
const mediaUrl = collected.push({
(content.url as string | undefined) ??
(content.file as { url?: string } | undefined)?.url;
if (mediaUrl) mediaUrlByEventId.set(evId, mediaUrl);
rawRecords.push({
ts, ts,
sender: ev.getSender() ?? '', sender: ev.getSender() ?? '',
body, body,
@@ -150,9 +125,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
msgtype, msgtype,
}); });
} }
// Approximate progress — excludes edit rows, which never become their setExportCount(collected.length);
// own line in the final (edit-resolved) output.
setExportCount(rawRecords.filter((r) => !r.editsEventId).length);
}; };
await addEvents(timeline.getEvents()); await addEvents(timeline.getEvents());
@@ -186,10 +159,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
await addEvents(timeline.getEvents()); await addEvents(timeline.getEvents());
} }
// Resolve m.replace edits against their target row (drops the edit rows,
// substitutes m.new_content into the original) before sorting/rendering.
const collected = resolveMessageEdits(rawRecords);
if (cancelled) { if (cancelled) {
setNotice(`Export cancelled after ${collected.length} messages.`); setNotice(`Export cancelled after ${collected.length} messages.`);
return; return;
@@ -217,7 +186,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const d = new Date(msg.ts); const d = new Date(msg.ts);
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
lines.push(`[${dateLabel}] ${msg.sender}: ${formatExportBody(msg)}`); lines.push(`[${dateLabel}] ${msg.sender}: ${msg.body}`);
} }
content = lines.join('\n'); content = lines.join('\n');
mimeType = 'text/plain'; mimeType = 'text/plain';
@@ -232,8 +201,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
body: m.body, body: m.body,
eventId: m.eventId, eventId: m.eventId,
type: m.msgtype, type: m.msgtype,
edited: m.edited,
mediaUrl: mediaUrlByEventId.get(m.eventId),
})), })),
}; };
content = JSON.stringify(payload, null, 2); content = JSON.stringify(payload, null, 2);
@@ -253,7 +220,7 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
const d = new Date(msg.ts); const d = new Date(msg.ts);
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; const dateLabel = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(formatExportBody(msg))}</span></div>`; return `<div class="msg"><span class="ts">[${esc(dateLabel)}]</span> <span class="sender">${esc(msg.sender)}</span><span class="body">: ${esc(msg.body)}</span></div>`;
}) })
.join('\n'); .join('\n');
@@ -421,11 +388,6 @@ ${msgRows}
{notice} {notice}
</Text> </Text>
)} )}
<Text size="T200" priority="300">
Attachments (images, videos, audio, files) are not included in the export -
media messages are marked with a type label (e.g. &quot;[Image]&quot;) but only
their filename is exported, not the file itself.
</Text>
</SequenceCard> </SequenceCard>
</Box> </Box>
</Box> </Box>
@@ -24,14 +24,6 @@ const POLICY_USER_EVENT = 'm.policy.rule.user';
const POLICY_ROOM_EVENT = 'm.policy.rule.room'; const POLICY_ROOM_EVENT = 'm.policy.rule.room';
const POLICY_SERVER_EVENT = 'm.policy.rule.server'; 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 = { type PolicyRuleContent = {
entity?: string; entity?: string;
reason?: string; reason?: string;
@@ -84,23 +76,6 @@ function extractPolicyEntries(events: MatrixEvent[]): PolicyEntry[] {
.filter((entry) => entry.entity !== ''); .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 ───────────────────────────────────────────────────────────────── // ── Entry row ─────────────────────────────────────────────────────────────────
function PolicyEntryRow({ entry }: { entry: PolicyEntry }) { function PolicyEntryRow({ entry }: { entry: PolicyEntry }) {
@@ -226,24 +201,9 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
return; return;
} }
setUserEntries( setUserEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)));
dedupePolicyEntries([ setRoomEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_ROOM_EVENT)));
...extractPolicyEntries(getRoomPolicyEvents(room, POLICY_USER_EVENT)), setServerEntries(extractPolicyEntries(getRoomPolicyEvents(room, POLICY_SERVER_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); setLoadedRoomId(roomId);
setError(undefined); setError(undefined);
}, [mx, roomIdInput]); }, [mx, roomIdInput]);
@@ -63,26 +63,8 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
const prevMembership = prevContent.membership as string | undefined; const prevMembership = prevContent.membership as string | undefined;
const reason = content.reason as string | undefined; const reason = content.reason as string | undefined;
const targetName = getDisplayName(mx, stateKey); const targetName = getDisplayName(mx, stateKey);
// getPrevContent() falls back to {} when unsigned.prev_content is missing,
// which is common for state events fetched via back-pagination (homeservers
// don't always include it in /messages). That makes prevMembership
// indistinguishable from "no prior membership" (fresh join). Check the raw
// unsigned field so we only apply the join/leave-transition assumptions
// below to events where the SDK actually gave us prior state.
const hasPrevContent = ev.getUnsigned().prev_content !== undefined;
if (membership === 'join') { if (membership === 'join') {
if (!hasPrevContent) {
return {
text: (
<>
<strong>{targetName}</strong>&apos;s membership changed to <strong>joined</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if ( if (
prevMembership === 'invite' || prevMembership === 'invite' ||
prevMembership === 'knock' || prevMembership === 'knock' ||
@@ -112,17 +94,6 @@ function describeEvent(mx: ReturnType<typeof useMatrixClient>, ev: MatrixEvent):
} }
if (membership === 'leave') { if (membership === 'leave') {
if (!hasPrevContent) {
return {
text: (
<>
<strong>{targetName}</strong>&apos;s membership changed to <strong>left</strong>
</>
),
iconSrc: Icons.User,
filter: 'members',
};
}
if (prevMembership === 'ban') { if (prevMembership === 'ban') {
return { return {
text: ( text: (
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds'; import { Avatar, Box, Icon, IconButton, Icons, IconSrc, Scroll, Text, color, config } from 'folds';
import { EventType, MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk'; import { EventType } from 'matrix-js-sdk';
import { Page, PageContent, PageHeader } from '../../components/page'; import { Page, PageContent, PageHeader } from '../../components/page';
import { SequenceCard } from '../../components/sequence-card'; import { SequenceCard } from '../../components/sequence-card';
import { useRoom } from '../../hooks/useRoom'; import { useRoom } from '../../hooks/useRoom';
@@ -20,15 +20,6 @@ function formatDate(ts: number): string {
}); });
} }
function formatUpdatedAt(ts: number): string {
return new Date(ts).toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
}
// Throttle window for re-computing stats on new timeline events - avoids
// re-running every heatmap/list computation on every single incoming message
// during a burst.
const RECOMPUTE_THROTTLE_MS = 2000;
// ── Section header ──────────────────────────────────────────────────────────── // ── Section header ────────────────────────────────────────────────────────────
function SectionHeader({ label }: { label: string }) { function SectionHeader({ label }: { label: string }) {
@@ -78,52 +69,6 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
const room = useRoom(); const room = useRoom();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const [lastUpdated, setLastUpdated] = useState(() => Date.now());
// Bumped to force the stats useMemo below to re-run; the value itself is unused.
const [recomputeTick, setRecomputeTick] = useState(0);
const recomputeNow = useCallback(() => {
setRecomputeTick((n) => n + 1);
setLastUpdated(Date.now());
}, []);
// Stats were previously computed once (keyed only on `room`, whose reference
// never changes) and never reflected new activity while the panel stayed
// open. Re-run on new timeline events for this room, throttled so a burst of
// messages doesn't recompute on every single event.
useEffect(() => {
let throttleTimer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
const scheduleTrailing = () => {
throttleTimer = setTimeout(() => {
if (pending) {
pending = false;
recomputeNow();
scheduleTrailing();
} else {
throttleTimer = undefined;
}
}, RECOMPUTE_THROTTLE_MS);
};
const handleTimeline = (_event: MatrixEvent, eventRoom: Room | undefined) => {
if (eventRoom?.roomId !== room.roomId) return;
if (throttleTimer) {
pending = true;
return;
}
recomputeNow();
scheduleTrailing();
};
mx.on(RoomEvent.Timeline, handleTimeline);
return () => {
mx.removeListener(RoomEvent.Timeline, handleTimeline);
if (throttleTimer) clearTimeout(throttleTimer);
};
}, [mx, room, recomputeNow]);
const stats = useMemo(() => { const stats = useMemo(() => {
const events = room.getLiveTimeline().getEvents(); const events = room.getLiveTimeline().getEvents();
@@ -192,10 +137,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
newestTs, newestTs,
totalCached: events.length, totalCached: events.length,
}; };
// recomputeTick is intentionally in the deps (unused in the body) - it's the }, [room]);
// signal bumped by the timeline listener above to force this to re-run.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, recomputeTick]);
const maxHour = Math.max(...stats.hourBuckets, 1); const maxHour = Math.max(...stats.hourBuckets, 1);
const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1; const maxMsgCount = stats.top5.length > 0 ? (stats.top5[0]?.[1] ?? 1) : 1;
@@ -225,7 +167,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
{/* ── Disclaimer banner ── */} {/* ── Disclaimer banner ── */}
<SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center"> <SequenceCard variant="SurfaceVariant" gap="200" alignItems="Center">
<Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} /> <Icon src={Icons.Warning} size="200" style={{ color: color.Warning.Main }} />
<Box grow="Yes" direction="Column" gap="100"> <Box direction="Column" gap="100">
<Text size="T300"> <Text size="T300">
<strong> <strong>
Based on {stats.totalMessages} locally cached message Based on {stats.totalMessages} locally cached message
@@ -237,20 +179,6 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)} from {formatDate(stats.oldestTs)} to {formatDate(stats.newestTs)}
</Text> </Text>
)} )}
<Text size="T200" priority="300">
Last updated {formatUpdatedAt(lastUpdated)}
</Text>
</Box>
<Box shrink="No">
<IconButton
onClick={recomputeNow}
variant="SurfaceVariant"
size="300"
radii="300"
aria-label="Refresh insights"
>
<Icon src={Icons.Reload} size="100" />
</IconButton>
</Box> </Box>
</SequenceCard> </SequenceCard>
@@ -341,7 +341,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
variant="Primary" variant="Primary"
/> />
<Box direction="Column" gap="0"> <Box direction="Column" gap="0">
{} {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label <label
htmlFor="allow-ip-literals" htmlFor="allow-ip-literals"
style={{ cursor: canEdit ? 'pointer' : 'default' }} style={{ cursor: canEdit ? 'pointer' : 'default' }}
@@ -1,93 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
resolveMessageEdits,
formatExportBody,
RawExportRecord,
ResolvedExportRecord,
} from './exportRoomHistory.utils';
const msg = (over: Partial<RawExportRecord>): RawExportRecord => ({
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'hello',
msgtype: 'm.text',
...over,
});
test('resolveMessageEdits leaves unedited messages untouched', () => {
const out = resolveMessageEdits([msg({ eventId: '$1', body: 'hi' })]);
assert.deepEqual(out, [
{
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'hi',
msgtype: 'm.text',
edited: false,
},
]);
});
test('resolveMessageEdits substitutes the new content into the original row and drops the edit row', () => {
const out = resolveMessageEdits([
msg({ eventId: '$1', body: 'origianl typo' }),
msg({ eventId: '$2', editsEventId: '$1', newBody: 'original fixed' }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].eventId, '$1');
assert.equal(out[0].body, 'original fixed');
assert.equal(out[0].edited, true);
});
test('resolveMessageEdits works regardless of whether the edit appears before its target', () => {
const out = resolveMessageEdits([
msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' }),
msg({ eventId: '$1', body: 'orig' }),
]);
assert.equal(out.length, 1);
assert.equal(out[0].body, 'fixed');
assert.equal(out[0].edited, true);
});
test('resolveMessageEdits ignores an edit with no matching target (target never collected)', () => {
const out = resolveMessageEdits([msg({ eventId: '$2', editsEventId: '$1', newBody: 'fixed' })]);
assert.deepEqual(out, []);
});
test('formatExportBody prefixes media messages with a type marker', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'photo.jpg',
msgtype: 'm.image',
edited: false,
};
assert.equal(formatExportBody(record), '[Image] photo.jpg');
});
test('formatExportBody appends an (edited) suffix', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'fixed text',
msgtype: 'm.text',
edited: true,
};
assert.equal(formatExportBody(record), 'fixed text (edited)');
});
test('formatExportBody combines media prefix and edited suffix', () => {
const record: ResolvedExportRecord = {
eventId: '$1',
ts: 1000,
sender: '@alice:example.org',
body: 'photo.jpg',
msgtype: 'm.file',
edited: true,
};
assert.equal(formatExportBody(record), '[File] photo.jpg (edited)');
});
@@ -1,76 +0,0 @@
// Pure helpers for ExportRoomHistory.tsx, kept SDK/DOM-free so they're easy to
// unit test in isolation.
export type RawExportRecord = {
eventId: string;
ts: number;
sender: string;
body: string;
msgtype: string;
// Set when this record IS an m.replace edit event, to the event id it targets.
editsEventId?: string;
// The m.new_content.body carried by an m.replace edit event.
newBody?: string;
};
export type ResolvedExportRecord = {
eventId: string;
ts: number;
sender: string;
body: string;
msgtype: string;
edited: boolean;
};
// Human-readable label for media msgtypes, used to prefix txt/html export rows
// so a media message isn't indistinguishable from a plain-text one.
export const MEDIA_TYPE_LABELS: Partial<Record<string, string>> = {
'm.image': 'Image',
'm.video': 'Video',
'm.audio': 'Audio',
'm.file': 'File',
};
/**
* Resolves m.replace (edit) events against the original message they target:
* the edit event is dropped from the output (it should not appear as its own
* row), and the original message's body is replaced with the edit's
* `m.new_content.body`, marked `edited: true`.
*
* Order-independent: edits may appear before or after their target in the
* input (e.g. across separate back-pagination batches).
*/
export function resolveMessageEdits(records: RawExportRecord[]): ResolvedExportRecord[] {
const edits = new Map<string, string>();
for (const record of records) {
if (record.editsEventId && record.newBody) {
edits.set(record.editsEventId, record.newBody);
}
}
const resolved: ResolvedExportRecord[] = [];
for (const record of records) {
// Edit events never become their own row.
if (record.editsEventId) continue;
const editedBody = edits.get(record.eventId);
resolved.push({
eventId: record.eventId,
ts: record.ts,
sender: record.sender,
body: editedBody ?? record.body,
msgtype: record.msgtype,
edited: editedBody !== undefined,
});
}
return resolved;
}
// Prefixes a media message's body with a "[Image]"-style marker for txt/html
// export output, and appends an "(edited)" suffix when applicable. Plain-text
// messages are returned unchanged (aside from the edited suffix).
export function formatExportBody(record: ResolvedExportRecord): string {
const label = MEDIA_TYPE_LABELS[record.msgtype];
const mediaPrefix = label ? `[${label}] ` : '';
const editedSuffix = record.edited ? ' (edited)' : '';
return `${mediaPrefix}${record.body}${editedSuffix}`;
}
+2 -52
View File
@@ -30,7 +30,6 @@ import {
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk'; import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
import { useVirtualizer } from '@tanstack/react-virtual'; import { useVirtualizer } from '@tanstack/react-virtual';
import classNames from 'classnames'; import classNames from 'classnames';
import { useSetAtom } from 'jotai';
import { MobileTouchTarget } from '../../styles/mobile.css'; import { MobileTouchTarget } from '../../styles/mobile.css';
import { Membership } from '../../../types/matrix/room'; import { Membership } from '../../../types/matrix/room';
@@ -72,7 +71,6 @@ import { MemberVerificationBadge } from '../../components/MemberVerificationBadg
import { useUserPresence } from '../../hooks/useUserPresence'; import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence'; import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration'; import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
import { createErrorToast, toastQueueAtom } from '../../state/toast';
type MemberDrawerHeaderProps = { type MemberDrawerHeaderProps = {
room: Room; room: Room;
@@ -241,55 +239,11 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
const myUserId = mx.getUserId(); const myUserId = mx.getUserId();
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined); const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite'); const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
const kickPowerLevel = readPowerLevel.action(powerLevels, 'kick');
const canApproveKnock = myPowerLevel >= invitePowerLevel; const canApproveKnock = myPowerLevel >= invitePowerLevel;
// Deny uses mx.kick, which is gated by the room's kick power level, not invite -
// these can differ (e.g. moderators can invite but only admins can kick).
const canDenyKnock = myPowerLevel >= kickPowerLevel;
const knockMembers = useMemo( const knockMembers = useMemo(
() => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []), () => (canApproveKnock ? room.getMembersWithMembership(Membership.Knock) : []),
[room, canApproveKnock], [room, canApproveKnock],
); );
const setToast = useSetAtom(toastQueueAtom);
const [pendingKnockAction, setPendingKnockAction] = useState<string | undefined>(undefined);
const handleApproveKnock = useCallback(
(userId: string) => {
setPendingKnockAction(userId);
mx.invite(room.roomId, userId)
.catch((err: unknown) => {
console.error('Failed to approve knock request:', err);
setToast(
createErrorToast(
'Could not approve this request. Please try again.',
Icons.Warning,
'Failed',
),
);
})
.finally(() => setPendingKnockAction(undefined));
},
[mx, room.roomId, setToast],
);
const handleDenyKnock = useCallback(
(userId: string) => {
setPendingKnockAction(userId);
mx.kick(room.roomId, userId)
.catch((err: unknown) => {
console.error('Failed to deny knock request:', err);
setToast(
createErrorToast(
'Could not deny this request. Please try again.',
Icons.Warning,
'Failed',
),
);
})
.finally(() => setPendingKnockAction(undefined));
},
[mx, room.roomId, setToast],
);
const filteredMembers = useMemo( const filteredMembers = useMemo(
() => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort), () => members.filter(membershipFilter.filterFn).sort(memberSort.sortFn).sort(memberPowerSort),
@@ -508,24 +462,20 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
radii="300" radii="300"
fill="Soft" fill="Soft"
className={MobileTouchTarget} className={MobileTouchTarget}
disabled={pendingKnockAction === knockMember.userId} onClick={() => mx.invite(room.roomId, knockMember.userId)}
onClick={() => handleApproveKnock(knockMember.userId)}
> >
<Text size="B300">Approve</Text> <Text size="B300">Approve</Text>
</Button> </Button>
{canDenyKnock && (
<Button <Button
size="300" size="300"
variant="Critical" variant="Critical"
radii="300" radii="300"
fill="Soft" fill="Soft"
className={MobileTouchTarget} className={MobileTouchTarget}
disabled={pendingKnockAction === knockMember.userId} onClick={() => mx.kick(room.roomId, knockMember.userId)}
onClick={() => handleDenyKnock(knockMember.userId)}
> >
<Text size="B300">Deny</Text> <Text size="B300">Deny</Text>
</Button> </Button>
)}
</Box> </Box>
</Box> </Box>
); );
+2 -6
View File
@@ -25,12 +25,10 @@ import { useModalStyle } from '../../hooks/useModalStyle';
interface PollCreatorProps { interface PollCreatorProps {
roomId: string; roomId: string;
room: Room; room: Room;
/** Set when the composer is inside a thread so the poll lands in that thread. */
threadRootId?: string;
onClose: () => void; onClose: () => void;
} }
export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps) { export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const modalStyle = useModalStyle(440); const modalStyle = useModalStyle(440);
const [question, setQuestion] = useState(''); const [question, setQuestion] = useState('');
@@ -87,9 +85,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join( const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
'\n', '\n',
); );
// Pass the thread id explicitly (like the sticker path in RoomInput); the await mx.sendEvent(roomId, 'm.poll.start' as any, {
// legacy 3-arg form always resolves to the main timeline.
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
'm.poll': { 'm.poll': {
question: { 'm.text': trimmedQuestion }, question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })), answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
+27 -91
View File
@@ -105,7 +105,6 @@ import {
settingsAtom, settingsAtom,
} from '../../state/settings'; } from '../../state/settings';
import { import {
buildCompressedUploadItem,
getAudioMsgContent, getAudioMsgContent,
getFileMsgContent, getFileMsgContent,
getImageMsgContent, getImageMsgContent,
@@ -241,18 +240,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showFormat = composerToolbarButtons?.showFormat ?? true; const showFormat = composerToolbarButtons?.showFormat ?? true;
const showEmoji = composerToolbarButtons?.showEmoji ?? true; const showEmoji = composerToolbarButtons?.showEmoji ?? true;
const showSticker = composerToolbarButtons?.showSticker ?? true; const showSticker = composerToolbarButtons?.showSticker ?? true;
// [Gitea #68] The GIF picker is opt-in (searches go to Giphy); hide the const showGif = composerToolbarButtons?.showGif ?? true;
// toolbar button entirely when it's off so it never opens an empty popover.
const [gifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
const showGif = (composerToolbarButtons?.showGif ?? true) && gifPickerEnabled;
const showLocation = composerToolbarButtons?.showLocation ?? true; const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true; const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true; const showVoice = composerToolbarButtons?.showVoice ?? true;
// Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms: // Schedule-send is hidden in thread mode (v1 reduction).
// MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
// SDK's encryption pipeline, so scheduling in an E2EE room would leak the body.
const showSchedule =
(composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted;
const composerButtonOrder = useMemo( const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order), () => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order], [composerToolbarButtons?.order],
@@ -401,20 +394,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
try { try {
const stored = localStorage.getItem(`draft-msg-${draftKey}`); const stored = localStorage.getItem(`draft-msg-${draftKey}`);
if (stored) { if (stored) {
const parsed = JSON.parse(stored); const nodes = 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) { if (Array.isArray(nodes) && nodes.length > 0) {
Transforms.insertFragment(editor, nodes); Transforms.insertFragment(editor, nodes);
// Mirror the restored draft into the atom so the draft indicator // Mirror the restored draft into the atom so the draft indicator
@@ -423,25 +403,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setMsgDraft(nodes); setMsgDraft(nodes);
} }
} }
}
} catch { } catch {
// Ignore malformed stored draft // Ignore malformed stored draft
} }
} }
}, [editor, msgDraft, draftKey, setMsgDraft, mx]); }, [editor, msgDraft, draftKey, setMsgDraft]);
useEffect( useEffect(
() => () => { () => () => {
if (!isEmptyEditor(editor)) { if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children)); const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft); setMsgDraft(parsedDraft);
// [Gitea #41] Tag the persisted draft with the writing user's id so a localStorage.setItem(`draft-msg-${draftKey}`, JSON.stringify(parsedDraft));
// 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 { } else {
setMsgDraft([]); setMsgDraft([]);
localStorage.removeItem(`draft-msg-${draftKey}`); localStorage.removeItem(`draft-msg-${draftKey}`);
@@ -449,7 +422,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
}, },
[draftKey, editor, setMsgDraft, mx], [draftKey, editor, setMsgDraft],
); );
const handleFileMetadata = useCallback( const handleFileMetadata = useCallback(
@@ -512,14 +485,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const compressedFile = new File([compressionResult.blob], compressedName, { const compressedFile = new File([compressionResult.blob], compressedName, {
type: compressedType, type: compressedType,
}); });
// Compression re-encodes the image, so in an encrypted room the new const uploadRes = await mx.uploadContent(compressedFile, {
// 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, name: compressedName,
type: compressedType, type: compressedType,
}); });
@@ -528,13 +494,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
// Delete the pre-uploaded original so only one copy lives on the server. // Delete the pre-uploaded original so only one copy lives on the server.
tryDeleteMxcContent(mx, upload.mxc); tryDeleteMxcContent(mx, upload.mxc);
mxc = compressedMxc; mxc = compressedMxc;
// Synthetic fileItem referring to the compressed file so // Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions, type and encInfo. // getImageMsgContent picks up the correct dimensions and type.
const compressedItem = buildCompressedUploadItem( const compressedItem = {
fileItem, ...fileItem,
compressedFile, file: compressedFile,
encrypted, originalFile: compressedFile,
); };
return getImageMsgContent(mx, compressedItem, mxc); return getImageMsgContent(mx, compressedItem, mxc);
} }
} }
@@ -731,14 +697,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, [editor, isMarkdown, mx, roomId, replyDraft]); }, [editor, isMarkdown, mx, roomId, replyDraft]);
const handleScheduleClick = useCallback(() => { 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. // Pre-fill from editor if there's content; open blank if editor is empty.
const content = buildCurrentTextContent(); const content = buildCurrentTextContent();
setScheduleContent(content); setScheduleContent(content);
setScheduleOpen(true); setScheduleOpen(true);
}, [buildCurrentTextContent, isEncrypted]); }, [buildCurrentTextContent]);
const handleScheduled = useCallback( const handleScheduled = useCallback(
(delayId: string, sendAt: number, content: IContent) => { (delayId: string, sendAt: number, content: IContent) => {
@@ -860,38 +823,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return; return;
} }
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' }); const uploadRes = await mx.uploadContent(
const baseContent = { new File([blob], 'image.gif', { type: 'image/gif' }),
{ type: 'image/gif', name: 'image.gif', includeFilename: false },
);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
msgtype: MsgType.Image, msgtype: MsgType.Image,
body: 'image.gif', body: 'image.gif',
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, url: mxcUrl,
} as any); info: { mimetype: 'image/gif', w, h, size: blob.size },
} });
} catch (e) { } catch (e) {
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error'); console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
if (!alive()) return; if (!alive()) return;
@@ -901,7 +844,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false); if (alive()) setGifUploading(false);
} }
}, },
[mx, room, roomId, threadRootId, alive], [mx, roomId, threadRootId, alive],
); );
const handleStickerSelect = useCallback( const handleStickerSelect = useCallback(
@@ -1503,14 +1446,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</> </>
} }
/> />
{pollOpen && ( {pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
<PollCreator
room={room}
roomId={roomId}
threadRootId={threadRootId}
onClose={() => setPollOpen(false)}
/>
)}
{scheduleOpen && ( {scheduleOpen && (
<ScheduleMessageModal <ScheduleMessageModal
roomId={roomId} roomId={roomId}
@@ -42,7 +42,7 @@ import {
recentForwardTargetsAtom, recentForwardTargetsAtom,
addRecentForwardTarget, addRecentForwardTarget,
} from '../../../state/recentForwardTargets'; } from '../../../state/recentForwardTargets';
import { buildForwardContent, buildPlaintextAttachmentContent } from './forwardContent'; import { buildForwardContent } from './forwardContent';
type RoomRowProps = { type RoomRowProps = {
room: Room; room: Room;
@@ -314,43 +314,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
setError(null); setError(null);
const ids = [...selectedRoomIds]; const ids = [...selectedRoomIds];
// `fwdContent.file` (present on encrypted attachments) carries the AES key/iv
// needed to decrypt it. Sending it as-is into a room that isn't encrypted would
// publish that key in plaintext (Gitea #63), so any unencrypted destination gets
// a decrypted-and-re-uploaded plaintext version instead. Built once (not per
// room) since every unencrypted destination gets the same re-upload.
let plaintextContent: Record<string, unknown> | undefined;
let plaintextContentError: string | undefined;
if (fwdContent.file) {
const needsPlaintext = ids.some((id) => !mx.getRoom(id)?.hasEncryptionStateEvent());
if (needsPlaintext) {
try {
plaintextContent = await buildPlaintextAttachmentContent(
mx,
fwdContent,
useAuthentication,
);
} catch {
plaintextContentError = 'Could not prepare this attachment for an unencrypted room.';
}
}
}
const commentBody = comment.trim(); const commentBody = comment.trim();
const results = await Promise.allSettled( const results = await Promise.allSettled(
ids.map((id) => { ids.map((id) => {
const destEncrypted = !!mx.getRoom(id)?.hasEncryptionStateEvent();
// Encrypted destinations keep the original (possibly encrypted-attachment)
// content; unencrypted ones get the plaintext version, or fail outright if
// that couldn't be built — never fall back to sending the encrypted `file`.
const contentToSend = fwdContent.file && !destEncrypted ? plaintextContent : fwdContent;
if (fwdContent.file && !destEncrypted && !contentToSend) {
return Promise.reject(new Error(plaintextContentError));
}
// threadId-aware overload (P3-8): explicit null = send to the main timeline. // threadId-aware overload (P3-8): explicit null = send to the main timeline.
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, contentToSend); const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
// Send the optional comment first so it reads as a note above the // Send the optional comment first so it reads as a note above the
// forwarded content. The room counts as failed if either send rejects. // forwarded content. The room counts as failed if either send rejects.
// Track rooms whose comment already landed so a retry (after the FORWARD // Track rooms whose comment already landed so a retry (after the FORWARD
@@ -401,7 +370,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
return; return;
} }
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`); setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents, useAuthentication]); }, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
return ( return (
<Overlay open backdrop={<OverlayBackdrop />}> <Overlay open backdrop={<OverlayBackdrop />}>
@@ -1273,9 +1273,6 @@ export const Message = React.memo(
const content = mEvent.getContent(); const content = mEvent.getContent();
const body: string = const body: string =
(content?.body as string | undefined) ?? ''; (content?.body as string | undefined) ?? '';
// For E2EE rooms useBookmarks strips the text
// fields before persisting (account data is
// server-readable); the panel resolves them live.
addBookmark({ addBookmark({
roomId: room.roomId, roomId: room.roomId,
eventId, eventId,
@@ -1,8 +1,5 @@
import { MatrixClient, MatrixEvent } from 'matrix-js-sdk'; import { MatrixClient, MatrixEvent } from 'matrix-js-sdk';
import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room'; import { getEditedEvent, trimReplyFromBody, trimReplyFromFormattedBody } from '../../../utils/room';
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../../utils/matrix';
import { FALLBACK_MIMETYPE } from '../../../utils/mimeTypes';
import { IEncryptedFile } from '../../../../types/matrix/common';
/** /**
* Build the content to forward: * Build the content to forward:
@@ -43,44 +40,3 @@ export function buildForwardContent(
} }
return content; return content;
} }
/**
* `content.file` on an encrypted attachment carries the AES key/iv/hashes
* needed to decrypt it. Forwarding that content verbatim into a room that
* isn't itself encrypted would publish the key in plaintext to anyone who can
* read the destination room (Gitea #63). Re-encrypting for the destination is
* out of scope, so instead download+decrypt the attachment here and re-upload
* it as a plain (unencrypted) upload, sending `url` in place of `file`.
*
* Throws if the attachment can't be fetched/decrypted — callers must treat
* that as a hard failure for this forward rather than falling back to
* sending the encrypted `file` block into the plaintext room.
*/
export async function buildPlaintextAttachmentContent(
mx: MatrixClient,
content: Record<string, unknown>,
useAuthentication: boolean,
): Promise<Record<string, unknown>> {
const file = content.file as IEncryptedFile;
const info = content.info as Record<string, unknown> | undefined;
const mimeType = (info?.mimetype as string | undefined) ?? FALLBACK_MIMETYPE;
const mediaUrl = mxcUrlToHttp(mx, file.url, useAuthentication);
if (!mediaUrl) throw new Error('Invalid attachment URL');
const blob = await downloadEncryptedMedia(mediaUrl, (buf) => decryptFile(buf, mimeType, file));
const uploadResult = await mx.uploadContent(blob, { type: mimeType });
const plainContent = { ...content };
delete plainContent.file;
plainContent.url = uploadResult.content_uri;
// The thumbnail can carry its own encryption key (`thumbnail_file`); drop it
// rather than leak it too — the full attachment still forwards fine without
// a thumbnail.
if (info && (info.thumbnail_file || info.thumbnail_url)) {
const { thumbnail_file: _tf, thumbnail_url: _tu, ...restInfo } = info;
plainContent.info = restInfo;
}
return plainContent;
}
@@ -1,73 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { buildCompressedUploadItem } from './msgContent';
import { TUploadItem } from '../../state/room/roomInputDrafts';
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
// (if any) the resulting m.image event carries. Getting this wrong either leaks
// a plaintext image into an E2EE room or produces an undecryptable attachment.
const enc = (tag: string): EncryptedAttachmentInfo =>
({
v: 'v2',
key: { alg: 'A256CTR', k: tag },
iv: `iv-${tag}`,
hashes: { sha256: `sha-${tag}` },
}) as unknown as EncryptedAttachmentInfo;
const fakeFile = (name: string, size: number): File =>
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
({
file: fakeFile('photo.png', 900),
originalFile: fakeFile('photo.png', 900),
encInfo,
metadata: { markedAsSpoiler: false, compressImage: true },
}) as unknown as TUploadItem;
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
const compressed = fakeFile('photo.jpg', 300);
const item = buildCompressedUploadItem(makeItem(), compressed);
assert.equal(item.file, compressed);
assert.equal(item.originalFile, compressed);
assert.equal(item.encInfo, undefined);
});
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
const compressed = fakeFile('photo.jpg', 300);
const encryptedBlob = fakeFile('photo.jpg', 320);
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
file: encryptedBlob,
encInfo: enc('compressed'),
});
// The ciphertext is what gets uploaded; the plaintext stays available for
// dimensions/blurhash only.
assert.equal(item.file, encryptedBlob);
assert.equal(item.originalFile, compressed);
assert.deepEqual(item.encInfo, enc('compressed'));
assert.notDeepEqual(item.encInfo, enc('original'));
});
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
// stale encInfo (that is the bug this helper exists to prevent).
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
assert.equal(item.encInfo, undefined);
});
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
const base = makeItem();
base.metadata.caption = 'a caption';
base.metadata.markedAsSpoiler = true;
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
assert.equal(item.metadata.caption, 'a caption');
assert.equal(item.metadata.markedAsSpoiler, true);
});
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
// msgContent.test.ts builders, which share the same shape.
-23
View File
@@ -1,6 +1,5 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk'; import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js'; import to from 'await-to-js';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { import {
IThumbnailContent, IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME, MATRIX_BLUR_HASH_PROPERTY_NAME,
@@ -44,28 +43,6 @@ const generateThumbnailContent = async (
return thumbnailContent; 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 ( export const getImageMsgContent = async (
mx: MatrixClient, mx: MatrixClient,
item: TUploadItem, item: TUploadItem,
+2 -4
View File
@@ -116,8 +116,6 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
const editor = useEditor(); const editor = useEditor();
const thread = useThreadInstance(room, threadId); const thread = useThreadInstance(room, threadId);
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts'); 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>; const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
useKeyDown( useKeyDown(
@@ -159,7 +157,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
} }
if (!latestId || latestId === lastReadEventIdRef.current) return; if (!latestId || latestId === lastReadEventIdRef.current) return;
lastReadEventIdRef.current = latestId; lastReadEventIdRef.current = latestId;
markThreadAsRead(mx, thread, hideActivity || privateReadReceipts).catch(() => { markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
// Allow a retry on the next event if the receipt POST failed. // Allow a retry on the next event if the receipt POST failed.
if (lastReadEventIdRef.current === latestId) { if (lastReadEventIdRef.current === latestId) {
lastReadEventIdRef.current = undefined; lastReadEventIdRef.current = undefined;
@@ -173,7 +171,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
thread.off(ThreadEvent.NewReply, markRead); thread.off(ThreadEvent.NewReply, markRead);
thread.off(RoomEvent.Timeline, markRead); thread.off(RoomEvent.Timeline, markRead);
}; };
}, [mx, thread, privateReadReceipts, hideActivity]); }, [mx, thread, privateReadReceipts]);
return ( return (
<Box <Box
@@ -545,19 +545,9 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
[room, thread, setReplyDraft, editor], [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( const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => { (targetEventId: string, key: string, shortcode?: string) => {
const isRoot = targetEventId === thread.id; const timelineSet = thread.getUnfilteredTimelineSet();
const timelineSet = getRelationTimelineSet(targetEventId);
const relations = getEventReactions(timelineSet, targetEventId); const relations = getEventReactions(timelineSet, targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? []; const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? []; const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
@@ -573,14 +563,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent( mx.sendEvent(
room.roomId, room.roomId,
// A reaction on the root is a main-timeline event, not a thread reply. thread.id,
isRoot ? null : thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any, MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode), getReactionContent(targetEventId, key, rShortcode),
); );
}, },
[mx, room, thread, getRelationTimelineSet], [mx, room, thread],
); );
const handleEdit = useCallback( const handleEdit = useCallback(
@@ -726,7 +715,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
): ReactNode => { ): ReactNode => {
const mEventId = mEvent.getId(); const mEventId = mEvent.getId();
if (!mEventId) return null; if (!mEventId) return null;
const timelineSet = getRelationTimelineSet(mEventId); const timelineSet = thread.getUnfilteredTimelineSet();
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations?.getSortedAnnotationsByKey(); const reactions = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = !!reactions && reactions.length > 0; const hasReactions = !!reactions && reactions.length > 0;
@@ -794,6 +783,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
); );
}, },
[ [
thread,
room, room,
messageSpacing, messageSpacing,
messageLayout, messageLayout,
@@ -820,7 +810,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
lotusTerminal, lotusTerminal,
mx, mx,
renderMessageContent, renderMessageContent,
getRelationTimelineSet,
], ],
); );
@@ -60,6 +60,7 @@ export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
clientApi.stop(); clientApi.stop();
iframe.remove(); iframe.remove();
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, room.roomId, widget.id, widget.templateUrl]); }, [mx, room.roomId, widget.id, widget.templateUrl]);
if (blocked) { if (blocked) {
@@ -84,6 +84,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
data: {}, data: {},
}; };
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id); await sendStateEvent(mx, room.roomId, StateEvent.Widget, content, id);
setAdding(false); setAdding(false);
} catch (e) { } catch (e) {
@@ -95,7 +96,7 @@ export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const handleRemove = (id: string) => { const handleRemove = (id: string) => {
if (viewingId === id) setViewingId(null); if (viewingId === id) setViewingId(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined); sendStateEvent(mx, room.roomId, StateEvent.Widget, {}, id).catch(() => undefined);
}; };
@@ -1,72 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixEvent } from 'matrix-js-sdk';
import { widgetsFromStateEvents } from './useRoomWidgets';
const APP = 'https://chat.lotusguild.org';
// Minimal fake MatrixEvent, just enough of the surface widgetsFromStateEvents reads.
const fakeEvent = (stateKey: string, sender: string, content: Record<string, unknown>) =>
({
getStateKey: () => stateKey,
getSender: () => sender,
getContent: () => content,
}) as unknown as MatrixEvent;
test('returns a Widget for a valid im.vector.modular.widgets state event', () => {
// Regression test for matrix-widget-api 1.17.0's broken isValidUrl, which
// compares URL.protocol ("https:") to "https" and rejects every URL,
// making WidgetParser.parseWidgetsFromRoomState always return [].
const events = new Map([
[
'w1',
fakeEvent('w1', '@a:example.org', {
id: 'w1',
type: 'custom',
url: 'https://example.com/widget',
name: 'My Widget',
creatorUserId: '@a:example.org',
}),
],
]);
const widgets = widgetsFromStateEvents(events, APP);
assert.equal(widgets.length, 1);
assert.equal(widgets[0].id, 'w1');
assert.equal(widgets[0].templateUrl, 'https://example.com/widget');
assert.equal(widgets[0].creatorUserId, '@a:example.org');
assert.equal(widgets[0].name, 'My Widget');
});
test('skips removed widgets (empty content)', () => {
const events = new Map([['w1', fakeEvent('w1', '@a:example.org', {})]]);
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
});
test('skips non-https and same-origin urls', () => {
const events = new Map([
[
'w1',
fakeEvent('w1', '@a:example.org', {
id: 'w1',
type: 'custom',
url: 'http://example.com/widget',
creatorUserId: '@a:example.org',
}),
],
[
'w2',
fakeEvent('w2', '@a:example.org', {
id: 'w2',
type: 'custom',
url: `${APP}/evil`,
creatorUserId: '@a:example.org',
}),
],
]);
assert.deepEqual(widgetsFromStateEvents(events, APP), []);
});
test('undefined state map yields no widgets', () => {
assert.deepEqual(widgetsFromStateEvents(undefined, APP), []);
});
+11 -57
View File
@@ -1,67 +1,21 @@
import { Room, MatrixEvent } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Widget } from 'matrix-widget-api'; import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
import { StateEvent } from '../../../../types/matrix/room'; import { StateEvent } from '../../../../types/matrix/room';
import { StateKeyToEvents, useRoomState } from '../../../hooks/useRoomState'; import { useRoomState } from '../../../hooks/useRoomState';
import { isWidgetUrlSafe } from './widgetUtils';
/**
* Builds the `Widget` list from raw `im.vector.modular.widgets` state events.
*
* NOTE: we do NOT use `WidgetParser.parseWidgetsFromRoomState` here. In
* matrix-widget-api 1.17.0 its `isValidUrl` compares `URL.protocol` (which is
* always colon-suffixed, e.g. "https:") against the bare strings "http"/
* "https", so it rejects every URL and the parser always returns []. We build
* the `Widget`s ourselves with a correct scheme check plus the existing
* `isWidgetUrlSafe` origin check.
*/
export const widgetsFromStateEvents = (
widgetEvents: StateKeyToEvents | undefined,
appOrigin: string,
): Widget[] => {
if (!widgetEvents) return [];
const widgets: Widget[] = [];
Array.from(widgetEvents.values()).forEach((event: MatrixEvent) => {
const content = event.getContent();
// Removed widgets are represented as an empty content state event.
if (!content || Object.keys(content).length === 0) return;
const id = event.getStateKey();
const { type, url, name, data, waitForIframeLoad } = content;
const creatorUserId = content.creatorUserId || event.getSender();
if (!id || !type || !url || !creatorUserId) return;
let scheme: string;
try {
scheme = new URL(url).protocol;
} catch {
return;
}
if (scheme !== 'https:') return;
if (!isWidgetUrlSafe(url, appOrigin)) return;
widgets.push(
new Widget({
id,
creatorUserId,
type,
url,
name,
data,
waitForIframeLoad,
}),
);
});
return widgets;
};
/** /**
* All valid `im.vector.modular.widgets` room widgets, reactive on room state. * All valid `im.vector.modular.widgets` room widgets, reactive on room state.
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
*/ */
export const useRoomWidgets = (room: Room): Widget[] => { export const useRoomWidgets = (room: Room): Widget[] => {
const state = useRoomState(room); const state = useRoomState(room);
return useMemo( return useMemo(() => {
() => widgetsFromStateEvents(state.get(StateEvent.Widget), window.location.origin), const widgetEvents = state.get(StateEvent.Widget);
[state], if (!widgetEvents) return [];
const stateEvents = Array.from(widgetEvents.values()).map(
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
); );
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
}, [state]);
}; };
+2 -2
View File
@@ -4,12 +4,12 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
import pkg from '../../../../../package.json'; import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png'); const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
type MSC1929Contact = { type MSC1929Contact = {
matrix_id?: string; matrix_id?: string;
+2 -29
View File
@@ -751,32 +751,22 @@ function ProfilePronouns() {
const [pronouns, setPronouns] = useState<string>(''); const [pronouns, setPronouns] = useState<string>('');
const [savedPronouns, setSavedPronouns] = useState<string>(''); const [savedPronouns, setSavedPronouns] = useState<string>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const pronounsDirtyRef = useRef(false);
useEffect(() => { useEffect(() => {
let cancelled = false;
mx.http mx.http
.authedRequest<{ 'm.pronouns': string }>( .authedRequest<{ 'm.pronouns': string }>(
Method.Get, Method.Get,
`/profile/${encodeURIComponent(userId)}/m.pronouns`, `/profile/${encodeURIComponent(userId)}/m.pronouns`,
) )
.then((res) => { .then((res) => {
if (cancelled || pronounsDirtyRef.current) return;
const val = res['m.pronouns'] ?? ''; const val = res['m.pronouns'] ?? '';
setPronouns(val); setPronouns(val);
setSavedPronouns(val); setSavedPronouns(val);
}) })
.catch(() => { .catch(() => {
if (cancelled || pronounsDirtyRef.current) return;
setPronouns(''); setPronouns('');
setSavedPronouns(''); setSavedPronouns('');
}); });
return () => {
cancelled = true;
};
}, [mx, userId]); }, [mx, userId]);
const [saveState, savePronouns] = useAsyncCallback( const [saveState, savePronouns] = useAsyncCallback(
@@ -798,12 +788,10 @@ function ProfilePronouns() {
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => { const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
pronounsDirtyRef.current = true;
setPronouns(evt.currentTarget.value); setPronouns(evt.currentTarget.value);
}; };
const handleReset = () => { const handleReset = () => {
pronounsDirtyRef.current = true;
setPronouns(savedPronouns); setPronouns(savedPronouns);
}; };
@@ -887,15 +875,10 @@ function ProfileTimezone() {
const [timezone, setTimezone] = useState<string>(''); const [timezone, setTimezone] = useState<string>('');
const [savedTimezone, setSavedTimezone] = useState<string>(''); const [savedTimezone, setSavedTimezone] = useState<string>('');
// True once the user has edited the field — guards against the mount-time
// fetch below clobbering a fresh edit if it resolves late (mirrors
// ProfileStatus's statusDirtyRef in this file).
const timezoneDirtyRef = useRef(false);
useEffect(() => { useEffect(() => {
let cancelled = false;
const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone'); const cached = getAccountData<{ timezone: string }>(mx, 'im.lotus.timezone');
if (cached?.timezone && !timezoneDirtyRef.current) { if (cached?.timezone) {
setTimezone(cached.timezone); setTimezone(cached.timezone);
setSavedTimezone(cached.timezone); setSavedTimezone(cached.timezone);
} }
@@ -906,7 +889,6 @@ function ProfileTimezone() {
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`, `/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
) )
.then((res) => { .then((res) => {
if (cancelled || timezoneDirtyRef.current) return;
const val = res.timezone ?? ''; const val = res.timezone ?? '';
setTimezone(val); setTimezone(val);
setSavedTimezone(val); setSavedTimezone(val);
@@ -914,9 +896,6 @@ function ProfileTimezone() {
.catch(() => { .catch(() => {
/* no stored timezone yet */ /* no stored timezone yet */
}); });
return () => {
cancelled = true;
};
}, [mx, userId]); }, [mx, userId]);
const [saveState, saveTimezone] = useAsyncCallback( const [saveState, saveTimezone] = useAsyncCallback(
@@ -942,13 +921,7 @@ function ProfileTimezone() {
); );
const saving = saveState.status === AsyncStatus.Loading; const saving = saveState.status === AsyncStatus.Loading;
const handleChange = (value: string) => {
timezoneDirtyRef.current = true;
setTimezone(value);
};
const handleReset = () => { const handleReset = () => {
timezoneDirtyRef.current = true;
setTimezone(savedTimezone); setTimezone(savedTimezone);
}; };
@@ -982,7 +955,7 @@ function ProfileTimezone() {
{ value: '', label: '— select timezone —' }, { value: '', label: '— select timezone —' },
...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })), ...COMMON_TIMEZONES.map((tz) => ({ value: tz, label: tz })),
]} ]}
onChange={handleChange} onChange={setTimezone}
disabled={saving} disabled={saving}
aria-label="Timezone" aria-label="Timezone"
/> />
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { Box, Button, Text, Spinner, color } from 'folds'; import { Box, Button, Text, Spinner, color } from 'folds';
import { Method } from 'matrix-js-sdk'; import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
@@ -67,47 +67,24 @@ export function ProfileDecoration() {
const [current, setCurrent] = useState<string | null>(null); const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null); const [selected, setSelected] = useState<string | null>(null);
// Distinguish "confirmed no decoration" from "failed to load": a fetch
// failure must not be shown as (and saved over) "None".
const [loadError, setLoadError] = useState(false);
const [loading, setLoading] = useState(true);
// True once the user has picked/cleared a decoration — guards against the
// mount-time fetch below clobbering a fresh selection if it resolves late
// (mirrors ProfileStatus's statusDirtyRef in Profile.tsx).
const dirtyRef = useRef(false);
const fetchDecoration = useCallback(() => { useEffect(() => {
let cancelled = false;
setLoading(true);
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field // Fetch the whole profile, not the `/{field}` sub-resource: an unset field
// 404s (a console error for anyone without a decoration). The full profile // 404s (a console error for anyone without a decoration). The full profile
// returns 200 with all fields incl. custom MSC4133 ones — read it out. // returns 200 with all fields incl. custom MSC4133 ones — read it out.
mx.http mx.http
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`) .authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => { .then((res) => {
if (cancelled) return;
setLoadError(false);
setLoading(false);
if (dirtyRef.current) return;
const val = (res[PROFILE_FIELD] as string | undefined) ?? null; const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val); setCurrent(val);
setSelected(val); setSelected(val);
}) })
.catch(() => { .catch(() => {
if (cancelled) return; setCurrent(null);
setLoading(false); setSelected(null);
// Do NOT touch current/selected here — a network failure is not proof
// there's no decoration, and defaulting to null risks the user saving
// "None" over a real, still-set decoration (see #46).
setLoadError(true);
}); });
return () => {
cancelled = true;
};
}, [mx, userId]); }, [mx, userId]);
useEffect(() => fetchDecoration(), [fetchDecoration]);
const [saveState, save] = useAsyncCallback( const [saveState, save] = useAsyncCallback(
useCallback( useCallback(
async (slug: string | null) => { async (slug: string | null) => {
@@ -128,27 +105,16 @@ export function ProfileDecoration() {
const hasChanges = selected !== current; const hasChanges = selected !== current;
const handleSelect = (slug: string) => { const handleSelect = (slug: string) => {
dirtyRef.current = true;
setSelected((prev) => (prev === slug ? null : slug)); setSelected((prev) => (prev === slug ? null : slug));
}; };
const handleClear = () => { const handleClear = () => setSelected(null);
dirtyRef.current = true;
setSelected(null);
};
const handleSave = () => { const handleSave = () => {
// Refuse to save while the initial load failed: `current`/`selected` are if (!hasChanges || saving) return;
// not known-good, so saving could silently overwrite a real decoration.
if (!hasChanges || saving || loadError) return;
save(selected); save(selected);
}; };
const handleRetry = () => {
dirtyRef.current = false;
fetchDecoration();
};
return ( return (
<SettingTile <SettingTile
title={ title={
@@ -193,15 +159,13 @@ export function ProfileDecoration() {
</div> </div>
<Box grow="Yes" direction="Column" gap="100"> <Box grow="Yes" direction="Column" gap="100">
<Text size="T300"> <Text size="T300">
{loadError {selected
? 'Failed to load'
: selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find( ? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected, (d) => d.slug === selected,
)?.name ?? selected) )?.name ?? selected)
: 'None'} : 'None'}
</Text> </Text>
{selected && !loadError && ( {selected && (
<Button <Button
type="button" type="button"
size="300" size="300"
@@ -214,7 +178,7 @@ export function ProfileDecoration() {
</Button> </Button>
)} )}
</Box> </Box>
{hasChanges && !loadError && ( {hasChanges && (
<Button <Button
type="button" type="button"
size="400" size="400"
@@ -230,26 +194,6 @@ export function ProfileDecoration() {
)} )}
</Box> </Box>
{loadError && (
<Box alignItems="Center" gap="200">
<Text size="T200" style={{ color: color.Critical.Main }}>
Could not load your current decoration. Saving is disabled until this succeeds, so you
don’t overwrite it based on a wrong display.
</Text>
<Button
type="button"
size="300"
radii="300"
variant="Secondary"
fill="Soft"
onClick={handleRetry}
disabled={loading}
>
<Text size="B300">{loading ? 'Retrying…' : 'Retry'}</Text>
</Button>
</Box>
)}
{saveState.status === AsyncStatus.Error && ( {saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: color.Critical.Main }}> <Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save. Try again. Failed to save. Try again.
+13 -51
View File
@@ -1,6 +1,5 @@
import React, { import React, {
ChangeEventHandler, ChangeEventHandler,
FocusEventHandler,
FormEventHandler, FormEventHandler,
KeyboardEventHandler, KeyboardEventHandler,
MouseEventHandler, MouseEventHandler,
@@ -118,7 +117,6 @@ import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones'; import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester'; import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect'; 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 * P5-47 — opt-in TDS window chrome toggle (desktop only). Renders nothing in the
@@ -396,16 +394,6 @@ function PageZoomInput() {
setCurrentZoom(evt.target.value); setCurrentZoom(evt.target.value);
}; };
// Shared by Enter and blur so a typed-but-unconfirmed value is never
// silently dropped when the input loses focus.
const commitZoom = (value: string) => {
const newZoom = parseInt(value, 10);
if (Number.isNaN(newZoom)) return;
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
setPageZoom(safeZoom);
setCurrentZoom(safeZoom.toString());
};
const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => { const handleZoomEnter: KeyboardEventHandler<HTMLInputElement> = (evt) => {
if (isKeyHotkey('escape', evt)) { if (isKeyHotkey('escape', evt)) {
evt.stopPropagation(); evt.stopPropagation();
@@ -416,14 +404,14 @@ function PageZoomInput() {
'value' in evt.target && 'value' in evt.target &&
typeof evt.target.value === 'string' typeof evt.target.value === 'string'
) { ) {
commitZoom(evt.target.value); const newZoom = parseInt(evt.target.value, 10);
if (Number.isNaN(newZoom)) return;
const safeZoom = Math.max(Math.min(newZoom, 150), 75);
setPageZoom(safeZoom);
setCurrentZoom(safeZoom.toString());
} }
}; };
const handleZoomBlur: FocusEventHandler<HTMLInputElement> = (evt) => {
commitZoom(evt.target.value);
};
return ( return (
<Input <Input
style={{ width: toRem(100) }} style={{ width: toRem(100) }}
@@ -437,7 +425,6 @@ function PageZoomInput() {
value={currentZoom} value={currentZoom}
onChange={handleZoomChange} onChange={handleZoomChange}
onKeyDown={handleZoomEnter} onKeyDown={handleZoomEnter}
onBlur={handleZoomBlur}
after={<Text size="T300">%</Text>} after={<Text size="T300">%</Text>}
outlined outlined
/> />
@@ -1487,12 +1474,8 @@ function Privacy() {
); );
} }
// [Gitea #23] Denylist navigation-critical/modifier codes and reject a code that function useKeyBind(setter: (code: string) => void) {
// 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 [listening, setListening] = useState(false);
const [error, setError] = useState<string | null>(null);
const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null); const listenerRef = useRef<((e: KeyboardEvent) => void) | null>(null);
useEffect( useEffect(
@@ -1504,28 +1487,19 @@ function useKeyBind(setter: (code: string) => void, otherKey?: string) {
const startListening = useCallback(() => { const startListening = useCallback(() => {
if (listening) return; if (listening) return;
setError(null);
setListening(true); setListening(true);
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
e.preventDefault(); e.preventDefault();
if (e.code === 'Escape') { if (e.code !== 'Escape') setter(e.code);
// 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); setListening(false);
window.removeEventListener('keydown', onKey, true); window.removeEventListener('keydown', onKey, true);
listenerRef.current = null; listenerRef.current = null;
}; };
listenerRef.current = onKey; listenerRef.current = onKey;
window.addEventListener('keydown', onKey, true); window.addEventListener('keydown', onKey, true);
}, [listening, setter, otherKey]); }, [listening, setter]);
return { listening, startListening, error }; return { listening, startListening };
} }
const keyLabel = (code: string) => const keyLabel = (code: string) =>
@@ -1582,8 +1556,8 @@ function Calls() {
previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100))); previewRingtone(value, Math.max(0, Math.min(1, ringtoneVolume / 100)));
}; };
const pttBind = useKeyBind(setPttKey, deafenKey); const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey, pttKey); const deafenBind = useKeyBind(setDeafenKey);
const mlSupported = isMLDenoiseSupported(); const mlSupported = isMLDenoiseSupported();
const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel); const selectedDenoiseModel = DENOISE_MODELS.find((m) => m.id === callDenoiseModel);
@@ -1849,7 +1823,7 @@ function Calls() {
{pttMode && ( {pttMode && (
<SettingTile <SettingTile
title="PTT Key" title="PTT Key"
description={pttBind.error ?? 'Press a key to bind it as your push-to-talk key.'} description="Press a key to bind it as your push-to-talk key."
after={ after={
<Button <Button
size="300" size="300"
@@ -1867,9 +1841,7 @@ function Calls() {
)} )}
<SettingTile <SettingTile
title="Push to Deafen" title="Push to Deafen"
description={ description="Toggle speaker mute during a call. Press Escape to cancel rebind."
deafenBind.error ?? 'Toggle speaker mute during a call. Press Escape to cancel rebind.'
}
after={ after={
<Button <Button
size="300" size="300"
@@ -2356,7 +2328,6 @@ function Messages() {
'translateTargetLang', 'translateTargetLang',
); );
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate'); const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const [gifPickerEnabled, setGifPickerEnabled] = useSetting(settingsAtom, 'gifPickerEnabled');
const translationSupported = chromeTranslationEngine.isSupported(); const translationSupported = chromeTranslationEngine.isSupported();
const selectedTargetLang = isSupportedTargetLang(translateTargetLang) const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
? normalizeLang(translateTargetLang) ? normalizeLang(translateTargetLang)
@@ -2459,15 +2430,6 @@ function Messages() {
} }
/> />
</SequenceCard> </SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="GIF Picker"
description="Every search term (and your IP address) is sent directly to Giphy, not through your homeserver. Off by default."
after={
<Switch variant="Primary" value={gifPickerEnabled} onChange={setGifPickerEnabled} />
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column"> <SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile <SettingTile
title="Show Hidden Events" title="Show Hidden Events"
@@ -11,19 +11,12 @@ import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { settingsAtom, Settings } from '../../../state/settings'; import { settingsAtom, Settings } from '../../../state/settings';
import { notificationSnoozeUntilAtom } from '../../../state/notificationSnooze';
const PRESETS: Array<{ const PRESETS: Array<{
label: string; label: string;
icon: IconSrc; icon: IconSrc;
description: string; description: string;
patch: Partial<Settings>; patch: Partial<Settings>;
// Whether applying this preset should also clear an active "Pause
// Notifications" snooze. Work/Gaming both claim notifications end up on,
// so a leftover snooze would silently contradict them (#48). Sleep's
// description ("All notifications off") is still true with a snooze left
// active, so it does not need to touch it.
clearSnooze: boolean;
}> = [ }> = [
{ {
label: 'Gaming', label: 'Gaming',
@@ -36,7 +29,6 @@ const PRESETS: Array<{
inviteSoundId: 'none', inviteSoundId: 'none',
quietHoursEnabled: false, quietHoursEnabled: false,
}, },
clearSnooze: true,
}, },
{ {
label: 'Work', label: 'Work',
@@ -49,7 +41,6 @@ const PRESETS: Array<{
inviteSoundId: 'invite', inviteSoundId: 'invite',
quietHoursEnabled: false, quietHoursEnabled: false,
}, },
clearSnooze: true,
}, },
{ {
label: 'Sleep', label: 'Sleep',
@@ -60,23 +51,15 @@ const PRESETS: Array<{
isNotificationSounds: false, isNotificationSounds: false,
quietHoursEnabled: false, quietHoursEnabled: false,
}, },
clearSnooze: false,
}, },
]; ];
function NotificationPresets() { function NotificationPresets() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const setSettings = useSetAtom(settingsAtom); const setSettings = useSetAtom(settingsAtom);
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
const applyPreset = (patch: Partial<Settings>, clearSnooze: boolean) => { const applyPreset = (patch: Partial<Settings>) => {
setSettings({ ...settings, ...patch }); setSettings({ ...settings, ...patch });
// Work/Gaming promise notifications are on; an active snooze from an
// earlier "Pause Notifications" would otherwise keep them silently
// suppressed despite the preset applying successfully (#48).
if (clearSnooze) {
setSnoozeUntil(0);
}
}; };
return ( return (
@@ -88,7 +71,7 @@ function NotificationPresets() {
<Button <Button
key={preset.label} key={preset.label}
type="button" type="button"
onClick={() => applyPreset(preset.patch, preset.clearSnooze)} onClick={() => applyPreset(preset.patch)}
title={preset.description} title={preset.description}
variant="Secondary" variant="Secondary"
fill="Soft" fill="Soft"
@@ -18,7 +18,6 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { import {
getNotificationModeActions, getNotificationModeActions,
getNotificationModeOptionsFromActions,
NotificationMode, NotificationMode,
useNotificationModeActions, useNotificationModeActions,
} from '../../../hooks/useNotificationMode'; } from '../../../hooks/useNotificationMode';
@@ -132,13 +131,7 @@ type RuleModeSwitcherProps = {
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) { function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
// Preserve any `highlight`/custom sound tweak already on the rule — otherwise const getModeActions = useNotificationModeActions();
// 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( const handleChange = useCallback(
async (mode: NotificationMode) => { async (mode: NotificationMode) => {
+1 -5
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { MatrixError, Method } from 'matrix-js-sdk'; import { MatrixError, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { isValidDecorationSlug } from '../features/lotus/avatarDecorations';
const PROFILE_FIELD = 'io.lotus.avatar_decoration'; const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -52,10 +51,7 @@ function fetchDecoration(
// all fields (incl. custom MSC4133 ones); read the decoration out of it. // all fields (incl. custom MSC4133 ones); read the decoration out of it.
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`) return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => { .then((res) => {
const rawVal = (res[PROFILE_FIELD] as string | undefined) ?? null; const val = (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); cache.set(userId, val);
return val; return val;
}) })
-81
View File
@@ -1,81 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
Bookmark,
cleanupEncryptedBookmarks,
hasBookmarkText,
stripBookmarkText,
toStorableBookmark,
} from './useBookmarks';
import { cleanupEncryptedReminders, Reminder, toStorableReminder } from './useReminders';
// E2EE policy for bookmarks/reminders (Gitea #10): account data is stored
// unencrypted on the homeserver, so entries for encrypted rooms must carry no
// message text or names. `isEncryptedRoom` is injected to keep these pure.
const isEncrypted = (roomId: string) => roomId === '!enc';
const full = (roomId: string): Bookmark => ({
roomId,
eventId: `$ev-${roomId}`,
savedAt: 100,
previewText: 'secret text',
roomName: 'Room',
senderName: 'Alice',
});
test('stripBookmarkText keeps only roomId/eventId/savedAt', () => {
const stripped = stripBookmarkText(full('!enc'));
assert.deepEqual(stripped, { roomId: '!enc', eventId: '$ev-!enc', savedAt: 100, roomName: '' });
assert.equal(hasBookmarkText(stripped), false);
assert.equal(hasBookmarkText(full('!enc')), true);
});
test('toStorableBookmark strips text for encrypted rooms only', () => {
const plain = full('!plain');
assert.equal(toStorableBookmark(plain, isEncrypted), plain);
const enc = toStorableBookmark(full('!enc'), isEncrypted);
assert.equal(enc.previewText, undefined);
assert.equal(enc.senderName, undefined);
assert.equal(enc.roomName, '');
});
test('cleanupEncryptedBookmarks returns undefined when nothing to strip', () => {
const already = stripBookmarkText(full('!enc'));
assert.equal(cleanupEncryptedBookmarks([full('!plain'), already], isEncrypted), undefined);
assert.equal(cleanupEncryptedBookmarks([], isEncrypted), undefined);
});
test('cleanupEncryptedBookmarks strips legacy encrypted entries and keeps the rest', () => {
const plain = full('!plain');
const out = cleanupEncryptedBookmarks([plain, full('!enc')], isEncrypted);
assert.ok(out);
assert.equal(out[0], plain);
assert.deepEqual(out[1], stripBookmarkText(full('!enc')));
// Running it again on the result is a no-op (no write loop).
assert.equal(cleanupEncryptedBookmarks(out, isEncrypted), undefined);
});
const reminder = (roomId: string): Reminder => ({
roomId,
eventId: `$ev-${roomId}`,
timestamp: 200,
message: 'secret text',
});
test('toStorableReminder drops message for encrypted rooms only', () => {
const plain = reminder('!plain');
assert.equal(toStorableReminder(plain, isEncrypted), plain);
assert.deepEqual(toStorableReminder(reminder('!enc'), isEncrypted), {
roomId: '!enc',
eventId: '$ev-!enc',
timestamp: 200,
});
});
test('cleanupEncryptedReminders strips legacy entries once', () => {
const out = cleanupEncryptedReminders([reminder('!plain'), reminder('!enc')], isEncrypted);
assert.ok(out);
assert.equal(out[0].message, 'secret text');
assert.equal(out[1].message, undefined);
assert.equal(cleanupEncryptedReminders(out, isEncrypted), undefined);
});
+7 -97
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect } from 'react'; import { useCallback } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore'; import { createAccountDataListStore } from './createAccountDataListStore';
@@ -7,14 +6,7 @@ export type Bookmark = {
roomId: string; roomId: string;
eventId: string; eventId: string;
savedAt: number; savedAt: number;
// Snapshot fields. Account data is stored UNENCRYPTED on the homeserver, so previewText: string;
// for E2EE rooms none of these are persisted (see stripBookmarkText) — the
// panel resolves the preview / room / author live from the local timeline
// instead. They remain for unencrypted rooms (already server-visible) and for
// entries saved before this policy, so older stored bookmarks still render.
previewText?: string;
// Kept as a (possibly empty) string in memory because the grouping helpers in
// utils/bookmarks read it directly; the store omits it from the wire when empty.
roomName: string; roomName: string;
// Author display name, snapshotted at save time. Optional for backward // Author display name, snapshotted at save time. Optional for backward
// compatibility with bookmarks stored before attribution was added; the panel // compatibility with bookmarks stored before attribution was added; the panel
@@ -24,83 +16,22 @@ export type Bookmark = {
senderName?: string; senderName?: string;
}; };
// Wire shape: identical to Bookmark except that `roomName` may be absent.
type StoredBookmark = Omit<Bookmark, 'roomName'> & { roomName?: string };
const BOOKMARKS_KEY = 'io.lotus.bookmarks'; const BOOKMARKS_KEY = 'io.lotus.bookmarks';
const MAX_BOOKMARKS = 500; const MAX_BOOKMARKS = 500;
type BookmarksContent = { type BookmarksContent = {
bookmarks: StoredBookmark[]; bookmarks: Bookmark[];
}; };
/** True when the entry carries any of the text fields we refuse to upload for E2EE rooms. */
export const hasBookmarkText = (b: Bookmark): boolean =>
b.previewText !== undefined || b.roomName !== '' || b.senderName !== undefined;
/**
* Reduce a bookmark to its non-text fields ({roomId, eventId, savedAt}). Used for
* messages in encrypted rooms so no decrypted plaintext (or who said it) ever
* reaches the server's account-data store.
*/
export const stripBookmarkText = (b: Bookmark): Bookmark => ({
roomId: b.roomId,
eventId: b.eventId,
savedAt: b.savedAt,
roomName: '',
});
/**
* Apply the E2EE storage policy to a bookmark about to be persisted.
* `isEncryptedRoom` is injected so the policy is testable without a client.
*/
export const toStorableBookmark = (
b: Bookmark,
isEncryptedRoom: (roomId: string) => boolean,
): Bookmark => (isEncryptedRoom(b.roomId) ? stripBookmarkText(b) : b);
/**
* One-time cleanup for entries persisted before the E2EE policy: returns the list
* with text fields stripped for every bookmark whose room is currently encrypted,
* or `undefined` when nothing needs to change (so callers can skip the write).
*/
export const cleanupEncryptedBookmarks = (
bookmarks: Bookmark[],
isEncryptedRoom: (roomId: string) => boolean,
): Bookmark[] | undefined => {
let changed = false;
const next = bookmarks.map((b) => {
if (hasBookmarkText(b) && isEncryptedRoom(b.roomId)) {
changed = true;
return stripBookmarkText(b);
}
return b;
});
return changed ? next : undefined;
};
const isEncryptedRoomFor =
(mx: MatrixClient) =>
(roomId: string): boolean =>
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
// Shared, concurrency-safe store. See createAccountDataListStore for why the // Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid // snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge). // lost updates, since setAccountData replaces the whole content with no merge).
const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({ const bookmarksStore = createAccountDataListStore<Bookmark[], BookmarksContent>({
eventType: BOOKMARKS_KEY, eventType: BOOKMARKS_KEY,
read: (content) => (content?.bookmarks ?? []).map((b) => ({ ...b, roomName: b.roomName ?? '' })), read: (content) => content?.bookmarks ?? [],
write: (bookmarks) => ({ write: (bookmarks) => ({ bookmarks }),
// Drop the in-memory '' placeholder so stripped entries stay {roomId, eventId, savedAt}.
bookmarks: bookmarks.map(({ roomName, ...rest }) => (roomName ? { ...rest, roomName } : rest)),
}),
}); });
// Guard so the load-time cleanup below runs once per client, not on every mount
// (useBookmarks is mounted by every message row) and not again after its own
// write echoes back.
let cleanedUpFor: MatrixClient | null = null;
export function useBookmarks(): { export function useBookmarks(): {
bookmarks: Bookmark[]; bookmarks: Bookmark[];
addBookmark: (b: Bookmark) => Promise<void>; addBookmark: (b: Bookmark) => Promise<void>;
@@ -110,33 +41,12 @@ export function useBookmarks(): {
const mx = useMatrixClient(); const mx = useMatrixClient();
const bookmarks = bookmarksStore.useValue(mx); const bookmarks = bookmarksStore.useValue(mx);
// Strip text from entries saved (by older builds) for rooms that are encrypted,
// writing the store back at most once per client.
useEffect(() => {
if (cleanedUpFor === mx) return;
cleanedUpFor = mx;
const cleaned = cleanupEncryptedBookmarks(bookmarksStore.getLatest(mx), isEncryptedRoomFor(mx));
if (cleaned) {
bookmarksStore
.enqueueWrite(
mx,
(current) => cleanupEncryptedBookmarks(current, isEncryptedRoomFor(mx)) ?? current,
)
.catch(() => {
// Retry on the next client (re)mount.
if (cleanedUpFor === mx) cleanedUpFor = null;
});
}
}, [mx]);
const addBookmark = useCallback( const addBookmark = useCallback(
(b: Bookmark) => (b: Bookmark) =>
bookmarksStore.enqueueWrite(mx, (current) => { bookmarksStore.enqueueWrite(mx, (current) => {
// Never upload plaintext for E2EE rooms — account data is server-readable.
const stored = toStorableBookmark(b, isEncryptedRoomFor(mx));
// Avoid duplicates // Avoid duplicates
const filtered = current.filter((bk) => bk.eventId !== stored.eventId); const filtered = current.filter((bk) => bk.eventId !== b.eventId);
let next = [stored, ...filtered]; let next = [b, ...filtered];
if (next.length > MAX_BOOKMARKS) { if (next.length > MAX_BOOKMARKS) {
next = next.slice(0, MAX_BOOKMARKS); next = next.slice(0, MAX_BOOKMARKS);
} }
-35
View File
@@ -1,35 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isDeafenKeyDown, isPttKeyDown } from './useCallHotkeys';
const key = (
code: string,
mods: Partial<Record<'repeat' | 'ctrlKey' | 'altKey' | 'metaKey' | 'shiftKey', boolean>> = {},
) => ({
code,
repeat: false,
ctrlKey: false,
altKey: false,
metaKey: false,
shiftKey: false,
...mods,
});
test('isPttKeyDown: matches the bare key and Shift, ignores repeats and Ctrl/Alt/Meta chords (#23)', () => {
assert.equal(isPttKeyDown(key('Space'), 'Space'), true);
assert.equal(isPttKeyDown(key('Space', { shiftKey: true }), 'Space'), true);
assert.equal(isPttKeyDown(key('KeyV'), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { repeat: true }), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { ctrlKey: true }), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { altKey: true }), 'Space'), false);
assert.equal(isPttKeyDown(key('Space', { metaKey: true }), 'Space'), false);
});
test('isDeafenKeyDown: matches only the bare key — any modifier (incl. Shift) is ignored (#23)', () => {
assert.equal(isDeafenKeyDown(key('KeyM'), 'KeyM'), true);
assert.equal(isDeafenKeyDown(key('KeyN'), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { repeat: true }), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { shiftKey: true }), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { ctrlKey: true }), 'KeyM'), false);
assert.equal(isDeafenKeyDown(key('KeyM', { metaKey: true }), 'KeyM'), false);
});
-214
View File
@@ -1,214 +0,0 @@
import { useEffect, useRef } from 'react';
import { atom, useSetAtom } from 'jotai';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
/**
* True while a push-to-talk key is held. Written by useCallHotkeys (mounted for
* the lifetime of the embed) and read by the PTT chip in CallControls, which
* only renders while the call room is selected.
*/
export const pttActiveAtom = atom(false);
type KeyLike = {
code: string;
repeat: boolean;
ctrlKey: boolean;
altKey: boolean;
metaKey: boolean;
shiftKey: boolean;
};
/**
* Whether a keydown should engage push-to-talk for `pttKey`.
* [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.
*/
export const isPttKeyDown = (e: KeyLike, pttKey: string): boolean =>
e.code === pttKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey;
/**
* Whether a keydown should toggle deafen for `deafenKey`.
* [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).
*/
export const isDeafenKeyDown = (e: KeyLike, deafenKey: string): boolean =>
e.code === deafenKey && !e.repeat && !e.ctrlKey && !e.altKey && !e.metaKey && !e.shiftKey;
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
const isEditable = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
if (node.contentEditable === 'true') return true;
if (node.contentEditable === 'false') return false;
node = node.parentElement;
}
return false;
};
// [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;
};
/**
* Bind listeners to the EC iframe's window and keep them bound across document
* reloads. [Gitea #60] Listeners registered on a contentWindow are discarded
* when that window navigates to a new document (crash recovery, in-widget
* navigation), so re-run `bind` from the iframe's `load` event and detach the
* previous set. Returns a cleanup that detaches everything.
*/
const bindIframeWindow = (
iframe: HTMLIFrameElement,
bind: (win: Window) => () => void,
): (() => void) => {
let unbind: (() => void) | undefined;
const attach = () => {
unbind?.();
const win = iframe.contentWindow;
unbind = win ? bind(win) : undefined;
};
attach();
iframe.addEventListener('load', attach);
return () => {
iframe.removeEventListener('load', attach);
unbind?.();
unbind = undefined;
};
};
/**
* Push-to-talk and deafen hotkeys for the active call.
*
* [Gitea #9] Mounted from CallUtils (CallEmbedProvider) so the bindings live as
* long as the embed — not only while the call room is the selected room. Before
* this lived in CallControls, so navigating away (PiP) or opening the in-call
* chat on mobile silently dropped PTT and the deafen key. Gated on `joined` so
* nothing is sent over the widget transport before EC's handler mounts.
*/
export function useCallHotkeys(callEmbed: CallEmbed | undefined, joined: boolean): void {
const embed = joined ? callEmbed : undefined;
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const { microphone } = useCallControlState(embed?.control);
const setPttActive = useSetAtom(pttActiveAtom);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
const microphoneRef = useRef(microphone);
useEffect(() => {
microphoneRef.current = microphone;
}, [microphone]);
// Handle PTT mode toggle mid-call — save/restore mic state (I-4)
const pttModeRef = useRef(pttMode);
const micBeforePTTRef = useRef<boolean | null>(null);
useEffect(() => {
if (embed) {
if (pttMode && !pttModeRef.current) {
micBeforePTTRef.current = microphoneRef.current;
embed.control.setMicrophone(false);
} else if (!pttMode && pttModeRef.current) {
embed.control.setMicrophone(micBeforePTTRef.current ?? true);
micBeforePTTRef.current = null;
}
}
pttModeRef.current = pttMode;
}, [pttMode, embed]);
const pttActiveRef = useRef(false);
useEffect(() => {
if (!embed || !pttMode) return undefined;
const release = () => {
embed.control.pttActive = false;
embed.control.setMicrophone(false);
pttActiveRef.current = false;
setPttActive(false);
};
const onKeyDown = (e: KeyboardEvent) => {
if (!isPttKeyDown(e, pttKey)) return;
const target = e.target as HTMLElement;
if (isEditable(target)) return;
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.
embed.control.pttActive = true;
if (!microphoneRef.current) embed.control.setMicrophone(true);
pttActiveRef.current = true;
setPttActive(true);
};
const onKeyUp = (e: KeyboardEvent) => {
if (e.code !== pttKey) return;
release();
};
// BUG-9: release a held key when focus leaves/enters so the mic can't stick
// open after the keyup lands elsewhere. [Gitea #27] Only when a PTT hold is
// actually in progress — the mic button is still usable in PTT mode, and a
// deliberate hands-free unmute must survive a click into the iframe/alt-tab.
const onFocusChange = () => {
if (pttActiveRef.current) release();
};
const bind = (win: Window) => {
win.addEventListener('keydown', onKeyDown);
win.addEventListener('keyup', onKeyUp);
win.addEventListener('blur', onFocusChange);
win.addEventListener('focus', onFocusChange);
return () => {
win.removeEventListener('keydown', onKeyDown);
win.removeEventListener('keyup', onKeyUp);
win.removeEventListener('blur', onFocusChange);
win.removeEventListener('focus', onFocusChange);
};
};
const unbindHost = bind(window);
// BUG-9: also wire iframe key/blur/focus so PTT works with focus in the iframe
const unbindIframe = bindIframeWindow(embed.iframe, bind);
return () => {
unbindHost();
unbindIframe();
// BUG-8: if the embed changes while PTT is active, release mic on cleanup
if (pttActiveRef.current) release();
};
// microphone intentionally read via microphoneRef — excluded from deps to avoid listener churn
}, [pttMode, pttKey, embed, setPttActive]);
useEffect(() => {
if (!embed) return undefined;
const onKeyDown = (e: KeyboardEvent) => {
if (!isDeafenKeyDown(e, deafenKey)) return;
if (isEditable(e.target as HTMLElement)) return;
e.preventDefault();
embed.control.toggleSound();
};
window.addEventListener('keydown', onKeyDown);
// C-L4: also bind the EC iframe window so the deafen key works when focus is
// inside the iframe (mirrors the PTT binding above).
const unbindIframe = bindIframeWindow(embed.iframe, (win) => {
win.addEventListener('keydown', onKeyDown);
return () => win.removeEventListener('keydown', onKeyDown);
});
return () => {
window.removeEventListener('keydown', onKeyDown);
unbindIframe();
};
}, [embed, deafenKey]);
}
+3 -23
View File
@@ -1,8 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useStore } from 'jotai'; import { useStore } from 'jotai';
import { Descendant } from 'slate';
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts'; import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft'; 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 * On startup, pre-fill the per-room message-draft atoms from their localStorage
@@ -13,19 +13,11 @@ import { useMatrixClient } from './useMatrixClient';
* (identical content), so composer restore is unaffected. * (identical content), so composer restore is unaffected.
* *
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level. * 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 { export function useHydrateMsgDrafts(): void {
const store = useStore(); const store = useStore();
const mx = useMatrixClient();
useEffect(() => { useEffect(() => {
const userId = mx.getUserId();
let keys: string[]; let keys: string[];
try { try {
keys = Object.keys(localStorage); keys = Object.keys(localStorage);
@@ -42,19 +34,7 @@ export function useHydrateMsgDrafts(): void {
try { try {
const stored = localStorage.getItem(key); const stored = localStorage.getItem(key);
if (!stored) return; if (!stored) return;
const parsed = JSON.parse(stored); const nodes = JSON.parse(stored) as Descendant[];
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)) { if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes); store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
} }
@@ -62,5 +42,5 @@ export function useHydrateMsgDrafts(): void {
// Ignore a malformed stored draft. // Ignore a malformed stored draft.
} }
}); });
}, [store, mx]); }, [store]);
} }
-42
View File
@@ -1,42 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PushRuleActionName, TweakName } from 'matrix-js-sdk';
import { getNotificationModeOptionsFromActions } from './useNotificationMode';
test('getNotificationModeOptionsFromActions: no tweaks -> no highlight, no sound value', () => {
const options = getNotificationModeOptionsFromActions([PushRuleActionName.Notify]);
assert.deepEqual(options, { soundValue: undefined, highlight: false });
});
test('getNotificationModeOptionsFromActions: picks up highlight: true', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.equal(options.highlight, true);
});
test('getNotificationModeOptionsFromActions: highlight: false is not treated as set', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: false },
]);
assert.equal(options.highlight, false);
});
test('getNotificationModeOptionsFromActions: picks up a custom sound value', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
]);
assert.equal(options.soundValue, 'ping.ogg');
});
test('getNotificationModeOptionsFromActions: picks up both tweaks together', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.deepEqual(options, { soundValue: 'ping.ogg', highlight: true });
});
-23
View File
@@ -49,29 +49,6 @@ export const getNotificationModeActions = (
return actions; 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 type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
export const useNotificationModeActions = ( export const useNotificationModeActions = (
options?: NotificationModeOptions, options?: NotificationModeOptions,
+3 -69
View File
@@ -1,5 +1,4 @@
import { useCallback, useEffect } from 'react'; import { useCallback } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore'; import { createAccountDataListStore } from './createAccountDataListStore';
@@ -7,10 +6,7 @@ export type Reminder = {
roomId: string; roomId: string;
eventId: string; eventId: string;
timestamp: number; timestamp: number;
// Message preview. Account data is stored UNENCRYPTED on the homeserver, so message: string;
// this is omitted for E2EE rooms (see stripReminderText) and the toast resolves
// the text locally at fire time. Optional for entries stored before that policy.
message?: string;
}; };
const REMINDERS_KEY = 'io.lotus.reminders'; const REMINDERS_KEY = 'io.lotus.reminders';
@@ -19,43 +15,6 @@ type RemindersContent = {
reminders: Reminder[]; reminders: Reminder[];
}; };
/** Reduce a reminder to its non-text fields ({roomId, eventId, timestamp}). */
export const stripReminderText = (r: Reminder): Reminder => ({
roomId: r.roomId,
eventId: r.eventId,
timestamp: r.timestamp,
});
/** Apply the E2EE storage policy to a reminder about to be persisted. */
export const toStorableReminder = (
r: Reminder,
isEncryptedRoom: (roomId: string) => boolean,
): Reminder => (isEncryptedRoom(r.roomId) ? stripReminderText(r) : r);
/**
* One-time cleanup for entries persisted before the E2EE policy; `undefined`
* when nothing needs to change (so callers can skip the write).
*/
export const cleanupEncryptedReminders = (
reminders: Reminder[],
isEncryptedRoom: (roomId: string) => boolean,
): Reminder[] | undefined => {
let changed = false;
const next = reminders.map((r) => {
if (r.message !== undefined && isEncryptedRoom(r.roomId)) {
changed = true;
return stripReminderText(r);
}
return r;
});
return changed ? next : undefined;
};
const isEncryptedRoomFor =
(mx: MatrixClient) =>
(roomId: string): boolean =>
mx.getRoom(roomId)?.hasEncryptionStateEvent() ?? false;
// Shared, concurrency-safe store. See createAccountDataListStore for why the // Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped (writes are serialized to avoid // snapshot + write queue must be module-scoped (writes are serialized to avoid
// lost updates, since setAccountData replaces the whole content with no merge). // lost updates, since setAccountData replaces the whole content with no merge).
@@ -65,9 +24,6 @@ const remindersStore = createAccountDataListStore<Reminder[], RemindersContent>(
write: (reminders) => ({ reminders }), write: (reminders) => ({ reminders }),
}); });
// Once-per-client guard for the load-time cleanup (mirrors useBookmarks).
let cleanedUpFor: MatrixClient | null = null;
export function useReminders(): { export function useReminders(): {
reminders: Reminder[]; reminders: Reminder[];
addReminder: (r: Reminder) => Promise<void>; addReminder: (r: Reminder) => Promise<void>;
@@ -77,30 +33,8 @@ export function useReminders(): {
const mx = useMatrixClient(); const mx = useMatrixClient();
const reminders = remindersStore.useValue(mx); const reminders = remindersStore.useValue(mx);
// Strip text from entries saved (by older builds) for rooms that are encrypted,
// writing the store back at most once per client.
useEffect(() => {
if (cleanedUpFor === mx) return;
cleanedUpFor = mx;
if (cleanupEncryptedReminders(remindersStore.getLatest(mx), isEncryptedRoomFor(mx))) {
remindersStore
.enqueueWrite(
mx,
(current) => cleanupEncryptedReminders(current, isEncryptedRoomFor(mx)) ?? current,
)
.catch(() => {
if (cleanedUpFor === mx) cleanedUpFor = null;
});
}
}, [mx]);
const addReminder = useCallback( const addReminder = useCallback(
(r: Reminder) => (r: Reminder) => remindersStore.enqueueWrite(mx, (current) => [...current, r]),
remindersStore.enqueueWrite(mx, (current) => [
...current,
// Never upload plaintext for E2EE rooms — account data is server-readable.
toStorableReminder(r, isEncryptedRoomFor(mx)),
]),
[mx], [mx],
); );
-28
View File
@@ -1,28 +0,0 @@
import { Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { RoomQualityContent } from '../utils/callQuality';
export type RoomCallPolicy = {
allowCamera: boolean;
allowScreenshare: boolean;
};
/**
* [Gitea #26] Shared room-level camera/screenshare policy, read from the
* `io.lotus.room_quality` state event. Absent/true = allowed; only an explicit
* `false` forbids. Hoisted out of `CallControls` so other call surfaces (e.g.
* the app-wide `CallStatus` bar) can apply the same gating.
*/
export const useRoomCallPolicy = (room: Room): RoomCallPolicy => {
const roomQualityEvent = useStateEvent(room, StateEvent.LotusRoomQuality);
return useMemo(() => {
const roomQuality = roomQualityEvent?.getContent<RoomQualityContent>();
return {
allowCamera: roomQuality?.allow_camera !== false,
allowScreenshare: roomQuality?.allow_screenshare !== false,
};
}, [roomQualityEvent]);
};
-93
View File
@@ -1,93 +0,0 @@
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' });
});
+54 -47
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent'; import { useStateEvent } from './useStateEvent';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore'; import { getAccountData } from '../utils/accountData';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => { export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar); const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
@@ -40,72 +40,79 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
export type LocalRoomNamesContent = { rooms: Record<string, string> }; 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( export function getLocalRoomNamesContent(
mx: ReturnType<typeof useMatrixClient>, mx: ReturnType<typeof useMatrixClient>,
): LocalRoomNamesContent { ): LocalRoomNamesContent {
return { rooms: roomNamesStore.getLatest(mx) }; const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
} if (
raw &&
/** typeof raw === 'object' &&
* Set (or clear, when `name` is empty) the local display name for a room. 'rooms' in raw &&
* Routed through the shared write queue so back-to-back renames of different // eslint-disable-next-line @typescript-eslint/no-explicit-any
* rooms are always computed from the latest snapshot instead of a stale one. typeof (raw as any).rooms === 'object'
*/ ) {
export function setLocalRoomName( return raw as LocalRoomNamesContent;
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 }; return { rooms: {} };
});
} }
export const useLocalRoomName = (room: Room): string => { export const useLocalRoomName = (room: Room): string => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const localNames = roomNamesStore.useValue(mx);
const [name, setName] = useState(room.name); const getLocalName = useCallback((): string => {
const content = getLocalRoomNamesContent(mx);
return content.rooms[room.roomId] ?? room.name;
}, [mx, room]);
const [name, setName] = useState(getLocalName);
useEffect(() => { useEffect(() => {
setName(room.name); setName(getLocalName());
const handleAccountData = (event: MatrixEvent) => {
if (event.getType() !== LOCAL_ROOM_NAMES_KEY) return;
setName(getLocalName());
};
mx.on(ClientEvent.AccountData, handleAccountData);
const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => { const handleRoomNameChange: RoomEventHandlerMap[RoomEvent.Name] = () => {
setName(room.name); setName(getLocalName());
}; };
room.on(RoomEvent.Name, handleRoomNameChange); room.on(RoomEvent.Name, handleRoomNameChange);
return () => { return () => {
mx.removeListener(ClientEvent.AccountData, handleAccountData);
room.removeListener(RoomEvent.Name, handleRoomNameChange); room.removeListener(RoomEvent.Name, handleRoomNameChange);
}; };
}, [room]); }, [mx, room, getLocalName]);
return localNames[room.roomId] ?? name; return name;
}; };
export const useHasLocalRoomName = (roomId: string): boolean => { export const useHasLocalRoomName = (roomId: string): boolean => {
const mx = useMatrixClient(); const mx = useMatrixClient();
const localNames = roomNamesStore.useValue(mx);
return !!localNames[roomId]; 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;
}; };
export type RoomTopicContent = { export type RoomTopicContent = {
+5 -11
View File
@@ -1,4 +1,4 @@
import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk'; import { Room, RoomEvent, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { reactionOrEditEvent } from '../utils/room'; import { reactionOrEditEvent } from '../utils/room';
@@ -52,21 +52,15 @@ export function useRoomReadPositions(room: Room): Map<string, string[]> {
debounceTimer = null; debounceTimer = null;
}, 150); }, 150);
}; };
// RoomMemberEvent.Membership is emitted on the RoomMember (and re-emitted on the const onMembership = (): void => setPositions(computePositions(room, myUserId));
// MatrixClient), never on Room — listening on `room` never fired. Subscribe on
// the client and filter to this room (Gitea #61).
const onMembership = (_ev: MatrixEvent, member: RoomMember): void => {
if (member.roomId !== room.roomId) return;
setPositions(computePositions(room, myUserId));
};
room.on(RoomEvent.Receipt, onReceipt); room.on(RoomEvent.Receipt, onReceipt);
mx.on(RoomMemberEvent.Membership, onMembership); (room as any).on(RoomMemberEvent.Membership, onMembership);
return () => { return () => {
if (debounceTimer !== null) clearTimeout(debounceTimer); if (debounceTimer !== null) clearTimeout(debounceTimer);
room.removeListener(RoomEvent.Receipt, onReceipt); room.removeListener(RoomEvent.Receipt, onReceipt);
mx.removeListener(RoomMemberEvent.Membership, onMembership); (room as any).removeListener(RoomMemberEvent.Membership, onMembership);
}; };
}, [room, myUserId, mx]); }, [room, myUserId]);
return positions; return positions;
} }
-33
View File
@@ -1,33 +0,0 @@
import { useEffect } from 'react';
import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
/**
* #69 — `useSessionSync` (the full cross-tab session sync, with in-place token
* rotation handling) is mounted only inside the authenticated `ClientRoot`
* shell, so a tab parked on the login/register/OIDC-callback pages never
* learns that another tab just signed in. Left alone, that tab keeps showing
* the login form and, if submitted, starts a second device/session for the
* same account.
*
* This is a deliberately lighter watcher for the unauthenticated routes: it
* only cares about a session APPEARING where there was none before (a fresh
* login completed in another tab) and reloads so this tab picks up the
* authenticated shell instead. It does not need `useSessionSync`'s
* rotation/relogin handling since there is no running client here to keep in
* sync — do not import/duplicate that logic, just reload.
*/
export function useSessionAppeared(): void {
useEffect(() => {
// If we already have a session, there's nothing to watch for here — the
// authenticated shell's own useSessionSync takes over once mounted.
if (getFallbackSession()) return undefined;
const unsubscribe = subscribeSessionChanges((session) => {
if (session) {
window.location.reload();
}
});
return unsubscribe;
}, []);
}
-35
View File
@@ -1,35 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifySessionChange, SessionIdentity } from './useSessionSync';
const alice: SessionIdentity = { userId: '@alice:hs', deviceId: 'DEV1', accessToken: 'tok-1' };
test('classifySessionChange: session removed elsewhere → removed', () => {
assert.equal(classifySessionChange(alice, null), 'removed');
});
test('classifySessionChange: nothing before or after → none', () => {
assert.equal(classifySessionChange(null, null), 'none');
});
test('classifySessionChange: session appeared → relogin', () => {
assert.equal(classifySessionChange(null, alice), 'relogin');
});
test('classifySessionChange: different user or device → relogin', () => {
assert.equal(classifySessionChange(alice, { ...alice, userId: '@bob:hs' }), 'relogin');
assert.equal(classifySessionChange(alice, { ...alice, deviceId: 'DEV2' }), 'relogin');
// Even when the access token also changed, the identity change wins.
assert.equal(
classifySessionChange(alice, { ...alice, deviceId: 'DEV2', accessToken: 'tok-2' }),
'relogin',
);
});
test('classifySessionChange: same user+device, new access token → rotated (no reload)', () => {
assert.equal(classifySessionChange(alice, { ...alice, accessToken: 'tok-2' }), 'rotated');
});
test('classifySessionChange: identical credentials (metadata-only rewrite) → none', () => {
assert.equal(classifySessionChange(alice, { ...alice }), 'none');
});
+13 -53
View File
@@ -1,32 +1,5 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import type { MatrixClient } from 'matrix-js-sdk'; import { getFallbackSession, subscribeSessionChanges } from '../state/sessions';
import { getFallbackSession, Session, subscribeSessionChanges } from '../state/sessions';
/** The credential identity this tab is currently running on. */
export type SessionIdentity = Pick<Session, 'userId' | 'deviceId' | 'accessToken'>;
/**
* What an out-of-tab session change means for this tab:
* - `none` — nothing credential-relevant changed (metadata-only rewrite,
* or a duplicate storage event from the dual-write).
* - `removed` — the session disappeared (logout / localStorage.clear()).
* - `relogin` — a different user or device signed in.
* - `rotated` — same user + device, only the access token changed (an OIDC
* refresh performed by another tab).
*/
export type SessionChange = 'none' | 'removed' | 'relogin' | 'rotated';
/** Pure: classify a freshly-read session against the one this tab runs on. */
export const classifySessionChange = (
current: SessionIdentity | null,
next: SessionIdentity | null,
): SessionChange => {
if (!next) return current ? 'removed' : 'none';
if (!current) return 'relogin';
if (next.userId !== current.userId || next.deviceId !== current.deviceId) return 'relogin';
if (next.accessToken !== current.accessToken) return 'rotated';
return 'none';
};
/** /**
* Keep this tab in sync with session changes performed in other tabs/windows. * Keep this tab in sync with session changes performed in other tabs/windows.
@@ -38,39 +11,26 @@ export const classifySessionChange = (
* Default action is the safest one for auth-critical state — a full reload: * Default action is the safest one for auth-critical state — a full reload:
* - session REMOVED elsewhere (logout / localStorage.clear()) → the access * - session REMOVED elsewhere (logout / localStorage.clear()) → the access
* token disappears, so we reload; the router bounces to auth on next boot. * token disappears, so we reload; the router bounces to auth on next boot.
* - session APPEARED or its user/device CHANGED elsewhere (a fresh login) → * - session APPEARED or its access token CHANGED elsewhere (a fresh login or
* we reload so the client re-initialises with the new credentials rather * a token rotation) → we reload so the client re-initialises with the new
* than running on a stale/revoked token. * credentials rather than running on a stale/revoked token.
* *
* #16 — a same-user, same-device access-token ROTATION (a routine OIDC refresh * A change that does not alter the access token (e.g. an OIDC metadata-only
* in another tab) must NOT reload: that would drop in-progress calls/uploads
* every few minutes. Instead the new tokens are swapped into the running
* client in place. When no client is available yet we fall back to the reload.
*
* A change that does not alter the credentials (e.g. an OIDC metadata-only
* rewrite) is ignored, which also collapses the several storage events emitted * rewrite) is ignored, which also collapses the several storage events emitted
* by a single dual-write into at most one reaction. * by a single dual-write into at most one reload.
*/ */
export const useSessionSync = (mx?: MatrixClient): void => { export const useSessionSync = (): void => {
useEffect(() => { useEffect(() => {
// Snapshot the credential this tab runs on; compare against it so we only // Snapshot the credential this tab booted with; compare against it so we
// react to a genuine credential change. Updated on an in-place rotation. // only reload on a genuine credential change.
let current: SessionIdentity | null = getFallbackSession() ?? null; const initialAccessToken = getFallbackSession()?.accessToken ?? null;
const unsubscribe = subscribeSessionChanges((session) => { const unsubscribe = subscribeSessionChanges((session) => {
const change = classifySessionChange(current, session); const nextAccessToken = session?.accessToken ?? null;
if (change === 'none') return; if (nextAccessToken === initialAccessToken) return;
if (change === 'rotated' && mx && session) {
// Same opts object backs the SDK's TokenRefresher, so updating the
// refresh token here keeps its next refresh on the rotated token.
mx.setAccessToken(session.accessToken);
if (session.refreshToken) mx.http.opts.refreshToken = session.refreshToken;
current = session;
return;
}
window.location.reload(); window.location.reload();
}); });
return unsubscribe; return unsubscribe;
}, [mx]); }, []);
}; };
-6
View File
@@ -14,11 +14,5 @@ export function useTauriCallPower(): void {
const callEmbed = useAtomValue(callEmbedAtom); const callEmbed = useAtomValue(callEmbedAtom);
useEffect(() => { useEffect(() => {
invokeTauri('set_call_active', { active: callEmbed !== undefined }); invokeTauri('set_call_active', { active: callEmbed !== undefined });
// #76 — if the shell unmounts while a call is still active (forced
// reload/HMR, or a future teardown path that doesn't end the call first),
// release the native keep-awake state instead of leaving it held forever.
return () => {
invokeTauri('set_call_active', { active: false });
};
}, [callEmbed]); }, [callEmbed]);
} }
+1 -16
View File
@@ -1,10 +1,7 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { MsgType } from 'matrix-js-sdk'; import { MsgType } from 'matrix-js-sdk';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { useTauriEvent } from './useTauri'; import { useTauriEvent } from './useTauri';
import { createErrorToast, toastQueueAtom } from '../state/toast';
/** Payload of the `lotus-notification-activate` event (a plain body click). */ /** Payload of the `lotus-notification-activate` event (a plain body click). */
interface ActivateDetail { interface ActivateDetail {
@@ -30,7 +27,6 @@ interface ReplyDetail {
export function useTauriToastActions(): void { export function useTauriToastActions(): void {
const navigate = useNavigate(); const navigate = useNavigate();
const mx = useMatrixClient(); const mx = useMatrixClient();
const setToast = useSetAtom(toastQueueAtom);
useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => { useTauriEvent<ActivateDetail>('lotus-notification-activate', ({ path }) => {
if (path) navigate(path); if (path) navigate(path);
@@ -38,17 +34,6 @@ export function useTauriToastActions(): void {
useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => { useTauriEvent<ReplyDetail>('lotus-notification-reply', ({ roomId, text }) => {
if (!roomId || !text) return; if (!roomId || !text) return;
// #79 — a quick-reply failure (offline, no send permission, etc.) used to mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => undefined);
// be swallowed with no feedback, leaving the user believing it was sent.
// Surface it via the in-app error toast instead.
mx.sendMessage(roomId, { msgtype: MsgType.Text, body: text }).catch(() => {
setToast(
createErrorToast(
'Your quick reply could not be sent. Please try again.',
Icons.Warning,
'Reply failed',
),
);
});
}); });
} }
-34
View File
@@ -1,34 +0,0 @@
import { Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { useStateEvent } from './useStateEvent';
import { StateEvent } from '../../types/matrix/room';
import { VoiceLimitContent } from '../features/common-settings/general/RoomVoiceLimit';
import { useCallMembers, useCallSession } from './useCall';
import { useMatrixClient } from './useMatrixClient';
export type VoiceChannelFull = {
channelFull: boolean;
current: number;
max: number;
};
/**
* [Gitea #30] Voice channel user limit (`io.lotus.voice_limit`), shared between
* `CallPrescreen` and any other join path (e.g. the room-nav second-click join)
* so they agree on when a channel is full. 0/absent `max_users` means no limit.
*/
export const useVoiceChannelFull = (room: Room): VoiceChannelFull => {
const mx = useMatrixClient();
const callSession = useCallSession(room);
const callMembers = useCallMembers(callSession);
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
return useMemo(() => {
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
return { channelFull, current: callMembers.length, max: maxUsers };
}, [limitEvent, callMembers, mx]);
};
-7
View File
@@ -26,7 +26,6 @@ import { settingsAtom } from '../state/settings';
import { isWithinTimeWindow } from '../utils/timeWindow'; import { isWithinTimeWindow } from '../utils/timeWindow';
import { LotusToastContainer } from '../features/toast/LotusToastContainer'; import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge'; import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { useSessionAppeared } from '../hooks/useSessionAppeared';
import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome'; import { useTauriWindowChrome } from '../hooks/useTauriWindowChrome';
import { isTauri } from '../hooks/useTauri'; import { isTauri } from '../hooks/useTauri';
import { TitleBar } from '../features/desktop/TitleBar'; import { TitleBar } from '../features/desktop/TitleBar';
@@ -166,12 +165,6 @@ const queryClient = new QueryClient();
function App() { function App() {
const screenSize = useScreenSize(); const screenSize = useScreenSize();
useCompositionEndTracking(); useCompositionEndTracking();
// #69 — cross-tab session sync (useSessionSync) only mounts inside the
// authenticated ClientRoot shell, so a tab still on the unauthenticated
// routes (login/register/OIDC callback) never learns that another tab just
// signed in. This lighter watcher covers that gap by reloading once a
// session appears where there was none.
useSessionAppeared();
const portalContainer = document.getElementById('portalContainer') ?? undefined; const portalContainer = document.getElementById('portalContainer') ?? undefined;
+2 -2
View File
@@ -20,7 +20,6 @@ import {
} from '../../hooks/useClientConfig'; } from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
import { ServerPicker } from './ServerPicker'; import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@@ -30,8 +29,9 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer'; import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png'); const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
const currentAuthPath = (pathname: string): string => { const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) { if (matchPath(LOGIN_PATH, pathname)) {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import { OIDC_CALLBACK_PATH } from '../../paths'; import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils'; import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png'); const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
/** /**
* Absolute URL the OIDC provider redirects back to after authorization. * Absolute URL the OIDC provider redirects back to after authorization.
+3 -11
View File
@@ -6,12 +6,7 @@ import {
registerOidcClient, registerOidcClient,
} from 'matrix-js-sdk'; } from 'matrix-js-sdk';
import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig'; import { getOidcCallbackUrl, getOidcClientMetadata } from './oidcConfig';
import { import { cacheClientId, getCachedClientId, invalidateCachedClient } from './oidcState';
cacheClientId,
getCachedClientId,
invalidateCachedClient,
isStaleClientError,
} from './oidcState';
export { completeAuthorizationCodeGrant }; export { completeAuthorizationCodeGrant };
@@ -47,11 +42,8 @@ export const startOidcLogin = async (issuer: string, homeserverBaseUrl: string):
}); });
window.location.assign(url); window.location.assign(url);
} catch (e) { } catch (e) {
// #67 — drop the cached client so the next attempt re-registers, but ONLY // Drop a possibly-stale cached client so the next attempt re-registers.
// when the failure says the client id itself was rejected. Network and invalidateCachedClient(issuer);
// discovery failures keep the cache, otherwise each retry would register
// yet another throwaway dynamic client at the provider.
if (isStaleClientError(e)) invalidateCachedClient(issuer);
throw e; throw e;
} }
}; };
-22
View File
@@ -5,7 +5,6 @@ import {
cacheClientId, cacheClientId,
invalidateCachedClient, invalidateCachedClient,
parseOidcCallbackParams, parseOidcCallbackParams,
isStaleClientError,
} from './oidcState'; } from './oidcState';
const installStorage = (): Map<string, string> => { const installStorage = (): Map<string, string> => {
@@ -61,24 +60,3 @@ test('parseOidcCallbackParams classifies success / error / invalid', () => {
assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' }); assert.deepEqual(parseOidcCallbackParams('?code=only'), { kind: 'invalid' });
assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' }); assert.deepEqual(parseOidcCallbackParams(''), { kind: 'invalid' });
}); });
test('isStaleClientError: only client-rejection shapes invalidate the cache', () => {
// OAuth error responses naming the client (oidc-client-ts ErrorResponse shape).
assert.equal(isStaleClientError({ error: 'invalid_client' }), true);
assert.equal(isStaleClientError({ error: 'unauthorized_client' }), true);
// HTTP 400/401 from the registration/authorize step (MatrixError-style).
assert.equal(isStaleClientError({ httpStatus: 400 }), true);
assert.equal(isStaleClientError({ httpStatus: 401 }), true);
assert.equal(isStaleClientError({ status: 401 }), true);
assert.equal(isStaleClientError({ httpStatus: 500 }), false);
assert.equal(isStaleClientError({ httpStatus: 400, status: 200 }), true); // httpStatus wins
});
test('isStaleClientError: transient / discovery / local failures keep the cache', () => {
assert.equal(isStaleClientError(new TypeError('Failed to fetch')), false);
assert.equal(isStaleClientError(new Error('Something went wrong with OIDC discovery')), false);
assert.equal(isStaleClientError({ error: 'server_error' }), false);
assert.equal(isStaleClientError(new Error('crypto.randomUUID is not a function')), false);
assert.equal(isStaleClientError(undefined), false);
assert.equal(isStaleClientError('invalid_client'), false); // bare strings are not error objects
});
-26
View File
@@ -40,32 +40,6 @@ export const invalidateCachedClient = (issuer: string): void => {
} }
}; };
// OAuth error codes that mean the provider no longer recognises our client id
// (deleted/expired dynamic registration, or one issued by a different deployment).
const STALE_CLIENT_ERROR_CODES = new Set(['invalid_client', 'unauthorized_client']);
/**
* #67 — pure: does a `startOidcLogin` failure indicate the CACHED client id is
* bad? Only then is dropping the registration cache justified; a transient
* network error, an offline discovery fetch, or a local (`crypto.randomUUID`)
* failure must leave it alone, otherwise every retry performs a fresh dynamic
* registration and piles throwaway clients onto the provider. Recognised
* shapes: an OAuth error response (`{ error: 'invalid_client' }`, as thrown by
* oidc-client-ts `ErrorResponse`), or an HTTP 400/401 carried as
* `httpStatus`/`status` (MatrixError-style) from the registration/authorize step.
*/
export const isStaleClientError = (e: unknown): boolean => {
if (!e || typeof e !== 'object') return false;
const { error, httpStatus, status } = e as {
error?: unknown;
httpStatus?: unknown;
status?: unknown;
};
if (typeof error === 'string' && STALE_CLIENT_ERROR_CODES.has(error)) return true;
const code = typeof httpStatus === 'number' ? httpStatus : status;
return code === 400 || code === 401;
};
/** Parsed shape of the provider's redirect back to our callback URL. */ /** Parsed shape of the provider's redirect back to our callback URL. */
export type OidcCallbackParams = export type OidcCallbackParams =
| { kind: 'success'; code: string; state: string } | { kind: 'success'; code: string; state: string }
+7 -25
View File
@@ -26,7 +26,6 @@ import { settingsAtom } from '../../state/settings';
import { allInvitesAtom } from '../../state/room-list/inviteList'; import { allInvitesAtom } from '../../state/room-list/inviteList';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts'; import { useHydrateMsgDrafts } from '../../hooks/useHydrateMsgDrafts';
import { useSearchCacheInvalidation } from '../../utils/searchCacheInvalidation';
import { import {
getDirectRoomPath, getDirectRoomPath,
getHomeRoomPath, getHomeRoomPath,
@@ -52,7 +51,7 @@ import {
MuteTimerEntry, MuteTimerEntry,
loadMuteTimers, loadMuteTimers,
unmuteRoom, unmuteRoom,
} from '../../features/room-nav/muteTimers'; } from '../../features/room-nav/RoomNavItem';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile'; import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast'; import { toastQueueAtom } from '../../state/toast';
@@ -71,12 +70,12 @@ import {
THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR, THREAD_NOTIFICATIONS_FALLBACK_BEHAVIOR,
} from '../../utils/threadNotifications'; } from '../../utils/threadNotifications';
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/lotus-highlight.png');
// Grace period after the initial sync settles before invite notifications arm, so // Grace period after the initial sync settles before invite notifications arm, so
// the async invite-atom population lands first and isn't mistaken for new invites. // the async invite-atom population lands first and isn't mistaken for new invites.
const LogoSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus.png');
const LogoUnreadSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-unread.png');
const LogoHighlightSVG = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/lotus-highlight.png');
const INVITE_NOTIFY_ARM_DELAY_MS = 3000; const INVITE_NOTIFY_ARM_DELAY_MS = 3000;
function SystemEmojiFeature() { function SystemEmojiFeature() {
@@ -723,23 +722,14 @@ function ReminderMonitor() {
if (!firedRef.current.has(key)) { if (!firedRef.current.has(key)) {
firedRef.current.add(key); firedRef.current.add(key);
const room = mx.getRoom(r.roomId); const room = mx.getRoom(r.roomId);
const roomName = room?.name ?? 'Unknown Room';
// Reminders for E2EE rooms carry no text (account data is server-
// readable), so resolve the body from the local timeline at fire time.
const localBody = room?.findEventById(r.eventId)?.getContent()?.body;
const body =
r.message ??
(typeof localBody === 'string' && localBody
? localBody.slice(0, 120)
: `Reminder for a message in ${roomName}`);
const hashPath = mDirectsRef.current.has(r.roomId) const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId) ? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId); : getHomeRoomPath(r.roomId, r.eventId);
setToast({ setToast({
id: `reminder-${key}`, id: `reminder-${key}`,
displayName: 'Reminder', displayName: 'Reminder',
body, body: r.message,
roomName, roomName: room?.name ?? 'Unknown Room',
roomId: r.roomId, roomId: r.roomId,
hashPath, hashPath,
}); });
@@ -906,18 +896,10 @@ function MsgDraftHydrator(): null {
return null; return null;
} }
// Gitea #14 — keeps the opt-in persistent search cache from outliving the
// plaintext it indexed (redactions + leaving/being banned from a room).
function SearchCacheInvalidationFeature(): null {
useSearchCacheInvalidation();
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return ( return (
<> <>
<MsgDraftHydrator /> <MsgDraftHydrator />
<SearchCacheInvalidationFeature />
<SystemEmojiFeature /> <SystemEmojiFeature />
<PageZoomFeature /> <PageZoomFeature />
<FaviconUpdater /> <FaviconUpdater />

Some files were not shown because too many files have changed in this diff Show More