fix(wave-2): audit fixes — account-data races, search-cache wipe, export, media
Web fixes from the Wave-2 bug-hunt (findings in LOTUS_TODO): - F1 (security): wipe the decrypted-plaintext search index on SERVER-FORCED logout too (token expiry / remote sign-out) — only manual logout did before. F4: the delete no longer reports success while onblocked (waits, 3s cap). - M1/M2 (data-loss): useBookmarks + useUserNotes account-data writes are now serialized at MODULE scope (single queue + latestRef per client, echo-driven), fixing the cross-instance lost-update clobber (useBookmarks mounts per message row, so a per-instance queue was insufficient — caught in review). - M6: room-history export gets a 200-page cap + Cancel + unmount-abort + correct date-range early-break (raw paginated ts). M4: image compression skips PNG (was flattening transparency to black), bakes EXIF orientation via createImageBitmap, .jpg-renames, and falls back to the original on decode failure instead of dropping the file. M5: MediaGallery lightbox opens the right item (shared thumb guard). M8: audio speed survives async decrypt. - Desktop web wiring: D2 badge sums leaf rooms only (space double-count, like the favicon fix); D3 useTauriDnd re-hydrates from get_tray_dnd on mount; D5 updater has a terminal state. Reviewed; M7 reverted (past-time clamp is an intentional, tested contract). tsc/eslint/prettier clean, build OK, 678 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
## ✅ 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.)
|
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.)
|
||||||
|
|||||||
@@ -99,9 +99,21 @@ export function AudioContent({
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
const [playbackSpeed, setPlaybackSpeed] = useState<0.75 | 1 | 1.5 | 2>(1);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (audioRef.current) {
|
const audio = audioRef.current;
|
||||||
audioRef.current.playbackRate = playbackSpeed;
|
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]);
|
}, [playbackSpeed]);
|
||||||
|
|
||||||
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
const SPEED_STEPS: Array<0.75 | 1 | 1.5 | 2> = [0.75, 1, 1.5, 2];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Spinner, Text } from 'folds';
|
import { Box, Button, Icon, IconButton, Icons, Input, Scroll, Text } from 'folds';
|
||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { Page, PageContent, PageHeader } from '../../components/page';
|
import { Page, PageContent, PageHeader } from '../../components/page';
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
@@ -16,6 +16,12 @@ const FORMAT_LABELS: Record<ExportFormat, string> = {
|
|||||||
html: 'HTML',
|
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 = {
|
type ExportRoomHistoryProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -30,11 +36,28 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const [toDate, setToDate] = useState('');
|
const [toDate, setToDate] = useState('');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [exportCount, setExportCount] = useState(0);
|
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 () => {
|
const handleExport = useCallback(async () => {
|
||||||
if (exporting) return;
|
if (exporting) return;
|
||||||
|
cancelledRef.current = false;
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
setExportCount(0);
|
setExportCount(0);
|
||||||
|
setNotice('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
const fromTs = fromDate ? new Date(`${fromDate}T00:00:00`).getTime() : null;
|
||||||
@@ -55,6 +78,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const timeline = room.getLiveTimeline();
|
const timeline = room.getLiveTimeline();
|
||||||
let canLoadMore = true;
|
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<typeof timeline.getEvents>) => {
|
const addEvents = async (events: ReturnType<typeof timeline.getEvents>) => {
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
@@ -70,12 +101,14 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||||
if (ev.isDecryptionFailure()) continue;
|
if (ev.isDecryptionFailure()) continue;
|
||||||
const ts = ev.getTs();
|
const ts = ev.getTs();
|
||||||
|
if (ts < oldestRawTs) oldestRawTs = ts;
|
||||||
if (fromTs !== null && ts < fromTs) continue;
|
if (fromTs !== null && ts < fromTs) continue;
|
||||||
if (toTs !== null && ts > toTs) continue;
|
if (toTs !== null && ts > toTs) continue;
|
||||||
const content = ev.getContent();
|
const content = ev.getContent();
|
||||||
const body: string = content.body ?? '';
|
const body: string = content.body ?? '';
|
||||||
const msgtype: string = content.msgtype ?? '';
|
const msgtype: string = content.msgtype ?? '';
|
||||||
if (!body) continue;
|
if (!body) continue;
|
||||||
|
if (ts < oldestTs) oldestTs = ts;
|
||||||
collected.push({
|
collected.push({
|
||||||
ts,
|
ts,
|
||||||
sender: ev.getSender() ?? '',
|
sender: ev.getSender() ?? '',
|
||||||
@@ -89,25 +122,40 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
|||||||
|
|
||||||
await addEvents(timeline.getEvents());
|
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) {
|
while (canLoadMore) {
|
||||||
// If we have a fromTs, check whether the oldest collected event is already
|
if (cancelledRef.current) {
|
||||||
// before it — if so we don't need to paginate further.
|
cancelled = true;
|
||||||
if (fromTs !== null && collected.length > 0) {
|
break;
|
||||||
const oldestTs = Math.min(...collected.map((r) => r.ts));
|
|
||||||
if (oldestTs < fromTs) 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
|
// eslint-disable-next-line no-await-in-loop
|
||||||
canLoadMore = await mx.paginateEventTimeline(timeline, {
|
canLoadMore = await mx.paginateEventTimeline(timeline, {
|
||||||
backwards: true,
|
backwards: true,
|
||||||
limit: 100,
|
limit: PAGE_LIMIT,
|
||||||
});
|
});
|
||||||
|
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await addEvents(timeline.getEvents());
|
await addEvents(timeline.getEvents());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
setNotice(`Export cancelled after ${collected.length} messages.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Sort chronologically (oldest first)
|
// Sort chronologically (oldest first)
|
||||||
collected.sort((a, b) => a.ts - b.ts);
|
collected.sort((a, b) => a.ts - b.ts);
|
||||||
|
|
||||||
@@ -191,6 +239,12 @@ ${msgRows}
|
|||||||
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
|
a.download = `export-${safeRoomName}-${dateStr}.${ext}`;
|
||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
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 {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -297,24 +351,35 @@ ${msgRows}
|
|||||||
? `Exporting… ${exportCount} messages`
|
? `Exporting… ${exportCount} messages`
|
||||||
: 'Export will download automatically.'}
|
: 'Export will download automatically.'}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
{exporting ? (
|
||||||
size="400"
|
<Button
|
||||||
variant="Primary"
|
size="400"
|
||||||
fill="Solid"
|
variant="Critical"
|
||||||
radii="300"
|
fill="Soft"
|
||||||
disabled={exporting}
|
radii="300"
|
||||||
onClick={handleExport}
|
onClick={handleCancel}
|
||||||
before={
|
before={<Icon src={Icons.Cross} size="100" />}
|
||||||
exporting ? (
|
>
|
||||||
<Spinner size="200" />
|
<Text size="B400">Cancel</Text>
|
||||||
) : (
|
</Button>
|
||||||
<Icon src={Icons.Download} size="100" />
|
) : (
|
||||||
)
|
<Button
|
||||||
}
|
size="400"
|
||||||
>
|
variant="Primary"
|
||||||
<Text size="B400">{exporting ? 'Exporting…' : 'Export'}</Text>
|
fill="Solid"
|
||||||
</Button>
|
radii="300"
|
||||||
|
onClick={handleExport}
|
||||||
|
before={<Icon src={Icons.Download} size="100" />}
|
||||||
|
>
|
||||||
|
<Text size="B400">Export</Text>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
{notice && (
|
||||||
|
<Text size="T200" priority="400">
|
||||||
|
{notice}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -133,6 +133,18 @@ function getSenderName(room: Room, userId: string): string {
|
|||||||
return room.getMember(userId)?.name ?? userId.split(':')[0]?.slice(1) ?? userId;
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Lightbox ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type LightboxItem = {
|
type LightboxItem = {
|
||||||
@@ -585,7 +597,10 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const lightboxItems: LightboxItem[] = events
|
const lightboxItems: LightboxItem[] = events
|
||||||
.filter((ev) => {
|
.filter((ev) => {
|
||||||
const c = ev.getContent();
|
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) => {
|
.map((ev) => {
|
||||||
const c = ev.getContent();
|
const c = ev.getContent();
|
||||||
@@ -712,9 +727,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
|||||||
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
const info: (IImageInfo & IThumbnailContent) | undefined = c.info;
|
||||||
|
|
||||||
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
|
// Prefer thumbnail_file (encrypted thumb) > file > thumbnail_url > url
|
||||||
const thumbMxc: string | undefined = isEnc
|
const thumbMxc: string | undefined = getThumbMxc(mEvent);
|
||||||
? (info?.thumbnail_file?.url ?? c.file?.url)
|
|
||||||
: (info?.thumbnail_url ?? c.url);
|
|
||||||
const thumbEnc: IEncryptedFile | undefined = isEnc
|
const thumbEnc: IEncryptedFile | undefined = isEnc
|
||||||
? (info?.thumbnail_file ?? c.file)
|
? (info?.thumbnail_file ?? c.file)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|||||||
@@ -456,12 +456,16 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
|
|
||||||
if (compressionResult) {
|
if (compressionResult) {
|
||||||
const originalFile = fileItem.originalFile as File;
|
const originalFile = fileItem.originalFile as File;
|
||||||
const compressedFile = new File([compressionResult.blob], originalFile.name, {
|
// compressImage re-encodes as JPEG; swap the extension so the file
|
||||||
type: 'image/jpeg',
|
// 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, {
|
const uploadRes = await mx.uploadContent(compressedFile, {
|
||||||
name: originalFile.name,
|
name: compressedName,
|
||||||
type: 'image/jpeg',
|
type: compressedType,
|
||||||
});
|
});
|
||||||
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
const compressedMxc = (uploadRes as { content_uri: string }).content_uri;
|
||||||
if (compressedMxc) {
|
if (compressedMxc) {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
export type Bookmark = {
|
export type Bookmark = {
|
||||||
roomId: string;
|
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<unknown>;
|
||||||
|
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<BookmarksContent>()?.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<void> {
|
||||||
|
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(): {
|
export function useBookmarks(): {
|
||||||
bookmarks: Bookmark[];
|
bookmarks: Bookmark[];
|
||||||
addBookmark: (b: Bookmark) => Promise<void>;
|
addBookmark: (b: Bookmark) => Promise<void>;
|
||||||
@@ -32,45 +100,37 @@ export function useBookmarks(): {
|
|||||||
isBookmarked: (eventId: string) => boolean;
|
isBookmarked: (eventId: string) => boolean;
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => readBookmarks(mx));
|
const [bookmarks, setBookmarks] = useState<Bookmark[]>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
useAccountDataCallback(
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
mx,
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
useCallback(
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
(evt) => {
|
|
||||||
if (evt.getType() === BOOKMARKS_KEY) {
|
|
||||||
setBookmarks(evt.getContent<BookmarksContent>()?.bookmarks ?? []);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setBookmarks],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-read on mx change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBookmarks(readBookmarks(mx));
|
const state = ensureModuleState(mx);
|
||||||
|
setBookmarks(state.latest);
|
||||||
|
state.listeners.add(setBookmarks);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(setBookmarks);
|
||||||
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
const addBookmark = useCallback(
|
const addBookmark = useCallback(
|
||||||
async (b: Bookmark) => {
|
(b: Bookmark) =>
|
||||||
const current = readBookmarks(mx);
|
enqueueBookmarkWrite(mx, (current) => {
|
||||||
// Avoid duplicates
|
// Avoid duplicates
|
||||||
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
const filtered = current.filter((bk) => bk.eventId !== b.eventId);
|
||||||
let next = [b, ...filtered];
|
let next = [b, ...filtered];
|
||||||
if (next.length > MAX_BOOKMARKS) {
|
if (next.length > MAX_BOOKMARKS) {
|
||||||
next = next.slice(0, MAX_BOOKMARKS);
|
next = next.slice(0, MAX_BOOKMARKS);
|
||||||
}
|
}
|
||||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
return next;
|
||||||
},
|
}),
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeBookmark = useCallback(
|
const removeBookmark = useCallback(
|
||||||
async (eventId: string) => {
|
(eventId: string) =>
|
||||||
const current = readBookmarks(mx);
|
enqueueBookmarkWrite(mx, (current) => current.filter((bk) => bk.eventId !== eventId)),
|
||||||
const next = current.filter((bk) => bk.eventId !== eventId);
|
|
||||||
await (mx as any).setAccountData(BOOKMARKS_KEY, { bookmarks: next });
|
|
||||||
},
|
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
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 { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
export type Reminder = {
|
export type Reminder = {
|
||||||
roomId: string;
|
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<unknown>;
|
||||||
|
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<RemindersContent>()?.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<void> {
|
||||||
|
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(): {
|
export function useReminders(): {
|
||||||
reminders: Reminder[];
|
reminders: Reminder[];
|
||||||
addReminder: (r: Reminder) => Promise<void>;
|
addReminder: (r: Reminder) => Promise<void>;
|
||||||
@@ -30,69 +98,34 @@ export function useReminders(): {
|
|||||||
getReminders: () => Reminder[];
|
getReminders: () => Reminder[];
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
|
const [reminders, setReminders] = useState<Reminder[]>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
// Authoritative local snapshot used to compute mutations. Reading
|
// Subscribe to the shared module state. A single AccountData listener is
|
||||||
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
|
// installed per client (in ensureModuleState); each hook instance only
|
||||||
// read the same stale baseline and the second write clobbers the first
|
// registers a local setter and unregisters it on unmount / client change.
|
||||||
// (N113). We instead mutate from this ref, kept in sync with server echoes.
|
|
||||||
const latestRef = useRef<Reminder[]>(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<unknown>>(Promise.resolve());
|
|
||||||
|
|
||||||
const applyServerState = useCallback((list: Reminder[]) => {
|
|
||||||
latestRef.current = list;
|
|
||||||
setReminders(list);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback(
|
|
||||||
(evt) => {
|
|
||||||
if (evt.getType() === REMINDERS_KEY) {
|
|
||||||
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[applyServerState],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Re-read on mx change
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyServerState(readReminders(mx));
|
const state = ensureModuleState(mx);
|
||||||
}, [mx, applyServerState]);
|
setReminders(state.latest);
|
||||||
|
state.listeners.add(setReminders);
|
||||||
const enqueueWrite = useCallback(
|
return () => {
|
||||||
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
|
state.listeners.delete(setReminders);
|
||||||
const run = writeQueueRef.current.then(async () => {
|
};
|
||||||
const next = compute(latestRef.current);
|
}, [mx]);
|
||||||
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 addReminder = useCallback(
|
const addReminder = useCallback(
|
||||||
(r: Reminder) => enqueueWrite((current) => [...current, r]),
|
(r: Reminder) => enqueueReminderWrite(mx, (current) => [...current, r]),
|
||||||
[enqueueWrite],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const removeReminder = useCallback(
|
const removeReminder = useCallback(
|
||||||
(eventId: string, timestamp: number) =>
|
(eventId: string, timestamp: number) =>
|
||||||
enqueueWrite((current) =>
|
enqueueReminderWrite(mx, (current) =>
|
||||||
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
|
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 };
|
return { reminders, addReminder, removeReminder, getReminders };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import { manualDndAtom } from '../state/manualDnd';
|
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. */
|
/** Detail shape of the `lotus-dnd-changed` event emitted by the native side. */
|
||||||
type DndChangedDetail = {
|
type DndChangedDetail = {
|
||||||
@@ -18,4 +19,17 @@ export function useTauriDnd(): void {
|
|||||||
const setDnd = useSetAtom(manualDndAtom);
|
const setDnd = useSetAtom(manualDndAtom);
|
||||||
|
|
||||||
useTauriEvent<DndChangedDetail>('lotus-dnd-changed', ({ active }) => setDnd(active));
|
useTauriEvent<DndChangedDetail>('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]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function useTauriNotificationBadge() {
|
|||||||
|
|
||||||
let totalHighlights = 0;
|
let totalHighlights = 0;
|
||||||
roomToUnread.forEach((unread) => {
|
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;
|
totalHighlights += unread.highlight;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ export function useTauriUpdater() {
|
|||||||
setStatus({ state: 'installing' });
|
setStatus({ state: 'installing' });
|
||||||
try {
|
try {
|
||||||
await invoke('install_update');
|
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) {
|
} catch (e) {
|
||||||
setStatus({ state: 'error', message: String(e) });
|
setStatus({ state: 'error', message: String(e) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
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 { useMatrixClient } from './useMatrixClient';
|
||||||
import { useAccountDataCallback } from './useAccountDataCallback';
|
|
||||||
|
|
||||||
const NOTES_KEY = 'io.lotus.user_notes';
|
const NOTES_KEY = 'io.lotus.user_notes';
|
||||||
export const USER_NOTE_MAX_LENGTH = 500;
|
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) ?? {};
|
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<unknown>;
|
||||||
|
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<UserNotesContent>() ?? {};
|
||||||
|
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<void> {
|
||||||
|
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(): {
|
export function useUserNotes(): {
|
||||||
getNote: (userId: string) => string;
|
getNote: (userId: string) => string;
|
||||||
setNote: (userId: string, note: string) => Promise<void>;
|
setNote: (userId: string, note: string) => Promise<void>;
|
||||||
} {
|
} {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx));
|
const [notes, setNotes] = useState<UserNotesContent>(() => ensureModuleState(mx).latest);
|
||||||
|
|
||||||
useAccountDataCallback(
|
|
||||||
mx,
|
|
||||||
useCallback((evt) => {
|
|
||||||
if (evt.getType() === NOTES_KEY) {
|
|
||||||
setNotes(evt.getContent<UserNotesContent>() ?? {});
|
|
||||||
}
|
|
||||||
}, []),
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
setNotes(readNotes(mx));
|
const state = ensureModuleState(mx);
|
||||||
|
setNotes(state.latest);
|
||||||
|
state.listeners.add(setNotes);
|
||||||
|
return () => {
|
||||||
|
state.listeners.delete(setNotes);
|
||||||
|
};
|
||||||
}, [mx]);
|
}, [mx]);
|
||||||
|
|
||||||
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
|
||||||
|
|
||||||
const setNote = useCallback(
|
const setNote = useCallback(
|
||||||
async (userId: string, note: string) => {
|
(userId: string, note: string) => {
|
||||||
const current = readNotes(mx);
|
|
||||||
const updated = { ...current };
|
|
||||||
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
|
||||||
if (trimmed) {
|
return enqueueNotesWrite(mx, (current) => {
|
||||||
updated[userId] = trimmed;
|
const updated = { ...current };
|
||||||
} else {
|
if (trimmed) {
|
||||||
delete updated[userId];
|
updated[userId] = trimmed;
|
||||||
}
|
} else {
|
||||||
await (mx as any).setAccountData(NOTES_KEY, updated);
|
delete updated[userId];
|
||||||
|
}
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[mx],
|
[mx],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
logoutClient,
|
logoutClient,
|
||||||
startClient,
|
startClient,
|
||||||
} from '../../../client/initMatrix';
|
} from '../../../client/initMatrix';
|
||||||
|
import { deleteSearchCacheDatabase } from '../../utils/searchCache';
|
||||||
import { SplashScreen } from '../../components/splash-screen';
|
import { SplashScreen } from '../../components/splash-screen';
|
||||||
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
import { ServerConfigsLoader } from '../../components/ServerConfigsLoader';
|
||||||
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
import { CapabilitiesProvider } from '../../hooks/useCapabilities';
|
||||||
@@ -144,6 +145,11 @@ const useLogoutListener = (mx?: MatrixClient) => {
|
|||||||
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
|
||||||
mx?.stopClient();
|
mx?.stopClient();
|
||||||
await mx?.clearStores();
|
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
|
// Remove only the session credential keys — NOT settings, drafts, and
|
||||||
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
|
||||||
// window.localStorage.clear() is reserved for the explicit reset path.
|
// window.localStorage.clear() is reserved for the explicit reset path.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
export type CompressionResult = {
|
export type CompressionResult = {
|
||||||
blob: Blob;
|
blob: Blob;
|
||||||
|
/** MIME type of the produced blob (currently always image/jpeg). */
|
||||||
|
type: string;
|
||||||
originalSize: number;
|
originalSize: number;
|
||||||
compressedSize: number;
|
compressedSize: number;
|
||||||
width: number;
|
width: number;
|
||||||
@@ -17,22 +19,47 @@ export function isCompressible(file: File | Blob): boolean {
|
|||||||
return isCompressibleType(file.type);
|
return isCompressibleType(file.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const JPEG_OUTPUT_TYPE = 'image/jpeg';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compress an image file via canvas.toBlob → JPEG at the given quality.
|
* 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(
|
export async function compressImage(
|
||||||
file: File | Blob,
|
file: File | Blob,
|
||||||
quality = 0.82,
|
quality = 0.82,
|
||||||
): Promise<CompressionResult | null> {
|
): Promise<CompressionResult | null> {
|
||||||
if (!isCompressibleType(file.type)) return null;
|
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');
|
const canvas = document.createElement('canvas');
|
||||||
canvas.width = img.naturalWidth;
|
canvas.width = width;
|
||||||
canvas.height = img.naturalHeight;
|
canvas.height = height;
|
||||||
const ctx = canvas.getContext('2d')!;
|
const ctx = canvas.getContext('2d');
|
||||||
ctx.drawImage(img, 0, 0);
|
if (!ctx) {
|
||||||
|
bitmap.close();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, 0, 0);
|
||||||
|
bitmap.close();
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
canvas.toBlob(
|
canvas.toBlob(
|
||||||
@@ -43,31 +70,19 @@ export async function compressImage(
|
|||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
blob,
|
blob,
|
||||||
|
type: JPEG_OUTPUT_TYPE,
|
||||||
originalSize: file.size,
|
originalSize: file.size,
|
||||||
compressedSize: blob.size,
|
compressedSize: blob.size,
|
||||||
width: img.naturalWidth,
|
width,
|
||||||
height: img.naturalHeight,
|
height,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
'image/jpeg',
|
JPEG_OUTPUT_TYPE,
|
||||||
quality,
|
quality,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadImage(file: File | Blob): Promise<HTMLImageElement> {
|
|
||||||
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 {
|
export function formatFileSize(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export async function scheduleMessage(
|
|||||||
content: IContent,
|
content: IContent,
|
||||||
sendAtMs: number,
|
sendAtMs: number,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
// 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 delayMs = Math.max(1000, Math.round(sendAtMs - Date.now()));
|
||||||
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
const txnId = `sched_${Date.now()}_${Math.random().toString(36).slice(2)}`;
|
||||||
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
const path = `/rooms/${encodeURIComponent(roomId)}/send/m.room.message/${txnId}`;
|
||||||
|
|||||||
@@ -298,9 +298,23 @@ export const deleteSearchCacheDatabase = async (): Promise<void> => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const req = indexedDB.deleteDatabase(DB_NAME);
|
const req = indexedDB.deleteDatabase(DB_NAME);
|
||||||
req.onsuccess = () => resolve();
|
let settled = false;
|
||||||
req.onerror = () => resolve();
|
const done = () => {
|
||||||
req.onblocked = () => resolve();
|
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 {
|
} catch {
|
||||||
resolve();
|
resolve();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user