From 4a401cf816aeae75bf8d915d2e3bde1c905c78b2 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Mon, 15 Jun 2026 20:50:00 -0400 Subject: [PATCH] fix(calls): harden ML denoise shim against static; fix lint/format 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 --- LOTUS_BUGS.md | 43 +-- LOTUS_FEATURES.md | 70 ++--- LOTUS_TODO_REFERENCE.md | 283 ++++++++++-------- build/lotus-denoise.js | 181 ++++++----- scripts/syncDecorations.mjs | 16 +- .../avatar-decoration/AvatarDecoration.tsx | 6 +- .../message/content/PollContent.tsx | 8 +- .../components/seasonal/SeasonalEffect.tsx | 19 +- .../components/url-preview/UrlPreviewCard.tsx | 1 - .../features/message-search/MessageSearch.tsx | 92 +++--- .../message-search/useLocalMessageSearch.ts | 7 +- src/app/hooks/usePresenceUpdater.ts | 6 +- src/app/hooks/useUserNotes.ts | 13 +- 13 files changed, 388 insertions(+), 357 deletions(-) diff --git a/LOTUS_BUGS.md b/LOTUS_BUGS.md index 72d0248df..876a6c00b 100644 --- a/LOTUS_BUGS.md +++ b/LOTUS_BUGS.md @@ -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 `` 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`. diff --git a/LOTUS_FEATURES.md b/LOTUS_FEATURES.md index 72acaa06b..0b8235a7c 100644 --- a/LOTUS_FEATURES.md +++ b/LOTUS_FEATURES.md @@ -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 `` 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 | diff --git a/LOTUS_TODO_REFERENCE.md b/LOTUS_TODO_REFERENCE.md index e1880d857..14771bc32 100644 --- a/LOTUS_TODO_REFERENCE.md +++ b/LOTUS_TODO_REFERENCE.md @@ -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(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(null); + ``` +- **2. Layout (src/app/features/room/Room.tsx):** + Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`. + ```tsx + { + 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 ; - 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 ; + 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 `` 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 `` 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. diff --git a/build/lotus-denoise.js b/build/lotus-denoise.js index 5ab42a4da..c61e20ebd 100644 --- a/build/lotus-denoise.js +++ b/build/lotus-denoise.js @@ -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 }); diff --git a/scripts/syncDecorations.mjs b/scripts/syncDecorations.mjs index 78009e0ea..f43e8ebde 100644 --- a/scripts/syncDecorations.mjs +++ b/scripts/syncDecorations.mjs @@ -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'); diff --git a/src/app/components/avatar-decoration/AvatarDecoration.tsx b/src/app/components/avatar-decoration/AvatarDecoration.tsx index 9f510e9ae..3f179a985 100644 --- a/src/app/components/avatar-decoration/AvatarDecoration.tsx +++ b/src/app/components/avatar-decoration/AvatarDecoration.tsx @@ -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) { diff --git a/src/app/components/message/content/PollContent.tsx b/src/app/components/message/content/PollContent.tsx index 38bf674ac..625e7e1e3 100644 --- a/src/app/components/message/content/PollContent.tsx +++ b/src/app/components/message/content/PollContent.tsx @@ -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', }} diff --git a/src/app/components/seasonal/SeasonalEffect.tsx b/src/app/components/seasonal/SeasonalEffect.tsx index d0c09f6bf..5ecd320b3 100644 --- a/src/app/components/seasonal/SeasonalEffect.tsx +++ b/src/app/components/seasonal/SeasonalEffect.tsx @@ -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`, }} >
@@ -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(() => { const override = settings.seasonalThemeOverride ?? 'auto'; diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 6aa492690..91f0bad9c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -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 ( <> diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index c87155872..aaadd219b 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -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({ )} - {localResult && (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && ( - - - - - {senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'} - {!senderOnlyMode && ( - - {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`} - - )} + {localResult && + (senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && ( + + + + + {senderOnlyMode ? 'Messages from user' : 'Encrypted Rooms'} + {!senderOnlyMode && ( + + {`${localResult.searchedRoomsCount} / ${localResult.encryptedRoomsCount} cached`} + + )} + + + {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.`} + + - - {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.`} - - + {localResult.groups.length > 0 && ( + + {localResult.groups.map((group) => { + const groupRoom = mx.getRoom(group.roomId); + if (!groupRoom) return null; + return ( + + ); + })} + + )} + - {localResult.groups.length > 0 && ( - - {localResult.groups.map((group) => { - const groupRoom = mx.getRoom(group.roomId); - if (!groupRoom) return null; - return ( - - ); - })} - - )} - - - )} + )} {error && ( { 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 = ''; diff --git a/src/app/hooks/usePresenceUpdater.ts b/src/app/hooks/usePresenceUpdater.ts index 681b0b7cb..62ff28b07 100644 --- a/src/app/hooks/usePresenceUpdater.ts +++ b/src/app/hooks/usePresenceUpdater.ts @@ -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); }; diff --git a/src/app/hooks/useUserNotes.ts b/src/app/hooks/useUserNotes.ts index 94fb75c28..866be1cf7 100644 --- a/src/app/hooks/useUserNotes.ts +++ b/src/app/hooks/useUserNotes.ts @@ -21,14 +21,11 @@ export function useUserNotes(): { useAccountDataCallback( mx, - useCallback( - (evt) => { - if (evt.getType() === NOTES_KEY) { - setNotes(evt.getContent() ?? {}); - } - }, - [], - ), + useCallback((evt) => { + if (evt.getType() === NOTES_KEY) { + setNotes(evt.getContent() ?? {}); + } + }, []), ); useEffect(() => {