239 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|