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
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
963 lines
34 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|