import React, { useCallback } from 'react'; import { Box, Line, toRem } from 'folds'; import { useMatch, useParams } from 'react-router-dom'; import { isKeyHotkey } from 'is-hotkey'; import { useAtomValue } from 'jotai'; import { RoomView } from './RoomView'; import { userRoomProfileAtom } from '../../state/userRoomProfile'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_VOID_COLOR } from '../../styles/horseshoe'; import { MembersDrawer } from './MembersDrawer'; import { ThreadDrawer } from './ThreadDrawer'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels'; import { useIsOneOnOne, useRoom } from '../../hooks/useRoom'; import { useKeyDown } from '../../hooks/useKeyDown'; import { markAsRead } from '../../utils/notifications'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomMembers } from '../../hooks/useRoomMembers'; import { CallView } from '../call/CallView'; import { RoomViewHeader } from './RoomViewHeader'; import { callChatAtom } from '../../state/callEmbed'; import { CallChatView } from './CallChatView'; import { RoomViewProfilePanel } from './RoomViewProfilePanel'; import { RoomViewProfileSidePanel } from './RoomViewProfileSidePanel'; import { useChannelsMode, ThreadDrawerOpenProvider } from '../../hooks/useChannelsMode'; import { CHANNELS_THREAD_PATH } from '../../pages/paths'; import { getChannelsRoomPath } from '../../pages/pathUtils'; import { isBridgedRoom } from '../../utils/room'; import { useUnreadThreadingEnabled } from '../../hooks/useClientConfig'; type RoomProps = { renderRoomView?: (props: { eventId?: string }) => React.ReactNode; }; export function Room({ renderRoomView }: RoomProps) { const { eventId } = useParams(); const room = useRoom(); const mx = useMatrixClient(); const channelsMode = useChannelsMode(); // Match the thread URL pattern via useMatch rather than reading // useParams: rootId lives in a CHILD route relative to , so // useParams here returns only the room-level segments. useMatch // walks the absolute URL and gives us all four segments back. const threadMatch = useMatch({ path: CHANNELS_THREAD_PATH, end: true }); const matchedSpaceParam = threadMatch?.params.spaceIdOrAlias; const matchedRoomParam = threadMatch?.params.roomIdOrAlias; const matchedRootParam = threadMatch?.params.rootId; // Suppress the drawer in bridged rooms. Telegram puppets have no // m.thread on the bridge side; M7 will replace this gate with an // inline-quote indicator. Until then, keeping the drawer hidden // matches the «Reply in Thread» button gate in RoomTimeline. const showThreadDrawer = channelsMode && !!matchedRootParam && !!matchedSpaceParam && !!matchedRoomParam && !isBridgedRoom(room); let parentRoomPath: string | undefined; let decodedRootId: string | undefined; if (showThreadDrawer && matchedSpaceParam && matchedRoomParam && matchedRootParam) { try { parentRoomPath = getChannelsRoomPath( decodeURIComponent(matchedSpaceParam), decodeURIComponent(matchedRoomParam) ); } catch { parentRoomPath = getChannelsRoomPath(matchedSpaceParam, matchedRoomParam); } try { decodedRootId = decodeURIComponent(matchedRootParam); } catch { decodedRootId = matchedRootParam; } } const [isDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const screenSize = useScreenSizeContext(); const isMobile = screenSize === ScreenSize.Mobile; // Mobile drawer takes over the chat column instead of sitting beside // it — the timeline+composer stay mounted but visually hidden, so the // M5 nav-stack work picks up where M2 left off without re-routing. const drawerHidesChat = showThreadDrawer && isMobile; const powerLevels = usePowerLevels(room); const members = useRoomMembers(mx, room.roomId); const chat = useAtomValue(callChatAtom); // 1:1 rooms get peer-profile-sheet via avatar tap in the header instead of // the members drawer — see dm_1x1_redesign.md §8 P4 deliverable 9. The // value comes from the same `IsOneOnOneProvider` the header reads, so the // drawer-suppression and header chrome flip together when membership // changes (provider is reactive via `useIsOneOnOneRoom` upstream). const isOneOnOne = useIsOneOnOne(); const unreadThreadingEnabled = useUnreadThreadingEnabled(); // Profile horseshoe: when the right-side profile pane is open on // desktop/tablet, paint a 12px void seam between the chat column and // the profile, and carve rounded TL/BL corners on the profile pane. // The chat column itself keeps a straight right edge (no rounding) — // only the profile carves, so the void gap reads as one black bar // with a single rounded contour on the right side of the seam. // Mirrors the page-nav <-> chat split from PageRoot (commit 363bd9d) // minus the chat-side rounding. Mobile and the thread-drawer case // don't mount the side pane, so the wrap is gated on both. // // Void colour is painted on the parent flex row (covering everything // not painted by an opaque child) so the profile pane's TL/BL carves // expose void rather than the chat-panel-inner's Background from // PageRoot. The chat column applies an explicit Background bg so the // parent void can't bleed through any transparent slivers. const profileOpen = !!useAtomValue(userRoomProfileAtom); const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer; useKeyDown( window, useCallback( (evt) => { // Escape marks the room as read. Pre-M4 the receipt handler // blind-deleted ALL room unread on any own receipt, so the // drawer-open case had to be skipped to avoid wiping thread // unread the user hadn't seen. After the M4 receipt-handler // refactor (PUT-with-fresh, partitioned by thread_id) main and // thread unread are independent, so Escape on the channel main // surface marks main as read and leaves threads alone — works // unconditionally. Under the kill switch the handler reverts // to blind DELETE, so we keep the drawer-open guard there. if ( isKeyHotkey('escape', evt) && (unreadThreadingEnabled || !showThreadDrawer) ) { markAsRead(mx, room.roomId, hideActivity); } }, [mx, room.roomId, hideActivity, showThreadDrawer, unreadThreadingEnabled] ) ); const callView = room.isCallRoom(); return ( {callView && (screenSize === ScreenSize.Desktop || !chat) && ( }> )} {!callView && !drawerHidesChat && ( }> {renderRoomView?.({ eventId }) ?? } )} {/* Tablet / Desktop: profile renders as a third pane to the right of the chat. Mobile uses the top horseshoe inside `RoomViewProfilePanel`, so we don't mount the side pane there. The 12px void gap (same as page-nav <-> chat split from PageRoot) sits between the chat column and the pane so the seam reads identically to the rest of the app. */} {!isMobile && !showThreadDrawer && ( <> {showProfileHorseshoe && ( )} )} {callView && chat && ( <> {screenSize === ScreenSize.Desktop && ( )} )} {/* Members drawer hidden when thread drawer is open — three simultaneous side panes don't fit the chat column on anything narrower than ultrawide. The thread is the more recent intent so it wins. */} {!callView && !isOneOnOne && !showThreadDrawer && screenSize === ScreenSize.Desktop && isDrawer && ( <> )} {showThreadDrawer && decodedRootId && parentRoomPath && ( )} ); }