Compare commits
1 Commits
5deed79b42
...
4a401cf816
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a401cf816 |
+26
-17
@@ -1,4 +1,5 @@
|
||||
# Lotus Chat — Bug Report & Technical Audit
|
||||
|
||||
**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.
|
||||
@@ -8,65 +9,73 @@ This document tracks identified bugs, edge cases, and architectural discrepancie
|
||||
## 🚩 Critical & UI Bugs
|
||||
|
||||
### 1. Avatar Decoration Displacement in Profile
|
||||
|
||||
**File:** `src/app/components/user-profile/UserHero.tsx`
|
||||
**Status:** **OPEN**
|
||||
|
||||
* **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.
|
||||
* **Recommended Fix:** Wrap only the `Avatar` component with `AvatarDecoration`.
|
||||
- **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.
|
||||
- **Recommended Fix:** Wrap only the `Avatar` component with `AvatarDecoration`.
|
||||
|
||||
### 2. Inconsistent Settings Dropdown Styling
|
||||
|
||||
**Files:** `Profile.tsx`, `SystemNotification.tsx`
|
||||
**Status:** **OPEN**
|
||||
|
||||
* **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`.
|
||||
- **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`.
|
||||
|
||||
### 3. Ringing Modal Fires in Voice Rooms
|
||||
|
||||
**File:** `src/app/components/CallEmbedProvider.tsx`
|
||||
**Status:** **OPEN**
|
||||
|
||||
* **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.
|
||||
- **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.
|
||||
|
||||
### 4. No Camera Focus During Screenshare
|
||||
|
||||
**File:** `src/app/features/call/CallControls.tsx`
|
||||
**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.
|
||||
* **Recommended Fix:** Implement a "Focus" toggle on participant tiles that overrides the automatic screenshare spotlight.
|
||||
- **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.
|
||||
|
||||
### 5. Chat Background Animation Flickering
|
||||
|
||||
**File:** `src/app/features/lotus/chatBackground.ts`
|
||||
**Status:** **OPEN**
|
||||
|
||||
* **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 📱 PWA & Mobile Issues
|
||||
|
||||
### 1. Exclusive Background vs. Seasonal Choice
|
||||
|
||||
**Status:** **OPEN**
|
||||
|
||||
* **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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Technical & Performance Refinements
|
||||
|
||||
### 1. Decrypted Media Memory Leak (Gallery & Lightbox)
|
||||
|
||||
**File:** `src/app/features/room/MediaGallery.tsx`
|
||||
**Status:** **OPEN**
|
||||
|
||||
* **Issue:** Every image in the gallery history is decrypted and converted to a Blob URL simultaneously.
|
||||
* **Recommended Fix:** Implement virtualization for the gallery grid.
|
||||
- **Issue:** Every image in the gallery history is decrypted and converted to a Blob URL simultaneously.
|
||||
- **Recommended Fix:** Implement virtualization for the gallery grid.
|
||||
|
||||
### 2. Scheduled Messages are Ephemeral
|
||||
|
||||
**File:** `src/app/state/scheduledMessages.ts`
|
||||
**Status:** **OPEN**
|
||||
|
||||
* **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`.
|
||||
- **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`.
|
||||
|
||||
+35
-35
@@ -173,19 +173,19 @@ Decorative CSS-only overlays that activate automatically on holidays and events.
|
||||
|
||||
### Themes
|
||||
|
||||
| Theme | Window | Effect |
|
||||
|---|---|---|
|
||||
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
|
||||
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
|
||||
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
|
||||
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
|
||||
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
|
||||
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
|
||||
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
|
||||
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
|
||||
| 🚀 Deep Space Week | Oct 4–10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
|
||||
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
|
||||
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
|
||||
| Theme | Window | Effect |
|
||||
| -------------------- | ------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 🎆 New Year | Dec 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
|
||||
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
|
||||
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
|
||||
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ clovers drifting down; gold metallic shimmer top border |
|
||||
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
|
||||
| 🌱 Earth Day | Apr 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
|
||||
| 🍂 Autumn | Sep 21–Oct 31 | Warm orange/amber leaf shapes rotating and falling |
|
||||
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
|
||||
| 🚀 Deep Space Week | Oct 4–10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
|
||||
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
|
||||
| ❄️ Christmas | Dec 10–Jan 2 | White dot snowfall in multiple layers at varied speeds |
|
||||
|
||||
### Implementation
|
||||
|
||||
@@ -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:
|
||||
|
||||
| Category | Count | Highlights |
|
||||
|---|---|---|
|
||||
| Gaming | 13 | Slither 'n Snack, Joystick, Space Invaders, Gaming Headsets |
|
||||
| Cyber | 9 | Cybernetic, Glitch, Digital Sunrise, Futuristic UI (3 colors) |
|
||||
| Space | 8 | Black Hole, Constellations, Solar Orbit, Aurora |
|
||||
| Fantasy | 22 | Kitsune, Phoenix, Glowing Runes, D&D dice, Crystal Balls |
|
||||
| Elements | 7 | Fire, Water, Air, Earth, Lightning, Ki Energy |
|
||||
| Japanese | 6 | Kabuto, Oni Mask, Sakura Warrior, Straw Hat |
|
||||
| Nature | 12 | **Lotus Flower**, Koi Pond, Sakura, Fall Leaves, Fireflies |
|
||||
| Spooky | 13 | Candlelight, Witch Hat, Ghosts, Jack-o'-Lantern |
|
||||
| Cozy | 11 | Cozy Cat, Fox Hat (3 colors), Cat Ears, Frog Hat |
|
||||
| Category | Count | Highlights |
|
||||
| -------- | ----- | ------------------------------------------------------------- |
|
||||
| Gaming | 13 | Slither 'n Snack, Joystick, Space Invaders, Gaming Headsets |
|
||||
| Cyber | 9 | Cybernetic, Glitch, Digital Sunrise, Futuristic UI (3 colors) |
|
||||
| Space | 8 | Black Hole, Constellations, Solar Orbit, Aurora |
|
||||
| Fantasy | 22 | Kitsune, Phoenix, Glowing Runes, D&D dice, Crystal Balls |
|
||||
| Elements | 7 | Fire, Water, Air, Earth, Lightning, Ki Energy |
|
||||
| Japanese | 6 | Kabuto, Oni Mask, Sakura Warrior, Straw Hat |
|
||||
| Nature | 12 | **Lotus Flower**, Koi Pond, Sakura, Fall Leaves, Fireflies |
|
||||
| Spooky | 13 | Candlelight, Witch Hat, Ghosts, Jack-o'-Lantern |
|
||||
| Cozy | 11 | Cozy Cat, Fox Hat (3 colors), Cat Ears, Frog Hat |
|
||||
|
||||
All decoration files are 256×256 APNGs. They animate natively in all modern browsers via `<img>` elements.
|
||||
|
||||
@@ -239,12 +239,12 @@ Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://d
|
||||
|
||||
### Placement — Where Decorations Render
|
||||
|
||||
| Location | File |
|
||||
|---|---|
|
||||
| Message timeline | `src/app/features/room/message/Message.tsx` |
|
||||
| Members drawer | `src/app/features/room/MembersDrawer.tsx` |
|
||||
| Location | File |
|
||||
| ----------------------- | -------------------------------------------------------------------- |
|
||||
| Message timeline | `src/app/features/room/message/Message.tsx` |
|
||||
| Members drawer | `src/app/features/room/MembersDrawer.tsx` |
|
||||
| `@mention` autocomplete | `src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx` |
|
||||
| Inbox / notifications | `src/app/pages/client/inbox/Notifications.tsx` |
|
||||
| Inbox / notifications | `src/app/pages/client/inbox/Notifications.tsx` |
|
||||
|
||||
### 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**:
|
||||
|
||||
| Tier | What it does |
|
||||
|---|---|
|
||||
| **Off** | No suppression (`noiseSuppression=false` to Element Call). |
|
||||
| Tier | What it does |
|
||||
| ------------------ | ----------------------------------------------------------------------------- |
|
||||
| **Off** | No suppression (`noiseSuppression=false` to Element Call). |
|
||||
| **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:**
|
||||
|
||||
@@ -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/components/avatar-decoration/AvatarDecoration.tsx` | APNG overlay wrapper rendered around avatars in timeline, members drawer, autocomplete |
|
||||
| `src/app/features/settings/account/ProfileDecoration.tsx` | Settings decoration picker — scrollable grid, category headers, save button |
|
||||
| `scripts/syncDecorations.mjs` | CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files |
|
||||
| `scripts/syncDecorations.mjs` | CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files |
|
||||
|
||||
+150
-133
@@ -1,4 +1,5 @@
|
||||
# Lotus Chat — Technical Implementation Field Guide
|
||||
|
||||
**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).
|
||||
@@ -8,190 +9,206 @@ This document provides exhaustive, low-level implementation details for the rema
|
||||
## 🧵 Priority 3 — Higher Complexity
|
||||
|
||||
### P3-8 · Thread Panel (Full Side Drawer)
|
||||
|
||||
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
|
||||
|
||||
* **1. State (src/app/state/room/thread.ts):**
|
||||
```typescript
|
||||
export const activeThreadIdAtom = atom<string | null>(null);
|
||||
```
|
||||
* **2. Layout (src/app/features/room/Room.tsx):**
|
||||
Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`.
|
||||
```tsx
|
||||
{activeThreadId && (
|
||||
- **1. State (src/app/state/room/thread.ts):**
|
||||
```typescript
|
||||
export const activeThreadIdAtom = atom<string | null>(null);
|
||||
```
|
||||
- **2. Layout (src/app/features/room/Room.tsx):**
|
||||
Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`.
|
||||
```tsx
|
||||
{
|
||||
activeThreadId && (
|
||||
<>
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
|
||||
</>
|
||||
)}
|
||||
```
|
||||
* **3. Component (src/app/features/room/thread/ThreadPanel.tsx):**
|
||||
* Use `room.getThread(threadId)` from the SDK.
|
||||
* Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`.
|
||||
* Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`.
|
||||
* **Pro Tip:** Use `thread.timelineSet` directly for the most accurate thread view.
|
||||
);
|
||||
}
|
||||
```
|
||||
- **3. Component (src/app/features/room/thread/ThreadPanel.tsx):**
|
||||
- Use `room.getThread(threadId)` from the SDK.
|
||||
- Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`.
|
||||
- Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`.
|
||||
- **Pro Tip:** Use `thread.timelineSet` directly for the most accurate thread view.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Priority 4 — Specialized Features
|
||||
|
||||
### P4-4 · Math / LaTeX Rendering
|
||||
|
||||
**Mechanism:** KaTeX injection into the HTML parser.
|
||||
|
||||
* **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.
|
||||
* **2. Parser (src/app/plugins/react-custom-html-parser.tsx):**
|
||||
Detect `$ ... $` and `$$ ... $$` patterns in text nodes.
|
||||
```tsx
|
||||
if (node.type === 'text') {
|
||||
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
|
||||
return parts.map(p => {
|
||||
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
|
||||
return p;
|
||||
});
|
||||
}
|
||||
```
|
||||
* **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.
|
||||
- **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.
|
||||
- **2. Parser (src/app/plugins/react-custom-html-parser.tsx):**
|
||||
Detect `$ ... $` and `$$ ... $$` patterns in text nodes.
|
||||
```tsx
|
||||
if (node.type === 'text') {
|
||||
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
|
||||
return parts.map((p) => {
|
||||
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
|
||||
return p;
|
||||
});
|
||||
}
|
||||
```
|
||||
- **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.
|
||||
|
||||
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
|
||||
|
||||
**Mechanism:** Matrix Authentication Service (MAS) Integration.
|
||||
|
||||
* **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`.
|
||||
* **Implementation:**
|
||||
1. Use `oidc-client-ts` or a similar lightweight OIDC library.
|
||||
2. Check for `m.authentication` in `/.well-known/matrix/client`.
|
||||
3. Redirect to the MAS authorization endpoint.
|
||||
4. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
|
||||
- **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`.
|
||||
- **Implementation:**
|
||||
1. Use `oidc-client-ts` or a similar lightweight OIDC library.
|
||||
2. Check for `m.authentication` in `/.well-known/matrix/client`.
|
||||
3. Redirect to the MAS authorization endpoint.
|
||||
4. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
|
||||
|
||||
### P5-1 · Custom Accent Color Picker (Non-TDS only)
|
||||
|
||||
**Mechanism:** Dynamic CSS variable injection.
|
||||
|
||||
* **1. Setting (src/app/state/settings.ts):**
|
||||
Add `customAccentColor: string` (hex).
|
||||
* **2. Manager (src/app/pages/ThemeManager.tsx):**
|
||||
Inside the `useEffect` that monitors theme changes:
|
||||
```typescript
|
||||
if (!lotusTerminal && customAccentColor) {
|
||||
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
|
||||
// Also derive a 'glow' version (e.g. 50% opacity)
|
||||
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
|
||||
}
|
||||
```
|
||||
* **3. UI (src/app/features/settings/general/General.tsx):**
|
||||
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
|
||||
- **1. Setting (src/app/state/settings.ts):**
|
||||
Add `customAccentColor: string` (hex).
|
||||
- **2. Manager (src/app/pages/ThemeManager.tsx):**
|
||||
Inside the `useEffect` that monitors theme changes:
|
||||
```typescript
|
||||
if (!lotusTerminal && customAccentColor) {
|
||||
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
|
||||
// Also derive a 'glow' version (e.g. 50% opacity)
|
||||
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
|
||||
}
|
||||
```
|
||||
- **3. UI (src/app/features/settings/general/General.tsx):**
|
||||
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
|
||||
|
||||
### P5-40 · Desktop — Proactive Update Notifications (Tauri)
|
||||
|
||||
**Mechanism:** Global Background Check via `useTauriUpdater`.
|
||||
|
||||
* **Objective:** Alert users to app updates without requiring a manual check in settings.
|
||||
* **Key Files:**
|
||||
* `src/app/hooks/useTauriUpdater.ts`: Logic source.
|
||||
* `src/app/pages/client/ClientNonUIFeatures.tsx`: Background mounting point.
|
||||
* `src/app/features/toast/LotusToastContainer.tsx`: UI for notification.
|
||||
* **Implementation:**
|
||||
1. Create a `TauriUpdateFeature` component.
|
||||
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).
|
||||
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.
|
||||
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.
|
||||
- **Objective:** Alert users to app updates without requiring a manual check in settings.
|
||||
- **Key Files:**
|
||||
- `src/app/hooks/useTauriUpdater.ts`: Logic source.
|
||||
- `src/app/pages/client/ClientNonUIFeatures.tsx`: Background mounting point.
|
||||
- `src/app/features/toast/LotusToastContainer.tsx`: UI for notification.
|
||||
- **Implementation:**
|
||||
1. Create a `TauriUpdateFeature` component.
|
||||
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).
|
||||
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.
|
||||
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
|
||||
|
||||
### P5-15 · In-Call Soundboard
|
||||
|
||||
**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.
|
||||
* **Implementation:**
|
||||
1. Create an `AudioContext`.
|
||||
2. Create a `MediaStreamDestinationNode`.
|
||||
3. Create an `AudioBufferSourceNode` for the clip.
|
||||
4. Route the mic `MediaStream` and the clip source to the destination.
|
||||
5. Pass the destination's `.stream` to the call bridge.
|
||||
- **Architecture:** Use the `Web Audio API` to mix sounds into the `MediaStream` before it enters the Element Call widget.
|
||||
- **Implementation:**
|
||||
1. Create an `AudioContext`.
|
||||
2. Create a `MediaStreamDestinationNode`.
|
||||
3. Create an `AudioBufferSourceNode` for the clip.
|
||||
4. Route the mic `MediaStream` and the clip source to the destination.
|
||||
5. Pass the destination's `.stream` to the call bridge.
|
||||
|
||||
### P5-20 · Quick Reply from Browser Notification
|
||||
|
||||
**Mechanism:** Service Worker `notificationclick` Action.
|
||||
|
||||
* **1. Registration (src/sw.ts):**
|
||||
```typescript
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
if (event.action === 'reply' && event.reply) {
|
||||
const { roomId, threadId } = event.notification.data;
|
||||
const session = sessions.get(event.clientId); // Uses existing session mapping
|
||||
// Send via direct fetch to bypass SDK loading
|
||||
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'm.text',
|
||||
body: event.reply,
|
||||
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
- **1. Registration (src/sw.ts):**
|
||||
```typescript
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
if (event.action === 'reply' && event.reply) {
|
||||
const { roomId, threadId } = event.notification.data;
|
||||
const session = sessions.get(event.clientId); // Uses existing session mapping
|
||||
// Send via direct fetch to bypass SDK loading
|
||||
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||
body: JSON.stringify({
|
||||
msgtype: 'm.text',
|
||||
body: event.reply,
|
||||
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔬 Extreme Complexity Projects
|
||||
|
||||
### P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
||||
|
||||
**Mechanism:** RNNoise WASM + Web Audio Worklet Pipeline.
|
||||
|
||||
* **Objective:** Filter non-vocal noise from the microphone stream in real-time.
|
||||
* **Architecture:**
|
||||
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`.
|
||||
* **Implementation Steps:**
|
||||
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.
|
||||
3. **Client Integration:**
|
||||
* In `CallControl.ts`, intercept the `localStream`.
|
||||
* Pass the stream through the Worklet.
|
||||
* Crucially, you must ensure that the processed stream is used by the `RTCPeerConnection` within the Element Call iframe.
|
||||
- **Objective:** Filter non-vocal noise from the microphone stream in real-time.
|
||||
- **Architecture:**
|
||||
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`.
|
||||
- **Implementation Steps:**
|
||||
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.
|
||||
3. **Client Integration:**
|
||||
- In `CallControl.ts`, intercept the `localStream`.
|
||||
- Pass the stream through the Worklet.
|
||||
- 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
|
||||
|
||||
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
|
||||
|
||||
* **Objective:** Per-room and per-user control over audio fidelity and screenshare smoothness.
|
||||
* **Architecture:**
|
||||
1. **State Event:** `io.lotus.room_quality` (state key `""`) containing:
|
||||
```json
|
||||
{
|
||||
"audio_bitrate": 128000,
|
||||
"screen_max_res": "1080p",
|
||||
"screen_max_fps": 60
|
||||
}
|
||||
```
|
||||
2. **Client-Side (RoomInput / CallControl):**
|
||||
* **Screenshare:** In `src/app/plugins/call/CallControl.ts`, when initiating screenshare, map the "Quality" setting to `getDisplayMedia` constraints:
|
||||
```typescript
|
||||
const constraints = {
|
||||
video: {
|
||||
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');
|
||||
const params = sender.getParameters();
|
||||
params.encodings[0].maxBitrate = roomBitrate || 128000;
|
||||
await sender.setParameters(params);
|
||||
```
|
||||
3. **Backend Sidecar (The "Quality Guard"):**
|
||||
* **Pattern:** Extend the `voice-limit-guard.py` (on LXC 151) to handle quality metadata.
|
||||
* **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.
|
||||
- **Objective:** Per-room and per-user control over audio fidelity and screenshare smoothness.
|
||||
- **Architecture:**
|
||||
1. **State Event:** `io.lotus.room_quality` (state key `""`) containing:
|
||||
```json
|
||||
{
|
||||
"audio_bitrate": 128000,
|
||||
"screen_max_res": "1080p",
|
||||
"screen_max_fps": 60
|
||||
}
|
||||
```
|
||||
2. **Client-Side (RoomInput / CallControl):**
|
||||
- **Screenshare:** In `src/app/plugins/call/CallControl.ts`, when initiating screenshare, map the "Quality" setting to `getDisplayMedia` constraints:
|
||||
|
||||
```typescript
|
||||
const constraints = {
|
||||
video: {
|
||||
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');
|
||||
const params = sender.getParameters();
|
||||
params.encodings[0].maxBitrate = roomBitrate || 128000;
|
||||
await sender.setParameters(params);
|
||||
```
|
||||
|
||||
3. **Backend Sidecar (The "Quality Guard"):**
|
||||
- **Pattern:** Extend the `voice-limit-guard.py` (on LXC 151) to handle quality metadata.
|
||||
- **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
@@ -10,12 +10,14 @@
|
||||
* captured mic through an RNNoise AudioWorklet (@sapphi-red/web-noise-suppressor)
|
||||
* and hand the processed track back to EC/LiveKit.
|
||||
*
|
||||
* This mirrors Element Call's own (still-unmerged) PR #3892 pipeline, executed
|
||||
* from the realm we already control instead of forking and rebuilding EC.
|
||||
* RNNoise REQUIRES mono, 48 kHz float audio. Feeding it anything else (stereo,
|
||||
* 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
|
||||
* acoustic echo cancellation (AEC operates on the native capture track). We keep
|
||||
* echoCancellation/autoGainControl enabled on the raw capture to mitigate.
|
||||
* Any failure falls back to the unprocessed mic so calls never break.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
@@ -37,25 +39,13 @@
|
||||
|
||||
var origGetUserMedia = md.getUserMedia.bind(md);
|
||||
var wasmPromise = null;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
var ctxPromise = null; // shared AudioContext + worklet module, created once
|
||||
|
||||
function loadWasm() {
|
||||
if (!wasmPromise) {
|
||||
var url = ASSET_BASE + (hasSimd() ? 'rnnoise_simd.wasm' : 'rnnoise.wasm');
|
||||
wasmPromise = fetch(url).then(function (r) {
|
||||
// Non-SIMD build for maximum compatibility — the SIMD wasm has produced
|
||||
// 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);
|
||||
return r.arrayBuffer();
|
||||
});
|
||||
@@ -63,71 +53,98 @@
|
||||
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) {
|
||||
var audioTracks = stream.getAudioTracks();
|
||||
if (audioTracks.length === 0) return Promise.resolve(stream);
|
||||
|
||||
return loadWasm()
|
||||
.then(function (wasmBinary) {
|
||||
var ctx = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
return ctx.audioWorklet
|
||||
.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);
|
||||
return Promise.all([loadWasm(), getContext()])
|
||||
.then(function (res) {
|
||||
var wasmBinary = res[0];
|
||||
var ctx = res[1];
|
||||
|
||||
var origTrack = audioTracks[0];
|
||||
var processedTrack = dest.stream.getAudioTracks()[0];
|
||||
var node = new AudioWorkletNode(ctx, PROCESSOR_NAME, {
|
||||
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;
|
||||
function cleanup() {
|
||||
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) {}
|
||||
}
|
||||
var origTrack = audioTracks[0];
|
||||
var processedTrack = dest.stream.getAudioTracks()[0];
|
||||
|
||||
// When EC stops the track we handed it, release the raw capture + graph.
|
||||
var rawStop = processedTrack.stop.bind(processedTrack);
|
||||
processedTrack.stop = function () {
|
||||
cleanup();
|
||||
rawStop();
|
||||
};
|
||||
// Device unplugged / capture ended involuntarily.
|
||||
origTrack.addEventListener('ended', function () {
|
||||
try {
|
||||
rawStop();
|
||||
} catch (e) {}
|
||||
cleanup();
|
||||
});
|
||||
var torndown = false;
|
||||
function cleanup() {
|
||||
if (torndown) return;
|
||||
torndown = true;
|
||||
try {
|
||||
node.port.postMessage('destroy');
|
||||
} catch (e) {}
|
||||
try {
|
||||
source.disconnect();
|
||||
node.disconnect();
|
||||
} catch (e) {}
|
||||
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.
|
||||
var out = new MediaStream();
|
||||
out.addTrack(processedTrack);
|
||||
stream.getVideoTracks().forEach(function (t) {
|
||||
out.addTrack(t);
|
||||
});
|
||||
return out;
|
||||
});
|
||||
// When EC stops the track we handed it, release the raw capture + graph.
|
||||
var rawStop = processedTrack.stop.bind(processedTrack);
|
||||
processedTrack.stop = function () {
|
||||
cleanup();
|
||||
rawStop();
|
||||
};
|
||||
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) {
|
||||
// Any failure -> fall back to the raw mic so calls never break.
|
||||
@@ -141,9 +158,13 @@
|
||||
var wantsAudio = !!(constraints && constraints.audio);
|
||||
var effective = constraints;
|
||||
if (wantsAudio) {
|
||||
// RNNoise owns noise suppression; keep AEC + AGC on the raw capture.
|
||||
var audioC = typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
||||
// RNNoise needs mono 48 kHz; it owns suppression. Keep AEC + AGC on the
|
||||
// raw capture (they run before our processing).
|
||||
var audioC =
|
||||
typeof constraints.audio === 'object' ? Object.assign({}, constraints.audio) : {};
|
||||
audioC.noiseSuppression = false;
|
||||
audioC.channelCount = 1;
|
||||
audioC.sampleRate = SAMPLE_RATE;
|
||||
if (audioC.echoCancellation === undefined) audioC.echoCancellation = true;
|
||||
if (audioC.autoGainControl === undefined) audioC.autoGainControl = true;
|
||||
effective = Object.assign({}, constraints, { audio: audioC });
|
||||
|
||||
@@ -21,8 +21,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
||||
|
||||
const CDN =
|
||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
const CDN = 'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
|
||||
// Extract all slugs from the catalog file
|
||||
const catalog = readFileSync(catalogPath, 'utf8');
|
||||
@@ -63,16 +62,13 @@ if (missing.length === 0) {
|
||||
}
|
||||
|
||||
console.log(`Found: ${found.length} Missing: ${missing.length}\n`);
|
||||
missing.forEach((r) =>
|
||||
console.log(` Removing (HTTP ${r.status}): ${r.slug}`),
|
||||
);
|
||||
missing.forEach((r) => console.log(` Removing (HTTP ${r.status}): ${r.slug}`));
|
||||
|
||||
const missingSet = new Set(missing.map((r) => r.slug));
|
||||
|
||||
// Remove individual entries for missing slugs
|
||||
let updated = catalog.replace(
|
||||
/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm,
|
||||
(match, slug) => (missingSet.has(slug) ? '' : match),
|
||||
let updated = catalog.replace(/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm, (match, slug) =>
|
||||
missingSet.has(slug) ? '' : match,
|
||||
);
|
||||
|
||||
// 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');
|
||||
|
||||
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');
|
||||
|
||||
@@ -10,7 +10,11 @@ type AvatarDecorationProps = {
|
||||
inset?: number;
|
||||
};
|
||||
|
||||
export function AvatarDecoration({ userId, children, inset = DEFAULT_INSET }: AvatarDecorationProps) {
|
||||
export function AvatarDecoration({
|
||||
userId,
|
||||
children,
|
||||
inset = DEFAULT_INSET,
|
||||
}: AvatarDecorationProps) {
|
||||
const slug = useAvatarDecoration(userId);
|
||||
|
||||
if (!slug) {
|
||||
|
||||
@@ -282,9 +282,7 @@ export function PollContent({
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: '8px',
|
||||
background: selected
|
||||
? 'var(--accent-cyan-dim)'
|
||||
: 'rgba(255,255,255,0.04)',
|
||||
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.04)',
|
||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.4,
|
||||
@@ -308,9 +306,7 @@ export function PollContent({
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected
|
||||
? 'var(--accent-cyan-dim)'
|
||||
: 'rgba(255,255,255,0.03)',
|
||||
background: selected ? 'var(--accent-cyan-dim)' : 'rgba(255,255,255,0.03)',
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
|
||||
@@ -109,9 +109,7 @@ function HalloweenOverlay({ reduced }: { reduced: boolean }) {
|
||||
height: `${size}px`,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: isOrange ? 'rgba(255,100,0,0.75)' : 'rgba(160,0,255,0.7)',
|
||||
boxShadow: isOrange
|
||||
? '0 0 8px rgba(255,100,0,0.5)'
|
||||
: '0 0 8px rgba(160,0,255,0.5)',
|
||||
boxShadow: isOrange ? '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`,
|
||||
}}
|
||||
/>
|
||||
@@ -379,8 +377,9 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
|
||||
position: 'absolute',
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
animation:
|
||||
reduced ? 'none' : `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -419,10 +418,9 @@ function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
|
||||
height: '14px',
|
||||
backgroundColor: '#ffd700',
|
||||
margin: '0 auto',
|
||||
animation:
|
||||
reduced
|
||||
? 'none'
|
||||
: `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
|
||||
animation: reduced
|
||||
? 'none'
|
||||
: `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
/>
|
||||
@@ -789,8 +787,7 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
||||
export function SeasonalEffect() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const reduced =
|
||||
typeof window !== 'undefined' &&
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
const theme = useMemo<SeasonTheme | null>(() => {
|
||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||
|
||||
@@ -1651,7 +1651,6 @@ function GenericCard({
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
|
||||
const domain = getDomain(url);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -216,8 +216,7 @@ export function MessageSearch({
|
||||
// term === undefined → no search started
|
||||
// term === '' → sender-only search (from:user with no body text)
|
||||
// term === 'foo' → normal text search
|
||||
const hasActiveSearch =
|
||||
msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
||||
const hasActiveSearch = msgSearchParams.term !== undefined || !!msgSearchParams.senders?.length;
|
||||
const senderOnlyMode = !msgSearchParams.term && !!msgSearchParams.senders?.length;
|
||||
|
||||
// Run synchronous client-side search immediately.
|
||||
@@ -534,52 +533,53 @@ export function MessageSearch({
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{localResult && (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="200">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
|
||||
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
|
||||
{!senderOnlyMode && (
|
||||
<Text size="T200" style={{ opacity: 0.55 }}>
|
||||
{`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`}
|
||||
</Text>
|
||||
)}
|
||||
{localResult &&
|
||||
(senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
|
||||
<Box direction="Column" gap="300">
|
||||
<Box direction="Column" gap="200">
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="200" src={senderOnlyMode ? Icons.User : Icons.Lock} />
|
||||
<Text size="H5">{senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'}</Text>
|
||||
{!senderOnlyMode && (
|
||||
<Text size="T200" style={{ opacity: 0.55 }}>
|
||||
{`${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>
|
||||
<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" />
|
||||
{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>
|
||||
{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 && (
|
||||
<Box
|
||||
|
||||
@@ -75,8 +75,7 @@ export const useLocalMessageSearch = () => {
|
||||
if (senderOnlyMode) continue;
|
||||
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';
|
||||
if (!isSticker && !isPoll) continue;
|
||||
}
|
||||
|
||||
@@ -90,9 +89,7 @@ export const useLocalMessageSearch = () => {
|
||||
// Sender-only mode: no text filter needed
|
||||
if (!senderOnlyMode) {
|
||||
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 formattedBody = '';
|
||||
|
||||
@@ -37,11 +37,7 @@ export function usePresenceUpdater() {
|
||||
return mx
|
||||
.setPresence({
|
||||
presence: 'unavailable',
|
||||
...(statusMsg
|
||||
? { status_msg: statusMsg }
|
||||
: status
|
||||
? { status_msg: status }
|
||||
: {}),
|
||||
...(statusMsg ? { status_msg: statusMsg } : status ? { status_msg: status } : {}),
|
||||
})
|
||||
.catch(() => undefined);
|
||||
};
|
||||
|
||||
@@ -21,14 +21,11 @@ export function useUserNotes(): {
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (evt.getType() === NOTES_KEY) {
|
||||
setNotes(evt.getContent<UserNotesContent>() ?? {});
|
||||
}
|
||||
},
|
||||
[],
|
||||
),
|
||||
useCallback((evt) => {
|
||||
if (evt.getType() === NOTES_KEY) {
|
||||
setNotes(evt.getContent<UserNotesContent>() ?? {});
|
||||
}
|
||||
}, []),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user