Compare commits

..
Author SHA1 Message Date
nathan 0aef410f60 fixed retarded prettier 'error'
CI / Build & Quality Checks (pull_request) Successful in 10m45s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 19:01:05 -04:00
nathan b2376513fd fixed retarded linter problem
CI / Build & Quality Checks (pull_request) Failing after 6m2s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 18:42:37 -04:00
nathan.vititoe 2bbd390a3b image path changes, for dev setup, needs testing on 'prod'
CI / Build & Quality Checks (pull_request) Failing after 6m0s
CI / Trigger Desktop Build (pull_request) Skipped
2026-08-02 16:35:11 -04:00
53 changed files with 353 additions and 1259 deletions
+26 -30
View File
@@ -30,13 +30,8 @@ jobs:
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version-file: '.node-version' node-version-file: '.node-version'
cache: npm
# No npm / node_modules cache: the act_runner's internal cache server is
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
# node_modules step) once the runner's cache server is reachable from jobs.
- name: Install dependencies - name: Install dependencies
# Harden against transient registry network failures (ECONNRESET etc.): # Harden against transient registry network failures (ECONNRESET etc.):
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to # raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
@@ -57,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
-27
View File
@@ -1,27 +0,0 @@
---
name: Bug Report
about: Report something that isn't working in Lotus Chat
title: ''
labels: bug
---
**Describe the bug**
A clear and concise description of what went wrong.
**Steps to reproduce**
1. Go to '...'
2. Click on '...'
3. See error
**Expected behavior**
What you expected to happen instead.
**Client info**
- Lotus Chat version (Settings → Help & About):
- Platform: Web / Desktop (Windows / macOS / Linux)
- Browser + version (if web):
**Screenshots / logs**
If applicable, add screenshots or the browser devtools console output.
+5 -1
View File
@@ -1 +1,5 @@
blank_issues_enabled: true blank_issues_enabled: false
contact_links:
- name: Features, Bug Reports, Questions
url: https://github.com/cinnyapp/cinny/discussions/new/choose
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
-15
View File
@@ -1,15 +0,0 @@
---
name: Feature Request
about: Suggest an idea or improvement for Lotus Chat
title: ''
labels: enhancement
---
**What would you like?**
A clear and concise description of the feature or change.
**Why / use case**
What problem does it solve, or what does it make better?
**Alternatives considered**
Any workarounds or other approaches you've thought about.
+9
View File
@@ -0,0 +1,9 @@
---
name: Pre-Discussed and Approved Topics
about: |-
Only for topics already discussed and approved in the GitHub Discussions section.
---
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
+3 -1
View File
@@ -1 +1,3 @@
npx lint-staged # These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
+14 -14
View File
@@ -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
View File
@@ -372,11 +372,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
### Element Call fork — operational reference ### Element Call fork — operational reference
Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** since the 2026-09 sync — was v0.20.1); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`). Fork = `LotusGuild/element-call` (branch `lotus`, from upstream tag `v0.20.1`); cinny consumes the npm package `@lotusguild/element-call-embedded` (built bundle copied into `public/element-call/`).
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`. **Publish a new version (manual; needs the Gitea npm token):** bump `embedded/web/package.json` (current unpublished `0.20.1-lotus.2`) → `pnpm run build:embedded` (Node 24, pnpm 10.33) → `cd embedded/web && npm version <tag> --no-git-tag-version && npm publish` (Gitea registry) → in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build.
**Publish a new version (CI on tag push; needs the `GITEA_NPM_TOKEN` secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.1`, tag `v0.25.0-lotus.1` created locally, unpublished until pushed), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.20.1-lotus.1`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
**`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):
+3 -29
View File
@@ -22,7 +22,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
- Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices) - Slack-style thread notifications: by default you're only pinged for threads you're in or where you're @mentioned; set any thread to All / Mentions-only / Mute from the panel's bell menu (muted threads stop bumping badges; syncs across devices)
- See who has read each message, and track delivery status (sending / sent / failed) - See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar - Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms) - Schedule messages to send at a specific time
- Click "edited" on any message to see the full edit history - Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads - Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand - Long messages collapse automatically — click "Read more" to expand
@@ -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.20.1-lotus.1`; `0.25.0-lotus.1` is built and tagged on the fork, pending publish — source at (`@lotusguild/element-call-embedded` `0.20.1-lotus.1`, source at
`LotusGuild/element-call`), published to our private Gitea npm registry and served `LotusGuild/element-call`), published to our private Gitea npm registry and served
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
behavior is editable source instead of fragile DOM/widget hacks. behavior is editable source instead of fragile DOM/widget hacks.
+3 -3
View File
@@ -4,9 +4,9 @@
"allowCustomHomeservers": true, "allowCustomHomeservers": true,
"featuredCommunities": { "featuredCommunities": {
"openAsDefault": false, "openAsDefault": false,
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"], "spaces": [],
"rooms": ["#jellyfin:matrix.org"], "rooms": [],
"servers": ["matrixrooms.info"] "servers": []
}, },
"hashRouter": { "hashRouter": {
"enabled": false, "enabled": false,
+2 -9
View File
@@ -58,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';
@@ -411,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.
@@ -569,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;
@@ -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)' },
}); });
/** /**
+69 -103
View File
@@ -46,10 +46,6 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)'; 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
@@ -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;
+1 -25
View File
@@ -171,10 +171,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== pttKey || e.repeat) return; if (e.code !== pttKey || e.repeat) return;
// [Gitea #23] Ignore the PTT key with Ctrl/Alt/Meta held so it doesn't
// hijack OS/app chords (e.g. Cmd+Space) that happen to share the code.
// Shift is allowed through — Shift+Space is a harmless combo for PTT.
if (e.ctrlKey || e.altKey || e.metaKey) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// BUG-7: use ownerDocument.body so isEditable works inside the EC iframe // BUG-7: use ownerDocument.body so isEditable works inside the EC iframe
const isEditable = (el: HTMLElement): boolean => { const isEditable = (el: HTMLElement): boolean => {
@@ -189,23 +185,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
return false; return false;
}; };
if (isEditable(target)) return; if (isEditable(target)) return;
// [Gitea #23] Don't swallow Space on a focused button/link/etc — PTT still e.preventDefault();
// engages the mic, but the key's default action (activating the control)
// is left alone so keyboard users can still Tab+Space the call buttons.
const isInteractive = (el: HTMLElement): boolean => {
const tag = el.tagName;
if (tag === 'BUTTON' || tag === 'A' || tag === 'SELECT') return true;
let node: HTMLElement | null = el;
while (node && node !== el.ownerDocument.body) {
const role = node.getAttribute('role');
if (role === 'button' || role === 'link' || role === 'menuitem' || role === 'tab') {
return true;
}
node = node.parentElement;
}
return false;
};
if (!isInteractive(target)) e.preventDefault();
// C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState) // C-M5: mark PTT active BEFORE unmuting so the mic echo (onMediaState)
// doesn't treat this transient unmute as a user-initiated undeafen. // doesn't treat this transient unmute as a user-initiated undeafen.
callEmbed.control.pttActive = true; callEmbed.control.pttActive = true;
@@ -276,10 +256,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const onKeyDown = (e: KeyboardEvent) => { const onKeyDown = (e: KeyboardEvent) => {
if (e.code !== deafenKey) return; if (e.code !== deafenKey) return;
if (e.repeat) return; if (e.repeat) return;
// [Gitea #23] Ignore the deafen key with any modifier held — with the
// default 'KeyM', Ctrl+M / Alt+M / Cmd+M are common OS/app chords that
// shouldn't also toggle deafen (and previously got preventDefault()ed).
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
if (isEditable(e.target as HTMLElement)) return; if (isEditable(e.target as HTMLElement)) return;
e.preventDefault(); e.preventDefault();
callEmbed.control.toggleSound(); callEmbed.control.toggleSound();
+1 -2
View File
@@ -74,8 +74,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="400" size="400"
className={MobileTouchTarget} className={MobileTouchTarget}
onClick={() => onToggle()} onClick={() => onToggle()}
aria-label={enabled ? 'Deafen' : 'Undeafen'} aria-label={enabled ? 'Undeafen' : 'Deafen'}
aria-pressed={enabled}
outlined outlined
> >
<Icon <Icon
@@ -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,11 +1,6 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { import { filterGroupsByMsgType, filterGroupsByPinned, ResultGroup } from './useMessageSearch';
filterGroupsByDateRange,
filterGroupsByMsgType,
filterGroupsByPinned,
ResultGroup,
} from './useMessageSearch';
// Minimal ResultGroup/ResultItem fixtures — only the fields the filters read // Minimal ResultGroup/ResultItem fixtures — only the fields the filters read
// (event.content.msgtype, event.event_id, group.roomId). // (event.content.msgtype, event.event_id, group.roomId).
@@ -14,11 +9,6 @@ const item = (msgtype: string | undefined, eventId: string) => ({
event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } }, event: { event_id: eventId, content: msgtype === undefined ? {} : { msgtype } },
context: {}, context: {},
}); });
const tsItem = (eventId: string, ts: number) => ({
rank: 1,
event: { event_id: eventId, origin_server_ts: ts, content: {} },
context: {},
});
const mkGroups = ( const mkGroups = (
...groups: { roomId: string; items: ReturnType<typeof item>[] }[] ...groups: { roomId: string; items: ReturnType<typeof item>[] }[]
): ResultGroup[] => groups as unknown as ResultGroup[]; ): ResultGroup[] => groups as unknown as ResultGroup[];
@@ -58,33 +48,6 @@ test('filterGroupsByMsgType: ignores items with a non-string msgtype', () => {
assert.equal(out[0].items[0].event.event_id, '$2'); assert.equal(out[0].items[0].event.event_id, '$2');
}); });
test('filterGroupsByDateRange: no bounds returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [tsItem('$1', 100)] });
assert.equal(filterGroupsByDateRange(groups, undefined, undefined), groups);
});
test('filterGroupsByDateRange: keeps only items within an inclusive range', () => {
const groups = mkGroups({
roomId: '!r1',
items: [tsItem('$1', 50), tsItem('$2', 100), tsItem('$3', 150), tsItem('$4', 200)],
});
const out = filterGroupsByDateRange(groups, 100, 150);
assert.deepEqual(
out[0].items.map((i) => i.event.event_id),
['$2', '$3'],
);
});
test('filterGroupsByDateRange: drops groups left empty and supports one-sided bounds', () => {
const groups = mkGroups(
{ roomId: '!r1', items: [tsItem('$1', 50)] },
{ roomId: '!r2', items: [tsItem('$2', 500)] },
);
const out = filterGroupsByDateRange(groups, 100, undefined);
assert.equal(out.length, 1);
assert.equal(out[0].roomId, '!r2');
});
test('filterGroupsByPinned: disabled returns groups unchanged', () => { test('filterGroupsByPinned: disabled returns groups unchanged', () => {
const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] }); const groups = mkGroups({ roomId: '!r1', items: [item('m.text', '$1')] });
assert.equal( assert.equal(
@@ -71,31 +71,6 @@ export const filterGroupsByPinned = (
.filter((group) => group.items.length > 0); .filter((group) => group.items.length > 0);
}; };
/** Inclusive-range predicate, mirrored from `inRange` in useLocalMessageSearch.ts. */
export const inTsRange = (ts: number, fromTs?: number, toTs?: number): boolean =>
(fromTs === undefined || ts >= fromTs) && (toTs === undefined || ts <= toTs);
/**
* Filter result groups to items whose `origin_server_ts` falls within
* [fromTs, toTs] (inclusive, either bound optional). The Matrix search API
* has no timestamp filter fields, so server results must be post-filtered
* here — the same predicate the local/encrypted search already applies.
* Now-empty groups are dropped.
*/
export const filterGroupsByDateRange = (
groups: ResultGroup[],
fromTs?: number,
toTs?: number,
): ResultGroup[] => {
if (fromTs === undefined && toTs === undefined) return groups;
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => inTsRange(item.event.origin_server_ts, fromTs, toTs)),
}))
.filter((group) => group.items.length > 0);
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => { const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = []; const groups: ResultGroup[] = [];
@@ -144,9 +119,7 @@ export type MessageSearchParams = {
}; };
export const useMessageSearch = (params: MessageSearchParams) => { export const useMessageSearch = (params: MessageSearchParams) => {
const mx = useMatrixClient(); const mx = useMatrixClient();
// fromTs/toTs are intentionally not sent to the server (see comment below) — const { term, order, rooms, senders, fromTs, toTs, containsUrl } = params;
// callers post-filter results with filterGroupsByDateRange instead.
const { term, order, rooms, senders, containsUrl } = params;
const searchMessages = useCallback( const searchMessages = useCallback(
async (nextBatch?: string) => { async (nextBatch?: string) => {
@@ -169,10 +142,9 @@ export const useMessageSearch = (params: MessageSearchParams) => {
limit, limit,
rooms, rooms,
senders, senders,
// `RoomEventFilter` has no timestamp bounds — from_ts/to_ts are not // from_ts / to_ts and contains_url are valid Matrix spec fields not yet in SDK types
// Matrix filter fields and the homeserver silently drops them, so the ...(fromTs !== undefined && { from_ts: fromTs }),
// date range is instead enforced client-side (see filterGroupsByDateRange). ...(toTs !== undefined && { to_ts: toTs }),
// contains_url is a valid spec field not yet in SDK types.
...(containsUrl !== undefined && { contains_url: containsUrl }), ...(containsUrl !== undefined && { contains_url: containsUrl }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any, } as any,
@@ -189,7 +161,7 @@ export const useMessageSearch = (params: MessageSearchParams) => {
}); });
return parseSearchResult(r); return parseSearchResult(r);
}, },
[mx, term, order, rooms, senders, containsUrl], [mx, term, order, rooms, senders, fromTs, toTs, containsUrl],
); );
return searchMessages; return searchMessages;
+60 -10
View File
@@ -6,7 +6,7 @@ import React, {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { Room } from 'matrix-js-sdk'; import { MatrixClient, Room } from 'matrix-js-sdk';
import { import {
Avatar, Avatar,
Box, Box,
@@ -42,6 +42,7 @@ import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../componen
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
import { setAccountData } from '../../utils/accountData';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useRoomUnread } from '../../state/hooks/unread'; import { useRoomUnread } from '../../state/hooks/unread';
@@ -65,16 +66,14 @@ 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';
@@ -137,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]);
@@ -268,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;
@@ -344,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,
@@ -1,20 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
});
test('does not reset when the user switched to All messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false);
});
test('does not reset when the user switched to Special messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false);
});
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
-88
View File
@@ -1,88 +0,0 @@
import { IPushRule, IPushRules, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData } from '../../utils/room';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import {
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
// Unset if the room's notification mode is still Mute at expiry time. If the user
// manually changed it (e.g. to All messages) while the timer was pending, leave
// their choice alone — just let the stale timer entry get dropped.
export function shouldResetMuteOnUnmute(currentMode: RoomNotificationMode): boolean {
return currentMode === RoomNotificationMode.Mute;
}
// Reads the room's live notification mode straight from account data push rules,
// mirroring useRoomsNotificationPreferences' per-room derivation, without needing
// the React hook (this runs from plain timers/effects, not components).
export function getLiveRoomNotificationMode(
mx: MatrixClient,
roomId: string,
): RoomNotificationMode {
const pushRules = getAccountData(mx, AccountDataEvent.PushRules)?.getContent<IPushRules>();
const global = pushRules?.global;
const overrideRule = global?.override?.find((rule: IPushRule) => rule.rule_id === roomId);
if (overrideRule && getNotificationMode(overrideRule.actions) === NotificationMode.OFF) {
return RoomNotificationMode.Mute;
}
const roomRule = global?.room?.find((rule: IPushRule) => rule.rule_id === roomId);
if (roomRule) {
return getNotificationMode(roomRule.actions) === NotificationMode.OFF
? RoomNotificationMode.SpecialMessages
: RoomNotificationMode.AllMessages;
}
return RoomNotificationMode.Unset;
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
// Only resets the mode if it is still Mute — otherwise a manual change made
// during the mute window (e.g. switching to "All messages") would silently get
// reverted when the stale timer fires.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const currentMode = getLiveRoomNotificationMode(mx, roomId);
if (shouldResetMuteOnUnmute(currentMode)) {
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
@@ -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]);
+2 -6
View File
@@ -25,12 +25,10 @@ import { useModalStyle } from '../../hooks/useModalStyle';
interface PollCreatorProps { interface PollCreatorProps {
roomId: string; roomId: string;
room: Room; room: Room;
/** Set when the composer is inside a thread so the poll lands in that thread. */
threadRootId?: string;
onClose: () => void; onClose: () => void;
} }
export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps) { export function PollCreator({ roomId, onClose }: PollCreatorProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const modalStyle = useModalStyle(440); const modalStyle = useModalStyle(440);
const [question, setQuestion] = useState(''); const [question, setQuestion] = useState('');
@@ -87,9 +85,7 @@ export function PollCreator({ roomId, threadRootId, onClose }: PollCreatorProps)
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join( const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
'\n', '\n',
); );
// Pass the thread id explicitly (like the sticker path in RoomInput); the await mx.sendEvent(roomId, 'm.poll.start' as any, {
// legacy 3-arg form always resolves to the main timeline.
await mx.sendEvent(roomId, threadRootId ?? null, 'm.poll.start' as any, {
'm.poll': { 'm.poll': {
question: { 'm.text': trimmedQuestion }, question: { 'm.text': trimmedQuestion },
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })), answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
+35 -96
View File
@@ -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}
@@ -1,73 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { buildCompressedUploadItem } from './msgContent';
import { TUploadItem } from '../../state/room/roomInputDrafts';
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
// (if any) the resulting m.image event carries. Getting this wrong either leaks
// a plaintext image into an E2EE room or produces an undecryptable attachment.
const enc = (tag: string): EncryptedAttachmentInfo =>
({
v: 'v2',
key: { alg: 'A256CTR', k: tag },
iv: `iv-${tag}`,
hashes: { sha256: `sha-${tag}` },
}) as unknown as EncryptedAttachmentInfo;
const fakeFile = (name: string, size: number): File =>
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
({
file: fakeFile('photo.png', 900),
originalFile: fakeFile('photo.png', 900),
encInfo,
metadata: { markedAsSpoiler: false, compressImage: true },
}) as unknown as TUploadItem;
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
const compressed = fakeFile('photo.jpg', 300);
const item = buildCompressedUploadItem(makeItem(), compressed);
assert.equal(item.file, compressed);
assert.equal(item.originalFile, compressed);
assert.equal(item.encInfo, undefined);
});
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
const compressed = fakeFile('photo.jpg', 300);
const encryptedBlob = fakeFile('photo.jpg', 320);
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
file: encryptedBlob,
encInfo: enc('compressed'),
});
// The ciphertext is what gets uploaded; the plaintext stays available for
// dimensions/blurhash only.
assert.equal(item.file, encryptedBlob);
assert.equal(item.originalFile, compressed);
assert.deepEqual(item.encInfo, enc('compressed'));
assert.notDeepEqual(item.encInfo, enc('original'));
});
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
// stale encInfo (that is the bug this helper exists to prevent).
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
assert.equal(item.encInfo, undefined);
});
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
const base = makeItem();
base.metadata.caption = 'a caption';
base.metadata.markedAsSpoiler = true;
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
assert.equal(item.metadata.caption, 'a caption');
assert.equal(item.metadata.markedAsSpoiler, true);
});
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
// msgContent.test.ts builders, which share the same shape.
-23
View File
@@ -1,6 +1,5 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk'; import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js'; import to from 'await-to-js';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { import {
IThumbnailContent, IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME, MATRIX_BLUR_HASH_PROPERTY_NAME,
@@ -44,28 +43,6 @@ const generateThumbnailContent = async (
return thumbnailContent; return thumbnailContent;
}; };
/**
* Build the synthetic upload item for a *re-encoded* (compressed) image.
*
* The compressed bytes are a brand new payload, so the item must never inherit
* the original's `encInfo` — that key/iv/sha256 describes the pre-compression
* ciphertext and would make receivers fail to decrypt. In an encrypted room the
* caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in
* an unencrypted room both are omitted and the item carries no `encInfo` at all.
*/
export const buildCompressedUploadItem = (
item: TUploadItem,
compressedFile: File,
encrypted?: { file: File; encInfo: EncryptedAttachmentInfo },
): TUploadItem => ({
...item,
// `file` is what gets uploaded/described, `originalFile` is the plaintext used
// for dimensions + blurhash.
file: encrypted?.file ?? compressedFile,
originalFile: compressedFile,
encInfo: encrypted?.encInfo,
});
export const getImageMsgContent = async ( export const getImageMsgContent = async (
mx: MatrixClient, mx: MatrixClient,
item: TUploadItem, item: TUploadItem,
+2 -4
View File
@@ -116,8 +116,6 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
const editor = useEditor(); const editor = useEditor();
const thread = useThreadInstance(room, threadId); const thread = useThreadInstance(room, threadId);
const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts'); const [privateReadReceipts] = useSetting(settingsAtom, 'privateReadReceipts');
// "Hide Typing & Read Receipts" must also make thread receipts private (matches markAsRead).
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>; const fileDropContainerRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
useKeyDown( useKeyDown(
@@ -159,7 +157,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
} }
if (!latestId || latestId === lastReadEventIdRef.current) return; if (!latestId || latestId === lastReadEventIdRef.current) return;
lastReadEventIdRef.current = latestId; lastReadEventIdRef.current = latestId;
markThreadAsRead(mx, thread, hideActivity || privateReadReceipts).catch(() => { markThreadAsRead(mx, thread, privateReadReceipts).catch(() => {
// Allow a retry on the next event if the receipt POST failed. // Allow a retry on the next event if the receipt POST failed.
if (lastReadEventIdRef.current === latestId) { if (lastReadEventIdRef.current === latestId) {
lastReadEventIdRef.current = undefined; lastReadEventIdRef.current = undefined;
@@ -173,7 +171,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
thread.off(ThreadEvent.NewReply, markRead); thread.off(ThreadEvent.NewReply, markRead);
thread.off(RoomEvent.Timeline, markRead); thread.off(RoomEvent.Timeline, markRead);
}; };
}, [mx, thread, privateReadReceipts, hideActivity]); }, [mx, thread, privateReadReceipts]);
return ( return (
<Box <Box
@@ -545,19 +545,9 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
[room, thread, setReplyDraft, editor], [room, thread, setReplyDraft, editor],
); );
// Non-thread relations (reactions, edits) that target the thread root live only in
// the room's main timeline set (matrix-js-sdk Room.eventShouldLiveIn), so lookups
// for the root must use the room set instead of the thread set.
const getRelationTimelineSet = useCallback(
(eventId: string) =>
eventId === thread.id ? room.getUnfilteredTimelineSet() : thread.getUnfilteredTimelineSet(),
[room, thread],
);
const handleReactionToggle = useCallback( const handleReactionToggle = useCallback(
(targetEventId: string, key: string, shortcode?: string) => { (targetEventId: string, key: string, shortcode?: string) => {
const isRoot = targetEventId === thread.id; const timelineSet = thread.getUnfilteredTimelineSet();
const timelineSet = getRelationTimelineSet(targetEventId);
const relations = getEventReactions(timelineSet, targetEventId); const relations = getEventReactions(timelineSet, targetEventId);
const allReactions = relations?.getSortedAnnotationsByKey() ?? []; const allReactions = relations?.getSortedAnnotationsByKey() ?? [];
const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? []; const [, reactionsSet] = allReactions.find(([k]) => k === key) ?? [];
@@ -573,14 +563,13 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
(reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined); (reactions.find(eventWithShortcode)?.getContent().shortcode as string | undefined);
mx.sendEvent( mx.sendEvent(
room.roomId, room.roomId,
// A reaction on the root is a main-timeline event, not a thread reply. thread.id,
isRoot ? null : thread.id,
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
MessageEvent.Reaction as any, MessageEvent.Reaction as any,
getReactionContent(targetEventId, key, rShortcode), getReactionContent(targetEventId, key, rShortcode),
); );
}, },
[mx, room, thread, getRelationTimelineSet], [mx, room, thread],
); );
const handleEdit = useCallback( const handleEdit = useCallback(
@@ -726,7 +715,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
): ReactNode => { ): ReactNode => {
const mEventId = mEvent.getId(); const mEventId = mEvent.getId();
if (!mEventId) return null; if (!mEventId) return null;
const timelineSet = getRelationTimelineSet(mEventId); const timelineSet = thread.getUnfilteredTimelineSet();
const reactionRelations = getEventReactions(timelineSet, mEventId); const reactionRelations = getEventReactions(timelineSet, mEventId);
const reactions = reactionRelations?.getSortedAnnotationsByKey(); const reactions = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = !!reactions && reactions.length > 0; const hasReactions = !!reactions && reactions.length > 0;
@@ -794,6 +783,7 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
); );
}, },
[ [
thread,
room, room,
messageSpacing, messageSpacing,
messageLayout, messageLayout,
@@ -820,7 +810,6 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
lotusTerminal, lotusTerminal,
mx, mx,
renderMessageContent, renderMessageContent,
getRelationTimelineSet,
], ],
); );
+2 -2
View File
@@ -4,12 +4,12 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
import pkg from '../../../../../package.json'; import pkg from '../../../../../package.json';
import { clearCacheAndReload } from '../../../../client/initMatrix'; import { clearCacheAndReload } from '../../../../client/initMatrix';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png'); const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
type MSC1929Contact = { type MSC1929Contact = {
matrix_id?: string; matrix_id?: string;
+8 -24
View File
@@ -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"
@@ -18,7 +18,6 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { import {
getNotificationModeActions, getNotificationModeActions,
getNotificationModeOptionsFromActions,
NotificationMode, NotificationMode,
useNotificationModeActions, useNotificationModeActions,
} from '../../../hooks/useNotificationMode'; } from '../../../hooks/useNotificationMode';
@@ -132,13 +131,7 @@ type RuleModeSwitcherProps = {
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) { function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
// Preserve any `highlight`/custom sound tweak already on the rule — otherwise const getModeActions = useNotificationModeActions();
// switching mode here rebuilds actions from scratch and silently drops them.
const options = useMemo(
() => getNotificationModeOptionsFromActions(pushRule.actions),
[pushRule.actions],
);
const getModeActions = useNotificationModeActions(options);
const handleChange = useCallback( const handleChange = useCallback(
async (mode: NotificationMode) => { async (mode: NotificationMode) => {
+1 -5
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { MatrixError, Method } from 'matrix-js-sdk'; import { MatrixError, Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { isValidDecorationSlug } from '../features/lotus/avatarDecorations';
const PROFILE_FIELD = 'io.lotus.avatar_decoration'; const PROFILE_FIELD = 'io.lotus.avatar_decoration';
@@ -52,10 +51,7 @@ function fetchDecoration(
// all fields (incl. custom MSC4133 ones); read the decoration out of it. // all fields (incl. custom MSC4133 ones); read the decoration out of it.
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`) return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}`)
.then((res) => { .then((res) => {
const rawVal = (res[PROFILE_FIELD] as string | undefined) ?? null; const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
// The remote profile field is free-form and attacker-controlled; only
// accept it when it names a real catalog decoration (see decorationUrl).
const val = rawVal && isValidDecorationSlug(rawVal) ? rawVal : null;
cache.set(userId, val); cache.set(userId, val);
return val; return val;
}) })
+3 -23
View File
@@ -1,8 +1,8 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useStore } from 'jotai'; import { useStore } from 'jotai';
import { Descendant } from 'slate';
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts'; import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft'; import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
import { useMatrixClient } from './useMatrixClient';
/** /**
* On startup, pre-fill the per-room message-draft atoms from their localStorage * On startup, pre-fill the per-room message-draft atoms from their localStorage
@@ -13,19 +13,11 @@ import { useMatrixClient } from './useMatrixClient';
* (identical content), so composer restore is unaffected. * (identical content), so composer restore is unaffected.
* *
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level. * Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
*
* [Gitea #41] Drafts are stored as `{ userId, nodes }` (RoomInput's persist
* path) so a draft written by a different account never gets hydrated into the
* currently logged-in user's session. A legacy draft (stored as a bare array,
* pre-dating user-scoping) has no userId to check, so it's treated as foreign
* and dropped rather than trusted.
*/ */
export function useHydrateMsgDrafts(): void { export function useHydrateMsgDrafts(): void {
const store = useStore(); const store = useStore();
const mx = useMatrixClient();
useEffect(() => { useEffect(() => {
const userId = mx.getUserId();
let keys: string[]; let keys: string[];
try { try {
keys = Object.keys(localStorage); keys = Object.keys(localStorage);
@@ -42,19 +34,7 @@ export function useHydrateMsgDrafts(): void {
try { try {
const stored = localStorage.getItem(key); const stored = localStorage.getItem(key);
if (!stored) return; if (!stored) return;
const parsed = JSON.parse(stored); const nodes = JSON.parse(stored) as Descendant[];
const foreign =
!parsed ||
typeof parsed !== 'object' ||
Array.isArray(parsed) ||
parsed.userId !== userId;
if (foreign) {
// Another account's (or a pre-scoping legacy) draft — never hydrate it,
// and drop it so it can't resurface for the next login either.
localStorage.removeItem(key);
return;
}
const nodes = parsed.nodes;
if (Array.isArray(nodes) && hasMsgDraft(nodes)) { if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes); store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
} }
@@ -62,5 +42,5 @@ export function useHydrateMsgDrafts(): void {
// Ignore a malformed stored draft. // Ignore a malformed stored draft.
} }
}); });
}, [store, mx]); }, [store]);
} }
-42
View File
@@ -1,42 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PushRuleActionName, TweakName } from 'matrix-js-sdk';
import { getNotificationModeOptionsFromActions } from './useNotificationMode';
test('getNotificationModeOptionsFromActions: no tweaks -> no highlight, no sound value', () => {
const options = getNotificationModeOptionsFromActions([PushRuleActionName.Notify]);
assert.deepEqual(options, { soundValue: undefined, highlight: false });
});
test('getNotificationModeOptionsFromActions: picks up highlight: true', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.equal(options.highlight, true);
});
test('getNotificationModeOptionsFromActions: highlight: false is not treated as set', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: false },
]);
assert.equal(options.highlight, false);
});
test('getNotificationModeOptionsFromActions: picks up a custom sound value', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
]);
assert.equal(options.soundValue, 'ping.ogg');
});
test('getNotificationModeOptionsFromActions: picks up both tweaks together', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.deepEqual(options, { soundValue: 'ping.ogg', highlight: true });
});
-23
View File
@@ -49,29 +49,6 @@ export const getNotificationModeActions = (
return actions; return actions;
}; };
// Derive the options that would reproduce an existing rule's tweaks, so a mode
// switch rebuilds actions on top of them instead of silently dropping a
// `highlight` tweak (or a custom sound) that isn't part of the mode itself.
export const getNotificationModeOptionsFromActions = (
actions: PushRuleAction[],
): NotificationModeOptions => {
const soundTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Sound,
);
const highlightTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Highlight,
);
return {
soundValue:
soundTweak && typeof soundTweak === 'object' && typeof soundTweak.value === 'string'
? soundTweak.value
: undefined,
highlight:
!!highlightTweak && typeof highlightTweak === 'object' && highlightTweak.value !== false,
};
};
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[]; export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
export const useNotificationModeActions = ( export const useNotificationModeActions = (
options?: NotificationModeOptions, options?: NotificationModeOptions,
-93
View File
@@ -1,93 +0,0 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { MatrixClient } from 'matrix-js-sdk';
import { getLocalRoomNamesContent, setLocalRoomName } from './useRoomMeta';
// Minimal fake client. Mirrors the real SDK behavior that matters here:
// setAccountData resolves WITHOUT updating what getAccountData returns — the
// local cache only updates once the /sync echo is delivered via the
// AccountData listener. This is exactly the staleness that let two
// back-to-back renames clobber each other before the fix (issue #17).
const makeFakeMx = () => {
const accountData: Record<string, unknown> = {};
const listeners: Array<(e: { getType: () => string; getContent: () => unknown }) => void> = [];
const setAccountDataCalls: Array<{ type: string; content: unknown }> = [];
const mx = {
getAccountData: (type: string) => {
const content = accountData[type];
return content ? { getContent: () => content } : undefined;
},
setAccountData: (type: string, content: unknown) => {
setAccountDataCalls.push({ type, content });
// Deliberately do NOT update `accountData` here — the real SDK doesn't
// either. It only updates on the emitted echo below.
return Promise.resolve();
},
on: (_event: unknown, h: (e: { getType: () => string; getContent: () => unknown }) => void) => {
listeners.push(h);
},
removeListener: (
_event: unknown,
h: (e: { getType: () => string; getContent: () => unknown }) => void,
) => {
const i = listeners.indexOf(h);
if (i >= 0) listeners.splice(i, 1);
},
};
const emitEcho = (type: string, content: unknown) => {
accountData[type] = content;
listeners.forEach((h) => h({ getType: () => type, getContent: () => content }));
};
return {
mx: mx as unknown as MatrixClient,
emitEcho,
setAccountDataCalls,
};
};
test('back-to-back renames of different rooms both survive with no echo in between', async () => {
const { mx } = makeFakeMx();
// Rename room A, then room B, before either write's /sync echo has landed —
// the exact scenario from issue #17.
const writeA = setLocalRoomName(mx, '!a:example.org', 'Room A renamed');
const writeB = setLocalRoomName(mx, '!b:example.org', 'Room B renamed');
await Promise.all([writeA, writeB]);
const content = getLocalRoomNamesContent(mx);
assert.deepEqual(content.rooms, {
'!a:example.org': 'Room A renamed',
'!b:example.org': 'Room B renamed',
});
});
test("writes are serialized: the second write computes from the first write's result", async () => {
const { mx, setAccountDataCalls } = makeFakeMx();
await Promise.all([
setLocalRoomName(mx, '!a:example.org', 'A'),
setLocalRoomName(mx, '!b:example.org', 'B'),
]);
// The last PUT to the server must carry both renames — proof the second
// write's compute() saw the first write's in-memory result rather than a
// stale snapshot from before it landed.
const lastCall = setAccountDataCalls[setAccountDataCalls.length - 1];
assert.deepEqual(lastCall.content, {
rooms: { '!a:example.org': 'A', '!b:example.org': 'B' },
});
});
test('clearing a local name removes only that room', async () => {
const { mx } = makeFakeMx();
await setLocalRoomName(mx, '!a:example.org', 'A');
await setLocalRoomName(mx, '!b:example.org', 'B');
await setLocalRoomName(mx, '!a:example.org', '');
const content = getLocalRoomNamesContent(mx);
assert.deepEqual(content.rooms, { '!b:example.org': 'B' });
});
+55 -48
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
import { Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk'; import { ClientEvent, MatrixEvent, Room, RoomEvent, RoomEventHandlerMap } from 'matrix-js-sdk';
import { StateEvent } from '../../types/matrix/room'; import { StateEvent } from '../../types/matrix/room';
import { useStateEvent } from './useStateEvent'; import { useStateEvent } from './useStateEvent';
import { useMatrixClient } from './useMatrixClient'; import { useMatrixClient } from './useMatrixClient';
import { createAccountDataListStore } from './createAccountDataListStore'; import { getAccountData } from '../utils/accountData';
export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => { export const useRoomAvatar = (room: Room, dm?: boolean): string | undefined => {
const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar); const avatarEvent = useStateEvent(room, StateEvent.RoomAvatar);
@@ -40,72 +40,79 @@ export const LOCAL_ROOM_NAMES_KEY = 'io.lotus.room_names';
export type LocalRoomNamesContent = { rooms: Record<string, string> }; export type LocalRoomNamesContent = { rooms: Record<string, string> };
type LocalRoomNamesMap = Record<string, string>;
// Shared, concurrency-safe store. See createAccountDataListStore for why the
// snapshot + write queue must be module-scoped: setAccountData does not update
// the SDK's local cache (it only resolves once the /sync echo lands), so a
// plain read-modify-write against getAccountData can lose a rename that is
// still in flight when a second rename is issued (fixed: back-to-back renames
// of different rooms no longer clobber each other).
const roomNamesStore = createAccountDataListStore<LocalRoomNamesMap, LocalRoomNamesContent>({
eventType: LOCAL_ROOM_NAMES_KEY,
read: (content) =>
content && typeof content === 'object' && typeof content.rooms === 'object'
? content.rooms
: {},
write: (rooms) => ({ rooms }),
});
export function getLocalRoomNamesContent( export function getLocalRoomNamesContent(
mx: ReturnType<typeof useMatrixClient>, mx: ReturnType<typeof useMatrixClient>,
): LocalRoomNamesContent { ): LocalRoomNamesContent {
return { rooms: roomNamesStore.getLatest(mx) }; const raw: unknown = getAccountData<unknown>(mx, LOCAL_ROOM_NAMES_KEY);
} if (
raw &&
/** typeof raw === 'object' &&
* Set (or clear, when `name` is empty) the local display name for a room. 'rooms' in raw &&
* Routed through the shared write queue so back-to-back renames of different // eslint-disable-next-line @typescript-eslint/no-explicit-any
* rooms are always computed from the latest snapshot instead of a stale one. typeof (raw as any).rooms === 'object'
*/ ) {
export function setLocalRoomName( return raw as LocalRoomNamesContent;
mx: ReturnType<typeof useMatrixClient>, }
roomId: string, 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 = {
+2 -2
View File
@@ -20,7 +20,6 @@ import {
} from '../../hooks/useClientConfig'; } from '../../hooks/useClientConfig';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths'; import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
import { ServerPicker } from './ServerPicker'; import { ServerPicker } from './ServerPicker';
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api'; import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader'; import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
@@ -30,8 +29,9 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
import { AuthFlowsProvider } from '../../hooks/useAuthFlows'; import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
import { AuthServerProvider } from '../../hooks/useAuthServer'; import { AuthServerProvider } from '../../hooks/useAuthServer';
import { tryDecodeURIComponent } from '../../utils/dom'; import { tryDecodeURIComponent } from '../../utils/dom';
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png'); const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
const currentAuthPath = (pathname: string): string => { const currentAuthPath = (pathname: string): string => {
if (matchPath(LOGIN_PATH, pathname)) { if (matchPath(LOGIN_PATH, pathname)) {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
import { OIDC_CALLBACK_PATH } from '../../paths'; import { OIDC_CALLBACK_PATH } from '../../paths';
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils'; import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png'); const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
/** /**
* Absolute URL the OIDC provider redirects back to after authorization. * Absolute URL the OIDC provider redirects back to after authorization.
+5 -5
View File
@@ -51,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';
@@ -70,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() {
+2 -2
View File
@@ -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 (
+3 -32
View File
@@ -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);
}; };
-47
View File
@@ -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);
});
-33
View File
@@ -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);
});
-7
View File
@@ -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()));
+1 -1
View File
@@ -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,