Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aef410f60 | ||
|
|
b2376513fd | ||
|
|
2bbd390a3b |
+26
-30
@@ -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,36 +52,37 @@ 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 only (existing no-explicit-any warnings stay
|
|
||||||
# informational — check:eslint has no --max-warnings).
|
|
||||||
- name: Prettier
|
|
||||||
run: npm run check:prettier
|
|
||||||
|
|
||||||
- name: ESLint
|
|
||||||
run: npm run check:eslint
|
|
||||||
|
|
||||||
- name: TypeScript
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
|
|
||||||
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
|
|
||||||
- name: Unit tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||||
|
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||||
|
# A failure blocks the deploy.
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
|
||||||
|
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
|
||||||
|
# formatted), so these gate real regressions instead of relying on local
|
||||||
|
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
|
||||||
|
# introduce upstream type/lint/format issues; that path deploys without
|
||||||
|
# CI, but a subsequent normal push would surface the failure here — fix
|
||||||
|
# forward (or briefly re-soften a gate) rather than let it deploy broken.
|
||||||
|
# eslint gates on errors only (existing `no-explicit-any` warnings stay
|
||||||
|
# informational — `check:eslint` has no --max-warnings).
|
||||||
|
- name: TypeScript
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
run: npm run check:eslint
|
||||||
|
|
||||||
|
- name: Prettier
|
||||||
|
run: npm run check:prettier
|
||||||
|
|
||||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||||
- name: Audit (high/critical)
|
- name: Audit (high/critical)
|
||||||
run: npm audit --audit-level=high --omit=dev
|
run: npm audit --audit-level=high --omit=dev
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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
@@ -1 +1,3 @@
|
|||||||
npx lint-staged
|
# These are commented until we enable lint and typecheck
|
||||||
|
# npx tsc -p tsconfig.json --noEmit
|
||||||
|
# npx lint-staged
|
||||||
+14
-14
@@ -175,19 +175,19 @@ 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 |
|
||||||
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
|
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
|
||||||
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
|
| 🃏 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 |
|
||||||
|
|
||||||
### Implementation
|
### Implementation
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
|||||||
+2
-4
@@ -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):
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -129,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)
|
||||||
|
|
||||||
@@ -176,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
@@ -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,
|
||||||
|
|||||||
Generated
+4
-4
@@ -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",
|
||||||
@@ -2694,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": {
|
||||||
|
|||||||
+1
-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",
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -1154,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}
|
||||||
|
|||||||
@@ -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)' },
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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,59 +147,41 @@ 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={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
inset: 0,
|
left: '-25%',
|
||||||
maskImage:
|
right: '-25%',
|
||||||
'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%)',
|
bottom: 0,
|
||||||
WebkitMaskImage:
|
height: '62%',
|
||||||
'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%)',
|
overflow: 'hidden',
|
||||||
opacity: reduced ? 0.4 : 0.46,
|
perspective: '280px',
|
||||||
|
perspectiveOrigin: '50% 0%',
|
||||||
|
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||||
|
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
||||||
|
opacity: reduced ? 0.5 : 0.62,
|
||||||
contain: 'layout paint style',
|
contain: 'layout paint style',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '-25%',
|
left: 0,
|
||||||
right: '-25%',
|
right: 0,
|
||||||
bottom: 0,
|
top: 0,
|
||||||
height: '62%',
|
height: '200%',
|
||||||
overflow: 'hidden',
|
transformOrigin: 'top center',
|
||||||
perspective: '280px',
|
transform: 'rotateX(74deg)',
|
||||||
perspectiveOrigin: '50% 0%',
|
backgroundImage: GRID_URI,
|
||||||
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
backgroundRepeat: 'repeat-y',
|
||||||
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
|
backgroundSize: '100% 50%',
|
||||||
contain: 'layout paint style',
|
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
|
||||||
|
willChange: reduced ? undefined : 'transform',
|
||||||
|
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
top: 0,
|
|
||||||
height: '200%',
|
|
||||||
transformOrigin: 'top center',
|
|
||||||
transform: 'rotateX(74deg)',
|
|
||||||
backgroundImage: GRID_URI,
|
|
||||||
backgroundRepeat: 'repeat-y',
|
|
||||||
backgroundSize: '100% 50%',
|
|
||||||
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))',
|
|
||||||
willChange: reduced ? undefined : 'transform',
|
|
||||||
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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
|
||||||
@@ -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,
|
letterSpacing: '0.32em',
|
||||||
|
color: NEON_CYAN,
|
||||||
|
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
|
opacity: reduced ? 0.6 : undefined,
|
||||||
|
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
INSERT COIN
|
||||||
style={{
|
</div>
|
||||||
letterSpacing: '0.18em',
|
|
||||||
color: NEON_MAGENTA,
|
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
|
||||||
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
very low opacity so it reads as ambient chrome, not UI. */}
|
||||||
opacity: reduced ? 0.5 : undefined,
|
<div
|
||||||
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
aria-hidden="true"
|
||||||
}}
|
style={{
|
||||||
>
|
position: 'absolute',
|
||||||
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
|
top: '2.5%',
|
||||||
</div>
|
left: '2%',
|
||||||
<div
|
fontFamily: '"Courier New", monospace',
|
||||||
style={{
|
fontSize: '10px',
|
||||||
letterSpacing: '0.32em',
|
fontWeight: 700,
|
||||||
color: NEON_CYAN,
|
letterSpacing: '0.18em',
|
||||||
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
|
color: NEON_MAGENTA,
|
||||||
opacity: reduced ? 0.6 : undefined,
|
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
|
||||||
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
|
userSelect: 'none',
|
||||||
}}
|
whiteSpace: 'nowrap',
|
||||||
>
|
opacity: reduced ? 0.5 : undefined,
|
||||||
INSERT COIN
|
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
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%)',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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]),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -214,43 +196,16 @@ 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"
|
rowsToPersist.push({
|
||||||
// fallback. Indexing it under its own event id would leave two
|
roomId,
|
||||||
// separate matching rows (the stale pre-edit text and the edit
|
eventId: event.getId() as string,
|
||||||
// fallback) searchable forever. Instead, upsert the *original*
|
ts,
|
||||||
// event's row with the edit's `m.new_content` (Gitea #14).
|
sender,
|
||||||
const editTargetId =
|
body: text.body,
|
||||||
event.getRelation()?.rel_type === RelationType.Replace
|
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
||||||
? event.getRelation()?.event_id
|
...(text.pollText ? { pollText: text.pollText } : {}),
|
||||||
: 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({
|
|
||||||
roomId,
|
|
||||||
eventId: event.getId() as string,
|
|
||||||
ts,
|
|
||||||
sender,
|
|
||||||
body: text.body,
|
|
||||||
...(text.formattedBody ? { formattedBody: text.formattedBody } : {}),
|
|
||||||
...(text.pollText ? { pollText: text.pollText } : {}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (senderSet && !senderSet.has(sender)) continue;
|
if (senderSet && !senderSet.has(sender)) continue;
|
||||||
@@ -284,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;
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 })),
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ import {
|
|||||||
settingsAtom,
|
settingsAtom,
|
||||||
} from '../../state/settings';
|
} from '../../state/settings';
|
||||||
import {
|
import {
|
||||||
buildCompressedUploadItem,
|
|
||||||
getAudioMsgContent,
|
getAudioMsgContent,
|
||||||
getFileMsgContent,
|
getFileMsgContent,
|
||||||
getImageMsgContent,
|
getImageMsgContent,
|
||||||
@@ -245,11 +244,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
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],
|
||||||
@@ -398,47 +394,27 @@ 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
|
if (Array.isArray(nodes) && nodes.length > 0) {
|
||||||
// draft (stored as a bare array, pre-dating user-scoping) or one
|
Transforms.insertFragment(editor, nodes);
|
||||||
// written by a different userId is foreign — drop it rather than
|
// Mirror the restored draft into the atom so the draft indicator
|
||||||
// risk pre-filling another account's unsent text into the composer.
|
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
||||||
const foreign =
|
// after a page reload — not only on same-session room re-entry.
|
||||||
!parsed ||
|
setMsgDraft(nodes);
|
||||||
typeof parsed !== 'object' ||
|
|
||||||
Array.isArray(parsed) ||
|
|
||||||
parsed.userId !== mx.getUserId();
|
|
||||||
if (foreign) {
|
|
||||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
|
||||||
} else {
|
|
||||||
const nodes = parsed.nodes;
|
|
||||||
if (Array.isArray(nodes) && nodes.length > 0) {
|
|
||||||
Transforms.insertFragment(editor, nodes);
|
|
||||||
// Mirror the restored draft into the atom so the draft indicator
|
|
||||||
// (reads roomIdToMsgDraftAtomFamily) reflects a persisted draft
|
|
||||||
// after a page reload — not only on same-session room re-entry.
|
|
||||||
setMsgDraft(nodes);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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}`);
|
||||||
@@ -446,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(
|
||||||
@@ -509,29 +485,22 @@ 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
|
name: compressedName,
|
||||||
// *new* encInfo) — reusing the original's encInfo would publish the
|
type: compressedType,
|
||||||
// image in the clear and yield an undecryptable attachment.
|
});
|
||||||
const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined;
|
|
||||||
const uploadRes = encrypted
|
|
||||||
? await mx.uploadContent(encrypted.file)
|
|
||||||
: await mx.uploadContent(compressedFile, {
|
|
||||||
name: compressedName,
|
|
||||||
type: compressedType,
|
|
||||||
});
|
|
||||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (compressedMxc) {
|
if (compressedMxc) {
|
||||||
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -728,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) => {
|
||||||
@@ -857,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',
|
||||||
|
url: mxcUrl,
|
||||||
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
info: { mimetype: 'image/gif', w, h, size: blob.size },
|
||||||
};
|
});
|
||||||
|
|
||||||
// Mirror the attachment/voice paths: in an encrypted room the media
|
|
||||||
// itself must be encrypted, otherwise the homeserver (and anyone with
|
|
||||||
// the mxc URI) can see the GIF even though the event body is encrypted.
|
|
||||||
if (room.hasEncryptionStateEvent()) {
|
|
||||||
const { encInfo, file: encBlob } = await encryptFile(gifFile);
|
|
||||||
const uploadRes = await mx.uploadContent(encBlob);
|
|
||||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
|
||||||
if (!mxcUrl) return;
|
|
||||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
|
||||||
...baseContent,
|
|
||||||
file: { ...encInfo, url: mxcUrl },
|
|
||||||
} as any);
|
|
||||||
} else {
|
|
||||||
const uploadRes = await mx.uploadContent(gifFile, {
|
|
||||||
type: 'image/gif',
|
|
||||||
name: 'image.gif',
|
|
||||||
includeFilename: false,
|
|
||||||
});
|
|
||||||
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
|
|
||||||
if (!mxcUrl) return;
|
|
||||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
|
||||||
...baseContent,
|
|
||||||
url: mxcUrl,
|
|
||||||
} as any);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} 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;
|
||||||
@@ -898,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(
|
||||||
@@ -1500,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}
|
||||||
|
|||||||
@@ -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,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.
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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), []);
|
|
||||||
});
|
|
||||||
@@ -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]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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'
|
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||||
: selected
|
(d) => d.slug === selected,
|
||||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
)?.name ?? selected)
|
||||||
(d) => d.slug === selected,
|
: 'None'}
|
||||||
)?.name ?? selected)
|
|
||||||
: '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.
|
||||||
|
|||||||
@@ -117,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
|
||||||
@@ -1475,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(
|
||||||
@@ -1492,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) =>
|
||||||
@@ -1570,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);
|
||||||
@@ -1837,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"
|
||||||
@@ -1855,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"
|
||||||
|
|||||||
@@ -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,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;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
});
|
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
};
|
|
||||||
@@ -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' });
|
|
||||||
});
|
|
||||||
@@ -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,
|
return { rooms: {} };
|
||||||
name: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return roomNamesStore.enqueueWrite(mx, (current) => {
|
|
||||||
if (!name) {
|
|
||||||
const { [roomId]: _removed, ...rest } = current;
|
|
||||||
return rest;
|
|
||||||
}
|
|
||||||
return { ...current, [roomId]: name };
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useLocalRoomName = (room: Room): string => {
|
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 = {
|
||||||
|
|||||||
@@ -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');
|
|
||||||
});
|
|
||||||
@@ -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]);
|
}, []);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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]);
|
|
||||||
};
|
|
||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -208,11 +208,9 @@ export function ClientRoot({ children }: ClientRootProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useLogoutListener(mx);
|
useLogoutListener(mx);
|
||||||
// Cross-tab session sync: another tab logging out / in reloads this tab so it
|
// Cross-tab session sync: another tab logging out / in (access token changed
|
||||||
// never runs with stale credentials. A same-device token *rotation* (OIDC
|
// in localStorage) reloads this tab so it never runs with stale credentials.
|
||||||
// refresh in another tab) is swapped into the running client instead of
|
useSessionSync();
|
||||||
// reloading, which would drop an in-progress call/upload (#16).
|
|
||||||
useSessionSync(mx);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loadState.status === AsyncStatus.Idle) {
|
if (loadState.status === AsyncStatus.Idle) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
import { Box, Button, Icon, Icons, Text, config, toRem } from 'folds';
|
||||||
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
import { Page, PageHero, PageHeroSection } from '../../components/page';
|
||||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
|
||||||
import pkg from '../../../../package.json';
|
import pkg from '../../../../package.json';
|
||||||
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
||||||
|
|
||||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
|
||||||
|
|
||||||
export function WelcomePage() {
|
export function WelcomePage() {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,31 +5,6 @@ import { clearRecentForwardTargets } from './recentForwardTargets';
|
|||||||
import { clearRecentGifs } from './recentGifs';
|
import { clearRecentGifs } from './recentGifs';
|
||||||
import { clearRecentStickers } from './recentStickers';
|
import { clearRecentStickers } from './recentStickers';
|
||||||
import { clearNavToActivePathStore } from './navToActivePath';
|
import { clearNavToActivePathStore } from './navToActivePath';
|
||||||
import { DRAFT_MSG_KEY_PREFIX } from '../utils/draft';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* [Gitea #41] Wipe every persisted composer draft (`draft-msg-<roomId>`). Drafts
|
|
||||||
* hold decrypted, unsent message text with no user scoping, so leaving them in
|
|
||||||
* place across logout lets the next account on this device see (and send) the
|
|
||||||
* previous user's draft the moment they open the same room.
|
|
||||||
*/
|
|
||||||
const clearMsgDrafts = (): void => {
|
|
||||||
let keys: string[];
|
|
||||||
try {
|
|
||||||
keys = Object.keys(localStorage);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
keys.forEach((key) => {
|
|
||||||
if (key.startsWith(DRAFT_MSG_KEY_PREFIX)) {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch {
|
|
||||||
// Best-effort — a single unreadable/blocked key must not abort the sweep.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Single auditable place that wipes the `localStorage` caches holding decrypted
|
* Single auditable place that wipes the `localStorage` caches holding decrypted
|
||||||
@@ -43,10 +18,6 @@ const clearMsgDrafts = (): void => {
|
|||||||
* - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII)
|
* - `cinny_recent_forward_targets_v1` — recent forward contact/room graph (PII)
|
||||||
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
* - `cinny_recent_gifs_v1` / `cinny_recent_stickers_v1` — media the user sent
|
||||||
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
|
* - `navToActivePath<userId>` — per-space last-visited room paths (needs userId)
|
||||||
* - `draft-msg-*` — unsent composer drafts (decrypted message text, unscoped by
|
|
||||||
* user — see [Gitea #41]; previously deliberately preserved across logout
|
|
||||||
* (N98), which let the next account on this device see/send a prior user's
|
|
||||||
* draft, so this is no longer a "by design" exemption)
|
|
||||||
*
|
*
|
||||||
* NOT swept here (by design):
|
* NOT swept here (by design):
|
||||||
* - session credential keys → `removeFallbackSession()`
|
* - session credential keys → `removeFallbackSession()`
|
||||||
@@ -54,8 +25,9 @@ const clearMsgDrafts = (): void => {
|
|||||||
* bookmarks, user notes, status presets — themselves plaintext) → wiped by
|
* bookmarks, user notes, status presets — themselves plaintext) → wiped by
|
||||||
* `mx.clearStores()` on both logout paths
|
* `mx.clearStores()` on both logout paths
|
||||||
* - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()`
|
* - the opt-in encrypted-search index (IndexedDB) → `deleteSearchCacheDatabase()`
|
||||||
* - the presence status message (`lotus-status-msg-*`) is deliberately
|
* - unsent composer drafts (`draft-msg-*`) and the presence status message
|
||||||
* preserved across a normal logout; clearing it is a separate product decision
|
* (`lotus-status-msg-*`) are deliberately preserved across a normal logout
|
||||||
|
* (N98); clearing them is a separate product decision
|
||||||
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
* - low-sensitivity UI/metadata residue (`io.lotus.mute_timers`, collapsed
|
||||||
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
* nav/space categories, `cinny_oidc_dynamic_clients`) is treated as
|
||||||
* preferences, not swept here
|
* preferences, not swept here
|
||||||
@@ -67,6 +39,5 @@ export const clearPlaintextCaches = (userId?: string): void => {
|
|||||||
clearRecentForwardTargets();
|
clearRecentForwardTargets();
|
||||||
clearRecentGifs();
|
clearRecentGifs();
|
||||||
clearRecentStickers();
|
clearRecentStickers();
|
||||||
clearMsgDrafts();
|
|
||||||
if (userId) clearNavToActivePathStore(userId);
|
if (userId) clearNavToActivePathStore(userId);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { isBindableCallKey } from './callKeybind';
|
|
||||||
|
|
||||||
test('isBindableCallKey rejects navigation-critical codes', () => {
|
|
||||||
[
|
|
||||||
'Escape',
|
|
||||||
'Tab',
|
|
||||||
'Enter',
|
|
||||||
'NumpadEnter',
|
|
||||||
'ArrowUp',
|
|
||||||
'ArrowDown',
|
|
||||||
'ArrowLeft',
|
|
||||||
'ArrowRight',
|
|
||||||
'Home',
|
|
||||||
'End',
|
|
||||||
'PageUp',
|
|
||||||
'PageDown',
|
|
||||||
].forEach((code) => {
|
|
||||||
assert.equal(isBindableCallKey(code), false, `${code} should be unbindable`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isBindableCallKey rejects bare modifier codes', () => {
|
|
||||||
[
|
|
||||||
'ShiftLeft',
|
|
||||||
'ShiftRight',
|
|
||||||
'ControlLeft',
|
|
||||||
'ControlRight',
|
|
||||||
'AltLeft',
|
|
||||||
'AltRight',
|
|
||||||
'MetaLeft',
|
|
||||||
'MetaRight',
|
|
||||||
].forEach((code) => {
|
|
||||||
assert.equal(isBindableCallKey(code), false, `${code} should be unbindable`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isBindableCallKey accepts ordinary keys', () => {
|
|
||||||
['Space', 'KeyM', 'KeyQ', 'Digit1', 'F13'].forEach((code) => {
|
|
||||||
assert.equal(isBindableCallKey(code), true, `${code} should be bindable`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('isBindableCallKey rejects the empty string', () => {
|
|
||||||
assert.equal(isBindableCallKey(''), false);
|
|
||||||
});
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
/**
|
|
||||||
* `KeyboardEvent.code` values the push-to-talk / push-to-deafen rebind must
|
|
||||||
* never accept. Binding one of these turns it into a keyboard trap (the call
|
|
||||||
* hotkey listener swallows the key everywhere outside an editable field for
|
|
||||||
* the rest of the call) or collides with a bare modifier chord.
|
|
||||||
*/
|
|
||||||
const UNBINDABLE_CALL_KEY_CODES = new Set<string>([
|
|
||||||
'Escape',
|
|
||||||
'Tab',
|
|
||||||
'Enter',
|
|
||||||
'NumpadEnter',
|
|
||||||
'ArrowUp',
|
|
||||||
'ArrowDown',
|
|
||||||
'ArrowLeft',
|
|
||||||
'ArrowRight',
|
|
||||||
'Home',
|
|
||||||
'End',
|
|
||||||
'PageUp',
|
|
||||||
'PageDown',
|
|
||||||
'ShiftLeft',
|
|
||||||
'ShiftRight',
|
|
||||||
'ControlLeft',
|
|
||||||
'ControlRight',
|
|
||||||
'AltLeft',
|
|
||||||
'AltRight',
|
|
||||||
'MetaLeft',
|
|
||||||
'MetaRight',
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** Whether `code` is safe to bind as a call hotkey (PTT / push-to-deafen). */
|
|
||||||
export function isBindableCallKey(code: string): boolean {
|
|
||||||
return code.length > 0 && !UNBINDABLE_CALL_KEY_CODES.has(code);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import { scheduleMessage } from './scheduledMessages';
|
|
||||||
|
|
||||||
// MSC4140 delayed events are PUT as a plaintext m.room.message, so scheduling
|
|
||||||
// must be refused outright for encrypted rooms — the composer hides the button,
|
|
||||||
// this guard stops any other caller from regressing it.
|
|
||||||
const makeMx = (encrypted: boolean | undefined) => {
|
|
||||||
const calls: unknown[][] = [];
|
|
||||||
const mx = {
|
|
||||||
getRoom: (_roomId: string) =>
|
|
||||||
encrypted === undefined ? null : { hasEncryptionStateEvent: () => encrypted },
|
|
||||||
http: {
|
|
||||||
authedRequest: (...args: unknown[]) => {
|
|
||||||
calls.push(args);
|
|
||||||
return Promise.resolve({ delay_id: 'delay-1' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { mx: mx as never, calls };
|
|
||||||
};
|
|
||||||
|
|
||||||
test('scheduleMessage throws and sends nothing for an encrypted room', async () => {
|
|
||||||
const { mx, calls } = makeMx(true);
|
|
||||||
await assert.rejects(
|
|
||||||
() => scheduleMessage(mx, '!enc:lotusguild.org', { body: 'secret' }, Date.now() + 60_000),
|
|
||||||
/encrypted rooms/,
|
|
||||||
);
|
|
||||||
assert.equal(calls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scheduleMessage still sends for an unencrypted room', async () => {
|
|
||||||
const { mx, calls } = makeMx(false);
|
|
||||||
const delayId = await scheduleMessage(
|
|
||||||
mx,
|
|
||||||
'!plain:lotusguild.org',
|
|
||||||
{ body: 'hi' },
|
|
||||||
Date.now() + 60_000,
|
|
||||||
);
|
|
||||||
assert.equal(delayId, 'delay-1');
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scheduleMessage sends when the room is unknown to the client', async () => {
|
|
||||||
const { mx, calls } = makeMx(undefined);
|
|
||||||
await scheduleMessage(mx, '!unknown:lotusguild.org', { body: 'hi' }, Date.now() + 60_000);
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
});
|
|
||||||
@@ -12,13 +12,6 @@ export async function scheduleMessage(
|
|||||||
content: IContent,
|
content: IContent,
|
||||||
sendAtMs: number,
|
sendAtMs: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing
|
|
||||||
// the SDK's encryptEventIfNeeded pipeline — the body would land on the server
|
|
||||||
// (and later in the timeline) in the clear. Refuse rather than leak; the
|
|
||||||
// composer also hides the Schedule button in encrypted rooms.
|
|
||||||
if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) {
|
|
||||||
throw new Error('Scheduled messages are not supported in encrypted rooms.');
|
|
||||||
}
|
|
||||||
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
||||||
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
||||||
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
saveRoomIndex,
|
saveRoomIndex,
|
||||||
clearRoom,
|
clearRoom,
|
||||||
clearAll,
|
clearAll,
|
||||||
deleteRow,
|
|
||||||
deleteSearchCacheDatabase,
|
deleteSearchCacheDatabase,
|
||||||
SearchCacheRow,
|
SearchCacheRow,
|
||||||
} from './searchCache';
|
} from './searchCache';
|
||||||
@@ -129,31 +128,6 @@ test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
|
|||||||
await deleteSearchCacheDatabase();
|
await deleteSearchCacheDatabase();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasIdb }, async () => {
|
|
||||||
await clearAll();
|
|
||||||
const rows: SearchCacheRow[] = [
|
|
||||||
{ roomId: '!r1', eventId: '$1', ts: 100, sender: '@a', body: 'hello' },
|
|
||||||
{ roomId: '!r1', eventId: '$2', ts: 200, sender: '@b', body: 'world' },
|
|
||||||
{ roomId: '!r2', eventId: '$1', ts: 300, sender: '@a', body: 'other room, same id' },
|
|
||||||
];
|
|
||||||
await putRows(rows);
|
|
||||||
|
|
||||||
await deleteRow('!r1', '$1');
|
|
||||||
|
|
||||||
const r1 = await queryRoom('!r1');
|
|
||||||
assert.deepEqual(
|
|
||||||
r1.map((x) => x.eventId),
|
|
||||||
['$2'],
|
|
||||||
);
|
|
||||||
// A same-eventId row in a different room is untouched (composite key).
|
|
||||||
assert.equal((await queryRoom('!r2')).length, 1);
|
|
||||||
|
|
||||||
// Deleting a row that doesn't exist is a silent no-op.
|
|
||||||
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
|
|
||||||
|
|
||||||
await deleteSearchCacheDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
|
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
|
||||||
// In this environment IndexedDB is absent; every call must degrade to a
|
// In this environment IndexedDB is absent; every call must degrade to a
|
||||||
// cache-miss rather than throwing.
|
// cache-miss rather than throwing.
|
||||||
@@ -164,7 +138,6 @@ test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb },
|
|||||||
assert.equal(await getCoverage('!r'), null);
|
assert.equal(await getCoverage('!r'), null);
|
||||||
await assert.doesNotReject(saveRoomIndex('!r', []));
|
await assert.doesNotReject(saveRoomIndex('!r', []));
|
||||||
await assert.doesNotReject(clearRoom('!r'));
|
await assert.doesNotReject(clearRoom('!r'));
|
||||||
await assert.doesNotReject(deleteRow('!r', '$1'));
|
|
||||||
await assert.doesNotReject(clearAll());
|
await assert.doesNotReject(clearAll());
|
||||||
await assert.doesNotReject(deleteSearchCacheDatabase());
|
await assert.doesNotReject(deleteSearchCacheDatabase());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -288,23 +288,6 @@ export const mergeSearchResults = <
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a single cached row, e.g. because its event was redacted. Gitea #14:
|
|
||||||
* without this, a redaction only ever removed the in-memory hit — the
|
|
||||||
* decrypted plaintext stayed in IndexedDB forever.
|
|
||||||
*/
|
|
||||||
export const deleteRow = async (roomId: string, eventId: string): Promise<void> => {
|
|
||||||
const db = await openDb();
|
|
||||||
if (!db) return;
|
|
||||||
try {
|
|
||||||
const tx = db.transaction(MESSAGES_STORE, 'readwrite');
|
|
||||||
tx.objectStore(MESSAGES_STORE).delete([roomId, eventId]);
|
|
||||||
await awaitTx(tx);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const clearRoom = async (roomId: string): Promise<void> => {
|
export const clearRoom = async (roomId: string): Promise<void> => {
|
||||||
const db = await openDb();
|
const db = await openDb();
|
||||||
if (!db) return;
|
if (!db) return;
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
import { useEffect } from 'react';
|
|
||||||
import { useAtomValue } from 'jotai';
|
|
||||||
import { MatrixEvent, Room, RoomEvent } from 'matrix-js-sdk';
|
|
||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
|
||||||
import { searchCacheEnabledAtom } from '../state/searchCacheEnabled';
|
|
||||||
import { clearRoom, deleteRow } from './searchCache';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gitea #14 — the persistent search cache (`searchCache.ts`) had no
|
|
||||||
* invalidation path other than logout or the manual "Clear cached index"
|
|
||||||
* button, so redacted messages and rooms the user left kept their decrypted
|
|
||||||
* plaintext searchable on disk indefinitely.
|
|
||||||
*
|
|
||||||
* While the cache is enabled, listen client-wide for redactions (delete the
|
|
||||||
* redacted row) and for leaving/being banned from a room (wipe the room's
|
|
||||||
* cached rows via the existing `clearRoom`).
|
|
||||||
*/
|
|
||||||
export const useSearchCacheInvalidation = (): void => {
|
|
||||||
const mx = useMatrixClient();
|
|
||||||
const cacheEnabled = useAtomValue(searchCacheEnabledAtom);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!cacheEnabled) return undefined;
|
|
||||||
|
|
||||||
const onRedaction = (event: MatrixEvent, room: Room) => {
|
|
||||||
const redactedEventId = event.getAssociatedId();
|
|
||||||
if (redactedEventId) deleteRow(room.roomId, redactedEventId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onMyMembership = (room: Room, membership: string) => {
|
|
||||||
if (membership === 'leave' || membership === 'ban') {
|
|
||||||
clearRoom(room.roomId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
mx.on(RoomEvent.Redaction, onRedaction);
|
|
||||||
mx.on(RoomEvent.MyMembership, onMyMembership);
|
|
||||||
return () => {
|
|
||||||
mx.off(RoomEvent.Redaction, onRedaction);
|
|
||||||
mx.off(RoomEvent.MyMembership, onMyMembership);
|
|
||||||
};
|
|
||||||
}, [mx, cacheEnabled]);
|
|
||||||
};
|
|
||||||
@@ -1,23 +1,6 @@
|
|||||||
import { AccessTokens, OidcTokenRefresher } from 'matrix-js-sdk';
|
import { OidcTokenRefresher } from 'matrix-js-sdk';
|
||||||
import type { IdTokenClaims } from 'oidc-client-ts';
|
import type { IdTokenClaims } from 'oidc-client-ts';
|
||||||
import { getFallbackSession, OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
import { OidcSessionMeta, setFallbackSession } from '../app/state/sessions';
|
||||||
|
|
||||||
// Web Lock name serialising OIDC refreshes across tabs (Gitea #16). Every tab
|
|
||||||
// runs its own refresher against the SAME stored refresh token; with rotating
|
|
||||||
// refresh tokens the second tab to hit the issuer gets `invalid_grant` and is
|
|
||||||
// signed out. Holding the lock while refreshing (and re-reading storage once
|
|
||||||
// inside it) makes the loser adopt the winner's tokens instead.
|
|
||||||
const REFRESH_LOCK_NAME = 'lotus-oidc-refresh';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run `fn` under the cross-tab refresh lock. Falls back to running it directly
|
|
||||||
* when the Web Locks API is unavailable (older browsers, non-secure contexts).
|
|
||||||
*/
|
|
||||||
export const withRefreshLock = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
||||||
const locks = typeof navigator !== 'undefined' ? navigator.locks : undefined;
|
|
||||||
if (!locks) return fn();
|
|
||||||
return locks.request(REFRESH_LOCK_NAME, fn);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
|
* OidcTokenRefresher that persists rotated tokens back to the fallback session,
|
||||||
@@ -47,37 +30,6 @@ export class LotusOidcTokenRefresher extends OidcTokenRefresher {
|
|||||||
this.oidcRef = oidc;
|
this.oidcRef = oidc;
|
||||||
}
|
}
|
||||||
|
|
||||||
// #16 — before touching the issuer, check whether another tab already rotated
|
|
||||||
// the tokens. `refreshToken` is exactly what the SDK currently holds, so a
|
|
||||||
// DIFFERENT stored refresh token (same user + device) means a sibling tab won
|
|
||||||
// the race: adopt its tokens instead of burning a possibly-consumed refresh
|
|
||||||
// token. Comparing refresh tokens (not access tokens) can never adopt the very
|
|
||||||
// token that just 401'd, so this cannot loop. The whole thing runs under a
|
|
||||||
// cross-tab Web Lock so concurrent refreshes serialise and the waiter sees
|
|
||||||
// the winner's write.
|
|
||||||
public doRefreshAccessToken(refreshToken: string): Promise<AccessTokens> {
|
|
||||||
return withRefreshLock(async () => {
|
|
||||||
const stored = getFallbackSession();
|
|
||||||
if (
|
|
||||||
stored &&
|
|
||||||
stored.userId === this.userIdRef &&
|
|
||||||
stored.deviceId === this.deviceIdRef &&
|
|
||||||
stored.refreshToken &&
|
|
||||||
stored.refreshToken !== refreshToken
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
accessToken: stored.accessToken,
|
|
||||||
refreshToken: stored.refreshToken,
|
|
||||||
expiry:
|
|
||||||
typeof stored.expiresInMs === 'number'
|
|
||||||
? new Date(Date.now() + stored.expiresInMs)
|
|
||||||
: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return super.doRefreshAccessToken(refreshToken);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
|
// F5 — persist the new expiry so the stored `expiresAt` stays fresh across
|
||||||
// reloads instead of going stale. The SDK invokes persistTokens synchronously
|
// reloads instead of going stale. The SDK invokes persistTokens synchronously
|
||||||
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
|
// inside the refresh and passes the freshly-refreshed `expiry` (a Date) on the
|
||||||
|
|||||||
+1
-1
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
appType: 'spa',
|
appType: 'spa',
|
||||||
publicDir: false,
|
publicDir: './public/res',
|
||||||
base: buildConfig.base,
|
base: buildConfig.base,
|
||||||
server: {
|
server: {
|
||||||
port: 8080,
|
port: 8080,
|
||||||
|
|||||||
Reference in New Issue
Block a user