import React, { MouseEventHandler, forwardRef, useState } from 'react'; import FocusTrap from 'focus-trap-react'; import { Box, Avatar, Text, Overlay, OverlayCenter, OverlayBackdrop, IconButton, Tooltip, TooltipProvider, Menu, MenuItem, toRem, config, Line, PopOut, RectCords, Badge, Spinner, color } from 'folds'; import { Icon, Icons } from '../../components/icons'; import { useNavigate } from 'react-router-dom'; import { JoinRule, Room } from 'matrix-js-sdk'; import { useAtom, useAtomValue } from 'jotai'; import { activeThreadIdAtomFamily } from '../../state/activeThread'; 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 { 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, guessDmRoomUserId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; import colorMXID from '../../../util/colorMXID'; import { useOtherUserColor } from '../../hooks/useUserColor'; import { _SearchPathSearchParams } from '../../pages/paths'; import * as css from './RoomViewHeader.css'; import { useRoomUnread } from '../../state/hooks/unread'; import { usePowerLevelsContext } from '../../hooks/usePowerLevels'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { copyToClipboard } from '../../utils/dom'; import { SpeechBubble } from '../../components/speech-bubble/SpeechBubble'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { useRoomAvatar, useRoomName, useRoomTopic } from '../../hooks/useRoomMeta'; import { mDirectAtom } from '../../state/mDirectList'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { stopPropagation } from '../../utils/keyboard'; import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getViaServers } from '../../plugins/via-servers'; import { PluginButtonSlot } from '../settings/plugins/PluginButtonSlot'; import { useBackRoute } from '../../hooks/useBackRoute'; 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 { InviteUserPrompt } from '../../components/invite-user-prompt'; import { useRoomCall, useRoomCallMembers } from '../call'; import { CallState, CallType } from '../call/types'; import { UserAvatar } from '../../components/user-avatar'; import { getMemberDisplayName, getMemberAvatarMxc } from '../../utils/room'; type RoomMenuProps = { room: Room; requestClose: () => void; }; const RoomMenu = forwardRef(({ room, requestClose }, ref) => { 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 markAsRead = useMarkAsRead(mx); const [invitePrompt, setInvitePrompt] = useState(false); const handleMarkAsRead = () => { markAsRead(room.roomId, hideActivity); requestClose(); }; const handleInvite = () => { setInvitePrompt(true); }; const handleCopyLink = () => { const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId); const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room); copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers)); requestClose(); }; const openSettings = useOpenRoomSettings(); const parentSpace = useSpaceOptionally(); const handleOpenSettings = () => { openSettings(room.roomId, parentSpace?.roomId); requestClose(); }; return ( {invitePrompt && ( { setInvitePrompt(false); requestClose(); }} /> )} } radii="300" disabled={!unread} > Mark as Read {(handleOpen, opened, changing) => ( ) : ( ) } radii="300" aria-pressed={opened} onClick={handleOpen} > Notifications )} } radii="300" aria-pressed={invitePrompt} disabled={!canInvite} > Invite } radii="300" > Copy Link } radii="300" > Room Settings {(promptJump, setPromptJump) => ( <> setPromptJump(true)} size="300" after={} radii="300" aria-pressed={promptJump} > Jump to Time {promptJump && ( { setPromptJump(false); navigateRoom(room.roomId, eventId); requestClose(); }} onCancel={() => setPromptJump(false)} /> )} )} {(promptLeave, setPromptLeave) => ( <> setPromptLeave(true)} variant="Critical" fill="None" size="300" after={} radii="300" aria-pressed={promptLeave} > Leave Room {promptLeave && ( setPromptLeave(false)} /> )} )} ); }); type CallIndicatorProps = { roomId: string; }; /** * Shows all users currently in a call in this room (including self) */ function CallIndicator({ roomId }: CallIndicatorProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const { callMembers, isCallActive } = useRoomCallMembers(roomId); const room = mx.getRoom(roomId); if (!isCallActive || callMembers.length === 0) { return null; } const getAvatarUrl = (userId: string): string | undefined => { if (!room) return undefined; const mxcUrl = getMemberAvatarMxc(room, userId); if (!mxcUrl) return undefined; return mxcUrlToHttp(mx, mxcUrl, useAuthentication, 24, 24, 'crop') ?? undefined; }; const getDisplayName = (userId: string): string => { if (!room) return userId.split(':')[0].slice(1); return getMemberDisplayName(room, userId) ?? userId.split(':')[0].slice(1); }; return ( {callMembers.slice(0, 5).map((member) => { const displayName = getDisplayName(member.userId); const avatarUrl = getAvatarUrl(member.userId); return ( {displayName} {member.userId} } > {(triggerRef) => ( ( {displayName.charAt(0).toUpperCase()} )} /> )} ); })} {callMembers.length > 5 && ( +{callMembers.length - 5} )} ); } type RoomCallButtonsProps = { roomId: string; }; /** * Component for voice/video call buttons in the room header */ function RoomCallButtons({ roomId }: RoomCallButtonsProps) { const { isInCall, callState, activeCall, isUsingRemoteHomeserver, startVoiceCall, startVideoCall, endCall, callSupported, callSupportLoading, } = useRoomCall(roomId); // Debug logging React.useEffect(() => { // eslint-disable-next-line no-console console.log('RoomCallButtons render:', { callSupportLoading, callSupported, roomId }); }, [callSupportLoading, callSupported, roomId]); if (callSupportLoading || !callSupported) { return null; } const isConnecting = callState === CallState.Connecting; const isConnected = callState === CallState.Connected; const isActive = isConnecting || isConnected; const handleVoiceCall = async () => { if (isInCall) { await endCall(); } else { try { await startVoiceCall(); } catch (error) { console.error('Failed to start voice call:', error); } } }; const handleVideoCall = async () => { if (isInCall) { await endCall(); } else { try { await startVideoCall(); } catch (error) { console.error('Failed to start video call:', error); } } }; return ( <> {isInCall ? 'End Call' : 'Voice Call'} {isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'} } > {(triggerRef) => ( {isConnecting ? ( ) : ( )} )} {isInCall ? 'End Call' : 'Video Call'} {isUsingRemoteHomeserver && !isInCall && ' (Remote Server)'} } > {(triggerRef) => ( {isConnecting ? ( ) : ( )} )} ); } type RoomViewHeaderProps = { forumLayout?: boolean; }; export function RoomViewHeader({ forumLayout = false }: RoomViewHeaderProps) { const navigate = useNavigate(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const screenSize = useScreenSizeContext(); const { showInPageHeader, goBack } = useBackRoute(); const room = useRoom(); const space = useSpaceOptionally(); const [menuAnchor, setMenuAnchor] = useState(); const [pinMenuAnchor, setPinMenuAnchor] = useState(); const mDirects = useAtomValue(mDirectAtom); const pinnedEvents = useRoomPinnedEvents(room); const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption); const ecryptedRoom = !!encryptionEvent; const isDirect = mDirects.has(room.roomId); const avatarMxc = useRoomAvatar(room, isDirect); const name = useRoomName(room); const topic = useRoomTopic(room); const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; const dmUserId = isDirect ? guessDmRoomUserId(room, mx.getSafeUserId()) : undefined; const dmAvatarMxc = (dmUserId ? room.getMember(dmUserId)?.getMxcAvatarUrl() : undefined) || (isDirect ? avatarMxc : undefined); const dmCustomColor = useOtherUserColor(dmUserId ?? '', dmAvatarMxc); const dmFolderTabColor = isDirect ? `color-mix(in srgb, ${dmCustomColor ?? colorMXID(dmUserId ?? room.roomId)} 50%, var(--folder-tab-room, #f3e6c8))` : undefined; const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [activeThreadId, setActiveThreadId] = useAtom(activeThreadIdAtomFamily(room.roomId)); const handleSearchClick = () => { const searchParams: _SearchPathSearchParams = { rooms: room.roomId, }; const path = space ? getSpaceSearchPath(getCanonicalAliasOrRoomId(mx, space.roomId)) : getHomeSearchPath(); navigate(withSearchParam(path, searchParams)); }; const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; const handleOpenPinMenu: MouseEventHandler = (evt) => { setPinMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; return ( {showInPageHeader && ( )} {!showInPageHeader && ( ( )} /> )} {name} {forumLayout && ( Forum )} {topic && ( {(viewTopic, setViewTopic) => ( <> }> setViewTopic(false), escapeDeactivates: stopPropagation, }} > setViewTopic(false)} /> setViewTopic(true)} className={css.HeaderTopic} size="T200" priority="300" truncate > {topic} )} )} {!ecryptedRoom && ( Search } > {(triggerRef) => ( )} )} Pinned Messages } > {(triggerRef) => ( {pinnedEvents.length > 0 && ( {pinnedEvents.length} )} )} setPinMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > setPinMenuAnchor(undefined)} /> } /> {screenSize === ScreenSize.Desktop && ( {peopleDrawer ? 'Hide Members' : 'Show Members'} } > {(triggerRef) => ( setPeopleDrawer((drawer) => !drawer)}> )} )} {activeThreadId ? 'Back to Chat' : 'Threads'} } > {(triggerRef) => ( setActiveThreadId(undefined)} aria-pressed={!!activeThreadId} > )} More Options } > {(triggerRef) => ( )} setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > setMenuAnchor(undefined)} /> } /> ); }