# Lotus Chat — Feature Reference

Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
Last updated: July 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
- **Schedule (auto on at night):** an optional schedule with From/To time inputs; when on, the overlay only shows during that window and turns itself on/off automatically (the overlay re-checks every minute, no reload). Overnight windows that wrap midnight (e.g. 21:00 → 07:00) are handled. Window logic is the pure, unit-tested `isWithinTimeWindow`/`parseHHMM` in `src/app/utils/timeWindow.ts` (`timeWindow.test.ts`). Defaults: 21:00–07:00.
- 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
> the Element Call fork reference in [`LOTUS_TODO.md`](./LOTUS_TODO.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
- **Word-level diff**: a "Highlight changes" toggle (on by default) renders each edit as a word diff against the previous version — added words highlighted (green), removed words struck-through (red) — using semantic `<ins>`/`<del>`. Toggle off to see full text (formatted messages render rich there; the diff is plain-text only). Diff logic is the pure, unit-tested `diffWords` (LCS) in `src/app/utils/textDiff.ts`.
- 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
- **Recently used**: a "Recent" row at the top of the picker (shown on the default view, hidden while searching) surfaces the GIFs you last sent for one-click re-sending — no re-searching. Persisted in localStorage (`cinny_recent_gifs_v1`), deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentGif` (`src/app/state/recentGifs.ts`).
- Picker UI is styled with TDS variables when the TDS theme is active
- Located at `src/app/components/GifPicker.tsx`

### Sticker Picker — Recently used

The sticker tab of the shared `EmojiBoard` now has a **"Recent" group** (a sidebar
`RecentClock` icon + top group), matching the emoji and GIF pickers — the stickers you last sent
surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at
least one sticker (hidden otherwise). Persisted in localStorage (`cinny_recent_stickers_v1`),
deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentSticker`
(`src/app/state/recentStickers.ts`). Recent entries are rebuilt into minimal `PackImageReader`s
(`StickerItem` only needs `url`/`shortcode`/`body`) so they render and re-send exactly like pack
stickers. Recorded on select for both the grouped and search paths (shared delegated click).

### Message Forwarding

Context menu → **Forward** allows forwarding a message to any room the user is a member of.

### Copy Message Text

Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.

### On-Device Message Translation

Translate chat messages written in other languages into a language you choose,
inline in the timeline — running **entirely on your device** so message text
never leaves it.

- **Per-message translate** — a foreign-language message shows a **Translate**
  action in its message menu; once translated, the message displays an inline
  **"Translated from &lt;language&gt; · Show original"** toggle that swaps between
  the translation and the original text.
- **Fully on-device / E2EE-preserving** — translation and language detection run
  through the browser's built-in **Translator** and **Language Detector** APIs
  (the Chromium on-device AI translation models). Message text is **never** sent
  to any cloud translation service — no Google / DeepL / Microsoft, not even a
  self-hosted server — and there is **no network fallback**, so end-to-end
  encryption is preserved. That privacy guarantee is the whole point of the
  feature.
- **Automatic detection** — the language of each message is detected
  automatically; messages already in your target language are skipped (no
  Translate action is shown).
- **Settings (Settings → General → Messages):**
  - **Translate Messages Into** — your target language (default **English**;
    ~26 common languages).
  - **Auto-translate Incoming Messages** (default **off**) — automatically
    translates foreign-language messages whose on-device language model is
    already downloaded.
- **One-time model download** — the first time you translate from a given
  language, a small on-device model (a few MB) downloads once. Because the
  browser requires a user gesture for that first download, the initial
  translation needs a click.
- **Availability** — Chromium desktop browsers (**Chrome / Edge 138+**) and the
  **Lotus desktop app** (WebView2 / Chromium). Not available in Firefox, Safari,
  or mobile browsers; where the APIs are unavailable the feature hides itself and
  the settings tile shows a note.

### 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
- **Room-nav draft indicator**: a subtle green dot (the composer's shared `DraftDot`, `color.Success.Main`) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. The dot reuses the composer draft affordance's vocabulary (`role="img"`, aria-label "Unsent draft"). It reacts to the shared draft atom via a memoized `selectAtom(…, hasMsgDraft)` (re-renders only when the flag flips; the atom is written on room-leave, not per keystroke). `useHydrateMsgDrafts` (mounted in `ClientNonUIFeatures`) pre-fills the draft atoms from `draft-msg-*` localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-tested `hasMsgDraft` (`src/app/utils/draft.ts`), also used by the composer's `DraftIndicator`.

### 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`
- **Outgoing interop:** on send, the composer converts `$…$`/`$$…$$` to spec `<span/div data-mx-maths>` HTML in `formatted_body` (extracted before markdown so LaTeX isn't mangled; off inside code), so math renders on Element and every other client — not just Lotus. The plain `body` keeps literal `$…$` as the fallback
- 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` (render), `components/editor/output.ts` (+ `output.test.ts`, outgoing)

### Image / Video Captions

Images and videos can be sent with a caption. The caption and media are sent as a single event (caption = the event `body` when it differs from `filename`).

- **Edit caption**: an image/video you sent shows an **Edit caption** action (quick-actions pencil + message menu). It opens the message editor seeded with the current caption; saving sends an `m.replace` whose `m.new_content` preserves the media (`url`/`info`/encrypted `file`/`filename`) and only changes `body`/`formatted_body`. An empty caption removes it (`body` falls back to `filename`). Gated by `canEditCaption` (`utils/room.ts`) to your own image/video messages that carry an MSC2530 `filename`. Caption edits carry `m.mentions` (an @-mention in a caption notifies) and appear in Edit History (diffed by the word-diff) — even after a caption is removed, the "(edited)" marker remains so history stays reachable. No re-upload — encrypted media keeps its original file/key.

### Location Sharing

`m.location` events render an inline map tile using the coordinates from the event content. The renderer reads the top-level `geo_uri` and **falls back to the MSC3488 `org.matrix.msc3488.location`/`m.location` `uri`**, so locations from clients that send only the new shape still render (previously they showed as broken); an MSC3488 `description`, if present, is shown above the coordinates.

Sharing your location (composer → location button) sends an **MSC3488-compliant** `m.location` event: the legacy `geo_uri` plus the `org.matrix.msc3488.location` (uri), `org.matrix.msc3488.asset` (`m.self`), and `org.matrix.msc3488.ts`/`m.ts` blocks, and a human-readable `body`. This makes Lotus-shared locations render as proper pins on Element and other clients instead of falling back to plain text.

### 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
- Live-renders edits/redactions, text search, jump-to-message, and remove
- **Author attribution**: each saved-message card shows who wrote it (`{sender} · {time ago}`). The author is snapshotted at save time (`senderId`/`senderName` on the bookmark, optional for backward compatibility) and re-resolved live from the event when the room is joined; search also matches the author name.
- **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`).
- 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 send-now, edit, and cancel buttons
- **Send now**: the tray's send button fires a pending message immediately via MSC4140 `action: 'send'` (the server dispatches the stored delayed event now, as a normal timeline event — no cancel+retype). The row is pruned only once the server confirms; a failed send leaves an inline "Could not send now" error with the message still sendable/editable/cancellable.
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
- Utilities in `src/app/utils/scheduledMessages.ts`

### Message Reminders

- Message context menu → **Remind Me** sets a personal reminder to revisit a message; reminders are stored in `io.lotus.reminders` account data (sync across devices) via `useReminders`, and fire from `ClientNonUIFeatures`.
- `RemindMeDialog.tsx` offers quick presets (in 20 min / 1 hour / 3 hours / tomorrow 9am) **plus a "Custom time…" option** that reveals date + time pickers for an arbitrary reminder time (validated to be ≥ 1 minute in the future).
- **Manage existing reminders**: opening the dialog on a message that already has reminders lists them (soonest first, friendly time via `formatFriendlyDateTime`) each with a cancel (×) button, so you can see and remove pending reminders instead of silently stacking duplicates.
- The date/time input helpers (`toLocalDate`, `toLocalTime`, `parseLocalDateTime`, `pickerInputStyle`) are shared, pure, and unit-tested in `src/app/utils/datetimeInput.ts` (`datetimeInput.test.ts`) — also used by `ScheduleMessageModal` (deduped from a prior inline copy).

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

### Inline Media Embeds

Media links play/render **in place** instead of opening a browser tab. A pure
resolver, `parseMediaEmbed(url, host)` in `src/app/utils/videoEmbed.ts`, maps a
URL to `{ provider, kind, embedUrl }`; `MediaEmbedCard` / `TikTokEmbedCard` /
`TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s:

| kind        | shape                     | providers                                                          |
| ----------- | ------------------------- | ------------------------------------------------------------------ |
| `landscape` | 16:9 video player         | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
| `portrait`  | 9:16 video player         | YouTube Shorts, TikTok                                             |
| `audio`     | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal                            |
| `rich`      | self-resizing post embed  | X/Twitter, Instagram, Reddit, Bluesky                              |

**Privacy-friendly facade.** The tile first shows the homeserver's cached
`og:image` thumbnail + a play button; the third-party `<iframe>` is only mounted
on click, so nothing hits Google/Meta/etc. until the user opts in. A **Close**
button collapses a playing embed back to the facade, and video players carry a
Fullscreen control. Cookie-less/DNT variants are used where offered
(`youtube-nocookie.com`, Vimeo `dnt=1`).

**Provider notes.**

- **TikTok** — short "copy-link" URLs (`vm.tiktok.com`, `tiktok.com/t/…`) carry no
  video id and the homeserver preview is bot-walled, so `TikTokEmbedCard`
  resolves the id client-side via TikTok's CORS-enabled **oEmbed** API on click
  (`AbortController`-guarded), then plays the `player/v1` embed.
- **Reddit / Instagram / Bluesky / X** — post embeds self-size via `postMessage`;
  `useIframeAutoHeight` listens scoped to each provider's origin **and** our own
  iframe, parsing each provider's height shape (Instagram `MEASURE`, Reddit
  `resize.embed`, Twitter `twttr.private.resize`). `redd.it` short links resolve to
  the subreddit-less `embed.reddit.com/comments/{id}/` route.
- **Vimeo** unlisted-video privacy hashes (`vimeo.com/{id}/{hash}`) and
  channel/group/album forms are parsed; **YouTube** handles `/watch`, `youtu.be`,
  `/embed`, `/live`, `/shorts`, and `m.`/`music.youtube.com`.

**Defense-in-depth.** Every embed iframe carries a `sandbox` that omits
`allow-top-navigation` (so a compromised embed can't redirect the whole app —
phishing guard) on top of the CSP `frame-src` allowlist. Previews are also capped
at 6 per message.

**Setting.** `inlineMediaEmbeds` (Settings → General → "Inline Media Players",
default **on**). Off → media links fall back to plain link tiles.

**Latent web bug fixed along the way.** YouTube thumbnails now come from the
homeserver `og:image` instead of `img.youtube.com` — which was silently broken on
the web build (nginx `img-src` has no YouTube host) — removing a pre-click Google
request as a bonus.

**CSP.** Desktop Tauri `frame-src` (`cinny-desktop` `tauri.conf.json`) and the web
nginx `frame-src` allowlist enumerate every embed host (youtube-nocookie,
player.vimeo, geo.dailymotion, streamable, player/clips.twitch, open.spotify,
w.soundcloud, embed.music.apple, embed.tidal, www.tiktok + connect-src for its
oEmbed, platform.twitter, www.instagram, embed.reddit, embed.bsky.app, www.loom,
player.kick).

**Files:** `src/app/utils/videoEmbed.ts` (resolver + parsers, unit-tested),
`src/app/components/url-preview/{UrlPreviewCard,UrlPreview.css}.tsx`.

### Poll Creation

- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
- Supports 2 to 10 answer options; single-choice or multiple-choice
- **Max selections** — for a multiple-choice poll, a "Voters can pick up to N of M options" control sets `max_selections` (2 … option count), so you can run "pick your top 2" polls rather than only "select all". Defaults to the option count (unchanged "select all that apply" behavior) until you lower it; the display side already enforces the cap ("Select up to N")
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
- Accessible via the `Icons.OrderList` button in the composer toolbar

### Poll Display & Voting (MSC3381, full lifecycle)

`PollContent.tsx` is a fully interactive, spec-correct poll card:

- **Vote / change / clear** in place — sends stable `m.poll.response` (`m.selections`); latest response per voter wins; clearing removes you from the tally. Multi-choice enforces `max_selections` ("Select up to N").
- **Disclosed vs undisclosed** — undisclosed polls hide counts/percentages/bars (and the vote total) until the poll ends; disclosed polls show live results.
- **See who voted** — a "Show who voted" toggle (shown only when results are visible, i.e. disclosed live or undisclosed-after-end) reveals the voter names under each answer. The voter list rides the same tally as the counts (`voters: Map<answerId, senderId[]>` populated in `tallyResponses`'s latest-response-per-sender loop), so it can never disagree with the numbers; a re-vote moves the voter, and a cleared vote drops them. Names via `getMemberName`; undisclosed polls stay secret until close.
- **End a poll** — the poll's creator or a moderator (redact power) can end it via an inline confirm; sends stable `m.poll.end`. Ended polls lock voting, show "Poll closed · Final results", reveal results, and highlight the winner(s) (ties supported). Only responses cast on/before the end event count.
- **Cross-client** — reads **both** the stable (`m.poll`/`m.id`/`m.selections`) and unstable (`org.matrix.msc3381.poll.*`) wire formats by hand (matrix-js-sdk 41.7.0's poll parsers only speak unstable), and uses the SDK `Poll` model for end-event validation (creator / redact-PL) + before-end response filtering. Polls authored in Element render/vote/end correctly and vice-versa.
- **Accessibility** — `radiogroup`/`radio` (single, with arrow-key roving) or `group`/`checkbox` (multi) semantics, `aria-checked`/`aria-disabled`, winner announced to AT.
- Pure tally/visibility/winner/voters + wire-format parsing live in `utils/poll.ts` (+ `poll.test.ts`, 18 tests incl. the stable/unstable round-trip and voter attribution).

### Voice Message Playback (waveform + speed)

`AudioContent.tsx` is the shared audio player (timeline + Media Gallery Audio tab):

- **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar.
- **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it).
- **Recording pause / resume** — `VoiceMessageRecorder.tsx` supports a `paused` state via `MediaRecorder.pause()/resume()`, so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control.

---

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

### Threads List Panel

A room-level overview of **all** threads, opened from a **Threads** button (🧵) in the room header (mirrors the gallery/widgets toggles). Each row shows the root sender + message snippet, an unread dot, a meta line ("N replies · last reply 5m ago") and a **participant avatar pile**. A segmented **filter** (All / Unread / Participating — the latter via `thread.hasCurrentUserParticipated`) and **sort** (Recent / Oldest, by last-reply time) sit in the toolbar; both persist in localStorage (`cinny_threads_filter_v1` / `cinny_threads_sort_v1`). Clicking a row opens the existing single-thread `ThreadPanel` (reuses `setActiveThreadId`), and reading it clears the row's unread badge live. The list stays live via `ThreadEvent.New/NewReply/Update/Delete` + `RoomEvent.UnreadNotifications` and is virtualized for busy rooms.

- Files: `features/room/thread/ThreadsListPanel.tsx`, `hooks/useRoomThreads.ts` (populates via `room.fetchRoomThreads()` + `room.getThreads()`; last-activity + reply-count + root-edit signature so rows refresh live), `state/threadsList.ts`, pure filter/sort in `utils/threadList.ts` (`filterThreads`/`sortThreads`, unit-tested). Unread mirrors `useThreadSummary`'s logic (`getThreadUnreadNotificationCount`, muted threads zeroed) and renders the app-wide `UnreadBadge` (red for mentions via the Highlight count). Reuses `StackedAvatar`/`useMemberAvatar` for the participant pile and the Bookmarks-panel segmented-control pattern.

### 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`). **Slash commands work in threads** — content-transform commands (`/me`, `/notice`, `/shrug`, `/tableflip`, `/unflip`) route into the thread via the normal send path, and room-level commands (`/invite`, `/kick`, …) act on the room; this also matches the command autocomplete, which was already shown in the thread composer. Scheduling is still disabled inside threads (v1).

**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply _in that thread_ — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).

### 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
- **Emoji picker is unicode-only** (`EmojiBoard hideCustomEmojis`): a status is plain-text presence (`status_msg`) that can't render a custom mxc-image emoji, so the picker hides custom/image-pack emojis (packs, sidebar icons, search, and custom entries in Recent) — every emoji shown actually inserts. (Previously custom emojis were listed but silently did nothing when clicked, since there was no `onCustomEmojiSelect` on this field.)
- 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
- **Status presets**: a "Quick statuses" row of 11 built-in presets spanning gaming/social/life/work — 🎮 Gaming, 🎧 In a party, 🏆 Ranked grind, 😴 AFK, 🍿 Watching, 🍽️ Lunch, 🗓️ In a meeting, 🏠 Working remotely, 🎯 Focusing, 🌴 On vacation, 🤒 Out sick (see `BUILT_IN_STATUS_PRESETS`). Clicking a preset applies its message + suggested auto-clear in one click. Users can also save the current status as a reusable custom preset (stored in `io.lotus.status_presets` account data, synced across devices, de-duped by label, capped at 20; a saved preset matching a built-in is hidden to avoid a duplicate chip) and delete presets inline. Built-in list + pure `upsertPreset` de-dupe/cap logic live in `src/app/utils/statusPresets.ts` (unit-tested); persistence in `src/app/hooks/useStatusPresets.ts`.

### 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 mentions / keyword highlights (the count is highlights, not total unread)
- `· Lotus Chat` — unread messages without a mention (activity, no specific count)
- `Lotus Chat` — no unread items

The favicon mirrors this (highlight badge / unread dot / default).

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

### Forward to Multiple Rooms (P6-3)

The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.

- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room _before_ the forwarded message (sequenced per room; a room counts as failed if either send fails).
- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row.

### Live Bookmark Previews (P6-3)

`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.

### 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 fixed height threshold are truncated with a "Show more" toggle.

- Threshold: a fixed `COLLAPSE_MAX_HEIGHT` of 320px (≈ 20 lines) — not currently user-configurable
- 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 locally via `qrcode.react` (`QRCodeSVG`) — no third-party service, works offline and under strict CSP, on a white quiet-zone so it scans on any theme
- **Download QR**: exports the code as a PNG via an offscreen high-resolution (1024px, spec 4-module margin) `QRCodeCanvas` + `canvas.toBlob`, saved through `useSaveFile` (filename derived from the room name) with the standard download toast
- "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.

- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, **images support zoom & pan** (scroll wheel or −/+ header buttons, `+`/`-`/`0` keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shared `useZoom`/`usePan` hooks; zoom resets when navigating to another item.
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play)
- **Files** — name/size/sender rows with download
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error)

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

- Enter a policy-list room's **ID or alias** (one you have already joined) to view its `m.policy.rule.user` / `.room` / `.server` rules in read-only format
- Viewer only — there are **no** subscribe/unsubscribe controls and no listing of "subscribed" lists; join or leave the policy-list room itself the normal way
- Enforcement is delegated to Draupnir or equivalent tooling

---

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

### Pause Notifications (snooze)

- A **cross-platform** "Pause Notifications" control in **Settings → Notifications** (the desktop-tray Do Not Disturb only worked on the desktop app; web/mobile had no manual pause).
- Quick presets: 30 minutes / 1 hour / 4 hours / Until 8 AM / **Until I resume** (indefinite), plus a **Resume** button; the tile shows the live "Paused until …" status (via `formatFriendlyDateTime`) and flips back to "on" the moment the snooze lapses.
- Persisted (`cinny_notification_snooze_until_v1`) as the epoch-ms instant to pause until (`0` = off), so a snooze survives a reload. Feeds the same notification gate as Focus Assist / Quiet Hours (`ClientNonUIFeatures`), suppressing both `notify()` and `playSound()`.
- Pure, unit-tested helpers `isSnoozeActive` / `nextTimeAtHour` / `SNOOZE_INDEFINITE` in `src/app/utils/snooze.ts` (`snooze.test.ts`); persisted atom in `src/app/state/notificationSnooze.ts`.

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

---

## Accessibility (P3-4)

WCAG 2.1 AA hardening of the golden path (find room → read → reply → send) for keyboard and screen-reader users.

- **Timeline for screen readers:** each message is `role="article"`; **collapsed messages announce their sender + time** (they drop the visible header, so AT would otherwise hear the body with no attribution). The timeline is a `role="log"` `aria-live="polite"` region so new messages are announced; emoji/emoticons carry text labels.
- **Live status:** typing indicators announce via a `role="status"` region; editing a message announces "Editing message from <sender>".
- **Forms & overlays:** all inputs have associated labels (visible `<label htmlFor>` or `aria-label`); the Media Gallery and Search overlays are named.
- **Focus management:** skip-to-content link + `nav`/`main` landmarks; genuine dialogs return focus to their trigger on close (inline popouts intentionally keep focus in context).
- **Keyboard-shortcuts help:** press <kbd>?</kbd> for a dialog of the existing shortcuts (Escape, type-to-focus composer, Enter/Shift+Enter send, message actions).
- **Regression gate:** a curated `eslint-plugin-jsx-a11y` rule set (ARIA correctness + label association) runs in CI. Files: `components/message/*`, `features/room/RoomViewTyping.tsx`, `features/shortcuts/*`, `utils/a11y.ts`, `eslint.config.mjs`.
- _Known limitation:_ list virtualization keeps far-scrolled history out of the a11y tree (perf trade-off); newly-arriving messages are announced.

## 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 diagnosis: the Encryption / E2EE section of [`LOTUS_TODO.md`](./LOTUS_TODO.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`).

### Linux parity + cross-platform extras (P6-1)

Rounds out the native app beyond Windows (macOS out of scope):

- **No-sleep during calls on Linux** — a D-Bus `org.freedesktop.ScreenSaver` inhibit (zbus) keeps the display awake mid-call, matching the Windows behavior. `native/power.rs`.
- **Launcher unread badge on Linux** — best-effort Unity `LauncherEntry` D-Bus signal (Ubuntu/Dash-to-Dock/KDE), mirroring the Windows taskbar badge.
- **Launch on login** — `tauri-plugin-autostart` + a **Settings → General "Launch on login"** toggle (desktop-only).
- **Tray "Do Not Disturb"** — a tray checkbox that silences Lotus notifications (feeds `manualDndAtom` into the same quiet-gate as Focus Assist). `useTauriDnd`.

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