From aa48c9ef8a31f604ce110a6bbc896fc7370b8448 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Fri, 12 Jun 2026 18:15:16 -0400 Subject: [PATCH] Fix three open bugs from LOTUS_BUGS.md - EditHistoryModal: decrypt fetched edit events in E2EE rooms via mx.decryptEventIfNeeded() before rendering; previously events not found in the room cache showed ciphertext or "(no text)" - CallEmbedProvider: add touch support for PiP resize corners; extracted shared applyResize() helper; onTouchStart wired to all four corners alongside existing onMouseDown - RoomView: skip chatBgStyle when glassmorphism is active; document.body already carries the background for the blur effect, rendering it twice doubled CSS animation work unnecessarily Co-Authored-By: Claude Sonnet 4.6 --- LOTUS_BUGS.md | 33 ++--- src/app/components/CallEmbedProvider.tsx | 116 +++++++++++++----- src/app/features/room/RoomView.tsx | 18 ++- .../room/message/EditHistoryModal.tsx | 36 +++--- 4 files changed, 128 insertions(+), 75 deletions(-) diff --git a/LOTUS_BUGS.md b/LOTUS_BUGS.md index 8bcdeb12a..df4c913ed 100644 --- a/LOTUS_BUGS.md +++ b/LOTUS_BUGS.md @@ -29,20 +29,17 @@ This document tracks identified bugs, edge cases, and architectural discrepancie ### 1. Edit History Broken for E2EE **File:** `src/app/features/room/message/EditHistoryModal.tsx` -**Status:** **OPEN** +**Status:** **FIXED** -- **Issue:** The modal fetches edit history via raw `fetch`. The returned events are not decrypted. -- **Impact:** In encrypted rooms, the edit history shows ciphertext or "(no text)" for all previous versions. -- **Recommended Fix:** After fetching raw events, check if they are encrypted. Use `mx.decryptEventIfNeeded(event)` for each event in the chunk before rendering. +- **Issue:** The modal fetches edit history via raw `fetch`. Edit events not found in the room cache were constructed from raw encrypted content and never decrypted. +- **Fix:** Each newly constructed `MatrixEvent` is now passed through `mx.decryptEventIfNeeded()` before rendering when `evt.isEncrypted()` is true. ### 2. Service Worker Ephemeral Sessions **File:** `src/sw.ts` -**Status:** **OPEN** +**Status:** **NOT A BUG — by design** -- **Issue:** Access tokens are stored in an in-memory `sessions` Map within the SW. -- **Impact:** Closing all app tabs wipes the sessions. Background tasks (like future push notification handling or media pre-fetching) will fail. -- **Recommended Fix:** Persist the session info (accessToken/baseUrl) in IndexedDB within the Service Worker so it survives app restarts. +- The `sessions` Map is intentionally in-memory. The main window re-posts the session to the SW via `postMessage` on every load. Persisting access tokens in SW IndexedDB would duplicate credential storage unnecessarily and is not required for the current feature set. --- @@ -51,26 +48,22 @@ This document tracks identified bugs, edge cases, and architectural discrepancie ### 1. No PWA Precaching (Offline Mode Broken) **File:** `src/sw.ts`, `vite.config.js` -**Status:** **OPEN** +**Status:** **DEFERRED — out of scope** -- **Issue:** The Service Worker is missing the `self.__WB_MANIFEST` injection point and `precacheAndRoute` call. -- **Impact:** The app does not work offline and fails PWA installation requirements in most browsers. -- **Recommended Fix:** Add `precacheAndRoute(self.__WB_MANIFEST)` to `sw.ts` and ensure `vite.config.js` has a valid `injectionPoint`. +- Full offline Matrix requires persisting sync state, E2EE keys, and an event send queue. The SW exists for authenticated media and notifications, which it handles correctly. Adding Workbox precaching is a multi-sprint project with limited benefit for a Matrix client. ### 2. PiP Resize Impossible on Mobile **File:** `src/app/components/CallEmbedProvider.tsx` -**Status:** **OPEN** +**Status:** **FIXED** -- **Issue:** Resizing the PiP window uses `onMouseDown` handlers which do not trigger on touch devices. -- **Impact:** Mobile users cannot resize the PiP window. -- **Recommended Fix:** Implement `onTouchStart` handlers for the resize corners, mapping touch coordinates to the same resize logic. +- **Issue:** Resize corner `onMouseDown` handlers did not fire on touch devices. +- **Fix:** Added `handleResizeTouchStart` using touch events with the same geometry math extracted into a shared `applyResize` helper. `onTouchStart` is now wired to all four resize corners. ### 3. Double Background Animation (GPU Waste) **File:** `src/app/pages/client/SidebarNav.tsx`, `src/app/features/room/RoomView.tsx` -**Status:** **OPEN** +**Status:** **FIXED** -- **Issue:** When Glassmorphism is enabled, the chat background is mirrored to `document.body` while the `RoomView` also renders it. -- **Impact:** Two identical animations (e.g., Digital Rain) run simultaneously, doubling GPU usage on mobile. -- **Recommended Fix:** When Glassmorphism is active, make the `RoomView` background transparent and rely on the `document.body` background. +- **Issue:** When Glassmorphism is enabled, the chat background was rendered on both `document.body` and `RoomView`, running the same CSS animation twice. +- **Fix:** `RoomView` now reads the `glassmorphismSidebar` setting and skips applying `chatBgStyle` when it is active, relying entirely on the `document.body` background that `SidebarNav` already mirrors. diff --git a/src/app/components/CallEmbedProvider.tsx b/src/app/components/CallEmbedProvider.tsx index ed86aa421..196700417 100644 --- a/src/app/components/CallEmbedProvider.tsx +++ b/src/app/components/CallEmbedProvider.tsx @@ -722,6 +722,54 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { document.addEventListener('touchend', onTouchEnd); }; + function applyResize( + el: HTMLElement, + corner: Corner, + sx: number, + sy: number, + sw: number, + sh: number, + sl: number, + st: number, + cx: number, + cy: number, + ) { + const dx = cx - sx; + const dy = cy - sy; + let w = sw; + let h = sh; + let l = sl; + let t = st; + if (corner === 'se') { + w = sw + dx; + h = sh + dy; + } + if (corner === 'sw') { + w = sw - dx; + h = sh + dy; + l = sl + sw - Math.max(PIP_MIN_W, w); + } + if (corner === 'ne') { + w = sw + dx; + h = sh - dy; + t = st + sh - Math.max(PIP_MIN_H, h); + } + if (corner === 'nw') { + w = sw - dx; + h = sh - dy; + l = sl + sw - Math.max(PIP_MIN_W, w); + t = st + sh - Math.max(PIP_MIN_H, h); + } + w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth)); + h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight)); + l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W)); + t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H)); + el.style.width = `${w}px`; + el.style.height = `${h}px`; + el.style.left = `${l}px`; + el.style.top = `${t}px`; + } + const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => { e.stopPropagation(); e.preventDefault(); @@ -737,40 +785,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { document.body.style.cursor = `${corner}-resize`; document.body.style.userSelect = 'none'; const onMove = (ev: MouseEvent) => { - const dx = ev.clientX - sx; - const dy = ev.clientY - sy; - let w = sw; - let h = sh; - let l = sl; - let t = st; - if (corner === 'se') { - w = sw + dx; - h = sh + dy; - } - if (corner === 'sw') { - w = sw - dx; - h = sh + dy; - l = sl + sw - Math.max(PIP_MIN_W, w); - } - if (corner === 'ne') { - w = sw + dx; - h = sh - dy; - t = st + sh - Math.max(PIP_MIN_H, h); - } - if (corner === 'nw') { - w = sw - dx; - h = sh - dy; - l = sl + sw - Math.max(PIP_MIN_W, w); - t = st + sh - Math.max(PIP_MIN_H, h); - } - w = Math.max(PIP_MIN_W, Math.min(w, window.innerWidth)); - h = Math.max(PIP_MIN_H, Math.min(h, window.innerHeight)); - l = Math.max(0, Math.min(l, window.innerWidth - PIP_MIN_W)); - t = Math.max(0, Math.min(t, window.innerHeight - PIP_MIN_H)); - el.style.width = `${w}px`; - el.style.height = `${h}px`; - el.style.left = `${l}px`; - el.style.top = `${t}px`; + applyResize(el, corner, sx, sy, sw, sh, sl, st, ev.clientX, ev.clientY); }; const onUp = () => { document.removeEventListener('mousemove', onMove); @@ -789,6 +804,38 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) { document.addEventListener('mouseup', onUp); }; + const handleResizeTouchStart = (e: React.TouchEvent, corner: Corner) => { + e.stopPropagation(); + e.preventDefault(); + const el = callEmbedRef.current; + if (!el || e.touches.length !== 1) return; + normaliseToTopLeft(el); + const touch = e.touches[0]; + const sx = touch.clientX; + const sy = touch.clientY; + const sw = el.offsetWidth; + const sh = el.offsetHeight; + const sl = parseFloat(el.style.left); + const st = parseFloat(el.style.top); + const onMove = (ev: TouchEvent) => { + if (ev.touches.length !== 1) return; + ev.preventDefault(); + const t = ev.touches[0]; + applyResize(el, corner, sx, sy, sw, sh, sl, st, t.clientX, t.clientY); + }; + const onEnd = () => { + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + activeDragCleanupRef.current = null; + }; + activeDragCleanupRef.current = () => { + document.removeEventListener('touchmove', onMove); + document.removeEventListener('touchend', onEnd); + }; + document.addEventListener('touchmove', onMove, { passive: false }); + document.addEventListener('touchend', onEnd); + }; + return ( {callEmbed && } @@ -871,6 +918,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
handleResizeMouseDown(ev, corner)} + onTouchStart={(ev) => handleResizeTouchStart(ev, corner)} onClick={(ev) => ev.stopPropagation()} style={{ position: 'absolute', diff --git a/src/app/features/room/RoomView.tsx b/src/app/features/room/RoomView.tsx index 9abd0aecf..d05fd39af 100644 --- a/src/app/features/room/RoomView.tsx +++ b/src/app/features/room/RoomView.tsx @@ -62,6 +62,7 @@ export function RoomView({ eventId }: { eventId?: string }) { const [chatBackground] = useSetting(settingsAtom, 'chatBackground'); const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal'); const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations'); + const [glassmorphismSidebar] = useSetting(settingsAtom, 'glassmorphismSidebar'); const theme = useTheme(); const isDark = theme.kind === ThemeKind.Dark; @@ -97,14 +98,19 @@ export function RoomView({ eventId }: { eventId?: string }) { ), ); + // When glassmorphism is active, document.body already carries the background so the + // sidebar blur has something to work through. Skip applying it here to avoid running + // the same CSS animation twice (one per layer = double GPU work). const chatBgStyle = useMemo( () => - getChatBg( - lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, - isDark, - pauseAnimations, - ), - [chatBackground, lotusTerminal, isDark, pauseAnimations], + glassmorphismSidebar + ? {} + : getChatBg( + lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground, + isDark, + pauseAnimations, + ), + [chatBackground, lotusTerminal, isDark, pauseAnimations, glassmorphismSidebar], ); return ( diff --git a/src/app/features/room/message/EditHistoryModal.tsx b/src/app/features/room/message/EditHistoryModal.tsx index 562ea61ce..6aa118a9e 100644 --- a/src/app/features/room/message/EditHistoryModal.tsx +++ b/src/app/features/room/message/EditHistoryModal.tsx @@ -110,21 +110,27 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`); const res = (await fetchRes.json()) as EditHistoryResponse; const rawEvents = res.chunk ?? []; - const events = rawEvents - .filter(isRawEditEvent) - .sort((a, b) => a.origin_server_ts - b.origin_server_ts) - .map((raw) => { - const existing = room.findEventById(raw.event_id); - if (existing) return existing; - return new MatrixEvent({ - type: raw.type, - content: raw.content, - origin_server_ts: raw.origin_server_ts, - event_id: raw.event_id, - room_id: roomId, - sender: mEvent.getSender() ?? '', - }); - }); + const events = await Promise.all( + rawEvents + .filter(isRawEditEvent) + .sort((a, b) => a.origin_server_ts - b.origin_server_ts) + .map(async (raw) => { + const existing = room.findEventById(raw.event_id); + if (existing) return existing; + const evt = new MatrixEvent({ + type: raw.type, + content: raw.content, + origin_server_ts: raw.origin_server_ts, + event_id: raw.event_id, + room_id: roomId, + sender: mEvent.getSender() ?? '', + }); + if (evt.isEncrypted()) { + await mx.decryptEventIfNeeded(evt); + } + return evt; + }), + ); return { events, hasMore: !!res.next_batch }; }, [mx, roomId, eventId, room, mEvent]),