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

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>
);
}