Compare commits

...
Author SHA1 Message Date
jaredandClaude Fable 5.1 2b66dcc08c fix(room-nav): expiring timed mute no longer clobbers a manual mode change
CI / Build & Quality Checks (push) Successful in 1m58s
CI / Trigger Desktop Build (push) Successful in 10s
unmuteRoom unconditionally reset the room to Unset when a timed mute expired
(in-session timer and boot-time restore alike), silently reverting a mode the
user had changed by hand during the window. Mute-timer helpers move to
muteTimers.ts; unmuteRoom now reads the live push-rule mode and only resets
when it is still Mute, always dropping the persisted timer. Unit-tested.

Fixes #21

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:08:03 -04:00
jaredandClaude Fable 5.1 dac74f098e fix(composer): stop three E2EE plaintext leaks (compress, schedule, GIF)
- Image compression in an encrypted room re-encoded the *plaintext* original,
  uploaded it unencrypted, and reused the original's encInfo, so the media sat
  on the server in the clear AND the attachment was undecryptable. The
  compressed bytes are now run through encryptFile and the synthetic upload
  item carries the new encInfo (buildCompressedUploadItem, unit-tested; it can
  never inherit the stale encInfo).
- Scheduled messages (MSC4140) are PUT as raw m.room.message, bypassing the
  SDK encryption pipeline. The Schedule button is now hidden in encrypted
  rooms, handleScheduleClick no-ops there, and scheduleMessage() itself
  refuses with a clear error so no caller can regress this. README notes the
  limitation.
- The GIF picker uploaded the Giphy blob unencrypted into E2EE rooms; it now
  mirrors the voice/attachment path (encryptFile -> upload ciphertext ->
  content.file).

Fixes #6
Fixes #7
Fixes #11

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:08:03 -04:00
jaredandClaude Fable 5.1 d4d1b4957f fix(notifications): Advanced Push Rules mode switch keeps highlight/sound tweaks
RuleModeSwitcher rebuilt a rule's actions from scratch, so changing the
mode of e.g. .m.rule.is_user_mention silently dropped its highlight tweak
account-wide. Derive the existing highlight/custom-sound tweaks from the
rule's current actions and pass them through as NotificationModeOptions.
Adds getNotificationModeOptionsFromActions + unit tests.

Fixes #19

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:05:34 -04:00
jaredandClaude Fable 5.1 c4aa1567d7 fix(call): answering an incoming call honours cameraOnJoin and mic/sound prefs
handleAnswer hard-coded { microphone: true, video, sound: true }, so
pressing Answer on a video call published the camera even with the
"Join with Camera On" setting off (the default) and ignored a persisted
muted/deafened preference. Build the answer prefs the same way every other
join path does; PTT's forceAudioOff still applies downstream.

Fixes #8

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:05:34 -04:00
jaredandClaude Fable 5.1 a6ddafb446 fix(seasonal): Halloween fog layers drop willChange under reduced motion
The three fog bands pinned compositor layers even when their animation was
'none' (always the case in the settings swatch). Match the sibling layers.

Fixes #73

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:04:55 -04:00
jaredandClaude Fable 5.1 6c1a9942b0 fix(call): deafen button aria-label was inverted relative to its tooltip
`enabled` means sound is on; the tooltip said "Deafen" but the aria-label
said "Undeafen". Align the label with the tooltip and add aria-pressed to
match the call-status bar's equivalent button.

Fixes #33

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:04:52 -04:00
jaredandClaude Fable 5.1 4bea48959e fix(seasonal): keep Arcade + Deep Space overlays out of the chat text
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Trigger Desktop Build (push) Successful in 7s
Arcade:
- perspective grid is masked through the central timeline column so it
  frames the chat instead of striping messages; lower opacity, thinner
  non-scaling strokes (also fixes the grid vanishing in the 76px swatch)
- neon horizon line is broken through the middle so it never underlines
  a message; softer sun glow
- "1UP" score + "INSERT COIN" moved from top-left/bottom-centre (space
  bar / composer) to a single bottom-right HUD stack; font-size clamps
  to 0 via cqw inside the settings swatch so no more clipped glyphs
- scanlines: 4px pitch at lower opacity (light theme was fully striped);
  ambient wash slightly lighter

Deep Space:
- starfield 28 → 62 stars across the two parallax layers
- base violet wash slightly lighter for light themes

SeasonalPreview wrapper is now a size container (container-type) so
overlays can scale/hide fixed-px details with cqw. LOTUS_FEATURES.md
theme table now describes what both overlays actually render.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 01:29:33 -04:00
jaredandClaude Sonnet 5 34a3352e21 docs(readme): add per-OS desktop download table
CI / Build & Quality Checks (push) Successful in 2m48s
CI / Trigger Desktop Build (push) Successful in 6s
Replaces the single generic releases-page link with direct downloads
for Windows (.exe), Linux (AppImage/.deb/.pkg.tar.zst), plus a note on
the webkit2gtk/GStreamer WebRTC dependency needed for calls to work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:32:18 -04:00
jaredandClaude Sonnet 5 3cc5f0cc6a fix(room-nav): drop pointless dynamic import of setRoomNotificationPreference
RoomNavItem.tsx already statically imported getRoomNotificationModeIcon
and RoomNotificationMode from the same module, so the two
`await import('../../hooks/useRoomsNotificationPreferences')` calls
(in unmuteRoom and handleMuteFor) never achieved real code-splitting —
verified by building and grepping dist/assets: setRoomNotificationPreference
landed in the same eager entry chunk regardless, since Rolldown can't
split a module already reachable via a static import elsewhere. Just
import it statically alongside its siblings instead. No behavior
change — confirmed via 3 independent investigations before starting
and 3 independent reviews of this diff before committing.

Closes LotusGuild/cinny#5

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 21:29:29 -04:00
jaredandClaude Sonnet 5 fd93339ad4 feat(explore): feature the Homelab space instead of its individual rooms
CI / Build & Quality Checks (push) Successful in 2m21s
CI / Trigger Desktop Build (push) Successful in 12s
Swaps #homelabbing/#proxmox for their parent #homelab:codestorm.net
space, so browsing it surfaces the whole space rather than two
hand-picked children.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:14:24 -04:00
jaredandClaude Sonnet 5 b1ecb0c46b feat(explore): feature Lotus Guild Space + favorite rooms, add matrixrooms.info directory
CI / Build & Quality Checks (push) Successful in 3m46s
CI / Trigger Desktop Build (push) Successful in 15s
Populates the previously-empty featuredCommunities block so the Explore
tab's Featured page shows the Lotus Guild Space and a few community
favorites by default, and adds matrixrooms.info as a browsable server
in the Explore sidebar (it speaks enough of the Matrix federation API
to serve as an aggregated public-room search across the network).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-28 22:07:41 -04:00
jared 4656f08802 Revert "ci: re-enable npm/node_modules cache — runner cache network fixed"
CI / Build & Quality Checks (push) Successful in 1m42s
CI / Trigger Desktop Build (push) Successful in 12s
This reverts commit a631e90ea2.
2026-08-02 23:23:52 -04:00
jaredandClaude Opus 4.8 a631e90ea2 ci: re-enable npm/node_modules cache — runner cache network fixed
CI / Build & Quality Checks (push) Canceled after 4m44s
CI / Trigger Desktop Build (push) Canceled after 0s
The act_runner cache server is now reachable from job containers: jobs were
landing on isolated per-job docker networks and couldn't reach the runner's
cache server on docker0 (getCacheEntry ETIMEDOUT, ~5 min wasted/build). Fixed
runner-side by putting the runner + all job containers on a shared dedicated
network (`act-cache-net`, runner at 172.30.0.2) and pointing cache.host at it —
verified a container on that network reaches the cache port.

Restores `cache: npm` on Setup Node and the actions/cache node_modules step
(restore + save-on-miss-and-success). Reverts 10270b75 now that the underlying
network issue is resolved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 23:18:00 -04:00
jaredandClaude Opus 4.8 10270b75ca ci: drop npm/node_modules cache — runner cache server is unreachable
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Trigger Desktop Build (push) Successful in 13s
The act_runner's internal cache server (172.17.0.2:46367) can't be reached
from job containers: `setup-node` with `cache: npm` spends ~4m42s on
`getCacheEntry failed: connect ETIMEDOUT` every build, then reports "npm cache
is not found" — ~5 min of pure cost for zero caching. The `actions/cache`
node_modules steps added in 79258668 would hit the same dead server and hang
too, so they're removed here as well.

Removing the cache usage reclaims ~5 min/build with no loss (nothing was being
cached). The fast-gates-before-build reorder is kept. Re-enable caching once
the runner's cache server is reachable from job containers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:26:26 -04:00
jaredandClaude Opus 4.8 7925866868 ci: cache node_modules + run fast gates first; enable lint-staged hook
CI (.gitea/workflows/ci.yml):
- Cache node_modules keyed on package-lock + .node-version (actions/cache
  restore/save). An unchanged lockfile now skips `npm ci` (extraction +
  postinstall folds patch) and just restores the tree. Save runs only on a
  cache miss and only when install succeeded (`success()`), so a failed
  `npm ci` can't poison the cache. setup-node's existing `cache: npm` still
  warms the download cache on the miss path.
- Run prettier/eslint/typecheck/tests BEFORE the ~minutes-long build so a
  format/lint/type/test error fails in seconds instead of after the build.

DX (.husky/pre-commit):
- Enable the pre-commit hook (`npx lint-staged`). husky + lint-staged were
  already installed with a config (eslint + `prettier --write` on staged
  files), just commented out — so formatting kept reaching CI. It's now
  auto-applied on commit. (typecheck left out of the hook — too slow per commit.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 21:05:50 -04:00
24 changed files with 586 additions and 202 deletions
+30 -26
View File
@@ -30,8 +30,13 @@ jobs:
uses: actions/setup-node@v4
with:
node-version-file: '.node-version'
cache: npm
# No npm / node_modules cache: the act_runner's internal cache server is
# unreachable from job containers (`getCacheEntry failed: connect ETIMEDOUT
# 172.17.0.2`), so every cache restore hangs ~5 min and then fails — pure
# cost, zero benefit. `cache: npm` was removed from Setup Node above for the
# same reason. Re-enable both (setup-node `cache: npm` + an actions/cache
# node_modules step) once the runner's cache server is reachable from jobs.
- name: Install dependencies
# Harden against transient registry network failures (ECONNRESET etc.):
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
@@ -52,37 +57,36 @@ jobs:
sleep $((attempt * 15))
done
# ── Critical gate — if this fails, nothing deploys ──────────────────
# ── Quality gates run BEFORE the slow build so a format/lint/type/test
# error fails in seconds instead of after the ~minutes-long build. All are
# hard gates — any failure fails the job and blocks the deploy. The tree is
# held clean (prettier formatted, eslint 0 errors, typecheck 0), so these
# gate real regressions. NOTE: the lotus-build.sh upstream-merge path can
# deploy without CI; a later normal push surfaces any introduced issue here
# — fix forward (or briefly re-soften a gate) rather than deploy broken.
# eslint gates on errors only (existing no-explicit-any warnings stay
# informational — check:eslint has no --max-warnings).
- name: Prettier
run: npm run check:prettier
- name: ESLint
run: npm run check:eslint
- name: TypeScript
run: npm run typecheck
# Deterministic pure-logic tests on Node's built-in runner via tsx (no
# vitest — Vite 8 is ahead of vitest's range). A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Critical gate — if this fails, nothing deploys. Produces dist/. ──
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max_old_space_size=4096'
VITE_APP_VERSION: ${{ github.sha }}
# Unit tests are a hard gate too — deterministic pure-logic tests on Node's
# built-in runner via tsx (no vitest — Vite 8 is ahead of vitest's range).
# A failure blocks the deploy.
- name: Unit tests
run: npm test
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
# formatted), so these gate real regressions instead of relying on local
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
# introduce upstream type/lint/format issues; that path deploys without
# CI, but a subsequent normal push would surface the failure here — fix
# forward (or briefly re-soften a gate) rather than let it deploy broken.
# eslint gates on errors only (existing `no-explicit-any` warnings stay
# informational — `check:eslint` has no --max-warnings).
- name: TypeScript
run: npm run typecheck
- name: ESLint
run: npm run check:eslint
- name: Prettier
run: npm run check:prettier
# ── Security (informational — findings shouldn't block a deploy) ─────
- name: Audit (high/critical)
run: npm audit --audit-level=high --omit=dev
+1 -3
View File
@@ -1,3 +1 @@
# These are commented until we enable lint and typecheck
# npx tsc -p tsconfig.json --noEmit
# npx lint-staged
npx lint-staged
+13 -13
View File
@@ -175,19 +175,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes
| Theme | Window | Effect |
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 Deep Space Week | Oct 4–10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
| Theme | Window | Effect |
| -------------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | Synthwave CRT: neon perspective grid framing the timeline (faded through the chat column), broken horizon line, rolling scanlines, pixel sparkles, bottom-right "1UP / INSERT COIN" HUD |
| 🚀 Deep Space Week | Oct 4–10 | Violet void with drifting magenta/cyan nebula clouds, two-depth parallax starfield (~60 twinkling stars + 6 hero gleams), slow galaxy spiral, occasional comet streaks |
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation
+11 -2
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)
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time
- Schedule messages to send at a specific time (unencrypted rooms only — MSC4140 delayed events cannot be end-to-end encrypted, so the option is hidden in E2EE rooms)
- Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand
@@ -129,7 +129,16 @@ Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same we
### Download
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
Operating System | Download
---|---
Windows | [Get the installer (.exe)](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64-setup.exe)
Linux (AppImage, any distro) | [Get the AppImage](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.AppImage)
Linux (Debian/Ubuntu) | [Get the .deb](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.deb)
Linux (Arch/CachyOS/EndeavourOS) | [Get the .pkg.tar.zst](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases/download/latest/LotusChat-x86_64.pkg.tar.zst) — install with `pacman -U LotusChat-x86_64.pkg.tar.zst`
All Linux builds need `webkit2gtk-4.1` and, for calls to work, GStreamer's `good`/`bad`/`ugly`/`libav` plugin sets (the pacman package pulls these in automatically; on the AppImage/.deb, install them via your package manager if joining a call shows "browser does not support WebRTC").
See the full [Releases page on code.lotusguild.org](https://code.lotusguild.org/LotusGuild/cinny-desktop/releases) for signatures and older builds.
### SmartScreen Warning (Windows)
+3 -3
View File
@@ -4,9 +4,9 @@
"allowCustomHomeservers": true,
"featuredCommunities": {
"openAsDefault": false,
"spaces": [],
"rooms": [],
"servers": []
"spaces": ["!-1ZBnAH-JiCOV8MGSKN77zDGTuI3pgSdy8Unu_DrDyc", "#homelab:codestorm.net"],
"rooms": ["#jellyfin:matrix.org"],
"servers": ["matrixrooms.info"]
},
"hashRouter": {
"enabled": false,
+9 -2
View File
@@ -58,6 +58,7 @@ import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useReducedMotion } from '../hooks/useReducedMotion';
import { useSetting } from '../state/hooks/settings';
import { useCallPreferences } from '../state/hooks/callPreferences';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
@@ -410,6 +411,8 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const [callInfo, setCallInfo] = useState<IncomingCallInfo>();
const dm = callInfo ? directs.has(callInfo.room.roomId) : false;
const startCall = useCallStart(dm);
const { microphone, sound } = useCallPreferences();
const [cameraOnJoin] = useSetting(settingsAtom, 'cameraOnJoin');
// C-L6: handleTimelineEvent awaits decryption before calling setState; guard
// against the component unmounting during that await.
@@ -566,11 +569,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
const handleAnswer = useCallback(
(room: Room, video: boolean) => {
startCall(room, { microphone: true, video, sound: true });
// Honour cameraOnJoin and the persisted mic/sound preferences instead of
// forcing camera+mic+sound on — every other join path does this, and
// Answer was skipping it, publishing the camera with no prescreen.
// (PTT's forceAudioOff is applied downstream inside useCallStart.)
startCall(room, { microphone, video: cameraOnJoin && video, sound });
setCallInfo(undefined);
navigateRoom(room.roomId);
},
[startCall, navigateRoom],
[startCall, navigateRoom, microphone, sound, cameraOnJoin],
);
if (!callInfo) return null;
@@ -84,7 +84,15 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
return (
<div
aria-hidden="true"
style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}
style={{
position: 'absolute',
inset: 0,
overflow: 'hidden',
pointerEvents: 'none',
// Size container so overlays can scale/hide fixed-size details (e.g.
// Arcade's HUD text) with `cqw` instead of rendering clipped in a swatch.
containerType: 'inline-size',
}}
>
{buildOverlayContent(theme, true)}
</div>
@@ -102,12 +102,12 @@ export const animSparkleTwinkle = keyframes({
* Opacity + a hair of scale for a CRT bloom feel.
*/
export const animCoinBlink = keyframes({
'0%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'6%': { opacity: '1', transform: 'translateX(-50%) scale(1.015)' },
'12%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'49%': { opacity: '0.85', transform: 'translateX(-50%) scale(1)' },
'50%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'100%': { opacity: '0', transform: 'translateX(-50%) scale(1)' },
'0%': { opacity: '0.85', transform: 'scale(1)' },
'6%': { opacity: '1', transform: 'scale(1.015)' },
'12%': { opacity: '0.85', transform: 'scale(1)' },
'49%': { opacity: '0.85', transform: 'scale(1)' },
'50%': { opacity: '0', transform: 'scale(1)' },
'100%': { opacity: '0', transform: 'scale(1)' },
});
/**
+103 -69
View File
@@ -46,6 +46,10 @@ const NEON_CYAN = 'oklch(0.80 0.15 200)';
const GRID_PURPLE = 'oklch(0.45 0.18 300)';
// The receding grid as an inline SVG data-URI (CSP-safe, no external assets).
// Strokes use vector-effect=non-scaling-stroke so a line is ~1px whether the
// tile is stretched across a 2000px plane (preserveAspectRatio=none would
// otherwise fatten the verticals ~4x) or squeezed into the 76px settings
// swatch (where scaled strokes disappeared entirely).
// It is a 1x2 vertical tile of horizontal rule lines + a single set of vertical
// lines fanning toward a top-center vanishing point. The plane is then placed
// under a CSS `perspective` rotateX so the lines genuinely recede. Scrolling the
@@ -58,7 +62,7 @@ function gridDataUri(): string {
rows.forEach((y) => {
lines.push(
`<line x1='0' y1='${y}' x2='600' y2='${y}' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.9'/>`,
`stroke-width='1.2' stroke-opacity='0.9' vector-effect='non-scaling-stroke'/>`,
);
});
// Vertical lines fanning out from the top-center vanishing point.
@@ -67,7 +71,7 @@ function gridDataUri(): string {
const botX = 300 + i * 95; // wide at the foreground
lines.push(
`<line x1='${topX}' y1='0' x2='${botX}' y2='600' stroke='${GRID_PURPLE}' ` +
`stroke-width='1.4' stroke-opacity='0.8'/>`,
`stroke-width='1.2' stroke-opacity='0.8' vector-effect='non-scaling-stroke'/>`,
);
}
const svg =
@@ -105,6 +109,13 @@ const RESTING_SPARKLES: ReadonlyArray<{
const GRID_URI = gridDataUri();
// HUD text size: 11px on any real viewport, 0px (invisible) inside anything
// narrower than ~330px. `cqw` resolves against the nearest size container —
// the settings swatch (`SeasonalPreview` sets container-type) — and falls back
// to the viewport width when there is no container, i.e. the full-screen
// overlay. clamp(0, 100cqw - 320px, 11px) → 76px swatch: 0px; 1440px app: 11px.
const HUD_FONT_SIZE = 'clamp(0px, calc(100cqw - 320px), 11px)';
export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
// Deterministic sparkle field, computed ONCE. No per-frame state.
const sparkles = useMemo<Sparkle[]>(() => {
@@ -134,9 +145,9 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.16) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.20) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.10) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.16) 100%)',
'radial-gradient(140% 80% at 50% -8%, oklch(0.65 0.25 350 / 0.12) 0%, transparent 55%)',
'radial-gradient(120% 70% at 50% 112%, oklch(0.45 0.18 300 / 0.16) 0%, transparent 60%)',
'linear-gradient(180deg, oklch(0.12 0.05 300 / 0.08) 0%, transparent 38%, oklch(0.10 0.06 310 / 0.12) 100%)',
].join(','),
contain: 'layout paint style',
}}
@@ -147,41 +158,59 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
a vanishing point at the top (the horizon). It lives in the lower
half of the screen — the "floor". The inner plane scrolls upward by
one tile via transform translateY, which reads as the grid flowing
toward the viewer. Pure transform; never background-position. */}
toward the viewer. Pure transform; never background-position.
Two masks are nested (multiple mask-images on one element union by
default, and `mask-composite: intersect` isn't universal yet): the
outer wrapper fades the lattice through the central column where the
message timeline lives, so it frames the chat instead of striping
the text; the inner box fades it in from the horizon. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
opacity: reduced ? 0.5 : 0.62,
inset: 0,
maskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
WebkitMaskImage:
'linear-gradient(90deg, #000 0%, #000 12%, rgba(0,0,0,0.3) 34%, rgba(0,0,0,0.3) 66%, #000 88%, #000 100%)',
opacity: reduced ? 0.4 : 0.46,
contain: 'layout paint style',
}}
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 3px oklch(0.55 0.22 320 / 0.6))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
left: '-25%',
right: '-25%',
bottom: 0,
height: '62%',
overflow: 'hidden',
perspective: '280px',
perspectiveOrigin: '50% 0%',
maskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
WebkitMaskImage: 'linear-gradient(180deg, transparent 0%, #000 26%, #000 100%)',
contain: 'layout paint style',
}}
/>
>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: 0,
height: '200%',
transformOrigin: 'top center',
transform: 'rotateX(74deg)',
backgroundImage: GRID_URI,
backgroundRepeat: 'repeat-y',
backgroundSize: '100% 50%',
filter: 'drop-shadow(0 0 2px oklch(0.55 0.22 320 / 0.55))',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animGridScroll} 7s linear infinite`,
}}
/>
</div>
</div>
{/* 3. Horizon glow + neon horizon line. A soft synthwave sun-bloom sits
@@ -197,7 +226,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
height: '34%',
transform: 'translate(-50%, -50%)',
backgroundImage:
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.22) 0%, oklch(0.65 0.18 330 / 0.10) 40%, transparent 72%)',
'radial-gradient(60% 100% at 50% 100%, oklch(0.70 0.22 350 / 0.16) 0%, oklch(0.65 0.18 330 / 0.08) 40%, transparent 72%)',
contain: 'layout paint style',
}}
/>
@@ -209,8 +238,10 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
right: '12%',
top: '38%',
height: '1.5px',
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 25%, oklch(0.92 0.10 320 / 0.95) 50%, ${NEON_CYAN} 75%, transparent 100%)`,
opacity: 0.55,
// Bright at the flanks, dropped out through the centre column so the
// rule frames the timeline rather than underlining a message.
background: `linear-gradient(90deg, transparent 0%, ${NEON_CYAN} 14%, oklch(0.92 0.10 320 / 0.95) 22%, transparent 34%, transparent 66%, oklch(0.92 0.10 320 / 0.95) 78%, ${NEON_CYAN} 86%, transparent 100%)`,
opacity: 0.4,
filter: 'blur(0.4px) drop-shadow(0 0 4px oklch(0.78 0.16 200 / 0.7))',
}}
/>
@@ -273,7 +304,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
inset: 0,
overflow: 'hidden',
mixBlendMode: 'multiply',
opacity: 0.5,
opacity: 0.32,
contain: 'layout paint style',
}}
>
@@ -285,7 +316,7 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
top: '-8px',
bottom: '-8px',
backgroundImage:
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.55) 0px, oklch(0.10 0.04 300 / 0.55) 1px, transparent 1px, transparent 3px)',
'repeating-linear-gradient(0deg, oklch(0.10 0.04 300 / 0.45) 0px, oklch(0.10 0.04 300 / 0.45) 1px, transparent 1px, transparent 4px)',
willChange: reduced ? undefined : 'transform',
animation: reduced ? 'none' : `${animScanRoll} 6s linear infinite`,
}}
@@ -309,51 +340,54 @@ export function ArcadeOverlay({ reduced }: SeasonalOverlayProps) {
}}
/>
{/* 7a. Glowing "INSERT COIN" attract-mode blip, low-opacity, bottom-center.
Static scene shows it steady (no blink). */}
{/* 7. Attract-mode HUD: a tiny SCORE readout over a glowing "INSERT COIN"
blip, stacked bottom-right. That corner is the one spot that is
clear in every layout (below the members list, or the empty right
end of the read-receipt strip) — top-left collided with the space
bar and bottom-centre sat on the composer. Static scene shows both
steady (no blink). The font-size clamp collapses the text to nothing
when the host is narrower than ~330px, so the 76px settings swatch
never shows clipped glyphs. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
bottom: '5%',
left: '50%',
transform: 'translateX(-50%)',
right: '14px',
bottom: '8px',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: '3px',
fontFamily: '"Courier New", monospace',
fontSize: '12px',
fontSize: HUD_FONT_SIZE,
fontWeight: 700,
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
lineHeight: 1,
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
INSERT COIN
</div>
{/* 7b. Corner SCORE HUD glyph — a tiny pixel score that blips, top-left,
very low opacity so it reads as ambient chrome, not UI. */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '2.5%',
left: '2%',
fontFamily: '"Courier New", monospace',
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
userSelect: 'none',
whiteSpace: 'nowrap',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
<div
style={{
letterSpacing: '0.18em',
color: NEON_MAGENTA,
textShadow: '0 0 6px oklch(0.65 0.25 350 / 0.8)',
opacity: reduced ? 0.5 : undefined,
animation: reduced ? 'none' : `${animScoreBlip} 2.4s ease-in-out infinite`,
}}
>
1UP 00<span style={{ color: NEON_CYAN }}>0000</span>
</div>
<div
style={{
letterSpacing: '0.32em',
color: NEON_CYAN,
textShadow: '0 0 6px oklch(0.80 0.15 200 / 0.9), 0 0 14px oklch(0.65 0.25 350 / 0.5)',
opacity: reduced ? 0.6 : undefined,
animation: reduced ? 'none' : `${animCoinBlink} 1.6s step-end infinite`,
}}
>
INSERT COIN
</div>
</div>
{/* 8. CRT vignette + screen-glow. A radial darkening frames the corners,
@@ -97,8 +97,8 @@ function makeStars(count: number, seedBase: number): Star[] {
export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
// Two parallax depths. Far = dense + faint, Near = sparser + slightly larger.
const farStars = useMemo<Star[]>(() => makeStars(16, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(12, 2000), []);
const farStars = useMemo<Star[]>(() => makeStars(40, 1000), []);
const nearStars = useMemo<Star[]>(() => makeStars(22, 2000), []);
const heroStars = useMemo<HeroStar[]>(
() =>
@@ -144,7 +144,7 @@ export function DeepSpaceOverlay({ reduced }: SeasonalOverlayProps) {
position: 'absolute',
inset: '-6%',
contain: 'layout paint style',
backgroundColor: 'oklch(0.2 0.12 300 / 0.16)',
backgroundColor: 'oklch(0.2 0.12 300 / 0.12)',
backgroundImage: [
'radial-gradient(120% 90% at 50% -8%, oklch(0.28 0.13 295 / 0.2) 0%, transparent 60%)',
'radial-gradient(100% 80% at 12% 18%, oklch(0.55 0.2 330 / 0.1) 0%, transparent 55%)',
@@ -164,7 +164,7 @@ export function HalloweenOverlay({ reduced }: SeasonalOverlayProps) {
height: `${f.height}px`,
backgroundImage: `radial-gradient(60% 100% at 50% 100%, ${FOG_TINT} 0%, transparent 75%)`,
filter: 'blur(14px)',
willChange: 'transform, opacity',
willChange: reduced ? undefined : 'transform, opacity',
opacity: reduced ? 0.5 : undefined,
transform: reduced ? 'translate3d(2%, 0, 0) scale(1.18)' : undefined,
animation: reduced
+2 -1
View File
@@ -74,7 +74,8 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
size="400"
className={MobileTouchTarget}
onClick={() => onToggle()}
aria-label={enabled ? 'Undeafen' : 'Deafen'}
aria-label={enabled ? 'Deafen' : 'Undeafen'}
aria-pressed={enabled}
outlined
>
<Icon
+3 -46
View File
@@ -6,7 +6,7 @@ import React, {
useRef,
useState,
} from 'react';
import { MatrixClient, Room } from 'matrix-js-sdk';
import { Room } from 'matrix-js-sdk';
import {
Avatar,
Box,
@@ -66,8 +66,10 @@ import { useSpaceOptionally } from '../../hooks/useSpace';
import {
getRoomNotificationModeIcon,
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { scheduleMuteTimer, unmuteRoom } from './muteTimers';
import { getRoomCreatorsForRoomId, useRoomCreators } from '../../hooks/useRoomCreators';
import { getRoomPermissionsAPI, useRoomPermissions } from '../../hooks/useRoomPermissions';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
@@ -273,49 +275,6 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
);
}
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
type RoomNavItemMenuProps = {
room: Room;
requestClose: () => void;
@@ -392,8 +351,6 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
const handleMuteFor = useCallback(
async (durationMs: number | null) => {
const { setRoomNotificationPreference } =
await import('../../hooks/useRoomsNotificationPreferences');
const prevMode = notificationMode ?? RoomNotificationMode.Unset;
await setRoomNotificationPreference(
mx,
@@ -0,0 +1,20 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { RoomNotificationMode } from '../../hooks/useRoomsNotificationPreferences';
import { shouldResetMuteOnUnmute } from './muteTimers';
test('resets to Unset when the room is still Mute at expiry', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Mute), true);
});
test('does not reset when the user switched to All messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.AllMessages), false);
});
test('does not reset when the user switched to Special messages during the mute window', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.SpecialMessages), false);
});
test('does not reset when the mode is already Unset', () => {
assert.equal(shouldResetMuteOnUnmute(RoomNotificationMode.Unset), false);
});
+88
View File
@@ -0,0 +1,88 @@
import { IPushRule, IPushRules, MatrixClient } from 'matrix-js-sdk';
import { AccountDataEvent } from '../../../types/matrix/accountData';
import { getAccountData } from '../../utils/room';
import { getNotificationMode, NotificationMode } from '../../hooks/useNotificationMode';
import {
RoomNotificationMode,
setRoomNotificationPreference,
} from '../../hooks/useRoomsNotificationPreferences';
// localStorage key for timed mute timers
export const MUTE_TIMERS_KEY = 'io.lotus.mute_timers';
// setTimeout's delay is a signed 32-bit int; larger values overflow and fire
// immediately. Clamp long delays to this max (~24.8 days).
export const MAX_MUTE_TIMEOUT_MS = 2_147_483_647;
export type MuteTimerEntry = { roomId: string; unmuteAt: number };
export function loadMuteTimers(): MuteTimerEntry[] {
try {
const parsed = JSON.parse(localStorage.getItem(MUTE_TIMERS_KEY) ?? '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
export function saveMuteTimers(timers: MuteTimerEntry[]): void {
localStorage.setItem(MUTE_TIMERS_KEY, JSON.stringify(timers));
}
// Pure decision for the unmute guard: a timed mute should only be reset back to
// Unset if the room's notification mode is still Mute at expiry time. If the user
// manually changed it (e.g. to All messages) while the timer was pending, leave
// their choice alone — just let the stale timer entry get dropped.
export function shouldResetMuteOnUnmute(currentMode: RoomNotificationMode): boolean {
return currentMode === RoomNotificationMode.Mute;
}
// Reads the room's live notification mode straight from account data push rules,
// mirroring useRoomsNotificationPreferences' per-room derivation, without needing
// the React hook (this runs from plain timers/effects, not components).
export function getLiveRoomNotificationMode(
mx: MatrixClient,
roomId: string,
): RoomNotificationMode {
const pushRules = getAccountData(mx, AccountDataEvent.PushRules)?.getContent<IPushRules>();
const global = pushRules?.global;
const overrideRule = global?.override?.find((rule: IPushRule) => rule.rule_id === roomId);
if (overrideRule && getNotificationMode(overrideRule.actions) === NotificationMode.OFF) {
return RoomNotificationMode.Mute;
}
const roomRule = global?.room?.find((rule: IPushRule) => rule.rule_id === roomId);
if (roomRule) {
return getNotificationMode(roomRule.actions) === NotificationMode.OFF
? RoomNotificationMode.SpecialMessages
: RoomNotificationMode.AllMessages;
}
return RoomNotificationMode.Unset;
}
// Reverse a timed mute: restore the room's notification mode to Unset and drop
// its persisted timer. Shared by the in-session timer and the boot-time restore.
// Only resets the mode if it is still Mute — otherwise a manual change made
// during the mute window (e.g. switching to "All messages") would silently get
// reverted when the stale timer fires.
export async function unmuteRoom(mx: MatrixClient, roomId: string): Promise<void> {
const currentMode = getLiveRoomNotificationMode(mx, roomId);
if (shouldResetMuteOnUnmute(currentMode)) {
await setRoomNotificationPreference(
mx,
roomId,
RoomNotificationMode.Unset,
RoomNotificationMode.Mute,
).catch(() => {});
}
saveMuteTimers(loadMuteTimers().filter((e) => e.roomId !== roomId));
}
export function scheduleMuteTimer(roomId: string, durationMs: number, onUnmute: () => void): void {
const unmuteAt = Date.now() + durationMs;
const existing = loadMuteTimers().filter((e) => e.roomId !== roomId);
saveMuteTimers([...existing, { roomId, unmuteAt }]);
setTimeout(onUnmute, Math.min(durationMs, MAX_MUTE_TIMEOUT_MS));
}
+58 -24
View File
@@ -105,6 +105,7 @@ import {
settingsAtom,
} from '../../state/settings';
import {
buildCompressedUploadItem,
getAudioMsgContent,
getFileMsgContent,
getImageMsgContent,
@@ -244,8 +245,11 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
// Schedule-send is hidden in thread mode (v1 reduction).
const showSchedule = (composerToolbarButtons?.showSchedule ?? true) && !threadRootId;
// Schedule-send is hidden in thread mode (v1 reduction) and in encrypted rooms:
// MSC4140 delayed events are PUT as plaintext m.room.message, bypassing the
// SDK's encryption pipeline, so scheduling in an E2EE room would leak the body.
const showSchedule =
(composerToolbarButtons?.showSchedule ?? true) && !threadRootId && !isEncrypted;
const composerButtonOrder = useMemo(
() => normalizeComposerToolbarOrder(composerToolbarButtons?.order),
[composerToolbarButtons?.order],
@@ -485,22 +489,29 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const compressedFile = new File([compressionResult.blob], compressedName, {
type: compressedType,
});
const uploadRes = await mx.uploadContent(compressedFile, {
name: compressedName,
type: compressedType,
});
// Compression re-encodes the image, so in an encrypted room the new
// bytes must be encrypted before upload (and the event must carry the
// *new* encInfo) — reusing the original's encInfo would publish the
// image in the clear and yield an undecryptable attachment.
const encrypted = fileItem.encInfo ? await encryptFile(compressedFile) : undefined;
const uploadRes = encrypted
? await mx.uploadContent(encrypted.file)
: await mx.uploadContent(compressedFile, {
name: compressedName,
type: compressedType,
});
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
if (compressedMxc) {
// Delete the pre-uploaded original so only one copy lives on the server.
tryDeleteMxcContent(mx, upload.mxc);
mxc = compressedMxc;
// Build a synthetic fileItem that refers to the compressed file so
// getImageMsgContent picks up the correct dimensions and type.
const compressedItem = {
...fileItem,
file: compressedFile,
originalFile: compressedFile,
};
// Synthetic fileItem referring to the compressed file so
// getImageMsgContent picks up the correct dimensions, type and encInfo.
const compressedItem = buildCompressedUploadItem(
fileItem,
compressedFile,
encrypted,
);
return getImageMsgContent(mx, compressedItem, mxc);
}
}
@@ -697,11 +708,14 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}, [editor, isMarkdown, mx, roomId, replyDraft]);
const handleScheduleClick = useCallback(() => {
// Defense in depth: scheduling sends an unencrypted m.room.message, so never
// open the modal for an encrypted room even if the button somehow renders.
if (isEncrypted) return;
// Pre-fill from editor if there's content; open blank if editor is empty.
const content = buildCurrentTextContent();
setScheduleContent(content);
setScheduleOpen(true);
}, [buildCurrentTextContent]);
}, [buildCurrentTextContent, isEncrypted]);
const handleScheduled = useCallback(
(delayId: string, sendAt: number, content: IContent) => {
@@ -823,18 +837,38 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
return;
}
const uploadRes = await mx.uploadContent(
new File([blob], 'image.gif', { type: 'image/gif' }),
{ type: 'image/gif', name: 'image.gif', includeFilename: false },
);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
const gifFile = new File([blob], 'image.gif', { type: 'image/gif' });
const baseContent = {
msgtype: MsgType.Image,
body: 'image.gif',
url: mxcUrl,
info: { mimetype: 'image/gif', w, h, size: blob.size },
});
};
// Mirror the attachment/voice paths: in an encrypted room the media
// itself must be encrypted, otherwise the homeserver (and anyone with
// the mxc URI) can see the GIF even though the event body is encrypted.
if (room.hasEncryptionStateEvent()) {
const { encInfo, file: encBlob } = await encryptFile(gifFile);
const uploadRes = await mx.uploadContent(encBlob);
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
file: { ...encInfo, url: mxcUrl },
} as any);
} else {
const uploadRes = await mx.uploadContent(gifFile, {
type: 'image/gif',
name: 'image.gif',
includeFilename: false,
});
const mxcUrl = (uploadRes as { content_uri: string }).content_uri;
if (!mxcUrl) return;
mx.sendMessage(roomId, threadRootId ?? null, {
...baseContent,
url: mxcUrl,
} as any);
}
} catch (e) {
console.error('GIF send failed:', e instanceof Error ? e.message : 'unknown error');
if (!alive()) return;
@@ -844,7 +878,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
if (alive()) setGifUploading(false);
}
},
[mx, roomId, threadRootId, alive],
[mx, room, roomId, threadRootId, alive],
);
const handleStickerSelect = useCallback(
@@ -0,0 +1,73 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import { buildCompressedUploadItem } from './msgContent';
import { TUploadItem } from '../../state/room/roomInputDrafts';
// buildCompressedUploadItem decides which bytes are uploaded and which encInfo
// (if any) the resulting m.image event carries. Getting this wrong either leaks
// a plaintext image into an E2EE room or produces an undecryptable attachment.
const enc = (tag: string): EncryptedAttachmentInfo =>
({
v: 'v2',
key: { alg: 'A256CTR', k: tag },
iv: `iv-${tag}`,
hashes: { sha256: `sha-${tag}` },
}) as unknown as EncryptedAttachmentInfo;
const fakeFile = (name: string, size: number): File =>
new File([new Uint8Array(size)], name, { type: 'image/jpeg' });
const makeItem = (encInfo?: EncryptedAttachmentInfo): TUploadItem =>
({
file: fakeFile('photo.png', 900),
originalFile: fakeFile('photo.png', 900),
encInfo,
metadata: { markedAsSpoiler: false, compressImage: true },
}) as unknown as TUploadItem;
test('unencrypted room: compressed item uploads the plain file and carries no encInfo', () => {
const compressed = fakeFile('photo.jpg', 300);
const item = buildCompressedUploadItem(makeItem(), compressed);
assert.equal(item.file, compressed);
assert.equal(item.originalFile, compressed);
assert.equal(item.encInfo, undefined);
});
test('encrypted room: compressed item carries the NEW encInfo, never the original one', () => {
const compressed = fakeFile('photo.jpg', 300);
const encryptedBlob = fakeFile('photo.jpg', 320);
const item = buildCompressedUploadItem(makeItem(enc('original')), compressed, {
file: encryptedBlob,
encInfo: enc('compressed'),
});
// The ciphertext is what gets uploaded; the plaintext stays available for
// dimensions/blurhash only.
assert.equal(item.file, encryptedBlob);
assert.equal(item.originalFile, compressed);
assert.deepEqual(item.encInfo, enc('compressed'));
assert.notDeepEqual(item.encInfo, enc('original'));
});
test('encrypted room: an encInfo-less compressed item never inherits the original encInfo', () => {
// Defensive: even if the caller forgets to re-encrypt, we must not emit the
// stale encInfo (that is the bug this helper exists to prevent).
const item = buildCompressedUploadItem(makeItem(enc('original')), fakeFile('photo.jpg', 300));
assert.equal(item.encInfo, undefined);
});
test('metadata (caption, spoiler) is preserved on the compressed item', () => {
const base = makeItem();
base.metadata.caption = 'a caption';
base.metadata.markedAsSpoiler = true;
const item = buildCompressedUploadItem(base, fakeFile('photo.jpg', 300));
assert.equal(item.metadata.caption, 'a caption');
assert.equal(item.metadata.markedAsSpoiler, true);
});
// getImageMsgContent itself is not covered here: it needs a DOM (loadImageElement).
// Its encInfo branch (content.file vs content.url) is exercised by the sibling
// msgContent.test.ts builders, which share the same shape.
+23
View File
@@ -1,5 +1,6 @@
import { IContent, MatrixClient, MsgType } from 'matrix-js-sdk';
import to from 'await-to-js';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import {
IThumbnailContent,
MATRIX_BLUR_HASH_PROPERTY_NAME,
@@ -43,6 +44,28 @@ const generateThumbnailContent = async (
return thumbnailContent;
};
/**
* Build the synthetic upload item for a *re-encoded* (compressed) image.
*
* The compressed bytes are a brand new payload, so the item must never inherit
* the original's `encInfo` — that key/iv/sha256 describes the pre-compression
* ciphertext and would make receivers fail to decrypt. In an encrypted room the
* caller re-runs `encryptFile` and passes the new ciphertext + encInfo here; in
* an unencrypted room both are omitted and the item carries no `encInfo` at all.
*/
export const buildCompressedUploadItem = (
item: TUploadItem,
compressedFile: File,
encrypted?: { file: File; encInfo: EncryptedAttachmentInfo },
): TUploadItem => ({
...item,
// `file` is what gets uploaded/described, `originalFile` is the plaintext used
// for dimensions + blurhash.
file: encrypted?.file ?? compressedFile,
originalFile: compressedFile,
encInfo: encrypted?.encInfo,
});
export const getImageMsgContent = async (
mx: MatrixClient,
item: TUploadItem,
@@ -18,6 +18,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import {
getNotificationModeActions,
getNotificationModeOptionsFromActions,
NotificationMode,
useNotificationModeActions,
} from '../../../hooks/useNotificationMode';
@@ -131,7 +132,13 @@ type RuleModeSwitcherProps = {
function RuleModeSwitcher({ kind, pushRule }: RuleModeSwitcherProps) {
const mx = useMatrixClient();
const getModeActions = useNotificationModeActions();
// Preserve any `highlight`/custom sound tweak already on the rule — otherwise
// switching mode here rebuilds actions from scratch and silently drops them.
const options = useMemo(
() => getNotificationModeOptionsFromActions(pushRule.actions),
[pushRule.actions],
);
const getModeActions = useNotificationModeActions(options);
const handleChange = useCallback(
async (mode: NotificationMode) => {
+42
View File
@@ -0,0 +1,42 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { PushRuleActionName, TweakName } from 'matrix-js-sdk';
import { getNotificationModeOptionsFromActions } from './useNotificationMode';
test('getNotificationModeOptionsFromActions: no tweaks -> no highlight, no sound value', () => {
const options = getNotificationModeOptionsFromActions([PushRuleActionName.Notify]);
assert.deepEqual(options, { soundValue: undefined, highlight: false });
});
test('getNotificationModeOptionsFromActions: picks up highlight: true', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.equal(options.highlight, true);
});
test('getNotificationModeOptionsFromActions: highlight: false is not treated as set', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Highlight, value: false },
]);
assert.equal(options.highlight, false);
});
test('getNotificationModeOptionsFromActions: picks up a custom sound value', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
]);
assert.equal(options.soundValue, 'ping.ogg');
});
test('getNotificationModeOptionsFromActions: picks up both tweaks together', () => {
const options = getNotificationModeOptionsFromActions([
PushRuleActionName.Notify,
{ set_tweak: TweakName.Sound, value: 'ping.ogg' },
{ set_tweak: TweakName.Highlight, value: true },
]);
assert.deepEqual(options, { soundValue: 'ping.ogg', highlight: true });
});
+23
View File
@@ -49,6 +49,29 @@ export const getNotificationModeActions = (
return actions;
};
// Derive the options that would reproduce an existing rule's tweaks, so a mode
// switch rebuilds actions on top of them instead of silently dropping a
// `highlight` tweak (or a custom sound) that isn't part of the mode itself.
export const getNotificationModeOptionsFromActions = (
actions: PushRuleAction[],
): NotificationModeOptions => {
const soundTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Sound,
);
const highlightTweak = actions.find(
(action) => typeof action === 'object' && action.set_tweak === TweakName.Highlight,
);
return {
soundValue:
soundTweak && typeof soundTweak === 'object' && typeof soundTweak.value === 'string'
? soundTweak.value
: undefined,
highlight:
!!highlightTweak && typeof highlightTweak === 'object' && highlightTweak.value !== false,
};
};
export type GetNotificationModeCallback = (mode: NotificationMode) => PushRuleAction[];
export const useNotificationModeActions = (
options?: NotificationModeOptions,
+1 -1
View File
@@ -51,7 +51,7 @@ import {
MuteTimerEntry,
loadMuteTimers,
unmuteRoom,
} from '../../features/room-nav/RoomNavItem';
} from '../../features/room-nav/muteTimers';
import { STATUS_EXPIRY_KEY, STATUS_MSG_KEY } from '../../features/settings/account/Profile';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
@@ -0,0 +1,49 @@
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,6 +12,13 @@ export async function scheduleMessage(
content: IContent,
sendAtMs: number,
): 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,
// tested contract; the ScheduleMessageModal already guards ≥60s in the future.
const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));