Compare commits
2
Commits
15d85f52c4
...
1176bea0ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1176bea0ee | ||
|
|
477df4ae32 |
@@ -143,6 +143,10 @@ The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the
|
|||||||
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
|
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
|
||||||
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
|
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
|
||||||
|
|
||||||
|
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
|
||||||
|
|
||||||
|
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
|
||||||
|
|
||||||
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
|
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
|
||||||
|
|
||||||
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
|
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
|
||||||
|
|||||||
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||||
Transforms.select(editor, selectRange);
|
// Wrap the whole sequence: on a stale autocomplete range (the document changed
|
||||||
Transforms.insertNodes(editor, element);
|
// between the menu opening and the pick) `insertNodes` — not `select`, which is
|
||||||
Transforms.collapse(editor, {
|
// lazy in this Slate version — can throw. This runs inside the pick's event
|
||||||
edge: 'end',
|
// handler, so an escape wouldn't hit the error boundary, but keep it contained.
|
||||||
});
|
try {
|
||||||
|
Transforms.select(editor, selectRange);
|
||||||
|
Transforms.insertNodes(editor, element);
|
||||||
|
Transforms.collapse(editor, { edge: 'end' });
|
||||||
|
} catch {
|
||||||
|
/* stale range — the pick is a no-op rather than an uncaught error */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
||||||
// Defer to the next tick so React can flush any pending void-element DOM
|
// Move the caret out of the just-inserted inline void and land it in a real
|
||||||
// updates (e.g. after inserting a mention) before Slate resolves cursor
|
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
|
||||||
// positions via ReactEditor.toDOMNode — otherwise Slate throws
|
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
|
||||||
// "Cannot resolve a DOM node from slate node".
|
// INSIDE the void's inner text node; insertText there is a no-op, blocked by
|
||||||
|
// Slate's void guard). The space then lands in a real text node.
|
||||||
|
// Doing this in the same commit (vs the old deferred setTimeout) means the
|
||||||
|
// caret never sits on the void's zero-width edge on a racy tick — that edge's
|
||||||
|
// DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase
|
||||||
|
// selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped
|
||||||
|
// the composer error boundary. Both ops are pure model transforms (no DOM
|
||||||
|
// resolution), so running them synchronously is safe.
|
||||||
|
Transforms.move(editor);
|
||||||
|
if (withSpace) editor.insertText(' ');
|
||||||
|
// Re-assert focus next tick (a pick usually keeps the editor focused). Guarded
|
||||||
|
// because ReactEditor.focus resolves the DOM; with the caret now in a real text
|
||||||
|
// node this is safe, but stay defensive against a mid-flight editor.
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
ReactEditor.focus(editor);
|
try {
|
||||||
Transforms.move(editor);
|
ReactEditor.focus(editor);
|
||||||
if (withSpace) editor.insertText(' ');
|
} catch {
|
||||||
|
// The editor DOM can be mid-flight (autocomplete just closed / re-render
|
||||||
|
// landed). The element is already inserted, so skip the focus nudge.
|
||||||
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useCallback, useMemo, useRef } from 'react';
|
import React, { useCallback, useMemo, useRef } from 'react';
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
import { Box, Text, config } from 'folds';
|
import { Box, Button, Text, config } from 'folds';
|
||||||
import { EventType } from 'matrix-js-sdk';
|
import { EventType } from 'matrix-js-sdk';
|
||||||
import { ReactEditor } from 'slate-react';
|
import { ReactEditor } from 'slate-react';
|
||||||
|
import { Transforms } from 'slate';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
<>
|
<>
|
||||||
{canMessage && (
|
{canMessage && (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={
|
onReset={() => {
|
||||||
|
// The composer crash is a transient bad-selection render
|
||||||
|
// (e.g. after an autocomplete insert); the draft content is
|
||||||
|
// intact. Clear the selection so the remounted composer can
|
||||||
|
// render — the user clicks in to continue, no page refresh.
|
||||||
|
try {
|
||||||
|
Transforms.deselect(editor);
|
||||||
|
} catch {
|
||||||
|
/* editor already in a safe state */
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
fallbackRender={({ resetErrorBoundary }) => (
|
||||||
<RoomInputPlaceholder
|
<RoomInputPlaceholder
|
||||||
|
role="alert"
|
||||||
style={{ padding: config.space.S200 }}
|
style={{ padding: config.space.S200 }}
|
||||||
|
direction="Column"
|
||||||
alignItems="Center"
|
alignItems="Center"
|
||||||
justifyContent="Center"
|
justifyContent="Center"
|
||||||
|
gap="200"
|
||||||
>
|
>
|
||||||
<Text align="Center">
|
<Text align="Center">The message composer hit a snag.</Text>
|
||||||
Message composer encountered an error. Try refreshing.
|
<Button
|
||||||
</Text>
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
onClick={resetErrorBoundary}
|
||||||
|
>
|
||||||
|
<Text size="B300">Reload composer</Text>
|
||||||
|
</Button>
|
||||||
</RoomInputPlaceholder>
|
</RoomInputPlaceholder>
|
||||||
}
|
)}
|
||||||
>
|
>
|
||||||
<RoomInput
|
<RoomInput
|
||||||
room={room}
|
room={room}
|
||||||
|
|||||||
Reference in New Issue
Block a user