- MLocation "Open in OpenStreetMap" permalink built its URL from the raw parseGeoUri strings (location.latitude/longitude) while the embedded map iframe used the parseFloat + isFinite-validated lat/lon. Use lat/lon in the permalink too, so a malformed geo: substring can't reach the URL (they're already proven finite a few lines above and used identically in mapSrc). - LOTUS_FEATURES claimed the Policy List Viewer has "Subscribe (join) / unsubscribe (leave) controls for each list" and lists subscribed lists. Verified against PolicyListViewer.tsx: it's a room-ID/alias input viewer that displays a joined policy room's rules read-only — no subscribe controls, no subscribed-lists listing. Corrected the doc to match. Two low-tail bug-hunt findings from LOTUS_TODO. Gate-green (tsc, eslint, prettier, build). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
99 KiB
Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1. Last updated: July 2026.
Table of Contents
- Branding & Identity
- LotusGuild Terminal Design System (TDS) v1.2
- Animated Chat Backgrounds (P5-4)
- Seasonal Theme Overlays (P5-12)
- Avatar Decorations (P5-13/P5-14)
- Glassmorphism Sidebar (P5-3)
- Night Light / Blue Light Filter (P5-5)
- Voice / Video Call Improvements
- Per-Message Read Receipts
- Delivery Status Indicators
- Messaging Enhancements
- Threads (P3-8)
- Presence
- UX & Composer
- Room Customization
- Moderation
- Notifications
- Server Integration
- Infrastructure
- Desktop App Features
- Key Custom Files
Branding & Identity
- Package renamed to
lotus-chat; description updated inpackage.json - App title set to "Lotus Chat" throughout (window title, meta tags, manifest)
- Favicon, PWA icons (all sizes), and Apple touch icons replaced with
Lotus.png - Lotus logo displayed in the About dialog and Auth page
- Auth footer: dynamic version pulled from
package.json; links tolotusguild.org,chat.lotusguild.org, andmatrix.lotusguild.org - Welcome page tagline: "A Matrix client for Lotus Guild"
- Encryption key export filename changed to
lotus-keys.txt manifest.jsonupdated with Lotus name, description, and branding colors
LotusGuild Terminal Design System (TDS) v1.2
Dark Mode — LotusTerminalTheme
A CRT terminal aesthetic applied globally when the TDS theme is active.
Visual effects:
- Scanline overlay via repeating
linear-gradientpseudo-element - Vignette via radial-gradient overlay
- Phosphor glow on text and accents via
text-shadow/box-shadow
Color palette:
| Token | Value | Role |
|---|---|---|
--lt-bg |
#030508 |
Page/panel background |
--lt-accent-orange |
#FF6B00 |
Primary accent |
--lt-accent-cyan |
#00D4FF |
Secondary accent |
--lt-accent-green |
#00FF88 |
Success / active states |
--lt-text |
#c4d9ee |
Body text |
Typography & chrome:
- Monospace font stack applied to all UI elements
- Terminal-style scrollbars (thin, accent-colored track)
Decorative patterns:
- Custom hex-grid CSS background pattern
- Circuit-board CSS background pattern (switchable)
Boot sequence:
- Matrix-style boot messages on the welcome page; press Escape to skip
- Implemented in
src/lotus-boot.ts
CSS variable family: all custom tokens use the --lt-* prefix, defined in src/lotus-terminal.css.ts.
Light Mode — LotusTerminalLightTheme
A full light-palette counterpart to the dark TDS theme.
Color palette:
| Token | Light Value | Role |
|---|---|---|
--lt-bg |
#edf0f5 |
Page/panel background |
--lt-accent-orange |
#c44e00 |
Primary accent |
--lt-accent-cyan |
#0062b8 |
Secondary accent |
--lt-accent-green |
#006d35 |
Success / active states |
--lt-text |
#111827 |
Body text |
Differences from dark mode:
- CRT effects (scanlines, vignette, phosphor glow) are disabled
- Scoped to
html[data-theme="light"] body.lotusTerminalBodyClassto avoid bleed into non-TDS themes ThemeManager.tsxis responsible for setting thedata-themeattribute on the<html>element when theme changes
Chat Background Patterns (20+ static)
A library of CSS-only background patterns for the chat area, all using CSS custom properties so they adapt automatically to both TDS dark and light palettes:
- Blueprint grid
- Carbon fiber
- Starfield
- Topographic contours
- Herringbone
- Crosshatch
- Chevron
- Polka dots
- Triangles
- Plaid
- (and additional variants)
Animated Chat Backgrounds (P5-4)
Five CSS-only animated wallpapers implemented with vanilla-extract keyframes. No <canvas> element is used — all animation is pure CSS.
Available Animations
Digital Rain Two-layer vertical stripe scroll with parallax effect. Wide stripes animate at 8s, narrow stripes at 4s, creating depth.
Star Drift Three-layer radial-gradient dot field drifting diagonally across the viewport. Each layer moves at a distinct speed and angle.
Grid Pulse
Neon grid lines that expand and contract via a backgroundSize keyframe. Grid color follows --lt-accent-cyan in dark mode.
Aurora Flow Four radial-gradient ellipses sweeping across a 200% canvas. Colors use the TDS green/cyan/orange palette and blend softly.
Fireflies
Three layers of warm glowing dots that drift slowly. Dot color follows --lt-accent-orange for a warm bioluminescent feel.
API
getChatBg(bg: ChatBg, isDark: boolean, pauseAnimations?: boolean): CSSProperties
Strips all animation properties from the returned style object when either pauseAnimations is true or the prefers-reduced-motion: reduce media query is active.
Settings Integration
A "Pause Background Animations" toggle is exposed in Settings → Appearance. The preference is persisted and read by getChatBg() at render time.
Animation Improvements (June 2026)
All five animated backgrounds were rewritten for smoother, more organic motion:
- Digital Rain — added a phosphor glow flicker (
animRainGlowKeyframe, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility - Star Drift — each of the three dot layers now moves by exactly its own tile width/height per cycle (
−130 px,−190 px,−260 px), eliminating the visible seam on loop - Grid Pulse — independent brightness oscillation (
animGridBrightnessKeyframe, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise - Aurora Flow — four gradient layers now have individual
backgroundSizevalues (200%,250%,300%,220%); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth - Fireflies — glow pulse (
animFirefliesGlowKeyframe, 2.3 sfilter: brightness) and opacity blink (animFirefliesBlinkKeyframe, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
Files
src/app/styles/Animations.css.ts— vanilla-extract keyframe definitionssrc/app/features/lotus/chatBackground.ts—getChatBg()implementation and pattern registry
Seasonal Theme Overlays (P5-12)
Decorative CSS-only overlays that activate automatically on holidays and events. Manually overrideable in Settings → Appearance → Seasonal Theme.
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 |
Implementation
SeasonalEffectcomponent mounted inApp.tsxatz-index: 9997(below night light, above content)- Auto-detection via
getActiveSeason(now: Date)— themes checked in priority order (New Year > Valentine's > … > Autumn) seasonalThemeOverridesetting:'auto' | 'off' | <theme-name>— persisted insettingsAtom- All particle animations gated on
prefers-reduced-motion: reduce— ambient overlays (tints, textures, shimmer) remain active
Files
src/app/components/seasonal/SeasonalEffect.tsx— theme detection, date ranges, all overlay componentssrc/app/components/seasonal/Seasonal.css.ts— vanilla-extract keyframes (fall, leaf, float-up, bob, glitch, burst, warp, scanline, shimmer, etc.)
Avatar Decorations (P5-13/P5-14)
Animated APNG overlay frames that float around user avatars, inspired by Discord's Avatar Decorations feature. Each decoration extends 8px beyond the avatar border on all sides, with a transparent center hole that reveals the avatar beneath. Other Lotus Chat users see your selected decoration in real time — stored in the Matrix profile via MSC4133.
Decoration Library
99 hand-curated, original-IP decorations (no licensed character artwork) organized into 9 categories:
| Category | Count | Highlights |
|---|---|---|
| Gaming | 13 | Slither 'n Snack, Joystick, Space Invaders, Gaming Headsets |
| Cyber | 9 | Cybernetic, Glitch, Digital Sunrise, Futuristic UI (3 colors) |
| Space | 8 | Black Hole, Constellations, Solar Orbit, Aurora |
| Fantasy | 22 | Kitsune, Phoenix, Glowing Runes, D&D dice, Crystal Balls |
| Elements | 7 | Fire, Water, Air, Earth, Lightning, Ki Energy |
| Japanese | 6 | Kabuto, Oni Mask, Sakura Warrior, Straw Hat |
| Nature | 12 | Lotus Flower, Koi Pond, Sakura, Fall Leaves, Fireflies |
| Spooky | 13 | Candlelight, Witch Hat, Ghosts, Jack-o'-Lantern |
| Cozy | 11 | Cozy Cat, Fox Hat (3 colors), Cat Ears, Frog Hat |
All decoration files are 256×256 APNGs. They animate natively in all modern browsers via <img> elements.
Architecture
Profile storage — MSC4133:
Decoration preference is stored in the public Matrix profile field io.lotus.avatar_decoration (a slug string, e.g. lotus_flower). Any Lotus Chat user viewing your profile sees your current decoration.
CDN:
Files are self-hosted on the Lotus Nextcloud instance. Direct access: https://drive.lotusguild.org/public.php/dav/files/{token}/cinny-decorations/{slug}.png. <img> elements load cross-origin freely — no CORS headers needed.
Module-level cache with in-flight deduplication:
useAvatarDecoration(userId) fetches the profile field once per user per session. A Map<userId, slug|null> cache prevents redundant requests; a second pending waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
Wrapping pattern:
AvatarDecoration renders a position: relative; display: inline-flex wrapper div. The decoration <img> is position: absolute with top/left/right/bottom: -8px, extending equally on all sides while the z-index: 10 keeps it above the avatar. onError hides the image if the CDN file is absent. This wrapper sits outside PresenceRingAvatar so the presence ring and decoration layer are fully independent.
Placement — Where Decorations Render
| Location | File |
|---|---|
| Message timeline | src/app/features/room/message/Message.tsx |
| Members drawer | src/app/features/room/MembersDrawer.tsx |
@mention autocomplete |
src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx |
| Inbox / notifications | src/app/pages/client/inbox/Notifications.tsx |
Settings — Decoration Picker
Settings → Account → Avatar Decoration shows a scrollable grid of all decorations, grouped by category. Each cell is a 52×52px button with a live preview of the APNG. The currently selected decoration gets a 2px cyan border. "No Decoration" clears the field. Changes are saved only when the "Save" button is clicked (visible only when a change is pending). After save, invalidateDecorationCache(userId) forces other components to re-fetch.
Catalog Sync Script
After deleting decoration files from the Nextcloud share, run:
npm run sync:decorations
The script (scripts/syncDecorations.mjs) sends HTTP HEAD requests to the CDN URL for every slug in avatarDecorations.ts and automatically removes entries for files that returned 404. Empty categories are pruned automatically. Review with git diff.
Files
src/app/features/lotus/avatarDecorations.ts— full catalog (DECORATION_CATEGORIES,ALL_DECORATIONS,decorationUrl(),DECORATION_CDN)src/app/hooks/useAvatarDecoration.ts— profile fetch, module-level cache,invalidateDecorationCache()src/app/components/avatar-decoration/AvatarDecoration.tsx— wrapper component with APNG overlaysrc/app/features/settings/account/ProfileDecoration.tsx— settings UI (picker grid, save button)scripts/syncDecorations.mjs— CDN sync script to prune deleted decorations from the catalog
Glassmorphism Sidebar (P5-3)
An optional frosted-glass sidebar style toggled in Settings → Appearance.
Implementation:
SidebarGlassvanilla-extract class appliesbackground: rgba(3, 5, 8, 0.55)andbackdropFilter: blur(12px)to the sidebar elementSidebarNav.tsxuses auseEffectto mirror the active chat background ontodocument.bodywhen the glassmorphism setting is enabled, so the blur filter has meaningful content to work through- Degrades gracefully on browsers without
backdrop-filtersupport (falls back to the semi-transparent background)
Night Light / Blue Light Filter (P5-5)
A warm orange overlay rendered over the entire UI to reduce blue light emission.
Implementation:
NightLightOverlaycomponent mounted directly inApp.tsx- CSS:
position: fixed; inset: 0; pointer-events: none; z-index: 9998 - Orange tint color with configurable opacity
- Controls: Toggle to enable/disable + intensity slider ranging from 5% to 80% opacity
- Schedule (auto on at night): an optional schedule with From/To time inputs; when on, the overlay only shows during that window and turns itself on/off automatically (the overlay re-checks every minute, no reload). Overnight windows that wrap midnight (e.g. 21:00 → 07:00) are handled. Window logic is the pure, unit-tested
isWithinTimeWindow/parseHHMMinsrc/app/utils/timeWindow.ts(timeWindow.test.ts). Defaults: 21:00–07:00. - Settings persisted via the standard Lotus settings store
Font Selector (P5-22)
Users can choose the UI font in Settings → Appearance:
- System Default —
system-ui, -apple-system, sans-serif - Inter —
'InterVariable', sans-serif(current default) - JetBrains Mono —
'JetBrains Mono', monospace(already loaded from Google Fonts) - Fira Code —
'Fira Code', monospace(added to Google Fonts preload inindex.html)
Applied by overriding --font-secondary on document.body via AppearanceEffects in App.tsx. The TDS terminal mode font stack is unaffected.
Custom @Mention Highlight Color (P5-21)
Users can set a custom background color for @mention chips that highlight their own name, in Settings → Appearance.
- Color picker (native
<input type="color">) with a Reset button to revert to the theme default - Text color (black/white) auto-computed from the chosen background's luminance for readability
- Applied via CSS custom properties
--mention-highlight-bg,--mention-highlight-text,--mention-highlight-borderset ondocument.body CustomHtml.css.tsuses these as CSSvar()fallbacks over the original foldsSuccesstoken colors
Voice / Video Call Improvements
🔱 [EC-FORK] LIVE (2026-06). Element Call is now our self-built fork (
@lotusguild/element-call-embedded@0.20.1-lotus.1, source atLotusGuild/element-call), served same-origin — no longer the upstream pre-built npm bundle. Several in-call behaviors below are now first-class source changes rather than DOM/widget hacks. Background, plan, and the Phase-2 work list are in the Element Call fork reference inLOTUS_TODO.md.
Element Call — Self-Built Fork (0.20.1-lotus.1)
The embedded widget was upgraded 0.16.3 → 0.19.4 → 0.20.1, then forked.
We self-build LotusGuild/element-call and publish it to our private Gitea npm
registry as @lotusguild/element-call-embedded; cinny consumes that instead of
@element-hq/element-call-embedded. The iframe prints
Element Call embedded-v0.20.1-lotus.1 in its console (vs. embedded-v0.20.1
upstream) — the quickest way to confirm a deploy landed the fork.
All custom behavior lives in the fork's src/lotus/ modules and is additive
and dormant by default, gated by URL flags / widget actions the host opts into,
so a stock EC config is byte-for-byte upstream behavior.
Active (cinny drives them today):
| # | Feature | Mechanism | Replaces (old hack) |
|---|---|---|---|
| A7 | Denoise in-source | ML noise suppression runs inside EC as a LiveKit TrackProcessor<Audio> (flag lotusDenoiseSource=1); re-applied on every (re)publish |
the build-time getUserMedia monkeypatch injected into index.html — removed. Fixes mic-dead-after-reconnect. |
| #2 | Speaking / mute events | EC emits io.lotus.call_state (throttled); cinny reads speaker + mute state from it (flag lotusCallState=1) |
scraping EC's DOM for [data-lk-speaking] (kept only as fallback) |
| A5 | Focus participant | host sends io.lotus.focus_participant to pin a tile, coexisting with / overriding the screenshare spotlight |
the .click()-the-tile DOM hack in CallControl.ts — removed |
| #6 | In-call avatar decorations | host pushes io.lotus.decorations (per-user APNG URLs); the fork renders them on EC's video-tile avatars |
previously impossible — decorations only showed on our pre-join lobby roster |
| #5 | Native transparent background | flag lotusTransparent=1 makes EC's surface transparent so the host wallpaper shows through |
the injected background:none !important CSS |
Now wired (cinny drives them — ⚠️ awaiting live verification):
| # | Capability | Widget action | cinny surface |
|---|---|---|---|
| P5-15 | Audio inject | io.lotus.inject_audio — plays a clip into the call as a separately published track |
In-Call Soundboard (uploadable clips) — see below |
| P5-31 | Quality controls | io.lotus.set_quality — sets audio/screenshare encoding bitrate/framerate |
Call Quality Controls (user settings + room-admin caps) — see below |
Both were dormant capabilities; cinny now drives them (armed via
lotusAudioInject=1). The only EC item still open is the P5-31 server-side quality guard (avoice-limit-guard-style sidecar readingio.lotus.room_quality) for hard enforcement across all Matrix clients — the client cap is best-effort.
In-Call Soundboard (P5-15)
A soundboard button (🔔) in the call controls bar opens a popout of the user's
clips. Clicking one injects it into the call as a real published LiveKit
track (every participant hears it, via the fork's io.lotus.inject_audio) and
plays it locally for the presser (LiveKit doesn't loop your own track back).
- User-uploadable, like custom emoji/sticker packs. Clips are stored in the
io.lotus.soundboardaccount data event, so they sync across all your devices. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete inline. - Authenticated media can't be fetched from the widget's realm, so the host
resolves each mxc clip → an authenticated download → a same-session
blob:object URL and hands that to the widget. - Gated by the Soundboard toggle (Settings → General → Calls) with a volume slider. The button is hidden when disabled.
- Files:
utils/soundboardClips.ts,hooks/useSoundboard.ts,features/call/CallSoundboard.tsx,plugins/call/CallControl.ts#injectAudio.
Call Quality Controls (P5-31)
Discord-style encoding controls applied to the local tracks via the fork's
io.lotus.set_quality (RTCRtpSender.setParameters across all simulcast
encodings, re-applied on every re-publish/reconnect).
- User settings (Settings → General → Calls): Microphone Bitrate, Screenshare Bitrate, Screenshare Framerate (each defaults to Auto).
- Room-admin caps: admins set a ceiling in Room Settings → General → Voice
(
io.lotus.room_qualitystate event); every Lotus client clamps its per-user quality tomin(user setting, room cap). - Applied by the
useCallQualityhook on join and whenever settings/caps change;utils/callQuality.tsbuilds the payload (unit-tested).
Server-enforced call permissions (hard, ALL clients). The same
io.lotus.room_quality event carries a publish-source policy
(allow_screenshare, allow_camera) enforced server-side by
voice-limit-guard (matrix repo, LXC 151): it re-signs the LiveKit JWT's
canPublishSources, so the SFU refuses screenshare/camera tracks for every
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
these in Room Settings → Voice → Call Permissions; cinny also hides the
blocked buttons in the call bar. Enforcement is live: the JWT re-sign covers
new joins, and a background reconcile loop revokes an in-progress
screenshare/camera (via LiveKit UpdateParticipant) within ~3 s of an admin
flipping the policy — so it kills active shares mid-call, not just future ones.
- Why numeric caps aren't server-enforced: LiveKit is a pure SFU (forwards,
never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
grant, room config, server
limit:, or admin API; stock Element Call ignores room metadata for publish quality. Numeric caps are therefore inherently cooperative — our fork honors them, which is the design above. The publish-source policy is the one genuine hard, cross-client lever, and it's implemented. - Not yet: screenshare resolution control (needs a
getDisplayMediahook in the fork).
Camera Default Off
Camera starts disabled on join. The cameraOnJoin setting is explicitly opt-in and is not persisted to localStorage between sessions, preventing the previous behavior where a prior-session preference could unexpectedly enable the camera.
UI Fixes
- Deafen button — corrected tooltip text that was previously swapped with the mute tooltip
- Screenshare confirmation — a confirmation dialog is shown before initiating a screenshare broadcast to the room
- Auto-revert spotlight on screenshare — removed; the 600ms grid-revert click was causing fullscreen screenshare to show avatar tiles instead of the screen. EC now handles layout natively.
Push to Talk (PTT)
- Configurable keybind; defaults to
Space - Visual indicator shown in both TDS and non-TDS themes while PTT is active
- Event listener attached to both the main window and the Element Call iframe's
contentWindowto ensure reliable capture regardless of focus - Mic state is preserved correctly when switching into or out of PTT mode
Push to Deafen
M key triggers toggleSound() in CallControls.tsx, toggling the deafen state without requiring a mouse click.
AFK Auto-Mute in Voice (P5-11)
Automatically mutes the microphone after a configurable period of microphone-on silence.
Implementation:
useAfkAutoMute(callEmbed)hook opens a separate monitoring-onlygetUserMediastream (independent of Element Call's stream) and analyzes it viaAudioContext+AnalyserNode- RMS level is sampled every 500ms; if it stays below threshold while the mic is on, the silence timer starts
- After the configured timeout (
afkTimeoutMinutessetting),callEmbed.control.setMicrophone(false)mutes the mic and an in-app toast is shown - Monitoring stream and
AudioContextare fully cleaned up on unmount (no resource leak) - Activated inside
CallControlsviauseAfkAutoMute(callEmbed)— no changes required toCallEmbedor Element Call
Settings (Settings → Calls):
- AFK Auto-Mute toggle (default: off)
- Idle Timeout dropdown — 1 / 5 / 10 / 20 / 30 minutes (shown only when enabled; default: 10 minutes)
Hook: src/app/hooks/useAfkAutoMute.ts
Voice Channel User Limit (P5-10)
Room admins can cap the number of participants allowed in a room's voice call. The cap is a hard, server-side limit enforced for every Matrix client (Element, FluffyChat, …), backed by a client-side UX layer in Lotus Chat.
Client (this repo):
- Limit is stored in the
io.lotus.voice_limitroom state event with content{ max_users: N }(0 / absent = no limit) RoomVoiceLimitcomponent in Room Settings → General → Voice lets admins set the cap with a number input. Editing is gated bypermissions.stateEvent(StateEvent.LotusVoiceLimit, …), so only users withstate_defaultpower (or above) can change itCallPrescreen(CallView.tsx) reads the limit reactively viauseStateEventand compares it against the liveuseCallMemberscount; at capacity the Join button is disabled and a "Channel Full (N/N)" message is shown- A user already in the session (rejoining) is never blocked — only new joiners are gated
Files: src/app/features/common-settings/general/RoomVoiceLimit.tsx, src/app/features/call/CallView.tsx, StateEvent.LotusVoiceLimit in src/types/matrix/room.ts
Server (the hard backstop — matrix repo livekit/voice-limit-guard.py):
- Every client must fetch a LiveKit JWT from
lk-jwt-servicebefore joining a call. A fail-open guard sidecar sits in front of it (guard on:8070, lk-jwt-service moved to:8071) - On each token request the guard reads the room's
io.lotus.voice_limit(Synapse admin API), and if the room is at capacity it returns403so the client cannot obtain a token and therefore cannot join — regardless of which client they use - Distinct Matrix users are counted via LiveKit
ListParticipants; rejoins / extra devices are allowed. Any failure fails open so calls never break
The client-side "Channel Full" check is UX/early-feedback; the server guard is the actual enforcement.
Custom Join / Leave Sound Effects (P5-16)
A local sound plays when another participant joins or leaves a call you're in.
Implementation:
useCallJoinLeaveSounds(embed)hook (wired inCallUtilsinsideCallEmbedProvider) listens toMatrixRTCSessionmembership changes viauseCallMembersChange- Membership identity is tracked by
sender|deviceId; a snapshot is taken when the session (re)starts so participants already present never trigger a sound - Your own membership is filtered out (
mx.getSafeUserId()prefix), and sounds fire only while you are actually joined (useCallJoined) - Sounds are synthesized in-browser with the Web Audio API (
OscillatorNode+ envelope) — no audio assets to bundle. Join uses a rising motif, leave a falling one - Three styles: Chime (sine), Soft (triangle), Retro (square arpeggio), plus Off
Settings (Settings → Calls):
- Join & Leave Sounds dropdown — Off / Chime / Soft / Retro (default: Chime). Selecting a style previews the join sound immediately
Files: src/app/utils/callSounds.ts, src/app/hooks/useCallJoinLeaveSounds.ts
Noise Suppression (Advanced Multi-Tier) (P5-30)
A comprehensive mic noise-suppression system in Settings → General → Calls designed for high-end hardware and detailed performance testing.
| Tier | Description |
|---|---|
| Off | No suppression applied. |
| Browser-native | Google NSNet2 (WebRTC built-in). Best general performance/CPU balance. |
| ML (Advanced) | Custom ML pipeline supporting multiple models, series suppression, and gates. |
Advanced Features & Test Options:
- Multiple ML Models: Four in-source models, selectable from a dropdown ordered by quality/CPU (best first): DeepFilterNet 3 (48 kHz, best), DTLN (16 kHz), RNNoise (48 kHz), Speex (48 kHz, lightest). The tier default is Browser-native; when a user opts into ML the default model is DeepFilterNet 3.
- Series Suppression (Combination): Optional toggle to run the browser's native stationary noise filter before the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
- Noise Gate: Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
- Live Microphone Meter: A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
- High-Fidelity Capture: Captures at hardware native rates (supporting high-end gear like Scarlett Solo + PodMic) and handles high-quality resampling via Web Audio to prevent the "static" artifacts caused by low-quality browser pre-resamplers.
- Performance: Automatic WASM SIMD detection with transparent fallback to standard binaries.
- Support Detection: UI now detects
AudioWorklet/AudioContextsupport and disables ML options in unsupported environments. - Status Reporting: The ML shim notifies the host app via
postMessage. If initialization fails, a system toast alerts the user of the fallback to the raw microphone.
Open-Source Models (all now in-source in the EC fork):
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
|---|---|---|---|---|
| DeepFilterNet 3 (ML default) | Excellent | Very High | 25-50%+ | 48 kHz |
| DTLN | Good | High | 10-20% | 16 kHz |
| RNNoise | Poor | Moderate | < 5% | 48 kHz |
| Speex | Poor | Low | < 5% | 48 kHz |
Update (2026-06): with the EC fork live, denoise runs inside Element Call as a LiveKit
TrackProcessorand all four models ship in-source (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic rather than ever going silent). The model picker selects between them.
Update (2026-07) — quality, reliability & AEC/AGC:
- Quality tuning (addresses the "robotic/underwater" RNNoise reports): a dry/wet attenuation floor (default ~-16 dB) blends a little raw mic under the denoised signal so suppression can't fully collapse the noise floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN would comb-filter, so they rely on their own level. The noise gate now runs after the ML stage, and DeepFilterNet 3 level 80 → 60. Tunable via the
lotusDenoiseFloorparam.- AEC/AGC: browser echo cancellation stays ON, but the ML tier now sets auto gain control OFF (
autoGainControl=false) so the browser's dynamic gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote playback stays on standard elements — no AEC-defeat vector.)- Reliability: never-silent watchdog (auto-resume a suspended context),
resume()timeout (no track-lock deadlock), rejected-WASM-fetch eviction (transient failures recover), activation off the local participant (works solo), and init/build-failure leak fixes.- Real-call audio-quality A/B (model choice, floor value, AGC on/off) is the open by-ear validation item — see
LOTUS_TESTING.md§D2-1.
Files
- EC fork
src/lotus/lotusDenoise.ts+lotusDenoiseProcessor.ts— in-source LiveKitTrackProcessor(RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated bylotusDenoiseSource=1. (The old build-timegetUserMediashimbuild/lotus-denoise.jsis removed.) vite.config.js—lotusDenoise()plugin (now only copies model assets for the fork to load; no longer injects a shim)src/app/plugins/call/CallEmbed.ts— advanced tier →lotusDenoiseSourcewidget URL paramsrc/app/utils/lotusDenoiseUtils.ts— support detection and model comparison metadatasrc/app/features/settings/general/General.tsx— advanced settings UI + mic meter
Call Button Scoping
The call button is shown only in DMs and invite-only rooms that do not have an m.space.parent event. It is hidden in public rooms and space channels to avoid accidental broadcast calls.
Picture-in-Picture (PiP)
- 280×158px floating window that stays on screen when navigating away from the call room
- Draggable via pointer events with a 5px movement threshold to distinguish drags from clicks; touch events are supported
- Clicking the PiP window navigates back to the call room
- Implemented with an imperative
useEffectstyle for position overrides becauseuseCallEmbedPlacementSyncwrites geometry directly onto the DOM element, making declarative approaches unreliable
Call Embed Positioning
- Uses
getBoundingClientRect()(viewport-relative) instead of the previousoffsetTop/offsetLeft(parent-relative) calculations, fixing misalignment in scrolled layouts - Position is synced on mount via
useEffectwith aResizeObserverto handle dynamic layout changes
Dark Mode in Element Call
applyStyles() injects :root { color-scheme: dark | light } into the Element Call iframe after the user joins. The theme is also updated when setTheme() is called, keeping the call UI in sync with the Lotus theme selection.
Call Embed Wallpaper
The active chatBackground pattern is applied to the div[data-call-embed-container] wrapper. The iframe's html, body are forced to background: none !important so the host pattern shows through.
TDS Typing Indicator
The animated typing indicator dots use var(--lt-accent-orange) as their color when the TDS theme is active, matching the terminal aesthetic.
Per-Message Read Receipts
A full per-message read receipt system showing exactly who has seen each message.
Core Hook — useRoomReadPositions
useRoomReadPositions(room: Room): Map<eventId, userId[]>
- Listens to
Room.localEchoUpdatedandRoomMember.typingevents to stay reactive - Debounced 150ms to avoid excessive re-renders during rapid receipt updates
- Located at
src/app/hooks/useRoomReadPositions.ts
nearestRenderableId()
A utility that walks backward through the event timeline from a given event ID to find the nearest event that is actually rendered (skipping reactions, edits, and other non-display events). Used to map a user's read position to a visible message.
ReadPositionsContext
React context at src/app/features/room/ReadPositionsContext.ts that provides the positions map to all timeline components without prop drilling.
ReadReceiptAvatars
A pill of overlapping 24px user avatars displayed at the bottom-right of each message. Shows a maximum of 5 avatars; additional readers are shown as an overflow count (e.g., +3).
"Seen by" Modal — EventReaders
Clicking the avatar pill opens a modal (src/app/components/event-readers/EventReaders.tsx) listing:
- User avatar
- Display name
- Formatted timestamp of when the receipt was recorded
- Respects the
hour24Clocksetting for timestamp formatting
Delivery Status Indicators
Visual feedback on message delivery state, shown on the sender's own messages:
| State | Indicator |
|---|---|
| Sending (local echo) | ⟳ rotating clock icon |
| Sent (server ACK received) | ✓ checkmark |
| Failed | ✕ in red; orange glow in TDS |
The indicator is hidden once the server confirms the event (when the internal status transitions to null), keeping the timeline clean for settled messages.
Messaging Enhancements
Rich Room Topics
- Topic
formatted_bodyis rendered viasanitizeCustomHtml+html-react-parser, supporting bold, italic, links, and other inline HTML - The room header shows a plain-text preview of the topic
- Clicking the topic preview opens a full modal with the formatted body
- The room settings topic editor includes a formatting toolbar with B, I,
S, andcodebuttons
Edit History Viewer
EditHistoryModal.tsx fetches and displays the full edit history of a message.
- API:
GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.replace - E2EE fix: the "Original" entry uses
getClearContent()to retrieve the decrypted content rather than the encrypted payload - Word-level diff: a "Highlight changes" toggle (on by default) renders each edit as a word diff against the previous version — added words highlighted (green), removed words struck-through (red) — using semantic
<ins>/<del>. Toggle off to see full text (formatted messages render rich there; the diff is plain-text only). Diff logic is the pure, unit-testeddiffWords(LCS) insrc/app/utils/textDiff.ts. - Accessible from the message context menu
Inline GIF Preview
- Detects Giphy and Tenor share URLs via pattern matching on the message body
- Renders matched URLs as
<img loading="lazy">inline in the message - URL is proxied through the Matrix
/_matrix/media/v3/preview_urlendpoint to avoid mixed-content issues
GIF Picker
- Giphy-powered picker accessible from the composer toolbar
- The button is only shown when
gifApiKeyis set inconfig.json - Selected GIFs are sent as
m.imageevents - Recently used: a "Recent" row at the top of the picker (shown on the default view, hidden while searching) surfaces the GIFs you last sent for one-click re-sending — no re-searching. Persisted in localStorage (
cinny_recent_gifs_v1), deduped by url, most-recent-first, capped at 16, via the pure/unit-testedaddRecentGif(src/app/state/recentGifs.ts). - Picker UI is styled with TDS variables when the TDS theme is active
- Located at
src/app/components/GifPicker.tsx
Sticker Picker — Recently used
The sticker tab of the shared EmojiBoard now has a "Recent" group (a sidebar
RecentClock icon + top group), matching the emoji and GIF pickers — the stickers you last sent
surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at
least one sticker (hidden otherwise). Persisted in localStorage (cinny_recent_stickers_v1),
deduped by url, most-recent-first, capped at 16, via the pure/unit-tested addRecentSticker
(src/app/state/recentStickers.ts). Recent entries are rebuilt into minimal PackImageReaders
(StickerItem only needs url/shortcode/body) so they render and re-send exactly like pack
stickers. Recorded on select for both the grouped and search paths (shared delegated click).
Message Forwarding
Context menu → Forward allows forwarding a message to any room the user is a member of.
Copy Message Text
Context menu → Copy Text copies a message's plain-text body to the clipboard (reply fallback stripped via trimReplyFromBody), complementing the existing Copy Link (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
On-Device Message Translation
Translate chat messages written in other languages into a language you choose, inline in the timeline — running entirely on your device so message text never leaves it.
- Per-message translate — a foreign-language message shows a Translate action in its message menu; once translated, the message displays an inline "Translated from <language> · Show original" toggle that swaps between the translation and the original text.
- Fully on-device / E2EE-preserving — translation and language detection run through the browser's built-in Translator and Language Detector APIs (the Chromium on-device AI translation models). Message text is never sent to any cloud translation service — no Google / DeepL / Microsoft, not even a self-hosted server — and there is no network fallback, so end-to-end encryption is preserved. That privacy guarantee is the whole point of the feature.
- Automatic detection — the language of each message is detected automatically; messages already in your target language are skipped (no Translate action is shown).
- Settings (Settings → General → Messages):
- Translate Messages Into — your target language (default English; ~26 common languages).
- Auto-translate Incoming Messages (default off) — automatically translates foreign-language messages whose on-device language model is already downloaded.
- One-time model download — the first time you translate from a given language, a small on-device model (a few MB) downloads once. Because the browser requires a user gesture for that first download, the initial translation needs a click.
- Availability — Chromium desktop browsers (Chrome / Edge 138+) and the Lotus desktop app (WebView2 / Chromium). Not available in Firefox, Safari, or mobile browsers; where the APIs are unavailable the feature hides itself and the settings tile shows a note.
Draft Persistence
- Composer drafts are stored in
localStoragekeyed byroomId - Draft is cleared on successful send
- The Jotai atom is the primary source of truth;
localStorageis only read on room mount - Room-nav draft indicator: a subtle green dot (the composer's shared
DraftDot,color.Success.Main) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. The dot reuses the composer draft affordance's vocabulary (role="img", aria-label "Unsent draft"). It reacts to the shared draft atom via a memoizedselectAtom(…, hasMsgDraft)(re-renders only when the flag flips; the atom is written on room-leave, not per keystroke).useHydrateMsgDrafts(mounted inClientNonUIFeatures) pre-fills the draft atoms fromdraft-msg-*localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-testedhasMsgDraft(src/app/utils/draft.ts), also used by the composer'sDraftIndicator.
Message Search Date Range
- The search panel accepts
from_tsandto_tsvalues (epoch milliseconds) passed to the search API - A chip shows the active date range with an × button to clear it
Encrypted Search Cache (P4-8, opt-in)
Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.
- Raw IndexedDB (
lotus-search-cache): message rows keyed[roomId, eventId]+ per-room coverage markers; merged into local search results with in-memory-wins dedupe - Opt-in, default OFF (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
- Files:
src/app/utils/searchCache.ts,src/app/state/searchCacheEnabled.ts,features/message-search/useLocalMessageSearch.ts
Math / LaTeX Rendering (P4-4)
KaTeX-rendered math in messages, two paths:
- Spec path (CS-API §11.5):
<span/div data-mx-maths="…">informatted_bodyrenders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead - Plain-text path:
$…$(inline) and$$…$$(block) with conservative rules — escape-aware (\$), currency-guarded ($5 and $10stays text), never insidecode/pre - Outgoing interop: on send, the composer converts
$…$/$$…$$to spec<span/div data-mx-maths>HTML informatted_body(extracted before markdown so LaTeX isn't mangled; off inside code), so math renders on Element and every other client — not just Lotus. The plainbodykeeps literal$…$as the fallback - KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
- Files:
src/app/utils/mathParse.ts(+14 tests),components/math/KaTeX.tsx,plugins/react-custom-html-parser.tsx(render),components/editor/output.ts(+output.test.ts, outgoing)
Image / Video Captions
Images and videos can be sent with a caption. The caption and media are sent as a single event (caption = the event body when it differs from filename).
- Edit caption: an image/video you sent shows an Edit caption action (quick-actions pencil + message menu). It opens the message editor seeded with the current caption; saving sends an
m.replacewhosem.new_contentpreserves the media (url/info/encryptedfile/filename) and only changesbody/formatted_body. An empty caption removes it (bodyfalls back tofilename). Gated bycanEditCaption(utils/room.ts) to your own image/video messages that carry an MSC2530filename. Caption edits carrym.mentions(an @-mention in a caption notifies) and appear in Edit History (diffed by the word-diff) — even after a caption is removed, the "(edited)" marker remains so history stays reachable. No re-upload — encrypted media keeps its original file/key.
Location Sharing
m.location events render an inline map tile using the coordinates from the event content. The renderer reads the top-level geo_uri and falls back to the MSC3488 org.matrix.msc3488.location/m.location uri, so locations from clients that send only the new shape still render (previously they showed as broken); an MSC3488 description, if present, is shown above the coordinates.
Sharing your location (composer → location button) sends an MSC3488-compliant m.location event: the legacy geo_uri plus the org.matrix.msc3488.location (uri), org.matrix.msc3488.asset (m.self), and org.matrix.msc3488.ts/m.ts blocks, and a human-readable body. This makes Lotus-shared locations render as proper pins on Element and other clients instead of falling back to plain text.
Deleted Message Placeholders
Redacted events display "This message has been deleted" along with the redaction reason if one was provided, rather than leaving a blank gap in the timeline. This is a one-line change in the eventRenderer filter.
Message Bookmarks
- Bookmarks are stored in
io.lotus.bookmarksaccount data, syncing across all devices - Maximum of 500 bookmarked entries
BookmarksPanel.tsxis a sidebar panel accessible from the navigation rail- Live-renders edits/redactions, text search, jump-to-message, and remove
- Author attribution: each saved-message card shows who wrote it (
{sender} · {time ago}). The author is snapshotted at save time (senderId/senderNameon the bookmark, optional for backward compatibility) and re-resolved live from the event when the room is joined; search also matches the author name. - Sort & group: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (
cinny_bookmarks_sort_v1). Ordering/grouping logic is pure and unit-tested insrc/app/utils/bookmarks.ts(bookmarks.test.ts). - Hook:
src/app/hooks/useBookmarks.ts
Message Scheduling
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
ScheduleMessageModal.tsxprovides the date/time picker UI- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual send-now, edit, and cancel buttons
- Send now: the tray's send button fires a pending message immediately via MSC4140
action: 'send'(the server dispatches the stored delayed event now, as a normal timeline event — no cancel+retype). The row is pruned only once the server confirms; a failed send leaves an inline "Could not send now" error with the message still sendable/editable/cancellable. - Edit / reschedule: the tray's edit button re-opens
ScheduleMessageModal(seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomesm.text). - Utilities in
src/app/utils/scheduledMessages.ts
Message Reminders
- Message context menu → Remind Me sets a personal reminder to revisit a message; reminders are stored in
io.lotus.remindersaccount data (sync across devices) viauseReminders, and fire fromClientNonUIFeatures. RemindMeDialog.tsxoffers quick presets (in 20 min / 1 hour / 3 hours / tomorrow 9am) plus a "Custom time…" option that reveals date + time pickers for an arbitrary reminder time (validated to be ≥ 1 minute in the future).- Manage existing reminders: opening the dialog on a message that already has reminders lists them (soonest first, friendly time via
formatFriendlyDateTime) each with a cancel (×) button, so you can see and remove pending reminders instead of silently stacking duplicates. - The date/time input helpers (
toLocalDate,toLocalTime,parseLocalDateTime,pickerInputStyle) are shared, pure, and unit-tested insrc/app/utils/datetimeInput.ts(datetimeInput.test.ts) — also used byScheduleMessageModal(deduped from a prior inline copy).
File Upload Compression (opt-in)
- Implemented in
UploadCardRenderer.tsx - Uses the Canvas API:
canvas.toBlob(callback, 'image/jpeg', 0.82)for compression - A
Switchtoggle in the upload preview UI lets the user opt in per upload - Shows before and after file sizes so the user can evaluate the tradeoff
- Works on all image types except SVG (which cannot be drawn to canvas)
- On send, the original uncompressed MXC URL is deleted from the media store
- On cancel, any orphaned MXC from a prior upload is cleaned up via
tryDeleteMxcContent()
Richer URL Preview Cards
UrlPreviewCard.tsx implements 13 domain-specific card layouts:
| Domain | Layout |
|---|---|
| YouTube | Thumbnail, title, channel, duration |
| Vimeo | Thumbnail, title, author |
| GitHub | Repo name, description, stars/forks/language |
| Twitter / X | Avatar, display name, handle, tweet body |
| Subreddit, post title, score, comment count | |
| Spotify | Album art, track/album/playlist name, artist |
| Twitch | Stream thumbnail, streamer, game, viewer count |
| Steam | Header image, game name, price, rating |
| Wikipedia | Article title, extract excerpt |
| Discord | Server name, invite metadata |
| npm | Package name, version, description, weekly downloads |
| Stack Overflow | Question title, vote/answer count, tags |
| IMDb | Poster, title, year, rating |
Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unparseable preview responses are suppressed entirely rather than showing a blank card.
Inline Media Embeds
Media links play/render in place instead of opening a browser tab. A pure
resolver, parseMediaEmbed(url, host) in src/app/utils/videoEmbed.ts, maps a
URL to { provider, kind, embedUrl }; MediaEmbedCard / TikTokEmbedCard /
TwitterCard in UrlPreviewCard.tsx render it. Four render kinds:
| kind | shape | providers |
|---|---|---|
landscape |
16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
portrait |
9:16 video player | YouTube Shorts, TikTok |
audio |
fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
rich |
self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
Privacy-friendly facade. The tile first shows the homeserver's cached
og:image thumbnail + a play button; the third-party <iframe> is only mounted
on click, so nothing hits Google/Meta/etc. until the user opts in. A Close
button collapses a playing embed back to the facade, and video players carry a
Fullscreen control. Cookie-less/DNT variants are used where offered
(youtube-nocookie.com, Vimeo dnt=1).
Provider notes.
- TikTok — short "copy-link" URLs (
vm.tiktok.com,tiktok.com/t/…) carry no video id and the homeserver preview is bot-walled, soTikTokEmbedCardresolves the id client-side via TikTok's CORS-enabled oEmbed API on click (AbortController-guarded), then plays theplayer/v1embed. - Reddit / Instagram / Bluesky / X — post embeds self-size via
postMessage;useIframeAutoHeightlistens scoped to each provider's origin and our own iframe, parsing each provider's height shape (InstagramMEASURE, Redditresize.embed, Twittertwttr.private.resize).redd.itshort links resolve to the subreddit-lessembed.reddit.com/comments/{id}/route. - Vimeo unlisted-video privacy hashes (
vimeo.com/{id}/{hash}) and channel/group/album forms are parsed; YouTube handles/watch,youtu.be,/embed,/live,/shorts, andm./music.youtube.com.
Defense-in-depth. Every embed iframe carries a sandbox that omits
allow-top-navigation (so a compromised embed can't redirect the whole app —
phishing guard) on top of the CSP frame-src allowlist. Previews are also capped
at 6 per message.
Setting. inlineMediaEmbeds (Settings → General → "Inline Media Players",
default on). Off → media links fall back to plain link tiles.
Latent web bug fixed along the way. YouTube thumbnails now come from the
homeserver og:image instead of img.youtube.com — which was silently broken on
the web build (nginx img-src has no YouTube host) — removing a pre-click Google
request as a bonus.
CSP. Desktop Tauri frame-src (cinny-desktop tauri.conf.json) and the web
nginx frame-src allowlist enumerate every embed host (youtube-nocookie,
player.vimeo, geo.dailymotion, streamable, player/clips.twitch, open.spotify,
w.soundcloud, embed.music.apple, embed.tidal, www.tiktok + connect-src for its
oEmbed, platform.twitter, www.instagram, embed.reddit, embed.bsky.app, www.loom,
player.kick).
Files: src/app/utils/videoEmbed.ts (resolver + parsers, unit-tested),
src/app/components/url-preview/{UrlPreviewCard,UrlPreview.css}.tsx.
Poll Creation
PollCreator.tsxcreates stablem.poll.startevents (with a text fallback body for non-poll clients)- Supports 2 to 10 answer options; single-choice or multiple-choice
- Max selections — for a multiple-choice poll, a "Voters can pick up to N of M options" control sets
max_selections(2 … option count), so you can run "pick your top 2" polls rather than only "select all". Defaults to the option count (unchanged "select all that apply" behavior) until you lower it; the display side already enforces the cap ("Select up to N") - Results visibility toggle — Show live results (disclosed, default) vs Hidden until ended (undisclosed)
- Accessible via the
Icons.OrderListbutton in the composer toolbar
Poll Display & Voting (MSC3381, full lifecycle)
PollContent.tsx is a fully interactive, spec-correct poll card:
- Vote / change / clear in place — sends stable
m.poll.response(m.selections); latest response per voter wins; clearing removes you from the tally. Multi-choice enforcesmax_selections("Select up to N"). - Disclosed vs undisclosed — undisclosed polls hide counts/percentages/bars (and the vote total) until the poll ends; disclosed polls show live results.
- See who voted — a "Show who voted" toggle (shown only when results are visible, i.e. disclosed live or undisclosed-after-end) reveals the voter names under each answer. The voter list rides the same tally as the counts (
voters: Map<answerId, senderId[]>populated intallyResponses's latest-response-per-sender loop), so it can never disagree with the numbers; a re-vote moves the voter, and a cleared vote drops them. Names viagetMemberName; undisclosed polls stay secret until close. - End a poll — the poll's creator or a moderator (redact power) can end it via an inline confirm; sends stable
m.poll.end. Ended polls lock voting, show "Poll closed · Final results", reveal results, and highlight the winner(s) (ties supported). Only responses cast on/before the end event count. - Cross-client — reads both the stable (
m.poll/m.id/m.selections) and unstable (org.matrix.msc3381.poll.*) wire formats by hand (matrix-js-sdk 41.7.0's poll parsers only speak unstable), and uses the SDKPollmodel for end-event validation (creator / redact-PL) + before-end response filtering. Polls authored in Element render/vote/end correctly and vice-versa. - Accessibility —
radiogroup/radio(single, with arrow-key roving) orgroup/checkbox(multi) semantics,aria-checked/aria-disabled, winner announced to AT. - Pure tally/visibility/winner/voters + wire-format parsing live in
utils/poll.ts(+poll.test.ts, 18 tests incl. the stable/unstable round-trip and voter attribution).
Voice Message Playback (waveform + speed)
AudioContent.tsx is the shared audio player (timeline + Media Gallery Audio tab):
- Waveform scrubbing — voice messages carry an MSC1767 waveform (
org.matrix.msc1767.audio.waveform, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform is the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (role="slider", ARIA value text). Threaded throughMAudio(MsgTypeRenderers.tsx) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar. - Playback speed — a cycle button (
[0.75, 1, 1.5, 2]×); auseEffectsetsaudioElement.playbackRateand re-applies it on (re)load (the browser resets it). - Recording pause / resume —
VoiceMessageRecorder.tsxsupports apausedstate viaMediaRecorder.pause()/resume(), so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control.
Threads (P3-8)
Full threaded-conversation support (m.thread, matrix-js-sdk threadSupport), Element-consistent.
Thread Panel
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via /relations, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from Reply in Thread in the message menu, a reply's thread indicator, or a summary chip; close with × or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
Threads List Panel
A room-level overview of all threads, opened from a Threads button (🧵) in the room header (mirrors the gallery/widgets toggles). Each row shows the root sender + message snippet, an unread dot, a meta line ("N replies · last reply 5m ago") and a participant avatar pile. A segmented filter (All / Unread / Participating — the latter via thread.hasCurrentUserParticipated) and sort (Recent / Oldest, by last-reply time) sit in the toolbar; both persist in localStorage (cinny_threads_filter_v1 / cinny_threads_sort_v1). Clicking a row opens the existing single-thread ThreadPanel (reuses setActiveThreadId), and reading it clears the row's unread badge live. The list stays live via ThreadEvent.New/NewReply/Update/Delete + RoomEvent.UnreadNotifications and is virtualized for busy rooms.
- Files:
features/room/thread/ThreadsListPanel.tsx,hooks/useRoomThreads.ts(populates viaroom.fetchRoomThreads()+room.getThreads(); last-activity + reply-count + root-edit signature so rows refresh live),state/threadsList.ts, pure filter/sort inutils/threadList.ts(filterThreads/sortThreads, unit-tested). Unread mirrorsuseThreadSummary's logic (getThreadUnreadNotificationCount, muted threads zeroed) and renders the app-wideUnreadBadge(red for mentions via the Highlight count). ReusesStackedAvatar/useMemberAvatarfor the participant pile and the Bookmarks-panel segmented-control pattern.
Summary Chips
Root messages in the main timeline show a "N replies · time" chip (server-aggregated m.thread bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
Thread Composer
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues isolated per thread (roomId::threadRootId keys). Replies-to-replies produce spec-correct m.thread + m.in_reply_to (is_falling_back: false). Slash commands work in threads — content-transform commands (/me, /notice, /shrug, /tableflip, /unflip) route into the thread via the normal send path, and room-level commands (/invite, /kick, …) act on the room; this also matches the command autocomplete, which was already shown in the thread composer. Scheduling is still disabled inside threads (v1).
↑ to edit last reply: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply in that thread — parity with the main timeline. The thread composer carries a distinct editableName="ThreadInput" so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
Notifications (Slack-style, P4-1)
By default you're notified for a thread reply only when you participate in that thread (you've posted in it) or the reply @mentions you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: Default (participating) / All replies / Mentions only / Mute. Modes sync across your devices (io.lotus.thread_notifications account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).
Under the Hood
threadSupport: true(startClient) partitions thread events into SDKThreadtimelines; markAsRead sends unthreaded receipts so room badges keep clearing- Thread replies are notified via exactly one path (room-level
ThreadEvent.NewReplyw/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live onRoomEvent.UnreadNotifications - Pending sends render via a
LocalEchoUpdatedstrip (chronological local echo never enters thread timelineSets) - Deep links to thread events redirect into the panel
- Files:
features/room/thread/*,state/room/thread.ts,hooks/useThreadSummary.ts(+35 tests across the stack)
Presence
Discord-Style Presence Selector
A presence status selector in the user panel offering five modes:
| Mode | Matrix broadcast |
|---|---|
| Online | online |
| Idle | unavailable |
| Do Not Disturb | unavailable + status_msg: 'dnd' |
| Invisible | offline |
| Auto | Standard Matrix presence lifecycle |
- Selection persists via the
presenceStatussetting usePresenceUpdatershort-circuits its automatic presence updates when a manual mode (anything other than Auto) is selected
Custom Status Message
- Up to 64 characters of free text plus an emoji
- Emoji picker is unicode-only (
EmojiBoard hideCustomEmojis): a status is plain-text presence (status_msg) that can't render a custom mxc-image emoji, so the picker hides custom/image-pack emojis (packs, sidebar icons, search, and custom entries in Recent) — every emoji shown actually inserts. (Previously custom emojis were listed but silently did nothing when clicked, since there was noonCustomEmojiSelecton this field.) - Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
- Status is broadcast via
mx.setPresence({ status_msg: ... }) - Character counter appears at 56/64 characters remaining to warn of the limit
- Status presets: a "Quick statuses" row of 11 built-in presets spanning gaming/social/life/work — 🎮 Gaming, 🎧 In a party, 🏆 Ranked grind, 😴 AFK, 🍿 Watching, 🍽️ Lunch, 🗓️ In a meeting, 🏠 Working remotely, 🎯 Focusing, 🌴 On vacation, 🤒 Out sick (see
BUILT_IN_STATUS_PRESETS). Clicking a preset applies its message + suggested auto-clear in one click. Users can also save the current status as a reusable custom preset (stored inio.lotus.status_presetsaccount data, synced across devices, de-duped by label, capped at 20; a saved preset matching a built-in is hidden to avoid a duplicate chip) and delete presets inline. Built-in list + pureupsertPresetde-dupe/cap logic live insrc/app/utils/statusPresets.ts(unit-tested); persistence insrc/app/hooks/useStatusPresets.ts.
Presence Badges
PresenceBadge component displays a colored dot indicating presence state. Used in:
- Members drawer
- User settings panel
Presence Avatar Ring
PresenceRingAvatar wraps any avatar component using React.cloneElement to inject an outline: 2px solid ring whose color maps to the user's presence state. outlineOffset: 2px ensures the ring sits cleanly outside the avatar regardless of the avatar's border-radius.
Applied in:
- Message timeline
- Members drawer
@mentionautocomplete dropdown- Inbox / notifications panel
Status Revert Bug Fix (June 2026)
usePresenceUpdater previously captured the user's custom status message once via localStorage.getItem at effect initialization. When the user changed their status message in Profile Settings, subsequent automatic transitions back to online (e.g., returning from idle) would silently broadcast the old status message, reverting the custom status.
Fixed by replacing the single read with a readStatus() function called inside every setOnline and setUnavailable invocation, so the current localStorage value is always used.
Document Title Unread Count
The browser tab title updates to reflect unread state:
(N) Lotus Chat— N mentions / keyword highlights (the count is highlights, not total unread)· Lotus Chat— unread messages without a mention (activity, no specific count)Lotus Chat— no unread items
The favicon mirrors this (highlight badge / unread dot / default).
Extended Profile Fields
Supports MSC4133 custom profile fields via PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}:
m.pronouns— displayed in profile panelsm.tz— IANA timezone string (e.g.,America/New_York)
Hook: src/app/hooks/useExtendedProfile.ts
User Local Time
When a user has m.tz set in their profile:
- Their profile panel shows a clock icon, their current local time, and the timezone abbreviation
- The displayed time updates every 60 seconds
- Respects the global
hour24Clocksetting for 12h/24h formatting
Hook: src/app/hooks/useLocalTime.ts
User-to-User Private Notes (P5-34)
A private text note on any user's profile, visible only to the logged-in user and synced across all their devices.
- Textarea in the user profile popout (below device sessions), shown only when viewing another user — never on your own profile
- Auto-saves 800 ms after the last keystroke with a "Saving…" indicator
- Character counter appears when fewer than 100 characters remain (max 500)
- Stored in
io.lotus.user_notesaccount data as{ [userId]: string }— deletes the key when the note is cleared - Reactive: updates immediately if account data arrives from another device mid-session
Hook: src/app/hooks/useUserNotes.ts
UX & Composer
Forward to Multiple Rooms (P6-3)
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and "Send to N rooms" forwards in one batch (Promise.allSettled). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via m.new_content, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested forwardContent.ts.
- Message preview: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
- Optional comment: an "Add a comment" field sends a short
m.textnote to each target room before the forwarded message (sequenced per room; a room counts as failed if either send fails). - Recent targets: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (
cinny_recent_forward_targets_v1) via the pure, unit-testedaddRecentForwardTarget(state/recentForwardTargets.ts); rooms you've since left are dropped from the row.
Live Bookmark Previews (P6-3)
BookmarksPanel resolves each saved message's live event (useRoomEvent) so previews reflect edits and show a deleted indicator for redactions, instead of the save-time snapshot. The stored snapshot (previewText) remains the fallback while loading, on fetch failure, or when you've left the room.
Message Length Counter
A character count indicator is shown in the composer when charCount > 0. The counter resets to zero when switching rooms.
Quick Emoji Reactions on Hover
A hover toolbar appears over messages, showing the 3 most recently used emojis (sourced from ElementRecentEmoji account data) as one-click reaction buttons. The buttons are positioned between the emoji-board button and the Reply button. Clicking a quick reaction closes any open emoji picker.
In-App Notification Toasts
LotusToastContainer.tsx displays rich in-app notification toasts with:
- 24px sender avatar
- Sender display name
- Message body preview
- Room name
- × dismiss button
- 4-second auto-dismiss timeout
- Slide-in animation via
@keyframes lotusToastIn
OS-level notifications are unchanged and still fire when the window is not focused.
Collapsible Long Messages
Messages exceeding a fixed height threshold are truncated with a "Show more" toggle.
- Threshold: a fixed
COLLAPSE_MAX_HEIGHTof 320px (≈ 20 lines) — not currently user-configurable - Uses CSS
max-height+overflow: hiddenwith a smooth transition - Transition is disabled when
prefers-reduced-motion: reduceis active
Message Send Animation
A subtle animation plays on the sender's own messages as they appear in the timeline:
transform: scale(0.97) → scale(1)combined withopacity: 0.4 → 1- Duration: 0.15s, ease-out
- Only applied to the current user's outgoing messages
- Disabled when
prefers-reduced-motion: reduceis active
Right-Click Room Context Menu
Right-clicking a room in the sidebar opens a context menu with:
- Mute with a duration submenu: 15 minutes, 1 hour, 8 hours, 24 hours, Indefinite
- Copy Room Link — copies the
matrix.toURI to clipboard - Mark as Read — marks all events in the room as read
- Leave Room — with a confirmation step
- Room Settings — opens the room settings panel
Unverified Device Warning
- Controlled by the
warnOnUnverifiedDevicessetting (off by default) - When enabled, a
Warning.Containerbanner is displayed above the composer in encrypted rooms that have unverified device sessions - The warning is informational only and never blocks sending
Sidebar Room Filter
A text input at the top of the room list filters rooms by display name in real time. The filter is cleared automatically when switching sidebar tabs.
DM Last Message Preview
Direct message entries in the sidebar show:
- A 48-character truncated preview of the last message body
- A relative timestamp (e.g., "2m ago")
- Reactivity via
useRoomLatestRenderedEvent - Encrypted messages show "Encrypted message" if decryption fails; successfully decrypted messages show their plaintext preview
Room Sort Order
The room list sort order can be configured in Settings → Appearance:
- Recent Activity (default)
- A → Z (alphabetical)
- Unread First
Persists via the homeRoomSort setting.
Favorite Rooms
- Rooms can be favorited via the
m.favouritetag (standard Matrix tag) - A "Favorites" section appears above the main room list when any rooms are favorited
- Favorited rooms display a star indicator in the sidebar
- Favorites sync across all devices via account data
Invite Link + QR Code
RoomShareInvite.tsx provides a shareable invite UI:
- 160×160px QR code generated locally via
qrcode.react(QRCodeSVG) — no third-party service, works offline and under strict CSP, on a white quiet-zone so it scans on any theme - Download QR: exports the code as a PNG via an offscreen high-resolution (1024px, spec 4-module margin)
QRCodeCanvas+canvas.toBlob, saved throughuseSaveFile(filename derived from the room name) with the standard download toast - "Copy Link" button to copy the
matrix.toURI - Also accessible via a toggle button (⊞) in the Invite modal
Private Read Receipts
A toggle in Settings → Privacy switches between sending m.read (public receipts) and m.read.private (private receipts visible only to the sender and the server).
Media Gallery
MediaGallery.tsx — a right-side drawer for browsing room media.
- Four tabs: Images, Videos, Audio, Files (each with a live count)
- Images/Videos — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable lightbox (←/→/Esc, prev/next). Each grid tile has a hover/focus download button, and the lightbox header has a Download action — both reuse the shared
FileDownloadButton(full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, images support zoom & pan (scroll wheel or −/+ header buttons,+/-/0keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shareduseZoom/usePanhooks; zoom resets when navigating to another item. - Audio — voice messages + audio files (
m.audio) with an inline player (reusesAudioContent: waveform scrubbing for voice messages, play/seek/speed control; decrypts on play) - Files — name/size/sender rows with download
- Jump to message — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (
useRoomNavigate) and closes the drawer - Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
- Auto-pagination — an
IntersectionObserversentinel callsmx.paginateEventTimeline()to pull older media as you scroll (manual retry on error)
Knock-to-Join
RoomIntro.tsxshows a "Request to Join" button for knock-restricted rooms- Clicking sends
mx.knockRoom(roomId)with an optional reason - The members drawer shows a "Pending Requests" section for room admins, listing users who have knocked
Knock-to-Join Notifications for Admins (P4-3)
Room and space admins are notified in real time when users knock on a restricted room.
usePendingKnocks(room)hook listens toRoomMemberEvent.Membershipevents and returns all members currently in theknockstate- Power level check: only shown to users with sufficient invite-level permissions (
usePowerLevelsContext()) - Members button badge: when knocks are pending, a
Warning-variant solidBadgeoverlays the Members button in the room header showing the pending count - Badge is
aria-hidden; the Members buttonaria-labelis updated to announce the count for screen readers
Hook: src/app/hooks/usePendingKnocks.ts
Code Syntax Highlighting (TDS)
syntaxHighlight.ts provides TDS-aware syntax highlighting using inline styles derived from --lt-accent-* CSS variables. Supported languages: JavaScript, TypeScript, JSX, TSX, Python, Rust. Falls back to ReactPrism for unsupported languages.
Room Emoji Prefix
A leading emoji in a room name is rendered at 1.15× size in the sidebar for visual hierarchy. An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
Configurable Composer Toolbar (P3-6)
Users can individually show or hide each composer toolbar button in Settings → Editor → Composer Toolbar Buttons:
- Format Toggle, Emoji, Sticker, GIF, Location, Poll, Voice Message, Schedule Message
- All default to on — no visible change for existing users
- New buttons added in future will also default to on (deep-merge in
getSettings) - Send and Attach File buttons are not hideable
- Sticker still respects the existing
width < 500pxauto-hide on top of the setting
Room Customization
Personal Room Name Overrides
- Users can set a personal display name for any room, stored in
io.lotus.room_namesaccount data - A pencil indicator in the sidebar shows rooms with active overrides
- Overrides sync across all devices
- Applied consistently in: room header, sidebar, room intro, and call overlay
Export Room History
ExportRoomHistory.tsx exports a room's message history in three formats:
- Plain Text — human-readable transcript
- JSON — raw event data
- HTML — styled, self-contained page
Features:
- Optional date range filter
- Progress indicator during export
- Uses
mx.paginateEventTimeline()to retrieve history in chunks - E2EE-aware: exports decrypted content for rooms the user has keys for
Room Activity / Mod Log
RoomActivityLog.tsx provides a searchable log of administrative events in the room:
- Member join/leave/kick/ban events
- Power level changes
- Room name, topic, avatar, and ACL changes
- Human-readable descriptions for each event type
- Type filter to narrow results
- "Load More" button for pagination
Server ACL Editor
RoomServerACL.tsx — UI for editing m.room.server_acl state events.
- Separate allow and deny server lists
- Wildcard validation (e.g.,
*.example.com) - "Allow IP literal addresses" toggle
- Read-only for users without admin power level
Room Stats / Insights
RoomInsights.tsx (not shown by default; accessible as a non-default tab in room settings).
- Top 5 most active members (bar chart by message count)
- Top 5 most-used reactions
- Media breakdown by type (images, videos, files)
- 24-hour activity heatmap showing message volume by hour of day
Moderation
Report Room
ReportRoomModal.tsx provides a UI for reporting a room to the homeserver.
- API:
POST /_matrix/client/v3/rooms/{roomId}/report - Category dropdown for classifying the report
- Modal auto-closes 1.5 seconds after a successful submission
- Hidden for rooms the user owns and for server notice rooms
Policy List / Ban List Viewer
Accessible via Room/Space Settings → Policy Lists (admin only).
- Enter a policy-list room's ID or alias (one you have already joined) to view its
m.policy.rule.user/.room/.serverrules in read-only format - Viewer only — there are no subscribe/unsubscribe controls and no listing of "subscribed" lists; join or leave the policy-list room itself the normal way
- Enforcement is delegated to Draupnir or equivalent tooling
Notifications
Custom Notification Sounds
messageSoundIdandinviteSoundIdsettings select from a predefined map- Sound options defined in
notificationSounds.tsasNOTIFICATION_SOUND_MAP - Preview (▶) buttons in the settings panel let users audition sounds before selecting
- Separate sound selection for:
notification,invite,call,none
Notification Quiet Hours
- Controlled by
quietHoursEnabled,quietHoursStart, andquietHoursEndsettings - Correctly handles overnight spans (e.g., 22:00 → 07:00)
- Gates both
notify()(visual/OS notifications) andplaySound()(audio alerts) - When active, notifications are silently dropped rather than queued
Pause Notifications (snooze)
- A cross-platform "Pause Notifications" control in Settings → Notifications (the desktop-tray Do Not Disturb only worked on the desktop app; web/mobile had no manual pause).
- Quick presets: 30 minutes / 1 hour / 4 hours / Until 8 AM / Until I resume (indefinite), plus a Resume button; the tile shows the live "Paused until …" status (via
formatFriendlyDateTime) and flips back to "on" the moment the snooze lapses. - Persisted (
cinny_notification_snooze_until_v1) as the epoch-ms instant to pause until (0= off), so a snooze survives a reload. Feeds the same notification gate as Focus Assist / Quiet Hours (ClientNonUIFeatures), suppressing bothnotify()andplaySound(). - Pure, unit-tested helpers
isSnoozeActive/nextTimeAtHour/SNOOZE_INDEFINITEinsrc/app/utils/snooze.ts(snooze.test.ts); persisted atom insrc/app/state/notificationSnooze.ts.
Full Push Rule Editor
A complete UI for managing Matrix push notification rules:
- Displays rules by kind:
override,room,sender,underride - Enable/disable toggle per rule
- Delete button for removable rules
- Add-rule form for creating new
roomandsenderrules
Notification Profile Presets (P5-27)
Three one-tap presets at the top of Settings → Notifications that apply a group of notification settings atomically:
- Gaming 🎮 — notifications on, all sounds off (
messageSoundId: none,inviteSoundId: none) - Work 💼 — all notifications and sounds on (restores defaults)
- Sleep 🌙 — all notifications off (
showNotifications: false, sounds off)
Server Integration
Server Support Contact
- Fetches
/.well-known/matrix/supporton the user's homeserver - Contact information is displayed in Settings → Help & About
- Styled with TDS cyan when the TDS theme is active
- Degrades gracefully with no error shown on 404 responses
Server Notices
m.server_notice rooms receive special treatment:
- A
Chip variant="Warning"badge reading "Server Notice" is shown in the room header - The composer is read-only (no message input)
- Invite, Report Room, and Room Settings menu items are hidden
Accessibility (P3-4)
WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.
- Timeline for screen readers: each message is
role="article"; collapsed messages announce their sender + time (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is arole="log"aria-live="polite"region so new messages are announced; emoji/emoticons carry text labels. - Live status: typing indicators announce via a
role="status"region; editing a message announces "Editing message from ". - Forms & overlays: all inputs have associated labels (visible
<label htmlFor>oraria-label); the Media Gallery and Search overlays are named. - Focus management: skip-to-content link +
nav/mainlandmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context). - Keyboard-shortcuts help: press ? for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
- Regression gate: a curated
eslint-plugin-jsx-a11yrule set (ARIA correctness + label association) runs in CI. Files:components/message/*,features/room/RoomViewTyping.tsx,features/shortcuts/*,utils/a11y.ts,eslint.config.mjs. - Known limitation: list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.
Infrastructure
Authenticated Media
mxcUrlToHttp() calls now use the correct argument order for MSC3916 authenticated media:
mxcUrlToHttp(mx, mxcUrl, useAuthentication, width, height, 'crop');
The useAuthentication parameter was previously mispositioned, causing unauthenticated requests to be sent for media in rooms that required authentication.
Upstream Tracking
- An
upstreamgit remote pointing togithub.com/cinnyapp/cinnyis maintained for merge tracking - A daily divergence check runs via
cinny-upstream-check.shon LXC 106 - This enables prompt review of upstream security patches and feature releases
Rolldown CJS Interop — millify
src/app/plugins/millify.ts re-exports millify as a named import to bypass the __toESM interop bug in the Rolldown bundler, which caused the default export of CJS modules to be undefined at runtime.
Sentry Noise Filter
ignoreErrors: ['Request timed out'] is added to Sentry.init() to suppress a high-volume, low-signal error caused by transient network conditions. This keeps the Sentry issue queue focused on actionable errors.
URL Preview Default in Encrypted Rooms
The encUrlPreview setting defaults to true rather than false. A security advisory chip in Settings → Privacy explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
Hardened Session Storage (N97 partial, 2026-07)
The session persists as ONE atomic cinny_session_v1 JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. state/sessions.ts (22 tests), hooks/useSessionSync.ts.
Crypto Diagnostics (E2EE investigation kit)
Settings → Developer Tools → Crypto Diagnostics: a capture-only ring buffer (max 200) hooks console.warn/error for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion diagnosis: the Encryption / E2EE section of LOTUS_TODO.md. utils/cryptoDiagLog.ts, features/settings/developer/CryptoDiagnostics.tsx.
Desktop App Features
Native capabilities of the Lotus Chat Tauri v2 desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in src/app/hooks/useTauri*.ts (each no-ops in the browser) and call Rust commands in cinny-desktop/src-tauri/src/native/*. Windows-only pieces are #[cfg(target_os = "windows")], compile-verified in CI (Windows runners).
Call Continuity — No-Sleep (P5-46)
Holds the system awake (SetThreadExecutionState) while a voice/video call is active; releases on end. useTauriCallPower ↔ native/power.rs.
Windows Jump List (P5-36)
Right-click the taskbar icon → a Recent Rooms list of your most-active rooms; each entry opens that room via the matrix: deep-link. useTauriJumpList ↔ native/jumplist.rs (ICustomDestinationList).
Taskbar Thumbnail Toolbar (P5-44)
Hover the taskbar preview during a call → Mute / Deafen / End Call buttons. useTauriThumbbar ↔ native/thumbbar.rs (ITaskbarList3 + a window subclass for THBN_CLICKED).
System Media Transport Controls — SMTC (P5-43)
Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT SystemMediaTransportControls). useTauriSmtc ↔ native/smtc.rs. Experimental — may require an active audio session to surface.
Network Awareness (P5-49)
Detects Windows connectivity changes (INetworkListManager) and nudges the Matrix client to reconnect (retryImmediately). useTauriNetwork ↔ native/network.rs.
Instant Background Sync (P5-42)
Keeps the /sync loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 additional_browser_args (lib.rs) — no separate background process. Windows/WebView2 only; doesn't block system sleep.
Native Rich Notifications (P5-41 / P5-35)
Windows toasts with click-to-open-room and inline quick reply (WinRT ToastNotification, in-process Activated event). Falls back to the standard toast otherwise. useTauriToastActions ↔ native/toast.rs; the desktop notification bridge routes room notifications to it.
Focus Assist Sync (P5-56)
When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). useTauriFocusAssist + focusAssistActiveAtom ↔ native/focus_assist.rs (SHQueryUserNotificationState).
Linux parity + cross-platform extras (P6-1)
Rounds out the native app beyond Windows (macOS out of scope):
- No-sleep during calls on Linux — a D-Bus
org.freedesktop.ScreenSaverinhibit (zbus) keeps the display awake mid-call, matching the Windows behavior.native/power.rs. - Launcher unread badge on Linux — best-effort Unity
LauncherEntryD-Bus signal (Ubuntu/Dash-to-Dock/KDE), mirroring the Windows taskbar badge. - Launch on login —
tauri-plugin-autostart+ a Settings → General "Launch on login" toggle (desktop-only). - Tray "Do Not Disturb" — a tray checkbox that silences Lotus notifications (feeds
manualDndAtominto the same quiet-gate as Focus Assist).useTauriDnd.
Custom Window Chrome (P5-47)
Opt-in (Settings → General → Custom Window Chrome): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via set_decorations. features/desktop/TitleBar.tsx + useTauriWindowChrome ↔ native/chrome.rs.
Proactive Update Toast (P5-40)
Checks for a new desktop release every 12h and offers a one-click update. TauriUpdateFeature (ClientNonUIFeatures) + useTauriUpdater.
Cross-platform composer niceties
- Composer toolbar drag-reorder (P5-55) — drag to reorder the composer buttons (Settings → General), via
@atlaskit/pragmatic-drag-and-drop. - Draft-saved indicator (P5-57) — a subtle cue in the composer when the current room has a persisted draft.
- Recursive folder drag-drop (P5-48) — drop a folder to upload every file inside it (all nesting levels),
utils/fileEntries.ts.
Files
- Web:
src/app/hooks/useTauri*.ts,src/app/components/TauriDesktopFeatures.tsx,src/app/features/desktop/TitleBar.tsx,src/app/features/room/DraftIndicator.tsx,src/app/utils/fileEntries.ts,src/app/state/{customWindowChrome,focusAssist}.ts. - Native (
cinny-desktop):src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs+native/mod.rs(registered inlib.rs).
Key Custom Files
| File | Purpose |
|---|---|
src/lotus-terminal.css.ts |
TDS CSS variable definitions, scanline/vignette effects, dark + light palette tokens |
src/lotus-boot.ts |
Matrix-style boot sequence animation on the welcome page |
src/app/hooks/useRoomReadPositions.ts |
Reactive hook returning Map<eventId, userId[]> for per-message read receipts |
src/app/features/room/ReadPositionsContext.ts |
React context providing read positions map to timeline components |
src/app/components/read-receipt-avatars/ |
ReadReceiptAvatars component — overlapping avatar pill with overflow count |
src/app/components/event-readers/EventReaders.tsx |
"Seen by" modal listing readers with display name and timestamp |
src/app/components/GifPicker.tsx |
Giphy-powered GIF picker, TDS-styled, gated on gifApiKey in config |
src/app/features/call/CallControls.tsx |
Push to Deafen (M key), PTT visual indicator, TDS typing dots |
src/app/plugins/call/CallControl.ts |
onControlMutation() state tracking, screenshare audio mute logic, call button scoping |
src/app/components/CallEmbedProvider.tsx |
PiP window, draggable overlay, navigate-on-click, imperative geometry sync |
src/app/plugins/call/CallEmbed.ts |
getBoundingClientRect()-based embed positioning, ResizeObserver sync, dark mode injection |
src/app/plugins/millify.ts |
Named re-export of millify to fix Rolldown __toESM CJS interop bug |
src/app/features/room/MediaGallery.tsx |
Images/Videos/Files gallery drawer with pagination and E2EE awareness |
src/app/features/room/PollCreator.tsx |
Poll creation UI for stable m.poll.start, single/multiple choice, 2–10 options |
src/app/features/common-settings/general/RoomShareInvite.tsx |
QR code + copy link invite sharing modal |
src/app/utils/syntaxHighlight.ts |
TDS-aware syntax highlighter using --lt-accent-* inline styles |
src/app/features/room-settings/ExportRoomHistory.tsx |
Plain Text / JSON / HTML room history export with date range and E2EE support |
src/app/features/room-settings/RoomActivityLog.tsx |
Human-readable mod log for member and state change events |
src/app/features/room-settings/RoomServerACL.tsx |
m.room.server_acl editor with allow/deny lists and wildcard validation |
src/app/features/room-settings/RoomInsights.tsx |
Room stats: top members, top reactions, media breakdown, activity heatmap |
src/app/features/bookmarks/BookmarksPanel.tsx |
Bookmarks sidebar panel backed by io.lotus.bookmarks account data |
src/app/hooks/useBookmarks.ts |
Hook for reading and mutating the bookmarks account data entry |
src/app/features/room/ScheduleMessageModal.tsx |
MSC4140 delayed event scheduling UI with date/time picker |
src/app/utils/scheduledMessages.ts |
Helpers for creating, listing, and cancelling MSC4140 delayed events |
src/app/hooks/useExtendedProfile.ts |
MSC4133 extended profile fields (m.pronouns, m.tz) read/write |
src/app/hooks/useLocalTime.ts |
Derives current local time from m.tz profile field, updates every 60s |
src/app/components/url-preview/UrlPreviewCard.tsx |
13 domain-specific URL preview layouts plus generic fallback with favicon |
src/app/features/lotus/avatarDecorations.ts |
Avatar decoration catalog, CDN URL, decorationUrl() helper |
src/app/hooks/useAvatarDecoration.ts |
Profile field fetch with module-level cache and in-flight deduplication |
src/app/components/avatar-decoration/AvatarDecoration.tsx |
APNG overlay wrapper rendered around avatars in timeline, members drawer, autocomplete |
src/app/features/settings/account/ProfileDecoration.tsx |
Settings decoration picker — scrollable grid, category headers, save button |
scripts/syncDecorations.mjs |
CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files |