Compare commits
6
Commits
62595ef066
...
908e735933
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
908e735933 | ||
|
|
ceada3e113 | ||
|
|
9c1c29f4fc | ||
|
|
9e566807b3 | ||
|
|
e0861849b7 | ||
|
|
bec248b228 |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user