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

763 lines
28 KiB
TypeScript

/* eslint-disable react/destructuring-assignment */
import React, { forwardRef, MouseEventHandler, useState } from 'react';
import {
Avatar,
Badge,
Box,
Icon,
IconButton,
Icons,
Line,
Menu,
MenuItem,
PopOut,
RectCords,
Spinner,
Text,
Tooltip,
TooltipProvider,
config,
toRem,
} from 'folds';
import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UserAvatar } from '../../components/user-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useStateEvent } from '../../hooks/useStateEvent';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useIsOneOnOne, useRoom } from '../../hooks/useRoom';
import { useDmCallVisible } from '../../hooks/useDmCallVisible';
import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomUnread } from '../../state/hooks/unread';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useSetting } from '../../state/hooks/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed';
import { searchModalAtom } from '../../state/searchModal';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { RoomSettingsPage } from '../../state/roomSettings';
import { StateEvent } from '../../../types/matrix/room';
import {
getCanonicalAliasOrRoomId,
getMxIdLocalPart,
getMxIdServer,
guessDmRoomUserId,
isRoomAlias,
mxcUrlToHttp,
} from '../../utils/matrix';
import { copyToClipboard } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard';
import { markAsRead } from '../../utils/notifications';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { useBotPresets } from '../bots/catalog';
import { findBotPresetForRoom, isCatalogBotControlRoom } from '../bots/room';
import { botFailedAtomFamily, botShowChatAtomFamily } from '../bots/botExperienceState';
import { getBotPath } from '../../pages/pathUtils';
import { JumpToTime } from './jump-to-time';
import { RoomPinMenu } from './room-pin-menu';
import * as css from './RoomViewHeaderDm.css';
// Single bot-aware menu item rendered at the top of RoomMenu when the
// current room is a Vojo bot control DM. Reads `botFailedAtomFamily` to
// label correctly («Retry widget» when a prior load failed, «Show widget»
// otherwise) and clears both atoms on click.
//
// IMPORTANT: this menu surfaces in BOTH `/bots/:botId` (chat-fallback) and
// `/direct/:roomId` (regular DM that happens to be a bot's control room).
// In the second case clearing the atoms alone is invisible — the user is
// not on the route that observes them. We navigate to `/bots/:botId` so
// the BotShell actually mounts.
function BotShowWidgetMenuItem({
roomId,
botId,
requestClose,
}: {
roomId: string;
botId: string;
requestClose: () => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const setShowChat = useSetAtom(botShowChatAtomFamily(roomId));
const [failed, setFailed] = useAtom(botFailedAtomFamily(roomId));
const handleClick = () => {
setFailed(false);
setShowChat(false);
navigate(getBotPath(botId));
requestClose();
};
return (
<MenuItem
onClick={handleClick}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')}
</Text>
</MenuItem>
);
}
type RoomMenuProps = {
room: Room;
callView?: boolean;
// When true the room is a Vojo bot control DM rendered in chat-fallback
// mode. The menu prepends a «Show widget / Retry widget» item so the
// user can return to BotShell without hunting for an overlay button.
// Other items stay standard — bots in chat-fallback should look like
// normal rooms beyond that one affordance.
botControlRoom?: boolean;
onSearch: () => void;
onPin: (cords: RectCords) => void;
requestClose: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
({ room, callView, botControlRoom, onSearch, onPin, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const pinnedEvents = useRoomPinnedEvents(room);
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
// Look up the matching bot preset only when this menu IS the bot
// variant. The lookup walks the preset list once; cheap. Returns
// undefined for non-bot rooms — we won't render the menu item.
const bots = useBotPresets();
const botPreset = botControlRoom ? findBotPresetForRoom(mx, room, bots) : undefined;
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleSearch = () => {
onSearch();
requestClose();
};
const handleOpenPinned: MouseEventHandler<HTMLButtonElement> = (evt) => {
onPin(evt.currentTarget.getBoundingClientRect());
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(220), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
{botControlRoom && botPreset && (
<>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<BotShowWidgetMenuItem
roomId={room.roomId}
botId={botPreset.id}
requestClose={requestClose}
/>
</Box>
<Line variant="Surface" size="300" />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleSearch}
size="300"
after={<Icon size="100" src={Icons.Search} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.search')}
</Text>
</MenuItem>
<MenuItem
onClick={handleOpenPinned}
size="300"
after={
<Box gap="100" alignItems="Center">
{pinnedEvents.length > 0 && (
<Badge variant="Secondary" size="400" fill="Solid" radii="Pill">
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="100" src={Icons.Pin} />
</Box>
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.pinned_messages')}
</Text>
</MenuItem>
{canInvite && (
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.invite')}
</Text>
</MenuItem>
)}
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.copy_link')}
</Text>
</MenuItem>
<MenuItem
onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.room_settings')}
</Text>
</MenuItem>
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.jump_to_time')}
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
disabled={callView}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);
function DmCallButton({ room }: { room: Room }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const switchOrStartDmCall = useSwitchOrStartDmCall();
const livekitSupported = useLivekitSupport();
const session = useCallSession(room);
const members = useCallMembers(room, session);
const currentEmbed = useAtomValue(callEmbedAtom);
const myUserId = mx.getSafeUserId();
const inCallHere = currentEmbed?.roomId === room.roomId;
if (inCallHere) return null;
const ongoingByOthers = members.length > 0 && !members.some((m) => m.userId === myUserId);
const disabled = !livekitSupported;
let tooltipText: string;
if (!livekitSupported) tooltipText = t('Call.unavailable');
else if (ongoingByOthers) tooltipText = t('Call.join');
else tooltipText = t('Call.start');
const handleClick = () => {
if (disabled) return;
switchOrStartDmCall(room.roomId).catch((err: unknown) => {
// eslint-disable-next-line no-console
console.warn('[call] header switch/start failed', err);
});
};
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{tooltipText}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleClick}
disabled={disabled}
aria-label={tooltipText}
>
<Icon size="400" src={Icons.Phone} />
</IconButton>
)}
</TooltipProvider>
);
}
export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const room = useRoom();
const isOneOnOne = useIsOneOnOne();
const isMobile = screenSize === ScreenSize.Mobile;
// Call surface visibility is shared with the `Call` button inside
// `UserRoomProfile` via `useDmCallVisible` — the hook captures all
// four gates (1:1 + m.direct + !bridged + !bot-control) so the two
// call-entry surfaces can't drift apart. Header-specific extras
// here: hide in callView (we're already showing CallView). The
// hook itself documents the call-lifecycle reasoning.
const dmCallVisible = useDmCallVisible(room);
const callButtonVisible = !callView && dmCallVisible;
// `isBotControlRoom` is also passed down to `RoomMenu` to gate the
// bot-config menu item — kept here as its own derivation because
// the hook above is purpose-built for the call gate, not for
// generic «is this a bot DM?» queries.
const bots = useBotPresets();
const isBotControlRoom = isCatalogBotControlRoom(mx, room, bots);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, isOneOnOne);
const name = useRoomName(room);
const memberCount = useRoomMemberCount(room);
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
// Peer details for 1:1 chrome — handle (`local:server`, no `@` prefix —
// we drop the sigil to keep the line lighter) and tap-to-open user-room-
// profile sheet. `guessDmRoomUserId` returns the oldest-joined non-self
// member, but its last fallback is `myUserId` itself — drop that case so
// we never render the room as if I were the peer (avatar trigger would
// open my own profile, handle would show my mxid).
//
// The server segment is kept so cross-server users with identical
// local-parts (e.g. `alex:vojo.chat` vs `alex:other.org`) stay visually
// distinct.
const myUserId = mx.getSafeUserId();
const peerCandidate = isOneOnOne ? guessDmRoomUserId(room, myUserId) : undefined;
const peerUserId = peerCandidate && peerCandidate !== myUserId ? peerCandidate : undefined;
const peerLocal = peerUserId ? getMxIdLocalPart(peerUserId) : undefined;
const peerServer = peerUserId ? getMxIdServer(peerUserId) : undefined;
const handle =
peerLocal && peerServer ? `${peerLocal}:${peerServer}` : peerUserId?.replace(/^@/, '');
// `useUserPresence` is a no-op when `peerUserId` is undefined (group rooms,
// unresolved peer) — it just returns undefined. Real presence comes from
// matrix-js-sdk `User` events; the hook subscribes to Presence /
// CurrentlyActive / LastPresenceTs and re-renders. We only show «в сети»
// when the peer is actively online; offline / unavailable hides the line.
const peerPresence = useUserPresence(peerUserId ?? '');
const peerOnline = !!peerUserId && peerPresence?.presence === Presence.Online;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const setSearchOpen = useSetAtom(searchModalAtom);
const openUserRoomProfile = useOpenUserRoomProfile();
const parentSpace = useSpaceOptionally();
const openSettings = useOpenRoomSettings();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleSearch = () => {
// P4 routes in-room search through the global cmd+K modal — there is no
// /direct/{roomId}/search/ page yet (see desired_features.md §26). The
// modal lets the user pick the target room manually.
setSearchOpen(true);
};
const handlePeerProfile: MouseEventHandler<HTMLButtonElement> = (evt) => {
if (!peerUserId) return;
openUserRoomProfile(
room.roomId,
parentSpace?.roomId,
peerUserId,
evt.currentTarget.getBoundingClientRect(),
'Bottom'
);
};
const handleMemberToggle = () => {
if (callView) {
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
return;
}
setPeopleDrawer(!peopleDrawer);
};
const avatarNode = (
<Avatar size="300">
{isOneOnOne && peerUserId ? (
<UserAvatar
userId={peerUserId}
src={avatarUrl}
alt={name}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
) : (
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
/>
)}
</Avatar>
);
const e2eeChip = encryptedRoom && (
<Text as="span" size="T200" className={css.E2eeChip}>
<Icon src={Icons.Lock} filled className={css.E2eeIcon} />
{t('Room.encrypted_short')}
</Text>
);
// Single subline format, identical on mobile + desktop:
// 1:1 → «@local:server» (mono) + (peer online ? « · в сети» : nothing)
// group → «N members»
// Title row carries the room name and the «онлайн» presence tag (1:1
// peer only, when actually Online via `useUserPresence`). Subline carries
// the muted identifier (handle for 1:1, member-count for groups) and,
// for 1:1 rooms, the e2ee chip — moved out of the title row so the chip
// sits near the technical identifier rather than the human name.
const showOnlineTag = isOneOnOne && peerOnline;
const showHandlePart = isOneOnOne && !!handle;
const showGroupSubline = !isOneOnOne;
// Title block (name row + subline) — identical for 1:1 and group; the
// difference is purely in the wrapper (clickable identity button for
// 1:1, plain row for groups).
const titleBlock = (
<Box grow="Yes" direction="Column" style={{ minWidth: 0 }}>
<div className={css.TitleRow}>
<Text size={isMobile ? 'H5' : 'H4'} truncate className={css.RoomName}>
{name}
</Text>
{showOnlineTag && (
<Text as="span" size="T200" className={css.OnlineTag}>
{t('Room.status_online')}
</Text>
)}
</div>
{showHandlePart && (
<Text as="span" size="T200" className={css.Subline}>
<span className={css.SublineMuted}>{handle}</span>
{e2eeChip}
</Text>
)}
{showGroupSubline && (
<Text as="span" size="T200" className={css.Subline}>
<span className={css.SublineMuted}>
{t('Room.members_count', {
count: memberCount,
formattedCount: memberCount,
})}
</span>
{e2eeChip}
</Text>
)}
</Box>
);
// For 1:1 rooms wrap the avatar AND title in a single button so a tap
// anywhere on the user identity (avatar, name, handle, online tag)
// opens the user-room-profile sheet — one tab stop, one focus
// outline, single popout anchor. Groups keep the avatar non-clickable
// and the title as a plain row.
const identityArea =
isOneOnOne && peerUserId ? (
<button
type="button"
className={css.PeerIdentityTrigger}
onClick={handlePeerProfile}
aria-label={t('Room.open_profile_of', { name })}
>
{avatarNode}
{titleBlock}
</button>
) : (
<>
<span className={css.PeerAvatarStatic}>{avatarNode}</span>
{titleBlock}
</>
);
return (
<PageHeader
className={`${ContainerColor({ variant: 'SurfaceVariant' })} ${css.HeaderShell}`}
balance={isMobile}
>
{/* `gap="200"` (= S200 = 8px) keeps the avatar's right side flush with
the same `S200` left/top/bottom clearance the header gives — using
the default `gap="300"` (12px) made the avatar→title spacing
visibly looser than the rest of its surround. */}
<Box grow="Yes" alignItems="Center" gap="200">
{isMobile && (
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack} aria-label={t('Room.close')}>
<Icon src={Icons.ChevronLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
{identityArea}
<Box shrink="No" alignItems="Center">
{callButtonVisible && <DmCallButton room={room} />}
{/* Member toggle — desktop-only. Visible in two cases:
- group rooms (>2 members): toggles the members drawer.
- call rooms (`callView=true`), regardless of size: routes to
Room Settings → Members so 1:1 call rooms can still reach
the member list (drawer doesn't render in callView).
For non-call 1:1 rooms it stays hidden because the avatar tap
already opens the peer profile sheet. */}
{(callView || !isOneOnOne) && screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{callView ? (
<Text>{t('Room.members')}</Text>
) : (
<Text>{peopleDrawer ? t('Room.hide_members') : t('Room.show_members')}</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton fill="None" ref={triggerRef} onClick={handleMemberToggle}>
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Room.more_options')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomMenu
room={room}
callView={callView}
botControlRoom={isBotControlRoom}
onSearch={handleSearch}
onPin={(cords) => setPinMenuAnchor(cords)}
requestClose={() => setMenuAnchor(undefined)}
/>
</FocusTrap>
}
/>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setPinMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomPinMenu room={room} requestClose={() => setPinMenuAnchor(undefined)} />
</FocusTrap>
}
/>
</Box>
</Box>
</PageHeader>
);
}