Compare commits
37 Commits
71386f4ef2
...
107921e0d0
| Author | SHA1 | Date | |
|---|---|---|---|
| 107921e0d0 | |||
| 053b364a44 | |||
| 3282832a4a | |||
| 00524bebe0 | |||
| 9df4d2d7ee | |||
| 2c5f0b8b28 | |||
| 702e2e00eb | |||
| 2b1c3256b6 | |||
| 6a57c13c56 | |||
| 362f4943d4 | |||
| f15c4caf97 | |||
| aa48c9ef8a | |||
| 3df9c4d9e6 | |||
| 2178295eaa | |||
| 055dcec65b | |||
| 9a24feb914 | |||
| 46567555e1 | |||
| b41bfd35c0 | |||
| 6a83e67f95 | |||
| 469b9aa9c6 | |||
| 77a29ed3c6 | |||
| a30a3d3a47 | |||
| a9787ef041 | |||
| fcf16fd654 | |||
| 0a14ec63de | |||
| 5469740f4c | |||
| 891f2daf99 | |||
| b7daabe2e0 | |||
| d78f81c3a7 | |||
| 170d22eebb | |||
| c8ff7b0718 | |||
| bafd9cbe75 | |||
| f11b308f91 | |||
| 81e1a25de6 | |||
| 8ff2f33d3a | |||
| ea15db430c | |||
| 7e4178f7e2 |
@@ -0,0 +1,30 @@
|
||||
name: Trigger Desktop Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [lotus]
|
||||
|
||||
jobs:
|
||||
trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Bump cinny submodule
|
||||
env:
|
||||
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||
run: |
|
||||
CINNY_SHA="${{ github.sha }}"
|
||||
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
|
||||
cd desktop
|
||||
git config user.email "ci@lotusguild.org"
|
||||
git config user.name "Lotus CI"
|
||||
git submodule update --init cinny
|
||||
git -C cinny fetch origin
|
||||
git -C cinny checkout "$CINNY_SHA"
|
||||
git add cinny
|
||||
if git diff --cached --quiet; then
|
||||
echo "Submodule already at $CINNY_SHA, nothing to do"
|
||||
else
|
||||
git commit -m "chore: bump cinny submodule to ${CINNY_SHA:0:8}"
|
||||
git push origin main
|
||||
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
||||
fi
|
||||
@@ -0,0 +1,69 @@
|
||||
# Lotus Chat — Bug Report & Technical Audit
|
||||
|
||||
**Date:** June 2026
|
||||
|
||||
This document tracks identified bugs, edge cases, and architectural discrepancies.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Resolved Issues (Recently Fixed)
|
||||
|
||||
- **GIF Sending Bypasses E2EE**: Fixed in `RoomInput.tsx`.
|
||||
- **Scheduled Messages Bypass E2EE**: Fixed in `scheduledMessages.ts`.
|
||||
- **Drag-and-Drop Overlay Persistence**: Fixed in `useFileDrop.ts`.
|
||||
- **Stale Member List in Verification Banner**: Fixed in `useDeviceVerificationStatus.ts`.
|
||||
- **Incomplete Python Comment Highlighting**: Fixed in `syntaxHighlight.ts`.
|
||||
- **Search Button Hidden in E2EE Rooms**: Fixed in `RoomViewHeader.tsx`.
|
||||
- **TDS Design Law Violations (Hardcoded Hex)**: Fixed in `GifPicker.tsx` and `VoiceMessageRecorder.tsx`.
|
||||
- **Recent Emoji Sort Order**: Fixed in `recent-emoji.ts` (recency order, not frequency).
|
||||
- **Encrypted Search Misses Historic Events**: Fixed in `useLocalMessageSearch.ts`.
|
||||
- **Presence Updater Base URL Hack**: Fixed in `usePresenceUpdater.ts`.
|
||||
- **Presence Badge Accessibility**: Fixed in `Presence.tsx` (`aria-label` on badge).
|
||||
- **Presence Updater Wipes Custom Status**: Fixed in `usePresenceUpdater.ts` (removed `status_msg: ''`).
|
||||
- **Manifest Main Icon Paths 404**: Fixed in `public/manifest.json` (`./public/android/` → `./res/android/`). Shortcut icon was already correct.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Critical Security & Logic
|
||||
|
||||
### 1. Edit History Broken for E2EE
|
||||
|
||||
**File:** `src/app/features/room/message/EditHistoryModal.tsx`
|
||||
**Status:** **FIXED**
|
||||
|
||||
- **Issue:** The modal fetches edit history via raw `fetch`. Edit events not found in the room cache were constructed from raw encrypted content and never decrypted.
|
||||
- **Fix:** Each newly constructed `MatrixEvent` is now passed through `mx.decryptEventIfNeeded()` before rendering when `evt.isEncrypted()` is true.
|
||||
|
||||
### 2. Service Worker Ephemeral Sessions
|
||||
|
||||
**File:** `src/sw.ts`
|
||||
**Status:** **NOT A BUG — by design**
|
||||
|
||||
- The `sessions` Map is intentionally in-memory. The main window re-posts the session to the SW via `postMessage` on every load. Persisting access tokens in SW IndexedDB would duplicate credential storage unnecessarily and is not required for the current feature set.
|
||||
|
||||
---
|
||||
|
||||
## 📱 PWA & Mobile Issues
|
||||
|
||||
### 1. No PWA Precaching (Offline Mode Broken)
|
||||
|
||||
**File:** `src/sw.ts`, `vite.config.js`
|
||||
**Status:** **DEFERRED — out of scope**
|
||||
|
||||
- Full offline Matrix requires persisting sync state, E2EE keys, and an event send queue. The SW exists for authenticated media and notifications, which it handles correctly. Adding Workbox precaching is a multi-sprint project with limited benefit for a Matrix client.
|
||||
|
||||
### 2. PiP Resize Impossible on Mobile
|
||||
|
||||
**File:** `src/app/components/CallEmbedProvider.tsx`
|
||||
**Status:** **FIXED**
|
||||
|
||||
- **Issue:** Resize corner `onMouseDown` handlers did not fire on touch devices.
|
||||
- **Fix:** Added `handleResizeTouchStart` using touch events with the same geometry math extracted into a shared `applyResize` helper. `onTouchStart` is now wired to all four resize corners.
|
||||
|
||||
### 3. Double Background Animation (GPU Waste)
|
||||
|
||||
**File:** `src/app/pages/client/SidebarNav.tsx`, `src/app/features/room/RoomView.tsx`
|
||||
**Status:** **FIXED**
|
||||
|
||||
- **Issue:** When Glassmorphism is enabled, the chat background was rendered on both `document.body` and `RoomView`, running the same CSS animation twice.
|
||||
- **Fix:** `RoomView` now reads the `glassmorphismSidebar` setting and skips applying `chatBgStyle` when it is active, relying entirely on the `document.body` background that `SidebarNav` already mirrors.
|
||||
@@ -0,0 +1,908 @@
|
||||
# Lotus Chat — Feature Reference
|
||||
|
||||
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
||||
Last updated: June 2026.
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Branding & Identity](#branding--identity)
|
||||
2. [LotusGuild Terminal Design System (TDS) v1.2](#lotusguild-terminal-design-system-tds-v12)
|
||||
3. [Animated Chat Backgrounds (P5-4)](#animated-chat-backgrounds-p5-4)
|
||||
4. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
|
||||
5. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
|
||||
6. [Voice / Video Call Improvements](#voice--video-call-improvements)
|
||||
7. [Per-Message Read Receipts](#per-message-read-receipts)
|
||||
8. [Delivery Status Indicators](#delivery-status-indicators)
|
||||
9. [Messaging Enhancements](#messaging-enhancements)
|
||||
10. [Presence](#presence)
|
||||
11. [UX & Composer](#ux--composer)
|
||||
12. [Room Customization](#room-customization)
|
||||
13. [Moderation](#moderation)
|
||||
14. [Notifications](#notifications)
|
||||
15. [Server Integration](#server-integration)
|
||||
16. [Infrastructure](#infrastructure)
|
||||
17. [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.
|
||||
|
||||
### Files
|
||||
|
||||
- `src/app/styles/Animations.css.ts` — vanilla-extract keyframe definitions
|
||||
- `src/app/features/lotus/chatBackground.ts` — `getChatBg()` implementation and pattern registry
|
||||
|
||||
---
|
||||
|
||||
## Glassmorphism Sidebar (P5-3)
|
||||
|
||||
An optional frosted-glass sidebar style toggled in **Settings → Appearance**.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `SidebarGlass` vanilla-extract class applies `background: rgba(3, 5, 8, 0.55)` and `backdropFilter: blur(12px)` to the sidebar element
|
||||
- `SidebarNav.tsx` uses a `useEffect` to mirror the active chat background onto `document.body` when the glassmorphism setting is enabled, so the blur filter has meaningful content to work through
|
||||
- Degrades gracefully on browsers without `backdrop-filter` support (falls back to the semi-transparent background)
|
||||
|
||||
---
|
||||
|
||||
## Night Light / Blue Light Filter (P5-5)
|
||||
|
||||
A warm orange overlay rendered over the entire UI to reduce blue light emission.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `NightLightOverlay` component mounted directly in `App.tsx`
|
||||
- CSS: `position: fixed; inset: 0; pointer-events: none; z-index: 9998`
|
||||
- Orange tint color with configurable opacity
|
||||
- **Controls:** Toggle to enable/disable + intensity slider ranging from 5% to 80% opacity
|
||||
- Settings persisted via the standard Lotus settings store
|
||||
|
||||
---
|
||||
|
||||
## Font Selector (P5-22)
|
||||
|
||||
Users can choose the UI font in **Settings → Appearance**:
|
||||
|
||||
- **System Default** — `system-ui, -apple-system, sans-serif`
|
||||
- **Inter** — `'InterVariable', sans-serif` (current default)
|
||||
- **JetBrains Mono** — `'JetBrains Mono', monospace` (already loaded from Google Fonts)
|
||||
- **Fira Code** — `'Fira Code', monospace` (added to Google Fonts preload in `index.html`)
|
||||
|
||||
Applied by overriding `--font-secondary` on `document.body` via `AppearanceEffects` in `App.tsx`. The TDS terminal mode font stack is unaffected.
|
||||
|
||||
---
|
||||
|
||||
## Custom @Mention Highlight Color (P5-21)
|
||||
|
||||
Users can set a custom background color for `@mention` chips that highlight their own name, in **Settings → Appearance**.
|
||||
|
||||
- Color picker (native `<input type="color">`) with a **Reset** button to revert to the theme default
|
||||
- Text color (black/white) auto-computed from the chosen background's luminance for readability
|
||||
- Applied via CSS custom properties `--mention-highlight-bg`, `--mention-highlight-text`, `--mention-highlight-border` set on `document.body`
|
||||
- `CustomHtml.css.ts` uses these as CSS `var()` fallbacks over the original folds `Success` token colors
|
||||
|
||||
---
|
||||
|
||||
## Voice / Video Call Improvements
|
||||
|
||||
### Element Call Upgrade
|
||||
|
||||
Upgraded embedded Element Call widget from **0.16.3** to **0.19.4**.
|
||||
|
||||
### Camera Default Off
|
||||
|
||||
Camera starts disabled on join. The `cameraOnJoin` setting is explicitly opt-in and is not persisted to `localStorage` between sessions, preventing the previous behavior where a prior-session preference could unexpectedly enable the camera.
|
||||
|
||||
### UI Fixes
|
||||
|
||||
- **Deafen button** — corrected tooltip text that was previously swapped with the mute tooltip
|
||||
- **Screenshare confirmation** — a confirmation dialog is shown before initiating a screenshare broadcast to the room
|
||||
- **Auto-revert spotlight on screenshare** — removed; the 600ms grid-revert click was causing fullscreen screenshare to show avatar tiles instead of the screen. EC now handles layout natively.
|
||||
|
||||
### Push to Talk (PTT)
|
||||
|
||||
- Configurable keybind; defaults to `Space`
|
||||
- Visual indicator shown in both TDS and non-TDS themes while PTT is active
|
||||
- Event listener attached to both the main window and the Element Call iframe's `contentWindow` to ensure reliable capture regardless of focus
|
||||
- Mic state is preserved correctly when switching into or out of PTT mode
|
||||
|
||||
### Push to Deafen
|
||||
|
||||
`M` key triggers `toggleSound()` in `CallControls.tsx`, toggling the deafen state without requiring a mouse click.
|
||||
|
||||
### AFK Auto-Mute in Voice (P5-11)
|
||||
|
||||
Automatically mutes the microphone after a configurable period of microphone-on silence.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `useAfkAutoMute(callEmbed)` hook opens a separate monitoring-only `getUserMedia` stream (independent of Element Call's stream) and analyzes it via `AudioContext` + `AnalyserNode`
|
||||
- RMS level is sampled every 500ms; if it stays below threshold while the mic is on, the silence timer starts
|
||||
- After the configured timeout (`afkTimeoutMinutes` setting), `callEmbed.control.setMicrophone(false)` mutes the mic and an in-app toast is shown
|
||||
- Monitoring stream and `AudioContext` are fully cleaned up on unmount (no resource leak)
|
||||
- Activated inside `CallControls` via `useAfkAutoMute(callEmbed)` — no changes required to `CallEmbed` or Element Call
|
||||
|
||||
**Settings (Settings → Calls):**
|
||||
|
||||
- **AFK Auto-Mute** toggle (default: off)
|
||||
- **Idle Timeout** dropdown — 1 / 5 / 10 / 20 / 30 minutes (shown only when enabled; default: 10 minutes)
|
||||
|
||||
Hook: `src/app/hooks/useAfkAutoMute.ts`
|
||||
|
||||
### Voice Channel User Limit (P5-10)
|
||||
|
||||
Room admins can cap the number of participants allowed in a room's voice call. The cap is a **hard, server-side limit enforced for every Matrix client** (Element, FluffyChat, …), backed by a client-side UX layer in Lotus Chat.
|
||||
|
||||
**Client (this repo):**
|
||||
|
||||
- Limit is stored in the `io.lotus.voice_limit` room state event with content `{ max_users: N }` (0 / absent = no limit)
|
||||
- `RoomVoiceLimit` component in Room Settings → General → **Voice** lets admins set the cap with a number input. Editing is gated by `permissions.stateEvent(StateEvent.LotusVoiceLimit, …)`, so only users with `state_default` power (or above) can change it
|
||||
- `CallPrescreen` (`CallView.tsx`) reads the limit reactively via `useStateEvent` and compares it against the live `useCallMembers` count; at capacity the **Join** button is disabled and a "Channel Full (N/N)" message is shown
|
||||
- A user already in the session (rejoining) is never blocked — only new joiners are gated
|
||||
|
||||
Files: `src/app/features/common-settings/general/RoomVoiceLimit.tsx`, `src/app/features/call/CallView.tsx`, `StateEvent.LotusVoiceLimit` in `src/types/matrix/room.ts`
|
||||
|
||||
**Server (the hard backstop — `matrix` repo `livekit/voice-limit-guard.py`):**
|
||||
|
||||
- Every client must fetch a LiveKit JWT from `lk-jwt-service` before joining a call. A fail-open guard sidecar sits in front of it (guard on `:8070`, lk-jwt-service moved to `:8071`)
|
||||
- On each token request the guard reads the room's `io.lotus.voice_limit` (Synapse admin API), and if the room is at capacity it returns `403` so the client cannot obtain a token and therefore cannot join — regardless of which client they use
|
||||
- Distinct Matrix users are counted via LiveKit `ListParticipants`; rejoins / extra devices are allowed. Any failure fails open so calls never break
|
||||
|
||||
> The client-side "Channel Full" check is UX/early-feedback; the server guard is the actual enforcement.
|
||||
|
||||
### Custom Join / Leave Sound Effects (P5-16)
|
||||
|
||||
A local sound plays when another participant joins or leaves a call you're in.
|
||||
|
||||
**Implementation:**
|
||||
|
||||
- `useCallJoinLeaveSounds(embed)` hook (wired in `CallUtils` inside `CallEmbedProvider`) listens to `MatrixRTCSession` membership changes via `useCallMembersChange`
|
||||
- Membership identity is tracked by `sender|deviceId`; a snapshot is taken when the session (re)starts so participants already present never trigger a sound
|
||||
- Your own membership is filtered out (`mx.getSafeUserId()` prefix), and sounds fire only while you are actually joined (`useCallJoined`)
|
||||
- Sounds are synthesized in-browser with the Web Audio API (`OscillatorNode` + envelope) — no audio assets to bundle. Join uses a rising motif, leave a falling one
|
||||
- Three styles: **Chime** (sine), **Soft** (triangle), **Retro** (square arpeggio), plus **Off**
|
||||
|
||||
**Settings (Settings → Calls):**
|
||||
|
||||
- **Join & Leave Sounds** dropdown — Off / Chime / Soft / Retro (default: Chime). Selecting a style previews the join sound immediately
|
||||
|
||||
Files: `src/app/utils/callSounds.ts`, `src/app/hooks/useCallJoinLeaveSounds.ts`
|
||||
|
||||
### Noise Suppression Toggle
|
||||
|
||||
A `noiseSuppression` URL parameter is passed to the Element Call widget URL, allowing the noise suppression feature to be toggled from within Lotus settings.
|
||||
|
||||
### Call Button Scoping
|
||||
|
||||
The call button is shown only in DMs and invite-only rooms that do not have an `m.space.parent` event. It is hidden in public rooms and space channels to avoid accidental broadcast calls.
|
||||
|
||||
### Picture-in-Picture (PiP)
|
||||
|
||||
- 280×158px floating window that stays on screen when navigating away from the call room
|
||||
- Draggable via pointer events with a 5px movement threshold to distinguish drags from clicks; touch events are supported
|
||||
- Clicking the PiP window navigates back to the call room
|
||||
- Implemented with an imperative `useEffect` style for position overrides because `useCallEmbedPlacementSync` writes geometry directly onto the DOM element, making declarative approaches unreliable
|
||||
|
||||
### Call Embed Positioning
|
||||
|
||||
- Uses `getBoundingClientRect()` (viewport-relative) instead of the previous `offsetTop`/`offsetLeft` (parent-relative) calculations, fixing misalignment in scrolled layouts
|
||||
- Position is synced on mount via `useEffect` with a `ResizeObserver` to handle dynamic layout changes
|
||||
|
||||
### Dark Mode in Element Call
|
||||
|
||||
`applyStyles()` injects `:root { color-scheme: dark | light }` into the Element Call iframe after the user joins. The theme is also updated when `setTheme()` is called, keeping the call UI in sync with the Lotus theme selection.
|
||||
|
||||
### Call Embed Wallpaper
|
||||
|
||||
The active `chatBackground` pattern is applied to the `div[data-call-embed-container]` wrapper. The iframe's `html, body` are forced to `background: none !important` so the host pattern shows through.
|
||||
|
||||
### TDS Typing Indicator
|
||||
|
||||
The animated typing indicator dots use `var(--lt-accent-orange)` as their color when the TDS theme is active, matching the terminal aesthetic.
|
||||
|
||||
---
|
||||
|
||||
## Per-Message Read Receipts
|
||||
|
||||
A full per-message read receipt system showing exactly who has seen each message.
|
||||
|
||||
### Core Hook — `useRoomReadPositions`
|
||||
|
||||
```ts
|
||||
useRoomReadPositions(room: Room): Map<eventId, userId[]>
|
||||
```
|
||||
|
||||
- Listens to `Room.localEchoUpdated` and `RoomMember.typing` events to stay reactive
|
||||
- Debounced 150ms to avoid excessive re-renders during rapid receipt updates
|
||||
- Located at `src/app/hooks/useRoomReadPositions.ts`
|
||||
|
||||
### `nearestRenderableId()`
|
||||
|
||||
A utility that walks backward through the event timeline from a given event ID to find the nearest event that is actually rendered (skipping reactions, edits, and other non-display events). Used to map a user's read position to a visible message.
|
||||
|
||||
### `ReadPositionsContext`
|
||||
|
||||
React context at `src/app/features/room/ReadPositionsContext.ts` that provides the positions map to all timeline components without prop drilling.
|
||||
|
||||
### `ReadReceiptAvatars`
|
||||
|
||||
A pill of overlapping 24px user avatars displayed at the bottom-right of each message. Shows a maximum of 5 avatars; additional readers are shown as an overflow count (e.g., `+3`).
|
||||
|
||||
### "Seen by" Modal — `EventReaders`
|
||||
|
||||
Clicking the avatar pill opens a modal (`src/app/components/event-readers/EventReaders.tsx`) listing:
|
||||
|
||||
- User avatar
|
||||
- Display name
|
||||
- Formatted timestamp of when the receipt was recorded
|
||||
- Respects the `hour24Clock` setting for timestamp formatting
|
||||
|
||||
---
|
||||
|
||||
## Delivery Status Indicators
|
||||
|
||||
Visual feedback on message delivery state, shown on the sender's own messages:
|
||||
|
||||
| State | Indicator |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| Sending (local echo) | ⟳ rotating clock icon |
|
||||
| Sent (server ACK received) | ✓ checkmark |
|
||||
| Failed | ✕ in red; orange glow in TDS |
|
||||
|
||||
The indicator is hidden once the server confirms the event (when the internal status transitions to `null`), keeping the timeline clean for settled messages.
|
||||
|
||||
---
|
||||
|
||||
## Messaging Enhancements
|
||||
|
||||
### Rich Room Topics
|
||||
|
||||
- Topic `formatted_body` is rendered via `sanitizeCustomHtml` + `html-react-parser`, supporting bold, italic, links, and other inline HTML
|
||||
- The room header shows a plain-text preview of the topic
|
||||
- Clicking the topic preview opens a full modal with the formatted body
|
||||
- The room settings topic editor includes a formatting toolbar with **B**, _I_, ~~S~~, and `code` buttons
|
||||
|
||||
### Edit History Viewer
|
||||
|
||||
`EditHistoryModal.tsx` fetches and displays the full edit history of a message.
|
||||
|
||||
- API: `GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.replace`
|
||||
- E2EE fix: the "Original" entry uses `getClearContent()` to retrieve the decrypted content rather than the encrypted payload
|
||||
- Accessible from the message context menu
|
||||
|
||||
### Inline GIF Preview
|
||||
|
||||
- Detects Giphy and Tenor share URLs via pattern matching on the message body
|
||||
- Renders matched URLs as `<img loading="lazy">` inline in the message
|
||||
- URL is proxied through the Matrix `/_matrix/media/v3/preview_url` endpoint to avoid mixed-content issues
|
||||
|
||||
### GIF Picker
|
||||
|
||||
- Giphy-powered picker accessible from the composer toolbar
|
||||
- The button is only shown when `gifApiKey` is set in `config.json`
|
||||
- Selected GIFs are sent as `m.image` events
|
||||
- Picker UI is styled with TDS variables when the TDS theme is active
|
||||
- Located at `src/app/components/GifPicker.tsx`
|
||||
|
||||
### Message Forwarding
|
||||
|
||||
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
|
||||
|
||||
### Draft Persistence
|
||||
|
||||
- Composer drafts are stored in `localStorage` keyed by `roomId`
|
||||
- Draft is cleared on successful send
|
||||
- The Jotai atom is the primary source of truth; `localStorage` is only read on room mount
|
||||
|
||||
### Message Search Date Range
|
||||
|
||||
- The search panel accepts `from_ts` and `to_ts` values (epoch milliseconds) passed to the search API
|
||||
- A chip shows the active date range with an **×** button to clear it
|
||||
|
||||
### Image / Video Captions
|
||||
|
||||
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
||||
|
||||
### Location Sharing
|
||||
|
||||
`m.location` events render an inline map tile using the coordinates from the event content.
|
||||
|
||||
### Deleted Message Placeholders
|
||||
|
||||
Redacted events display "This message has been deleted" along with the redaction reason if one was provided, rather than leaving a blank gap in the timeline. This is a one-line change in the `eventRenderer` filter.
|
||||
|
||||
### Message Bookmarks
|
||||
|
||||
- Bookmarks are stored in `io.lotus.bookmarks` account data, syncing across all devices
|
||||
- Maximum of 500 bookmarked entries
|
||||
- `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
|
||||
- Hook: `src/app/hooks/useBookmarks.ts`
|
||||
|
||||
### Message Scheduling
|
||||
|
||||
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
|
||||
- `ScheduleMessageModal.tsx` provides the date/time picker UI
|
||||
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual cancel buttons
|
||||
- Utilities in `src/app/utils/scheduledMessages.ts`
|
||||
|
||||
### File Upload Compression (opt-in)
|
||||
|
||||
- Implemented in `UploadCardRenderer.tsx`
|
||||
- Uses the Canvas API: `canvas.toBlob(callback, 'image/jpeg', 0.82)` for compression
|
||||
- A `Switch` toggle in the upload preview UI lets the user opt in per upload
|
||||
- Shows before and after file sizes so the user can evaluate the tradeoff
|
||||
- Works on all image types except SVG (which cannot be drawn to canvas)
|
||||
- On send, the original uncompressed MXC URL is deleted from the media store
|
||||
- On cancel, any orphaned MXC from a prior upload is cleaned up via `tryDeleteMxcContent()`
|
||||
|
||||
### Richer URL Preview Cards
|
||||
|
||||
`UrlPreviewCard.tsx` implements 13 domain-specific card layouts:
|
||||
|
||||
| Domain | Layout |
|
||||
| -------------- | ---------------------------------------------------- |
|
||||
| YouTube | Thumbnail, title, channel, duration |
|
||||
| Vimeo | Thumbnail, title, author |
|
||||
| GitHub | Repo name, description, stars/forks/language |
|
||||
| Twitter / X | Avatar, display name, handle, tweet body |
|
||||
| Reddit | Subreddit, post title, score, comment count |
|
||||
| Spotify | Album art, track/album/playlist name, artist |
|
||||
| Twitch | Stream thumbnail, streamer, game, viewer count |
|
||||
| Steam | Header image, game name, price, rating |
|
||||
| Wikipedia | Article title, extract excerpt |
|
||||
| Discord | Server name, invite metadata |
|
||||
| npm | Package name, version, description, weekly downloads |
|
||||
| Stack Overflow | Question title, vote/answer count, tags |
|
||||
| IMDb | Poster, title, year, rating |
|
||||
|
||||
Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unparseable preview responses are suppressed entirely rather than showing a blank card.
|
||||
|
||||
### Poll Creation
|
||||
|
||||
- `PollCreator.tsx` creates stable `m.poll.start` events
|
||||
- Supports 2 to 10 answer options
|
||||
- Supports both single-choice and multiple-choice modes
|
||||
- Accessible via the `Icons.OrderList` button in the composer toolbar
|
||||
|
||||
### Poll Display
|
||||
|
||||
`PollContent.tsx` renders polls in read-only mode. Handles both the stable `m.poll` format and the legacy MSC3381 unstable `org.matrix.msc3381.poll.start` format. Displays current vote counts and a note directing users to Element to cast votes.
|
||||
|
||||
### Voice Message Playback Speed
|
||||
|
||||
`AudioContent.tsx` adds a playback speed cycle button to voice message players. Available speeds: `[0.75, 1, 1.5, 2]×`. A `useEffect` sets `audioElement.playbackRate` whenever the speed selection changes.
|
||||
|
||||
---
|
||||
|
||||
## Presence
|
||||
|
||||
### Discord-Style Presence Selector
|
||||
|
||||
A presence status selector in the user panel offering five modes:
|
||||
|
||||
| Mode | Matrix broadcast |
|
||||
| -------------- | ----------------------------------- |
|
||||
| Online | `online` |
|
||||
| Idle | `unavailable` |
|
||||
| Do Not Disturb | `unavailable` + `status_msg: 'dnd'` |
|
||||
| Invisible | `offline` |
|
||||
| Auto | Standard Matrix presence lifecycle |
|
||||
|
||||
- Selection persists via the `presenceStatus` setting
|
||||
- `usePresenceUpdater` short-circuits its automatic presence updates when a manual mode (anything other than Auto) is selected
|
||||
|
||||
### Custom Status Message
|
||||
|
||||
- Up to 64 characters of free text plus an emoji
|
||||
- Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
|
||||
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
|
||||
- Character counter appears at 56/64 characters remaining to warn of the limit
|
||||
|
||||
### Presence Badges
|
||||
|
||||
`PresenceBadge` component displays a colored dot indicating presence state. Used in:
|
||||
|
||||
- Members drawer
|
||||
- User settings panel
|
||||
|
||||
### Presence Avatar Ring
|
||||
|
||||
`PresenceRingAvatar` wraps any avatar component using `React.cloneElement` to inject an `outline: 2px solid` ring whose color maps to the user's presence state. `outlineOffset: 2px` ensures the ring sits cleanly outside the avatar regardless of the avatar's `border-radius`.
|
||||
|
||||
Applied in:
|
||||
|
||||
- Message timeline
|
||||
- Members drawer
|
||||
- `@mention` autocomplete dropdown
|
||||
- Inbox / notifications panel
|
||||
|
||||
### Document Title Unread Count
|
||||
|
||||
The browser tab title updates to reflect unread state:
|
||||
|
||||
- `(N) Lotus Chat` — N unread messages
|
||||
- `· Lotus Chat` — unread activity without a specific count
|
||||
- `Lotus Chat` — no unread items
|
||||
|
||||
### Extended Profile Fields
|
||||
|
||||
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
|
||||
|
||||
- `m.pronouns` — displayed in profile panels
|
||||
- `m.tz` — IANA timezone string (e.g., `America/New_York`)
|
||||
|
||||
Hook: `src/app/hooks/useExtendedProfile.ts`
|
||||
|
||||
### User Local Time
|
||||
|
||||
When a user has `m.tz` set in their profile:
|
||||
|
||||
- Their profile panel shows a clock icon, their current local time, and the timezone abbreviation
|
||||
- The displayed time updates every 60 seconds
|
||||
- Respects the global `hour24Clock` setting for 12h/24h formatting
|
||||
|
||||
Hook: `src/app/hooks/useLocalTime.ts`
|
||||
|
||||
---
|
||||
|
||||
## UX & Composer
|
||||
|
||||
### Message Length Counter
|
||||
|
||||
A character count indicator is shown in the composer when `charCount > 0`. The counter resets to zero when switching rooms.
|
||||
|
||||
### Quick Emoji Reactions on Hover
|
||||
|
||||
A hover toolbar appears over messages, showing the 3 most recently used emojis (sourced from `ElementRecentEmoji` account data) as one-click reaction buttons. The buttons are positioned between the emoji-board button and the Reply button. Clicking a quick reaction closes any open emoji picker.
|
||||
|
||||
### In-App Notification Toasts
|
||||
|
||||
`LotusToastContainer.tsx` displays rich in-app notification toasts with:
|
||||
|
||||
- 24px sender avatar
|
||||
- Sender display name
|
||||
- Message body preview
|
||||
- Room name
|
||||
- × dismiss button
|
||||
- 4-second auto-dismiss timeout
|
||||
- Slide-in animation via `@keyframes lotusToastIn`
|
||||
|
||||
OS-level notifications are unchanged and still fire when the window is not focused.
|
||||
|
||||
### Collapsible Long Messages
|
||||
|
||||
Messages exceeding a configurable line threshold are truncated with a "Show more" toggle.
|
||||
|
||||
- Default threshold: 20 lines
|
||||
- Threshold is configurable in **Settings → Appearance**
|
||||
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
|
||||
- Transition is disabled when `prefers-reduced-motion: reduce` is active
|
||||
|
||||
### Message Send Animation
|
||||
|
||||
A subtle animation plays on the sender's own messages as they appear in the timeline:
|
||||
|
||||
- `transform: scale(0.97) → scale(1)` combined with `opacity: 0.4 → 1`
|
||||
- Duration: 0.15s, ease-out
|
||||
- Only applied to the current user's outgoing messages
|
||||
- Disabled when `prefers-reduced-motion: reduce` is active
|
||||
|
||||
### Right-Click Room Context Menu
|
||||
|
||||
Right-clicking a room in the sidebar opens a context menu with:
|
||||
|
||||
- **Mute** with a duration submenu: 15 minutes, 1 hour, 8 hours, 24 hours, Indefinite
|
||||
- **Copy Room Link** — copies the `matrix.to` URI to clipboard
|
||||
- **Mark as Read** — marks all events in the room as read
|
||||
- **Leave Room** — with a confirmation step
|
||||
- **Room Settings** — opens the room settings panel
|
||||
|
||||
### Unverified Device Warning
|
||||
|
||||
- Controlled by the `warnOnUnverifiedDevices` setting (off by default)
|
||||
- When enabled, a `Warning.Container` banner is displayed above the composer in encrypted rooms that have unverified device sessions
|
||||
- The warning is informational only and never blocks sending
|
||||
|
||||
### Sidebar Room Filter
|
||||
|
||||
A text input at the top of the room list filters rooms by display name in real time. The filter is cleared automatically when switching sidebar tabs.
|
||||
|
||||
### DM Last Message Preview
|
||||
|
||||
Direct message entries in the sidebar show:
|
||||
|
||||
- A 48-character truncated preview of the last message body
|
||||
- A relative timestamp (e.g., "2m ago")
|
||||
- Reactivity via `useRoomLatestRenderedEvent`
|
||||
- Encrypted messages show "Encrypted message" if decryption fails; successfully decrypted messages show their plaintext preview
|
||||
|
||||
### Room Sort Order
|
||||
|
||||
The room list sort order can be configured in **Settings → Appearance**:
|
||||
|
||||
- Recent Activity (default)
|
||||
- A → Z (alphabetical)
|
||||
- Unread First
|
||||
|
||||
Persists via the `homeRoomSort` setting.
|
||||
|
||||
### Favorite Rooms
|
||||
|
||||
- Rooms can be favorited via the `m.favourite` tag (standard Matrix tag)
|
||||
- A "Favorites" section appears above the main room list when any rooms are favorited
|
||||
- Favorited rooms display a star indicator in the sidebar
|
||||
- Favorites sync across all devices via account data
|
||||
|
||||
### Invite Link + QR Code
|
||||
|
||||
`RoomShareInvite.tsx` provides a shareable invite UI:
|
||||
|
||||
- 160×160px QR code generated via `api.qrserver.com`
|
||||
- "Copy Link" button to copy the `matrix.to` URI
|
||||
- Also accessible via a toggle button (⊞) in the Invite modal
|
||||
|
||||
### Private Read Receipts
|
||||
|
||||
A toggle in **Settings → Privacy** switches between sending `m.read` (public receipts) and `m.read.private` (private receipts visible only to the sender and the server).
|
||||
|
||||
### Media Gallery
|
||||
|
||||
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
||||
|
||||
- Three tabs: **Images**, **Videos**, **Files**
|
||||
- Reads already-decrypted events from the room timeline
|
||||
- Encrypted images show a lock placeholder rather than an error
|
||||
- "Load More" button triggers `mx.paginateEventTimeline()` to fetch older media
|
||||
|
||||
### Knock-to-Join
|
||||
|
||||
- `RoomIntro.tsx` shows a "Request to Join" button for knock-restricted rooms
|
||||
- Clicking sends `mx.knockRoom(roomId)` with an optional reason
|
||||
- The members drawer shows a "Pending Requests" section for room admins, listing users who have knocked
|
||||
|
||||
### Knock-to-Join Notifications for Admins (P4-3)
|
||||
|
||||
Room and space admins are notified in real time when users knock on a restricted room.
|
||||
|
||||
- `usePendingKnocks(room)` hook listens to `RoomMemberEvent.Membership` events and returns all members currently in the `knock` state
|
||||
- Power level check: only shown to users with sufficient invite-level permissions (`usePowerLevelsContext()`)
|
||||
- **Members button badge:** when knocks are pending, a `Warning`-variant solid `Badge` overlays the Members button in the room header showing the pending count
|
||||
- Badge is `aria-hidden`; the Members button `aria-label` is updated to announce the count for screen readers
|
||||
|
||||
Hook: `src/app/hooks/usePendingKnocks.ts`
|
||||
|
||||
### Code Syntax Highlighting (TDS)
|
||||
|
||||
`syntaxHighlight.ts` provides TDS-aware syntax highlighting using inline styles derived from `--lt-accent-*` CSS variables. Supported languages: JavaScript, TypeScript, JSX, TSX, Python, Rust. Falls back to ReactPrism for unsupported languages.
|
||||
|
||||
### Room Emoji Prefix
|
||||
|
||||
A leading emoji in a room name is rendered at 1.15× size in the sidebar for visual hierarchy. An emoji picker button (😊) is added to all room name input fields, prepending the selected emoji to the room name.
|
||||
|
||||
### Configurable Composer Toolbar (P3-6)
|
||||
|
||||
Users can individually show or hide each composer toolbar button in **Settings → Editor → Composer Toolbar Buttons**:
|
||||
|
||||
- Format Toggle, Emoji, Sticker, GIF, Location, Poll, Voice Message, Schedule Message
|
||||
- All default to **on** — no visible change for existing users
|
||||
- New buttons added in future will also default to on (deep-merge in `getSettings`)
|
||||
- Send and Attach File buttons are not hideable
|
||||
- Sticker still respects the existing `width < 500px` auto-hide on top of the setting
|
||||
|
||||
---
|
||||
|
||||
## Room Customization
|
||||
|
||||
### Personal Room Name Overrides
|
||||
|
||||
- Users can set a personal display name for any room, stored in `io.lotus.room_names` account data
|
||||
- A pencil indicator in the sidebar shows rooms with active overrides
|
||||
- Overrides sync across all devices
|
||||
- Applied consistently in: room header, sidebar, room intro, and call overlay
|
||||
|
||||
### Export Room History
|
||||
|
||||
`ExportRoomHistory.tsx` exports a room's message history in three formats:
|
||||
|
||||
- **Plain Text** — human-readable transcript
|
||||
- **JSON** — raw event data
|
||||
- **HTML** — styled, self-contained page
|
||||
|
||||
Features:
|
||||
|
||||
- Optional date range filter
|
||||
- Progress indicator during export
|
||||
- Uses `mx.paginateEventTimeline()` to retrieve history in chunks
|
||||
- E2EE-aware: exports decrypted content for rooms the user has keys for
|
||||
|
||||
### Room Activity / Mod Log
|
||||
|
||||
`RoomActivityLog.tsx` provides a searchable log of administrative events in the room:
|
||||
|
||||
- Member join/leave/kick/ban events
|
||||
- Power level changes
|
||||
- Room name, topic, avatar, and ACL changes
|
||||
- Human-readable descriptions for each event type
|
||||
- Type filter to narrow results
|
||||
- "Load More" button for pagination
|
||||
|
||||
### Server ACL Editor
|
||||
|
||||
`RoomServerACL.tsx` — UI for editing `m.room.server_acl` state events.
|
||||
|
||||
- Separate allow and deny server lists
|
||||
- Wildcard validation (e.g., `*.example.com`)
|
||||
- "Allow IP literal addresses" toggle
|
||||
- Read-only for users without admin power level
|
||||
|
||||
### Room Stats / Insights
|
||||
|
||||
`RoomInsights.tsx` (not shown by default; accessible as a non-default tab in room settings).
|
||||
|
||||
- Top 5 most active members (bar chart by message count)
|
||||
- Top 5 most-used reactions
|
||||
- Media breakdown by type (images, videos, files)
|
||||
- 24-hour activity heatmap showing message volume by hour of day
|
||||
|
||||
---
|
||||
|
||||
## Moderation
|
||||
|
||||
### Report Room
|
||||
|
||||
`ReportRoomModal.tsx` provides a UI for reporting a room to the homeserver.
|
||||
|
||||
- API: `POST /_matrix/client/v3/rooms/{roomId}/report`
|
||||
- Category dropdown for classifying the report
|
||||
- Modal auto-closes 1.5 seconds after a successful submission
|
||||
- Hidden for rooms the user owns and for server notice rooms
|
||||
|
||||
### Policy List / Ban List Viewer
|
||||
|
||||
Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
||||
|
||||
- Displays the room's subscribed policy lists in read-only format
|
||||
- Subscribe (join) and unsubscribe (leave) controls for each list
|
||||
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
|
||||
|
||||
---
|
||||
|
||||
## Notifications
|
||||
|
||||
### Custom Notification Sounds
|
||||
|
||||
- `messageSoundId` and `inviteSoundId` settings select from a predefined map
|
||||
- Sound options defined in `notificationSounds.ts` as `NOTIFICATION_SOUND_MAP`
|
||||
- Preview (▶) buttons in the settings panel let users audition sounds before selecting
|
||||
- Separate sound selection for: `notification`, `invite`, `call`, `none`
|
||||
|
||||
### Notification Quiet Hours
|
||||
|
||||
- Controlled by `quietHoursEnabled`, `quietHoursStart`, and `quietHoursEnd` settings
|
||||
- Correctly handles overnight spans (e.g., 22:00 → 07:00)
|
||||
- Gates both `notify()` (visual/OS notifications) and `playSound()` (audio alerts)
|
||||
- When active, notifications are silently dropped rather than queued
|
||||
|
||||
### Full Push Rule Editor
|
||||
|
||||
A complete UI for managing Matrix push notification rules:
|
||||
|
||||
- Displays rules by kind: `override`, `room`, `sender`, `underride`
|
||||
- Enable/disable toggle per rule
|
||||
- Delete button for removable rules
|
||||
- Add-rule form for creating new `room` and `sender` rules
|
||||
|
||||
### Notification Profile Presets (P5-27)
|
||||
|
||||
Three one-tap presets at the top of **Settings → Notifications** that apply a group of notification settings atomically:
|
||||
|
||||
- **Gaming 🎮** — notifications on, all sounds off (`messageSoundId: none`, `inviteSoundId: none`)
|
||||
- **Work 💼** — all notifications and sounds on (restores defaults)
|
||||
- **Sleep 🌙** — all notifications off (`showNotifications: false`, sounds off)
|
||||
|
||||
---
|
||||
|
||||
## Server Integration
|
||||
|
||||
### Server Support Contact
|
||||
|
||||
- Fetches `/.well-known/matrix/support` on the user's homeserver
|
||||
- Contact information is displayed in **Settings → Help & About**
|
||||
- Styled with TDS cyan when the TDS theme is active
|
||||
- Degrades gracefully with no error shown on 404 responses
|
||||
|
||||
### Server Notices
|
||||
|
||||
`m.server_notice` rooms receive special treatment:
|
||||
|
||||
- A `Chip variant="Warning"` badge reading "Server Notice" is shown in the room header
|
||||
- The composer is read-only (no message input)
|
||||
- Invite, Report Room, and Room Settings menu items are hidden
|
||||
|
||||
---
|
||||
|
||||
## Infrastructure
|
||||
|
||||
### Authenticated Media
|
||||
|
||||
`mxcUrlToHttp()` calls now use the correct argument order for MSC3916 authenticated media:
|
||||
|
||||
```ts
|
||||
mxcUrlToHttp(mx, mxcUrl, useAuthentication, width, height, 'crop');
|
||||
```
|
||||
|
||||
The `useAuthentication` parameter was previously mispositioned, causing unauthenticated requests to be sent for media in rooms that required authentication.
|
||||
|
||||
### Upstream Tracking
|
||||
|
||||
- An `upstream` git remote pointing to `github.com/cinnyapp/cinny` is maintained for merge tracking
|
||||
- A daily divergence check runs via `cinny-upstream-check.sh` on LXC 106
|
||||
- This enables prompt review of upstream security patches and feature releases
|
||||
|
||||
### Rolldown CJS Interop — millify
|
||||
|
||||
`src/app/plugins/millify.ts` re-exports `millify` as a named import to bypass the `__toESM` interop bug in the Rolldown bundler, which caused the default export of CJS modules to be undefined at runtime.
|
||||
|
||||
### Sentry Noise Filter
|
||||
|
||||
`ignoreErrors: ['Request timed out']` is added to `Sentry.init()` to suppress a high-volume, low-signal error caused by transient network conditions. This keeps the Sentry issue queue focused on actionable errors.
|
||||
|
||||
### URL Preview Default in Encrypted Rooms
|
||||
|
||||
The `encUrlPreview` setting defaults to `true` rather than `false`. A security advisory chip in **Settings → Privacy** explains the tradeoff (the homeserver can see which URLs are being previewed) so users can make an informed choice.
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
@@ -0,0 +1,109 @@
|
||||
# Lotus Chat — Implementation Reference for Backlog
|
||||
|
||||
**Date:** June 2026
|
||||
|
||||
This document provides technical guidance, file paths, and architectural notes for unimplemented items in `LOTUS_TODO.md` to assist engineers during development.
|
||||
|
||||
---
|
||||
|
||||
## 🧵 Priority 3 — Higher Complexity
|
||||
|
||||
### P3-8 · Thread Panel (Full Side Drawer)
|
||||
|
||||
**⚠️ Largest Feature**
|
||||
|
||||
- **Objective:** Add a right-side drawer to view and reply to threads (`m.thread` relations).
|
||||
- **Key Files to Reference:**
|
||||
- `src/app/features/room/RoomView.tsx`: Main layout. Needs to render the new `ThreadPanel` component conditionally.
|
||||
- `src/app/features/room/MembersDrawer.tsx`: Use this as a pattern for side drawers (fixed width, toggleable).
|
||||
- `src/app/features/room/message/Message.tsx`: Check `isThreadedMessage` logic and the `onReplyClick(ev, true)` handler.
|
||||
- **Architecture:**
|
||||
- Create `activeThreadEventIdAtom` in a new state file.
|
||||
- `ThreadPanel` should reuse `Timeline` components but filter for events where `m.relates_to.event_id === activeThreadEventId` and `rel_type === 'm.thread'`.
|
||||
- **SDK API:** Use `mx.getThread(eventId)` or the aggregations API: `GET /rooms/{roomId}/relations/{eventId}/m.thread`.
|
||||
- **Note:** `RoomTimeline.tsx` currently has `handleReplyClick` (Line 978) which already supports starting threads.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Priority 4 — Specialized Features
|
||||
|
||||
### P4-3 · Knock-to-join Notifications for Admins
|
||||
|
||||
- **Objective:** Alert admins when users are knocking and provide an easy way to approve/deny.
|
||||
- **Key Files:**
|
||||
- `src/app/features/room/MembersDrawer.tsx`: Already contains logic to show "Pending Requests" (Line 412).
|
||||
- `src/app/hooks/useRoomsNotificationPreferences.ts`: Add logic to detect `Membership.Knock` events in joined rooms where the user has invite permissions.
|
||||
- **Implementation:**
|
||||
- Create a hook `usePendingKnocks(room)` that returns `room.getMembersWithMembership(Membership.Knock)`.
|
||||
- Add a notification badge to the "Members" icon in the room header if knocks > 0.
|
||||
|
||||
### P4-4 · Math / LaTeX Rendering
|
||||
|
||||
- **Objective:** Render `$...$` and `$$...$$` blocks using KaTeX.
|
||||
- **Key Files:**
|
||||
- `src/app/utils/sanitize.ts`: **Critical.** The sanitizer currently strips many tags. You must allow specific KaTeX/MathML outputs.
|
||||
- `src/app/plugins/react-custom-html-parser.ts`: Add a custom rule to detect LaTeX patterns in plain text or handle the specific HTML from the server.
|
||||
- `src/app/styles/CustomHtml.css.ts`: Add KaTeX CSS import/styles.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
|
||||
|
||||
### P5-1 · Custom Accent Color Picker
|
||||
|
||||
- **Objective:** User-defined accent color for non-TDS themes.
|
||||
- **Key Files:**
|
||||
- `src/app/hooks/useTheme.ts`: Central theme logic.
|
||||
- `src/app/state/settings.ts`: Add `customAccentColor: string` to the settings atom.
|
||||
- **Implementation:**
|
||||
- Inject a `<style>` block into `<head>` that overrides CSS variables.
|
||||
- **Variables to target:** `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green` (or their non-TDS equivalents in `folds`).
|
||||
|
||||
### P5-10 · Voice Channel User Limit
|
||||
|
||||
- **Objective:** Prevent joining a voice channel if the user limit is reached.
|
||||
- **Key Files:**
|
||||
- `src/app/features/call/CallView.tsx`: Join logic site.
|
||||
- **Implementation:**
|
||||
- In `CallPrescreen` (Line 77), retrieve the `io.lotus.voice_limit` state event from the room.
|
||||
- Compare `callMembers.length` with the `max_users` value from the event content.
|
||||
- If current members >= limit, disable the `canJoin` flag and display a "Channel Full" message.
|
||||
|
||||
### P5-13 · Avatar Frame / Border Decorations
|
||||
|
||||
- **Objective:** Add cosmetic frames around user avatars.
|
||||
- **Key Files:**
|
||||
- `src/app/components/user-avatar/UserAvatar.tsx`: Rendering site.
|
||||
- **Implementation:**
|
||||
- Add an optional `frameName` prop to the `UserAvatar` component.
|
||||
- Since `folds` components like `AvatarImage` are restrictive, wrap the entire return value (both fallback and image paths) in a new `Box` container that applies the frame/glow effects via CSS.
|
||||
|
||||
### P5-21 · Custom @Mention Highlight Color
|
||||
|
||||
- **Objective:** Persistent background highlight for messages that mention the user.
|
||||
- **Key Files:**
|
||||
- `src/app/components/message/layout/layout.css.ts`: Styling site.
|
||||
- `src/app/features/room/message/Message.tsx`: Logic site.
|
||||
- **Implementation:**
|
||||
- In `layout.css.ts`, add a `mention` variant to the `MessageBase` recipe that sets a static `backgroundColor`.
|
||||
- In `Message.tsx`, pass the `isMentioned` boolean (Line 800) into the `MessageBase` component as a new prop to trigger the highlight variant.
|
||||
|
||||
### P5-20 · Quick Reply from Browser Notification
|
||||
|
||||
- **Objective:** Inline reply in OS notifications.
|
||||
- **Key Files:**
|
||||
- `src/sw.ts`: Handle the `notificationclick` event.
|
||||
- **Implementation:**
|
||||
- Check for `event.reply` in the service worker.
|
||||
- Use the `accessToken` and `baseUrl` stored in the `sessions` map (already implemented in `sw.ts`) to send a Matrix message via `fetch` directly from the Service Worker.
|
||||
- **Crucial:** Ensure the message is sent as a relation if the notification was for a thread.
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Pending Audits Guidance
|
||||
|
||||
### Audit-3 · Profile Banner Image
|
||||
|
||||
- **Task:** Check if MSC4133 or Matrix v1.16 defines a banner field.
|
||||
- **Update:** Matrix spec does not currently have a stable `m.banner` field. Most clients use `org.matrix.msc4133.banner_url` (unstable).
|
||||
- **Recommendation:** Use `mx.http.authedRequest` to experiment with this field on `matrix.lotusguild.org`.
|
||||
@@ -1,280 +1,160 @@
|
||||
# Lotus Chat
|
||||
|
||||
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1.
|
||||
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
||||
|
||||
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
|
||||
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1
|
||||
|
||||
---
|
||||
|
||||
## Changes from upstream Cinny
|
||||
## Licensing & Attribution
|
||||
|
||||
### Branding & Identity
|
||||
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
||||
|
||||
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
|
||||
- App title changed from "Cinny" to "Lotus Chat" throughout
|
||||
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
|
||||
- Logo in About dialog and Auth page replaced with official Lotus.png
|
||||
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
|
||||
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
|
||||
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
|
||||
- `manifest.json` updated with Lotus name, description, and branding colors
|
||||
|
||||
### LotusGuild Terminal Design System (TDS) v1.2
|
||||
|
||||
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
||||
|
||||
**Dark mode** (`LotusTerminalTheme`):
|
||||
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
|
||||
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
|
||||
- Monospace font stack, terminal-style scrollbars
|
||||
- Custom hex-grid and circuit-board CSS background patterns
|
||||
- Matrix-style boot messages on the welcome page (press Escape to skip)
|
||||
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
|
||||
|
||||
**Light mode** (`LotusTerminalLightTheme`):
|
||||
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
|
||||
- No CRT effects (scanlines, vignette disabled)
|
||||
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
|
||||
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
|
||||
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
|
||||
|
||||
**Chat Backgrounds** (20+ custom patterns, all TDS-aware):
|
||||
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
|
||||
- Chevron, polka dots, triangles, plaid
|
||||
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
|
||||
- Settings toggle for showing per-message sender profiles
|
||||
|
||||
### Voice / Video Call Improvements
|
||||
|
||||
- **Element Call 0.19.4**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
|
||||
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
|
||||
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
|
||||
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
|
||||
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
|
||||
- **Push to Talk (PTT)**:
|
||||
- Configurable keybind (default: Space) via Settings > General > Calls
|
||||
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
|
||||
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
|
||||
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
|
||||
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
|
||||
- **Mic state preservation**: when enabling PTT mode mid-call, the user's previous mic state is saved and restored when PTT is disabled — prevents unwanted unmute if the user had manually muted before switching to PTT.
|
||||
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
|
||||
- **Call button scoping**: The upstream Cinny 4.12.1 call button (voice + video dropdown) is restricted to DMs and private group chats only. Specifically: direct messages, or invite-only rooms that have no `m.space.parent` state event (i.e. not a space/guild text channel). Public rooms and space channels are excluded to prevent accidental mass-notifications. `Room.tsx` switches to CallView layout when a call embed is active in the current room.
|
||||
- **Poll display**: `m.poll.start` events (both stable Matrix 1.7 `m.poll` content key and MSC3381 unstable `org.matrix.msc3381.poll.start`) render as read-only poll cards inside the standard message bubble — question and answer options shown. Registered as top-level event renderers AND inside the `EncryptedContent` callback so encrypted polls also display after decryption. "Open in Element to vote" note displayed. Implemented in `PollContent.tsx`.
|
||||
- **Deleted message placeholder**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events no longer disappear from the timeline. Instead they reach the existing `RedactedContent` component (trash icon + italic "This message has been deleted" with reason if provided), matching Element, FluffyChat, Commet, and Nheko behaviour. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
||||
- **Call embed positioning**: `useCallEmbedPlacementSync` uses `getBoundingClientRect()` (not `offsetTop/Left`) for accurate viewport-relative coordinates on the `position:fixed` container. Position is synced immediately on mount via `useEffect` in addition to the ResizeObserver, so the embed is placed correctly the instant the call view renders. The `[pipMode, callVisible]` effect in `CallEmbedProvider` only clears pip-specific styles when actually exiting pip mode — no longer clobbers the position set by `syncCallEmbedPlacement` on every `callVisible` toggle.
|
||||
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
|
||||
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
|
||||
|
||||
### Moderation
|
||||
|
||||
- **Report Room**: A "Report Room" option in the room header menu (⋮) allows users to report a room to homeserver admins with a reason and abuse category (Spam / Harassment / Inappropriate Content / Other). Calls `POST /_matrix/client/v3/rooms/{roomId}/report` (MSC4151, confirmed supported on matrix.lotusguild.org). Implemented in `ReportRoomModal.tsx` with loading/success/error states.
|
||||
- **Policy List / Ban List Viewer (MSC2313)**: A "Policy Lists" tab in Room / Space Settings (admin-only, power-level gated) shows all subscribed `m.policy.rule.*` rooms and their contents — banned users, banned rooms, and banned servers — each with entity, reason, and recommendation fields. Subscribe (join the policy room) and Unsubscribe (leave) actions are provided. Enforcement remains solely with the Draupnir bot; this UI is a read-only complement.
|
||||
|
||||
### Messaging Enhancements
|
||||
|
||||
- **Rich room topics**: Room topics that contain formatted text (bold, links, italic) are now rendered with full HTML formatting. Falls back to plain text if no `formatted_body` is present. Activates when any room admin sets a formatted topic.
|
||||
- **Edit history viewer**: Clicking the "edited" label on any edited message opens a modal showing every prior version with timestamps. Fetches all `m.replace` relations for the event and displays them oldest-to-newest. Previously the "edited" label was visible but unclickable. E2EE fix: the "Original" entry now uses `getClearContent()` (bypasses the replacing-event chain, returns the decrypted pre-edit body) instead of `event.content` which is still raw ciphertext for encrypted messages — fixes "(no text)" shown for almost all E2EE message originals.
|
||||
- **Inline GIF preview**: Giphy and Tenor share links sent as plain text auto-embed as animated GIFs inline in the timeline. URL patterns are detected client-side; the image is fetched via the homeserver's `/_matrix/media/v3/preview_url` proxy (no direct contact with Giphy/Tenor from the client). Rendered as `<img loading="lazy">` — respects the existing URL preview enabled/disabled setting.
|
||||
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
|
||||
- **Message forwarding**: Forward any message to any room from the message context menu.
|
||||
- **Draft persistence**: Unsent message drafts survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom is primary; localStorage is used as fallback on reload and cleared on send.
|
||||
- **Message search date range**: From/To date pickers in the search filter bar. Sends `from_ts`/`to_ts` epoch ms to the Matrix `/search` endpoint. Chip shows active range with X to clear.
|
||||
- **Image/video captions**: Caption text field on image and video upload — sent as a single event with the media.
|
||||
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
|
||||
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
||||
- **Message bookmarks / saved messages**: Right-click any message → "Bookmark" / "Remove Bookmark". A star icon in the sidebar nav opens `BookmarksPanel.tsx` — a right-side panel listing all saved messages with room name, preview text, relative timestamp, filter input, "Jump to message" deep-link, and individual remove buttons. Stored in `io.lotus.bookmarks` account data (max 500 entries); syncs across devices. Implemented in `src/app/features/bookmarks/BookmarksPanel.tsx` + `src/app/hooks/useBookmarks.ts`.
|
||||
- **Message scheduling (MSC4140)**: Clock button next to send opens `ScheduleMessageModal.tsx` with a message textarea and datetime picker. Messages sent via the MSC4140 delayed events API (`org.matrix.msc4140`), confirmed supported on `matrix.lotusguild.org`. A collapsible tray above the composer lists pending scheduled messages with Cancel buttons. Utilities in `src/app/utils/scheduledMessages.ts`.
|
||||
- **Richer link preview cards**: `UrlPreviewCard.tsx` renders domain-specific cards for 13 sites: YouTube (thumbnail + ▶ play overlay), Vimeo, GitHub (repo parse), Twitter/X (tweet text + media parse), Reddit (subreddit + upvotes + comments), Spotify (artwork), Twitch (LIVE badge + game), Steam, Wikipedia, Discord (server invite), npm, Stack Overflow, and IMDb (poster). Generic cards gain a favicon from Google's S2 service. Cards that produce no renderable content are suppressed.
|
||||
- **File upload compression (opt-in)**: JPEG and PNG files in the upload preview show a "Compress" checkbox. When checked, a Canvas API call (`toBlob(..., 'image/jpeg', 0.82)`) compresses the image client-side. Original and compressed sizes are shown side-by-side. Compression is strictly opt-in — unchecked by default, skipped for GIF/SVG/WebP.
|
||||
|
||||
### Room Customization
|
||||
|
||||
- **Personal room name overrides**: Right-click any room in the sidebar → "Rename for me…" to set a local display name visible only to you. Other members see the original name unchanged. A small pencil icon marks rooms with a custom local name. Stored in Matrix account data (`io.lotus.room_names`). Uses `io.lotus.room_names` account data key (based on MSC4431).
|
||||
- **Export room history**: Room Settings → Export tab. Supports Plain Text, JSON, and HTML formats with optional start/end date range filters. Paginates backwards via `mx.paginateEventTimeline()` with a live progress counter. E2EE-aware — events that failed decryption are skipped rather than exported as garbled ciphertext. Downloads via `Blob` + `<a download>`. Implemented in `src/app/features/room-settings/ExportRoomHistory.tsx`.
|
||||
- **Room activity / mod log**: Room Settings → Activity tab. Filterable log of `m.room.member` (join/leave/kick/ban/unban/invite), `m.room.power_levels`, `m.room.name`, `m.room.topic`, `m.room.avatar`, and `m.room.server_acl` events. Human-readable descriptions, relative timestamps, type-filter dropdown, Load More pagination, and auto-paginate on mount. Implemented in `src/app/features/room-settings/RoomActivityLog.tsx`.
|
||||
- **Server ACL editor**: Room Settings → Server ACL tab. Reads and writes `m.room.server_acl` state events. Editable allow and deny server lists with wildcard pattern validation (`*.example.com`). "Allow IP literal addresses" toggle. Read-only view shown to users without the required power level. Implemented in `src/app/features/room-settings/RoomServerACL.tsx`.
|
||||
- **Room stats / insights**: Room Settings → Insights tab (not the default tab). Derives all statistics from the local timeline cache only; a disclaimer banner clarifies this. Shows top 5 active members (bar chart), top 5 reactions (emoji chips), media breakdown (images/videos/audio/files tiles), and a 24-hour activity heatmap (CSS bar chart). Implemented in `src/app/features/room-settings/RoomInsights.tsx`.
|
||||
|
||||
### Per-Message Read Receipts
|
||||
|
||||
Full per-message read receipt system — shows who has read each message directly in the timeline.
|
||||
|
||||
**Architecture:**
|
||||
- `useRoomReadPositions(room)` hook — computes a `Map<eventId, userId[]>` from all joined members' `room.getEventReadUpTo()` positions. Subscribes to `RoomEvent.Receipt` for live updates (debounced at 150ms to batch burst updates from mass-read events).
|
||||
- `nearestRenderableId(liveEvents, evtId)` — receipts can land on reaction/edit events that `RoomTimeline` skips (renders `null`). This walks backwards from the receipt event through the live timeline until it finds a non-reaction/non-edit event to attach to.
|
||||
- `ReadPositionsContext` — React context providing the positions map from `RoomTimeline` down to all `Message` instances without prop drilling.
|
||||
- `ReadReceiptAvatars` component — renders a pill-shaped row of overlapping `StackedAvatar` circles (24px, `SurfaceVariant` outline) below messages with readers. Pill uses `color.SurfaceVariant.Container` background for visibility on any wallpaper. Max 5 avatars shown + `+N` overflow count. Avatar fallback uses `colorMXID(userId)` for distinctive per-user color.
|
||||
- Clicking the pill opens the **"Seen by" modal** (`EventReaders`) listing all readers with their avatar, display name, and a formatted read timestamp ("Today at 3:42 PM", "Yesterday at 10:15 AM", "May 14 at 9:00 AM"). Timestamps use `room.getReadReceiptForUserId(userId)?.data.ts` and respect the user's 24-hour clock setting.
|
||||
- Authenticated media (`mxcUrlToHttp` utility) used for all avatar loads, matching the correct Lotus utility signature.
|
||||
|
||||
### Delivery Status Indicators
|
||||
|
||||
Own messages display a small status marker below the message content (when no read receipts are visible yet):
|
||||
- `⟳` — message is being sent / encrypting
|
||||
- `✓` — message confirmed sent (local echo)
|
||||
- `✕` — message failed to send (shown in red; orange glow in TDS mode)
|
||||
- Status hidden once the server confirms receipt (`status === null`) — read receipts take over at that point
|
||||
|
||||
### URL Preview Cards (TDS)
|
||||
|
||||
URL preview cards (`UrlPreviewCard`) styled for terminal mode:
|
||||
- Dark transparent background with cyan border-left accent (Anduril Orange)
|
||||
- Link text in cyan, hover switches to orange with glow
|
||||
- Light TDS variant: off-white background with blue accent
|
||||
|
||||
### Reaction Chips (TDS)
|
||||
|
||||
Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]` selector:
|
||||
- Unselected: `rgba(0,212,255,0.06)` background, cyan border
|
||||
- Hover: brighter background + box-shadow glow
|
||||
- Own reaction (aria-pressed=true): orange tint `rgba(255,107,0,0.12)`, orange border
|
||||
- Light TDS: equivalent blue/orange variants
|
||||
|
||||
|
||||
### DM Call Improvements
|
||||
|
||||
|
||||
- **Incoming call ring**: DM calls trigger a ring tone with Answer/Decline UI. 30-second auto-dismiss if unanswered. Implemented in `Room.tsx` and `RoomViewHeader.tsx`.
|
||||
|
||||
### Room Customization
|
||||
|
||||
- **Room emoji prefix**: A leading emoji in a room name (e.g. 🎮 general) renders at 1.15× size in the sidebar for visual impact. Matrix room names already support Unicode — this is purely a rendering enhancement in `RoomNavItem.tsx`. All three room-name inputs (Create Room, Room Settings, "Rename for me…" dialog) now include a 😊 emoji picker button that prepends the selected emoji to the name field.
|
||||
|
||||
### Presence
|
||||
|
||||
- **Discord-style presence selector**: Clicking your avatar in the bottom-left sidebar opens a popout with five status options — Online (green), Idle (yellow), Do Not Disturb (red, broadcasts `unavailable` with `status_msg: 'dnd'`), Invisible (grey outline, broadcasts `offline`), and Auto (activity-tracking, the original behaviour). The selected status persists across reloads via the settings atom. A colored badge on the avatar reflects the current status at a glance. `usePresenceUpdater` short-circuits immediately for manual modes; full idle-timer and visibility-change logic only runs in Auto mode. Settings also exposed via `src/app/state/settings.ts` (`presenceStatus` field).
|
||||
- **Custom status message**: Set a short status text (up to 64 characters) with an emoji picker, shown below your display name in member lists and presence displays. Accessible via Settings → Account → Profile. Includes an **auto-clear timer** (options: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days) — after the timer expires, the status is automatically cleared by setting `status_msg: ''` via `mx.setPresence`. A character counter (shown when ≥ 56/64 chars) prevents overflow. Implemented in `src/app/features/settings/account/Profile.tsx`.
|
||||
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
|
||||
- **Presence avatar border ring**: A 2px colored `outline` ring on user avatars throughout the app shows presence at a glance — green (online), yellow (idle), red (DND), no ring (offline). Implemented as `PresenceRingAvatar` component (`src/app/components/presence/PresenceRingAvatar.tsx`) using `React.cloneElement` to inject `outline` + `outlineOffset` directly onto the child `Avatar` element — the ring follows the avatar's actual `border-radius` regardless of shape. Applied to: message timeline sender avatars, members drawer, @mention autocomplete, and inbox notification senders.
|
||||
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
|
||||
- **Extended profile fields (MSC4133)**: Settings → Account → Profile includes Pronouns (`m.pronouns`) and Timezone (`m.tz`) fields, saved via MSC4133 `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`. Both fields are displayed in user profile panels. Implemented via `src/app/hooks/useExtendedProfile.ts`.
|
||||
- **User local time in profile**: When a user has `m.tz` set, their profile panel shows a clock icon, their current local time, and the timezone abbreviation (e.g. EST, JST). Updates every 60 seconds. Respects the viewer's `hour24Clock` setting. Implemented via `src/app/hooks/useLocalTime.ts`.
|
||||
|
||||
### UX & Composer
|
||||
|
||||
- **Message length counter**: A muted character counter appears just left of the send button while typing, disappearing when the composer is empty. Resets on room switch.
|
||||
- **Quick emoji reactions on hover**: The 3 most-recently-used emoji reactions appear directly in the message hover toolbar (between the emoji-board button and Reply), so reacting requires a single click rather than opening the 3-dots menu first. Clicking a quick-reaction also closes any open emoji picker. Powered by `useRecentEmoji` sourced from Matrix account data.
|
||||
- **In-app notification toasts**: When a message or invite notification fires and the browser window is focused, a slim TDS-styled toast card slides in from the bottom-right instead of triggering an OS notification. Card shows: 24px avatar (initials fallback), sender name in orange, truncated message body, room name, × dismiss, 4 s auto-dismiss. Clicking navigates directly to the correct room (DM or home path) or the invites inbox. OS notifications are unchanged when the window is not focused. Implemented in `src/app/features/toast/LotusToastContainer.tsx` + `src/app/state/toast.ts`.
|
||||
- **Collapsible long messages**: Messages exceeding ~20 lines are auto-collapsed with a "Read more ↓" button. Click to expand inline; a "Collapse ↑" button re-folds. Threshold (in lines) configurable in Settings → Appearance. Uses CSS `max-height` + `overflow: hidden` — works correctly with code blocks and embedded media. Respects `prefers-reduced-motion`.
|
||||
- **Message send animation**: Own sent messages fade and scale into the timeline (0.15 s ease-out: `scale(0.97)→scale(1)`, `opacity 0.4→1`). Incoming messages are unaffected. Respects `prefers-reduced-motion`.
|
||||
- **Right-click room context menu**: Expanded sidebar room context menu — **Mute** now opens a duration submenu (15 min / 1 hr / 8 hr / 24 hr / Indefinite) with auto-restore after the selected window; **Copy Room Link** copies the `matrix.to` URL with a "Copied!" flash; **Mark as Read** marks the room read to the latest event; plus Leave Room and Room Settings shortcuts.
|
||||
- **Unverified device warning**: `warnOnUnverifiedDevices` setting (default off). When enabled via Settings → General → Privacy, a warning banner appears above the composer in encrypted rooms that contain unverified devices, showing the count. Sending is never blocked — the banner is informational only. Uses the existing `useUnverifiedDeviceCount()` hook.
|
||||
- **Sidebar room filter**: A search-icon input at the top of the Home and DMs sidebar tabs filters rooms by display name in real time. Clears on tab switch. Styled to match the members-drawer search bar (`size="400"`, search prefix icon).
|
||||
- **DM last message preview**: Each DM row in the sidebar shows a truncated message body (48 chars) and relative timestamp (`Xm`, `Xhr`, `Yesterday`, `D MMM`) below the room name, sourced reactively from `useRoomLatestRenderedEvent`. Encrypted rooms show "Encrypted message" only on actual decryption failure.
|
||||
- **Room sort order**: Sort icon in the Rooms sidebar header lets users sort non-space rooms by Recent Activity (default), A→Z, or Unread First. Persists via `homeRoomSort` setting.
|
||||
- **Favorite rooms**: Right-click any room → "Add to Favorites" / "Remove from Favorites". Favorited rooms (using the standard Matrix `m.favourite` tag) appear in a collapsible "Favorites" section above the main room list on the Home tab. Syncs across devices via account data.
|
||||
- **Poll creation**: Polls can be created directly from the composer — `Icons.OrderList` button opens a modal with question field, 2–10 answer options (add/remove), and Single/Multiple choice toggle. Sends a stable `m.poll.start` event. (Poll display & voting were already supported.)
|
||||
- **Voice message playback speed**: `0.75×` → `1×` → `1.5×` → `2×` speed toggle pill on voice message player — cycles on click via `playbackRate` on the `<audio>` element.
|
||||
- **Invite link + QR code**: Room settings → General shows a "Share Room" tile with the `matrix.to` invite URL and a QR code. The Invite modal also has a `⊞` toggle button showing a QR panel when clicked. Both use `api.qrserver.com` (added to CSP on LXC 106).
|
||||
- **Private read receipts**: Settings → General → Privacy — "Private Read Receipts" toggle. When on, sends `m.read.private` instead of `m.read` so other room members can't see when you've read messages.
|
||||
- **Media gallery**: A right-side drawer (photo icon in room header, Desktop only) showing Images | Videos | Files tabs. Reads already-decrypted timeline events — works in E2EE rooms. Encrypted-blob images show a lock-icon placeholder. Load More paginates backwards via `mx.paginateEventTimeline()`.
|
||||
- **Knock-to-join**: When a room's join rule is `knock`, RoomIntro shows "Request to Join" (calls `mx.knockRoom()`) with "Request sent" pending state. Room admins see a "Pending Requests" section in the members drawer with Approve / Deny buttons.
|
||||
- **Code syntax highlighting** (TDS mode): Fenced code blocks in messages highlight keywords (cyan), strings (green), numbers (orange), comments (italic dim), function names (purple) using inline `--lt-accent-*` CSS variables. Custom tokenizer in `syntaxHighlight.ts` — supports JS/TS/JSX/TSX, Python, Rust. Falls back to ReactPrism for other languages.
|
||||
|
||||
### Settings (Appearance)
|
||||
|
||||
- **Animated Chat Backgrounds**: Five CSS-only animated wallpapers added to the background picker — Digital Rain (two-layer vertical stripe scroll with parallax), Star Drift (three-layer radial-gradient star field drifting diagonally), Grid Pulse (neon grid lines expanding/contracting via `backgroundSize` keyframe), Aurora Flow (four radial gradient ellipses sweeping across a 200% canvas), Fireflies (three layers of glowing dots drifting). All use vanilla-extract `keyframes()` — no canvas, GPU-composited. Respects `prefers-reduced-motion: reduce` (animation stripped at call time). "Pause Background Animations" toggle in Settings → Appearance provides an in-app override. Implemented in `src/app/styles/Animations.css.ts` + `src/app/features/lotus/chatBackground.ts`.
|
||||
- **Glassmorphism Sidebar**: Settings → Appearance toggle (off by default). When enabled, the left sidebar becomes semi-transparent (`background: rgba(3,5,8,0.55)`) with `backdrop-filter: blur(12px)` so chat background patterns show through as a frosted glass effect. Fix: the active chat background is mirrored onto `document.body` via `useEffect` in `SidebarNav.tsx` so the blur has content to work through (previously the sidebar was a flex sibling with nothing physically behind it). Implemented as a vanilla-extract `SidebarGlass` class applied to the `<Sidebar>` container in `SidebarNav.tsx`.
|
||||
|
||||
|
||||
- **Night Light / Blue Light Filter**: Warm orange overlay (`rgba(255,140,0,N%)`) across the entire UI. Toggle + intensity slider (5–80%) in Settings → Appearance. `position:fixed; pointer-events:none; z-index:9998`. Persists across sessions.
|
||||
|
||||
### Notification Enhancements
|
||||
|
||||
- **Custom notification sounds**: `messageSoundId` / `inviteSoundId` settings select per-category notification sound (`notification.ogg`, `invite.ogg`, `call.ogg`, or None). Settings → Notifications expands the sound toggle with Message Sound + Invite Sound selects and ▶ preview buttons. Shared `notificationSounds.ts` module.
|
||||
- **Notification quiet hours**: `quietHoursEnabled` / `quietHoursStart` / `quietHoursEnd` settings suppress all desktop notifications and sounds during a configured time window. Handles overnight spans (e.g. 23:00–08:00). Settings → Notifications: Quiet Hours card with toggle + start/end time pickers.
|
||||
- **Full push rule editor**: Settings → Notifications → Advanced Push Rules section. Covers override, room, sender, and underride rule kinds. Each row has a human-readable label for built-in rules, an enable/disable toggle, and a delete button for custom rules. An add-rule form at the bottom of the room and sender sections lets users create new per-room or per-sender push rules by entering the room/user ID.
|
||||
|
||||
### Calls (Extended)
|
||||
|
||||
- **Push-to-Deafen**: Press `M` during a call to toggle speaker mute (deafen). Configurable in Settings → General → Calls alongside the PTT key. Skips editable elements; guards `e.repeat`; uses `el.ownerDocument.body` for iframe safety.
|
||||
- **TDS typing indicator dots**: When Lotus Terminal mode is active, the animated typing indicator dots turn TDS orange (`var(--lt-accent-orange)`) via `color: currentColor` inheritance.
|
||||
|
||||
### Server Integration
|
||||
|
||||
- **Server support contact (MSC1929)**: Settings → Help & About displays the homeserver admin contact fetched from `/.well-known/matrix/support`. Shows the admin's Matrix ID and a link to the support page when the homeserver has configured this endpoint. Degrades gracefully when not configured (section is hidden on 404 or network error). In TDS mode the contact text and link render in `--lt-accent-cyan`. Implemented in `src/app/features/settings/about/About.tsx`.
|
||||
- **Server notices**: Rooms of type `m.server_notice` (system messages from the homeserver) now render with a distinct "Server Notice" `<Chip variant="Warning">` badge in the room header and a disabled composer showing "This is a server notice room — you cannot send messages here." Previously indistinguishable from regular DMs. Badge in `src/app/features/room/RoomViewHeader.tsx`; composer guard in `src/app/features/room/RoomInput.tsx`.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
|
||||
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
|
||||
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
|
||||
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
|
||||
- **URL preview default in encrypted rooms**: `encUrlPreview` default changed from `false` to `true` in `src/app/state/settings.ts`. A security note is shown next to the toggle in Settings → General explaining that the homeserver fetches the URL (and sees it) but not the message content.
|
||||
The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
## Features
|
||||
|
||||
### Messaging
|
||||
|
||||
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||
- Bookmark any message and revisit saved messages from the sidebar
|
||||
- Schedule messages to send at a specific time
|
||||
- Click "edited" on any message to see the full edit history
|
||||
- Drafts are saved automatically and survive page reloads
|
||||
- Long messages collapse automatically — click "Read more" to expand
|
||||
- Forward messages to other rooms
|
||||
- Create and view polls directly in chat
|
||||
- Share your location with an inline map embed
|
||||
- Add captions to image and video uploads
|
||||
- Optionally compress images before uploading — shows before/after file sizes
|
||||
- GIF links from Giphy and Tenor auto-preview inline
|
||||
- Search for and send GIFs from a built-in GIF picker
|
||||
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||
- Search messages with a date range filter
|
||||
- Room topics support rich formatting (bold, links, italics)
|
||||
- Deleted messages show a placeholder instead of disappearing
|
||||
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||
- Rich link preview cards for YouTube, GitHub, Twitter/X, Reddit, Spotify, Twitch, Steam, Wikipedia, Discord, npm, Stack Overflow, and IMDb
|
||||
|
||||
### Calls & Voice
|
||||
|
||||
- Push to Talk with a configurable keybind (default: Space)
|
||||
- Push to Deafen with the M key
|
||||
- Camera starts turned off by default when joining a call
|
||||
- Screenshare requires confirmation before going live
|
||||
- Toggle noise suppression on or off
|
||||
- Calls float in a draggable picture-in-picture window when you navigate away
|
||||
- Your chat background shows through the call view
|
||||
- Dark/light mode inside calls matches your Lotus Chat theme
|
||||
- Calls are available in DMs and private groups only — no accidental mass rings
|
||||
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
||||
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
||||
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||
|
||||
### Customization & Appearance
|
||||
|
||||
- LotusGuild Terminal Design System (TDS) — a CRT terminal-inspired dark theme
|
||||
- TDS light mode variant for daytime use
|
||||
- 20+ static chat background patterns
|
||||
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies
|
||||
- Toggle to pause background animations
|
||||
- Glassmorphism sidebar — frosted glass effect that lets the background show through
|
||||
- Night Light / blue light filter with an adjustable intensity slider
|
||||
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
|
||||
- Rename any room for yourself only — other members see the original name
|
||||
- Emoji picker on all room name inputs
|
||||
|
||||
### Presence & Profile
|
||||
|
||||
- Discord-style presence selector: Online, Idle, Do Not Disturb, Invisible, or Auto
|
||||
- Custom status message with emoji and an optional auto-clear timer
|
||||
- Colored presence ring on member avatars (green / yellow / red)
|
||||
- Profile fields for pronouns and timezone
|
||||
- When a user's timezone is set, their current local time appears in their profile
|
||||
- Unread count shown in the browser tab title
|
||||
|
||||
### Moderation & Privacy
|
||||
|
||||
- Report any room to homeserver admins from the room menu
|
||||
- View policy lists and ban lists (Draupnir-compatible, read-only)
|
||||
- Toggle private read receipts so others can't see when you've read messages
|
||||
- Optional warning when an encrypted room contains unverified devices
|
||||
- Full push rule editor in notification settings
|
||||
- View and edit Server ACL rules in room settings
|
||||
- Filterable room activity / mod log (joins, kicks, bans, power level changes, etc.)
|
||||
- Room stats and insights panel (active members, top reactions, media breakdown, activity heatmap)
|
||||
- Export room history as plain text, JSON, or HTML with optional date range filter
|
||||
|
||||
### Notifications
|
||||
|
||||
- In-app toast notifications appear bottom-right when the window is focused
|
||||
- Custom notification sounds per category (messages, invites)
|
||||
- Quiet hours — suppress notifications during a configured time window
|
||||
- Click a toast to jump directly to the room or DM
|
||||
|
||||
### UX
|
||||
|
||||
- Filter and search rooms in the sidebar
|
||||
- Favorite rooms sync across devices and appear in a pinned section
|
||||
- Sort rooms by recent activity, alphabetical, or unread first
|
||||
- DM rows show a message preview and relative timestamp
|
||||
- Right-click a room for a context menu: mute with duration, copy link, mark as read
|
||||
- Quick emoji reactions appear on message hover — one click to react
|
||||
- Knock-to-join: request access to a room; admins approve or deny from the members list
|
||||
- Media gallery drawer: browse all images, videos, and files shared in a room
|
||||
- Invite link and QR code in room settings
|
||||
- Pending knock requests shown in the members list for room admins with a live badge count on the Members button
|
||||
- Homeserver support contact displayed in Help & About (MSC1929)
|
||||
- Server notice rooms are visually distinct from regular DMs
|
||||
|
||||
---
|
||||
|
||||
## Desktop App
|
||||
|
||||
Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same web client in a native window with automatic background updates — no need to reinstall for new versions.
|
||||
|
||||
### Download
|
||||
|
||||
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
|
||||
|
||||
### SmartScreen Warning (Windows)
|
||||
|
||||
When you first run the installer on Windows, you may see a popup that says **"Windows protected your PC"** with the app listed as an unknown publisher. This is normal.
|
||||
|
||||
**Why it happens:** Windows SmartScreen flags any app that does not have an expensive commercial code-signing certificate from a major CA. Lotus Chat is signed with its own key for update verification, but that key is not in Microsoft's pre-approved list.
|
||||
|
||||
**How to install anyway:**
|
||||
|
||||
1. Click **"More info"** in the SmartScreen dialog.
|
||||
2. A **"Run anyway"** button will appear.
|
||||
3. Click it to proceed with installation.
|
||||
|
||||
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
|
||||
|
||||
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run build # outputs to dist/
|
||||
npm ci && npm run build # outputs to dist/
|
||||
```
|
||||
|
||||
Vite's render-chunks phase requires ~6 GB Node heap. If OOM killed, set:
|
||||
If the build is killed due to out-of-memory:
|
||||
|
||||
```bash
|
||||
NODE_OPTIONS=--max_old_space_size=6144 npm run build
|
||||
```
|
||||
|
||||
## Development workflow
|
||||
|
||||
All code changes should be made in the local clone at `/root/code/cinny` on the dev box, then committed and pushed to `origin/lotus`. The CI/CD pipeline handles everything from there — no manual build or deploy steps needed.
|
||||
### CI/CD
|
||||
|
||||
```
|
||||
edit → commit → git push # ~11 minutes → auto-deployed to chat.lotusguild.org
|
||||
edit → commit → git push → ~11 min → live at chat.lotusguild.org
|
||||
```
|
||||
|
||||
Pipeline (`.gitea/workflows/ci.yml` + `lotus_deploy.sh` on LXC 106):
|
||||
1. Push triggers a Gitea Actions build — TypeScript check, ESLint, Prettier, bundle size report
|
||||
2. Build must pass as the CI gate; quality checks are informational (`continue-on-error`)
|
||||
3. A Gitea webhook fires `lotus_deploy.sh` on LXC 106, which polls the API until CI passes (up to 15 min), then pulls `origin/lotus`, runs `npm ci && npm run build`, and rsyncs to `/var/www/html/`
|
||||
|
||||
LXC 106's stored Gitea credential is **read-only** — it can only pull. Pushes must be done from the dev box with your personal credentials (entered manually, never cached).
|
||||
|
||||
## Deployment
|
||||
|
||||
Built files are served from `/var/www/html/` on LXC 106 (nginx). Config lives at `/opt/lotus-cinny/config.json` (vite copies it to `dist/`):
|
||||
|
||||
```json
|
||||
{
|
||||
"defaultHomeserver": 0,
|
||||
"homeserverList": ["matrix.lotusguild.org"],
|
||||
"allowCustomHomeservers": false,
|
||||
"gifApiKey": "<giphy_key>"
|
||||
}
|
||||
```
|
||||
|
||||
## Key Custom Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/lotus-terminal.css.ts` | All TDS CSS tokens, global styles, light/dark variants |
|
||||
| `src/lotus-boot.ts` | Boot sequence animation (runs once per session) |
|
||||
| `src/app/hooks/useRoomReadPositions.ts` | Per-message read receipt position map |
|
||||
| `src/app/features/room/ReadPositionsContext.ts` | React context for read positions |
|
||||
| `src/app/components/read-receipt-avatars/` | Read receipt avatar pill component |
|
||||
| `src/app/components/event-readers/EventReaders.tsx` | "Seen by" modal with timestamps |
|
||||
| `src/app/components/GifPicker.tsx` | GIF search + send |
|
||||
| `src/app/features/call/CallControls.tsx` | PTT badge + keybind logic |
|
||||
| `src/app/plugins/call/CallControl.ts` | EC widget bridge (screenshare revert, PTT mic) |
|
||||
| `src/app/components/CallEmbedProvider.tsx` | PiP + draggable call embed, call wallpaper carry-over |
|
||||
| `src/app/plugins/call/CallEmbed.ts` | EC widget bridge: iframe setup, `color-scheme` dark/light injection, built-in control hiding, theme sync |
|
||||
| `src/app/plugins/millify.ts` | Named import fix for Rolldown CJS interop (prevents `zc.default is not a function` crash) |
|
||||
| `src/app/features/room/MediaGallery.tsx` | Right-side media gallery drawer (images/videos/files) |
|
||||
| `src/app/features/room/PollCreator.tsx` | Poll creation modal (single/multiple choice, 2–10 options) |
|
||||
| `src/app/features/common-settings/general/RoomShareInvite.tsx` | Invite link + QR code tile for room settings |
|
||||
| `src/app/utils/syntaxHighlight.ts` | TDS code syntax tokenizer (JS/TS/Python/Rust → inline CSS vars) |
|
||||
| `src/app/features/room-settings/ExportRoomHistory.tsx` | Export room messages to plain text / JSON / HTML with date range filter and E2EE awareness |
|
||||
| `src/app/features/room-settings/RoomActivityLog.tsx` | Filterable mod log of room state events (joins, kicks, bans, power level changes, etc.) |
|
||||
| `src/app/features/room-settings/RoomServerACL.tsx` | Server ACL viewer/editor (allow/deny lists, IP literal toggle, power-level gated) |
|
||||
| `src/app/features/room-settings/RoomInsights.tsx` | Room stats panel: top members bar chart, top reactions, media breakdown, 24h heatmap |
|
||||
| `src/app/features/bookmarks/BookmarksPanel.tsx` | Saved messages sidebar panel with filter, jump-to-message, and remove |
|
||||
| `src/app/hooks/useBookmarks.ts` | Read/write `io.lotus.bookmarks` account data for message bookmarks |
|
||||
| `src/app/features/room/ScheduleMessageModal.tsx` | Schedule-message modal with datetime picker; sends via MSC4140 delayed events API |
|
||||
| `src/app/utils/scheduledMessages.ts` | Utilities for MSC4140 scheduled message state and cancel endpoint |
|
||||
| `src/app/hooks/useExtendedProfile.ts` | Read/write MSC4133 extended profile fields (`m.pronouns`, `m.tz`) |
|
||||
| `src/app/hooks/useLocalTime.ts` | Formats user local time from `m.tz` IANA zone; updates every 60 s |
|
||||
| `src/app/components/url-preview/UrlPreviewCard.tsx` | Domain-specific URL preview cards for 13 sites + generic favicon fallback |
|
||||
|
||||
@@ -29,10 +29,8 @@
|
||||
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
|
||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
|
||||
|
After Width: | Height: | Size: 851 KiB |
|
After Width: | Height: | Size: 944 KiB |
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<meta charset=utf-8>
|
||||
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||
<title>Error 404 (Not Found)!!1</title>
|
||||
<style>
|
||||
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||
</style>
|
||||
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||
@@ -0,0 +1,51 @@
|
||||
/* Self-hosted fonts — avoids tracking prevention in desktop WebView2 */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-italic-400.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-normal-400.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url('/fonts/JetBrainsMono-normal-700.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FiraCode-400.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url('/fonts/FiraCode-600.woff2') format('woff2');
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@@ -11,47 +11,47 @@
|
||||
"theme_color": "#980000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./public/android/android-chrome-36x36.png",
|
||||
"src": "./res/android/android-chrome-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-48x48.png",
|
||||
"src": "./res/android/android-chrome-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-72x72.png",
|
||||
"src": "./res/android/android-chrome-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-96x96.png",
|
||||
"src": "./res/android/android-chrome-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-144x144.png",
|
||||
"src": "./res/android/android-chrome-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-192x192.png",
|
||||
"src": "./res/android/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-256x256.png",
|
||||
"src": "./res/android/android-chrome-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-384x384.png",
|
||||
"src": "./res/android/android-chrome-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./public/android/android-chrome-512x512.png",
|
||||
"src": "./res/android/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -42,6 +42,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import CallSound from '../../../public/sound/call.ogg';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||
import { mDirectAtom } from '../state/mDirectList';
|
||||
@@ -406,6 +407,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
|
||||
useCallMemberSoundSync(embed);
|
||||
useCallJoinLeaveSounds(embed);
|
||||
useCallThemeSync(embed);
|
||||
useCallHangupEvent(
|
||||
embed,
|
||||
@@ -722,6 +724,54 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
document.addEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
|
||||
function applyResize(
|
||||
el: HTMLElement,
|
||||
corner: Corner,
|
||||
sx: number,
|
||||
sy: number,
|
||||
sw: number,
|
||||
sh: number,
|
||||
sl: number,
|
||||
st: number,
|
||||
cx: number,
|
||||
cy: number,
|
||||
) {
|
||||
const dx = cx - sx;
|
||||
const dy = cy - sy;
|
||||
let w = sw;
|
||||
let h = sh;
|
||||
let l = sl;
|
||||
let t = st;
|
||||
if (corner === 'se') {
|
||||
w = sw + dx;
|
||||
h = sh + dy;
|
||||
}
|
||||
if (corner === 'sw') {
|
||||
w = sw - dx;
|
||||
h = sh + dy;
|
||||
l = sl + sw - Math.max(PIP_MIN_W, w);
|
||||
}
|
||||
if (corner === 'ne') {
|
||||
w = sw + dx;
|
||||
h = sh - dy;
|
||||
t = st + sh - Math.max(PIP_MIN_H, h);
|
||||
}
|
||||
if (corner === 'nw') {
|
||||
w = sw - dx;
|
||||
h = sh - dy;
|
||||
l = sl + sw - Math.max(PIP_MIN_W, w);
|
||||
t = st + sh - Math.max(PIP_MIN_H, h);
|
||||
}
|
||||
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
||||
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
||||
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
||||
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
}
|
||||
|
||||
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
@@ -737,40 +787,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
document.body.style.cursor = `${corner}-resize`;
|
||||
document.body.style.userSelect = 'none';
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
const dx = ev.clientX - sx;
|
||||
const dy = ev.clientY - sy;
|
||||
let w = sw;
|
||||
let h = sh;
|
||||
let l = sl;
|
||||
let t = st;
|
||||
if (corner === 'se') {
|
||||
w = sw + dx;
|
||||
h = sh + dy;
|
||||
}
|
||||
if (corner === 'sw') {
|
||||
w = sw - dx;
|
||||
h = sh + dy;
|
||||
l = sl + sw - Math.max(PIP_MIN_W, w);
|
||||
}
|
||||
if (corner === 'ne') {
|
||||
w = sw + dx;
|
||||
h = sh - dy;
|
||||
t = st + sh - Math.max(PIP_MIN_H, h);
|
||||
}
|
||||
if (corner === 'nw') {
|
||||
w = sw - dx;
|
||||
h = sh - dy;
|
||||
l = sl + sw - Math.max(PIP_MIN_W, w);
|
||||
t = st + sh - Math.max(PIP_MIN_H, h);
|
||||
}
|
||||
w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth));
|
||||
h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight));
|
||||
l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W));
|
||||
t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H));
|
||||
el.style.width = `${w}px`;
|
||||
el.style.height = `${h}px`;
|
||||
el.style.left = `${l}px`;
|
||||
el.style.top = `${t}px`;
|
||||
applyResize(el, corner, sx, sy, sw, sh, sl, st, ev.clientX, ev.clientY);
|
||||
};
|
||||
const onUp = () => {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
@@ -789,6 +806,38 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
document.addEventListener('mouseup', onUp);
|
||||
};
|
||||
|
||||
const handleResizeTouchStart = (e: React.TouchEvent, corner: Corner) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
const el = callEmbedRef.current;
|
||||
if (!el || e.touches.length !== 1) return;
|
||||
normaliseToTopLeft(el);
|
||||
const touch = e.touches[0];
|
||||
const sx = touch.clientX;
|
||||
const sy = touch.clientY;
|
||||
const sw = el.offsetWidth;
|
||||
const sh = el.offsetHeight;
|
||||
const sl = parseFloat(el.style.left);
|
||||
const st = parseFloat(el.style.top);
|
||||
const onMove = (ev: TouchEvent) => {
|
||||
if (ev.touches.length !== 1) return;
|
||||
ev.preventDefault();
|
||||
const t = ev.touches[0];
|
||||
applyResize(el, corner, sx, sy, sw, sh, sl, st, t.clientX, t.clientY);
|
||||
};
|
||||
const onEnd = () => {
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onEnd);
|
||||
activeDragCleanupRef.current = null;
|
||||
};
|
||||
activeDragCleanupRef.current = () => {
|
||||
document.removeEventListener('touchmove', onMove);
|
||||
document.removeEventListener('touchend', onEnd);
|
||||
};
|
||||
document.addEventListener('touchmove', onMove, { passive: false });
|
||||
document.addEventListener('touchend', onEnd);
|
||||
};
|
||||
|
||||
return (
|
||||
<CallEmbedContextProvider value={callEmbed}>
|
||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||
@@ -871,6 +920,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
<div
|
||||
key={corner}
|
||||
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
|
||||
onTouchStart={(ev) => handleResizeTouchStart(ev, corner)}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
|
||||
@@ -36,12 +36,12 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
<div
|
||||
style={{
|
||||
padding: '5px 10px 4px',
|
||||
borderBottom: '1px solid rgba(255,107,0,0.2)',
|
||||
borderBottom: '1px solid color-mix(in srgb, var(--lt-accent-orange) 20%, transparent)',
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
color: '#FF6B00',
|
||||
color: 'var(--lt-accent-orange)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
@@ -82,11 +82,12 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||
|
||||
const containerStyle = lotusTerminal
|
||||
? {
|
||||
background: '#060c14',
|
||||
border: '1px solid rgba(255,107,0,0.35)',
|
||||
background: 'var(--lt-bg-secondary)',
|
||||
border: '1px solid color-mix(in srgb, var(--lt-accent-orange) 35%, transparent)',
|
||||
borderRadius: '4px',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 4px 24px rgba(255,107,0,0.10), 0 0 0 1px rgba(255,107,0,0.08)',
|
||||
boxShadow:
|
||||
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
|
||||
width: `${PICKER_WIDTH}px`,
|
||||
}
|
||||
: {
|
||||
|
||||
@@ -203,7 +203,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
width: toRem(8),
|
||||
height: toRem(8),
|
||||
borderRadius: '50%',
|
||||
background: lotusTerminal ? '#FF6B00' : 'var(--tc-danger-normal)',
|
||||
background: lotusTerminal ? 'var(--lt-accent-orange)' : 'var(--tc-danger-normal)',
|
||||
flexShrink: 0,
|
||||
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
||||
}}
|
||||
@@ -214,7 +214,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
minWidth: toRem(32),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
...(lotusTerminal
|
||||
? { fontFamily: 'JetBrains Mono, monospace', color: '#00FF88', fontWeight: 700 }
|
||||
? {
|
||||
fontFamily: 'JetBrains Mono, monospace',
|
||||
color: 'var(--lt-accent-green)',
|
||||
fontWeight: 700,
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
@@ -233,7 +237,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
width: toRem(2),
|
||||
height: toRem(2 + (h / barMax) * 16),
|
||||
borderRadius: toRem(1),
|
||||
background: lotusTerminal ? '#00FF88' : 'var(--tc-primary-normal)',
|
||||
background: lotusTerminal ? 'var(--lt-accent-green)' : 'var(--tc-primary-normal)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
TooltipProvider,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import React, { ReactNode, useId } from 'react';
|
||||
import React, { ReactNode } from 'react';
|
||||
import * as css from './styles.css';
|
||||
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||
|
||||
@@ -27,7 +27,7 @@ type PresenceBadgeProps = {
|
||||
};
|
||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
const label = usePresenceLabel();
|
||||
const badgeLabelId = useId();
|
||||
const ariaLabel = status ? `${label[presence]} — ${status}` : label[presence];
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
@@ -36,7 +36,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
offset={4}
|
||||
delay={200}
|
||||
tooltip={
|
||||
<Tooltip id={badgeLabelId}>
|
||||
<Tooltip>
|
||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||
<Text size="L400">{label[presence]}</Text>
|
||||
{status && <Text size="T200">•</Text>}
|
||||
@@ -47,7 +47,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<Badge
|
||||
aria-labelledby={badgeLabelId}
|
||||
aria-label={ariaLabel}
|
||||
ref={triggerRef}
|
||||
size={size}
|
||||
variant={PresenceToColor[presence]}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||
|
||||
type CallControlsProps = {
|
||||
callEmbed: CallEmbed;
|
||||
@@ -71,6 +72,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||
useCallControlState(callEmbed.control);
|
||||
|
||||
useAfkAutoMute(callEmbed);
|
||||
|
||||
const [cords, setCords] = useState<RectCords>();
|
||||
const [shareConfirm, setShareConfirm] = useState(false);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||
import { CallMemberRenderer } from './CallMemberCard';
|
||||
import * as css from './styles.css';
|
||||
import { CallControls } from './CallControls';
|
||||
@@ -74,6 +76,14 @@ function AlreadyInCallMessage() {
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelFullMessage({ current, max }: { current: number; max: number }) {
|
||||
return (
|
||||
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||
Channel Full ({current}/{max}) — Wait for someone to leave before joining.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function CallPrescreen() {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
@@ -96,7 +106,14 @@ function CallPrescreen() {
|
||||
const callEmbed = useCallEmbed();
|
||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported;
|
||||
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
|
||||
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||
// A user already counted in the session is rejoining and should not be blocked.
|
||||
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
|
||||
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
|
||||
|
||||
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
|
||||
|
||||
return (
|
||||
<Scroll variant="Surface" hideTrack>
|
||||
@@ -117,16 +134,17 @@ function CallPrescreen() {
|
||||
<CallMemberRenderer members={callMembers} />
|
||||
<PrescreenControls canJoin={canJoin} />
|
||||
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||
{!inOtherCall &&
|
||||
(hasPermission ? (
|
||||
<JoinMessage
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
/>
|
||||
) : (
|
||||
<NoPermissionMessage />
|
||||
))}
|
||||
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
|
||||
{!inOtherCall && hasPermission && channelFull && (
|
||||
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
|
||||
)}
|
||||
{!inOtherCall && hasPermission && !channelFull && (
|
||||
<JoinMessage
|
||||
hasParticipant={hasParticipant}
|
||||
livekitSupported={livekitSupported}
|
||||
rtcSupported={rtcSupported}
|
||||
/>
|
||||
)}
|
||||
{inOtherCall && <AlreadyInCallMessage />}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import React, { FormEventHandler, useCallback } from 'react';
|
||||
import { Box, Button, color, Input, Spinner, Text } from 'folds';
|
||||
import { MatrixError } from 'matrix-js-sdk';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
|
||||
export type VoiceLimitContent = {
|
||||
max_users?: number;
|
||||
};
|
||||
|
||||
type RoomVoiceLimitProps = {
|
||||
permissions: RoomPermissionsAPI;
|
||||
};
|
||||
export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
|
||||
const mx = useMatrixClient();
|
||||
const room = useRoom();
|
||||
|
||||
const canEdit = permissions.stateEvent(StateEvent.LotusVoiceLimit, mx.getSafeUserId());
|
||||
|
||||
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||
|
||||
const [submitState, submit] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (value: number) => {
|
||||
const content: VoiceLimitContent = value > 0 ? { max_users: value } : {};
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.LotusVoiceLimit as any, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
);
|
||||
const submitting = submitState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
const target = evt.target as HTMLFormElement;
|
||||
const limitInput = target.elements.namedItem('limitInput') as HTMLInputElement | null;
|
||||
if (!limitInput) return;
|
||||
const parsed = parseInt(limitInput.value, 10);
|
||||
const value = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
|
||||
submit(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="Voice Channel Limit"
|
||||
description="Set the maximum number of participants allowed in this room's voice call. Set to 0 for no limit. Enforced on the server for all Matrix clients."
|
||||
>
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||
<Box style={{ maxWidth: '100px' }} grow="Yes">
|
||||
<Input
|
||||
key={maxUsers}
|
||||
name="limitInput"
|
||||
defaultValue={maxUsers}
|
||||
type="number"
|
||||
min={0}
|
||||
max={99}
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
readOnly={!canEdit}
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</Box>
|
||||
<Button
|
||||
type="submit"
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill="Solid"
|
||||
radii="300"
|
||||
disabled={!canEdit || submitting}
|
||||
before={submitting ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined}
|
||||
>
|
||||
<Text size="B300">Save</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
{submitState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||
{(submitState.error as MatrixError).message}
|
||||
</Text>
|
||||
)}
|
||||
</SettingTile>
|
||||
</SequenceCard>
|
||||
);
|
||||
}
|
||||
@@ -6,3 +6,4 @@ export * from './RoomProfile';
|
||||
export * from './RoomPublish';
|
||||
export * from './RoomShareInvite';
|
||||
export * from './RoomUpgrade';
|
||||
export * from './RoomVoiceLimit';
|
||||
|
||||
@@ -50,7 +50,10 @@ export const useLocalMessageSearch = () => {
|
||||
|
||||
encryptedRoomsCount += 1;
|
||||
|
||||
const events = room.getLiveTimeline().getEvents();
|
||||
const events = room
|
||||
.getUnfilteredTimelineSet()
|
||||
.getTimelines()
|
||||
.flatMap((tl) => tl.getEvents());
|
||||
if (events.length === 0) continue;
|
||||
|
||||
searchedRoomsCount += 1;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
RoomPublish,
|
||||
RoomShareInvite,
|
||||
RoomUpgrade,
|
||||
RoomVoiceLimit,
|
||||
} from '../../common-settings/general';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||
@@ -54,6 +55,10 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<RoomEncryption permissions={permissions} />
|
||||
<RoomPublish permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Voice</Text>
|
||||
<RoomVoiceLimit permissions={permissions} />
|
||||
</Box>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Addresses</Text>
|
||||
<RoomPublishedAddresses permissions={permissions} />
|
||||
|
||||
@@ -209,6 +209,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||
const showGif = composerToolbarButtons?.showGif ?? true;
|
||||
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||
const handleShareLocation = () => {
|
||||
@@ -933,88 +942,96 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
after={
|
||||
<>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||
aria-pressed={toolbar}
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
emojiBoardTab === undefined
|
||||
? undefined
|
||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
}
|
||||
content={
|
||||
<React.Suspense fallback={null}>
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab((t) => {
|
||||
if (t) {
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
return undefined;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
}
|
||||
>
|
||||
{!hideStickerBtn && (
|
||||
<IconButton
|
||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
aria-label="Insert sticker"
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Sticker}
|
||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
ref={emojiBtnRef}
|
||||
aria-label="Insert emoji"
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
{showFormat && (
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={toolbar ? 'Hide formatting toolbar' : 'Show formatting toolbar'}
|
||||
aria-pressed={toolbar}
|
||||
onClick={() => setToolbar(!toolbar)}
|
||||
>
|
||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||
</IconButton>
|
||||
)}
|
||||
{(showEmoji || showSticker) && (
|
||||
<UseStateProvider initial={undefined}>
|
||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||
<PopOut
|
||||
offset={16}
|
||||
alignOffset={-44}
|
||||
position="Top"
|
||||
align="End"
|
||||
anchor={
|
||||
emojiBoardTab === undefined
|
||||
? undefined
|
||||
: (emojiBtnRef.current?.getBoundingClientRect() ?? undefined)
|
||||
}
|
||||
content={
|
||||
<React.Suspense fallback={null}>
|
||||
<EmojiBoard
|
||||
tab={emojiBoardTab}
|
||||
onTabChange={setEmojiBoardTab}
|
||||
imagePackRooms={imagePackRooms}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmoticonSelect}
|
||||
onCustomEmojiSelect={handleEmoticonSelect}
|
||||
onStickerSelect={handleStickerSelect}
|
||||
requestClose={() => {
|
||||
setEmojiBoardTab((t) => {
|
||||
if (t) {
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
return undefined;
|
||||
}
|
||||
return t;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
filled={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
{!!gifApiKey && (
|
||||
{showSticker && !hideStickerBtn && (
|
||||
<IconButton
|
||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
aria-label="Insert sticker"
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Sticker)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Sticker}
|
||||
filled={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
{showEmoji && (
|
||||
<IconButton
|
||||
ref={emojiBtnRef}
|
||||
aria-label="Insert emoji"
|
||||
aria-pressed={
|
||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
onClick={() => setEmojiBoardTab(EmojiBoardTab.Emoji)}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon
|
||||
src={Icons.Smile}
|
||||
filled={
|
||||
hideStickerBtn
|
||||
? !!emojiBoardTab
|
||||
: emojiBoardTab === EmojiBoardTab.Emoji
|
||||
}
|
||||
/>
|
||||
</IconButton>
|
||||
)}
|
||||
</PopOut>
|
||||
)}
|
||||
</UseStateProvider>
|
||||
)}
|
||||
{!!gifApiKey && showGif && (
|
||||
<UseStateProvider initial={false}>
|
||||
{(gifOpen: boolean, setGifOpen) => (
|
||||
<PopOut
|
||||
@@ -1093,38 +1110,44 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{locationError}
|
||||
</Text>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleShareLocation}
|
||||
disabled={locating}
|
||||
aria-label="Share location"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Share location"
|
||||
>
|
||||
{locating ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||
)}
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() => setPollOpen(true)}
|
||||
aria-label="Create poll"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Create poll"
|
||||
>
|
||||
<Icon src={Icons.OrderList} size="100" />
|
||||
</IconButton>
|
||||
<VoiceMessageRecorder
|
||||
onSend={handleVoiceSend}
|
||||
onError={(err) => {
|
||||
setLocationError(err);
|
||||
setTimeout(() => setLocationError(null), 4000);
|
||||
}}
|
||||
/>
|
||||
{showLocation && (
|
||||
<IconButton
|
||||
onClick={handleShareLocation}
|
||||
disabled={locating}
|
||||
aria-label="Share location"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Share location"
|
||||
>
|
||||
{locating ? (
|
||||
<Spinner variant="Secondary" size="100" />
|
||||
) : (
|
||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
{showPoll && (
|
||||
<IconButton
|
||||
onClick={() => setPollOpen(true)}
|
||||
aria-label="Create poll"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Create poll"
|
||||
>
|
||||
<Icon src={Icons.OrderList} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{showVoice && (
|
||||
<VoiceMessageRecorder
|
||||
onSend={handleVoiceSend}
|
||||
onError={(err) => {
|
||||
setLocationError(err);
|
||||
setTimeout(() => setLocationError(null), 4000);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{charCount > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1141,16 +1164,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
{charCount}
|
||||
</Text>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={handleScheduleClick}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Schedule message"
|
||||
title="Schedule message"
|
||||
>
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
</IconButton>
|
||||
{showSchedule && (
|
||||
<IconButton
|
||||
onClick={handleScheduleClick}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Schedule message"
|
||||
title="Schedule message"
|
||||
>
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
onClick={submit}
|
||||
variant="SurfaceVariant"
|
||||
|
||||
@@ -62,6 +62,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||
const [glassmorphismSidebar] = useSetting(settingsAtom, 'glassmorphismSidebar');
|
||||
const theme = useTheme();
|
||||
const isDark = theme.kind === ThemeKind.Dark;
|
||||
|
||||
@@ -97,14 +98,19 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
),
|
||||
);
|
||||
|
||||
// When glassmorphism is active, document.body already carries the background so the
|
||||
// sidebar blur has something to work through. Skip applying it here to avoid running
|
||||
// the same CSS animation twice (one per layer = double GPU work).
|
||||
const chatBgStyle = useMemo(
|
||||
() =>
|
||||
getChatBg(
|
||||
lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground,
|
||||
isDark,
|
||||
pauseAnimations,
|
||||
),
|
||||
[chatBackground, lotusTerminal, isDark, pauseAnimations],
|
||||
glassmorphismSidebar
|
||||
? {}
|
||||
: getChatBg(
|
||||
lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground,
|
||||
isDark,
|
||||
pauseAnimations,
|
||||
),
|
||||
[chatBackground, lotusTerminal, isDark, pauseAnimations, glassmorphismSidebar],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -73,6 +73,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { MediaGallery } from './MediaGallery';
|
||||
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
||||
|
||||
type RoomMenuProps = {
|
||||
room: Room;
|
||||
@@ -423,7 +424,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
|
||||
const pinnedEvents = useRoomPinnedEvents(room);
|
||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||
const encryptedRoom = !!encryptionEvent;
|
||||
const _encryptedRoom = !!encryptionEvent;
|
||||
const avatarMxc = useRoomAvatar(room, direct);
|
||||
const name = useLocalRoomName(room);
|
||||
const topic = useRoomTopic(room);
|
||||
@@ -433,6 +434,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const pendingKnocks = usePendingKnocks(room);
|
||||
|
||||
const handleSearchClick = () => {
|
||||
const searchParams: _SearchPathSearchParams = {
|
||||
@@ -558,7 +560,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
</Box>
|
||||
|
||||
<Box shrink="No">
|
||||
{!encryptedRoom && (
|
||||
{
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
@@ -579,7 +581,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
@@ -682,14 +684,45 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleMemberToggle}
|
||||
aria-label="Toggle member list"
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
<div style={{ position: 'relative', display: 'inline-flex' }}>
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={handleMemberToggle}
|
||||
aria-label={
|
||||
pendingKnocks.length > 0
|
||||
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
|
||||
: 'Toggle member list'
|
||||
}
|
||||
>
|
||||
<Icon size="400" src={Icons.User} />
|
||||
</IconButton>
|
||||
{pendingKnocks.length > 0 && (
|
||||
<Badge
|
||||
aria-hidden
|
||||
variant="Warning"
|
||||
fill="Solid"
|
||||
radii="Pill"
|
||||
size="200"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '-2px',
|
||||
right: '-2px',
|
||||
pointerEvents: 'none',
|
||||
fontSize: '9px',
|
||||
minWidth: '14px',
|
||||
height: '14px',
|
||||
padding: '0 3px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
|
||||
@@ -110,21 +110,27 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
||||
const res = (await fetchRes.json()) as EditHistoryResponse;
|
||||
const rawEvents = res.chunk ?? [];
|
||||
const events = rawEvents
|
||||
.filter(isRawEditEvent)
|
||||
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
|
||||
.map((raw) => {
|
||||
const existing = room.findEventById(raw.event_id);
|
||||
if (existing) return existing;
|
||||
return new MatrixEvent({
|
||||
type: raw.type,
|
||||
content: raw.content,
|
||||
origin_server_ts: raw.origin_server_ts,
|
||||
event_id: raw.event_id,
|
||||
room_id: roomId,
|
||||
sender: mEvent.getSender() ?? '',
|
||||
});
|
||||
});
|
||||
const events = await Promise.all(
|
||||
rawEvents
|
||||
.filter(isRawEditEvent)
|
||||
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
|
||||
.map(async (raw) => {
|
||||
const existing = room.findEventById(raw.event_id);
|
||||
if (existing) return existing;
|
||||
const evt = new MatrixEvent({
|
||||
type: raw.type,
|
||||
content: raw.content,
|
||||
origin_server_ts: raw.origin_server_ts,
|
||||
event_id: raw.event_id,
|
||||
room_id: roomId,
|
||||
sender: mEvent.getSender() ?? '',
|
||||
});
|
||||
if (evt.isEncrypted()) {
|
||||
await mx.decryptEventIfNeeded(evt);
|
||||
}
|
||||
return evt;
|
||||
}),
|
||||
);
|
||||
|
||||
return { events, hasMore: !!res.next_batch };
|
||||
}, [mx, roomId, eventId, room, mEvent]),
|
||||
|
||||
@@ -317,6 +317,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
||||
}
|
||||
|
||||
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||
|
||||
const CLEAR_AFTER_OPTIONS = [
|
||||
{ label: 'Never', value: '0' },
|
||||
@@ -344,7 +345,9 @@ function ProfileStatus() {
|
||||
const userId = mx.getUserId()!;
|
||||
const presence = useUserPresence(userId);
|
||||
|
||||
const [statusMsg, setStatusMsg] = useState<string>(presence?.status ?? '');
|
||||
const [statusMsg, setStatusMsg] = useState<string>(
|
||||
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
||||
);
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
|
||||
@@ -354,16 +357,22 @@ function ProfileStatus() {
|
||||
return stored ? parseInt(stored, 10) : 0;
|
||||
});
|
||||
|
||||
// Sync input when another device changes the status
|
||||
// Sync input when another device changes the status.
|
||||
// Only update if the server actually has a value — ignore empty sync events
|
||||
// caused by Synapse clearing status_msg on reconnect.
|
||||
useEffect(() => {
|
||||
setStatusMsg(presence?.status ?? '');
|
||||
}, [presence?.status]);
|
||||
if (presence?.status) {
|
||||
setStatusMsg(presence.status);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), presence.status);
|
||||
}
|
||||
}, [presence?.status, userId]);
|
||||
|
||||
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
|
||||
useEffect(() => {
|
||||
if (!expiryTs) return undefined;
|
||||
const remaining = expiryTs - Date.now();
|
||||
const clearStatus = () => {
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
setExpiryTs(0);
|
||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
||||
@@ -403,6 +412,12 @@ function ProfileStatus() {
|
||||
const msg = statusMsg.trim();
|
||||
saveStatus(msg).catch(() => undefined);
|
||||
|
||||
if (msg) {
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
}
|
||||
|
||||
const delayMs = getMsFromOption(clearAfter);
|
||||
if (msg && delayMs > 0) {
|
||||
const ts = Date.now() + delayMs;
|
||||
@@ -416,6 +431,7 @@ function ProfileStatus() {
|
||||
|
||||
const handleClear = () => {
|
||||
setStatusMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
setExpiryTs(0);
|
||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
||||
@@ -722,30 +738,36 @@ function ProfileTimezone() {
|
||||
const [savedTimezone, setSavedTimezone] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const cached = mx
|
||||
.getAccountData('im.lotus.timezone' as any)
|
||||
?.getContent<{ timezone: string }>();
|
||||
if (cached?.timezone) {
|
||||
setTimezone(cached.timezone);
|
||||
setSavedTimezone(cached.timezone);
|
||||
}
|
||||
// Also fetch from server in case account data hasn't synced yet
|
||||
mx.http
|
||||
.authedRequest<{ 'm.tz': string }>(Method.Get, `/profile/${encodeURIComponent(userId)}/m.tz`)
|
||||
.authedRequest<{ timezone: string }>(
|
||||
Method.Get,
|
||||
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
|
||||
)
|
||||
.then((res) => {
|
||||
const val = res['m.tz'] ?? '';
|
||||
const val = res.timezone ?? '';
|
||||
setTimezone(val);
|
||||
setSavedTimezone(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setTimezone('');
|
||||
setSavedTimezone('');
|
||||
/* no stored timezone yet */
|
||||
});
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, saveTimezone] = useAsyncCallback(
|
||||
useCallback(
|
||||
(value: string) =>
|
||||
mx.http
|
||||
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
|
||||
'm.tz': value,
|
||||
})
|
||||
.then(() => {
|
||||
setSavedTimezone(value);
|
||||
}),
|
||||
[mx, userId],
|
||||
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
|
||||
setSavedTimezone(value);
|
||||
}),
|
||||
[mx],
|
||||
),
|
||||
);
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
PopOut,
|
||||
RectCords,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
toRem,
|
||||
@@ -37,6 +38,7 @@ import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import {
|
||||
ChatBackground,
|
||||
ComposerToolbarSettings,
|
||||
DateFormat,
|
||||
MessageLayout,
|
||||
MessageSpacing,
|
||||
@@ -62,6 +64,8 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||
|
||||
type ThemeSelectorProps = {
|
||||
themeNames: Record<string, string>;
|
||||
@@ -332,6 +336,11 @@ function Appearance() {
|
||||
'glassmorphismSidebar',
|
||||
);
|
||||
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||
const [mentionHighlightColor, setMentionHighlightColor] = useSetting(
|
||||
settingsAtom,
|
||||
'mentionHighlightColor',
|
||||
);
|
||||
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -493,6 +502,74 @@ function Appearance() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="UI Font"
|
||||
description="Font used throughout the interface."
|
||||
after={
|
||||
<select
|
||||
value={fontFamily ?? 'inter'}
|
||||
onChange={(e) =>
|
||||
setFontFamily(e.target.value as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="system">System Default</option>
|
||||
<option value="inter">Inter (default)</option>
|
||||
<option value="jetbrains-mono">JetBrains Mono</option>
|
||||
<option value="fira-code">Fira Code</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="@Mention Highlight Color"
|
||||
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
|
||||
after={
|
||||
<Box alignItems="Center" gap="200">
|
||||
<input
|
||||
type="color"
|
||||
value={mentionHighlightColor || '#4caf50'}
|
||||
onChange={(e) => setMentionHighlightColor(e.target.value)}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '28px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
border: 'none',
|
||||
padding: '2px',
|
||||
}}
|
||||
/>
|
||||
{mentionHighlightColor && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMentionHighlightColor('')}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '2px 8px',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -855,6 +932,25 @@ function Editor() {
|
||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
||||
settingsAtom,
|
||||
'composerToolbarButtons',
|
||||
);
|
||||
|
||||
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
||||
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
||||
};
|
||||
|
||||
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
|
||||
{ key: 'showFormat', label: 'Format' },
|
||||
{ key: 'showEmoji', label: 'Emoji' },
|
||||
{ key: 'showSticker', label: 'Sticker' },
|
||||
{ key: 'showGif', label: 'GIF' },
|
||||
{ key: 'showLocation', label: 'Location' },
|
||||
{ key: 'showPoll', label: 'Poll' },
|
||||
{ key: 'showVoice', label: 'Voice' },
|
||||
{ key: 'showSchedule', label: 'Schedule' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -881,6 +977,38 @@ function Editor() {
|
||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="300"
|
||||
>
|
||||
<SettingTile
|
||||
title="Composer Toolbar"
|
||||
description="Tap a button to show or hide it in the message composer."
|
||||
/>
|
||||
<Box
|
||||
wrap="Wrap"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
{TOOLBAR_CHIPS.map(({ key, label }) => {
|
||||
const active = composerToolbarButtons?.[key] ?? true;
|
||||
return (
|
||||
<Chip
|
||||
key={key}
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
outlined={active}
|
||||
radii="Pill"
|
||||
onClick={() => toggleToolbarButton(key)}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<Text size="T300">{label}</Text>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -984,6 +1112,17 @@ function Calls() {
|
||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||||
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
||||
settingsAtom,
|
||||
'callJoinLeaveSound',
|
||||
);
|
||||
|
||||
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||
setCallJoinLeaveSound(value);
|
||||
if (value !== 'off') playCallJoinSound(value);
|
||||
};
|
||||
|
||||
const pttBind = useKeyBind(setPttKey);
|
||||
const deafenBind = useKeyBind(setDeafenKey);
|
||||
@@ -1059,6 +1198,73 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<SettingTile
|
||||
title="AFK Auto-Mute"
|
||||
description="Automatically mute your microphone when you have been silent in a call."
|
||||
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
|
||||
/>
|
||||
{afkAutoMute && (
|
||||
<SettingTile
|
||||
title="Idle Timeout"
|
||||
description="How long to wait before auto-muting."
|
||||
after={
|
||||
<select
|
||||
value={afkTimeoutMinutes}
|
||||
onChange={(e) => setAfkTimeoutMinutes(Number(e.target.value))}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value={1}>1 minute</option>
|
||||
<option value={5}>5 minutes</option>
|
||||
<option value={10}>10 minutes</option>
|
||||
<option value={20}>20 minutes</option>
|
||||
<option value={30}>30 minutes</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Join & Leave Sounds"
|
||||
description="Play a sound when someone joins or leaves a call you are in."
|
||||
after={
|
||||
<select
|
||||
value={callJoinLeaveSound}
|
||||
onChange={(e) =>
|
||||
handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro')
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="chime">Chime</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="retro">Retro</option>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1361,6 +1567,46 @@ function Messages() {
|
||||
);
|
||||
}
|
||||
|
||||
function AppUpdates() {
|
||||
const { isTauri, status, check, install } = useTauriUpdater();
|
||||
if (!isTauri) return null;
|
||||
|
||||
const description =
|
||||
status.state === 'checking'
|
||||
? 'Checking for updates...'
|
||||
: status.state === 'up-to-date'
|
||||
? 'Lotus Chat is up to date.'
|
||||
: status.state === 'available'
|
||||
? `Update available: v${status.version}`
|
||||
: status.state === 'installing'
|
||||
? 'Installing update, the app will restart shortly...'
|
||||
: status.state === 'error'
|
||||
? `Update check failed: ${status.message}`
|
||||
: 'Check for a new version of Lotus Chat.';
|
||||
|
||||
const after =
|
||||
status.state === 'available' ? (
|
||||
<Button size="300" radii="300" onClick={install}>
|
||||
<Text size="B300">Install & Restart</Text>
|
||||
</Button>
|
||||
) : status.state === 'checking' || status.state === 'installing' ? (
|
||||
<Spinner variant="Secondary" size="200" />
|
||||
) : (
|
||||
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||||
<Text size="B300">Check</Text>
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">App Updates</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile title="Check for Updates" description={description} after={after} />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type GeneralProps = {
|
||||
requestClose: () => void;
|
||||
};
|
||||
@@ -1391,6 +1637,7 @@ export function General({ requestClose }: GeneralProps) {
|
||||
<Messages />
|
||||
<Privacy />
|
||||
<Calls />
|
||||
<AppUpdates />
|
||||
</Box>
|
||||
</PageContent>
|
||||
</Scroll>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Box, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
|
||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SystemNotification } from './SystemNotification';
|
||||
import { AllMessagesNotifications } from './AllMessages';
|
||||
@@ -9,6 +10,101 @@ import { PushRuleEditor } from './PushRuleEditor';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { settingsAtom, Settings } from '../../../state/settings';
|
||||
|
||||
const PRESETS: Array<{
|
||||
label: string;
|
||||
emoji: string;
|
||||
description: string;
|
||||
patch: Partial<Settings>;
|
||||
}> = [
|
||||
{
|
||||
label: 'Gaming',
|
||||
emoji: '🎮',
|
||||
description: 'Notifications on, sounds off',
|
||||
patch: {
|
||||
showNotifications: true,
|
||||
isNotificationSounds: false,
|
||||
messageSoundId: 'none',
|
||||
inviteSoundId: 'none',
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Work',
|
||||
emoji: '💼',
|
||||
description: 'All notifications and sounds on',
|
||||
patch: {
|
||||
showNotifications: true,
|
||||
isNotificationSounds: true,
|
||||
messageSoundId: 'notification',
|
||||
inviteSoundId: 'invite',
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Sleep',
|
||||
emoji: '🌙',
|
||||
description: 'All notifications off',
|
||||
patch: {
|
||||
showNotifications: false,
|
||||
isNotificationSounds: false,
|
||||
quietHoursEnabled: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function NotificationPresets() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const setSettings = useSetAtom(settingsAtom);
|
||||
|
||||
const applyPreset = (patch: Partial<Settings>) => {
|
||||
setSettings({ ...settings, ...patch });
|
||||
};
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Quick Presets</Text>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
|
||||
{PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => applyPreset(preset.patch)}
|
||||
title={preset.description}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: toRem(4),
|
||||
padding: `${toRem(8)} ${toRem(16)}`,
|
||||
borderRadius: config.radii.R300,
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
background: 'var(--bg-surface-low)',
|
||||
color: 'inherit',
|
||||
cursor: 'pointer',
|
||||
minWidth: toRem(80),
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
|
||||
<Text size="T300" style={{ fontWeight: 600 }}>
|
||||
{preset.label}
|
||||
</Text>
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ textAlign: 'center', maxWidth: toRem(120) }}
|
||||
>
|
||||
{preset.description}
|
||||
</Text>
|
||||
</button>
|
||||
))}
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationsProps = {
|
||||
requestClose: () => void;
|
||||
@@ -34,6 +130,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
||||
<Scroll hideTrack visibility="Hover">
|
||||
<PageContent>
|
||||
<Box direction="Column" gap="700">
|
||||
<NotificationPresets />
|
||||
<SystemNotification />
|
||||
<AllMessagesNotifications />
|
||||
<SpecialMessagesNotifications />
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { toastQueueAtom } from '../state/toast';
|
||||
|
||||
const SILENCE_RMS_THRESHOLD = 0.008;
|
||||
const CHECK_INTERVAL_MS = 500;
|
||||
|
||||
/**
|
||||
* Monitors microphone audio while in a call. If the mic stays active but
|
||||
* silent for longer than the configured timeout, the mic is muted and a
|
||||
* toast is shown. Cleans up its own AudioContext and stream on unmount.
|
||||
*/
|
||||
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
||||
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
||||
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (!callEmbed || !enabled) return;
|
||||
|
||||
let stream: MediaStream | undefined;
|
||||
let audioCtx: AudioContext | undefined;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
let silenceStart: number | null = null;
|
||||
let active = true;
|
||||
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||
|
||||
navigator.mediaDevices
|
||||
.getUserMedia({ audio: true, video: false })
|
||||
.then((s) => {
|
||||
if (!active) {
|
||||
s.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
stream = s;
|
||||
audioCtx = new AudioContext();
|
||||
const source = audioCtx.createMediaStreamSource(stream);
|
||||
const analyser = audioCtx.createAnalyser();
|
||||
analyser.fftSize = 256;
|
||||
source.connect(analyser);
|
||||
const buffer = new Float32Array(analyser.fftSize);
|
||||
|
||||
intervalId = setInterval(() => {
|
||||
if (!active) return;
|
||||
analyser.getFloatTimeDomainData(buffer);
|
||||
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
|
||||
|
||||
if (rms > SILENCE_RMS_THRESHOLD) {
|
||||
// Audio detected — reset the silence timer
|
||||
silenceStart = null;
|
||||
} else if (callEmbed.control.microphone) {
|
||||
// Mic is on but silent — start or advance the timer
|
||||
if (silenceStart === null) silenceStart = Date.now();
|
||||
else if (Date.now() - silenceStart >= timeoutMs) {
|
||||
callEmbed.control.setMicrophone(false);
|
||||
setToast({
|
||||
id: `afk-mute-${Date.now()}`,
|
||||
displayName: 'Lotus Chat',
|
||||
body: 'Your microphone was muted after inactivity.',
|
||||
roomName: 'Voice call',
|
||||
roomId: callEmbed.roomId,
|
||||
});
|
||||
silenceStart = null;
|
||||
}
|
||||
} else {
|
||||
// Mic is already muted — don't count silence
|
||||
silenceStart = null;
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
if (intervalId !== undefined) clearInterval(intervalId);
|
||||
stream?.getTracks().forEach((t) => t.stop());
|
||||
audioCtx?.close().catch(() => undefined);
|
||||
};
|
||||
}, [callEmbed, enabled, timeoutMinutes, setToast]);
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { CallPreferences } from '../state/callPreferences';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { unlockCallSounds } from '../utils/callSounds';
|
||||
|
||||
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
||||
|
||||
@@ -84,6 +85,10 @@ export const useCallStart = (dm = false) => {
|
||||
if (!container) {
|
||||
throw new Error('Failed to start call, No embed container element found!');
|
||||
}
|
||||
// startCall is always invoked from a click/tap handler — unlock the Web
|
||||
// Audio context now (within the gesture) so join/leave sounds that fire
|
||||
// later, without any gesture, are audible to everyone in the call.
|
||||
unlockCallSounds();
|
||||
const callEmbed = createCallEmbed(
|
||||
mx,
|
||||
room,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||
import { CallEmbed } from '../plugins/call';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useCallMembersChange, useCallSession } from './useCall';
|
||||
import { useCallJoined } from './useCallEmbed';
|
||||
import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
|
||||
|
||||
const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`;
|
||||
|
||||
/**
|
||||
* Plays a local sound effect when another participant joins or leaves
|
||||
* the call you are in. Style (or off) is configured in Settings → Calls.
|
||||
*/
|
||||
export function useCallJoinLeaveSounds(embed: CallEmbed): void {
|
||||
const mx = useMatrixClient();
|
||||
const [style] = useSetting(settingsAtom, 'callJoinLeaveSound');
|
||||
const joined = useCallJoined(embed);
|
||||
const session = useCallSession(embed.room);
|
||||
|
||||
const prevKeysRef = useRef<Set<string> | null>(null);
|
||||
|
||||
// Snapshot current members when the session (re)starts so we never play
|
||||
// sounds for participants who were already present.
|
||||
useEffect(() => {
|
||||
prevKeysRef.current = new Set(session.memberships.map(membershipKey));
|
||||
}, [session]);
|
||||
|
||||
useCallMembersChange(
|
||||
session,
|
||||
useCallback(
|
||||
(members: CallMembership[]) => {
|
||||
const next = new Set(members.map(membershipKey));
|
||||
const prev = prevKeysRef.current ?? next;
|
||||
prevKeysRef.current = next;
|
||||
|
||||
if (!joined || style === 'off') return;
|
||||
|
||||
const myPrefix = `${mx.getSafeUserId()}|`;
|
||||
let someoneJoined = false;
|
||||
let someoneLeft = false;
|
||||
next.forEach((key) => {
|
||||
if (!prev.has(key) && !key.startsWith(myPrefix)) someoneJoined = true;
|
||||
});
|
||||
prev.forEach((key) => {
|
||||
if (!next.has(key) && !key.startsWith(myPrefix)) someoneLeft = true;
|
||||
});
|
||||
|
||||
if (someoneJoined) playCallJoinSound(style);
|
||||
if (someoneLeft) playCallLeaveSound(style);
|
||||
},
|
||||
[joined, style, mx],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useRoomNavigate } from './useRoomNavigate';
|
||||
import { isRoomId } from '../utils/matrix';
|
||||
import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils';
|
||||
import { _RoomSearchParams } from '../pages/paths';
|
||||
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../plugins/matrix-to';
|
||||
|
||||
/** Desktop (Tauri) injects deep links via a DOM CustomEvent — see lib.rs. */
|
||||
const DEEP_LINK_EVENT = 'lotus-deeplink';
|
||||
|
||||
const inTauri = (): boolean => '__TAURI_INTERNALS__' in window;
|
||||
|
||||
const tryDecode = (value: string): string => {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert a `matrix:` URI (MSC2312) to its `matrix.to` equivalent so we can
|
||||
* reuse the existing matrix.to parsers. Returns undefined for forms we don't
|
||||
* route (e.g. user links, which need an anchor/room context to open a profile).
|
||||
*
|
||||
* matrix:r/room:server -> https://matrix.to/#/#room:server
|
||||
* matrix:roomid/id:server/e/$ev -> https://matrix.to/#/!id:server/$ev
|
||||
*/
|
||||
const matrixUriToMatrixTo = (uri: string): string | undefined => {
|
||||
const body = uri.slice('matrix:'.length);
|
||||
const [pathPart, queryPart] = body.split('?');
|
||||
const segments = pathPart.split('/');
|
||||
if (segments.length < 2) return undefined;
|
||||
|
||||
const [kind, rawId, evKind, rawEventId] = segments;
|
||||
const sigil = kind === 'r' ? '#' : kind === 'roomid' ? '!' : undefined;
|
||||
if (!sigil || !rawId) return undefined;
|
||||
|
||||
let fragment = `${sigil}${tryDecode(rawId)}`;
|
||||
if (evKind === 'e' && rawEventId) {
|
||||
fragment += `/$${tryDecode(rawEventId)}`;
|
||||
}
|
||||
const query = queryPart ? `?${queryPart}` : '';
|
||||
return `https://matrix.to/#/${fragment}${query}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Routes deep links opened from outside the app (Windows `matrix:` links,
|
||||
* forwarded from Tauri) into the client, mirroring the navigation in
|
||||
* useMentionClickHandler. No-op outside Tauri.
|
||||
*/
|
||||
export const useDeepLinkNavigate = (): void => {
|
||||
const mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||
|
||||
const navigateUri = useCallback(
|
||||
(rawUrl: string) => {
|
||||
const href = rawUrl.startsWith('matrix:') ? matrixUriToMatrixTo(rawUrl) : rawUrl;
|
||||
if (!href || !testMatrixTo(href)) return;
|
||||
|
||||
const roomEvent = parseMatrixToRoomEvent(href);
|
||||
const target = roomEvent ?? parseMatrixToRoom(href);
|
||||
if (!target) return; // users / unsupported forms
|
||||
|
||||
const { roomIdOrAlias, viaServers } = target;
|
||||
const eventId = roomEvent?.eventId;
|
||||
|
||||
// Already-joined room/space: navigate directly.
|
||||
if (isRoomId(roomIdOrAlias) && mx.getRoom(roomIdOrAlias)) {
|
||||
if (mx.getRoom(roomIdOrAlias)?.isSpaceRoom()) navigateSpace(roomIdOrAlias);
|
||||
else navigateRoom(roomIdOrAlias, eventId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise route through the home path (triggers the join/preview flow).
|
||||
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
||||
navigate(
|
||||
viaServers && viaServers.length > 0
|
||||
? withSearchParam<_RoomSearchParams>(path, { viaServers: viaServers.join(',') })
|
||||
: path,
|
||||
);
|
||||
},
|
||||
[mx, navigate, navigateRoom, navigateSpace],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!inTauri()) return undefined;
|
||||
const handler = (evt: Event) => {
|
||||
const { detail } = evt as CustomEvent<unknown>;
|
||||
if (typeof detail === 'string') navigateUri(detail);
|
||||
};
|
||||
window.addEventListener(DEEP_LINK_EVENT, handler);
|
||||
return () => window.removeEventListener(DEEP_LINK_EVENT, handler);
|
||||
}, [navigateUri]);
|
||||
};
|
||||
@@ -115,8 +115,10 @@ export const useRoomUnverifiedDeviceCount = (
|
||||
|
||||
const memberIds = useMemo(
|
||||
() => room.getJoinedMembers().map((m) => m.userId),
|
||||
// room.roomId guards against room changes; getJoinedMemberCount() ensures
|
||||
// the list refreshes when members join/leave so new devices get checked.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[room.roomId],
|
||||
[room.roomId, room.getJoinedMemberCount()],
|
||||
);
|
||||
|
||||
const updateCount = useCallback(async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const myUserId = mx.getUserId();
|
||||
|
||||
const fetchField = async <T extends Record<string, string>>(
|
||||
field: string,
|
||||
@@ -28,14 +29,32 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
|
||||
}
|
||||
};
|
||||
|
||||
Promise.all([
|
||||
fetchField<{ 'm.pronouns': string }>('m.pronouns'),
|
||||
fetchField<{ 'm.tz': string }>('m.tz'),
|
||||
]).then(([pronouns, timezone]) => {
|
||||
const run = async () => {
|
||||
const [pronouns, tzFromProfile] = await Promise.all([
|
||||
fetchField<{ 'm.pronouns': string }>('m.pronouns'),
|
||||
fetchField<{ 'm.tz': string }>('m.tz'),
|
||||
]);
|
||||
|
||||
let timezone = tzFromProfile;
|
||||
// Standard Synapse doesn't support m.tz — fall back to account data for own profile
|
||||
if (!timezone && userId === myUserId) {
|
||||
try {
|
||||
const res = await mx.http.authedRequest<{ timezone: string }>(
|
||||
Method.Get,
|
||||
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
|
||||
);
|
||||
timezone = res.timezone || undefined;
|
||||
} catch {
|
||||
// not set yet
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined });
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
run();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -42,7 +42,13 @@ export const useFileDropZone = (
|
||||
setActive(true);
|
||||
}
|
||||
};
|
||||
const handleDragLeave = () => {
|
||||
const handleDragLeave = (evt: DragEvent) => {
|
||||
if (evt.relatedTarget === null) {
|
||||
// Mouse left the browser window — reset unconditionally
|
||||
dragCounterRef.current = 0;
|
||||
setActive(false);
|
||||
return;
|
||||
}
|
||||
dragCounterRef.current -= 1;
|
||||
if (dragCounterRef.current <= 0) {
|
||||
dragCounterRef.current = 0;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useReducer } from 'react';
|
||||
import { MatrixEvent, Room, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
|
||||
import { Membership } from '../../types/matrix/room';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { readPowerLevel, usePowerLevelsContext } from './usePowerLevels';
|
||||
|
||||
/**
|
||||
* Returns the list of members currently knocking on the room, reactively.
|
||||
* Returns an empty array if the current user lacks invite power level.
|
||||
*/
|
||||
export function usePendingKnocks(room: Room): RoomMember[] {
|
||||
const mx = useMatrixClient();
|
||||
const powerLevels = usePowerLevelsContext();
|
||||
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (_evt: MatrixEvent, member: RoomMember) => {
|
||||
if (member.roomId === room.roomId) forceUpdate();
|
||||
};
|
||||
mx.on(RoomMemberEvent.Membership, handler);
|
||||
return () => {
|
||||
mx.removeListener(RoomMemberEvent.Membership, handler);
|
||||
};
|
||||
}, [mx, room.roomId]);
|
||||
|
||||
const myUserId = mx.getUserId();
|
||||
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
|
||||
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
|
||||
const canApprove = myPowerLevel >= invitePowerLevel;
|
||||
|
||||
return canApprove ? room.getMembersWithMembership(Membership.Knock) : [];
|
||||
}
|
||||
@@ -16,10 +16,27 @@ export function usePresenceUpdater() {
|
||||
const lastActivityRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const userId = mx.getUserId();
|
||||
const storedStatus = userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
|
||||
|
||||
const setOnline = () =>
|
||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
||||
const setUnavailable = (statusMsg = '') =>
|
||||
mx.setPresence({ presence: 'unavailable', status_msg: statusMsg }).catch(() => undefined);
|
||||
mx
|
||||
.setPresence({
|
||||
presence: 'online',
|
||||
...(storedStatus ? { status_msg: storedStatus } : {}),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const setUnavailable = (statusMsg?: string) =>
|
||||
mx
|
||||
.setPresence({
|
||||
presence: 'unavailable',
|
||||
...(statusMsg
|
||||
? { status_msg: statusMsg }
|
||||
: storedStatus
|
||||
? { status_msg: storedStatus }
|
||||
: {}),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
const setOffline = () =>
|
||||
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
|
||||
|
||||
@@ -44,7 +61,7 @@ export function usePresenceUpdater() {
|
||||
// presenceStatus === 'auto' — original activity-tracking behavior.
|
||||
const startIdleTimer = () => {
|
||||
clearTimeout(idleTimerRef.current);
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
idleTimerRef.current = setTimeout(() => {
|
||||
isIdleRef.current = true;
|
||||
setUnavailable();
|
||||
}, IDLE_TIMEOUT_MS);
|
||||
@@ -75,9 +92,8 @@ export function usePresenceUpdater() {
|
||||
};
|
||||
|
||||
const handlePageHide = () => {
|
||||
const userId = mx.getUserId();
|
||||
const token = mx.getAccessToken();
|
||||
const baseUrl = (mx as unknown as { baseUrl: string }).baseUrl;
|
||||
const baseUrl = mx.getHomeserverUrl();
|
||||
if (!userId || !token || !baseUrl) return;
|
||||
|
||||
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { roomToUnreadAtom } from '../state/room/roomToUnread';
|
||||
|
||||
// Tauri v2 injects __TAURI_INTERNALS__ into the webview at runtime.
|
||||
// We use it directly so cinny doesn't need @tauri-apps/api as a dependency.
|
||||
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
|
||||
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
|
||||
|
||||
export function useTauriNotificationBadge() {
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const prevHighlightsRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
const invoke = tauriInvoke();
|
||||
if (!invoke) return;
|
||||
|
||||
let totalHighlights = 0;
|
||||
roomToUnread.forEach((unread) => {
|
||||
totalHighlights += unread.highlight;
|
||||
});
|
||||
|
||||
invoke('set_badge_count', { count: totalHighlights }).catch(() => {});
|
||||
// Mirror the unread state onto the tray icon (visible when minimized to tray).
|
||||
invoke('set_tray_unread', { unread: totalHighlights > 0 }).catch(() => {});
|
||||
|
||||
// Flash the taskbar button when new mentions arrive and the window is not
|
||||
// focused, then reset the baseline.
|
||||
if (totalHighlights > prevHighlightsRef.current && !document.hasFocus()) {
|
||||
invoke('flash_window').catch(() => {});
|
||||
}
|
||||
prevHighlightsRef.current = totalHighlights;
|
||||
}, [roomToUnread]);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
|
||||
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
|
||||
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
|
||||
|
||||
type UpdateStatus =
|
||||
| { state: 'idle' }
|
||||
| { state: 'checking' }
|
||||
| { state: 'up-to-date' }
|
||||
| { state: 'available'; version: string }
|
||||
| { state: 'installing' }
|
||||
| { state: 'error'; message: string };
|
||||
|
||||
export function useTauriUpdater() {
|
||||
const isTauri = !!tauriInvoke();
|
||||
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' });
|
||||
|
||||
const check = useCallback(async () => {
|
||||
const invoke = tauriInvoke();
|
||||
if (!invoke) return;
|
||||
setStatus({ state: 'checking' });
|
||||
try {
|
||||
const result = (await invoke('check_for_update')) as { available: boolean; version?: string };
|
||||
setStatus(
|
||||
result.available && result.version
|
||||
? { state: 'available', version: result.version }
|
||||
: { state: 'up-to-date' },
|
||||
);
|
||||
} catch (e) {
|
||||
setStatus({ state: 'error', message: String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const install = useCallback(async () => {
|
||||
const invoke = tauriInvoke();
|
||||
if (!invoke) return;
|
||||
setStatus({ state: 'installing' });
|
||||
try {
|
||||
await invoke('install_update');
|
||||
} catch (e) {
|
||||
setStatus({ state: 'error', message: String(e) });
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isTauri, status, check, install };
|
||||
}
|
||||
@@ -29,20 +29,24 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
|
||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||
|
||||
useEffect(() => {
|
||||
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
||||
// User objects have a default 10-listener limit; the same user can appear
|
||||
// in many components simultaneously (avatars, member list, etc.) and
|
||||
// per-user subscription causes MaxListenersExceededWarning at 11+.
|
||||
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
|
||||
if (u.userId === user?.userId) {
|
||||
setPresence(getUserPresence(user));
|
||||
setPresence(getUserPresence(u));
|
||||
}
|
||||
};
|
||||
user?.on(UserEvent.Presence, updatePresence);
|
||||
user?.on(UserEvent.CurrentlyActive, updatePresence);
|
||||
user?.on(UserEvent.LastPresenceTs, updatePresence);
|
||||
mx.on(UserEvent.Presence, updatePresence);
|
||||
mx.on(UserEvent.CurrentlyActive, updatePresence);
|
||||
mx.on(UserEvent.LastPresenceTs, updatePresence);
|
||||
return () => {
|
||||
user?.removeListener(UserEvent.Presence, updatePresence);
|
||||
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
||||
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
||||
mx.removeListener(UserEvent.Presence, updatePresence);
|
||||
mx.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
||||
mx.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
||||
};
|
||||
}, [user]);
|
||||
}, [mx, user]);
|
||||
|
||||
return presence;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
||||
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
|
||||
@@ -15,6 +15,48 @@ import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
|
||||
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||
|
||||
const FONT_MAP: Record<string, string> = {
|
||||
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||
inter: "'InterVariable', sans-serif",
|
||||
'jetbrains-mono': "'JetBrains Mono', monospace",
|
||||
'fira-code': "'Fira Code', monospace",
|
||||
};
|
||||
|
||||
function AppearanceEffects() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
|
||||
useEffect(() => {
|
||||
const color = settings.mentionHighlightColor;
|
||||
if (color) {
|
||||
document.body.style.setProperty('--mention-highlight-bg', color);
|
||||
// compute black or white text based on hex luminance
|
||||
const r = parseInt(color.slice(1, 3), 16);
|
||||
const g = parseInt(color.slice(3, 5), 16);
|
||||
const b = parseInt(color.slice(5, 7), 16);
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
document.body.style.setProperty('--mention-highlight-text', lum > 0.5 ? '#000' : '#fff');
|
||||
document.body.style.setProperty('--mention-highlight-border', color);
|
||||
} else {
|
||||
document.body.style.removeProperty('--mention-highlight-bg');
|
||||
document.body.style.removeProperty('--mention-highlight-text');
|
||||
document.body.style.removeProperty('--mention-highlight-border');
|
||||
}
|
||||
}, [settings.mentionHighlightColor]);
|
||||
|
||||
useEffect(() => {
|
||||
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
|
||||
document.body.style.setProperty('--font-secondary', font);
|
||||
}, [settings.fontFamily]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function TauriEffects() {
|
||||
useTauriNotificationBadge();
|
||||
return null;
|
||||
}
|
||||
|
||||
function NightLightOverlay() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
@@ -94,6 +136,8 @@ function App() {
|
||||
<ClientConfigProvider value={clientConfig}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<JotaiProvider>
|
||||
<AppearanceEffects />
|
||||
<TauriEffects />
|
||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||
<NightLightOverlay />
|
||||
<LotusToastContainer />
|
||||
|
||||
@@ -34,6 +34,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||
import { toastQueueAtom } from '../../state/toast';
|
||||
|
||||
function isInQuietHours(start: string, end: string): boolean {
|
||||
@@ -376,6 +377,11 @@ type ClientNonUIFeaturesProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function DeepLinkNavigator() {
|
||||
useDeepLinkNavigate();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -385,6 +391,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||
<PresenceUpdater />
|
||||
<InviteNotifications />
|
||||
<MessageNotifications />
|
||||
<DeepLinkNavigator />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -130,7 +130,7 @@ export class CallEmbed {
|
||||
header: 'none',
|
||||
});
|
||||
|
||||
if (!room.isCallRoom() && CallEmbed.startingCall(intent)) {
|
||||
if (CallEmbed.startingCall(intent)) {
|
||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||
}
|
||||
|
||||
|
||||
@@ -15,14 +15,11 @@ export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
||||
const recentEmoji = recentEmojiEvent?.getContent<IRecentEmojiContent>().recent_emoji;
|
||||
if (!Array.isArray(recentEmoji)) return [];
|
||||
|
||||
return recentEmoji
|
||||
.sort((e1, e2) => e2[1] - e1[1])
|
||||
.slice(0, limit)
|
||||
.reduce<IEmoji[]>((list, [unicode]) => {
|
||||
const emoji = emojis.find((e) => e.unicode === unicode);
|
||||
if (emoji) list.push(emoji);
|
||||
return list;
|
||||
}, []);
|
||||
return recentEmoji.slice(0, limit).reduce<IEmoji[]>((list, [unicode]) => {
|
||||
const emoji = emojis.find((e) => e.unicode === unicode);
|
||||
if (emoji) list.push(emoji);
|
||||
return list;
|
||||
}, []);
|
||||
};
|
||||
|
||||
export function addRecentEmoji(mx: MatrixClient, unicode: string) {
|
||||
|
||||
@@ -38,6 +38,28 @@ export enum MessageLayout {
|
||||
Bubble = 2,
|
||||
}
|
||||
|
||||
export interface ComposerToolbarSettings {
|
||||
showFormat: boolean;
|
||||
showEmoji: boolean;
|
||||
showSticker: boolean;
|
||||
showGif: boolean;
|
||||
showLocation: boolean;
|
||||
showPoll: boolean;
|
||||
showVoice: boolean;
|
||||
showSchedule: boolean;
|
||||
}
|
||||
|
||||
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
||||
showFormat: true,
|
||||
showEmoji: true,
|
||||
showSticker: true,
|
||||
showGif: true,
|
||||
showLocation: true,
|
||||
showPoll: true,
|
||||
showVoice: true,
|
||||
showSchedule: true,
|
||||
};
|
||||
|
||||
export interface Settings {
|
||||
themeId?: string;
|
||||
useSystemTheme: boolean;
|
||||
@@ -101,6 +123,16 @@ export interface Settings {
|
||||
warnOnUnverifiedDevices: boolean;
|
||||
|
||||
pauseAnimations: boolean;
|
||||
|
||||
composerToolbarButtons: ComposerToolbarSettings;
|
||||
|
||||
mentionHighlightColor: string;
|
||||
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
|
||||
|
||||
afkAutoMute: boolean;
|
||||
afkTimeoutMinutes: number;
|
||||
|
||||
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -166,13 +198,31 @@ const defaultSettings: Settings = {
|
||||
warnOnUnverifiedDevices: false,
|
||||
|
||||
pauseAnimations: false,
|
||||
|
||||
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||
|
||||
mentionHighlightColor: '',
|
||||
fontFamily: 'inter',
|
||||
|
||||
afkAutoMute: false,
|
||||
afkTimeoutMinutes: 10,
|
||||
|
||||
callJoinLeaveSound: 'chime',
|
||||
};
|
||||
|
||||
export const getSettings = (): Settings => {
|
||||
try {
|
||||
const settings = localStorage.getItem(STORAGE_KEY);
|
||||
if (settings === null) return defaultSettings;
|
||||
return { ...defaultSettings, ...(JSON.parse(settings) as Settings) };
|
||||
const saved = JSON.parse(settings) as Partial<Settings>;
|
||||
return {
|
||||
...defaultSettings,
|
||||
...saved,
|
||||
composerToolbarButtons: {
|
||||
...DEFAULT_COMPOSER_TOOLBAR,
|
||||
...(saved.composerToolbarButtons ?? {}),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return defaultSettings;
|
||||
|
||||
@@ -169,9 +169,9 @@ export const Mention = recipe({
|
||||
variants: {
|
||||
highlight: {
|
||||
true: {
|
||||
backgroundColor: color.Success.Container,
|
||||
color: color.Success.OnContainer,
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.ContainerLine}`,
|
||||
backgroundColor: `var(--mention-highlight-bg, ${color.Success.Container as string})`,
|
||||
color: `var(--mention-highlight-text, ${color.Success.OnContainer as string})`,
|
||||
boxShadow: `0 0 0 ${config.borderWidth.B300} var(--mention-highlight-border, ${color.Success.ContainerLine as string})`,
|
||||
},
|
||||
},
|
||||
focus: {
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
export type CallSoundStyle = 'chime' | 'soft' | 'retro';
|
||||
|
||||
let sharedCtx: AudioContext | undefined;
|
||||
|
||||
const getAudioContext = (): AudioContext | undefined => {
|
||||
try {
|
||||
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
|
||||
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
|
||||
return sharedCtx;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create and resume the shared AudioContext from within a user gesture
|
||||
* (e.g. clicking "Join"). Browsers block AudioContext playback until it has
|
||||
* been started by a gesture, so join/leave sounds — which fire later without
|
||||
* any gesture — would otherwise be silent. Call this on call entry so every
|
||||
* participant's later membership-change sounds are actually audible.
|
||||
*/
|
||||
export const unlockCallSounds = (): void => {
|
||||
getAudioContext();
|
||||
};
|
||||
|
||||
type Note = {
|
||||
freq: number;
|
||||
/** Offset from now, in seconds */
|
||||
at: number;
|
||||
/** Duration in seconds */
|
||||
dur: number;
|
||||
};
|
||||
|
||||
const playNotes = (notes: Note[], type: OscillatorType, peakGain: number): void => {
|
||||
const ctx = getAudioContext();
|
||||
if (!ctx) return;
|
||||
const now = ctx.currentTime;
|
||||
notes.forEach(({ freq, at, dur }) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.value = freq;
|
||||
const start = now + at;
|
||||
// Short attack/decay envelope to avoid clicks
|
||||
gain.gain.setValueAtTime(0, start);
|
||||
gain.gain.linearRampToValueAtTime(peakGain, start + 0.015);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + dur + 0.02);
|
||||
});
|
||||
};
|
||||
|
||||
const SOUNDS: Record<CallSoundStyle, { join: () => void; leave: () => void }> = {
|
||||
chime: {
|
||||
join: () =>
|
||||
playNotes(
|
||||
[
|
||||
{ freq: 587.33, at: 0, dur: 0.12 },
|
||||
{ freq: 880, at: 0.1, dur: 0.2 },
|
||||
],
|
||||
'sine',
|
||||
0.25,
|
||||
),
|
||||
leave: () =>
|
||||
playNotes(
|
||||
[
|
||||
{ freq: 880, at: 0, dur: 0.12 },
|
||||
{ freq: 587.33, at: 0.1, dur: 0.2 },
|
||||
],
|
||||
'sine',
|
||||
0.25,
|
||||
),
|
||||
},
|
||||
soft: {
|
||||
join: () => playNotes([{ freq: 523.25, at: 0, dur: 0.4 }], 'triangle', 0.18),
|
||||
leave: () => playNotes([{ freq: 392, at: 0, dur: 0.4 }], 'triangle', 0.18),
|
||||
},
|
||||
retro: {
|
||||
join: () =>
|
||||
playNotes(
|
||||
[
|
||||
{ freq: 440, at: 0, dur: 0.07 },
|
||||
{ freq: 554.37, at: 0.07, dur: 0.07 },
|
||||
{ freq: 659.25, at: 0.14, dur: 0.14 },
|
||||
],
|
||||
'square',
|
||||
0.1,
|
||||
),
|
||||
leave: () =>
|
||||
playNotes(
|
||||
[
|
||||
{ freq: 659.25, at: 0, dur: 0.07 },
|
||||
{ freq: 554.37, at: 0.07, dur: 0.07 },
|
||||
{ freq: 440, at: 0.14, dur: 0.14 },
|
||||
],
|
||||
'square',
|
||||
0.1,
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const playCallJoinSound = (style: CallSoundStyle): void => SOUNDS[style]?.join();
|
||||
|
||||
export const playCallLeaveSound = (style: CallSoundStyle): void => SOUNDS[style]?.leave();
|
||||
@@ -1,6 +1,16 @@
|
||||
export const targetFromEvent = (evt: Event, selector: string): Element | undefined => {
|
||||
const targets = evt.composedPath() as Element[];
|
||||
return targets.find((target) => target.matches?.(selector));
|
||||
if (targets.length > 0) {
|
||||
return targets.find((target) => target.matches?.(selector));
|
||||
}
|
||||
// composedPath() is empty when the event is no longer dispatching (e.g. inside a
|
||||
// portal-within-portal in React 19). Walk up the DOM from evt.target instead.
|
||||
let el = evt.target instanceof Element ? evt.target : null;
|
||||
while (el) {
|
||||
if (el.matches(selector)) return el;
|
||||
el = el.parentElement;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const editableActiveElement = (): boolean =>
|
||||
|
||||
@@ -222,7 +222,7 @@ export function tokenize(code: string, lang: string): SyntaxToken[] {
|
||||
if (
|
||||
code[i] === '#' &&
|
||||
(normalised === 'python' || normalised === 'py') &&
|
||||
(i === 0 || code[i - 1] === '\n')
|
||||
(i === 0 || code[i - 1] === '\n' || code[i - 1] === ' ' || code[i - 1] === '\t')
|
||||
) {
|
||||
const nlHash = code.indexOf('\n', i);
|
||||
const closeIdx = nlHash === -1 ? len : nlHash;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
/* semantic surface vars used by poll, location, upload card, gif picker */
|
||||
--bg-surface: #ffffff;
|
||||
--bg-surface-low: rgba(0, 0, 0, 0.04);
|
||||
--bg-surface-variant: rgba(0, 0, 0, 0.07);
|
||||
--bg-surface-active: rgba(0, 0, 0, 0.1);
|
||||
--bg-surface-border: rgba(0, 0, 0, 0.14);
|
||||
--text-primary: #1a1a1a;
|
||||
@@ -37,6 +38,7 @@
|
||||
/* semantic surface vars — dark overrides */
|
||||
--bg-surface: #25272e;
|
||||
--bg-surface-low: rgba(255, 255, 255, 0.05);
|
||||
--bg-surface-variant: rgba(255, 255, 255, 0.08);
|
||||
--bg-surface-active: rgba(255, 255, 255, 0.1);
|
||||
--bg-surface-border: rgba(255, 255, 255, 0.12);
|
||||
--text-primary: #e0e5ed;
|
||||
|
||||
@@ -40,6 +40,7 @@ export enum StateEvent {
|
||||
|
||||
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||
LotusVoiceLimit = 'io.lotus.voice_limit',
|
||||
}
|
||||
|
||||
export enum MessageEvent {
|
||||
|
||||
@@ -13,9 +13,19 @@ import buildConfig from './build.config';
|
||||
const copyFiles = {
|
||||
targets: [
|
||||
{
|
||||
// Element Call's dist must land flat in public/element-call/ so the call
|
||||
// widget URL (/public/element-call/index.html) resolves. v4.x of
|
||||
// vite-plugin-static-copy preserves the full source path under dest, so
|
||||
// we strip the 4 leading segments of the source base
|
||||
// (node_modules/@element-hq/element-call-embedded/dist) — mirroring the
|
||||
// stripBase pattern used by the android/locales targets below. The old
|
||||
// `rename: 'element-call'` form silently produced
|
||||
// public/node_modules/.../dist/ under v4.x, 404ing the widget (calls
|
||||
// broke on cinny-desktop; web only worked because its deployed copy was
|
||||
// a stale artifact from before the vite-plugin-static-copy v4 bump).
|
||||
src: 'node_modules/@element-hq/element-call-embedded/dist',
|
||||
dest: 'public',
|
||||
rename: 'element-call',
|
||||
dest: 'public/element-call',
|
||||
rename: { stripBase: 4 },
|
||||
},
|
||||
{
|
||||
src: 'config.json',
|
||||
|
||||