CI / Build & Quality Checks (push) Successful in 6m18s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Canceled after 14m59s
RoomView focuses the composer on any "typing" key pressed while nothing editable has focus, and Escape counted as typing. Closing the GIF picker with Esc (its focus trap returns focus to the GIF button) therefore landed in the composer instead, and so did Esc on any other room control. Escape, CapsLock, Insert, ContextMenu, PrintScreen and Pause are now excluded like the other non-typing keys. Verified at 1300 px and 320 px: Esc closes the picker and focus is back on "Insert GIF"; the 320 px picker fits with no page overflow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
225 lines
8.3 KiB
TypeScript
225 lines
8.3 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' ||
|
|
// 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 && (
|
|
<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>
|
|
);
|
|
}
|