fix(calls): harden ML denoise shim against static; fix lint/format
CI / Build & Quality Checks (push) Successful in 10m26s
Trigger Desktop Build / trigger (push) Successful in 17s

ML noise suppression produced loud static on real calls. RNNoise requires
mono 48kHz float input; feeding it stereo or wrong-rate data is the classic
cause of that static. Harden the shim:
- request mono (channelCount:1) + 48kHz capture
- run a 48kHz AudioContext and BAIL to the raw mic if the browser won't
  give a true 48kHz context (wrong-rate data -> static)
- force the worklet node to explicit mono in/out
- use the non-SIMD rnnoise.wasm (SIMD build artifacts on some GPUs)
- share one AudioContext across captures

Also fix the two CI-blocking eslint errors (unused vars in UrlPreviewCard
and useLocalMessageSearch) and apply repo-wide prettier formatting so
check:eslint and check:prettier pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:50:00 -04:00
parent 5deed79b42
commit 4a401cf816
13 changed files with 388 additions and 357 deletions
+26 -17
View File
@@ -1,4 +1,5 @@
# Lotus Chat — Bug Report & Technical Audit # Lotus Chat — Bug Report & Technical Audit
**Date:** June 2026 **Date:** June 2026
This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item. This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item.
@@ -8,65 +9,73 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
## 🚩 Critical & UI Bugs ## 🚩 Critical & UI Bugs
### 1. Avatar Decoration Displacement in Profile ### 1. Avatar Decoration Displacement in Profile
**File:** `src/app/components/user-profile/UserHero.tsx` **File:** `src/app/components/user-profile/UserHero.tsx`
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** Avatar decorations appear displaced left of the avatar when viewing the profile modal. - **Issue:** Avatar decorations appear displaced left of the avatar when viewing the profile modal.
* **Root Cause:** The `AvatarPresence` badge sticking out to the right shifts the center of the `inline-flex` container. The decoration centers on the container, not the avatar. - **Root Cause:** The `AvatarPresence` badge sticking out to the right shifts the center of the `inline-flex` container. The decoration centers on the container, not the avatar.
* **Recommended Fix:** Wrap only the `Avatar` component with `AvatarDecoration`. - **Recommended Fix:** Wrap only the `Avatar` component with `AvatarDecoration`.
### 2. Inconsistent Settings Dropdown Styling ### 2. Inconsistent Settings Dropdown Styling
**Files:** `Profile.tsx`, `SystemNotification.tsx` **Files:** `Profile.tsx`, `SystemNotification.tsx`
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** Dropdowns for Status Expiry and Notification Sounds use raw HTML `<select>` elements. - **Issue:** Dropdowns for Status Expiry and Notification Sounds use raw HTML `<select>` elements.
* **Recommended Fix:** Replace with the custom-styled `Menu` + `PopOut` pattern used in `General.tsx`. - **Recommended Fix:** Replace with the custom-styled `Menu` + `PopOut` pattern used in `General.tsx`.
### 3. Ringing Modal Fires in Voice Rooms ### 3. Ringing Modal Fires in Voice Rooms
**File:** `src/app/components/CallEmbedProvider.tsx` **File:** `src/app/components/CallEmbedProvider.tsx`
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** Joining a static voice room triggers the "Incoming Call" ringing. - **Issue:** Joining a static voice room triggers the "Incoming Call" ringing.
* **Recommended Fix:** Check `notification_type` in the Matrix RTC event. Only 'ring' should trigger the modal. - **Recommended Fix:** Check `notification_type` in the Matrix RTC event. Only 'ring' should trigger the modal.
### 4. No Camera Focus During Screenshare ### 4. No Camera Focus During Screenshare
**File:** `src/app/features/call/CallControls.tsx` **File:** `src/app/features/call/CallControls.tsx`
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it. - **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it.
* **Recommended Fix:** Implement a "Focus" toggle on participant tiles that overrides the automatic screenshare spotlight. - **Recommended Fix:** Implement a "Focus" toggle on participant tiles that overrides the automatic screenshare spotlight.
### 5. Chat Background Animation Flickering ### 5. Chat Background Animation Flickering
**File:** `src/app/features/lotus/chatBackground.ts` **File:** `src/app/features/lotus/chatBackground.ts`
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** Some animated backgrounds (like Fireflies) cause flickering/flashing of the message text and composer area on certain browsers/GPUs. - **Issue:** Some animated backgrounds (like Fireflies) cause flickering/flashing of the message text and composer area on certain browsers/GPUs.
* **Recommended Fix:** Ensure animations are scoped strictly to background properties (`background-position`, `background-size`) and do not use properties like `filter` or `opacity` on the main container. - **Recommended Fix:** Ensure animations are scoped strictly to background properties (`background-position`, `background-size`) and do not use properties like `filter` or `opacity` on the main container.
--- ---
## 📱 PWA & Mobile Issues ## 📱 PWA & Mobile Issues
### 1. Exclusive Background vs. Seasonal Choice ### 1. Exclusive Background vs. Seasonal Choice
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** Users can have both a Chat Background and a Seasonal Theme active, causing visual clutter and excessive GPU usage on mobile. - **Issue:** Users can have both a Chat Background and a Seasonal Theme active, causing visual clutter and excessive GPU usage on mobile.
* **Recommended Fix:** Implement a "Choose One" toggle in Settings. - **Recommended Fix:** Implement a "Choose One" toggle in Settings.
--- ---
## 🔍 Technical & Performance Refinements ## 🔍 Technical & Performance Refinements
### 1. Decrypted Media Memory Leak (Gallery & Lightbox) ### 1. Decrypted Media Memory Leak (Gallery & Lightbox)
**File:** `src/app/features/room/MediaGallery.tsx` **File:** `src/app/features/room/MediaGallery.tsx`
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** Every image in the gallery history is decrypted and converted to a Blob URL simultaneously. - **Issue:** Every image in the gallery history is decrypted and converted to a Blob URL simultaneously.
* **Recommended Fix:** Implement virtualization for the gallery grid. - **Recommended Fix:** Implement virtualization for the gallery grid.
### 2. Scheduled Messages are Ephemeral ### 2. Scheduled Messages are Ephemeral
**File:** `src/app/state/scheduledMessages.ts` **File:** `src/app/state/scheduledMessages.ts`
**Status:** **OPEN** **Status:** **OPEN**
* **Issue:** Refreshing the page clears the "Scheduled" tray, making it impossible for users to see or cancel messages they have already scheduled. - **Issue:** Refreshing the page clears the "Scheduled" tray, making it impossible for users to see or cancel messages they have already scheduled.
* **Recommended Fix:** Persist the scheduled message metadata in `localStorage`. - **Recommended Fix:** Persist the scheduled message metadata in `localStorage`.
+35 -35
View File
@@ -173,19 +173,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
### Themes ### Themes
| Theme | Window | Effect | | Theme | Window | Effect |
|---|---|---| | -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
| 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep | | 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent | | 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow | | 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border | | 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark | | 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge | | 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling | | 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt | | 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 Deep Space Week | Oct 410 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient | | 🚀 Deep Space Week | Oct 410 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
| 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint | | 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds | | ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation ### Implementation
@@ -209,17 +209,17 @@ Animated APNG overlay frames that float around user avatars, inspired by Discord
99 hand-curated, original-IP decorations (no licensed character artwork) organized into 9 categories: 99 hand-curated, original-IP decorations (no licensed character artwork) organized into 9 categories:
| Category | Count | Highlights | | Category | Count | Highlights |
|---|---|---| | -------- | ----- | ------------------------------------------------------------- |
| Gaming | 13 | Slither 'n Snack, Joystick, Space Invaders, Gaming Headsets | | Gaming | 13 | Slither 'n Snack, Joystick, Space Invaders, Gaming Headsets |
| Cyber | 9 | Cybernetic, Glitch, Digital Sunrise, Futuristic UI (3 colors) | | Cyber | 9 | Cybernetic, Glitch, Digital Sunrise, Futuristic UI (3 colors) |
| Space | 8 | Black Hole, Constellations, Solar Orbit, Aurora | | Space | 8 | Black Hole, Constellations, Solar Orbit, Aurora |
| Fantasy | 22 | Kitsune, Phoenix, Glowing Runes, D&D dice, Crystal Balls | | Fantasy | 22 | Kitsune, Phoenix, Glowing Runes, D&D dice, Crystal Balls |
| Elements | 7 | Fire, Water, Air, Earth, Lightning, Ki Energy | | Elements | 7 | Fire, Water, Air, Earth, Lightning, Ki Energy |
| Japanese | 6 | Kabuto, Oni Mask, Sakura Warrior, Straw Hat | | Japanese | 6 | Kabuto, Oni Mask, Sakura Warrior, Straw Hat |
| Nature | 12 | **Lotus Flower**, Koi Pond, Sakura, Fall Leaves, Fireflies | | Nature | 12 | **Lotus Flower**, Koi Pond, Sakura, Fall Leaves, Fireflies |
| Spooky | 13 | Candlelight, Witch Hat, Ghosts, Jack-o'-Lantern | | Spooky | 13 | Candlelight, Witch Hat, Ghosts, Jack-o'-Lantern |
| Cozy | 11 | Cozy Cat, Fox Hat (3 colors), Cat Ears, Frog Hat | | Cozy | 11 | Cozy Cat, Fox Hat (3 colors), Cat Ears, Frog Hat |
All decoration files are 256×256 APNGs. They animate natively in all modern browsers via `<img>` elements. All decoration files are 256×256 APNGs. They animate natively in all modern browsers via `<img>` elements.
@@ -239,12 +239,12 @@ Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://d
### Placement — Where Decorations Render ### Placement — Where Decorations Render
| Location | File | | Location | File |
|---|---| | ----------------------- | -------------------------------------------------------------------- |
| Message timeline | `src/app/features/room/message/Message.tsx` | | Message timeline | `src/app/features/room/message/Message.tsx` |
| Members drawer | `src/app/features/room/MembersDrawer.tsx` | | Members drawer | `src/app/features/room/MembersDrawer.tsx` |
| `@mention` autocomplete | `src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx` | | `@mention` autocomplete | `src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx` |
| Inbox / notifications | `src/app/pages/client/inbox/Notifications.tsx` | | Inbox / notifications | `src/app/pages/client/inbox/Notifications.tsx` |
### Settings — Decoration Picker ### Settings — Decoration Picker
@@ -409,13 +409,13 @@ Files: `src/app/utils/callSounds.ts`, `src/app/hooks/useCallJoinLeaveSounds.ts`
A three-way mic noise-suppression control in **Settings → General → Calls**: A three-way mic noise-suppression control in **Settings → General → Calls**:
| Tier | What it does | | Tier | What it does |
|---|---| | ------------------ | ----------------------------------------------------------------------------- |
| **Off** | No suppression (`noiseSuppression=false` to Element Call). | | **Off** | No suppression (`noiseSuppression=false` to Element Call). |
| **Browser-native** | Element Call's built-in WebRTC suppressor (`noiseSuppression=true`). Default. | | **Browser-native** | Element Call's built-in WebRTC suppressor (`noiseSuppression=true`). Default. |
| **ML (beta)** | On-device RNNoise — Krisp-style removal of fans, keyboards, dogs, etc. | | **ML (beta)** | On-device RNNoise — Krisp-style removal of fans, keyboards, dogs, etc. |
**Why a shim, not a fork:** Element Call captures the mic *inside* its iframe and publishes to LiveKit; the host can't reach that track. LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU), and EC's own RNNoise work (PR #3892) is unmerged. So the **ML tier** is delivered by injecting a same-origin pre-init script into the vendored EC `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` (`@sapphi-red/web-noise-suppressor`) before LiveKit ever sees it — the same post-capture pipeline #3892 uses, executed from the realm we already control. Works on the self-hosted LiveKit SFU, survives EC version bumps, no EC fork/AGPL/rebase burden. **Why a shim, not a fork:** Element Call captures the mic _inside_ its iframe and publishes to LiveKit; the host can't reach that track. LiveKit's Krisp filter is LiveKit-Cloud-only (we self-host the SFU), and EC's own RNNoise work (PR #3892) is unmerged. So the **ML tier** is delivered by injecting a same-origin pre-init script into the vendored EC `index.html` that monkeypatches `getUserMedia` and routes the captured mic through an RNNoise `AudioWorklet` (`@sapphi-red/web-noise-suppressor`) before LiveKit ever sees it — the same post-capture pipeline #3892 uses, executed from the realm we already control. Works on the self-hosted LiveKit SFU, survives EC version bumps, no EC fork/AGPL/rebase burden.
**How it's wired:** **How it's wired:**
@@ -1066,4 +1066,4 @@ The `encUrlPreview` setting defaults to `true` rather than `false`. A security a
| `src/app/hooks/useAvatarDecoration.ts` | Profile field fetch with module-level cache and in-flight deduplication | | `src/app/hooks/useAvatarDecoration.ts` | Profile field fetch with module-level cache and in-flight deduplication |
| `src/app/components/avatar-decoration/AvatarDecoration.tsx` | APNG overlay wrapper rendered around avatars in timeline, members drawer, autocomplete | | `src/app/components/avatar-decoration/AvatarDecoration.tsx` | APNG overlay wrapper rendered around avatars in timeline, members drawer, autocomplete |
| `src/app/features/settings/account/ProfileDecoration.tsx` | Settings decoration picker — scrollable grid, category headers, save button | | `src/app/features/settings/account/ProfileDecoration.tsx` | Settings decoration picker — scrollable grid, category headers, save button |
| `scripts/syncDecorations.mjs` | CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files | | `scripts/syncDecorations.mjs` | CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files |
+150 -133
View File
@@ -1,4 +1,5 @@
# Lotus Chat — Technical Implementation Field Guide # Lotus Chat — Technical Implementation Field Guide
**Date:** June 2026 **Date:** June 2026
This document provides exhaustive, low-level implementation details for the remaining items in `LOTUS_TODO.md`. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant). This document provides exhaustive, low-level implementation details for the remaining items in `LOTUS_TODO.md`. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
@@ -8,190 +9,206 @@ This document provides exhaustive, low-level implementation details for the rema
## 🧵 Priority 3 — Higher Complexity ## 🧵 Priority 3 — Higher Complexity
### P3-8 · Thread Panel (Full Side Drawer) ### P3-8 · Thread Panel (Full Side Drawer)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline. **Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
* **1. State (src/app/state/room/thread.ts):** - **1. State (src/app/state/room/thread.ts):**
```typescript ```typescript
export const activeThreadIdAtom = atom<string | null>(null); export const activeThreadIdAtom = atom<string | null>(null);
``` ```
* **2. Layout (src/app/features/room/Room.tsx):** - **2. Layout (src/app/features/room/Room.tsx):**
Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`. Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`.
```tsx ```tsx
{activeThreadId && ( {
activeThreadId && (
<> <>
<Line variant="Background" direction="Vertical" size="300" /> <Line variant="Background" direction="Vertical" size="300" />
<ThreadPanel roomId={roomId} threadId={activeThreadId} /> <ThreadPanel roomId={roomId} threadId={activeThreadId} />
</> </>
)} );
``` }
* **3. Component (src/app/features/room/thread/ThreadPanel.tsx):** ```
* Use `room.getThread(threadId)` from the SDK. - **3. Component (src/app/features/room/thread/ThreadPanel.tsx):**
* Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`. - Use `room.getThread(threadId)` from the SDK.
* Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`. - Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`.
* **Pro Tip:** Use `thread.timelineSet` directly for the most accurate thread view. - Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`.
- **Pro Tip:** Use `thread.timelineSet` directly for the most accurate thread view.
--- ---
## 🛠️ Priority 4 — Specialized Features ## 🛠️ Priority 4 — Specialized Features
### P4-4 · Math / LaTeX Rendering ### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser. **Mechanism:** KaTeX injection into the HTML parser.
* **1. Sanitizer (src/app/utils/sanitize.ts):** - **1. Sanitizer (src/app/utils/sanitize.ts):**
You must allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks. You must allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
* **2. Parser (src/app/plugins/react-custom-html-parser.tsx):** - **2. Parser (src/app/plugins/react-custom-html-parser.tsx):**
Detect `$ ... $` and `$$ ... $$` patterns in text nodes. Detect `$ ... $` and `$$ ... $$` patterns in text nodes.
```tsx ```tsx
if (node.type === 'text') { if (node.type === 'text') {
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g); const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
return parts.map(p => { return parts.map((p) => {
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />; if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
return p; return p;
}); });
} }
``` ```
* **3. CSS (src/app/styles/CustomHtml.css.ts):** - **3. CSS (src/app/styles/CustomHtml.css.ts):**
Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size. Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861) ### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
**Mechanism:** Matrix Authentication Service (MAS) Integration. **Mechanism:** Matrix Authentication Service (MAS) Integration.
* **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow. - **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
* **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`. - **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
* **Implementation:** - **Implementation:**
1. Use `oidc-client-ts` or a similar lightweight OIDC library. 1. Use `oidc-client-ts` or a similar lightweight OIDC library.
2. Check for `m.authentication` in `/.well-known/matrix/client`. 2. Check for `m.authentication` in `/.well-known/matrix/client`.
3. Redirect to the MAS authorization endpoint. 3. Redirect to the MAS authorization endpoint.
4. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`. 4. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
--- ---
## 🎨 Priority 5 — Gamer / Aesthetic / Customization ## 🎨 Priority 5 — Gamer / Aesthetic / Customization
### P5-1 · Custom Accent Color Picker (Non-TDS only) ### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection. **Mechanism:** Dynamic CSS variable injection.
* **1. Setting (src/app/state/settings.ts):** - **1. Setting (src/app/state/settings.ts):**
Add `customAccentColor: string` (hex). Add `customAccentColor: string` (hex).
* **2. Manager (src/app/pages/ThemeManager.tsx):** - **2. Manager (src/app/pages/ThemeManager.tsx):**
Inside the `useEffect` that monitors theme changes: Inside the `useEffect` that monitors theme changes:
```typescript ```typescript
if (!lotusTerminal && customAccentColor) { if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor); document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
// Also derive a 'glow' version (e.g. 50% opacity) // Also derive a 'glow' version (e.g. 50% opacity)
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`); document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
} }
``` ```
* **3. UI (src/app/features/settings/general/General.tsx):** - **3. UI (src/app/features/settings/general/General.tsx):**
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`. Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
### P5-40 · Desktop — Proactive Update Notifications (Tauri) ### P5-40 · Desktop — Proactive Update Notifications (Tauri)
**Mechanism:** Global Background Check via `useTauriUpdater`. **Mechanism:** Global Background Check via `useTauriUpdater`.
* **Objective:** Alert users to app updates without requiring a manual check in settings. - **Objective:** Alert users to app updates without requiring a manual check in settings.
* **Key Files:** - **Key Files:**
* `src/app/hooks/useTauriUpdater.ts`: Logic source. - `src/app/hooks/useTauriUpdater.ts`: Logic source.
* `src/app/pages/client/ClientNonUIFeatures.tsx`: Background mounting point. - `src/app/pages/client/ClientNonUIFeatures.tsx`: Background mounting point.
* `src/app/features/toast/LotusToastContainer.tsx`: UI for notification. - `src/app/features/toast/LotusToastContainer.tsx`: UI for notification.
* **Implementation:** - **Implementation:**
1. Create a `TauriUpdateFeature` component. 1. Create a `TauriUpdateFeature` component.
2. Use `useTauriUpdater()` to get the `check` function and `status`. 2. Use `useTauriUpdater()` to get the `check` function and `status`.
3. In a `useEffect`, call `check()` on mount and then on a `setInterval` (e.g., every 12 hours). 3. In a `useEffect`, call `check()` on mount and then on a `setInterval` (e.g., every 12 hours).
4. Watch the `status`. When it transitions to `{ state: 'available', version: '...' }`, trigger an in-app **Lotus Toast**. 4. Watch the `status`. When it transitions to `{ state: 'available', version: '...' }`, trigger an in-app **Lotus Toast**.
5. The toast should say "Lotus Chat v[version] is available!" with an "Update" button that calls the `install()` function from the hook. 5. The toast should say "Lotus Chat v[version] is available!" with an "Update" button that calls the `install()` function from the hook.
6. **Persistence:** Store the `lastCheck` timestamp in `localStorage` to ensure the background check doesn't fire redundant commands every time the user refreshes or re-opens the app. 6. **Persistence:** Store the `lastCheck` timestamp in `localStorage` to ensure the background check doesn't fire redundant commands every time the user refreshes or re-opens the app.
--- ---
## 🔊 Audio & Communications ## 🔊 Audio & Communications
### P5-15 · In-Call Soundboard ### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge. **Mechanism:** Local-to-Global Audio Bridge.
* **Architecture:** Use the `Web Audio API` to mix sounds into the `MediaStream` before it enters the Element Call widget. - **Architecture:** Use the `Web Audio API` to mix sounds into the `MediaStream` before it enters the Element Call widget.
* **Implementation:** - **Implementation:**
1. Create an `AudioContext`. 1. Create an `AudioContext`.
2. Create a `MediaStreamDestinationNode`. 2. Create a `MediaStreamDestinationNode`.
3. Create an `AudioBufferSourceNode` for the clip. 3. Create an `AudioBufferSourceNode` for the clip.
4. Route the mic `MediaStream` and the clip source to the destination. 4. Route the mic `MediaStream` and the clip source to the destination.
5. Pass the destination's `.stream` to the call bridge. 5. Pass the destination's `.stream` to the call bridge.
### P5-20 · Quick Reply from Browser Notification ### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action. **Mechanism:** Service Worker `notificationclick` Action.
* **1. Registration (src/sw.ts):** - **1. Registration (src/sw.ts):**
```typescript ```typescript
self.addEventListener('notificationclick', (event) => { self.addEventListener('notificationclick', (event) => {
if (event.action === 'reply' && event.reply) { if (event.action === 'reply' && event.reply) {
const { roomId, threadId } = event.notification.data; const { roomId, threadId } = event.notification.data;
const session = sessions.get(event.clientId); // Uses existing session mapping const session = sessions.get(event.clientId); // Uses existing session mapping
// Send via direct fetch to bypass SDK loading // Send via direct fetch to bypass SDK loading
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, { fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` }, headers: { Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({ body: JSON.stringify({
msgtype: 'm.text', msgtype: 'm.text',
body: event.reply, body: event.reply,
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined 'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined,
}) }),
}); });
} }
}); });
``` ```
--- ---
## 🔬 Extreme Complexity Projects ## 🔬 Extreme Complexity Projects
### P5-30 · Advanced ML Noise Suppression (Krisp-style) ### P5-30 · Advanced ML Noise Suppression (Krisp-style)
**Mechanism:** RNNoise WASM + Web Audio Worklet Pipeline. **Mechanism:** RNNoise WASM + Web Audio Worklet Pipeline.
* **Objective:** Filter non-vocal noise from the microphone stream in real-time. - **Objective:** Filter non-vocal noise from the microphone stream in real-time.
* **Architecture:** - **Architecture:**
1. **Engine:** Use `RNNoise` (Recurrent Neural Network for noise suppression). It is lightweight and highly effective for speech. 1. **Engine:** Use `RNNoise` (Recurrent Neural Network for noise suppression). It is lightweight and highly effective for speech.
2. **Pipeline:** `Mic Stream` -> `AudioWorkletNode` (Processing) -> `MediaStreamDestination` -> `Element Call`. 2. **Pipeline:** `Mic Stream` -> `AudioWorkletNode` (Processing) -> `MediaStreamDestination` -> `Element Call`.
* **Implementation Steps:** - **Implementation Steps:**
1. **WASM Wrapper:** Compile the `RNNoise` C library to WebAssembly. Use a library like `rnnoise-wasm` or `noise-suppression-js`. 1. **WASM Wrapper:** Compile the `RNNoise` C library to WebAssembly. Use a library like `rnnoise-wasm` or `noise-suppression-js`.
2. **Audio Worklet:** Create `src/app/utils/audio/RnnoiseWorklet.ts`. This must handle 480-sample chunks (10ms of audio at 48kHz), which is the standard frame size for RNNoise. 2. **Audio Worklet:** Create `src/app/utils/audio/RnnoiseWorklet.ts`. This must handle 480-sample chunks (10ms of audio at 48kHz), which is the standard frame size for RNNoise.
3. **Client Integration:** 3. **Client Integration:**
* In `CallControl.ts`, intercept the `localStream`. - In `CallControl.ts`, intercept the `localStream`.
* Pass the stream through the Worklet. - Pass the stream through the Worklet.
* Crucially, you must ensure that the processed stream is used by the `RTCPeerConnection` within the Element Call iframe. - Crucially, you must ensure that the processed stream is used by the `RTCPeerConnection` within the Element Call iframe.
### P5-31 · Granular Voice & Screenshare Quality Controls ### P5-31 · Granular Voice & Screenshare Quality Controls
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard. **Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
* **Objective:** Per-room and per-user control over audio fidelity and screenshare smoothness. - **Objective:** Per-room and per-user control over audio fidelity and screenshare smoothness.
* **Architecture:** - **Architecture:**
1. **State Event:** `io.lotus.room_quality` (state key `""`) containing: 1. **State Event:** `io.lotus.room_quality` (state key `""`) containing:
```json ```json
{ {
"audio_bitrate": 128000, "audio_bitrate": 128000,
"screen_max_res": "1080p", "screen_max_res": "1080p",
"screen_max_fps": 60 "screen_max_fps": 60
} }
``` ```
2. **Client-Side (RoomInput / CallControl):** 2. **Client-Side (RoomInput / CallControl):**
* **Screenshare:** In `src/app/plugins/call/CallControl.ts`, when initiating screenshare, map the "Quality" setting to `getDisplayMedia` constraints: - **Screenshare:** In `src/app/plugins/call/CallControl.ts`, when initiating screenshare, map the "Quality" setting to `getDisplayMedia` constraints:
```typescript
const constraints = { ```typescript
video: { const constraints = {
width: { ideal: 1920 }, // 1080p video: {
frameRate: { ideal: 60 } width: { ideal: 1920 }, // 1080p
} frameRate: { ideal: 60 },
}; },
``` };
* **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track and update parameters: ```
```typescript
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'audio'); - **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track and update parameters:
const params = sender.getParameters();
params.encodings[0].maxBitrate = roomBitrate || 128000; ```typescript
await sender.setParameters(params); const sender = peerConnection.getSenders().find((s) => s.track?.kind === 'audio');
``` const params = sender.getParameters();
3. **Backend Sidecar (The "Quality Guard"):** params.encodings[0].maxBitrate = roomBitrate || 128000;
* **Pattern:** Extend the `voice-limit-guard.py` (on LXC 151) to handle quality metadata. await sender.setParameters(params);
* **Mechanism:** When a user requests a LiveKit JWT to join a room, the Guard fetches the `io.lotus.room_quality` event for that room via the Synapse Admin API. ```
* **Enforcement:** The Guard injects these limits into the LiveKit token claims (if supported) or simply returns them to the client as an authorized "config" packet that the client must respect.
* **Challenges:** 3. **Backend Sidecar (The "Quality Guard"):**
* **LiveKit Compatibility:** Ensuring the SFU doesn't over-compress a high-bitrate stream from a "Pro" user. - **Pattern:** Extend the `voice-limit-guard.py` (on LXC 151) to handle quality metadata.
* **Network Stability:** High bitrates (512kbps audio + 60fps 1080p video) require significant upstream bandwidth. Implement a "Network Warning" UI if packets are dropped. - **Mechanism:** When a user requests a LiveKit JWT to join a room, the Guard fetches the `io.lotus.room_quality` event for that room via the Synapse Admin API.
- **Enforcement:** The Guard injects these limits into the LiveKit token claims (if supported) or simply returns them to the client as an authorized "config" packet that the client must respect.
- **Challenges:**
- **LiveKit Compatibility:** Ensuring the SFU doesn't over-compress a high-bitrate stream from a "Pro" user.
- **Network Stability:** High bitrates (512kbps audio + 60fps 1080p video) require significant upstream bandwidth. Implement a "Network Warning" UI if packets are dropped.
+101 -80
View File
@@ -10,12 +10,14 @@
* captured mic through an RNNoise AudioWorklet (@sapphi-red/web-noise-suppressor) * captured mic through an RNNoise AudioWorklet (@sapphi-red/web-noise-suppressor)
* and hand the processed track back to EC/LiveKit. * and hand the processed track back to EC/LiveKit.
* *
* This mirrors Element Call's own (still-unmerged) PR #3892 pipeline, executed * RNNoise REQUIRES mono, 48 kHz float audio. Feeding it anything else (stereo,
* from the realm we already control instead of forking and rebuilding EC. * or 44.1 kHz data the model treats as 48 kHz) produces loud static. So we:
* - request mono + 48 kHz capture,
* - run a 48 kHz AudioContext and BAIL to the raw mic if the browser refuses
* to give us a real 48 kHz context,
* - use the non-SIMD wasm (the SIMD build has produced artifacts on some GPUs).
* *
* Known beta caveat: routing capture through WebAudio can weaken the browser's * Any failure falls back to the unprocessed mic so calls never break.
* acoustic echo cancellation (AEC operates on the native capture track). We keep
* echoCancellation/autoGainControl enabled on the raw capture to mitigate.
*/ */
(function () { (function () {
'use strict'; 'use strict';
@@ -37,25 +39,13 @@
var origGetUserMedia = md.getUserMedia.bind(md); var origGetUserMedia = md.getUserMedia.bind(md);
var wasmPromise = null; var wasmPromise = null;
var ctxPromise = null; // shared AudioContext + worklet module, created once
// SIMD feature detection (bytes from @sapphi-red/web-noise-suppressor / wasm-feature-detect)
function hasSimd() {
try {
return WebAssembly.validate(
new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 123, 3, 2, 1, 0, 10, 10, 1, 8, 0, 65, 0,
253, 15, 253, 98, 11,
])
);
} catch (e) {
return false;
}
}
function loadWasm() { function loadWasm() {
if (!wasmPromise) { if (!wasmPromise) {
var url = ASSET_BASE + (hasSimd() ? 'rnnoise_simd.wasm' : 'rnnoise.wasm'); // Non-SIMD build for maximum compatibility — the SIMD wasm has produced
wasmPromise = fetch(url).then(function (r) { // static on some browser/GPU combinations.
wasmPromise = fetch(ASSET_BASE + 'rnnoise.wasm').then(function (r) {
if (!r.ok) throw new Error('rnnoise wasm fetch failed: ' + r.status); if (!r.ok) throw new Error('rnnoise wasm fetch failed: ' + r.status);
return r.arrayBuffer(); return r.arrayBuffer();
}); });
@@ -63,71 +53,98 @@
return wasmPromise; return wasmPromise;
} }
function getContext() {
if (!ctxPromise) {
ctxPromise = (function () {
var ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
// If the browser ignored our 48 kHz request, RNNoise would receive
// wrong-rate data and emit static. Refuse to process in that case.
if (ctx.sampleRate !== SAMPLE_RATE) {
try {
ctx.close();
} catch (e) {}
return Promise.reject(
new Error('AudioContext sampleRate is ' + ctx.sampleRate + ', need ' + SAMPLE_RATE),
);
}
return ctx.audioWorklet.addModule(ASSET_BASE + 'rnnoiseWorklet.js').then(function () {
return ctx.state === 'suspended'
? ctx.resume().then(function () {
return ctx;
})
: ctx;
});
})();
// Don't cache a rejected context forever — allow a later retry.
ctxPromise.catch(function () {
ctxPromise = null;
});
}
return ctxPromise;
}
function processStream(stream) { function processStream(stream) {
var audioTracks = stream.getAudioTracks(); var audioTracks = stream.getAudioTracks();
if (audioTracks.length === 0) return Promise.resolve(stream); if (audioTracks.length === 0) return Promise.resolve(stream);
return loadWasm() return Promise.all([loadWasm(), getContext()])
.then(function (wasmBinary) { .then(function (res) {
var ctx = new AudioContext({ sampleRate: SAMPLE_RATE }); var wasmBinary = res[0];
return ctx.audioWorklet var ctx = res[1];
.addModule(ASSET_BASE + 'rnnoiseWorklet.js')
.then(function () {
if (ctx.state === 'suspended') return ctx.resume().then(function () { return ctx; });
return ctx;
})
.then(function () {
var node = new AudioWorkletNode(ctx, PROCESSOR_NAME, {
processorOptions: { maxChannels: 1, wasmBinary: wasmBinary },
});
var source = ctx.createMediaStreamSource(stream);
var dest = ctx.createMediaStreamDestination();
source.connect(node).connect(dest);
var origTrack = audioTracks[0]; var node = new AudioWorkletNode(ctx, PROCESSOR_NAME, {
var processedTrack = dest.stream.getAudioTracks()[0]; channelCount: 1,
channelCountMode: 'explicit',
channelInterpretation: 'speakers',
numberOfInputs: 1,
numberOfOutputs: 1,
outputChannelCount: [1],
processorOptions: { maxChannels: 1, wasmBinary: wasmBinary },
});
var source = ctx.createMediaStreamSource(stream);
var dest = ctx.createMediaStreamDestination();
source.connect(node).connect(dest);
var torndown = false; var origTrack = audioTracks[0];
function cleanup() { var processedTrack = dest.stream.getAudioTracks()[0];
if (torndown) return;
torndown = true;
try {
node.port.postMessage('destroy');
} catch (e) {}
try {
source.disconnect();
node.disconnect();
} catch (e) {}
try {
origTrack.stop();
} catch (e) {}
try {
ctx.close();
} catch (e) {}
}
// When EC stops the track we handed it, release the raw capture + graph. var torndown = false;
var rawStop = processedTrack.stop.bind(processedTrack); function cleanup() {
processedTrack.stop = function () { if (torndown) return;
cleanup(); torndown = true;
rawStop(); try {
}; node.port.postMessage('destroy');
// Device unplugged / capture ended involuntarily. } catch (e) {}
origTrack.addEventListener('ended', function () { try {
try { source.disconnect();
rawStop(); node.disconnect();
} catch (e) {} } catch (e) {}
cleanup(); try {
}); origTrack.stop();
} catch (e) {}
// Keep the shared AudioContext alive for the next capture.
}
// Return a stream with the processed audio plus any original video. // When EC stops the track we handed it, release the raw capture + graph.
var out = new MediaStream(); var rawStop = processedTrack.stop.bind(processedTrack);
out.addTrack(processedTrack); processedTrack.stop = function () {
stream.getVideoTracks().forEach(function (t) { cleanup();
out.addTrack(t); rawStop();
}); };
return out; origTrack.addEventListener('ended', function () {
}); try {
rawStop();
} catch (e) {}
cleanup();
});
// Return a stream with the processed audio plus any original video.
var out = new MediaStream();
out.addTrack(processedTrack);
stream.getVideoTracks().forEach(function (t) {
out.addTrack(t);
});
return out;
}) })
.catch(function (e) { .catch(function (e) {
// Any failure -> fall back to the raw mic so calls never break. // Any failure -> fall back to the raw mic so calls never break.
@@ -141,9 +158,13 @@
var wantsAudio = !!(constraints && constraints.audio); var wantsAudio = !!(constraints && constraints.audio);
var effective = constraints; var effective = constraints;
if (wantsAudio) { if (wantsAudio) {
// RNNoise owns noise suppression; keep AEC + AGC on the raw capture. // RNNoise needs mono 48 kHz; it owns suppression. Keep AEC + AGC on the
var audioC = typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {}; // raw capture (they run before our processing).
var audioC =
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
audioC.noiseSuppression = false; audioC.noiseSuppression = false;
audioC.channelCount = 1;
audioC.sampleRate = SAMPLE_RATE;
if (audioC.echoCancellation === undefined) audioC.echoCancellation = true; if (audioC.echoCancellation === undefined) audioC.echoCancellation = true;
if (audioC.autoGainControl === undefined) audioC.autoGainControl = true; if (audioC.autoGainControl === undefined) audioC.autoGainControl = true;
effective = Object.assign({}, constraints, { audio: audioC }); effective = Object.assign({}, constraints, { audio: audioC });
+7 -9
View File
@@ -21,8 +21,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..'); const root = join(__dirname, '..');
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts'); const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
const CDN = const CDN = 'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
// Extract all slugs from the catalog file // Extract all slugs from the catalog file
const catalog = readFileSync(catalogPath, 'utf8'); const catalog = readFileSync(catalogPath, 'utf8');
@@ -63,16 +62,13 @@ if (missing.length === 0) {
} }
console.log(`Found: ${found.length} Missing: ${missing.length}\n`); console.log(`Found: ${found.length} Missing: ${missing.length}\n`);
missing.forEach((r) => missing.forEach((r) => console.log(` Removing (HTTP ${r.status}): ${r.slug}`));
console.log(` Removing (HTTP ${r.status}): ${r.slug}`),
);
const missingSet = new Set(missing.map((r) => r.slug)); const missingSet = new Set(missing.map((r) => r.slug));
// Remove individual entries for missing slugs // Remove individual entries for missing slugs
let updated = catalog.replace( let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) =>
/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, missingSet.has(slug) ? '' : match,
(match, slug) => (missingSet.has(slug) ? '' : match),
); );
// Drop category blocks that now have an empty decorations array // Drop category blocks that now have an empty decorations array
@@ -85,5 +81,7 @@ updated = updated.replace(
updated = updated.replace(/\n{3,}/g, '\n\n'); updated = updated.replace(/\n{3,}/g, '\n\n');
writeFileSync(catalogPath, updated, 'utf8'); writeFileSync(catalogPath, updated, 'utf8');
console.log(`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`); console.log(
`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`,
);
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts'); console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
@@ -10,7 +10,11 @@ type AvatarDecorationProps = {
inset?: number; inset?: number;
}; };
export function AvatarDecoration({ userId, children, inset = DEFAULT_INSET }: AvatarDecorationProps) { export function AvatarDecoration({
userId,
children,
inset = DEFAULT_INSET,
}: AvatarDecorationProps) {
const slug = useAvatarDecoration(userId); const slug = useAvatarDecoration(userId);
if (!slug) { if (!slug) {
@@ -282,9 +282,7 @@ export function PollContent({
style={{ style={{
padding: '7px 12px', padding: '7px 12px',
borderRadius: '8px', borderRadius: '8px',
background: selected background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.04)',
? 'var(--accent-cyan-dim)'
: 'rgba(255,255,255,0.04)',
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`, border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
fontSize: '0.88rem', fontSize: '0.88rem',
lineHeight: 1.4, lineHeight: 1.4,
@@ -308,9 +306,7 @@ export function PollContent({
inset: 0, inset: 0,
right: 'auto', right: 'auto',
width: `${pct}%`, width: `${pct}%`,
background: selected background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.03)',
? 'var(--accent-cyan-dim)'
: 'rgba(255,255,255,0.03)',
pointerEvents: 'none', pointerEvents: 'none',
transition: 'width 0.3s ease', transition: 'width 0.3s ease',
}} }}
+8 -11
View File
@@ -109,9 +109,7 @@ function HalloweenOverlay({ reduced }: { reduced: boolean }) {
height: `${size}px`, height: `${size}px`,
borderRadius: '50%', borderRadius: '50%',
backgroundColor: isOrange ? 'rgba(255,100,0,0.75)' : 'rgba(160,0,255,0.7)', backgroundColor: isOrange ? 'rgba(255,100,0,0.75)' : 'rgba(160,0,255,0.7)',
boxShadow: isOrange boxShadow: isOrange ? '0 0 8px rgba(255,100,0,0.5)' : '0 0 8px rgba(160,0,255,0.5)',
? '0 0 8px rgba(255,100,0,0.5)'
: '0 0 8px rgba(160,0,255,0.5)',
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`, animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
}} }}
/> />
@@ -379,8 +377,9 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
position: 'absolute', position: 'absolute',
left: `${left}%`, left: `${left}%`,
top: `${top}%`, top: `${top}%`,
animation: animation: reduced
reduced ? 'none' : `${animBob} ${duration}s ease-in-out ${delay}s infinite`, ? 'none'
: `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
}} }}
> >
<div <div
@@ -419,10 +418,9 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
height: '14px', height: '14px',
backgroundColor: '#ffd700', backgroundColor: '#ffd700',
margin: '0 auto', margin: '0 auto',
animation: animation: reduced
reduced ? 'none'
? 'none' : `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
: `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
transformOrigin: 'top center', transformOrigin: 'top center',
}} }}
/> />
@@ -789,8 +787,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
export function SeasonalEffect() { export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom); const settings = useAtomValue(settingsAtom);
const reduced = const reduced =
typeof window !== 'undefined' && typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const theme = useMemo<SeasonTheme | null>(() => { const theme = useMemo<SeasonTheme | null>(() => {
const override = settings.seasonalThemeOverride ?? 'auto'; const override = settings.seasonalThemeOverride ?? 'auto';
@@ -1651,7 +1651,6 @@ function GenericCard({
const title = prev['og:title'] ?? ''; const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? ''; const description = prev['og:description'] ?? '';
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined; const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
const domain = getDomain(url);
return ( return (
<> <>
@@ -216,8 +216,7 @@ export function MessageSearch({
// term === undefined → no search started // term === undefined → no search started
// term === '' → sender-only search (from:user with no body text) // term === '' → sender-only search (from:user with no body text)
// term === 'foo' → normal text search // term === 'foo' → normal text search
const hasActiveSearch = const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length; const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
// Run synchronous client-side search immediately. // Run synchronous client-side search immediately.
@@ -534,52 +533,53 @@ export function MessageSearch({
</Box> </Box>
)} )}
{localResult && (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && ( {localResult &&
<Box direction="Column" gap="300"> (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
<Box direction="Column" gap="200"> <Box direction="Column" gap="300">
<Box alignItems="Center" gap="200"> <Box direction="Column" gap="200">
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} /> <Box alignItems="Center" gap="200">
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text> <Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
{!senderOnlyMode && ( <Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
<Text size="T200" style={{ opacity: 0.55 }}> {!senderOnlyMode && (
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`} <Text size="T200" style={{ opacity: 0.55 }}>
</Text> {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
)} </Text>
)}
</Box>
<Text size="T300" priority="300">
{senderOnlyMode
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
: localResult.groups.length > 0
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
: `No matches in your local cache. Load messages below to search further back.`}
</Text>
<Line size="300" variant="Surface" />
</Box> </Box>
<Text size="T300" priority="300"> {localResult.groups.length > 0 && (
{senderOnlyMode <Box direction="Column" gap="300">
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.` {localResult.groups.map((group) => {
: localResult.groups.length > 0 const groupRoom = mx.getRoom(group.roomId);
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.` if (!groupRoom) return null;
: `No matches in your local cache. Load messages below to search further back.`} return (
</Text> <SearchResultGroup
<Line size="300" variant="Surface" /> key={group.roomId}
room={groupRoom}
highlights={[msgSearchParams.term ?? '']}
items={group.items}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
onOpen={navigateRoom}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
})}
</Box>
)}
<EncryptedRoomCachePanel roomIds={localSearchRooms} onLoaded={handleCacheLoaded} />
</Box> </Box>
{localResult.groups.length > 0 && ( )}
<Box direction="Column" gap="300">
{localResult.groups.map((group) => {
const groupRoom = mx.getRoom(group.roomId);
if (!groupRoom) return null;
return (
<SearchResultGroup
key={group.roomId}
room={groupRoom}
highlights={[msgSearchParams.term ?? '']}
items={group.items}
mediaAutoLoad={mediaAutoLoad}
urlPreview={urlPreview}
onOpen={navigateRoom}
legacyUsernameColor={legacyUsernameColor || mDirects.has(groupRoom.roomId)}
hour24Clock={hour24Clock}
dateFormatString={dateFormatString}
/>
);
})}
</Box>
)}
<EncryptedRoomCachePanel roomIds={localSearchRooms} onLoaded={handleCacheLoaded} />
</Box>
)}
{error && ( {error && (
<Box <Box
@@ -75,8 +75,7 @@ export const useLocalMessageSearch = () => {
if (senderOnlyMode) continue; if (senderOnlyMode) continue;
const evType = event.getType(); const evType = event.getType();
const isSticker = evType === 'm.sticker'; const isSticker = evType === 'm.sticker';
const isPoll = const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
if (!isSticker && !isPoll) continue; if (!isSticker && !isPoll) continue;
} }
@@ -90,9 +89,7 @@ export const useLocalMessageSearch = () => {
// Sender-only mode: no text filter needed // Sender-only mode: no text filter needed
if (!senderOnlyMode) { if (!senderOnlyMode) {
const evType = event.getType(); const evType = event.getType();
const isSticker = evType === 'm.sticker'; const isPoll = evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
const isPoll =
evType === 'm.poll.start' || evType === 'org.matrix.msc3381.poll.start';
let body = ''; let body = '';
let formattedBody = ''; let formattedBody = '';
+1 -5
View File
@@ -37,11 +37,7 @@ export function usePresenceUpdater() {
return mx return mx
.setPresence({ .setPresence({
presence: 'unavailable', presence: 'unavailable',
...(statusMsg ...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
? { status_msg: statusMsg }
: status
? { status_msg: status }
: {}),
}) })
.catch(() => undefined); .catch(() => undefined);
}; };
+5 -8
View File
@@ -21,14 +21,11 @@ export function useUserNotes(): {
useAccountDataCallback( useAccountDataCallback(
mx, mx,
useCallback( useCallback((evt) => {
(evt) => { if (evt.getType() === NOTES_KEY) {
if (evt.getType() === NOTES_KEY) { setNotes(evt.getContent<UserNotesContent>() ?? {});
setNotes(evt.getContent<UserNotesContent>() ?? {}); }
} }, []),
},
[],
),
); );
useEffect(() => { useEffect(() => {