329 lines
15 KiB
TypeScript
329 lines
15 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 { MobileMediaViewerHorseshoe } from './MobileMediaViewerHorseshoe';
|
|
import { RoomViewMediaSidePanel } from './RoomViewMediaSidePanel';
|
|
import { MediaViewerHostContext } from './mediaViewerHostContext';
|
|
import { mediaViewerAtom } from '../../state/mediaViewer';
|
|
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;
|
|
};
|
|
|
|
// 12px black seam between the chat column and the right-side pane (profile,
|
|
// media, thread, members). Every active right-side pane renders one of
|
|
// these locally; the parent flex row is painted the same void colour by
|
|
// `Room` so each pane's TL/BL rounded carve can expose the void cleanly.
|
|
function VoidGap() {
|
|
return (
|
|
<Box
|
|
shrink="No"
|
|
style={{
|
|
width: toRem(VOJO_HORSESHOE_GAP_PX),
|
|
backgroundColor: VOJO_HORSESHOE_VOID_COLOR,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 mediaOpen = !!useAtomValue(mediaViewerAtom);
|
|
const callView = room.isCallRoom();
|
|
const showProfileHorseshoe = profileOpen && !isMobile && !showThreadDrawer;
|
|
// Media viewer side pane on desktop — same horseshoe seam idiom
|
|
// as the profile pane. The two are mutually exclusive in practice
|
|
// (the open hooks clear the other atom), so at most one shows at
|
|
// a time; both feed into `paintParentVoid` for the chat-column bg
|
|
// and locally gate the shared `<VoidGap />` seam.
|
|
const showMediaHorseshoe = mediaOpen && !isMobile && !showThreadDrawer;
|
|
// Thread drawer side pane on desktop. Mobile hides the chat column
|
|
// entirely (`drawerHidesChat`) so the seam doesn't apply there.
|
|
const showThreadHorseshoe = showThreadDrawer && !isMobile;
|
|
// Members drawer side pane — mirrors the same horseshoe seam as the
|
|
// profile/media/thread panes. Gated on the exact same conditions that
|
|
// mount `<MembersDrawer>` below (group room, desktop, drawer setting on,
|
|
// no thread overlay, no call surface) so the void gap appears iff the
|
|
// pane appears.
|
|
const showMembersHorseshoe =
|
|
!callView &&
|
|
!isOneOnOne &&
|
|
!showThreadDrawer &&
|
|
screenSize === ScreenSize.Desktop &&
|
|
isDrawer;
|
|
// True whenever any right-side pane is mounted. Drives the parent flex
|
|
// row's void background and the chat column's explicit Background paint
|
|
// — both prevent the chat-side surface from bleeding through the carved
|
|
// TL/BL of whichever pane is open. The per-pane `<VoidGap />` decisions
|
|
// remain local to each render site (a single `paintParentVoid` gate
|
|
// would over-render when only members is open — there is no
|
|
// profile/media slot to anchor it to).
|
|
const paintParentVoid =
|
|
showProfileHorseshoe ||
|
|
showMediaHorseshoe ||
|
|
showThreadHorseshoe ||
|
|
showMembersHorseshoe;
|
|
|
|
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]
|
|
)
|
|
);
|
|
|
|
// Disable the atom-driven media viewer when the desktop thread
|
|
// drawer is open — the side-pane mount block below is gated on
|
|
// `!showThreadDrawer`, so the new viewer's right pane wouldn't
|
|
// render, and an atom-only open path would silently no-op on the
|
|
// user's tap. With the host context cleared, `ImageContent` /
|
|
// `VideoContent` fall back to the legacy full-screen `<Overlay>`
|
|
// modal, which sits on top of every pane and still works.
|
|
// Mobile is unaffected: `drawerHidesChat` hides the chat column
|
|
// entirely on mobile + thread, so there's no media to tap anyway.
|
|
//
|
|
// `useMemo` so the value object identity is stable across
|
|
// unrelated re-renders — without it, every parent re-render
|
|
// would spread a new `{ roomId }` literal through the context,
|
|
// re-running every `useContext(MediaViewerHostContext)` consumer
|
|
// (every image / video thumbnail in the timeline).
|
|
const mediaHostValue = React.useMemo(
|
|
() => (!isMobile && showThreadDrawer ? null : { roomId: room.roomId }),
|
|
[isMobile, showThreadDrawer, room.roomId]
|
|
);
|
|
|
|
return (
|
|
<PowerLevelsContextProvider value={powerLevels}>
|
|
<ThreadDrawerOpenProvider value={showThreadDrawer}>
|
|
<MediaViewerHostContext.Provider value={mediaHostValue}>
|
|
<Box
|
|
grow="Yes"
|
|
style={
|
|
paintParentVoid
|
|
? { backgroundColor: VOJO_HORSESHOE_VOID_COLOR }
|
|
: undefined
|
|
}
|
|
>
|
|
{callView && (screenSize === ScreenSize.Desktop || !chat) && (
|
|
<Box
|
|
grow="Yes"
|
|
direction="Column"
|
|
className={
|
|
paintParentVoid
|
|
? ContainerColor({ variant: 'Background' })
|
|
: undefined
|
|
}
|
|
// No chat-column padding-top / bg: the silhouette inside
|
|
// `MobileProfileHorseshoe` owns the safe-top inset and bg.
|
|
// See the !callView twin block below for the rationale.
|
|
>
|
|
<MobileMediaViewerHorseshoe>
|
|
<RoomViewProfilePanel header={<RoomViewHeader callView />}>
|
|
<Box grow="Yes">
|
|
<CallView />
|
|
</Box>
|
|
</RoomViewProfilePanel>
|
|
</MobileMediaViewerHorseshoe>
|
|
</Box>
|
|
)}
|
|
{!callView && !drawerHidesChat && (
|
|
<Box
|
|
grow="Yes"
|
|
direction="Column"
|
|
className={
|
|
paintParentVoid
|
|
? ContainerColor({ variant: 'Background' })
|
|
: undefined
|
|
}
|
|
// No padding-top / bg on mobile chat column: the silhouette
|
|
// inside `MobileProfileHorseshoe` permanently sits at body_top
|
|
// with its own `padding-top: var(--vojo-safe-top)` keeping
|
|
// chat-header / panel content below the status-bar icons.
|
|
// The silhouette's bg fades between chat-surface tone (when
|
|
// closed) and user-card tone (when fully open) as the user
|
|
// drags. Desktop branch still works because `--vojo-safe-top`
|
|
// resolves to 0 on web.
|
|
>
|
|
<MobileMediaViewerHorseshoe>
|
|
<RoomViewProfilePanel header={<RoomViewHeader />}>
|
|
<Box grow="Yes">
|
|
{renderRoomView?.({ eventId }) ?? <RoomView eventId={eventId} />}
|
|
</Box>
|
|
</RoomViewProfilePanel>
|
|
</MobileMediaViewerHorseshoe>
|
|
</Box>
|
|
)}
|
|
|
|
{/* Tablet / Desktop: profile or media renders as a third pane
|
|
to the right of the chat. Mobile uses the top horseshoe
|
|
(profile) and the bottom horseshoe (media), so we don't
|
|
mount the side panes there. The 12px void gap sits between
|
|
the chat column and whichever pane is open — both panes
|
|
share the same gap geometry since they're mutually
|
|
exclusive via the open hooks. */}
|
|
{!isMobile && !showThreadDrawer && (
|
|
<>
|
|
{(showProfileHorseshoe || showMediaHorseshoe) && <VoidGap />}
|
|
<RoomViewProfileSidePanel />
|
|
<RoomViewMediaSidePanel />
|
|
</>
|
|
)}
|
|
|
|
{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 && (
|
|
<>
|
|
{showMembersHorseshoe && <VoidGap />}
|
|
<MembersDrawer key={room.roomId} room={room} members={members} />
|
|
</>
|
|
)}
|
|
{showThreadDrawer && decodedRootId && parentRoomPath && (
|
|
<>
|
|
{showThreadHorseshoe && <VoidGap />}
|
|
<ThreadDrawer
|
|
key={`${room.roomId}/${decodedRootId}`}
|
|
room={room}
|
|
rootId={decodedRootId}
|
|
parentRoomPath={parentRoomPath}
|
|
variant={isMobile ? 'mobile' : 'desktop'}
|
|
/>
|
|
</>
|
|
)}
|
|
</Box>
|
|
</MediaViewerHostContext.Provider>
|
|
</ThreadDrawerOpenProvider>
|
|
</PowerLevelsContextProvider>
|
|
);
|
|
}
|