Files
cinny/LOTUS_FEATURES.md
jared 6634b2b8a2
CI / Build & Quality Checks (push) Successful in 10m41s
CI / Trigger Desktop Build (push) Successful in 6s
fix(calls): make ML denoise build-honest + gate desktop trigger on CI
Audit/repair of the multi-model denoise work so it actually builds and only
exposes working, self-hosted models.

- Complete the DTLN/DFN3 revert: uninstall @workadventure/noise-suppression
  and deepfilternet3-noise-filter (package.json + lockfile), drop the unused
  DTLN asset-copy block from vite.config.js (was shipping ~2MB of unused
  tflite/wasm), and narrow DenoiseModelId to the bundled models (rnnoise,
  speex). Coerce any retired persisted model value back to the default.
- Fix General.tsx CI typecheck failures introduced by the denoise UI: restore
  three imports the rewrite deleted (useDateFormatItems, SequenceCardStyle,
  useTauriUpdater), add the missing denoise/sound imports, and correct
  hallucinated Folds props (Text has no variant/bold; Box uses
  alignItems/justifyContent). tsc now passes with 0 errors.
- Harden the vite denoise plugin: required RNNoise/Speex/gate assets and the
  shim now fail the build loudly if missing (instead of a silent warn that
  shipped a broken ML feature), and the index.html shim injection is verified.
- CI: move the cinny-desktop submodule bump into ci.yml as a `trigger-desktop`
  job gated on `needs: build`, and delete the standalone trigger-desktop.yml.
  A failing push no longer kicks off the slow Tauri builds in parallel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:42:21 -04:00

54 KiB
Raw Permalink Blame History

Lotus Chat — Feature Reference

Everything added to Lotus Chat beyond upstream Cinny v4.12.1. Last updated: June 2026.


Table of Contents

  1. Branding & Identity
  2. LotusGuild Terminal Design System (TDS) v1.2
  3. Animated Chat Backgrounds (P5-4)
  4. Seasonal Theme Overlays (P5-12)
  5. Avatar Decorations (P5-13/P5-14)
  6. Glassmorphism Sidebar (P5-3)
  7. Night Light / Blue Light Filter (P5-5)
  8. Voice / Video Call Improvements
  9. Per-Message Read Receipts
  10. Delivery Status Indicators
  11. Messaging Enhancements
  12. Presence
  13. UX & Composer
  14. Room Customization
  15. Moderation
  16. Notifications
  17. Server Integration
  18. Infrastructure
  19. Key Custom Files

Branding & Identity

  • Package renamed to lotus-chat; description updated in package.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 to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
  • Welcome page tagline: "A Matrix client for Lotus Guild"
  • Encryption key export filename changed to lotus-keys.txt
  • manifest.json updated 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-gradient pseudo-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.lotusTerminalBodyClass to avoid bleed into non-TDS themes
  • ThemeManager.tsx is responsible for setting the data-theme attribute 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 backgroundSize values (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 s filter: 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 definitions
  • src/app/features/lotus/chatBackground.tsgetChatBg() 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 31Jan 2 Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep
🏮 Lunar New Year Jan 22Feb 5 Floating paper lanterns bobbing; silk texture; gold shimmer accent
💖 Valentine's Day Feb 1015 ♥ hearts floating upward; soft pink ambient glow
🍀 St. Patrick's Day Mar 1518 ☘ 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 2023 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge
🍂 Autumn Sep 21Oct 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 410 Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient
🎃 Halloween Oct 15Nov 1 Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint
❄️ Christmas Dec 10Jan 2 White dot snowfall in multiple layers at varied speeds

Implementation

  • SeasonalEffect component mounted in App.tsx at z-index: 9997 (below night light, above content)
  • Auto-detection via getActiveSeason(now: Date) — themes checked in priority order (New Year > Valentine's > … > Autumn)
  • seasonalThemeOverride setting: 'auto' | 'off' | <theme-name> — persisted in settingsAtom
  • 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 components
  • src/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 overlay
  • src/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:

  • SidebarGlass vanilla-extract class applies background: rgba(3, 5, 8, 0.55) and backdropFilter: blur(12px) to the sidebar element
  • SidebarNav.tsx uses a useEffect to mirror the active chat background onto document.body when the glassmorphism setting is enabled, so the blur filter has meaningful content to work through
  • Degrades gracefully on browsers without backdrop-filter support (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:

  • NightLightOverlay component mounted directly in App.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 Defaultsystem-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 in index.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-border set on document.body
  • CustomHtml.css.ts uses these as CSS var() fallbacks over the original folds Success token 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 contentWindow to 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-only getUserMedia stream (independent of Element Call's stream) and analyzes it via AudioContext + 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 (afkTimeoutMinutes setting), callEmbed.control.setMicrophone(false) mutes the mic and an in-app toast is shown
  • Monitoring stream and AudioContext are fully cleaned up on unmount (no resource leak)
  • Activated inside CallControls via useAfkAutoMute(callEmbed) — no changes required to CallEmbed or 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_limit room state event with content { max_users: N } (0 / absent = no limit)
  • RoomVoiceLimit component in Room Settings → General → Voice lets admins set the cap with a number input. Editing is gated by permissions.stateEvent(StateEvent.LotusVoiceLimit, …), so only users with state_default power (or above) can change it
  • CallPrescreen (CallView.tsx) reads the limit reactively via useStateEvent and compares it against the live useCallMembers count; 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-service before 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 returns 403 so 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 in CallUtils inside CallEmbedProvider) listens to MatrixRTCSession membership changes via useCallMembersChange
  • 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 / AudioContext support 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 shim
  • vite.config.jslotusDenoise() plugin (copies assets for RNNoise, Speex, and NoiseGate)
  • src/app/plugins/call/CallEmbed.ts — advanced tier → widget URL params
  • src/app/utils/lotusDenoiseUtils.ts — support detection and model comparison metadata
  • src/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 useEffect style for position overrides because useCallEmbedPlacementSync writes geometry directly onto the DOM element, making declarative approaches unreliable

Call Embed Positioning

  • Uses getBoundingClientRect() (viewport-relative) instead of the previous offsetTop/offsetLeft (parent-relative) calculations, fixing misalignment in scrolled layouts
  • Position is synced on mount via useEffect with a ResizeObserver to 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.localEchoUpdated and RoomMember.typing events 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 hour24Clock setting 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_body is rendered via sanitizeCustomHtml + 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, and code buttons

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_url endpoint to avoid mixed-content issues

GIF Picker

  • Giphy-powered picker accessible from the composer toolbar
  • The button is only shown when gifApiKey is set in config.json
  • Selected GIFs are sent as m.image events
  • 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 localStorage keyed by roomId
  • Draft is cleared on successful send
  • The Jotai atom is the primary source of truth; localStorage is only read on room mount

Message Search Date Range

  • The search panel accepts from_ts and to_ts values (epoch milliseconds) passed to the search API
  • A chip shows the active date range with an × button to clear it

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.bookmarks account data, syncing across all devices
  • Maximum of 500 bookmarked entries
  • BookmarksPanel.tsx is 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.tsx provides 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 Switch toggle 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
Reddit 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.tsx creates stable m.poll.start events
  • Supports 2 to 10 answer options
  • Supports both single-choice and multiple-choice modes
  • Accessible via the Icons.OrderList button 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 presenceStatus setting
  • usePresenceUpdater short-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
  • @mention autocomplete 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 count
  • Lotus 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 panels
  • m.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 hour24Clock setting 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_notes account 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: hidden with a smooth transition
  • Transition is disabled when prefers-reduced-motion: reduce is 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 with opacity: 0.4 → 1
  • Duration: 0.15s, ease-out
  • Only applied to the current user's outgoing messages
  • Disabled when prefers-reduced-motion: reduce is 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.to URI 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 warnOnUnverifiedDevices setting (off by default)
  • When enabled, a Warning.Container banner 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.favourite tag (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

RoomShareInvite.tsx provides a shareable invite UI:

  • 160×160px QR code generated via api.qrserver.com
  • "Copy Link" button to copy the matrix.to URI
  • 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).

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.tsx shows 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 to RoomMemberEvent.Membership events and returns all members currently in the knock state
  • Power level check: only shown to users with sufficient invite-level permissions (usePowerLevelsContext())
  • Members button badge: when knocks are pending, a Warning-variant solid Badge overlays the Members button in the room header showing the pending count
  • Badge is aria-hidden; the Members button aria-label is 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 < 500px auto-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_names account 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

  • messageSoundId and inviteSoundId settings select from a predefined map
  • Sound options defined in notificationSounds.ts as NOTIFICATION_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, and quietHoursEnd settings
  • Correctly handles overnight spans (e.g., 22:00 → 07:00)
  • Gates both notify() (visual/OS notifications) and playSound() (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 room and sender rules

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/support on 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 upstream git remote pointing to github.com/cinnyapp/cinny is maintained for merge tracking
  • A daily divergence check runs via cinny-upstream-check.sh on 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, 210 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