213 lines
8.2 KiB
TypeScript
213 lines
8.2 KiB
TypeScript
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
import { Box, Text, color, config } from 'folds';
|
|
import { EventType } from 'matrix-js-sdk';
|
|
import { ReactEditor } from 'slate-react';
|
|
import { isKeyHotkey } from 'is-hotkey';
|
|
import classNames from 'classnames';
|
|
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 { RoomTombstone } from './RoomTombstone';
|
|
import { RoomInput } from './RoomInput';
|
|
import { Page } from '../../components/page';
|
|
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';
|
|
import { useThreadDrawerOpen } from '../../hooks/useChannelsMode';
|
|
import * as css from './RoomView.css';
|
|
|
|
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);
|
|
const roomViewRef = useRef<HTMLDivElement>(null);
|
|
const composerWrapRef = useRef<HTMLDivElement>(null);
|
|
// Live composer height — RoomTimeline mirrors it as `paddingBottom` so
|
|
// the last message stays flush above the overlay.
|
|
const [composerHeight, setComposerHeight] = useState(0);
|
|
// Native scroll-aware composer (Android Capacitor only). RoomTimeline
|
|
// owns the scroll element and reports direction; we translate the wrap
|
|
// via the `data-hidden` attribute on the CSS class. paddingBottom on
|
|
// the timeline stays unchanged so the message column doesn't reflow.
|
|
const [composerHidden, setComposerHidden] = useState(false);
|
|
|
|
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());
|
|
|
|
// M2: when the thread drawer is open, the drawer hosts its own
|
|
// composer (RoomInput) for thread replies. We must NOT mount a
|
|
// second RoomInput here because (a) two `<Slate>` editors crash on
|
|
// cold-load with the shared module-level initial value (slate#6016)
|
|
// and (b) the channel composer's local-echo would still subscribe to
|
|
// room.timeline events and visibly react to the user's thread reply.
|
|
// Element-web uses the same single-composer-at-a-time pattern via
|
|
// `TimelineRenderingType.Thread` context.
|
|
const threadDrawerOpen = useThreadDrawerOpen();
|
|
|
|
useEffect(() => {
|
|
const el = composerWrapRef.current;
|
|
if (!el) {
|
|
setComposerHeight(0);
|
|
return undefined;
|
|
}
|
|
setComposerHeight(el.getBoundingClientRect().height);
|
|
const ro = new ResizeObserver((entries) => {
|
|
const entry = entries[0];
|
|
if (entry) setComposerHeight(entry.contentRect.height);
|
|
});
|
|
ro.observe(el);
|
|
return () => ro.disconnect();
|
|
}, [threadDrawerOpen, tombstoneEvent, canMessage]);
|
|
|
|
// Reset hidden state before paint when the room or drawer changes, so a
|
|
// composer-hidden tail from the previous surface can't flash through the
|
|
// 220ms transition on remount.
|
|
useLayoutEffect(() => {
|
|
setComposerHidden(false);
|
|
}, [roomId, threadDrawerOpen]);
|
|
|
|
useKeyDown(
|
|
window,
|
|
useCallback(
|
|
(evt) => {
|
|
if (editableActiveElement()) return;
|
|
const portalContainer = document.getElementById('portalContainer');
|
|
if (portalContainer && portalContainer.children.length > 0) {
|
|
return;
|
|
}
|
|
// Drawer-active: this RoomInput is unmounted; ReactEditor.focus
|
|
// on it silently no-ops, but the user's keystroke would bypass
|
|
// the drawer composer instead of routing there. Skip the auto-
|
|
// focus so the keystroke falls through to whatever has focus
|
|
// (typically the drawer composer's CustomEditor when open).
|
|
if (threadDrawerOpen) return;
|
|
if (shouldFocusMessageField(evt) || isKeyHotkey('mod+v', evt)) {
|
|
ReactEditor.focus(editor);
|
|
}
|
|
},
|
|
[editor, threadDrawerOpen]
|
|
)
|
|
);
|
|
|
|
// Composer renders as a true overlay (absolute, bottom-stuck) above the
|
|
// timeline so messages can scroll behind it — WhatsApp / Telegram pattern.
|
|
// RoomTimeline takes `bottomOverlayHeight` as a prop and both (a) reserves
|
|
// bottom scroll-padding for the overlay so the latest message sits flush
|
|
// above it at rest and (b) re-anchors scrollTop in a layout effect when
|
|
// the height transitions (e.g. composer measures async after mount, multi-
|
|
// line typing, reply-preview opens).
|
|
return (
|
|
<Page
|
|
ref={roomViewRef}
|
|
style={{ backgroundColor: color.SurfaceVariant.Container, position: 'relative' }}
|
|
>
|
|
<Box grow="Yes" direction="Column">
|
|
<RoomTimeline
|
|
key={roomId}
|
|
room={room}
|
|
eventId={eventId}
|
|
roomInputRef={roomInputRef}
|
|
editor={editor}
|
|
bottomOverlayHeight={composerHeight}
|
|
onComposerHiddenChange={setComposerHidden}
|
|
/>
|
|
</Box>
|
|
{!threadDrawerOpen && (
|
|
<div
|
|
ref={composerWrapRef}
|
|
// zIndex (in css.ComposerOverlay) must beat the Stream-rail dot
|
|
// halos (zIndex: 2 in layout.css.ts) — shared stacking context
|
|
// with the Page. `data-hidden` toggles the slide/fade.
|
|
// `onFocusCapture` covers programmatic focus while the wrap is
|
|
// off-screen (e.g. `ReactEditor.focus(editor)` from useKeyDown's
|
|
// auto-focus when the user starts typing).
|
|
className={css.ComposerOverlay}
|
|
data-hidden={composerHidden ? 'true' : 'false'}
|
|
onFocusCapture={() => setComposerHidden(false)}
|
|
>
|
|
<div
|
|
// Every room class fits its composer to the same centred ~960px band
|
|
// as the timeline (BubbleTimelineBand) and the AI-bot chat, so on wide
|
|
// web the input lines up with the message column. The band owns its own
|
|
// responsive horizontal padding, so no inline padding here.
|
|
className={classNames(css.ChatComposer, css.ComposerBubbleBand)}
|
|
>
|
|
{tombstoneEvent ? (
|
|
<RoomTombstone
|
|
roomId={roomId}
|
|
body={tombstoneEvent.getContent().body}
|
|
replacementRoomId={tombstoneEvent.getContent().replacement_room}
|
|
/>
|
|
) : (
|
|
<>
|
|
{canMessage && (
|
|
<RoomInput
|
|
room={room}
|
|
editor={editor}
|
|
roomId={roomId}
|
|
fileDropContainerRef={roomViewRef}
|
|
ref={roomInputRef}
|
|
/>
|
|
)}
|
|
{!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>
|
|
</div>
|
|
)}
|
|
</Page>
|
|
);
|
|
}
|