import React, { MouseEventHandler, forwardRef, useState, useMemo } from 'react'; import { Room, EventTimeline } from 'matrix-js-sdk'; import { useAtomValue } from 'jotai'; import { Avatar, Box, Icon, IconButton, Icons, Text, Menu, MenuItem, config, PopOut, toRem, Line, RectCords, Badge, Spinner, Tooltip, TooltipProvider, } from 'folds'; import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import * as css from '../../components/nav/styles.css'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent, getOrphanParents } from '../../utils/room'; import { nameInitials } from '../../utils/common'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomUnread } from '../../state/hooks/unread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { usePowerLevels } from '../../hooks/usePowerLevels'; import { copyToClipboard } from '../../utils/dom'; import { useMarkAsRead } from '../../hooks/useMarkAsRead'; import { UseStateProvider } from '../../components/UseStateProvider'; import { LeaveRoomPrompt } from '../../components/leave-room-prompt'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { TypingIndicator } from '../../components/typing-indicator'; import { stopPropagation } from '../../utils/keyboard'; import { getMatrixToRoom } from '../../plugins/matrix-to'; import { getCanonicalAliasOrRoomId, isRoomAlias, mxcUrlToHttp } from '../../utils/matrix'; import { getViaServers } from '../../plugins/via-servers'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getRoomNotificationModeIcon, RoomNotificationMode, } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; import { CallParticipantsIndicator } from './CallParticipantsIndicator'; import { useCall, useRoomCall } from '../call/useCall'; import { CallType } from '../call/types'; import { roomToParentsAtom } from '../../state/room/roomToParents'; import { StateEvent } from '../../../types/matrix/room'; import { mDirectAtom } from '../../state/mDirectList'; import { useOpenCreateSubRoomModal } from '../../state/hooks/createRoomModal'; type RoomNavItemMenuProps = { room: Room; requestClose: () => void; notificationMode?: RoomNotificationMode; }; const RoomNavItemMenu = forwardRef( ({ room, requestClose, notificationMode }, ref) => { const mx = useMatrixClient(); const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const powerLevels = usePowerLevels(room); const creators = useRoomCreators(room); const { startCall: startCallGlobal, endCall, activeCall } = useCall(); const { callSupported } = useRoomCall(room.roomId); const markAsRead = useMarkAsRead(mx); const permissions = useRoomPermissions(creators, powerLevels); const canInvite = permissions.action('invite', mx.getSafeUserId()); const canAddSubRoom = permissions.stateEvent(StateEvent.PaarrotSubRooms, mx.getSafeUserId()); const openRoomSettings = useOpenRoomSettings(); const openCreateSubRoom = useOpenCreateSubRoomModal(); const space = useSpaceOptionally(); 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 handleRoomSettings = () => { openRoomSettings(room.roomId, space?.roomId); requestClose(); }; const handleAddSubRoom = () => { openCreateSubRoom(room.roomId); requestClose(); }; const handleJoinVoice = async () => { try { // If already in a call, leave it first if (activeCall) { await endCall(); } await startCallGlobal(room.roomId, CallType.Voice); requestClose(); } catch (error) { console.error('Failed to join voice call:', error); } }; return ( {invitePrompt && room && ( { setInvitePrompt(false); requestClose(); }} /> )} } radii="300" disabled={!unread} > Mark as Read {(handleOpen, opened, changing) => ( ) : ( ) } radii="300" aria-pressed={opened} onClick={handleOpen} > Notifications )} } radii="300" disabled={!callSupported} > Join Voice } radii="300" aria-pressed={invitePrompt} disabled={!canInvite} > Invite } radii="300" > Copy Link } radii="300" > Room Settings } radii="300" disabled={!canAddSubRoom} > Add Sub-Room {(promptLeave, setPromptLeave) => ( <> { if (evt.shiftKey) { mx.leave(room.roomId); requestClose(); } else { setPromptLeave(true); } }} variant="Critical" fill="None" size="300" after={} radii="300" aria-pressed={promptLeave} > Leave Room {promptLeave && ( setPromptLeave(false)} /> )} )} ); } ); type RoomNavItemProps = { room: Room; selected: boolean; linkPath: string; notificationMode?: RoomNotificationMode; showAvatar?: boolean; direct?: boolean; hideIcon?: boolean; }; export function RoomNavItem({ room, selected, showAvatar, direct, notificationMode, linkPath, hideIcon, }: RoomNavItemProps) { const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); const [hover, setHover] = useState(false); const { hoverProps } = useHover({ onHoverChange: setHover }); const { focusWithinProps } = useFocusWithin({ onFocusWithinChange: setHover }); const [menuAnchor, setMenuAnchor] = useState(); const unread = useRoomUnread(room.roomId, roomToUnreadAtom); const avatarUrl = direct ? getDirectRoomAvatarUrl(mx, room, 96, useAuthentication) : getRoomAvatarUrl(mx, room, 96, useAuthentication); const shouldShowIcon = !hideIcon || (hideIcon && avatarUrl); const typingMember = useRoomTypingMember(room.roomId).filter( (receipt) => receipt.userId !== mx.getUserId() ); const roomToParents = useAtomValue(roomToParentsAtom); const mDirects = useAtomValue(mDirectAtom); // Get first participated thread for preview const firstThreadPreview = useMemo(() => { try { const threads = room.getThreads(); const participatedThreads = threads.filter((thread) => thread.hasCurrentUserParticipated); if (participatedThreads.length === 0) return undefined; const firstThread = participatedThreads[0]; const rootEvent = firstThread.rootEvent; if (!rootEvent) return undefined; const rootBody = rootEvent.getContent()?.body ?? ''; return rootBody; } catch (error) { return undefined; } }, [room]); // Get parent space for DMs const parentSpaceInfo = (() => { if (!direct || !mDirects.has(room.roomId)) return undefined; const orphanParents = getOrphanParents(roomToParents, room.roomId); if (orphanParents.length === 0) return undefined; const parentSpaceId = guessPerfectParent(mx, room.roomId, orphanParents) ?? orphanParents[0]; const parentSpace = mx.getRoom(parentSpaceId); if (!parentSpace) return undefined; const avatarEvent = parentSpace.getLiveTimeline().getState(EventTimeline.FORWARDS)?.getStateEvents(StateEvent.RoomAvatar, ''); const avatarContent = avatarEvent?.getContent(); const avatarMxc = avatarContent && typeof avatarContent.url === 'string' ? avatarContent.url : undefined; const avatarUrl = avatarMxc ? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined : undefined; return { name: parentSpace.name, avatarUrl, roomId: parentSpace.roomId, }; })(); const handleContextMenu: MouseEventHandler = (evt) => { evt.preventDefault(); setMenuAnchor({ x: evt.clientX, y: evt.clientY, width: 0, height: 0, }); }; const handleOpenMenu: MouseEventHandler = (evt) => { setMenuAnchor(evt.currentTarget.getBoundingClientRect()); }; const optionsVisible = hover || !!menuAnchor; return ( {shouldShowIcon && ( {showAvatar || avatarUrl ? ( ( {nameInitials(room.name)} )} /> ) : ( )} )} {room.name} {firstThreadPreview && ( ╰ {firstThreadPreview} )} {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( )} {!optionsVisible && unread && ( 0} count={unread.total} /> )} {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( )} {optionsVisible && ( {parentSpaceInfo && ( Space: {parentSpaceInfo.name} } > {(triggerRef) => ( ( {nameInitials(parentSpaceInfo.name, 1)} )} /> )} )} {optionsVisible && ( setMenuAnchor(undefined), clickOutsideDeactivates: true, isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', escapeDeactivates: stopPropagation, }} > setMenuAnchor(undefined)} notificationMode={notificationMode} /> } > )} )} ); }