Files
cinny/src/app/features/room/RoomViewHeader.tsx
T
Lotus CIandClaude Opus 5.5 103c6f4624
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 11m15s
fix(a11y): closing a header/sidebar menu returns focus to its button
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.

Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:16:12 -04:00

963 lines
34 KiB
TypeScript

import React, { MouseEventHandler, forwardRef, useState } from 'react';
import FocusTrap from 'focus-trap-react';
import {
Box,
Avatar,
Text,
Overlay,
OverlayCenter,
OverlayBackdrop,
IconButton,
Icon,
Icons,
Tooltip,
TooltipProvider,
Menu,
MenuItem,
toRem,
config,
Line,
PopOut,
RectCords,
Badge,
Chip,
Spinner,
Button,
} from 'folds';
import { useAtom } from 'jotai';
import { useNavigate } from 'react-router-dom';
import { Room } from 'matrix-js-sdk';
import { useVoiceChannelFull } from '../../hooks/useVoiceChannelFull';
import { useRoomCallPolicy } from '../../hooks/useRoomCallPolicy';
import { useStateEvent } from '../../hooks/useStateEvent';
import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { RoomTopicViewer } from '../../components/room-topic-viewer';
import { StateEvent } from '../../../types/matrix/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useIsDirectRoom, useRoom } from '../../hooks/useRoom';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useSpaceOptionally } from '../../hooks/useSpace';
import { getHomeSearchPath, getSpaceSearchPath, withSearchParam } from '../../pages/pathUtils';
import { getCanonicalAliasOrRoomId, mxcUrlToHttp } from '../../utils/matrix';
import { getStateEvents } from '../../utils/room';
import { _SearchPathSearchParams } from '../../pages/paths';
import * as css from './RoomViewHeader.css';
import { useRoomUnread } from '../../state/hooks/unread';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { markAsRead } from '../../utils/notifications';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { useRoomAvatar, useLocalRoomName, useRoomTopic } from '../../hooks/useRoomMeta';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { RoomPinMenu } from './room-pin-menu';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { JumpToTime } from './jump-to-time';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { ReportRoomModal } from './ReportRoomModal';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { ContainerColor } from '../../styles/ContainerColor.css';
import { RoomSettingsPage } from '../../state/roomSettings';
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { mobileMembersPanelAtom } from '../../state/mobileMembersPanel';
import { threadsListAtom } from '../../state/threadsList';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
type RoomMenuProps = {
room: Room;
requestClose: () => void;
galleryOpen?: boolean;
onToggleGallery?: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
({ room, requestClose, galleryOpen, onToggleGallery }, ref) => {
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext();
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 isServerNotice = room.getType() === 'm.server_notice';
const isCreator = creators.has(mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const [invitePrompt, setInvitePrompt] = useState(false);
const [reportRoomOpen, setReportRoomOpen] = useState(false);
const [bookmarksOpen, setBookmarksOpen] = useAtom(bookmarksPanelAtom);
const [mobileMembers, setMobileMembers] = useAtom(mobileMembersPanelAtom);
// [Gitea #165 / #211] Threads list and Widgets had desktop-only header
// buttons and no mobile entry point at all.
const [threadsList, setThreadsList] = useAtom(threadsListAtom);
const [widgets, setWidgets] = useAtom(widgetsPanelAtom);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(160), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
{reportRoomOpen && (
<ReportRoomModal
roomId={room.roomId}
onClose={() => {
setReportRoomOpen(false);
requestClose();
}}
/>
)}
<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>
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>
Notifications
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={() => {
setBookmarksOpen((v) => !v);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Star} filled={bookmarksOpen} />}
radii="300"
aria-pressed={bookmarksOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Saved Messages
</Text>
</MenuItem>
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setMobileMembers(!mobileMembers);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.User} filled={mobileMembers} />}
radii="300"
aria-pressed={mobileMembers}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Members
</Text>
</MenuItem>
)}
{screenSize === ScreenSize.Mobile && onToggleGallery && (
<MenuItem
onClick={() => {
onToggleGallery();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Photo} filled={galleryOpen} />}
radii="300"
aria-pressed={galleryOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Media Gallery
</Text>
</MenuItem>
)}
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setThreadsList(!threadsList);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Thread} filled={threadsList} />}
radii="300"
aria-pressed={threadsList}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Threads
</Text>
</MenuItem>
)}
{screenSize === ScreenSize.Mobile && (
<MenuItem
onClick={() => {
setWidgets(!widgets);
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Category} filled={widgets} />}
radii="300"
aria-pressed={widgets}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Widgets
</Text>
</MenuItem>
)}
{!isServerNotice && (
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
disabled={!canInvite}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Invite
</Text>
</MenuItem>
)}
{!isServerNotice && (
<MenuItem
onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
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>
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 }}>
{!isServerNotice && !isCreator && (
<MenuItem
onClick={() => setReportRoomOpen(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.Warning} />}
radii="300"
aria-pressed={reportRoomOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Report Room
</Text>
</MenuItem>
)}
<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}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
Leave Room
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
},
);
type CallMenuProps = {
onVoiceCall: () => void;
onVideoCall: () => void;
requestClose: () => void;
};
const CallMenu = forwardRef<HTMLDivElement, CallMenuProps>(
({ requestClose, onVoiceCall, onVideoCall }, ref) => {
const handleVoice = () => {
onVoiceCall();
requestClose();
};
const handleVideo = () => {
onVideoCall();
requestClose();
};
return (
<Menu ref={ref} style={{ padding: config.space.S200, minWidth: toRem(150) }}>
<Box direction="Column" gap="200">
<Text size="L400">Start Call</Text>
<Box direction="Column" gap="200">
<Button
size="300"
variant="Success"
fill="Soft"
outlined
radii="300"
before={<Icon size="100" src={Icons.Phone} filled />}
onClick={handleVoice}
>
<Text size="B300">Voice</Text>
</Button>
<Button
size="300"
variant="Success"
radii="300"
before={<Icon size="100" src={Icons.VideoCamera} filled />}
onClick={handleVideo}
>
<Text size="B300">Video</Text>
</Button>
</Box>
</Box>
</Menu>
);
},
);
/**
* [Gitea #134] One small chip that tells members a room's call rules before
* they hit the server-side guard: "8/10" when a voice limit is set, a muted
* camera glyph when the room is audio-only. Nothing when no rule exists; the
* tooltip carries the words. Desktop only (the mobile header is tight).
*/
function CallRulesChip({ room }: { room: Room }) {
const { current, max } = useVoiceChannelFull(room);
const { allowCamera, allowScreenshare } = useRoomCallPolicy(room);
const audioOnly = !allowCamera && !allowScreenshare;
if (max <= 0 && !audioOnly) return null;
const words = [
max > 0 ? `Voice limit ${max} (${current} in call)` : undefined,
audioOnly ? 'Audio-only room' : undefined,
]
.filter(Boolean)
.join(' · ');
return (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{words}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip
ref={triggerRef}
variant="Surface"
size="400"
radii="Pill"
aria-label={words}
tabIndex={0}
before={audioOnly ? <Icon size="50" src={Icons.VideoCameraMute} /> : undefined}
>
{max > 0 && (
<Text size="T200">
{current}/{max}
</Text>
)}
</Chip>
)}
</TooltipProvider>
);
}
function CallButton() {
const room = useRoom();
const direct = useIsDirectRoom();
const callEmbed = useCallEmbed();
const startCall = useCallStart(direct);
const callStarted = callEmbed && callEmbed.roomId === room.roomId;
const inAnotherCall = callEmbed && !callStarted;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
return (
<>
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{inAnotherCall ? (
<Text size="L400">Already in another call — End the current call to join!</Text>
) : (
<Text>Call</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
variant="Surface"
fill="None"
ref={triggerRef}
aria-label="Start call"
onClick={handleOpenMenu}
// Gitea #25 — removed an undiscoverable onContextMenu shortcut that
// started a camera-on video call directly, bypassing cameraOnJoin and
// the room camera policy. The popout menu already offers Voice/Video.
disabled={inAnotherCall || callStarted}
aria-pressed={!!menuAnchor}
>
<Icon size="400" src={Icons.VideoCamera} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="Center"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<CallMenu
onVideoCall={() => startCall(room, { microphone: true, video: true, sound: true })}
onVoiceCall={() => startCall(room, { microphone: true, video: false, sound: true })}
requestClose={() => setMenuAnchor(undefined)}
/>
</FocusTrap>
}
/>
</>
);
}
export function RoomViewHeader({ callView }: { callView?: boolean }) {
const navigate = useNavigate();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const room = useRoom();
const space = useSpaceOptionally();
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const hasCallPermission = permissions.stateEvent(
StateEvent.GroupCallMemberPrefix,
mx.getSafeUserId(),
);
const livekitSupported = useLivekitSupport();
const rtcSupported = webRTCSupported();
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const direct = useIsDirectRoom();
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const _encryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, direct);
const name = useLocalRoomName(room);
const topic = useRoomTopic(room);
const avatarUrl = avatarMxc
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined)
: undefined;
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
const [threadsListOpen, setThreadsListOpen] = useAtom(threadsListAtom);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
const searchParams: _SearchPathSearchParams = {
rooms: room.roomId,
};
const path = space
? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId))
: getHomeSearchPath();
navigate(withSearchParam(path, searchParams));
};
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const handleOpenPinMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setPinMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const handleMemberToggle = () => {
if (callView) {
openSettings(room.roomId, parentSpace?.roomId, RoomSettingsPage.MembersPage);
return;
}
setPeopleDrawer(!peopleDrawer);
};
return (
<>
<PageHeader
className={ContainerColor({ variant: 'Surface' })}
balance={screenSize === ScreenSize.Mobile}
aria-label={`${name} room header`}
>
<Box grow="Yes" gap="300">
{screenSize === ScreenSize.Mobile && (
<BackRouteHandler>
{(onBack) => (
<Box shrink="No" alignItems="Center">
<IconButton fill="None" onClick={onBack} aria-label="Back">
<Icon src={Icons.ArrowLeft} />
</IconButton>
</Box>
)}
</BackRouteHandler>
)}
<Box grow="Yes" alignItems="Center" gap="300">
{screenSize !== ScreenSize.Mobile && (
<Avatar size="300">
<RoomAvatar
roomId={room.roomId}
src={avatarUrl}
alt={name}
renderFallback={() => (
<RoomIcon size="200" joinRule={room.getJoinRule()} roomType={room.getType()} />
)}
/>
</Avatar>
)}
<Box direction="Column">
<Box alignItems="Center" gap="200">
<Text size={topic ? 'H5' : 'H3'} truncate>
{name}
</Text>
{room.getType() === 'm.server_notice' && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>System messages from your homeserver administrator.</Text>
</Tooltip>
}
>
{(triggerRef) => (
<Chip ref={triggerRef} size="400" variant="Warning" radii="300" outlined>
<Text size="T200">Server Notice</Text>
</Chip>
)}
</TooltipProvider>
)}
</Box>
{topic && (
<UseStateProvider initial={false}>
{(viewTopic, setViewTopic) => (
<>
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
<OverlayCenter>
<FocusTrap
focusTrapOptions={{
initialFocus: false,
clickOutsideDeactivates: true,
onDeactivate: () => setViewTopic(false),
escapeDeactivates: stopPropagation,
}}
>
<RoomTopicViewer
name={name}
topic={topic}
requestClose={() => setViewTopic(false)}
/>
</FocusTrap>
</OverlayCenter>
</Overlay>
<Text
as="button"
type="button"
onClick={() => setViewTopic(true)}
className={css.HeaderTopic}
size="T200"
priority="300"
truncate
>
{topic.topic}
</Text>
</>
)}
</UseStateProvider>
)}
</Box>
</Box>
<Box shrink="No">
{
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Search</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleSearchClick}
aria-label="Search"
>
<Icon size="400" src={Icons.Search} />
</IconButton>
)}
</TooltipProvider>
}
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>Pinned Messages</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
style={{ position: 'relative' }}
onClick={handleOpenPinMenu}
ref={triggerRef}
aria-label="Pinned messages"
aria-pressed={!!pinMenuAnchor}
>
{pinnedEvents.length > 0 && (
<Badge
style={{
position: 'absolute',
left: toRem(3),
top: toRem(3),
}}
variant="Secondary"
size="400"
fill="Solid"
radii="Pill"
>
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.Pin} filled={!!pinMenuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={pinMenuAnchor}
position="Bottom"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: 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>
}
/>
{screenSize === ScreenSize.Desktop && <CallRulesChip room={room} />}
{!room.isCallRoom() &&
livekitSupported &&
rtcSupported &&
hasCallPermission &&
(direct ||
(room.getJoinRule() === 'invite' &&
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{threadsListOpen ? 'Hide Threads' : 'Threads'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setThreadsListOpen(!threadsListOpen)}
aria-label="Toggle threads"
aria-pressed={threadsListOpen}
>
<Icon size="400" src={Icons.Thread} filled={threadsListOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{galleryOpen ? 'Hide Gallery' : 'Media Gallery'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setGalleryOpen(!galleryOpen)}
aria-label="Toggle media gallery"
aria-pressed={galleryOpen}
>
<Icon size="400" src={Icons.Photo} filled={galleryOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{widgetsOpen ? 'Hide Widgets' : 'Widgets'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setWidgetsOpen(!widgetsOpen)}
aria-label="Toggle widgets"
aria-pressed={widgetsOpen}
>
<Icon size="400" src={Icons.Category} filled={widgetsOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
{callView ? (
<Text>Members</Text>
) : (
<Text>{peopleDrawer ? 'Hide Members' : 'Show Members'}</Text>
)}
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
style={{ position: 'relative' }}
onClick={handleMemberToggle}
aria-label={
pendingKnocks.length > 0
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
: 'Toggle member list'
}
>
{pendingKnocks.length > 0 && (
<Badge
aria-hidden
variant="Warning"
fill="Solid"
radii="Pill"
size="400"
style={{
position: 'absolute',
right: toRem(3),
top: toRem(3),
pointerEvents: 'none',
}}
>
<Text as="span" size="L400">
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
</Text>
</Badge>
)}
<Icon size="400" src={Icons.User} />
</IconButton>
)}
</TooltipProvider>
)}
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>More Options</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
onClick={handleOpenMenu}
ref={triggerRef}
aria-label="More options"
aria-expanded={!!menuAnchor}
aria-haspopup="menu"
>
<Icon size="400" src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<RoomMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
galleryOpen={galleryOpen}
onToggleGallery={() => setGalleryOpen((v: boolean) => !v)}
/>
</FocusTrap>
}
/>
</Box>
</Box>
</PageHeader>
</>
);
}