CI / Build & Quality Checks (pull_request) Successful in 2m12s
CI / Trigger Desktop Build (pull_request) Skipped
CI / Docker image build & smoke test (pull_request) Skipped
CI / Secret scan (gitleaks) (pull_request) Successful in 17s
CI / Playwright smoke (e2e) (pull_request) Successful in 10m22s
The Linux desktop app runs on WebKitGTK, which ships without WebRTC (2.52 has no RTCPeerConnection; 2.54 disables it outright pending a libwebrtc backend around 2.56), so calls can't work there. Until now the call button just disappeared, the call room said "Your browser does not support WebRTC" with Join disabled, and an incoming call couldn't be answered. In the desktop app (isTauri) without WebRTC: - call rooms: "Calls aren't available in the desktop app on Linux yet: its web engine has no WebRTC" + an "Open in browser" button; - incoming-call overlay: the same, with "Answer in browser"; - room header: the call button stays, and opens the room in the browser. The link is the room in the web app (config.json `webAppUrl`, https only, new key); the user presses Join there. Deliberately not an auto-join link: a crafted URL must not be able to join a call and open someone's mic. It opens through the desktop's new-window handler (web/mail schemes only → the system browser). Without `webAppUrl` the explanation shows with no button; browsers without WebRTC keep the old message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
1000 lines
35 KiB
TypeScript
1000 lines
35 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 { openCallInBrowser, useCallInBrowserUrl } from '../call/CallsUnavailable';
|
||
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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* [cinny-desktop: Linux calls] Stand-in for CallButton where this app can't
|
||
* make calls (desktop on WebKitGTK): opens the room in the web app instead.
|
||
*/
|
||
function CallInBrowserButton({ url }: { url: string }) {
|
||
return (
|
||
<TooltipProvider
|
||
position="Bottom"
|
||
offset={4}
|
||
tooltip={
|
||
<Tooltip>
|
||
<Text>Call in your browser (this desktop app can’t make calls here)</Text>
|
||
</Tooltip>
|
||
}
|
||
>
|
||
{(triggerRef) => (
|
||
<IconButton
|
||
variant="Surface"
|
||
fill="None"
|
||
ref={triggerRef}
|
||
aria-label="Start call in browser"
|
||
onClick={() => openCallInBrowser(url)}
|
||
>
|
||
<Icon size="400" src={Icons.VideoCamera} />
|
||
</IconButton>
|
||
)}
|
||
</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 callInBrowserUrl = useCallInBrowserUrl(room.roomId);
|
||
|
||
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 || callInBrowserUrl) &&
|
||
hasCallPermission &&
|
||
(direct ||
|
||
(room.getJoinRule() === 'invite' &&
|
||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) &&
|
||
(rtcSupported ? (
|
||
<CallButton />
|
||
) : (
|
||
callInBrowserUrl && <CallInBrowserButton url={callInBrowserUrl} />
|
||
))}
|
||
{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>
|
||
</>
|
||
);
|
||
}
|