127 lines
5.3 KiB
TypeScript
127 lines
5.3 KiB
TypeScript
// Desktop / tablet right-side profile pane. Renders the same
|
||
// `UserRoomProfile` content the mobile top horseshoe shows, but as a
|
||
// flex sibling next to the chat column instead of a slide-down rail.
|
||
//
|
||
// Mounted in `Room.tsx` only when the screen size is not Mobile —
|
||
// the mobile branch keeps using the top horseshoe via
|
||
// `RoomViewProfilePanel`.
|
||
|
||
import React, { useEffect, useRef, useState } from 'react';
|
||
import { useAtomValue } from 'jotai';
|
||
import { Box, Icon, IconButton, Icons, Text, config } from 'folds';
|
||
import FocusTrap from 'focus-trap-react';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { userRoomProfileAtom } from '../../state/userRoomProfile';
|
||
import { useCloseUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||
import { useOpenRoomMembersSheet } from '../../state/hooks/roomMembersSheet';
|
||
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
|
||
import { UserRoomProfile } from '../../components/user-profile/UserRoomProfile';
|
||
import { stopPropagation } from '../../utils/keyboard';
|
||
import { PageHeader } from '../../components/page';
|
||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||
import * as css from './RoomViewProfileSidePanel.css';
|
||
|
||
export function RoomViewProfileSidePanel() {
|
||
const { t } = useTranslation();
|
||
const profileState = useAtomValue(userRoomProfileAtom);
|
||
const close = useCloseUserRoomProfile();
|
||
const room = useRoom();
|
||
const isOneOnOne = useIsOneOnOne();
|
||
const openMembersSheet = useOpenRoomMembersSheet();
|
||
|
||
const open = !!profileState;
|
||
// Inline avatar-expanded mode: the hero avatar stretches to fill
|
||
// the card width as a square, pushing the rest of the card down in
|
||
// the scroll container. Replaces the legacy full-pane image swap
|
||
// which hid the name / presence / info rows. Mobile keeps the
|
||
// full-rail swap inside `RoomViewProfilePanel` — the rail there is
|
||
// short enough that pushing content down would just create a
|
||
// scroll well.
|
||
const [avatarExpanded, setAvatarExpanded] = useState(false);
|
||
|
||
// Close profile when the room changes — atom is global state and
|
||
// would otherwise carry the previous room's userId into this room
|
||
// where chrome would render against the wrong member / power
|
||
// levels.
|
||
useEffect(() => () => close(), [room.roomId, close]);
|
||
|
||
// Reset avatar-zoom mode whenever the rendered user changes.
|
||
useEffect(() => {
|
||
setAvatarExpanded(false);
|
||
}, [profileState?.userId]);
|
||
|
||
const profileStateRef = useRef(profileState);
|
||
profileStateRef.current = profileState;
|
||
|
||
const renderUserId = profileState?.userId;
|
||
|
||
if (!open || !renderUserId) return null;
|
||
|
||
return (
|
||
<FocusTrap
|
||
focusTrapOptions={{
|
||
initialFocus: false,
|
||
// Outside clicks pass through to the chat (`allowOutsideClick`)
|
||
// but don't close the pane — clicking around in the chat to
|
||
// scroll, react, or compose shouldn't dismiss the profile.
|
||
// Explicit close is via the header's `×` or `Esc` (the latter
|
||
// still routes through focus-trap → `onDeactivate`).
|
||
clickOutsideDeactivates: false,
|
||
allowOutsideClick: () => true,
|
||
escapeDeactivates: stopPropagation,
|
||
onDeactivate: () => {
|
||
if (profileStateRef.current) close();
|
||
},
|
||
checkCanFocusTrap: () => Promise.resolve(),
|
||
}}
|
||
active={open}
|
||
>
|
||
<div className={css.panel}>
|
||
{/* Use the same `PageHeader` (folds `Header size="600"`) the
|
||
chat header uses, so the bottom rule lines up with the
|
||
adjacent `RoomViewHeaderDm` row to the pixel. */}
|
||
<PageHeader className={`${ContainerColor({ variant: 'Surface' })} ${css.header}`}>
|
||
{/* Group rooms: leading back arrow returns to the members sheet
|
||
(the room card) the profile replaced in this pane — the open
|
||
hook clears the profile atom, so the panes swap in place
|
||
(redesign item 15). 1:1 has no members sheet → no arrow.
|
||
Call rooms neither: Room.tsx mounts the members side pane only
|
||
when `!callView`, so opening the sheet atom from inside a call
|
||
would close the profile into nothing AND leak a stale atom
|
||
that auto-opens the pane in the next group room. */}
|
||
{!isOneOnOne && !room.isCallRoom() && (
|
||
<Box shrink="No" alignItems="Center">
|
||
<IconButton
|
||
fill="None"
|
||
onClick={() => openMembersSheet(room.roomId)}
|
||
aria-label={t('User.back')}
|
||
>
|
||
<Icon size="400" src={Icons.ArrowLeft} />
|
||
</IconButton>
|
||
</Box>
|
||
)}
|
||
<Box grow="Yes" alignItems="Center">
|
||
<Text size="H4" truncate>
|
||
{t('User.profile_title')}
|
||
</Text>
|
||
</Box>
|
||
<Box shrink="No" alignItems="Center">
|
||
<IconButton fill="None" onClick={close} aria-label={t('Room.close')}>
|
||
<Icon size="400" src={Icons.Cross} />
|
||
</IconButton>
|
||
</Box>
|
||
</PageHeader>
|
||
|
||
<div className={css.scrollWrap}>
|
||
<div style={{ padding: config.space.S400 }}>
|
||
<UserRoomProfile
|
||
userId={renderUserId}
|
||
onAvatarClick={() => setAvatarExpanded((prev) => !prev)}
|
||
avatarExpanded={avatarExpanded}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</FocusTrap>
|
||
);
|
||
}
|