vojo/src/app/features/room/Room.tsx

239 lines
10 KiB
TypeScript

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 <Room>, 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 (
<PowerLevelsContextProvider value={powerLevels}>
<ThreadDrawerOpenProvider value={showThreadDrawer}>
<Box
grow="Yes"
style={
showProfileHorseshoe
? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR }
: undefined
}
>
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
<Box
grow="Yes"
direction="Column"
className={
showProfileHorseshoe ? ContainerColor({ variant: 'Background' }) : undefined
}
>
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
<Box grow="Yes">
<CallView />
</Box>
</RoomViewProfilePanel>
</Box>
)}
{!callView && !drawerHidesChat && (
<Box
grow="Yes"
direction="Column"
className={
showProfileHorseshoe ? ContainerColor({ variant: 'Background' }) : undefined
}
>
<RoomViewProfilePanel header={<RoomViewHeader />}>
<Box grow="Yes">
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
</Box>
</RoomViewProfilePanel>
</Box>
)}
{/* 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 && (
<Box
shrink="No"
style={{
width: toRem(VOJO_HORSESHOE_GAP_PX),
backgroundColor: VOJO_HORSESHOE_VOID_COLOR,
}}
/>
)}
<RoomViewProfileSidePanel />
</>
)}
{callView && chat && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<CallChatView />
</>
)}
{/* 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 && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<MembersDrawer key={room.roomId} room={room} members={members} />
</>
)}
{showThreadDrawer && decodedRootId && parentRoomPath && (
<ThreadDrawer
key={`${room.roomId}/${decodedRootId}`}
room={room}
rootId={decodedRootId}
parentRoomPath={parentRoomPath}
variant={isMobile ? 'mobile' : 'desktop'}
/>
)}
</Box>
</ThreadDrawerOpenProvider>
</PowerLevelsContextProvider>
);
}