# 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](#branding--identity)
2. [LotusGuild Terminal Design System (TDS) v1.2](#lotusguild-terminal-design-system-tds-v12)
3. [Animated Chat Backgrounds (P5-4)](#animated-chat-backgrounds-p5-4)
4. [Seasonal Theme Overlays (P5-12)](#seasonal-theme-overlays-p5-12)
5. [Avatar Decorations (P5-13/P5-14)](#avatar-decorations-p5-13p5-14)
6. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
7. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
8. [Voice / Video Call Improvements](#voice--video-call-improvements)
9. [Per-Message Read Receipts](#per-message-read-receipts)
10. [Delivery Status Indicators](#delivery-status-indicators)
11. [Messaging Enhancements](#messaging-enhancements)
12. [Threads (P3-8)](#threads-p3-8)
13. [Presence](#presence)
14. [UX & Composer](#ux--composer)
15. [Room Customization](#room-customization)
16. [Moderation](#moderation)
17. [Notifications](#notifications)
18. [Server Integration](#server-integration)
19. [Infrastructure](#infrastructure)
20. [Desktop App Features](#desktop-app-features)
21. [Key Custom Files](#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

```ts
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.ts` — `getChatBg()` implementation and pattern registry

---

## Seasonal Theme Overlays (P5-12)

Decorative CSS-only overlays that activate automatically on holidays and events. Manually overrideable in **Settings → Appearance → Seasonal Theme**.

### Themes

| Theme                | Window        | Effect                                                                                             |
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| 🎆 New Year          | Dec 31–Jan 2  | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep                              |
| 🏮 Lunar New Year    | Jan 22–Feb 5  | Floating paper lanterns bobbing; silk texture; gold shimmer accent                                 |
| 💖 Valentine's Day   | Feb 10–15     | ♥ hearts floating upward; soft pink ambient glow                                                   |
| 🍀 St. Patrick's Day | Mar 15–18     | ☘ clovers drifting down; gold metallic shimmer top border                                          |
| 🃏 April Fool's      | Apr 1         | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day         | Apr 20–23     | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge                           |
| 🍂 Autumn            | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling                                                 |
| 👾 Arcade Day        | Sep 12        | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt                             |
| 🚀 Deep Space Week   | Oct 4–10      | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient                   |
| 🎃 Halloween         | Oct 15–Nov 1  | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint           |
| ❄️ Christmas         | Dec 10–Jan 2  | White dot snowfall in multiple layers at varied speeds                                             |

### Implementation

- `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:

```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`)
- `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 Default** — `system-ui, -apple-system, sans-serif`
- **Inter** — `'InterVariable', sans-serif` (current default)
- **JetBrains Mono** — `'JetBrains Mono', monospace` (already loaded from Google Fonts)
- **Fira Code** — `'Fira Code', monospace` (added to Google Fonts preload 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

> 🔱 **[EC-FORK] LIVE (2026-06).** Element Call is now our **self-built fork**
> (`@lotusguild/element-call-embedded@0.20.1-lotus.1`, source at
> `LotusGuild/element-call`), served same-origin — no longer the upstream
> pre-built npm bundle. Several in-call behaviors below are now first-class
> source changes rather than DOM/widget hacks. Background, plan, and the Phase-2
> work list are in
> [`HANDOFF_ELEMENT_CALL_FORK.md`](./HANDOFF_ELEMENT_CALL_FORK.md).

### Element Call — Self-Built Fork (`0.20.1-lotus.1`)

The embedded widget was upgraded **0.16.3 → 0.19.4 → 0.20.1**, then **forked**.
We self-build `LotusGuild/element-call` and publish it to our private Gitea npm
registry as `@lotusguild/element-call-embedded`; cinny consumes that instead of
`@element-hq/element-call-embedded`. The iframe prints
`Element Call embedded-v0.20.1-lotus.1` in its console (vs. `embedded-v0.20.1`
upstream) — the quickest way to confirm a deploy landed the fork.

All custom behavior lives in the fork's `src/lotus/` modules and is **additive
and dormant by default**, gated by URL flags / widget actions the host opts into,
so a stock EC config is byte-for-byte upstream behavior.

**Active (cinny drives them today):**

| #   | Feature                           | Mechanism                                                                                                                               | Replaces (old hack)                                                                                                 |
| --- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
| A7  | **Denoise in-source**             | ML noise suppression runs inside EC as a LiveKit `TrackProcessor<Audio>` (flag `lotusDenoiseSource=1`); re-applied on every (re)publish | the build-time `getUserMedia` monkeypatch injected into `index.html` — **removed**. Fixes mic-dead-after-reconnect. |
| #2  | **Speaking / mute events**        | EC emits `io.lotus.call_state` (throttled); cinny reads speaker + mute state from it (flag `lotusCallState=1`)                          | scraping EC's DOM for `[data-lk-speaking]` (kept only as fallback)                                                  |
| A5  | **Focus participant**             | host sends `io.lotus.focus_participant` to pin a tile, coexisting with / overriding the screenshare spotlight                           | the `.click()`-the-tile DOM hack in `CallControl.ts` — **removed**                                                  |
| #6  | **In-call avatar decorations**    | host pushes `io.lotus.decorations` (per-user APNG URLs); the fork renders them on EC's video-tile avatars                               | previously impossible — decorations only showed on our pre-join lobby roster                                        |
| #5  | **Native transparent background** | flag `lotusTransparent=1` makes EC's surface transparent so the host wallpaper shows through                                            | the injected `background:none !important` CSS                                                                       |

**Now wired (cinny drives them — ⚠️ awaiting live verification):**

| #     | Capability           | Widget action                                                                        | cinny surface                                                       |
| ----- | -------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------- |
| P5-15 | **Audio inject**     | `io.lotus.inject_audio` — plays a clip into the call as a separately published track | In-Call Soundboard (uploadable clips) — see below                   |
| P5-31 | **Quality controls** | `io.lotus.set_quality` — sets audio/screenshare encoding bitrate/framerate           | Call Quality Controls (user settings + room-admin caps) — see below |

> Both were dormant capabilities; cinny now drives them (armed via
> `lotusAudioInject=1`). The **only** EC item still open is the P5-31
> **server-side** quality guard (a `voice-limit-guard`-style sidecar reading
> `io.lotus.room_quality`) for hard enforcement across all Matrix clients — the
> client cap is best-effort.

### In-Call Soundboard (P5-15)

A soundboard button (🔔) in the call controls bar opens a popout of the user's
clips. Clicking one **injects it into the call as a real published LiveKit
track** (every participant hears it, via the fork's `io.lotus.inject_audio`) and
plays it locally for the presser (LiveKit doesn't loop your own track back).

- **User-uploadable, like custom emoji/sticker packs.** Clips are stored in the
  `io.lotus.soundboard` account data event, so they **sync across all your
  devices**. Upload short audio (≤ 1 MB, ≤ 40 clips) from the popout; delete
  inline.
- Authenticated media can't be fetched from the widget's realm, so the host
  resolves each mxc clip → an authenticated download → a same-session `blob:`
  object URL and hands that to the widget.
- Gated by the **Soundboard** toggle (Settings → General → Calls) with a volume
  slider. The button is hidden when disabled.
- Files: `utils/soundboardClips.ts`, `hooks/useSoundboard.ts`,
  `features/call/CallSoundboard.tsx`, `plugins/call/CallControl.ts#injectAudio`.

### Call Quality Controls (P5-31)

Discord-style encoding controls applied to the local tracks via the fork's
`io.lotus.set_quality` (`RTCRtpSender.setParameters` across all simulcast
encodings, re-applied on every re-publish/reconnect).

- **User settings** (Settings → General → Calls): Microphone Bitrate,
  Screenshare Bitrate, Screenshare Framerate (each defaults to **Auto**).
- **Room-admin caps**: admins set a ceiling in Room Settings → General → Voice
  (`io.lotus.room_quality` state event); every Lotus client clamps its per-user
  quality to `min(user setting, room cap)`.
- Applied by the `useCallQuality` hook on join and whenever settings/caps
  change; `utils/callQuality.ts` builds the payload (unit-tested).

**Server-enforced call permissions (hard, ALL clients).** The same
`io.lotus.room_quality` event carries a **publish-source policy**
(`allow_screenshare`, `allow_camera`) enforced server-side by
`voice-limit-guard` (matrix repo, LXC 151): it re-signs the LiveKit JWT's
`canPublishSources`, so the SFU refuses screenshare/camera tracks for **every**
Matrix client (Element, FluffyChat, our fork) — not just Lotus. Admins toggle
these in Room Settings → Voice → **Call Permissions**; cinny also hides the
blocked buttons in the call bar. Enforcement is **live**: the JWT re-sign covers
new joins, and a background reconcile loop revokes an **in-progress**
screenshare/camera (via LiveKit `UpdateParticipant`) within ~3 s of an admin
flipping the policy — so it kills active shares mid-call, not just future ones.

- **Why numeric caps aren't server-enforced:** LiveKit is a pure SFU (forwards,
  never transcodes) and has no publisher bitrate/fps field anywhere in the JWT
  grant, room config, server `limit:`, or admin API; stock Element Call ignores
  room metadata for publish quality. Numeric caps are therefore inherently
  **cooperative** — our fork honors them, which is the design above. The
  publish-source policy is the one genuine hard, cross-client lever, and it's
  implemented.
- **Not yet**: screenshare resolution control (needs a `getDisplayMedia` hook in
  the fork).

### Camera Default Off

Camera starts disabled on join. The `cameraOnJoin` setting is explicitly opt-in and is not persisted to `localStorage` between sessions, preventing the previous behavior where a prior-session preference could unexpectedly enable the camera.

### UI Fixes

- **Deafen button** — corrected tooltip text that was previously swapped with the mute tooltip
- **Screenshare confirmation** — a confirmation dialog is shown before initiating a screenshare broadcast to the room
- **Auto-revert spotlight on screenshare** — removed; the 600ms grid-revert click was causing fullscreen screenshare to show avatar tiles instead of the screen. EC now handles layout natively.

### Push to Talk (PTT)

- Configurable keybind; defaults to `Space`
- Visual indicator shown in both TDS and non-TDS themes while PTT is active
- Event listener attached to both the main window and the Element Call iframe's `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:** Four in-source models, selectable from a dropdown **ordered by quality/CPU** (best first): **DeepFilterNet 3** (48 kHz, best), **DTLN** (16 kHz), **RNNoise** (48 kHz), **Speex** (48 kHz, lightest). The **tier default is Browser-native**; when a user opts into ML the default model is **DeepFilterNet 3**.
- **Series Suppression (Combination):** Optional toggle to run the browser's native stationary noise filter _before_ the ML model. This allows testing the individual performance of the ML model vs the combined effectiveness at removing fan hum.
- **Noise Gate:** Configurable hardware-style gate with a dB threshold. Hard-cuts all audio when input is below the threshold, ensuring absolute silence between sentences.
- **Live Microphone Meter:** A real-time volume visualizer in the settings panel to help users accurately tune their Noise Gate threshold.
- **High-Fidelity Capture:** Captures at hardware native rates (supporting high-end gear like **Scarlett Solo + PodMic**) and handles high-quality resampling via Web Audio to prevent the "static" artifacts caused by low-quality browser pre-resamplers.
- **Performance:** Automatic WASM SIMD detection with transparent fallback to standard binaries.
- **Support Detection:** UI now detects `AudioWorklet` / `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 Models (all now in-source in the EC fork):**
| Model | Transients (Clicks) | Voice Quality | CPU Usage (WASM) | Sample rate |
| :--- | :--- | :--- | :--- | :--- |
| **DeepFilterNet 3** (ML default) | **Excellent** | **Very High** | 25-50%+ | 48 kHz |
| **DTLN** | Good | High | 10-20% | 16 kHz |
| **RNNoise** | Poor | Moderate | < 5% | 48 kHz |
| **Speex** | Poor | Low | < 5% | 48 kHz |

> **Update (2026-06):** with the EC fork live, denoise runs **inside** Element
> Call as a LiveKit `TrackProcessor` and **all four models ship in-source**
> (DTLN at 16 kHz, the rest at 48 kHz; the processor degrades to the raw mic
> rather than ever going silent). The model picker selects between them.

> **Update (2026-07) — quality, reliability & AEC/AGC:**
>
> - **Quality tuning** (addresses the "robotic/underwater" RNNoise reports):
>   a **dry/wet attenuation floor** (default ~-16 dB) blends a little raw mic
>   under the denoised signal so suppression can't fully collapse the noise
>   floor — applied only to the low-latency flat models (RNNoise/Speex); DTLN/DFN
>   would comb-filter, so they rely on their own level. The **noise gate now runs
>   after the ML stage**, and **DeepFilterNet 3 level 80 → 60**. Tunable via the
>   `lotusDenoiseFloor` param.
> - **AEC/AGC:** browser **echo cancellation stays ON**, but the ML tier now sets
>   **auto gain control OFF** (`autoGainControl=false`) so the browser's dynamic
>   gain doesn't fight the ML model. Browser/off tiers keep AGC on. (Remote
>   playback stays on standard elements — no AEC-defeat vector.)
> - **Reliability:** never-silent watchdog (auto-resume a suspended context),
>   `resume()` timeout (no track-lock deadlock), rejected-WASM-fetch eviction
>   (transient failures recover), activation off the local participant (works
>   solo), and init/build-failure leak fixes.
> - Real-call **audio-quality** A/B (model choice, floor value, AGC on/off) is the
>   open by-ear validation item — see `LOTUS_TESTING.md` §D2-1.

### Files

- **EC fork** `src/lotus/lotusDenoise.ts` + `lotusDenoiseProcessor.ts` — in-source LiveKit `TrackProcessor` (RNNoise/Speex 48 kHz, DTLN 16 kHz, DeepFilterNet 48 kHz); activated by `lotusDenoiseSource=1`. (The old build-time `getUserMedia` shim `build/lotus-denoise.js` is **removed**.)
- `vite.config.js` — `lotusDenoise()` plugin (now only **copies model assets** for the fork to load; no longer injects a shim)
- `src/app/plugins/call/CallEmbed.ts` — advanced tier → `lotusDenoiseSource` widget URL param
- `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`

```ts
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

### Encrypted Search Cache (P4-8, opt-in)

Persistent local index for encrypted-room search, so coverage survives page reloads instead of requiring re-pagination + re-decryption every session.

- Raw IndexedDB (`lotus-search-cache`): message rows keyed `[roomId, eventId]` + per-room coverage markers; merged into local search results with in-memory-wins dedupe
- **Opt-in, default OFF** (it stores decrypted text at rest): toggle + "Clear cached index" live in the search panel's Encrypted Rooms section, with the privacy note "Stores decrypted text on this device"
- Always wiped on logout; any IndexedDB error degrades to a cache-miss (never breaks search)
- Files: `src/app/utils/searchCache.ts`, `src/app/state/searchCacheEnabled.ts`, `features/message-search/useLocalMessageSearch.ts`

### Math / LaTeX Rendering (P4-4)

KaTeX-rendered math in messages, two paths:

- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`

### 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.

---

## Threads (P3-8)

Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`), Element-consistent.

### Thread Panel

A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.

### Summary Chips

Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.

### Thread Composer

The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).

### Notifications (Slack-style, P4-1)

By default you're notified for a thread reply only when you **participate** in that thread (you've posted in it) or the reply **@mentions** you — other threads accumulate quietly behind their chip badges. Every thread can be overridden from the bell menu in the panel header: **Default (participating) / All replies / Mentions only / Mute**. Modes sync across your devices (`io.lotus.thread_notifications` account data, auto-pruned). Muting a thread silences notifications and sounds, removes the chip's unread badge (a small bell-mute glyph shows instead), and subtracts that thread from the room's sidebar unread badge (client-side — other Matrix clients on the account still count it).

### Under the Hood

- `threadSupport: true` (startClient) partitions thread events into SDK `Thread` timelines; markAsRead sends **unthreaded** receipts so room badges keep clearing
- Thread replies are notified via exactly one path (room-level `ThreadEvent.NewReply` w/ per-thread dedupe + panel-aware focus suppression); the main timeline notifier is thread-guarded, and room badges refresh live on `RoomEvent.UnreadNotifications`
- Pending sends render via a `LocalEchoUpdated` strip (chronological local echo never enters thread timelineSets)
- Deep links to thread events redirect into the panel
- Files: `features/room/thread/*`, `state/room/thread.ts`, `hooks/useThreadSummary.ts` (+35 tests across the stack)

---

## Presence

### Discord-Style Presence Selector

A presence status selector in the user panel offering five modes:

| Mode           | Matrix broadcast                    |
| -------------- | ----------------------------------- |
| Online         | `online`                            |
| Idle           | `unavailable`                       |
| Do Not Disturb | `unavailable` + `status_msg: 'dnd'` |
| Invisible      | `offline`                           |
| Auto           | Standard Matrix presence lifecycle  |

- Selection persists via the `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

### Invite Link + QR Code

`RoomShareInvite.tsx` provides a shareable invite UI:

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

### 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:

```ts
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.

### Hardened Session Storage (N97 partial, 2026-07)

The session persists as ONE atomic `cinny_session_v1` JSON write (previously ~10 separate localStorage keys written non-atomically). Reads prefer the blob with transparent migration from the legacy keys (dual-written one release for rollback). Cross-tab sync: logging out or in from one tab reloads the others so no tab runs with stale credentials. `state/sessions.ts` (22 tests), `hooks/useSessionSync.ts`.

### Crypto Diagnostics (E2EE investigation kit)

**Settings → Developer Tools → Crypto Diagnostics**: a capture-only ring buffer (max 200) hooks `console.warn/error` for E2EE failure signatures (OTK upload conflicts, missing call media keys, decryption errors, delayed-event timeouts) and downloads a JSON report — the evidence input for the KE-1→4 investigation. Companion runbook: [`LOTUS_E2EE_INVESTIGATION.md`](./LOTUS_E2EE_INVESTIGATION.md). `utils/cryptoDiagLog.ts`, `features/settings/developer/CryptoDiagnostics.tsx`.

---

## Desktop App Features

Native capabilities of the Lotus Chat **Tauri v2** desktop app (Windows, macOS, Linux) on top of the shared web client. Web hooks live in `src/app/hooks/useTauri*.ts` (each no-ops in the browser) and call Rust commands in `cinny-desktop/src-tauri/src/native/*`. Windows-only pieces are `#[cfg(target_os = "windows")]`, compile-verified in CI (Windows runners).

### Call Continuity — No-Sleep (P5-46)

Holds the system awake (`SetThreadExecutionState`) while a voice/video call is active; releases on end. `useTauriCallPower` ↔ `native/power.rs`.

### Windows Jump List (P5-36)

Right-click the taskbar icon → a **Recent Rooms** list of your most-active rooms; each entry opens that room via the `matrix:` deep-link. `useTauriJumpList` ↔ `native/jumplist.rs` (`ICustomDestinationList`).

### Taskbar Thumbnail Toolbar (P5-44)

Hover the taskbar preview during a call → **Mute / Deafen / End Call** buttons. `useTauriThumbbar` ↔ `native/thumbbar.rs` (`ITaskbarList3` + a window subclass for `THBN_CLICKED`).

### System Media Transport Controls — SMTC (P5-43)

Exposes call status + a mute control to the Windows volume-flyout / media overlay (WinRT `SystemMediaTransportControls`). `useTauriSmtc` ↔ `native/smtc.rs`. _Experimental — may require an active audio session to surface._

### Network Awareness (P5-49)

Detects Windows connectivity changes (`INetworkListManager`) and nudges the Matrix client to reconnect (`retryImmediately`). `useTauriNetwork` ↔ `native/network.rs`.

### Instant Background Sync (P5-42)

Keeps the `/sync` loop + notifications running full-speed while the app is closed to the tray, by disabling Chromium background throttling via WebView2 `additional_browser_args` (`lib.rs`) — no separate background process. Windows/WebView2 only; doesn't block system sleep.

### Native Rich Notifications (P5-41 / P5-35)

Windows toasts with **click-to-open-room** and **inline quick reply** (WinRT `ToastNotification`, in-process `Activated` event). Falls back to the standard toast otherwise. `useTauriToastActions` ↔ `native/toast.rs`; the desktop notification bridge routes room notifications to it.

### Focus Assist Sync (P5-56)

When Windows Focus Assist / Quiet Hours is active, Lotus suppresses its own notifications + sounds (reuses the quiet-hours gate). `useTauriFocusAssist` + `focusAssistActiveAtom` ↔ `native/focus_assist.rs` (`SHQueryUserNotificationState`).

### Custom Window Chrome (P5-47)

Opt-in (Settings → General → **Custom Window Chrome**): replaces the OS title bar with a TDS-styled titlebar (min / max / close + drag region), runtime-reversible via `set_decorations`. `features/desktop/TitleBar.tsx` + `useTauriWindowChrome` ↔ `native/chrome.rs`.

### Proactive Update Toast (P5-40)

Checks for a new desktop release every 12h and offers a one-click update. `TauriUpdateFeature` (ClientNonUIFeatures) + `useTauriUpdater`.

### Cross-platform composer niceties

- **Composer toolbar drag-reorder (P5-55)** — drag to reorder the composer buttons (Settings → General), via `@atlaskit/pragmatic-drag-and-drop`.
- **Draft-saved indicator (P5-57)** — a subtle cue in the composer when the current room has a persisted draft.
- **Recursive folder drag-drop (P5-48)** — drop a folder to upload every file inside it (all nesting levels), `utils/fileEntries.ts`.

### Files

- Web: `src/app/hooks/useTauri*.ts`, `src/app/components/TauriDesktopFeatures.tsx`, `src/app/features/desktop/TitleBar.tsx`, `src/app/features/room/DraftIndicator.tsx`, `src/app/utils/fileEntries.ts`, `src/app/state/{customWindowChrome,focusAssist}.ts`.
- Native (`cinny-desktop`): `src-tauri/src/native/{power,jumplist,thumbbar,smtc,network,chrome,toast,focus_assist}.rs` + `native/mod.rs` (registered in `lib.rs`).

---

## Key Custom Files

| File                                                           | Purpose                                                                                     |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `src/lotus-terminal.css.ts`                                    | TDS CSS variable definitions, scanline/vignette effects, dark + light palette tokens        |
| `src/lotus-boot.ts`                                            | Matrix-style boot sequence animation on the welcome page                                    |
| `src/app/hooks/useRoomReadPositions.ts`                        | Reactive hook returning `Map<eventId, userId[]>` for per-message read receipts              |
| `src/app/features/room/ReadPositionsContext.ts`                | React context providing read positions map to timeline components                           |
| `src/app/components/read-receipt-avatars/`                     | `ReadReceiptAvatars` component — overlapping avatar pill with overflow count                |
| `src/app/components/event-readers/EventReaders.tsx`            | "Seen by" modal listing readers with display name and timestamp                             |
| `src/app/components/GifPicker.tsx`                             | Giphy-powered GIF picker, TDS-styled, gated on `gifApiKey` in config                        |
| `src/app/features/call/CallControls.tsx`                       | Push to Deafen (M key), PTT visual indicator, TDS typing dots                               |
| `src/app/plugins/call/CallControl.ts`                          | `onControlMutation()` state tracking, screenshare audio mute logic, call button scoping     |
| `src/app/components/CallEmbedProvider.tsx`                     | PiP window, draggable overlay, navigate-on-click, imperative geometry sync                  |
| `src/app/plugins/call/CallEmbed.ts`                            | `getBoundingClientRect()`-based embed positioning, ResizeObserver sync, dark mode injection |
| `src/app/plugins/millify.ts`                                   | Named re-export of `millify` to fix Rolldown `__toESM` CJS interop bug                      |
| `src/app/features/room/MediaGallery.tsx`                       | Images/Videos/Files gallery drawer with pagination and E2EE awareness                       |
| `src/app/features/room/PollCreator.tsx`                        | Poll creation UI for stable `m.poll.start`, single/multiple choice, 2–10 options            |
| `src/app/features/common-settings/general/RoomShareInvite.tsx` | QR code + copy link invite sharing modal                                                    |
| `src/app/utils/syntaxHighlight.ts`                             | TDS-aware syntax highlighter using `--lt-accent-*` inline styles                            |
| `src/app/features/room-settings/ExportRoomHistory.tsx`         | Plain Text / JSON / HTML room history export with date range and E2EE support               |
| `src/app/features/room-settings/RoomActivityLog.tsx`           | Human-readable mod log for member and state change events                                   |
| `src/app/features/room-settings/RoomServerACL.tsx`             | `m.room.server_acl` editor with allow/deny lists and wildcard validation                    |
| `src/app/features/room-settings/RoomInsights.tsx`              | Room stats: top members, top reactions, media breakdown, activity heatmap                   |
| `src/app/features/bookmarks/BookmarksPanel.tsx`                | Bookmarks sidebar panel backed by `io.lotus.bookmarks` account data                         |
| `src/app/hooks/useBookmarks.ts`                                | Hook for reading and mutating the bookmarks account data entry                              |
| `src/app/features/room/ScheduleMessageModal.tsx`               | MSC4140 delayed event scheduling UI with date/time picker                                   |
| `src/app/utils/scheduledMessages.ts`                           | Helpers for creating, listing, and cancelling MSC4140 delayed events                        |
| `src/app/hooks/useExtendedProfile.ts`                          | MSC4133 extended profile fields (`m.pronouns`, `m.tz`) read/write                           |
| `src/app/hooks/useLocalTime.ts`                                | Derives current local time from `m.tz` profile field, updates every 60s                     |
| `src/app/components/url-preview/UrlPreviewCard.tsx`            | 13 domain-specific URL preview layouts plus generic fallback with favicon                   |
| `src/app/features/lotus/avatarDecorations.ts`                  | Avatar decoration catalog, CDN URL, `decorationUrl()` helper                                |
| `src/app/hooks/useAvatarDecoration.ts`                         | Profile field fetch with module-level cache and in-flight deduplication                     |
| `src/app/components/avatar-decoration/AvatarDecoration.tsx`    | APNG overlay wrapper rendered around avatars in timeline, members drawer, autocomplete      |
| `src/app/features/settings/account/ProfileDecoration.tsx`      | Settings decoration picker — scrollable grid, category headers, save button                 |
| `scripts/syncDecorations.mjs`                                  | CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files             |
