Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aef410f60 | ||
|
|
b2376513fd | ||
|
|
2bbd390a3b |
+26
-30
@@ -30,13 +30,8 @@ jobs:
|
|||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version-file: '.node-version'
|
node-version-file: '.node-version'
|
||||||
|
cache: npm
|
||||||
|
|
||||||
# No npm / node_modules cache: the act_runner's internal cache server is
|
|
||||||
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
|
|
||||||
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
|
|
||||||
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
|
|
||||||
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
|
|
||||||
# node_modules step) once the runner's cache server is reachable from jobs.
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
# Harden against transient registry network failures (ECONNRESET etc.):
|
# Harden against transient registry network failures (ECONNRESET etc.):
|
||||||
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
|
||||||
@@ -57,36 +52,37 @@ jobs:
|
|||||||
sleep $((attempt * 15))
|
sleep $((attempt * 15))
|
||||||
done
|
done
|
||||||
|
|
||||||
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
|
# ── Critical gate — if this fails, nothing deploys ──────────────────
|
||||||
# error fails in seconds instead of after the ~minutes-long build. All are
|
|
||||||
# hard gates — any failure fails the job and blocks the deploy. The tree is
|
|
||||||
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
|
|
||||||
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
|
|
||||||
# deploy without CI; a later normal push surfaces any introduced issue here
|
|
||||||
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
|
|
||||||
# eslint gates on errors only (existing no-explicit-any warnings stay
|
|
||||||
# informational — check:eslint has no --max-warnings).
|
|
||||||
- name: Prettier
|
|
||||||
run: npm run check:prettier
|
|
||||||
|
|
||||||
- name: ESLint
|
|
||||||
run: npm run check:eslint
|
|
||||||
|
|
||||||
- name: TypeScript
|
|
||||||
run: npm run typecheck
|
|
||||||
|
|
||||||
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
|
|
||||||
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
|
|
||||||
- name: Unit tests
|
|
||||||
run: npm test
|
|
||||||
|
|
||||||
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: npm run build
|
run: npm run build
|
||||||
env:
|
env:
|
||||||
NODE_OPTIONS: '--max_old_space_size=4096'
|
NODE_OPTIONS: '--max_old_space_size=4096'
|
||||||
VITE_APP_VERSION: ${{ github.sha }}
|
VITE_APP_VERSION: ${{ github.sha }}
|
||||||
|
|
||||||
|
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
|
||||||
|
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
|
||||||
|
# A failure blocks the deploy.
|
||||||
|
- name: Unit tests
|
||||||
|
run: npm test
|
||||||
|
|
||||||
|
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
|
||||||
|
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
|
||||||
|
# formatted), so these gate real regressions instead of relying on local
|
||||||
|
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
|
||||||
|
# introduce upstream type/lint/format issues; that path deploys without
|
||||||
|
# CI, but a subsequent normal push would surface the failure here — fix
|
||||||
|
# forward (or briefly re-soften a gate) rather than let it deploy broken.
|
||||||
|
# eslint gates on errors only (existing `no-explicit-any` warnings stay
|
||||||
|
# informational — `check:eslint` has no --max-warnings).
|
||||||
|
- name: TypeScript
|
||||||
|
run: npm run typecheck
|
||||||
|
|
||||||
|
- name: ESLint
|
||||||
|
run: npm run check:eslint
|
||||||
|
|
||||||
|
- name: Prettier
|
||||||
|
run: npm run check:prettier
|
||||||
|
|
||||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||||
- name: Audit (high/critical)
|
- name: Audit (high/critical)
|
||||||
run: npm audit --audit-level=high --omit=dev
|
run: npm audit --audit-level=high --omit=dev
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug Report
|
|
||||||
about: Report something that isn't working in Lotus Chat
|
|
||||||
title: ''
|
|
||||||
labels: bug
|
|
||||||
---
|
|
||||||
|
|
||||||
**Describe the bug**
|
|
||||||
A clear and concise description of what went wrong.
|
|
||||||
|
|
||||||
**Steps to reproduce**
|
|
||||||
|
|
||||||
1. Go to '...'
|
|
||||||
2. Click on '...'
|
|
||||||
3. See error
|
|
||||||
|
|
||||||
**Expected behavior**
|
|
||||||
What you expected to happen instead.
|
|
||||||
|
|
||||||
**Client info**
|
|
||||||
|
|
||||||
- Lotus Chat version (Settings → Help & About):
|
|
||||||
- Platform: Web / Desktop (Windows / macOS / Linux)
|
|
||||||
- Browser + version (if web):
|
|
||||||
|
|
||||||
**Screenshots / logs**
|
|
||||||
If applicable, add screenshots or the browser devtools console output.
|
|
||||||
@@ -1 +1,5 @@
|
|||||||
blank_issues_enabled: true
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Features, Bug Reports, Questions
|
||||||
|
url: https://github.com/cinnyapp/cinny/discussions/new/choose
|
||||||
|
about: Our preferred starting point if you have any questions or suggestions about features or behavior.
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
name: Feature Request
|
|
||||||
about: Suggest an idea or improvement for Lotus Chat
|
|
||||||
title: ''
|
|
||||||
labels: enhancement
|
|
||||||
---
|
|
||||||
|
|
||||||
**What would you like?**
|
|
||||||
A clear and concise description of the feature or change.
|
|
||||||
|
|
||||||
**Why / use case**
|
|
||||||
What problem does it solve, or what does it make better?
|
|
||||||
|
|
||||||
**Alternatives considered**
|
|
||||||
Any workarounds or other approaches you've thought about.
|
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
---
|
||||||
|
name: Pre-Discussed and Approved Topics
|
||||||
|
about: |-
|
||||||
|
Only for topics already discussed and approved in the GitHub Discussions section.
|
||||||
|
---
|
||||||
|
|
||||||
|
**DO NOT OPEN A NEW ISSUE. PLEASE USE THE DISCUSSIONS SECTION.**
|
||||||
|
|
||||||
|
**I DIDN'T READ THE ABOVE LINE. PLEASE CLOSE THIS ISSUE.**
|
||||||
+3
-1
@@ -1 +1,3 @@
|
|||||||
npx lint-staged
|
# These are commented until we enable lint and typecheck
|
||||||
|
# npx tsc -p tsconfig.json --noEmit
|
||||||
|
# npx lint-staged
|
||||||
+13
-13
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,23 +167,6 @@ 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**
|
||||||
|
|||||||
+3
-3
@@ -4,9 +4,9 @@
|
|||||||
"allowCustomHomeservers": true,
|
"allowCustomHomeservers": true,
|
||||||
"featuredCommunities": {
|
"featuredCommunities": {
|
||||||
"openAsDefault": false,
|
"openAsDefault": false,
|
||||||
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
|
"spaces": [],
|
||||||
"rooms": ["#jellyfin:matrix.org"],
|
"rooms": [],
|
||||||
"servers": ["matrixrooms.info"]
|
"servers": []
|
||||||
},
|
},
|
||||||
"hashRouter": {
|
"hashRouter": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
|
|||||||
@@ -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)' },
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -66,10 +66,8 @@ 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';
|
||||||
@@ -275,6 +273,49 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localStorage key for timed mute timers
|
||||||
|
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
|
||||||
|
|
||||||
|
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
|
||||||
|
// immediately. Clamp long delays to this max (~24.8 days).
|
||||||
|
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
|
||||||
|
|
||||||
|
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
|
||||||
|
|
||||||
|
export function loadMuteTimers(): MuteTimerEntry[] {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
|
||||||
|
return Array.isArray(parsed) ? parsed : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
|
||||||
|
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reverse a timed mute: restore the room's notification mode to Unset and drop
|
||||||
|
// its persisted timer. Shared by the in-session timer and the boot-time restore.
|
||||||
|
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
|
||||||
|
const { setRoomNotificationPreference } =
|
||||||
|
await import('../../hooks/useRoomsNotificationPreferences');
|
||||||
|
await setRoomNotificationPreference(
|
||||||
|
mx,
|
||||||
|
roomId,
|
||||||
|
RoomNotificationMode.Unset,
|
||||||
|
RoomNotificationMode.Mute,
|
||||||
|
).catch(() => {});
|
||||||
|
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
|
||||||
|
const unmuteAt = Date.now() + durationMs;
|
||||||
|
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
|
||||||
|
saveMuteTimers([...existing, { roomId, unmuteAt }]);
|
||||||
|
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
|
||||||
|
}
|
||||||
|
|
||||||
type RoomNavItemMenuProps = {
|
type RoomNavItemMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -351,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);
|
|
||||||
});
|
|
||||||
@@ -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));
|
|
||||||
}
|
|
||||||
@@ -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],
|
||||||
@@ -489,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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -708,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) => {
|
||||||
@@ -837,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;
|
||||||
@@ -878,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(
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
||||||
import { buildCompressedUploadItem } from './msgContent';
|
|
||||||
import { TUploadItem } from '../../state/room/roomInputDrafts';
|
|
||||||
|
|
||||||
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
|
|
||||||
// (if any) the resulting m.image event carries. Getting this wrong either leaks
|
|
||||||
// a plaintext image into an E2EE room or produces an undecryptable attachment.
|
|
||||||
|
|
||||||
const enc = (tag: string): EncryptedAttachmentInfo =>
|
|
||||||
({
|
|
||||||
v: 'v2',
|
|
||||||
key: { alg: 'A256CTR', k: tag },
|
|
||||||
iv: `iv-${tag}`,
|
|
||||||
hashes: { sha256: `sha-${tag}` },
|
|
||||||
}) as unknown as EncryptedAttachmentInfo;
|
|
||||||
|
|
||||||
const fakeFile = (name: string, size: number): File =>
|
|
||||||
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
|
|
||||||
|
|
||||||
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
|
|
||||||
({
|
|
||||||
file: fakeFile('photo.png', 900),
|
|
||||||
originalFile: fakeFile('photo.png', 900),
|
|
||||||
encInfo,
|
|
||||||
metadata: { markedAsSpoiler: false, compressImage: true },
|
|
||||||
}) as unknown as TUploadItem;
|
|
||||||
|
|
||||||
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
|
|
||||||
const compressed = fakeFile('photo.jpg', 300);
|
|
||||||
const item = buildCompressedUploadItem(makeItem(), compressed);
|
|
||||||
|
|
||||||
assert.equal(item.file, compressed);
|
|
||||||
assert.equal(item.originalFile, compressed);
|
|
||||||
assert.equal(item.encInfo, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
|
|
||||||
const compressed = fakeFile('photo.jpg', 300);
|
|
||||||
const encryptedBlob = fakeFile('photo.jpg', 320);
|
|
||||||
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
|
|
||||||
file: encryptedBlob,
|
|
||||||
encInfo: enc('compressed'),
|
|
||||||
});
|
|
||||||
|
|
||||||
// The ciphertext is what gets uploaded; the plaintext stays available for
|
|
||||||
// dimensions/blurhash only.
|
|
||||||
assert.equal(item.file, encryptedBlob);
|
|
||||||
assert.equal(item.originalFile, compressed);
|
|
||||||
assert.deepEqual(item.encInfo, enc('compressed'));
|
|
||||||
assert.notDeepEqual(item.encInfo, enc('original'));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
|
|
||||||
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
|
|
||||||
// stale encInfo (that is the bug this helper exists to prevent).
|
|
||||||
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
|
|
||||||
assert.equal(item.encInfo, undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
|
|
||||||
const base = makeItem();
|
|
||||||
base.metadata.caption = 'a caption';
|
|
||||||
base.metadata.markedAsSpoiler = true;
|
|
||||||
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
|
|
||||||
assert.equal(item.metadata.caption, 'a caption');
|
|
||||||
assert.equal(item.metadata.markedAsSpoiler, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
|
|
||||||
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
|
|
||||||
// msgContent.test.ts builders, which share the same shape.
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
|
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
|
||||||
import to from 'await-to-js';
|
import to from 'await-to-js';
|
||||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
|
||||||
import {
|
import {
|
||||||
IThumbnailContent,
|
IThumbnailContent,
|
||||||
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
MATRIX_BLUR_HASH_PROPERTY_NAME,
|
||||||
@@ -44,28 +43,6 @@ const generateThumbnailContent = async (
|
|||||||
return thumbnailContent;
|
return thumbnailContent;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the synthetic upload item for a *re-encoded* (compressed) image.
|
|
||||||
*
|
|
||||||
* The compressed bytes are a brand new payload, so the item must never inherit
|
|
||||||
* the original's `encInfo` — that key/iv/sha256 describes the pre-compression
|
|
||||||
* ciphertext and would make receivers fail to decrypt. In an encrypted room the
|
|
||||||
* caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in
|
|
||||||
* an unencrypted room both are omitted and the item carries no `encInfo` at all.
|
|
||||||
*/
|
|
||||||
export const buildCompressedUploadItem = (
|
|
||||||
item: TUploadItem,
|
|
||||||
compressedFile: File,
|
|
||||||
encrypted?: { file: File; encInfo: EncryptedAttachmentInfo },
|
|
||||||
): TUploadItem => ({
|
|
||||||
...item,
|
|
||||||
// `file` is what gets uploaded/described, `originalFile` is the plaintext used
|
|
||||||
// for dimensions + blurhash.
|
|
||||||
file: encrypted?.file ?? compressedFile,
|
|
||||||
originalFile: compressedFile,
|
|
||||||
encInfo: encrypted?.encInfo,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const getImageMsgContent = async (
|
export const getImageMsgContent = async (
|
||||||
mx: MatrixClient,
|
mx: MatrixClient,
|
||||||
item: TUploadItem,
|
item: TUploadItem,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,42 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
import { PushRuleActionName, TweakName } from 'matrix-js-sdk';
|
|
||||||
import { getNotificationModeOptionsFromActions } from './useNotificationMode';
|
|
||||||
|
|
||||||
test('getNotificationModeOptionsFromActions: no tweaks -> no highlight, no sound value', () => {
|
|
||||||
const options = getNotificationModeOptionsFromActions([PushRuleActionName.Notify]);
|
|
||||||
assert.deepEqual(options, { soundValue: undefined, highlight: false });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getNotificationModeOptionsFromActions: picks up highlight: true', () => {
|
|
||||||
const options = getNotificationModeOptionsFromActions([
|
|
||||||
PushRuleActionName.Notify,
|
|
||||||
{ set_tweak: TweakName.Highlight, value: true },
|
|
||||||
]);
|
|
||||||
assert.equal(options.highlight, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getNotificationModeOptionsFromActions: highlight: false is not treated as set', () => {
|
|
||||||
const options = getNotificationModeOptionsFromActions([
|
|
||||||
PushRuleActionName.Notify,
|
|
||||||
{ set_tweak: TweakName.Highlight, value: false },
|
|
||||||
]);
|
|
||||||
assert.equal(options.highlight, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getNotificationModeOptionsFromActions: picks up a custom sound value', () => {
|
|
||||||
const options = getNotificationModeOptionsFromActions([
|
|
||||||
PushRuleActionName.Notify,
|
|
||||||
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
|
|
||||||
]);
|
|
||||||
assert.equal(options.soundValue, 'ping.ogg');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('getNotificationModeOptionsFromActions: picks up both tweaks together', () => {
|
|
||||||
const options = getNotificationModeOptionsFromActions([
|
|
||||||
PushRuleActionName.Notify,
|
|
||||||
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
|
|
||||||
{ set_tweak: TweakName.Highlight, value: true },
|
|
||||||
]);
|
|
||||||
assert.deepEqual(options, { soundValue: 'ping.ogg', highlight: true });
|
|
||||||
});
|
|
||||||
@@ -49,29 +49,6 @@ export const getNotificationModeActions = (
|
|||||||
return actions;
|
return actions;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Derive the options that would reproduce an existing rule's tweaks, so a mode
|
|
||||||
// switch rebuilds actions on top of them instead of silently dropping a
|
|
||||||
// `highlight` tweak (or a custom sound) that isn't part of the mode itself.
|
|
||||||
export const getNotificationModeOptionsFromActions = (
|
|
||||||
actions: PushRuleAction[],
|
|
||||||
): NotificationModeOptions => {
|
|
||||||
const soundTweak = actions.find(
|
|
||||||
(action) => typeof action === 'object' && action.set_tweak === TweakName.Sound,
|
|
||||||
);
|
|
||||||
const highlightTweak = actions.find(
|
|
||||||
(action) => typeof action === 'object' && action.set_tweak === TweakName.Highlight,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
soundValue:
|
|
||||||
soundTweak && typeof soundTweak === 'object' && typeof soundTweak.value === 'string'
|
|
||||||
? soundTweak.value
|
|
||||||
: undefined,
|
|
||||||
highlight:
|
|
||||||
!!highlightTweak && typeof highlightTweak === 'object' && highlightTweak.value !== false,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
|
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
|
||||||
export const useNotificationModeActions = (
|
export const useNotificationModeActions = (
|
||||||
options?: NotificationModeOptions,
|
options?: NotificationModeOptions,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
} from '../../hooks/useClientConfig';
|
} from '../../hooks/useClientConfig';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
import { LOGIN_PATH, REGISTER_PATH, RESET_PASSWORD_PATH } from '../paths';
|
||||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
|
||||||
import { ServerPicker } from './ServerPicker';
|
import { ServerPicker } from './ServerPicker';
|
||||||
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
import { AutoDiscoveryAction, autoDiscovery } from '../../cs-api';
|
||||||
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
import { SpecVersionsLoader } from '../../components/SpecVersionsLoader';
|
||||||
@@ -30,8 +29,9 @@ import { AuthFlowsLoader } from '../../components/AuthFlowsLoader';
|
|||||||
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
import { AuthFlowsProvider } from '../../hooks/useAuthFlows';
|
||||||
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
import { AuthServerProvider } from '../../hooks/useAuthServer';
|
||||||
import { tryDecodeURIComponent } from '../../utils/dom';
|
import { tryDecodeURIComponent } from '../../utils/dom';
|
||||||
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../pathUtils';
|
||||||
|
|
||||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
|
||||||
|
|
||||||
const currentAuthPath = (pathname: string): string => {
|
const currentAuthPath = (pathname: string): string => {
|
||||||
if (matchPath(LOGIN_PATH, pathname)) {
|
if (matchPath(LOGIN_PATH, pathname)) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { OidcRegistrationClientMetadata } from 'matrix-js-sdk';
|
|||||||
import { OIDC_CALLBACK_PATH } from '../../paths';
|
import { OIDC_CALLBACK_PATH } from '../../paths';
|
||||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
import { getOriginBaseUrl, withOriginBaseUrl } from '../../pathUtils';
|
||||||
|
|
||||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/public/res/Lotus.png');
|
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Absolute URL the OIDC provider redirects back to after authorization.
|
* Absolute URL the OIDC provider redirects back to after authorization.
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
import { test } from 'node:test';
|
|
||||||
import assert from 'node:assert/strict';
|
|
||||||
|
|
||||||
import { scheduleMessage } from './scheduledMessages';
|
|
||||||
|
|
||||||
// MSC4140 delayed events are PUT as a plaintext m.room.message, so scheduling
|
|
||||||
// must be refused outright for encrypted rooms — the composer hides the button,
|
|
||||||
// this guard stops any other caller from regressing it.
|
|
||||||
const makeMx = (encrypted: boolean | undefined) => {
|
|
||||||
const calls: unknown[][] = [];
|
|
||||||
const mx = {
|
|
||||||
getRoom: (_roomId: string) =>
|
|
||||||
encrypted === undefined ? null : { hasEncryptionStateEvent: () => encrypted },
|
|
||||||
http: {
|
|
||||||
authedRequest: (...args: unknown[]) => {
|
|
||||||
calls.push(args);
|
|
||||||
return Promise.resolve({ delay_id: 'delay-1' });
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
return { mx: mx as never, calls };
|
|
||||||
};
|
|
||||||
|
|
||||||
test('scheduleMessage throws and sends nothing for an encrypted room', async () => {
|
|
||||||
const { mx, calls } = makeMx(true);
|
|
||||||
await assert.rejects(
|
|
||||||
() => scheduleMessage(mx, '!enc:lotusguild.org', { body: 'secret' }, Date.now() + 60_000),
|
|
||||||
/encrypted rooms/,
|
|
||||||
);
|
|
||||||
assert.equal(calls.length, 0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scheduleMessage still sends for an unencrypted room', async () => {
|
|
||||||
const { mx, calls } = makeMx(false);
|
|
||||||
const delayId = await scheduleMessage(
|
|
||||||
mx,
|
|
||||||
'!plain:lotusguild.org',
|
|
||||||
{ body: 'hi' },
|
|
||||||
Date.now() + 60_000,
|
|
||||||
);
|
|
||||||
assert.equal(delayId, 'delay-1');
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('scheduleMessage sends when the room is unknown to the client', async () => {
|
|
||||||
const { mx, calls } = makeMx(undefined);
|
|
||||||
await scheduleMessage(mx, '!unknown:lotusguild.org', { body: 'hi' }, Date.now() + 60_000);
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
});
|
|
||||||
@@ -12,13 +12,6 @@ export async function scheduleMessage(
|
|||||||
content: IContent,
|
content: IContent,
|
||||||
sendAtMs: number,
|
sendAtMs: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
// MSC4140 delayed events are PUT straight to /send/m.room.message, bypassing
|
|
||||||
// the SDK's encryptEventIfNeeded pipeline — the body would land on the server
|
|
||||||
// (and later in the timeline) in the clear. Refuse rather than leak; the
|
|
||||||
// composer also hides the Schedule button in encrypted rooms.
|
|
||||||
if (mx.getRoom?.(roomId)?.hasEncryptionStateEvent()) {
|
|
||||||
throw new Error('Scheduled messages are not supported in encrypted rooms.');
|
|
||||||
}
|
|
||||||
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
// A past/near target floors at 1000ms (send ~immediately) — an intentional,
|
||||||
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
|
||||||
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
||||||
|
|||||||
+1
-1
@@ -246,7 +246,7 @@ const vendorChunks = (id) => {
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
appType: 'spa',
|
appType: 'spa',
|
||||||
publicDir: false,
|
publicDir: './public/res',
|
||||||
base: buildConfig.base,
|
base: buildConfig.base,
|
||||||
server: {
|
server: {
|
||||||
port: 8080,
|
port: 8080,
|
||||||
|
|||||||
Reference in New Issue
Block a user