Files
cinny/src/app/features/room/RoomView.tsx
T
Lotus CIandClaude Opus 5.5 4c9af57a97
CI / Build & Quality Checks (push) Successful in 1m49s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 3m3s
fix(composer): recover silently from a composer render error; capture details
Reported on chat.lotusguild.org: picking a name from the @-mention list
showed "The message composer hit a snag." — clicking OK continued with the
draft intact. The composer's known failure mode is a transient render error
right after an autocomplete insert (slate-react's DOM selection sync racing
the model; 477df4ae fixed one such path). It did not reproduce locally in 60
attempts (click / Enter / Tab, fast and slow, display names with spaces and
emoji, Chromium and WebKit), so this makes it harmless and diagnosable:

- ComposerErrorBoundary: the first failure clears the selection and remounts
  the composer immediately, with no notice (the draft is intact). A second
  failure within 5 s shows the notice, now saying the draft is safe, with
  "Reload composer" and "Copy details" (time, error, stack, component stack,
  browser). Every failure is logged to the console as "[composer] render
  error".
- Used for the room composer and, newly, the thread panel composer (which had
  no boundary, so the same error took the whole panel down).

Verified with a temporary injected crash (not committed): one crash recovers
with the draft kept and no notice; two within 5 s show the notice, Copy
details copies the report, Reload composer restores the full draft.
Chromium e2e 19 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-25 18:08:33 -04:00

192 lines
6.9 KiB
TypeScript

import React, { useCallback, useMemo, useRef } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { Box, Text, config } from 'folds';
import { EventType } from 'matrix-js-sdk';
import { ReactEditor } from 'slate-react';
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 { ComposerErrorBoundary } from './ComposerErrorBoundary';
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' ||
// Not typing: Escape in particular dismisses popouts, and a focus-trap
// hands focus back to the opener on Escape. Treating it as "start typing"
// yanked focus into the composer instead (#187 DP9: closing the GIF picker
// with Esc left focus in the composer, not on the GIF button).
code === 'Escape' ||
code === 'CapsLock' ||
code === 'Insert' ||
code === 'ContextMenu' ||
code === 'PrintScreen' ||
code === 'Pause'
) {
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 && (
<ComposerErrorBoundary editor={editor}>
<RoomInput
room={room}
editor={editor}
roomId={roomId}
fileDropContainerRef={roomViewRef}
ref={roomInputRef}
/>
</ComposerErrorBoundary>
)}
{!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>
);
}