- 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
-`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
A `noiseSuppression` URL parameter is passed to the Element Call widget URL, allowing the noise suppression feature to be toggled from within Lotus settings.
### 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.
- 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.