Picking an autocomplete item (mention/emoji/command — all inline voids)
occasionally tripped the composer error boundary, forcing a page refresh, even
though the element had already inserted. Root cause (traced through slate-react):
moveCursor deferred its cursor work to setTimeout(0), leaving the caret on the
just-inserted void's zero-width edge whose DOM (a U+FEFF node) isn't populated on
that tick. slate-react's commit-phase selection sync then calls
setBaseAndExtent(voidEdge, 1) and throws IndexSizeError mid-render → boundary.
Prevention: do the cursor work SYNCHRONOUSLY, in the same commit as the insert —
Transforms.move (escapes the void into the real trailing text node) then
insertText(' '). The caret is then always a resolvable text point when the
selection sync runs. (moveCursor's focus stays deferred+guarded, unchanged.)
Recovery (belt-and-suspenders): the composer error boundary is now recoverable —
a "Reload composer" button (resetErrorBoundary) + onReset Transforms.deselect
clears a transient bad selection so it remounts with the draft intact, no page
refresh. + role="alert" for screen readers.
Three review agents: two root-caused the exact slate-react throw and proved the
try/catch-only version merely recovered; a third reproduced the transforms
headlessly and caught that a first "sync insertText WITHOUT move" attempt hit
Slate's void guard (space dropped, caret trapped) — the move is required to
escape the void. Not unit-testable (needs the live DOM + the timing race).
Gate-green (tsc, eslint, prettier, 925 tests, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
215 lines
7.8 KiB
TypeScript
215 lines
7.8 KiB
TypeScript
import React, { useCallback, useMemo, useRef } from 'react';
|
|
import { ErrorBoundary } from 'react-error-boundary';
|
|
import { Box, Button, Text, config } from 'folds';
|
|
import { EventType } from 'matrix-js-sdk';
|
|
import { ReactEditor } from 'slate-react';
|
|
import { Transforms } from 'slate';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
|
import { StateEvent } from '../../../types/matrix/room';
|
|
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
|
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
|
import { useEditor } from '../../components/editor';
|
|
import { RoomInputPlaceholder } from './RoomInputPlaceholder';
|
|
import { RoomTimeline } from './RoomTimeline';
|
|
import { RoomViewTyping } from './RoomViewTyping';
|
|
import { RoomTombstone } from './RoomTombstone';
|
|
import { RoomInput } from './RoomInput';
|
|
import { RoomViewFollowing, RoomViewFollowingPlaceholder } from './RoomViewFollowing';
|
|
import { Page } from '../../components/page';
|
|
import { useSetting } from '../../state/hooks/settings';
|
|
import { settingsAtom } from '../../state/settings';
|
|
import { useTheme, ThemeKind } from '../../hooks/useTheme';
|
|
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
|
import { getChatBg } from '../lotus/chatBackground';
|
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
|
import { editableActiveElement } from '../../utils/dom';
|
|
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
|
import { useRoom } from '../../hooks/useRoom';
|
|
|
|
const FN_KEYS_REGEX = /^F\d+$/;
|
|
const shouldFocusMessageField = (evt: KeyboardEvent): boolean => {
|
|
const { code } = evt;
|
|
if (evt.metaKey || evt.altKey || evt.ctrlKey) {
|
|
return false;
|
|
}
|
|
|
|
if (FN_KEYS_REGEX.test(code)) return false;
|
|
|
|
if (
|
|
code.startsWith('OS') ||
|
|
code.startsWith('Meta') ||
|
|
code.startsWith('Shift') ||
|
|
code.startsWith('Alt') ||
|
|
code.startsWith('Control') ||
|
|
code.startsWith('Arrow') ||
|
|
code.startsWith('Page') ||
|
|
code.startsWith('End') ||
|
|
code.startsWith('Home') ||
|
|
code === 'Tab' ||
|
|
code === 'Space' ||
|
|
code === 'Enter' ||
|
|
code === 'NumLock' ||
|
|
code === 'ScrollLock'
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
export function RoomView({ eventId }: { eventId?: string }) {
|
|
const roomInputRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
|
const roomViewRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
|
const theme = useTheme();
|
|
const isDark = theme.kind === ThemeKind.Dark;
|
|
const reduced = useReducedMotion();
|
|
|
|
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
|
|
const room = useRoom();
|
|
const { roomId } = room;
|
|
const editor = useEditor();
|
|
|
|
const mx = useMatrixClient();
|
|
|
|
const tombstoneEvent = useStateEvent(room, StateEvent.RoomTombstone);
|
|
const powerLevels = usePowerLevelsContext();
|
|
const creators = useRoomCreators(room);
|
|
|
|
const permissions = useRoomPermissions(creators, powerLevels);
|
|
const canMessage = permissions.event(EventType.RoomMessage, mx.getSafeUserId());
|
|
|
|
useKeyDown(
|
|
window,
|
|
useCallback(
|
|
(evt) => {
|
|
if (editableActiveElement()) return;
|
|
const portalContainer = document.getElementById('portalContainer');
|
|
if (portalContainer && portalContainer.children.length > 0) {
|
|
return;
|
|
}
|
|
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
|
|
ReactEditor.focus(editor);
|
|
}
|
|
},
|
|
[editor],
|
|
),
|
|
);
|
|
|
|
// Apply the background directly to Page so it overrides PageRoot's opaque
|
|
// Background.Container color. SidebarNav mirrors it onto document.body separately
|
|
// so the glassmorphism sidebar can blur through it.
|
|
const chatBgStyle = useMemo(() => {
|
|
if (chatBackground !== 'none')
|
|
return getChatBg(chatBackground, isDark, pauseAnimations || reduced);
|
|
if (lotusTerminal) return getChatBg('tactical', isDark, pauseAnimations || reduced);
|
|
return {};
|
|
}, [chatBackground, lotusTerminal, isDark, pauseAnimations, reduced]);
|
|
|
|
return (
|
|
<Page ref={roomViewRef} style={chatBgStyle}>
|
|
<Box grow="Yes" direction="Column">
|
|
<ErrorBoundary
|
|
fallback={
|
|
<Box
|
|
grow="Yes"
|
|
direction="Column"
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
gap="400"
|
|
style={{ padding: config.space.S400, opacity: 0.7 }}
|
|
>
|
|
<Text size="H4">Timeline unavailable</Text>
|
|
<Text size="T300" align="Center">
|
|
An error occurred while rendering messages. Try refreshing the page.
|
|
</Text>
|
|
</Box>
|
|
}
|
|
>
|
|
<RoomTimeline
|
|
key={roomId}
|
|
room={room}
|
|
eventId={eventId}
|
|
roomInputRef={roomInputRef}
|
|
editor={editor}
|
|
/>
|
|
</ErrorBoundary>
|
|
<RoomViewTyping room={room} />
|
|
</Box>
|
|
<Box shrink="No" direction="Column">
|
|
<div style={{ padding: `0 ${config.space.S400}` }}>
|
|
{tombstoneEvent ? (
|
|
<RoomTombstone
|
|
roomId={roomId}
|
|
body={tombstoneEvent.getContent().body}
|
|
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
|
/>
|
|
) : (
|
|
<>
|
|
{canMessage && (
|
|
<ErrorBoundary
|
|
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
|
|
role="alert"
|
|
style={{ padding: config.space.S200 }}
|
|
direction="Column"
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
gap="200"
|
|
>
|
|
<Text align="Center">The message composer hit a snag.</Text>
|
|
<Button
|
|
size="300"
|
|
variant="Secondary"
|
|
fill="Soft"
|
|
radii="300"
|
|
onClick={resetErrorBoundary}
|
|
>
|
|
<Text size="B300">Reload composer</Text>
|
|
</Button>
|
|
</RoomInputPlaceholder>
|
|
)}
|
|
>
|
|
<RoomInput
|
|
room={room}
|
|
editor={editor}
|
|
roomId={roomId}
|
|
fileDropContainerRef={roomViewRef}
|
|
ref={roomInputRef}
|
|
/>
|
|
</ErrorBoundary>
|
|
)}
|
|
{!canMessage && (
|
|
<RoomInputPlaceholder
|
|
style={{ padding: config.space.S200 }}
|
|
alignItems="Center"
|
|
justifyContent="Center"
|
|
>
|
|
<Text align="Center">You do not have permission to post in this room</Text>
|
|
</RoomInputPlaceholder>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
{hideActivity ? <RoomViewFollowingPlaceholder /> : <RoomViewFollowing room={room} />}
|
|
</Box>
|
|
</Page>
|
|
);
|
|
}
|