diff --git a/LOTUS_TODO.md b/LOTUS_TODO.md index 05127a8c1..484503b71 100644 --- a/LOTUS_TODO.md +++ b/LOTUS_TODO.md @@ -70,6 +70,32 @@ Bug-hunt of the Tier-1 high-risk areas (notifications/unread/receipts, threads, --- +## πŸ”Ž Audit findings β€” Wave 2 (2026-07) + +Tier-2 bug-hunt (desktop/native, crypto/session/infra, messaging data) by 3 parallel agents. `[D#]`=desktop/native, `[F#]`=crypto/session/infra, `[M#]`=messaging. + +**βœ… FIXED (2026-07):** + +- **πŸ”΄/security:** F1 (search-cache DECRYPTED plaintext never wiped on server-forced logout β†’ now `deleteSearchCacheDatabase()` on that path); D1 (Linux no-sleep was totally broken β€” zbus inhibit bound to a dropped connection; now a long-lived connection in managed state); M1/M2 (bookmarks + user-notes account-data **lost-update** data-loss β†’ serialized via `latestRef`+write-queue like `useReminders`). +- **🟠:** M3 (reminder cross-instance race β†’ hoisted the queue to module scope); M4 (image compression flattened transparent PNGs to black + stripped EXIF orientation β†’ skip PNG, `createImageBitmap` orientation, `.jpg` rename); M6 (export "all" had unbounded pagination/OOM β†’ 200-page cap + Cancel button + incremental `oldestTs`); D2 (desktop taskbar/Unity badge double-counted spaces β€” same as favicon N1 β†’ leaf-only sum); D3 (tray DND desynced from `manualDndAtom` after reload β†’ `get_tray_dnd` re-hydrate); F4 (search-cache delete falsely reported success while `onblocked` β†’ wait for real delete, 3s cap). +- **🟑:** M5 (MediaGallery lightbox opened the wrong item β€” index drift; shared `getThumbMxc` guard); M8 (audio playback-rate reset on async decrypt β†’ re-apply on loadedmetadata/play); D5 (updater never relaunched β†’ `app.restart()` + terminal UI state). + +**⚠️ FLAGGED β€” product decision (not auto-changed):** + +- **F2:** URL previews **default ON in encrypted rooms** (`settings.ts encUrlPreview: true`) β†’ the homeserver fetches every link in an E2EE message (leaks E2EE link URLs to the server). This is the deliberate Lotus "URL Preview Default in Encrypted Rooms" feature β€” most clients default it OFF for privacy. **Your call whether to flip the default to `false`.** + +**Won't-fix / by-design:** M7 (scheduledMessages clamps a past target to 1s β€” intentional + unit-tested; the modal already guards β‰₯60s). + +**Still open (low tail / follow-ups):** + +- **D4** cold-start deep link may navigate twice (idempotent; guard the argv path). **D6** WinRT rich-toast AUMID never registered β†’ P5-41 quick-reply / P5-35 click-to-open are inert on Windows (falls back to plain toast) β€” a wiring task. **D7** Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename (runtime-verify on the `.deb`/AppImage). +- **F3** session blob unconditionally wins over legacy keys even if legacy is fresher (downgrade-then-upgrade β†’ stale token β†’ forced re-login); **F5** OIDC refresh drops `expiresAt`/id-token claims on persist; **F6** server-forced logout leaves a stale token in the SW + skips issuer revocation (token already revoked server-side β€” minor). +- **Nit:** ForwardMessageDialog doesn't strip `m.mentions` β†’ forwarding can re-ping. + +**Verified sound (spot-checks):** media-auth token only in the `Authorization` header (never a URL); `removeFallbackSession` clears all credential keys; session cross-tab sync; the opt-in search gate; `cryptoCallbacks`; SW precache (no stale SPA shell); Windows `SetThreadExecutionState` main-thread clear; native IPC surface matches end-to-end; GDI/COM/jumplist/thumbbar resource hygiene; `useReminders` serialization template; forward multi-select index alignment; KaTeX (`trust:false`, no XSS); `mathParse`; `searchCache` merge/coverage; ScheduleMessageModal local-tz + β‰₯60s guard; polls 2–10 bounds; edit-history pagination; `useLocalTime` DST. + +--- + ## βœ… Done β€” Awaiting Verification Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Open bugs + the verification backlog now live in this file and LOTUS_TESTING.md.) diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 4aebd3ef3..71c6aca4a 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -99,9 +99,21 @@ export function AudioContent({ const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1); useEffect(() => { - if (audioRef.current) { - audioRef.current.playbackRate = playbackSpeed; - } + const audio = audioRef.current; + if (!audio) return undefined; + const applyRate = () => { + audio.playbackRate = playbackSpeed; + }; + // Apply immediately, and re-apply whenever the media element (re)loads a new + // source β€” e.g. after async decrypt swaps in the blob URL β€” since the browser + // resets playbackRate to 1 on load, discarding the user's speed choice. + applyRate(); + audio.addEventListener('loadedmetadata', applyRate); + audio.addEventListener('play', applyRate); + return () => { + audio.removeEventListener('loadedmetadata', applyRate); + audio.removeEventListener('play', applyRate); + }; }, [playbackSpeed]); const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2]; diff --git a/src/app/features/room-settings/ExportRoomHistory.tsx b/src/app/features/room-settings/ExportRoomHistory.tsx index 5687d9aae..83596c6ee 100644 --- a/src/app/features/room-settings/ExportRoomHistory.tsx +++ b/src/app/features/room-settings/ExportRoomHistory.tsx @@ -1,5 +1,5 @@ -import React, { useCallback, useState } from 'react'; -import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds'; import { EventType } from 'matrix-js-sdk'; import { Page, PageContent, PageHeader } from '../../components/page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -16,6 +16,12 @@ const FORMAT_LABELS: Record = { html: 'HTML', }; +const PAGE_LIMIT = 100; +// Hard cap on back-pagination requests. Without a fromDate, "export all" would +// otherwise decrypt and hold every message in the room, hammering the server and +// risking an OOM/freeze with no way to stop. 200 pages Γ— 100 β‰ˆ 20,000 events. +const MAX_EXPORT_PAGES = 200; + type ExportRoomHistoryProps = { requestClose: () => void; }; @@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) { const [toDate, setToDate] = useState(''); const [exporting, setExporting] = useState(false); const [exportCount, setExportCount] = useState(0); + const [notice, setNotice] = useState(''); + const cancelledRef = useRef(false); + + const handleCancel = useCallback(() => { + cancelledRef.current = true; + }, []); + + // Stop an in-flight export if the panel unmounts (closing settings mid-export + // would otherwise keep paginating + decrypting in the background). + useEffect( + () => () => { + cancelledRef.current = true; + }, + [], + ); const handleExport = useCallback(async () => { if (exporting) return; + cancelledRef.current = false; setExporting(true); setExportCount(0); + setNotice(''); try { const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null; @@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) { const seen = new Set(); const timeline = room.getLiveTimeline(); let canLoadMore = true; + // Track the oldest collected timestamp incrementally so the fromTs check + // doesn't rescan the whole `collected` array on every pagination step. + let oldestTs = Number.POSITIVE_INFINITY; + // Oldest RAW message ts paginated (tracked BEFORE the fromTs filter). The + // date-range early-break must use this β€” oldestTs only ever holds collected + // events (all >= fromTs), so it can never fall below fromTs and the export + // would over-paginate to the page cap and show a misleading "truncated". + let oldestRawTs = Number.POSITIVE_INFINITY; const addEvents = async (events: ReturnType) => { for (const ev of events) { @@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) { if (ev.getType() !== EventType.RoomMessage) continue; if (ev.isDecryptionFailure()) continue; const ts = ev.getTs(); + if (ts < oldestRawTs) oldestRawTs = ts; if (fromTs !== null && ts < fromTs) continue; if (toTs !== null && ts > toTs) continue; const content = ev.getContent(); const body: string = content.body ?? ''; const msgtype: string = content.msgtype ?? ''; if (!body) continue; + if (ts < oldestTs) oldestTs = ts; collected.push({ ts, sender: ev.getSender() ?? '', @@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) { await addEvents(timeline.getEvents()); - // Paginate backwards until start or date range exceeded + // Paginate backwards until start, date range exceeded, cap hit, or cancel + let pageCount = 0; + let truncated = false; + let cancelled = false; while (canLoadMore) { - // If we have a fromTs, check whether the oldest collected event is already - // before it β€” if so we don't need to paginate further. - if (fromTs !== null && collected.length > 0) { - const oldestTs = Math.min(...collected.map((r) => r.ts)); - if (oldestTs < fromTs) break; + if (cancelledRef.current) { + cancelled = true; + break; } + // If we've paginated back past the fromTs boundary, there's nothing more + // in range to fetch (use the raw paginated ts, not the collected one). + if (fromTs !== null && oldestRawTs < fromTs) break; + // Hard cap so "export all" can't run away and OOM the tab. + if (pageCount >= MAX_EXPORT_PAGES) { + truncated = true; + break; + } + pageCount += 1; // eslint-disable-next-line no-await-in-loop canLoadMore = await mx.paginateEventTimeline(timeline, { backwards: true, - limit: 100, + limit: PAGE_LIMIT, }); // eslint-disable-next-line no-await-in-loop await addEvents(timeline.getEvents()); } + if (cancelled) { + setNotice(`Export cancelled after ${collected.length} messages.`); + return; + } + // Sort chronologically (oldest first) collected.sort((a, b) => a.ts - b.ts); @@ -191,6 +239,12 @@ ${msgRows} a.download = `export-${safeRoomName}-${dateStr}.${ext}`; a.click(); URL.revokeObjectURL(url); + + if (truncated) { + setNotice( + `Export truncated to ${collected.length} messages (reached the ${MAX_EXPORT_PAGES}-page limit). Narrow the date range to export older history.`, + ); + } } finally { setExporting(false); } @@ -297,24 +351,35 @@ ${msgRows} ? `Exporting… ${exportCount} messages` : 'Export will download automatically.'} - + {exporting ? ( + + ) : ( + + )} + {notice && ( + + {notice} + + )} diff --git a/src/app/features/room/MediaGallery.tsx b/src/app/features/room/MediaGallery.tsx index ff213cc7e..d5890e01f 100644 --- a/src/app/features/room/MediaGallery.tsx +++ b/src/app/features/room/MediaGallery.tsx @@ -133,6 +133,18 @@ function getSenderName(room: Room, userId: string): string { return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId; } +// Resolve the thumbnail/display MXC for an image/video event, mirroring the +// grid's preference order (encrypted thumb > file > thumbnail_url > url). Both +// the grid and the lightbox must use this so their positional indices stay in +// lockstep β€” otherwise a tile skipped for lack of a thumb would shift the +// lightbox and open the wrong media. +function getThumbMxc(mEvent: MatrixEvent): string | undefined { + const c = mEvent.getContent(); + const isEnc = !!c.file; + const info: (IImageInfo & IThumbnailContent) | undefined = c.info; + return isEnc ? (info?.thumbnail_file?.url ?? c.file?.url) : (info?.thumbnail_url ?? c.url); +} + // ── Lightbox ────────────────────────────────────────────────────────────────── type LightboxItem = { @@ -585,7 +597,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { const lightboxItems: LightboxItem[] = events .filter((ev) => { const c = ev.getContent(); - return c.msgtype === MsgType.Image || c.msgtype === MsgType.Video; + if (c.msgtype !== MsgType.Image && c.msgtype !== MsgType.Video) return false; + // Match the grid's guard exactly: tiles without a thumb are not rendered, + // so they must not occupy a lightbox slot either. + return !!getThumbMxc(ev); }) .map((ev) => { const c = ev.getContent(); @@ -712,9 +727,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) { const info: (IImageInfo & IThumbnailContent) | undefined = c.info; // Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url - const thumbMxc: string | undefined = isEnc - ? (info?.thumbnail_file?.url ?? c.file?.url) - : (info?.thumbnail_url ?? c.url); + const thumbMxc: string | undefined = getThumbMxc(mEvent); const thumbEnc: IEncryptedFile | undefined = isEnc ? (info?.thumbnail_file ?? c.file) : undefined; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 5d5fbae7a..a487a8787 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -456,12 +456,16 @@ export const RoomInput = forwardRef( if (compressionResult) { const originalFile = fileItem.originalFile as File; - const compressedFile = new File([compressionResult.blob], originalFile.name, { - type: 'image/jpeg', + // compressImage re-encodes as JPEG; swap the extension so the file + // name and MIME type agree (avoids e.g. a JPEG named "photo.png"). + const compressedType = compressionResult.type; + const compressedName = `${originalFile.name.replace(/\.[^./\\]+$/, '')}.jpg`; + const compressedFile = new File([compressionResult.blob], compressedName, { + type: compressedType, }); const uploadRes = await mx.uploadContent(compressedFile, { - name: originalFile.name, - type: 'image/jpeg', + name: compressedName, + type: compressedType, }); const compressedMxc = (uploadRes as { content_uri: string }).content_uri; if (compressedMxc) { diff --git a/src/app/hooks/useBookmarks.ts b/src/app/hooks/useBookmarks.ts index 9657de1f2..a1911f228 100644 --- a/src/app/hooks/useBookmarks.ts +++ b/src/app/hooks/useBookmarks.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; +import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; -import { useAccountDataCallback } from './useAccountDataCallback'; export type Bookmark = { roomId: string; @@ -25,6 +24,75 @@ function readBookmarks(mx: MatrixClient): Bookmark[] { ); } +// Module-scoped serialization state. +// +// useBookmarks() is mounted once per message row (dozens of live instances), so +// a per-instance latest/queue would only serialize writes within a single row β€” +// bookmarking message A then message B from different rows (before the server +// echo lands) would let B compute from a stale snapshot and clobber A +// (setAccountData replaces the whole content, no server merge). We therefore +// keep a single shared latest ref + write queue, keyed off the active client. +type BookmarksModuleState = { + mx: MatrixClient; + latest: Bookmark[]; + writeQueue: Promise; + listeners: Set<(list: Bookmark[]) => void>; + onAccountData: ClientEventHandlerMap[ClientEvent.AccountData]; +}; + +let moduleState: BookmarksModuleState | null = null; + +// Lazily initialize the shared state for the given client. On a client change +// (login/logout swaps the MatrixClient) we tear down the old subscription and +// re-initialize against the new client so we never leak or double-subscribe. +function ensureModuleState(mx: MatrixClient): BookmarksModuleState { + if (moduleState && moduleState.mx === mx) { + return moduleState; + } + + if (moduleState) { + moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData); + } + + const state: BookmarksModuleState = { + mx, + latest: readBookmarks(mx), + writeQueue: Promise.resolve(), + listeners: new Set(), + // Reassigned below once `state` is captured. + onAccountData: () => undefined, + }; + + state.onAccountData = (evt) => { + if (evt.getType() === BOOKMARKS_KEY) { + const list = evt.getContent()?.bookmarks ?? []; + state.latest = list; + state.listeners.forEach((listener) => listener(list)); + } + }; + + mx.on(ClientEvent.AccountData, state.onAccountData); + moduleState = state; + return state; +} + +function enqueueBookmarkWrite( + mx: MatrixClient, + compute: (current: Bookmark[]) => Bookmark[], +): Promise { + const state = ensureModuleState(mx); + const run = state.writeQueue.then(async () => { + const next = compute(state.latest); + state.latest = next; + state.listeners.forEach((listener) => listener(next)); + await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next }); + }); + // Keep the chain alive even if one write rejects, but propagate the + // rejection to this caller so it can react (e.g. retry). + state.writeQueue = run.catch(() => undefined); + return run; +} + export function useBookmarks(): { bookmarks: Bookmark[]; addBookmark: (b: Bookmark) => Promise; @@ -32,45 +100,37 @@ export function useBookmarks(): { isBookmarked: (eventId: string) => boolean; } { const mx = useMatrixClient(); - const [bookmarks, setBookmarks] = useState(() => readBookmarks(mx)); + const [bookmarks, setBookmarks] = useState(() => ensureModuleState(mx).latest); - useAccountDataCallback( - mx, - useCallback( - (evt) => { - if (evt.getType() === BOOKMARKS_KEY) { - setBookmarks(evt.getContent()?.bookmarks ?? []); - } - }, - [setBookmarks], - ), - ); - - // Re-read on mx change + // Subscribe to the shared module state. A single AccountData listener is + // installed per client (in ensureModuleState); each hook instance only + // registers a local setter and unregisters it on unmount / client change. useEffect(() => { - setBookmarks(readBookmarks(mx)); + const state = ensureModuleState(mx); + setBookmarks(state.latest); + state.listeners.add(setBookmarks); + return () => { + state.listeners.delete(setBookmarks); + }; }, [mx]); const addBookmark = useCallback( - async (b: Bookmark) => { - const current = readBookmarks(mx); - // Avoid duplicates - const filtered = current.filter((bk) => bk.eventId !== b.eventId); - let next = [b, ...filtered]; - if (next.length > MAX_BOOKMARKS) { - next = next.slice(0, MAX_BOOKMARKS); - } - await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next }); - }, + (b: Bookmark) => + enqueueBookmarkWrite(mx, (current) => { + // Avoid duplicates + const filtered = current.filter((bk) => bk.eventId !== b.eventId); + let next = [b, ...filtered]; + if (next.length > MAX_BOOKMARKS) { + next = next.slice(0, MAX_BOOKMARKS); + } + return next; + }), [mx], ); const removeBookmark = useCallback( - async (eventId: string) => { - const current = readBookmarks(mx); - const next = current.filter((bk) => bk.eventId !== eventId); - await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next }); - }, + (eventId: string) => + enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)), [mx], ); diff --git a/src/app/hooks/useReminders.ts b/src/app/hooks/useReminders.ts index d26b6f7d4..35b79cce8 100644 --- a/src/app/hooks/useReminders.ts +++ b/src/app/hooks/useReminders.ts @@ -1,7 +1,6 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; +import { useCallback, useEffect, useState } from 'react'; +import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; -import { useAccountDataCallback } from './useAccountDataCallback'; export type Reminder = { roomId: string; @@ -23,6 +22,75 @@ function readReminders(mx: MatrixClient): Reminder[] { ); } +// Module-scoped serialization state. +// +// The latest snapshot and the write queue must be shared across every hook +// instance: ReminderMonitor (auto-removes fired reminders) and RemindMeDialog +// (adds reminders) mount separate hooks, and a per-instance queue would let a +// remove and an add race across instances and clobber each other (setAccountData +// replaces the whole content, no server merge). We therefore keep a single +// shared queue + latest ref, keyed off the active MatrixClient. +type ReminderModuleState = { + mx: MatrixClient; + latest: Reminder[]; + writeQueue: Promise; + listeners: Set<(list: Reminder[]) => void>; + onAccountData: ClientEventHandlerMap[ClientEvent.AccountData]; +}; + +let moduleState: ReminderModuleState | null = null; + +// Lazily initialize the shared state for the given client. On a client change +// (login/logout swaps the MatrixClient) we tear down the old subscription and +// re-initialize against the new client so we never leak or double-subscribe. +function ensureModuleState(mx: MatrixClient): ReminderModuleState { + if (moduleState && moduleState.mx === mx) { + return moduleState; + } + + if (moduleState) { + moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData); + } + + const state: ReminderModuleState = { + mx, + latest: readReminders(mx), + writeQueue: Promise.resolve(), + listeners: new Set(), + // Reassigned below once `state` is captured. + onAccountData: () => undefined, + }; + + state.onAccountData = (evt) => { + if (evt.getType() === REMINDERS_KEY) { + const list = evt.getContent()?.reminders ?? []; + state.latest = list; + state.listeners.forEach((listener) => listener(list)); + } + }; + + mx.on(ClientEvent.AccountData, state.onAccountData); + moduleState = state; + return state; +} + +function enqueueReminderWrite( + mx: MatrixClient, + compute: (current: Reminder[]) => Reminder[], +): Promise { + const state = ensureModuleState(mx); + const run = state.writeQueue.then(async () => { + const next = compute(state.latest); + state.latest = next; + state.listeners.forEach((listener) => listener(next)); + await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next }); + }); + // Keep the chain alive even if one write rejects, but propagate the + // rejection to this caller so it can react (e.g. retry). + state.writeQueue = run.catch(() => undefined); + return run; +} + export function useReminders(): { reminders: Reminder[]; addReminder: (r: Reminder) => Promise; @@ -30,69 +98,34 @@ export function useReminders(): { getReminders: () => Reminder[]; } { const mx = useMatrixClient(); - const [reminders, setReminders] = useState(() => readReminders(mx)); + const [reminders, setReminders] = useState(() => ensureModuleState(mx).latest); - // Authoritative local snapshot used to compute mutations. Reading - // mx.getAccountData() per-mutation is racy: two quick add/remove calls both - // read the same stale baseline and the second write clobbers the first - // (N113). We instead mutate from this ref, kept in sync with server echoes. - const latestRef = useRef(reminders); - // Serialize writes so overlapping setAccountData calls can't land out of - // order on the server (last-write-wins would otherwise drop data). - const writeQueueRef = useRef>(Promise.resolve()); - - const applyServerState = useCallback((list: Reminder[]) => { - latestRef.current = list; - setReminders(list); - }, []); - - useAccountDataCallback( - mx, - useCallback( - (evt) => { - if (evt.getType() === REMINDERS_KEY) { - applyServerState(evt.getContent()?.reminders ?? []); - } - }, - [applyServerState], - ), - ); - - // Re-read on mx change + // Subscribe to the shared module state. A single AccountData listener is + // installed per client (in ensureModuleState); each hook instance only + // registers a local setter and unregisters it on unmount / client change. useEffect(() => { - applyServerState(readReminders(mx)); - }, [mx, applyServerState]); - - const enqueueWrite = useCallback( - (compute: (current: Reminder[]) => Reminder[]): Promise => { - const run = writeQueueRef.current.then(async () => { - const next = compute(latestRef.current); - latestRef.current = next; - setReminders(next); - await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next }); - }); - // Keep the chain alive even if one write rejects, but propagate the - // rejection to this caller so it can react (e.g. retry). - writeQueueRef.current = run.catch(() => undefined); - return run; - }, - [mx], - ); + const state = ensureModuleState(mx); + setReminders(state.latest); + state.listeners.add(setReminders); + return () => { + state.listeners.delete(setReminders); + }; + }, [mx]); const addReminder = useCallback( - (r: Reminder) => enqueueWrite((current) => [...current, r]), - [enqueueWrite], + (r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]), + [mx], ); const removeReminder = useCallback( (eventId: string, timestamp: number) => - enqueueWrite((current) => + enqueueReminderWrite(mx, (current) => current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)), ), - [enqueueWrite], + [mx], ); - const getReminders = useCallback(() => reminders, [reminders]); + const getReminders = useCallback(() => ensureModuleState(mx).latest, [mx]); return { reminders, addReminder, removeReminder, getReminders }; } diff --git a/src/app/hooks/useTauriDnd.ts b/src/app/hooks/useTauriDnd.ts index 0fb3dda36..aac51a285 100644 --- a/src/app/hooks/useTauriDnd.ts +++ b/src/app/hooks/useTauriDnd.ts @@ -1,6 +1,7 @@ +import { useEffect } from 'react'; import { useSetAtom } from 'jotai'; import { manualDndAtom } from '../state/manualDnd'; -import { useTauriEvent } from './useTauri'; +import { tauriInvoke, useTauriEvent } from './useTauri'; /** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */ type DndChangedDetail = { @@ -18,4 +19,17 @@ export function useTauriDnd(): void { const setDnd = useSetAtom(manualDndAtom); useTauriEvent('lotus-dnd-changed', ({ active }) => setDnd(active)); + + // Re-hydrate on mount. The tray CheckMenuItem persists its checkstate, but + // `manualDndAtom` is in-memory and resets to false on every reload (the + // custom-chrome toggle, logout). Without this the tray could show DND ON while + // notifications resume firing. Query the native tray state (`get_tray_dnd`) so + // they stay in sync. No-op in the browser. + useEffect(() => { + tauriInvoke()?.('get_tray_dnd') + .then((active) => { + if (typeof active === 'boolean') setDnd(active); + }) + .catch(() => undefined); + }, [setDnd]); } diff --git a/src/app/hooks/useTauriNotificationBadge.ts b/src/app/hooks/useTauriNotificationBadge.ts index 927b3fe43..f1bfbc1de 100644 --- a/src/app/hooks/useTauriNotificationBadge.ts +++ b/src/app/hooks/useTauriNotificationBadge.ts @@ -18,6 +18,10 @@ export function useTauriNotificationBadge() { let totalHighlights = 0; roomToUnread.forEach((unread) => { + // Sum only leaf rooms (from === null); roomToUnread also holds per-ancestor + // space aggregates (from = Set), so counting all entries double-counts a + // space-nested room. Mirrors the favicon fix in ClientNonUIFeatures. + if (unread.from !== null) return; totalHighlights += unread.highlight; }); diff --git a/src/app/hooks/useTauriUpdater.ts b/src/app/hooks/useTauriUpdater.ts index 0f649bf2f..051a40d6a 100644 --- a/src/app/hooks/useTauriUpdater.ts +++ b/src/app/hooks/useTauriUpdater.ts @@ -38,6 +38,10 @@ export function useTauriUpdater() { setStatus({ state: 'installing' }); try { await invoke('install_update'); + // On a successful install the native side calls app.restart(), so this + // resolve is only reached when nothing was installed (no update found) β€” + // don't leave the UI stuck on "installing". + setStatus({ state: 'up-to-date' }); } catch (e) { setStatus({ state: 'error', message: String(e) }); } diff --git a/src/app/hooks/useUserNotes.ts b/src/app/hooks/useUserNotes.ts index 866be1cf7..808337298 100644 --- a/src/app/hooks/useUserNotes.ts +++ b/src/app/hooks/useUserNotes.ts @@ -1,7 +1,6 @@ import { useCallback, useEffect, useState } from 'react'; -import { MatrixClient } from 'matrix-js-sdk'; +import { ClientEvent, ClientEventHandlerMap, MatrixClient } from 'matrix-js-sdk'; import { useMatrixClient } from './useMatrixClient'; -import { useAccountDataCallback } from './useAccountDataCallback'; const NOTES_KEY = 'io.lotus.user_notes'; export const USER_NOTE_MAX_LENGTH = 500; @@ -12,39 +11,108 @@ function readNotes(mx: MatrixClient): UserNotesContent { return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {}; } +// Module-scoped serialization state. +// +// useUserNotes() can be mounted by many components at once, so a per-instance +// latest/queue would only serialize writes within one instance. Notes for +// different users saved from different instances (before the server echo lands) +// would each compute from a stale snapshot and clobber each other, since +// setAccountData replaces the whole record with no server merge. We therefore +// keep a single shared latest record + write queue, keyed off the active client. +type UserNotesModuleState = { + mx: MatrixClient; + latest: UserNotesContent; + writeQueue: Promise; + listeners: Set<(record: UserNotesContent) => void>; + onAccountData: ClientEventHandlerMap[ClientEvent.AccountData]; +}; + +let moduleState: UserNotesModuleState | null = null; + +// Lazily initialize the shared state for the given client. On a client change +// (login/logout swaps the MatrixClient) we tear down the old subscription and +// re-initialize against the new client so we never leak or double-subscribe. +function ensureModuleState(mx: MatrixClient): UserNotesModuleState { + if (moduleState && moduleState.mx === mx) { + return moduleState; + } + + if (moduleState) { + moduleState.mx.removeListener(ClientEvent.AccountData, moduleState.onAccountData); + } + + const state: UserNotesModuleState = { + mx, + latest: readNotes(mx), + writeQueue: Promise.resolve(), + listeners: new Set(), + // Reassigned below once `state` is captured. + onAccountData: () => undefined, + }; + + state.onAccountData = (evt) => { + if (evt.getType() === NOTES_KEY) { + const record = evt.getContent() ?? {}; + state.latest = record; + state.listeners.forEach((listener) => listener(record)); + } + }; + + mx.on(ClientEvent.AccountData, state.onAccountData); + moduleState = state; + return state; +} + +function enqueueNotesWrite( + mx: MatrixClient, + compute: (current: UserNotesContent) => UserNotesContent, +): Promise { + const state = ensureModuleState(mx); + const run = state.writeQueue.then(async () => { + const next = compute(state.latest); + state.latest = next; + state.listeners.forEach((listener) => listener(next)); + await (mx as any).setAccountData(NOTES_KEY, next); + }); + // Keep the chain alive even if one write rejects, but propagate the + // rejection to this caller so it can react (e.g. retry). + state.writeQueue = run.catch(() => undefined); + return run; +} + export function useUserNotes(): { getNote: (userId: string) => string; setNote: (userId: string, note: string) => Promise; } { const mx = useMatrixClient(); - const [notes, setNotes] = useState(() => readNotes(mx)); - - useAccountDataCallback( - mx, - useCallback((evt) => { - if (evt.getType() === NOTES_KEY) { - setNotes(evt.getContent() ?? {}); - } - }, []), - ); + const [notes, setNotes] = useState(() => ensureModuleState(mx).latest); + // Subscribe to the shared module state. A single AccountData listener is + // installed per client (in ensureModuleState); each hook instance only + // registers a local setter and unregisters it on unmount / client change. useEffect(() => { - setNotes(readNotes(mx)); + const state = ensureModuleState(mx); + setNotes(state.latest); + state.listeners.add(setNotes); + return () => { + state.listeners.delete(setNotes); + }; }, [mx]); const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]); const setNote = useCallback( - async (userId: string, note: string) => { - const current = readNotes(mx); - const updated = { ...current }; + (userId: string, note: string) => { const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH); - if (trimmed) { - updated[userId] = trimmed; - } else { - delete updated[userId]; - } - await (mx as any).setAccountData(NOTES_KEY, updated); + return enqueueNotesWrite(mx, (current) => { + const updated = { ...current }; + if (trimmed) { + updated[userId] = trimmed; + } else { + delete updated[userId]; + } + return updated; + }); }, [mx], ); diff --git a/src/app/pages/client/ClientRoot.tsx b/src/app/pages/client/ClientRoot.tsx index 0529e3c05..13668e62e 100644 --- a/src/app/pages/client/ClientRoot.tsx +++ b/src/app/pages/client/ClientRoot.tsx @@ -31,6 +31,7 @@ import { logoutClient, startClient, } from '../../../client/initMatrix'; +import { deleteSearchCacheDatabase } from '../../utils/searchCache'; import { SplashScreen } from '../../components/splash-screen'; import { ServerConfigsLoader } from '../../components/ServerConfigsLoader'; import { CapabilitiesProvider } from '../../hooks/useCapabilities'; @@ -144,6 +145,11 @@ const useLogoutListener = (mx?: MatrixClient) => { const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => { mx?.stopClient(); await mx?.clearStores(); + // The opt-in local search index holds DECRYPTED message plaintext. Wipe it + // on server-forced logout too (token expiry / remote sign-out / password + // change) β€” the manual logout path already does, but this path didn't, so + // the plaintext survived on disk (and persist() makes it non-evictable). + await deleteSearchCacheDatabase(); // Remove only the session credential keys β€” NOT settings, drafts, and // other preferences (N98). The SDK's IndexedDB stores are cleared above; // window.localStorage.clear() is reserved for the explicit reset path. diff --git a/src/app/utils/imageCompression.ts b/src/app/utils/imageCompression.ts index 35cc6e95a..63b277036 100644 --- a/src/app/utils/imageCompression.ts +++ b/src/app/utils/imageCompression.ts @@ -1,5 +1,7 @@ export type CompressionResult = { blob: Blob; + /** MIME type of the produced blob (currently always image/jpeg). */ + type: string; originalSize: number; compressedSize: number; width: number; @@ -17,22 +19,47 @@ export function isCompressible(file: File | Blob): boolean { return isCompressibleType(file.type); } +const JPEG_OUTPUT_TYPE = 'image/jpeg'; + /** * Compress an image file via canvas.toBlob β†’ JPEG at the given quality. - * Returns null if the browser cannot render the image (e.g. unsupported codec). + * Returns null if the browser cannot render the image (e.g. unsupported codec) + * or if the source is left untouched to avoid data loss (see below). + * + * PNG is skipped entirely: it may carry an alpha channel, and re-encoding to + * JPEG composites transparency onto an opaque (black) background, corrupting the + * image. Returning null makes callers fall back to uploading the lossless + * original. The image is decoded with `imageOrientation: 'from-image'` so any + * EXIF orientation is baked into the pixels instead of being silently dropped. */ export async function compressImage( file: File | Blob, quality = 0.82, ): Promise { if (!isCompressibleType(file.type)) return null; + // Skip PNG (potential alpha) β€” re-encoding to JPEG would flatten transparency. + if (file.type === 'image/png') return null; - const img = await loadImage(file); + let bitmap: ImageBitmap; + try { + bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' }); + } catch { + // Corrupt/unsupported source: fall back to uploading the lossless original + // (the caller uses the original file on a null result) rather than rejecting, + // which would drop the file entirely from the Promise.allSettled upload. + return null; + } + const { width, height } = bitmap; const canvas = document.createElement('canvas'); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - const ctx = canvas.getContext('2d')!; - ctx.drawImage(img, 0, 0); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + if (!ctx) { + bitmap.close(); + return null; + } + ctx.drawImage(bitmap, 0, 0); + bitmap.close(); return new Promise((resolve) => { canvas.toBlob( @@ -43,31 +70,19 @@ export async function compressImage( } resolve({ blob, + type: JPEG_OUTPUT_TYPE, originalSize: file.size, compressedSize: blob.size, - width: img.naturalWidth, - height: img.naturalHeight, + width, + height, }); }, - 'image/jpeg', + JPEG_OUTPUT_TYPE, quality, ); }); } -function loadImage(file: File | Blob): Promise { - return new Promise((resolve, reject) => { - const url = URL.createObjectURL(file); - const img = new Image(); - img.onload = () => { - URL.revokeObjectURL(url); - resolve(img); - }; - img.onerror = reject; - img.src = url; - }); -} - export function formatFileSize(bytes: number): string { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; diff --git a/src/app/utils/scheduledMessages.ts b/src/app/utils/scheduledMessages.ts index f7e8b6e5c..ae3e0bc4a 100644 --- a/src/app/utils/scheduledMessages.ts +++ b/src/app/utils/scheduledMessages.ts @@ -12,6 +12,8 @@ export async function scheduleMessage( content: IContent, sendAtMs: number, ): Promise { + // A past/near target floors at 1000ms (send ~immediately) β€” an intentional, + // tested contract; the ScheduleMessageModal already guards β‰₯60s in the future. const delayMs = Math.max(1000, Math.round(sendAtMs - Date.now())); const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`; const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`; diff --git a/src/app/utils/searchCache.ts b/src/app/utils/searchCache.ts index 6f06dbf48..1149b49d0 100644 --- a/src/app/utils/searchCache.ts +++ b/src/app/utils/searchCache.ts @@ -298,9 +298,23 @@ export const deleteSearchCacheDatabase = async (): Promise => { return; } const req = indexedDB.deleteDatabase(DB_NAME); - req.onsuccess = () => resolve(); - req.onerror = () => resolve(); - req.onblocked = () => resolve(); + let settled = false; + const done = () => { + if (!settled) { + settled = true; + resolve(); + } + }; + req.onsuccess = done; + req.onerror = done; + req.onblocked = () => { + // Another tab still holds the DB open, so the delete is QUEUED, not done β€” + // resolving now would report a wipe that hasn't happened (plaintext still + // on disk). Wait for the real onsuccess (fires once the other tab closes; + // cross-tab logout reloads it shortly), but cap the wait so logout can't + // hang forever if a tab never releases. + setTimeout(done, 3000); + }; } catch { resolve(); }