- 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.
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.
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
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:
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.
**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:
```bash
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`)
-`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.
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.
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
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.
- 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
**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.
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
- **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) |
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.
- 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:
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
- 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()`
Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unparseable preview responses are suppressed entirely rather than showing a blank card.
- 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:
`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`.
`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.
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.
- 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).
### 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.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
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
`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.
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.
| `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 |