Compare commits

..
2 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 14b6849f6e fix(search): "Clear cached index" was undone immediately by the active search re-persisting its in-memory rows (#184 O4)
CI / Build & Quality Checks (push) Successful in 1m50s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 33s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m2s
Clearing bumps cacheVersion so the search re-runs, and the re-run wrote
the scanned rows straight back to IndexedDB, so the index was never empty
while the button was visible. Skip persistence for that one re-run.
Verified: IDB stores go to 0/0 after Clear; logout still deletes the DB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 18:34:55 -04:00
jaredandClaude Opus 5 22371f8156 fix(composer): no LaTeX conversion inside a typed markdown fence or backtick span (#184 O3)
In markdown mode each paragraph line is serialised before parseBlockMD
joins them, so $x$ inside a ``` fence became data-mx-maths markup
inside the resulting <pre><code> (rendered as math in a code block).
Track fence state across lines and skip math for fenced lines and for
backtick code spans. Unit tests added; verified in the browser.

Also: scripts/dev-homeserver.sh enables MSC4140 delayed events so
scheduled messages work locally.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 18:26:15 -04:00
5 changed files with 67 additions and 2 deletions
+4
View File
@@ -43,6 +43,10 @@ rc_joins: { local: { per_second: 1000, burst_count: 10000 }, remote: { per_secon
rc_presence: { per_user: { per_second: 1000, burst_count: 10000 } }
max_upload_size: 50M
suppress_key_server_warning: true
# scheduled messages (MSC4140 delayed events)
max_event_delay_duration: 24h
experimental_features:
msc4140_enabled: true
"""
open(p, "w").write(s)
PY
+24
View File
@@ -65,3 +65,27 @@ test('no math conversion inside a code block', () => {
test('a message with no math is unchanged', () => {
assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello');
});
// Gitea #184 O3 — markdown mode: fences and backtick spans typed as plain
// paragraphs are literal too (the block markdown parser only sees the fence
// after the lines are joined, so math must be skipped while serialising them).
const MD_OPTS = { ...OPTS, allowInlineMarkdown: true, allowBlockMarkdown: true };
test('markdown: no math conversion inside a typed ``` fence', () => {
const paragraphs = [
el(BlockType.Paragraph, [txt('```')]),
el(BlockType.Paragraph, [txt('$x$ literal')]),
el(BlockType.Paragraph, [txt('```')]),
el(BlockType.Paragraph, [txt('after $y$')]),
];
const out = toMatrixCustomHTML(paragraphs, MD_OPTS);
assert.ok(out.includes('<pre'), 'fence became a code block');
assert.ok(out.includes('$x$ literal'), 'code block content stays literal');
assert.ok(out.includes('data-mx-maths="y"'), 'math after the fence still converts');
});
test('markdown: no math conversion inside a backtick span, math outside still converts', () => {
const out = toMatrixCustomHTML(el(BlockType.Paragraph, [txt('use `$x$` and $y$')]), MD_OPTS);
assert.ok(/<code[^>]*>\$x\$<\/code>/.test(out), 'backtick span stays literal');
assert.ok(out.includes('data-mx-maths="y"'));
});
+20
View File
@@ -36,6 +36,18 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
// applied inside inline code. Non-math text recurses with allowMath off so it
// still gets the normal marks + inline-markdown treatment.
if (opts.allowMath && !node.code) {
// Markdown inline code spans (`…`) are literal too: apply math only to the
// text between them (Gitea #184 O3 — `$x$` inside backticks stayed math).
if (opts.allowInlineMarkdown && /`[^`]*`/.test(node.text)) {
return node.text
.split(/(`+[^`]*`+)/)
.map((part) =>
part.startsWith('`')
? textToCustomHtml({ ...node, text: part }, { ...opts, allowMath: false })
: textToCustomHtml({ ...node, text: part }, opts),
)
.join('');
}
const segments = splitMathSegments(node.text);
if (segments.some((seg) => seg.type !== 'text')) {
return segments
@@ -128,12 +140,20 @@ export const toMatrixCustomHTML = (
opts: OutputOptions,
): string => {
let markdownLines = '';
// Inside a markdown ``` fence every line is literal: no `$…$` math conversion
// (the fence is only recognised by parseBlockMD after the lines are joined,
// which is too late — Gitea #184 O3).
let inFence = false;
const parseNode = (n: Descendant, index: number, targetNodes: Descendant[]) => {
if (opts.allowBlockMarkdown && 'type' in n && n.type === BlockType.Paragraph) {
const isFenceLine = /^\s*```/.test(toPlainText(n, false));
const literal = inFence || isFenceLine;
if (isFenceLine) inFence = !inFence;
const line = toMatrixCustomHTML(n, {
...opts,
allowInlineMarkdown: false,
allowBlockMarkdown: false,
allowMath: opts.allowMath && !literal,
})
.replace(/<br\/>$/, '\n')
.replace(/^(\\*)&gt;/, '$1>');
@@ -246,8 +246,15 @@ export function MessageSearch({
const [cacheVersion, setCacheVersion] = useState(0);
const handleCacheLoaded = useCallback(() => setCacheVersion((v) => v + 1), []);
// Explicit wipe of the persistent on-disk index, then re-run the merge.
// After a clear, the active search re-runs (cacheVersion) and would persist
// the in-memory rows straight back — skip that one write so "Clear cached
// index" actually leaves the disk empty until the next new search.
const skipPersistOnceRef = useRef(false);
const handleClearSearchCache = useCallback(() => {
clearSearchCache().then(() => setCacheVersion((v) => v + 1));
clearSearchCache().then(() => {
skipPersistOnceRef.current = true;
setCacheVersion((v) => v + 1);
});
}, []);
// The rooms actually in scope for this search (mirrors server-side logic)
@@ -277,12 +284,15 @@ export function MessageSearch({
return undefined;
}
let cancelled = false;
const persist = !skipPersistOnceRef.current;
skipPersistOnceRef.current = false;
searchLocalMessages({
term: msgSearchParams.term ?? '',
roomIds: localSearchRooms,
senders: msgSearchParams.senders,
fromTs: msgSearchParams.fromTs,
toTs: msgSearchParams.toTs,
persist,
}).then((result) => {
if (!cancelled) setLocalResult(result);
});
@@ -18,6 +18,12 @@ export type LocalSearchParams = {
/** Optional date-range filter (ms). Applied to both memory and cached rows. */
fromTs?: number;
toTs?: number;
/**
* Set false to run the scan without writing it back to IndexedDB — used
* right after "Clear cached index" so the clear is not undone by the
* re-run of the active search (Gitea #184 O4).
*/
persist?: boolean;
};
export type LocalSearchResult = {
@@ -149,6 +155,7 @@ export const useLocalMessageSearch = () => {
senders,
fromTs,
toTs,
persist = true,
}: LocalSearchParams): Promise<LocalSearchResult> => {
const trimmedTerm = term.trim();
const senderSet = senders && senders.length > 0 ? new Set(senders) : null;
@@ -302,7 +309,7 @@ export const useLocalMessageSearch = () => {
// Fire-and-forget persist of freshly scanned rows + coverage.
// saveRoomIndex swallows all errors internally, so a floating promise
// here can never reject.
if (cacheEnabled && rowsToPersist.length > 0) {
if (cacheEnabled && persist && rowsToPersist.length > 0) {
saveRoomIndex(roomId, rowsToPersist);
}
}