Compare commits

...
6 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 908e735933 fix(security): logout's search-index wipe coordinates across tabs
CI / Build & Quality Checks (push) Successful in 1m40s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
deleteSearchCacheDatabase() resolved after a 3 s "blocked" timeout while
another tab still held the DB, so decrypted rows could survive logout.
It now broadcasts lotus-logout first; every tab closes its handle and
refuses to reopen, then the delete proceeds. A boot with no session
re-runs the wipe once in case a race was still lost. Unit-tested.

Fixes #45

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 ceada3e113 fix(settings): sync across tabs; merge-on-write instead of clobbering
The settings atom was a load-time snapshot with no storage listener and
wrote the whole blob, so two tabs silently reverted each other. It now
re-reads on storage events and writes only the keys that changed
relative to the previous value. Unit-tested.

Fixes #42

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 9c1c29f4fc perf(translation): per-message cache subscription; bounded atom families
Every text message subscribed to the whole translation cache array, so
one translation re-rendered the entire timeline. Messages now subscribe
to their own keyed entry, the cache key is memoised, the auto-translate
"already tried" guard is module-scoped (no re-detect on virtualised
remount), and both atom families are FIFO-capped and evicted on logout.
Unit-tested.

Fixes #39

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 9e566807b3 fix(timeline): thread panel shows read receipts; receipts recompute incrementally
- ReadPositionsContext is provided once at Room level so the thread
  panel (a sibling of RoomView) gets real positions instead of the empty
  default; own thread messages no longer sit on "Sent" forever (#38).
- Receipt events only recompute the users they name, merged into the
  previous map with reference equality preserved for untouched rows, so
  a receipt no longer re-renders every message (#40). Unit-tested.

Fixes #38
Fixes #40

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 e0861849b7 fix(composer): persist drafts while typing, not only on unmount
Drafts were written solely in the effect cleanup, so F5 in the open room
lost them and the draft indicator never showed for the current room.
Persist on a 500 ms debounce and on pagehide with the same {userId,
nodes} shape; an empty editor clears the stored draft. Also passes the
full content to the schedule modal (#36).

Fixes #37

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 bec248b228 fix(composer): scheduling keeps formatting, mentions and the reply relation
The schedule modal took only a plain body and rebuilt {body, msgtype},
dropping formatted_body, m.mentions and m.relates_to. It now receives
the full IContent; an unedited body is sent verbatim, an edited body
drops the now-stale formatted_body but keeps mentions and the reply/
thread relation. Reschedule from the tray preserves them too.
Unit-tested (mergeScheduledBody).

Fixes #36

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
17 changed files with 841 additions and 141 deletions
+86 -78
View File
@@ -28,6 +28,8 @@ import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
import { threadsListAtom } from '../../state/threadsList';
import { ThreadPanel } from './thread';
import { ThreadsListPanel } from './thread/ThreadsListPanel';
import { ReadPositionsContext } from './ReadPositionsContext';
import { useRoomReadPositions } from '../../hooks/useRoomReadPositions';
export function Room() {
const { eventId } = useParams();
@@ -52,6 +54,10 @@ export function Room() {
const powerLevels = usePowerLevels(room);
const members = useRoomMembers(mx, room.roomId);
const chat = useAtomValue(callChatAtom);
// Hoisted here (instead of inside RoomTimeline) so ThreadPanel/ThreadTimeline —
// rendered as siblings of RoomView, not descendants — share the same computed
// positions instead of falling back to the context default (Gitea #38).
const readPositions = useRoomReadPositions(room);
useKeyDown(
window,
useCallback(
@@ -147,87 +153,89 @@ export function Room() {
return (
<PowerLevelsContextProvider value={powerLevels}>
<Box grow="Yes">
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewHeader callView />
<Box grow="Yes">
<CallView />
<ReadPositionsContext.Provider value={readPositions}>
<Box grow="Yes">
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box grow="Yes" direction="Column">
<RoomViewHeader callView />
<Box grow="Yes">
<CallView />
</Box>
</Box>
</Box>
)}
{!callView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes">
<RoomView eventId={eventId} />
)}
{!callView && (
<Box grow="Yes" direction="Column">
<RoomViewHeader />
<Box grow="Yes">
<RoomView eventId={eventId} />
</Box>
</Box>
</Box>
)}
)}
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView />
</>
)}
{showGallery && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</>
)}
{showWidgets && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<WidgetsPanel
key={room.roomId}
room={room}
requestClose={() => setWidgetsOpen(false)}
/>
</>
)}
{showThreadsList && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadsListPanel
key={room.roomId}
room={room}
onClose={closeThreadsList}
onOpenThread={openThreadFromList}
/>
</>
)}
{showThreadPanel && activeThreadId && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadPanel
key={`${room.roomId}${activeThreadId}`}
room={room}
threadId={activeThreadId}
requestClose={() => setActiveThreadId(null)}
/>
</>
)}
{showMembers && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
</Box>
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView />
</>
)}
{showGallery && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</>
)}
{showWidgets && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<WidgetsPanel
key={room.roomId}
room={room}
requestClose={() => setWidgetsOpen(false)}
/>
</>
)}
{showThreadsList && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadsListPanel
key={room.roomId}
room={room}
onClose={closeThreadsList}
onOpenThread={openThreadFromList}
/>
</>
)}
{showThreadPanel && activeThreadId && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<ThreadPanel
key={`${room.roomId}${activeThreadId}`}
room={room}
threadId={activeThreadId}
requestClose={() => setActiveThreadId(null)}
/>
</>
)}
{showMembers && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
</Box>
</ReadPositionsContext.Provider>
</PowerLevelsContextProvider>
);
}
+56 -22
View File
@@ -146,6 +146,9 @@ const EmojiBoard = React.lazy(() =>
import('../../components/emoji-board').then((m) => ({ default: m.EmojiBoard })),
);
/** [Gitea #37] Debounce for persisting the composer draft while typing. */
const DRAFT_PERSIST_DEBOUNCE_MS = 500;
interface RoomInputProps {
editor: Editor;
fileDropContainerRef: RefObject<HTMLElement>;
@@ -430,27 +433,54 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
}, [editor, msgDraft, draftKey, setMsgDraft, mx]);
useEffect(
() => () => {
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
// [Gitea #41] Tag the persisted draft with the writing user's id so a
// different account logging into this browser can't have it hydrated
// into their composer (see useHydrateMsgDrafts / clearPlaintextCaches).
localStorage.setItem(
`draft-msg-${draftKey}`,
JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }),
);
} else {
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${draftKey}`);
// Persist the current editor state to the draft atom + localStorage; an empty
// editor clears both so the draft indicators never claim a draft that isn't there.
const persistDraft = useCallback(() => {
if (!isEmptyEditor(editor)) {
const parsedDraft = JSON.parse(JSON.stringify(editor.children));
setMsgDraft(parsedDraft);
// [Gitea #41] Tag the persisted draft with the writing user's id so a
// different account logging into this browser can't have it hydrated
// into their composer (see useHydrateMsgDrafts / clearPlaintextCaches).
localStorage.setItem(
`draft-msg-${draftKey}`,
JSON.stringify({ userId: mx.getUserId(), nodes: parsedDraft }),
);
} else {
setMsgDraft([]);
localStorage.removeItem(`draft-msg-${draftKey}`);
}
}, [draftKey, editor, setMsgDraft, mx]);
// [Gitea #37] Drafts used to be written only in the unmount cleanup below, so
// a reload/tab-close in the open room lost them (and DraftIndicator never
// showed for the room being typed in). Debounce a persist while typing and
// flush it on pagehide; the cleanup still persists on unmount / draftKey change.
const persistDraftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const schedulePersistDraft = useCallback(() => {
if (persistDraftTimer.current) clearTimeout(persistDraftTimer.current);
persistDraftTimer.current = setTimeout(() => {
persistDraftTimer.current = null;
persistDraft();
}, DRAFT_PERSIST_DEBOUNCE_MS);
}, [persistDraft]);
useEffect(() => {
const flush = () => {
if (persistDraftTimer.current) {
clearTimeout(persistDraftTimer.current);
persistDraftTimer.current = null;
}
persistDraft();
};
window.addEventListener('pagehide', flush);
return () => {
window.removeEventListener('pagehide', flush);
flush();
resetEditor(editor);
resetEditorHistory(editor);
},
[draftKey, editor, setMsgDraft, mx],
);
};
}, [editor, persistDraft]);
const handleFileMetadata = useCallback(
(fileItem: TUploadItem, metadata: TUploadMetadata) => {
@@ -748,6 +778,9 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
next.set(roomId, [...current, { delayId, roomId, content, sendAt }]);
return next;
});
// [Gitea #36] Only reached after scheduleMessage() succeeded, so the reply
// draft (already baked into `content['m.relates_to']`) can be cleared here;
// a failed/cancelled schedule keeps the composer and reply target intact.
resetEditor(editor);
resetEditorHistory(editor);
setMsgDraft([]);
@@ -1066,7 +1099,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onPaste={handlePaste}
onChange={(value) => setCharCount(toPlainText(value, isMarkdown).trim().length)}
onChange={(value) => {
setCharCount(toPlainText(value, isMarkdown).trim().length);
schedulePersistDraft();
}}
top={
replyDraft && (
<div>
@@ -1514,9 +1550,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{scheduleOpen && (
<ScheduleMessageModal
roomId={roomId}
initialBody={
typeof scheduleContent?.body === 'string' ? scheduleContent.body : undefined
}
initialContent={scheduleContent}
onScheduled={handleScheduled}
onClose={() => {
setScheduleOpen(false);
+4 -5
View File
@@ -90,8 +90,6 @@ import { useSetting } from '../../state/hooks/settings';
import { MessageLayout, settingsAtom } from '../../state/settings';
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
import { Reactions, Message, Event, EncryptedContent } from './message';
import { ReadPositionsContext } from './ReadPositionsContext';
import { useRoomReadPositions } from '../../hooks/useRoomReadPositions';
import { useMemberEventParser } from '../../hooks/useMemberEventParser';
import * as customHtmlCss from '../../styles/CustomHtml.css';
import { RoomIntro } from '../../components/room-intro';
@@ -463,7 +461,8 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
const [messageSpacing] = useSetting(settingsAtom, 'messageSpacing');
const [legacyUsernameColor] = useSetting(settingsAtom, 'legacyUsernameColor');
const direct = useIsDirectRoom();
const readPositions = useRoomReadPositions(room);
// Read positions are computed once in Room.tsx and provided via ReadPositionsContext
// so both RoomTimeline and ThreadTimeline consume the same value (Gitea #38).
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
const [mediaAutoLoad] = useSetting(settingsAtom, 'mediaAutoLoad');
@@ -2128,7 +2127,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
};
return (
<ReadPositionsContext.Provider value={readPositions}>
<>
<Box grow="Yes" style={{ position: 'relative' }}>
{unreadInfo?.readUptoEventId && !unreadInfo?.inLiveTimeline && (
<TimelineFloat position="Top">
@@ -2264,6 +2263,6 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
onClose={() => setEditHistoryEvent(undefined)}
/>
)}
</ReadPositionsContext.Provider>
</>
);
}
@@ -0,0 +1,52 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { IContent } from 'matrix-js-sdk';
import { mergeScheduledBody } from './ScheduleMessageModal';
// [Gitea #36] Pure merge of the modal textarea back into the composer-built
// content: verbatim when unedited, body-only replacement (rich HTML dropped,
// mentions + relation kept) when edited.
const richContent: IContent = {
msgtype: 'm.text',
body: 'hello **bold** @alice',
format: 'org.matrix.custom.html',
formatted_body: 'hello <b>bold</b> <a href="https://matrix.to/#/@alice:x">alice</a>',
'm.mentions': { user_ids: ['@alice:x'] },
'm.relates_to': {
'm.in_reply_to': { event_id: '$reply' },
rel_type: 'm.thread',
event_id: '$root',
is_falling_back: false,
},
};
test('mergeScheduledBody returns the content verbatim when the body is unedited', () => {
assert.equal(mergeScheduledBody(richContent, richContent.body), richContent);
// Whitespace-only differences (the textarea value is trimmed) still count as unedited.
assert.equal(mergeScheduledBody(richContent, ` ${richContent.body}\n`), richContent);
});
test('mergeScheduledBody drops stale formatting but keeps mentions and relation on edit', () => {
const merged = mergeScheduledBody(richContent, 'edited text ');
assert.deepEqual(merged, {
msgtype: 'm.text',
body: 'edited text',
'm.mentions': { user_ids: ['@alice:x'] },
'm.relates_to': richContent['m.relates_to'],
});
assert.equal('format' in merged, false);
assert.equal('formatted_body' in merged, false);
// Input is not mutated.
assert.equal(richContent.formatted_body !== undefined, true);
});
test('mergeScheduledBody builds a bare text content when opened blank', () => {
assert.deepEqual(mergeScheduledBody(null, ' hi '), { body: 'hi', msgtype: 'm.text' });
assert.deepEqual(mergeScheduledBody(undefined, 'hi'), { body: 'hi', msgtype: 'm.text' });
});
test('mergeScheduledBody preserves a non-text msgtype on edit', () => {
const merged = mergeScheduledBody({ msgtype: 'm.emote', body: 'waves' }, 'nods');
assert.deepEqual(merged, { msgtype: 'm.emote', body: 'nods' });
});
+29 -5
View File
@@ -31,8 +31,13 @@ import {
interface ScheduleMessageModalProps {
roomId: string;
/** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
initialBody?: string;
/**
* Pre-fill from the composer (or the message being rescheduled). The textarea
* edits only `body`; the rest of the content (mentions, reply/thread relation,
* formatting) rides along — see `mergeScheduledBody`. Pass null/undefined to
* open blank.
*/
initialContent?: IContent | null;
/** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */
initialSendAt?: number;
/** Header title; defaults to "Schedule Message". */
@@ -43,6 +48,23 @@ interface ScheduleMessageModalProps {
onClose: () => void;
}
/**
* [Gitea #36] Build the content to schedule from the pre-filled composer content
* and the (possibly edited) textarea body. Previously the modal rebuilt a bare
* `{ body, msgtype }`, silently dropping `formatted_body`, `m.mentions` and the
* reply/thread relation. Now: an unedited body sends the content verbatim; an
* edited body replaces `body` and drops `format`/`formatted_body` (they would be
* stale) but keeps mentions and the relation.
*/
export function mergeScheduledBody(initial: IContent | null | undefined, body: string): IContent {
const trimmed = body.trim();
if (!initial) return { body: trimmed, msgtype: 'm.text' };
const initialBody = typeof initial.body === 'string' ? initial.body : '';
if (trimmed === initialBody.trim()) return initial;
const { format: _format, formatted_body: _formattedBody, ...rest } = initial;
return { ...rest, msgtype: initial.msgtype ?? 'm.text', body: trimmed };
}
function formatRelativeTime(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
@@ -55,7 +77,7 @@ function formatRelativeTime(ms: number): string {
export function ScheduleMessageModal({
roomId,
initialBody,
initialContent,
initialSendAt,
title = 'Schedule Message',
submitLabel = 'Schedule',
@@ -64,7 +86,9 @@ export function ScheduleMessageModal({
}: ScheduleMessageModalProps) {
const modalStyle = useModalStyle(400);
const mx = useMatrixClient();
const [messageText, setMessageText] = useState(initialBody ?? '');
const [messageText, setMessageText] = useState(
typeof initialContent?.body === 'string' ? initialContent.body : '',
);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -128,7 +152,7 @@ export function ScheduleMessageModal({
return;
}
const content: IContent = { body: messageText.trim(), msgtype: 'm.text' };
const content = mergeScheduledBody(initialContent, messageText);
setError(null);
setSubmitting(true);
try {
@@ -225,7 +225,7 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
{editing && (
<ScheduleMessageModal
roomId={roomId}
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
initialContent={editing.content}
initialSendAt={editing.sendAt}
title="Edit scheduled message"
submitLabel="Reschedule"
+19 -12
View File
@@ -1,13 +1,15 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { settingsAtom } from '../state/settings';
import { useSetting } from '../state/hooks/settings';
import {
translationCacheAtom,
translationEntryAtomFamily,
msgTranslationActiveAtomFamily,
makeCacheKey,
findTranslation,
addTranslation,
wasAutoTranslateTried,
markAutoTranslateTried,
} from '../state/translation';
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
import { sameLanguage } from '../utils/translation/langUtils';
@@ -47,12 +49,17 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
const [targetLang] = useSetting(settingsAtom, 'translateTargetLang');
const [autoTranslate] = useSetting(settingsAtom, 'autoTranslate');
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
const [cache, setCache] = useAtom(translationCacheAtom);
// Write-only: reading the whole cache array here (via useAtom) would
// resubscribe this message to every other message's writes. [Gitea #39]
const setCache = useSetAtom(translationCacheAtom);
// The key includes a fingerprint of `text`, so an edit (same event id, new
// body) misses the old entry and re-translates instead of showing stale text.
const key = makeCacheKey(eventId, targetLang, text);
const cached = findTranslation(cache, key);
// body) misses the old entry and re-translates instead of showing stale
// text. Memoised so the O(len) fingerprint isn't recomputed every render.
const key = useMemo(() => makeCacheKey(eventId, targetLang, text), [eventId, targetLang, text]);
// Subscribes to only this message's cache slot, so translating one message
// no longer re-renders every mounted message. [Gitea #39]
const cached = useAtomValue(translationEntryAtomFamily(key));
const [status, setStatus] = useState<MsgTranslationStatus>('idle');
const [translated, setTranslated] = useState<string | undefined>(undefined);
@@ -74,13 +81,13 @@ export const useMessageTranslation = (eventId: string, text: string): MsgTransla
// Auto-translate: when enabled, flip `active` on for foreign messages whose
// model is already downloaded (no gesture/download needed). Messages needing
// a first-time download keep the manual chip (which provides the gesture).
// Store the text we last auto-tried so an edit (new text) re-triggers auto
// detection instead of being suppressed by a one-shot flag.
const autoTriedText = useRef<string | undefined>(undefined);
// The "already tried" guard is hoisted to module scope (keyed by eventId) so
// it survives unmount — virtualised scrolling used to re-run detection every
// time the same message remounted with a per-hook-instance ref. [Gitea #39]
useEffect(() => {
if (!supported || !autoTranslate || active || cached || !eventId) return;
if (!text.trim() || autoTriedText.current === text) return;
autoTriedText.current = text;
if (!text.trim() || wasAutoTranslateTried(eventId, text)) return;
markAutoTranslateTried(eventId, text);
let alive = true;
(async () => {
const from = await engine.detect(text);
@@ -0,0 +1,88 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import type { Room, MatrixEvent } from 'matrix-js-sdk';
import { ReceiptType } from 'matrix-js-sdk/lib/@types/read_receipts';
import { computeUpdatedPositions, getReceiptUserIds } from './useRoomReadPositions';
// Fake, renderable (no reaction/edit relation) event.
const fakeEvent = (id: string) =>
({
getId: () => id,
getRelation: () => null,
}) as unknown as MatrixEvent;
// Minimal fake room: a fixed live timeline plus a mutable per-user read-up-to map,
// which the test mutates between calls to simulate new receipts arriving.
const makeFakeRoom = (eventIds: string[]) => {
const readUpTo = new Map<string, string>();
const events = eventIds.map(fakeEvent);
const room = {
getLiveTimeline: () => ({ getEvents: () => events }),
getEventReadUpTo: (userId: string) => readUpTo.get(userId) ?? null,
} as unknown as Room;
return { room, readUpTo };
};
test('getReceiptUserIds collects every userId across events/types in the content', () => {
const content = {
$a: {
[ReceiptType.Read]: { '@alice:hs': { ts: 1 }, '@bob:hs': { ts: 2 } },
},
$b: {
[ReceiptType.ReadPrivate]: { '@carol:hs': { ts: 3 } },
},
};
const ids = getReceiptUserIds(content).sort();
assert.deepEqual(ids, ['@alice:hs', '@bob:hs', '@carol:hs']);
});
test('getReceiptUserIds returns empty for an empty receipt content', () => {
assert.deepEqual(getReceiptUserIds({}), []);
});
test('computeUpdatedPositions returns the SAME map reference when no named user changed position', () => {
const { room, readUpTo } = makeFakeRoom(['$a', '$b']);
readUpTo.set('@alice:hs', '$a');
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs']);
assert.deepEqual(prev.get('$a'), ['@alice:hs']);
// Alice's read-up-to did not move; recompute should be a no-op reference-wise.
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
assert.equal(next, prev);
});
test('computeUpdatedPositions moves only the named user, leaving other entries untouched by reference', () => {
const { room, readUpTo } = makeFakeRoom(['$a', '$b']);
readUpTo.set('@alice:hs', '$a');
readUpTo.set('@bob:hs', '$a');
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs', '@bob:hs']);
const bArrayBefore = prev.get('$a');
// Only alice moves to $b.
readUpTo.set('@alice:hs', '$b');
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
assert.notEqual(next, prev);
assert.deepEqual(next.get('$a'), ['@bob:hs']);
assert.deepEqual(next.get('$b'), ['@alice:hs']);
// Untouched arrays for other targets should keep their old reference where possible.
assert.notEqual(next.get('$a'), bArrayBefore); // this one WAS filtered, expected to change
});
test('computeUpdatedPositions ignores changedUserIds not present after filtering myUserId', () => {
const { room } = makeFakeRoom(['$a']);
const prev = new Map([['$a', ['@bob:hs']]]);
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@me:hs']);
assert.equal(next, prev);
});
test('computeUpdatedPositions removes a user with no read-up-to event from the map', () => {
const { room, readUpTo } = makeFakeRoom(['$a']);
readUpTo.set('@alice:hs', '$a');
const prev = computeUpdatedPositions(room, '@me:hs', new Map(), ['@alice:hs']);
assert.deepEqual(prev.get('$a'), ['@alice:hs']);
readUpTo.delete('@alice:hs');
const next = computeUpdatedPositions(room, '@me:hs', prev, ['@alice:hs']);
assert.equal(next.has('$a'), false);
});
+81 -2
View File
@@ -1,4 +1,5 @@
import { Room, RoomEvent, RoomMember, RoomMemberEvent, MatrixEvent } from 'matrix-js-sdk';
import { ReceiptContent } from 'matrix-js-sdk/lib/@types/read_receipts';
import { useEffect, useState } from 'react';
import { useMatrixClient } from './useMatrixClient';
import { reactionOrEditEvent } from '../utils/room';
@@ -19,6 +20,18 @@ function nearestRenderableId(
return null;
}
// A `m.receipt` event's content names every user whose receipt moved, keyed by
// eventId -> receiptType -> userId. Exported for testing.
export function getReceiptUserIds(content: ReceiptContent): string[] {
const userIds = new Set<string>();
Object.values(content).forEach((receiptsByType) => {
Object.values(receiptsByType).forEach((receiptsByUser) => {
Object.keys(receiptsByUser).forEach((userId) => userIds.add(userId));
});
});
return Array.from(userIds);
}
function computePositions(room: Room, myUserId: string): Map<string, string[]> {
const map = new Map<string, string[]>();
const liveEvents = room.getLiveTimeline().getEvents();
@@ -37,6 +50,64 @@ function computePositions(room: Room, myUserId: string): Map<string, string[]> {
return map;
}
// Recompute positions for only the given users, reusing the previous Map/arrays for
// everyone else. Returns the SAME `prevMap` reference when nothing actually changed,
// so unaffected `Message`s (the overwhelming majority on every receipt) skip re-render
// (Gitea #40).
export function computeUpdatedPositions(
room: Room,
myUserId: string,
prevMap: Map<string, string[]>,
changedUserIds: Iterable<string>,
): Map<string, string[]> {
const usersToUpdate = new Set(changedUserIds);
usersToUpdate.delete(myUserId);
if (usersToUpdate.size === 0) return prevMap;
const liveEvents = room.getLiveTimeline().getEvents();
const eventIndex = new Map<string, number>(liveEvents.map((e, i) => [e.getId() ?? '', i]));
// Each changed user's current target, so a receipt that didn't actually move
// them past the previously-computed nearest renderable event is a true no-op.
const oldTargetByUser = new Map<string, string>();
prevMap.forEach((users, targetId) => {
users.forEach((u) => {
if (usersToUpdate.has(u)) oldTargetByUser.set(u, targetId);
});
});
// Copy lazily: stay on `prevMap` (and its untouched per-event arrays) unless a
// user actually moved, so unrelated Messages keep the same array/Map reference.
let nextMap = prevMap;
const ensureCopy = (): Map<string, string[]> => {
if (nextMap === prevMap) nextMap = new Map(prevMap);
return nextMap;
};
usersToUpdate.forEach((userId) => {
const evtId = room.getEventReadUpTo(userId);
const newTargetId = evtId ? nearestRenderableId(liveEvents, eventIndex, evtId) : null;
const oldTargetId = oldTargetByUser.get(userId) ?? null;
if (newTargetId === oldTargetId) return;
const map = ensureCopy();
if (oldTargetId) {
const users = map.get(oldTargetId);
if (users) {
const filtered = users.filter((u) => u !== userId);
if (filtered.length === 0) map.delete(oldTargetId);
else map.set(oldTargetId, filtered);
}
}
if (newTargetId) {
const users = map.get(newTargetId);
map.set(newTargetId, users ? [...users, userId] : [userId]);
}
});
return nextMap;
}
export function useRoomReadPositions(room: Room): Map<string, string[]> {
const mx = useMatrixClient();
const myUserId = mx.getUserId() ?? '';
@@ -45,11 +116,19 @@ export function useRoomReadPositions(room: Room): Map<string, string[]> {
useEffect(() => {
setPositions(computePositions(room, myUserId));
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const onReceipt = (): void => {
// Accumulated across every receipt event that lands during the debounce window so
// a burst of receipts still only touches the users actually named in them.
const pendingUserIds = new Set<string>();
const onReceipt = (event: MatrixEvent): void => {
getReceiptUserIds(event.getContent<ReceiptContent>()).forEach((userId) =>
pendingUserIds.add(userId),
);
if (debounceTimer !== null) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
setPositions(computePositions(room, myUserId));
const userIds = Array.from(pendingUserIds);
pendingUserIds.clear();
debounceTimer = null;
setPositions((prev) => computeUpdatedPositions(room, myUserId, prev, userIds));
}, 150);
};
// RoomMemberEvent.Membership is emitted on the RoomMember (and re-emitted on the
+59 -1
View File
@@ -1,6 +1,6 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { getSettings } from './settings';
import { getSettings, setSettings, Settings } from './settings';
// getSettings() reads localStorage; node has none, so install a controllable
// mock per case. (The module already loaded safely with no localStorage thanks
@@ -12,6 +12,21 @@ const setStored = (value: string | null): void => {
removeItem: () => undefined,
};
};
// Map-backed mock (unlike setStored above, get/set actually round-trip)
// for the setSettings merge tests below, which need writes to persist.
const installStorage = (): Map<string, string> => {
const map = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: (k: string) => (map.has(k) ? map.get(k)! : null),
setItem: (k: string, v: string) => {
map.set(k, v);
},
removeItem: (k: string) => {
map.delete(k);
},
};
return map;
};
const setThrowingStorage = (): void => {
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => {
@@ -89,3 +104,46 @@ test('returns defaults on malformed JSON', () => {
assert.doesNotThrow(() => getSettings());
assert.equal(getSettings().callNoiseSuppression, 'browser');
});
// Gitea #42: two tabs, each holding its own stale snapshot, must not clobber
// a key the *other* tab changed. setSettings() is given `previous` (this
// tab's own pre-update snapshot) and `next` (its whole-object update) and must
// apply only the keys that actually differ, on top of the freshest stored blob
// — not overwrite the blob wholesale with its own stale view.
test('setSettings merges only the changed key onto the freshest stored blob', () => {
installStorage();
const original = getSettings();
// Tab A reads settings, then tab B (concurrently) changes an unrelated key
// and writes it to the shared storage.
const tabB = { ...original, gifPickerEnabled: true };
setSettings(original, tabB);
// Tab A, still holding its original snapshot, now changes a different key.
const tabA = { ...original, isMarkdown: !original.isMarkdown };
setSettings(original, tabA);
// Both survive: tab B's change was not reverted by tab A's stale write.
const result = getSettings();
assert.equal(result.gifPickerEnabled, true);
assert.equal(result.isMarkdown, !original.isMarkdown);
});
test('setSettings is a no-op for keys unchanged relative to previous', () => {
const store = installStorage();
store.set('settings', JSON.stringify({ isMarkdown: false }));
const current = getSettings();
assert.equal(current.isMarkdown, false);
// `next` differs from `current` in object identity/composerToolbarButtons
// etc. but not in any actual value vs `previous` — nothing should be
// reapplied, so an out-of-band external change to storage survives.
const externallyChanged: Settings = { ...current, isMarkdown: true };
localStorage.setItem('settings', JSON.stringify(externallyChanged));
const previous = current;
const next = { ...current }; // identical values to `previous`
setSettings(previous, next);
assert.equal(getSettings().isMarkdown, true);
});
+34 -3
View File
@@ -473,19 +473,50 @@ export const getSettings = (): Settings => {
}
};
export const setSettings = (settings: Settings) => {
// Gitea #42 — merge-on-write. `next` is always this tab's whole-object view
// (a shallow copy of what it last read, with one key changed — see
// useSetSetting), which is stale the moment another tab has written since. Re-
// reading the stored blob and reapplying only the keys that actually changed
// relative to `previous` (this tab's own pre-update snapshot) means a change
// this tab didn't make — e.g. a different setting toggled in another tab —
// survives instead of being clobbered by this tab's stale snapshot of it.
export const setSettings = (previous: Settings, next: Settings) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
const stored = getSettings();
const merged: Settings = { ...stored };
(Object.keys(next) as (keyof Settings)[]).forEach((key) => {
if (next[key] !== previous[key]) {
(merged as Record<keyof Settings, unknown>)[key] = next[key];
}
});
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
} catch {
/* quota */
}
};
const baseSettings = atom<Settings>(getSettings());
// Gitea #42 — settingsAtom used to be a one-shot snapshot with no cross-tab
// sync at all (unlike atomWithLocalStorage.ts, which this mirrors). Without
// this, a tab left open never sees settings changed in another tab until it
// reloads, and (before the merge-on-write above) its next save would revert
// them.
baseSettings.onMount = (setAtom) => {
const handleStorageChange = (evt: StorageEvent) => {
if (evt.key !== STORAGE_KEY) return;
setAtom(getSettings());
};
window.addEventListener('storage', handleStorageChange);
return () => {
window.removeEventListener('storage', handleStorageChange);
};
};
export const settingsAtom = atom<Settings, [Settings], undefined>(
(get) => get(baseSettings),
(get, set, update) => {
const previous = get(baseSettings);
set(baseSettings, update);
setSettings(update);
setSettings(previous, update);
},
);
+107 -4
View File
@@ -1,16 +1,32 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createStore } from 'jotai';
// The module evaluates atomWithStorage(..., { getOnInit: true }) which reads
// localStorage at load time. node has none — install a no-op mock, then import
// dynamically (a static import would hoist above the mock).
const localStorageBacking = new Map<string, string>();
(globalThis as { localStorage?: unknown }).localStorage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
getItem: (k: string) => localStorageBacking.get(k) ?? null,
setItem: (k: string, v: string) => {
localStorageBacking.set(k, v);
},
removeItem: (k: string) => {
localStorageBacking.delete(k);
},
};
const { addTranslation, findTranslation, makeCacheKey } = await import('./translation');
const {
addTranslation,
findTranslation,
makeCacheKey,
translationCacheAtom,
translationEntryAtomFamily,
msgTranslationActiveAtomFamily,
wasAutoTranslateTried,
markAutoTranslateTried,
clearTranslationCache,
} = await import('./translation');
const entry = (key: string, translated = `t-${key}`, fromLang = 'de') => ({
key,
@@ -66,3 +82,90 @@ test('findTranslation: returns match or undefined', () => {
assert.equal(findTranslation(list, 'b')?.key, 'b');
assert.equal(findTranslation(list, 'z'), undefined);
});
// [Gitea #39] Keyed cache subscription: a message only re-renders for its own
// entry, not the whole array.
test('translationEntryAtomFamily: looks up only its own key, unaffected by others', () => {
const store = createStore();
store.set(translationCacheAtom, () => [entry('a'), entry('b')]);
assert.equal(store.get(translationEntryAtomFamily('a'))?.translated, 't-a');
assert.equal(store.get(translationEntryAtomFamily('z')), undefined);
});
test('translationEntryAtomFamily: does not notify a subscriber when an unrelated key changes', () => {
const store = createStore();
store.set(translationCacheAtom, () => [entry('a')]);
let notifications = 0;
const unsub = store.sub(translationEntryAtomFamily('a'), () => {
notifications += 1;
});
// atomWithStorage's `onMount` resyncs the base atom from storage as soon as
// it gets its first subscriber, which — via the JSON round trip — produces
// a structurally-equal-but-different-reference array, firing exactly one
// notification unrelated to what this test checks. Drain it before
// asserting so we're isolating the behaviour under test.
notifications = 0;
try {
// Add an unrelated entry — 'a's object reference in the array is
// preserved by addTranslation, so selectAtom's Object.is bails.
store.set(translationCacheAtom, (prev) => addTranslation(prev, entry('other')));
assert.equal(notifications, 0);
// Changing 'a' itself does notify.
store.set(translationCacheAtom, (prev) => addTranslation(prev, entry('a', 'new-a')));
assert.equal(notifications, 1);
} finally {
unsub();
}
});
test('msgTranslationActiveAtomFamily: FIFO-capped at 500 members', () => {
const store = createStore();
// Prime "e0" with non-default state.
store.set(msgTranslationActiveAtomFamily('e0'), true);
assert.equal(store.get(msgTranslationActiveAtomFamily('e0')), true);
// Create 500 more distinct members so the cap evicts "e0" (oldest-created);
// real usage is one member per mounted message regardless of translation.
for (let i = 1; i <= 500; i += 1) {
msgTranslationActiveAtomFamily(`e${i}`);
}
// Evicted members are unmounted (`.remove()`d): re-accessing "e0" creates a
// fresh atom, so it comes back at its default (inactive) value — evidence
// the family doesn't grow without bound as the timeline is scrolled.
assert.equal(store.get(msgTranslationActiveAtomFamily('e0')), false);
});
test('translationEntryAtomFamily: stays usable well past the FIFO cap', () => {
const store = createStore();
store.set(translationCacheAtom, () => [entry('k0')]);
for (let i = 1; i <= 500; i += 1) {
translationEntryAtomFamily(`k${i}`);
}
// Unlike the active family, this family is *derived* from the persisted
// cache array, so a re-created (evicted-then-reaccessed) atom still finds
// the entry — eviction here is a memory-hygiene concern, not a data-loss
// one. Confirms the cap doesn't corrupt lookups either way.
assert.equal(store.get(translationEntryAtomFamily('k0'))?.key, 'k0');
assert.equal(store.get(translationEntryAtomFamily('k250')), undefined);
});
test('clearTranslationCache: wipes storage, live family members, and the auto-translate guard', () => {
const store = createStore();
localStorageBacking.set('cinny_translation_cache_v1', '[{"key":"x"}]');
store.set(msgTranslationActiveAtomFamily('ev1'), true);
markAutoTranslateTried('ev1', 'hola');
assert.equal(wasAutoTranslateTried('ev1', 'hola'), true);
clearTranslationCache();
assert.equal(localStorageBacking.has('cinny_translation_cache_v1'), false);
// Family member was `.remove()`d, so re-accessing it yields a fresh atom
// back at its default value.
assert.equal(store.get(msgTranslationActiveAtomFamily('ev1')), false);
assert.equal(wasAutoTranslateTried('ev1', 'hola'), false);
});
+68 -5
View File
@@ -1,5 +1,5 @@
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage, atomFamily } from 'jotai/utils';
import { atomWithStorage, createJSONStorage, atomFamily, selectAtom } from 'jotai/utils';
import { normalizeLang } from '../utils/translation/langUtils';
export type CachedTranslation = {
@@ -73,15 +73,75 @@ export const addTranslation = (
return [entry, ...withoutDupe].slice(0, max);
};
// [Gitea #39] Every `atomFamily` here is capped with simple FIFO eviction:
// a message mounts one entry in each family regardless of whether it's ever
// translated, so scrolling a long-lived session used to grow both families
// (and, for the active family, forever — it was never `.remove()`d at all).
// The cap is generous relative to what's ever rendered at once, so evicting
// the oldest-*created* member (not oldest-used) doesn't touch on-screen
// messages in practice; a false eviction just means that message's atom is
// recreated at its default value next access, which is harmless here (a
// re-derived cache lookup / a re-collapsed "active" toggle).
const FAMILY_CAP = 500;
// Per-event "show translation" toggle, shared between the message menu action
// (which flips it) and the inline body render (which reacts to it). Keyed by
// event id so each message has its own independent state.
export const msgTranslationActiveAtomFamily = atomFamily((_eventId: string) => atom(false));
const activeFamilyOrder: string[] = [];
export const msgTranslationActiveAtomFamily = atomFamily((_eventId: string) => {
activeFamilyOrder.push(_eventId);
if (activeFamilyOrder.length > FAMILY_CAP) {
const oldest = activeFamilyOrder.shift();
if (oldest !== undefined) msgTranslationActiveAtomFamily.remove(oldest);
}
return atom(false);
});
/**
* Wipe the persisted translation cache. Called on logout — cached entries are
* decrypted message plaintext, so they must not survive a session on a shared
* device (mirrors the search-index wipe).
* Per-`(eventId, targetLang, textFingerprint)` cache slot, derived from the
* shared `translationCacheAtom` array via `selectAtom` with `Object.is`
* equality. `addTranslation` preserves the object reference of every entry it
* doesn't touch, so a message only re-renders when *its own* entry changes —
* fixing the render storm where translating one message re-rendered every
* mounted message subscribed to the whole array.
*/
const entryFamilyOrder: string[] = [];
export const translationEntryAtomFamily = atomFamily((key: string) => {
entryFamilyOrder.push(key);
if (entryFamilyOrder.length > FAMILY_CAP) {
const oldest = entryFamilyOrder.shift();
if (oldest !== undefined) translationEntryAtomFamily.remove(oldest);
}
return selectAtom(translationCacheAtom, (list) => findTranslation(list, key), Object.is);
});
// Auto-translate "already tried" guard, hoisted to module scope and keyed by
// event id (previously a `useRef` per hook instance, so virtualised scrolling
// re-ran `engine.detect` every time the same message remounted). Stores the
// text last auto-tried so an edit — same event id, new body — still
// re-triggers detection instead of being suppressed by a stale entry.
const AUTO_TRIED_CAP = 500;
const autoTriedTextByEvent = new Map<string, string>();
export const wasAutoTranslateTried = (eventId: string, text: string): boolean =>
autoTriedTextByEvent.get(eventId) === text;
export const markAutoTranslateTried = (eventId: string, text: string): void => {
// Re-insert so the Map's iteration (insertion) order tracks recency for
// the FIFO cap below.
autoTriedTextByEvent.delete(eventId);
autoTriedTextByEvent.set(eventId, text);
if (autoTriedTextByEvent.size > AUTO_TRIED_CAP) {
const oldest = autoTriedTextByEvent.keys().next().value;
if (oldest !== undefined) autoTriedTextByEvent.delete(oldest);
}
};
/**
* Wipe the persisted translation cache, every live family member, and the
* auto-translate guard. Called on logout — all of this is derived from
* decrypted message plaintext, so none of it must survive a session on a
* shared device (mirrors the search-index wipe).
*/
export const clearTranslationCache = (): void => {
try {
@@ -89,4 +149,7 @@ export const clearTranslationCache = (): void => {
} catch {
/* localStorage unavailable — nothing to clear */
}
activeFamilyOrder.splice(0).forEach((eventId) => msgTranslationActiveAtomFamily.remove(eventId));
entryFamilyOrder.splice(0).forEach((key) => translationEntryAtomFamily.remove(key));
autoTriedTextByEvent.clear();
};
+64 -2
View File
@@ -12,6 +12,7 @@ import {
clearAll,
deleteRow,
deleteSearchCacheDatabase,
shouldRunBootCleanup,
SearchCacheRow,
} from './searchCache';
@@ -126,7 +127,11 @@ test('searchCache IDB round-trip', { skip: !hasIdb }, async () => {
assert.equal((await queryRoom('!r1')).length, 0);
assert.equal((await queryRoom('!r2')).length, 1);
await deleteSearchCacheDatabase();
// clearAll() (not deleteSearchCacheDatabase()) so later tests in this file
// can still reopen the DB — deleteSearchCacheDatabase() now permanently
// closes this module's handle (Gitea #45), which in the real app is fine
// because logout always reloads the page right after.
await clearAll();
});
test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasIdb }, async () => {
@@ -151,7 +156,7 @@ test('deleteRow: removes only the targeted [roomId, eventId] row', { skip: !hasI
// Deleting a row that doesn't exist is a silent no-op.
await assert.doesNotReject(deleteRow('!r1', '$does-not-exist'));
await deleteSearchCacheDatabase();
await clearAll();
});
test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb }, async () => {
@@ -168,3 +173,60 @@ test('resilient helpers never throw when IDB is unavailable', { skip: hasIdb },
await assert.doesNotReject(clearAll());
await assert.doesNotReject(deleteSearchCacheDatabase());
});
// --- Gitea #45: boot-time completion wipe decision --------------------------
test('shouldRunBootCleanup: only when signed out and not already run this boot', () => {
assert.equal(shouldRunBootCleanup(false, false), true); // no session, first time
assert.equal(shouldRunBootCleanup(true, false), false); // signed in — never wipe a live cache
assert.equal(shouldRunBootCleanup(false, true), false); // already ran this boot
assert.equal(shouldRunBootCleanup(true, true), false);
});
// --- Gitea #45: closed-flag gating ------------------------------------------
//
// A minimal fake `indexedDB` that only supports what `deleteSearchCacheDatabase()`
// and the `openDb()` open path touch, so this test doesn't need a full
// IDBTransaction mock: it only needs to prove that once
// `deleteSearchCacheDatabase()` has run, nothing calls `indexedDB.open()`
// again in this module instance — the actual guarantee the fix provides.
//
// Note: `closed` is process-wide module state, and other tests above already
// call `deleteSearchCacheDatabase()` (harmlessly, since `indexedDB` was
// `undefined` when they ran), so this module may already be "closed" by the
// time this test runs. That's fine — the property under test doesn't depend
// on the prior state: after (another) `deleteSearchCacheDatabase()` call,
// `indexedDB.open()` must never fire again, however many callers still try.
test('closed-flag gating: deleteSearchCacheDatabase() stops the DB from reopening', async () => {
let openCalls = 0;
const previousIdb = (globalThis as { indexedDB?: unknown }).indexedDB;
(globalThis as { indexedDB?: unknown }).indexedDB = {
open: () => {
openCalls += 1;
const req: Record<string, unknown> = { result: {} };
// openDb() assigns onupgradeneeded/onsuccess/onerror/onblocked
// synchronously after calling open(); fire success on the next tick so
// those handlers are already attached.
setTimeout(() => (req.onsuccess as (() => void) | undefined)?.(), 0);
return req;
},
deleteDatabase: () => {
const req: Record<string, unknown> = {};
setTimeout(() => (req.onsuccess as (() => void) | undefined)?.(), 0);
return req;
},
};
try {
await deleteSearchCacheDatabase();
openCalls = 0; // isolate: only count opens attempted AFTER closing
// Every subsequent read must short-circuit to a cache-miss without ever
// calling indexedDB.open() again.
assert.deepEqual(await queryRoom('!gate'), []);
await putRows([{ roomId: '!gate', eventId: '$1', ts: 1, sender: '@a', body: 'x' }]);
assert.equal(openCalls, 0);
} finally {
(globalThis as { indexedDB?: unknown }).indexedDB = previousIdb;
}
});
+70
View File
@@ -55,7 +55,50 @@ const roomRange = (roomId: string): IDBKeyRange => IDBKeyRange.bound([roomId], [
let dbPromise: Promise<IDBDatabase | null> | null = null;
// Gitea #45 — `deleteSearchCacheDatabase()` used to resolve as soon as its
// bounded `onblocked` wait elapsed, even though another tab still had the DB
// open, so the delete stayed silently queued while that tab could keep
// `put`-ing fresh decrypted rows (or immediately reopen the DB once the
// delete eventually landed). `closed` is broadcast to every tab the moment a
// wipe starts: once set, `openDb()` refuses to (re)open a handle — including
// for an already in-flight `saveRoomIndex()` awaiting its next IDB
// round-trip — so nothing can repopulate the DB out from under the delete.
let closed = false;
const LOGOUT_CHANNEL_NAME = 'lotus-logout';
const LOGOUT_MESSAGE = 'lotus-logout';
/** Stop touching the DB and release our handle. Idempotent, never throws. */
const handleLogoutSignal = (): void => {
closed = true;
const pending = dbPromise;
dbPromise = null;
if (pending) {
pending.then((db) => db?.close()).catch(() => undefined);
}
};
const logoutChannel: BroadcastChannel | null = (() => {
try {
if (typeof BroadcastChannel === 'undefined') return null;
const channel = new BroadcastChannel(LOGOUT_CHANNEL_NAME);
channel.onmessage = (ev: MessageEvent) => {
if (ev.data === LOGOUT_MESSAGE) handleLogoutSignal();
};
// Node's BroadcastChannel (unlike the browser's) keeps the event loop
// alive while open, which would hang `node --test`. unref() is a
// Node-only extension — no-op via optional chaining in the browser.
(channel as unknown as { unref?: () => void }).unref?.();
return channel;
} catch {
return null;
}
})();
const openDb = (): Promise<IDBDatabase | null> => {
// Once logout has broadcast a wipe, this module must never reopen the DB —
// otherwise a write racing the delete could recreate it right after.
if (closed) return Promise.resolve(null);
if (dbPromise) return dbPromise;
dbPromise = new Promise<IDBDatabase | null>((resolve) => {
try {
@@ -335,8 +378,23 @@ export const clearAll = async (): Promise<void> => {
* Drop the entire on-disk database. Wired into the logout path by the
* coordinator (initMatrix) so no decrypted plaintext lingers after sign-out.
* Closes any open handle first so the delete is not blocked. Never throws.
*
* Gitea #45 — before touching IDB at all, broadcast the wipe on
* `lotus-logout` so every other tab closes its handle and stops writing
* (`handleLogoutSignal`/`closed` above); that tab's in-flight
* `saveRoomIndex()` short-circuits on its next IDB call instead of
* repopulating a DB we're about to delete or racing back in right after. The
* bounded `onblocked` wait stays as a last-resort fallback (a tab that hasn't
* processed the broadcast yet, or a browser without BroadcastChannel), not
* the primary coordination mechanism.
*/
export const deleteSearchCacheDatabase = async (): Promise<void> => {
closed = true;
try {
logoutChannel?.postMessage(LOGOUT_MESSAGE);
} catch {
// ignore
}
try {
const existing = dbPromise ? await dbPromise : null;
if (existing) existing.close();
@@ -373,3 +431,15 @@ export const deleteSearchCacheDatabase = async (): Promise<void> => {
}
});
};
/**
* Pure decision for the boot-time completion wipe (Gitea #45): a delete that
* lost the `onblocked` race leaves `lotus-search-cache` on disk even though
* the user is signed out. Re-run the wipe next boot, but only when there is
* no session (never nuke a live, signed-in cache) and only once per boot
* (the caller's own re-checks — e.g. route changes — must not repeat it).
* Exported for testing without a DOM/session; the actual boot wiring lives in
* initMatrix.ts.
*/
export const shouldRunBootCleanup = (hasSession: boolean, alreadyRan: boolean): boolean =>
!hasSession && !alreadyRan;
+16 -1
View File
@@ -6,7 +6,7 @@ import { getFallbackSession, removeFallbackSession, Session } from '../app/state
import { LotusOidcTokenRefresher } from './oidcTokenRefresher';
import { revokeOidcTokens } from './oidcLogout';
import { pushSessionToSW } from '../sw-session';
import { deleteSearchCacheDatabase } from '../app/utils/searchCache';
import { deleteSearchCacheDatabase, shouldRunBootCleanup } from '../app/utils/searchCache';
import { clearPlaintextCaches } from '../app/state/plaintextCaches';
import { clearSoundboardClipCache } from '../app/utils/soundboardClips';
@@ -141,6 +141,21 @@ export const logoutClient = async (mx: MatrixClient) => {
window.location.reload();
};
// Gitea #45 — deleteSearchCacheDatabase()'s onblocked handling is a bounded
// best-effort wait; if another tab held `lotus-search-cache` open through it
// (the pre-broadcast case, or a browser without BroadcastChannel), the delete
// stayed queued and plaintext survived on disk despite logout "succeeding".
// Complete it on the next boot with no session, once per boot — call this
// wherever the app decides there is no session to hydrate (e.g. alongside the
// existing getFallbackSession() checks in src/index.tsx).
let bootCleanupRan = false;
export const cleanupSearchCacheIfSignedOut = async (): Promise<void> => {
const hasSession = !!getFallbackSession();
if (!shouldRunBootCleanup(hasSession, bootCleanupRan)) return;
bootCleanupRan = true;
await deleteSearchCacheDatabase();
};
export const clearLoginData = async () => {
const dbs = await window.indexedDB.databases();
+7
View File
@@ -17,6 +17,7 @@ import App from './app/pages/App';
import './app/i18n';
import { pushSessionToSW } from './sw-session';
import { getFallbackSession } from './app/state/sessions';
import { cleanupSearchCacheIfSignedOut } from './client/initMatrix';
document.body.classList.add(configClass, varsClass);
@@ -51,6 +52,12 @@ if ('serviceWorker' in navigator) {
// already exists" upload storm and E2EE breakage. Only ask for sessions worth
// protecting (skip anonymous/landing visitors to avoid a needless Firefox
// prompt); check persisted() first so we don't re-prompt. Best-effort.
// [Gitea #45] If a logout's search-index wipe lost the multi-tab race (another
// tab still held the DB), finish it now that no session exists.
if (!getFallbackSession()) {
cleanupSearchCacheIfSignedOut().catch(() => undefined);
}
if (navigator.storage?.persist && getFallbackSession()) {
navigator.storage
.persisted()