Fix three open bugs from LOTUS_BUGS.md
CI / Build & Quality Checks (push) Successful in 10m38s
Trigger Desktop Build / trigger (push) Failing after 5s

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-12 18:15:16 -04:00
parent 3df9c4d9e6
commit aa48c9ef8a
4 changed files with 128 additions and 75 deletions
+13 -20
View File
@@ -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.
+82 -34
View File
@@ -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 (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
@@ -871,6 +918,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
<div
key={corner}
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
onTouchStart={(ev) => handleResizeTouchStart(ev, corner)}
onClick={(ev) => ev.stopPropagation()}
style={{
position: 'absolute',
+12 -6
View File
@@ -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 (
@@ -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]),