Implement a flexible, multi-model noise suppression pipeline for Element Call/LiveKit integration: - ML Engines: Added support for RNNoise, Speex, DTLN, and DeepFilterNet 3 models. - Pipeline Architecture: Implemented modular audio processing in lotus-denoise.js, supporting 'Series Suppression' (running browser-native NSNet2 before ML) and a hardware-style Noise Gate. - UI & UX Enhancements: - Settings UI: Added model comparison chart with CPU/Quality metadata. - Tuning: Added Live Microphone Meter for calibrating Noise Gate thresholds. - Reporting: Added LotusToast system to alert users when ML suppression fails or falls back to raw input. - Robustness & Quality: - Capture Fidelity: Removed forced 48kHz capture constraints to allow native-rate capture (solving static issues with high-end audio interfaces). - Performance: Added WASM SIMD detection with transparent fallback. - Capability Detection: Added browser feature detection to disable unsupported ML modes. - Build Integration: Updated Vite config to self-host all model WASM/tflite assets in /denoise/ directory.
54 KiB
Lotus Chat — Feature Reference
Everything added to Lotus Chat beyond upstream Cinny v4.12.1. Last updated: June 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
- Presence
- UX & Composer
- Room Customization
- Moderation
- Notifications
- Server Integration
- Infrastructure
- 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
- 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
Element Call Upgrade
Upgraded embedded Element Call widget from 0.16.3 to 0.19.4.
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: Toggle between RNNoise (standard hybrid) and Speex (legacy DSP-based) to compare artifact levels and suppression strength.
- 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 Model Roadmap:
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) |
|---|---|---|---|
| RNNoise | Poor | Moderate | < 5% |
| DTLN | Good | High | 10-20% |
| DeepFilterNet 3 | Excellent | Very High | 25-50%+ |
Note: DeepFilterNet 3 is planned for future inclusion in the desktop build where larger binaries and higher CPU overhead are more acceptable.
Files
build/lotus-denoise.js— multi-model getUserMedia shimvite.config.js—lotusDenoise()plugin (copies assets for RNNoise, Speex, and NoiseGate)src/app/plugins/call/CallEmbed.ts— advanced tier → widget URL paramssrc/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 - 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 - Picker UI is styled with TDS variables when the TDS theme is active
- Located at
src/app/components/GifPicker.tsx
Message Forwarding
Context menu → Forward allows forwarding a message to any room the user is a member of.
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
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
Image / Video Captions
Images and videos can be sent with a caption. The caption and media are sent as a single event.
Location Sharing
m.location events render an inline map tile using the coordinates from the event content.
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- 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 cancel buttons
- Utilities in
src/app/utils/scheduledMessages.ts
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.
Poll Creation
PollCreator.tsxcreates stablem.poll.startevents- Supports 2 to 10 answer options
- Supports both single-choice and multiple-choice modes
- Accessible via the
Icons.OrderListbutton in the composer toolbar
Poll Display
PollContent.tsx renders polls in read-only mode. Handles both the stable m.poll format and the legacy MSC3381 unstable org.matrix.msc3381.poll.start format. Displays current vote counts and a note directing users to Element to cast votes.
Voice Message Playback Speed
AudioContent.tsx adds a playback speed cycle button to voice message players. Available speeds: [0.75, 1, 1.5, 2]×. A useEffect sets audioElement.playbackRate whenever the speed selection changes.
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
- 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
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 unread messages· Lotus Chat— unread activity without a specific countLotus Chat— no unread items
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
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 configurable line threshold are truncated with a "Show more" toggle.
- Default threshold: 20 lines
- Threshold is configurable in Settings → Appearance
- 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 via
api.qrserver.com - "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.
- Three tabs: Images, Videos, Files
- Reads already-decrypted events from the room timeline
- Encrypted images show a lock placeholder rather than an error
- "Load More" button triggers
mx.paginateEventTimeline()to fetch older media
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).
- Displays the room's subscribed policy lists in read-only format
- Subscribe (join) and unsubscribe (leave) controls for each list
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
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
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
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.
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 |