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(null); const roomViewRef = useRef(null); const composerWrapRef = useRef(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 `` 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 ( {!threadDrawerOpen && (
setComposerHidden(false)} >
{tombstoneEvent ? ( ) : ( <> {canMessage && ( )} {!canMessage && ( You do not have permission to post in this room )} )}
)}
); }