diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index ff6ffe05..565e49a9 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -22,7 +22,7 @@ import { import { useFocusWithin, useHover } from 'react-aria'; import FocusTrap from 'focus-trap-react'; import { useParams } from 'react-router-dom'; -import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; +import { NavButton, NavItem, NavItemContent, NavItemOptions, NavLink } from '../../components/nav'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room'; @@ -53,8 +53,10 @@ import { } from '../../hooks/useRoomsNotificationPreferences'; import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher'; import { useCallState } from '../../pages/client/call/CallProvider'; +import { useCallMembers } from '../../hooks/useCallMembers'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; +import { RoomNavUser } from './RoomNavUser'; type RoomNavItemMenuProps = { room: Room; @@ -237,6 +239,7 @@ export function RoomNavItem({ (receipt) => receipt.userId !== mx.getUserId() ); const isActiveCall = isCallActive && activeCallRoomId === room.roomId; + const callMembers = useCallMembers(mx, room.roomId); const { navigateRoom } = useRoomNavigate(); const { roomIdOrAlias: viewedRoomId } = useParams(); const screenSize = useScreenSizeContext(); @@ -293,142 +296,176 @@ export function RoomNavItem({ }; const optionsVisible = hover || !!menuAnchor; + const ariaLabel = [ + room.name, + room.isCallRoom() + ? [ + 'Call Room', + isActiveCall && 'Currently in Call', + callMembers.length && `${callMembers.length} in Call`, + ] + : 'Text Room', + unread?.total && `${unread.total} Messages`, + ] + .flat() + .filter(Boolean) + .join(', '); return ( - - - - - {showAvatar ? ( - ( - - {nameInitials(room.name)} - + + + + + + + {showAvatar ? ( + ( + + {nameInitials(room.name)} + + )} + /> + ) : ( + )} - /> - ) : ( - - )} - - - - {room.name} - - - {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( - - - - )} - {!optionsVisible && unread && ( - - 0} count={unread.total} /> - - )} - {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( - - )} - - - {optionsVisible && ( - - setMenuAnchor(undefined), - clickOutsideDeactivates: true, - isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', - isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', - escapeDeactivates: stopPropagation, - }} - > - setMenuAnchor(undefined)} - notificationMode={notificationMode} + + + + {room.name} + + + {!optionsVisible && !unread && !selected && typingMember.length > 0 && ( + + + + )} + {!optionsVisible && unread && ( + + 0} count={unread.total} /> + + )} + {!optionsVisible && notificationMode !== RoomNotificationMode.Unset && ( + - - } - > - {room.isCallRoom() && ( - + + {optionsVisible && ( + + - Open Chat - + align={menuAnchor?.width === 0 ? 'Start' : 'End'} + content={ + setMenuAnchor(undefined), + clickOutsideDeactivates: true, + isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown', + isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp', + escapeDeactivates: stopPropagation, + }} + > + setMenuAnchor(undefined)} + notificationMode={notificationMode} + /> + } > - {(triggerRef) => ( - + Open Chat + + } > - - - - + {(triggerRef) => ( + + + + + + )} + )} - - )} - - - - - + + + + + + )} + + + {room.isCallRoom() && ( + + {callMembers.map((userId) => ( + + ))} + )} - + ); } diff --git a/src/app/features/room-nav/RoomNavUser.tsx b/src/app/features/room-nav/RoomNavUser.tsx index f72df92e..bd65ef78 100644 --- a/src/app/features/room-nav/RoomNavUser.tsx +++ b/src/app/features/room-nav/RoomNavUser.tsx @@ -1,51 +1,163 @@ -import { Avatar, Box, Icon, Icons, Text } from 'folds'; -import React from 'react'; +import { + Avatar, + Box, + config, + Icon, + IconButton, + Icons, + Text, + Tooltip, + TooltipProvider, +} from 'folds'; +import React, { useState } from 'react'; import { Room } from 'matrix-js-sdk'; -import { NavItem, NavItemContent } from '../../components/nav'; +import { useFocusWithin, useHover } from 'react-aria'; +import { NavItem, NavItemContent, NavItemOptions } from '../../components/nav'; import { UserAvatar } from '../../components/user-avatar'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { useRoomMembers } from '../../hooks/useRoomMembers'; +import { useCallState } from '../../pages/client/call/CallProvider'; import { getMxIdLocalPart } from '../../utils/matrix'; -import { getMemberDisplayName } from '../../utils/room'; +import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; +import { openProfileViewer } from '../../../client/action/navigation'; type RoomNavUserProps = { room: Room; - space: Room; - sender: string; + userId: string; }; -export function RoomNavUser({ room, space, sender }: RoomNavUserProps) { +export function RoomNavUser({ room, userId }: RoomNavUserProps) { const mx = useMatrixClient(); - const members = useRoomMembers(mx, space.roomId); const useAuthentication = useMediaAuthentication(); - - const member = members.find((roomMember) => roomMember.userId === sender); - const avatarMxcUrl = member?.getMxcAvatarUrl(); + const [navUserExpanded, setNavUserExpanded] = useState(false); + const [hover, setHover] = useState(false); + const { hoverProps } = useHover({ onHoverChange: setHover }); + const { focusWithinProps } = useFocusWithin({ + onFocusWithinChange: (isFocused) => { + setHover(isFocused); + if (!isFocused) setNavUserExpanded(false); + }, + }); + const { isCallActive, activeCallRoomId } = useCallState(); + const isActiveCall = isCallActive && activeCallRoomId === room.roomId; + const avatarMxcUrl = getMemberAvatarMxc(room, userId); const avatarUrl = avatarMxcUrl ? mx.mxcUrlToHttp(avatarMxcUrl, 32, 32, 'crop', undefined, false, useAuthentication) : undefined; - const getName = - getMemberDisplayName(room, member?.userId ?? '') ?? - getMxIdLocalPart(member?.userId ?? '') ?? - member?.userId; + const getName = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId; + const isCallParticipant = isActiveCall && userId !== mx.getUserId(); + + const handleNavUserClick = () => { + if (isCallParticipant) { + setNavUserExpanded((prev) => !prev); + } + }; + + const handleClickUser = () => { + openProfileViewer(userId, room.roomId); + }; + + // PLACEHOLDER + const [userMuted, setUserMuted] = useState(false); + const handleToggleMute = () => { + setUserMuted(!userMuted); + }; + + const optionsVisible = (hover || userMuted || navUserExpanded) && isCallParticipant && false; // Disable until individual volume control and mute have been added + const ariaLabel = isCallParticipant + ? `Call Participant: ${getName}${userMuted ? ', Muted' : ''}` + : getName; return ( - - - - - } - /> - - - {getName} - + + + + + + } + /> + + + {getName} + + + {navUserExpanded && ( + + {/* Slider here, when implemented into folds */} + ---- THIS IS A SLIDER --- + + )} + {optionsVisible && ( + + + {userMuted ? 'Unmute' : 'Mute'} + + } + > + {(triggerRef) => ( + + + + )} + + {navUserExpanded && ( + + View Profile + + } + > + {(triggerRef) => ( + + + + )} + + )} + + )} ); } diff --git a/src/app/hooks/useCallMembers.ts b/src/app/hooks/useCallMembers.ts new file mode 100644 index 00000000..6e2e184c --- /dev/null +++ b/src/app/hooks/useCallMembers.ts @@ -0,0 +1,81 @@ +/*import { MatrixClient } from 'matrix-js-sdk'; +import { + MatrixRTCSession, + MatrixRTCSessionEvent, +} from 'matrix-js-sdk/lib/matrixrtc/MatrixRTCSession'; +import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership'; +import { useEffect, useState } from 'react'; + +export const useCallMembers = ( + mx: MatrixClient, + mxr: MatrixRTCSession, + roomId: string +): CallMembership[] => { + const [memberships, setMemberships] = useState([]); + + useEffect(() => { + const room = mx.getRoom(roomId); + + const updateMemberships = () => { + if (!room?.isCallRoom()) return; + setMemberships(MatrixRTCSession.callMembershipsForRoom(room)); + //setMemberships(mxr.memberships); + //console.log('MEMBERSHIPS:'); + //console.log(memberships); + }; + + updateMemberships(); + + mxr.on(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + return () => { + mxr.removeListener(MatrixRTCSessionEvent.MembershipsChanged, updateMemberships); + }; + }, [mx, mxr, roomId]); + + return memberships; +};*/ + +// TEMPORARY +import { MatrixClient, MatrixEvent, RoomStateEvent } from 'matrix-js-sdk'; +import { useEffect, useMemo, useState } from 'react'; +import { getStateEvents } from '../utils/room'; +import { StateEvent } from '../../types/matrix/room'; + +export const useCallMembers = (mx: MatrixClient, roomId: string): string[] => { + const [events, setEvents] = useState([]); + + useEffect(() => { + const room = mx.getRoom(roomId); + + const updateEvents = (event?: MatrixEvent) => { + if (!room?.isCallRoom() || (event && event.getRoomId() !== roomId)) return; + setEvents(getStateEvents(room, StateEvent.GroupCallMemberPrefix)); + }; + + updateEvents(); + + mx.on(RoomStateEvent.Events, updateEvents); + return () => { + mx.removeListener(RoomStateEvent.Events, updateEvents); + }; + }, [mx, roomId]); + + const participants = useMemo( + () => + events + .filter((ev) => { + const content = ev.getContent(); + return ( + content && + ev.getSender() && + content.expires && + ev.getTs() + content.expires > Date.now() + ); + }) + /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ + .map((ev) => ev.getSender()!), + [events] + ); + + return participants; +}; diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index f474ce50..36909e8b 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -26,7 +26,6 @@ import { useVirtualizer } from '@tanstack/react-virtual'; import { JoinRule, Room } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; import FocusTrap from 'focus-trap-react'; -import { logger } from 'matrix-js-sdk/lib/logger'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { mDirectAtom } from '../../../state/mDirectList'; import { @@ -46,8 +45,7 @@ import { import { useSpace } from '../../../hooks/useSpace'; import { VirtualTile } from '../../../components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -// Using the original name for clarity when generating space category IDs -import { makeNavCategoryId as makeSpaceNavCategoryId } from '../../../state/closedNavCategories'; +import { makeNavCategoryId } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; @@ -79,118 +77,7 @@ import { import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { CallNavStatus } from '../../../features/room-nav/RoomCallNavStatus'; -import { getStateEvents } from '../../../utils/room'; -import { RoomNavUser } from '../../../features/room-nav/RoomNavUser'; -import { useStateEvents } from '../../../hooks/useStateEvents'; - -/** - * Processes the raw hierarchy from useSpaceJoinedHierarchy into a flat list - * suitable for the virtualizer, including collapsible headers for text/voice rooms. - * Removes the top-level "Rooms" category header. - * - * @param hierarchy - The raw hierarchy data (array of { roomId: string }). - * @param mx - The Matrix client instance. - * @param spaceRoomId - The ID of the root space being viewed. - * @param closedCategories - The Set of currently closed category IDs. - * @returns An array of processed items for rendering. - */ -const processHierarchyForVirtualizer = ( - hierarchy: { roomId: string }[], - mx: ReturnType, - spaceRoomId: string, - closedCategories: Set -): Array<{ type: string; key: string; [key: string]: any }> => { - const processed: Array<{ type: string; key: string; [key: string]: any }> = []; - let currentCategoryRooms = { text: [], voice: [], users: [] }; - let currentParentId: string = spaceRoomId; - - const addCollectedRoomsToProcessed = (parentId: string) => { - const textCategoryId = `${parentId}_text_rooms`; - const voiceCategoryId = `${parentId}_call_rooms`; - const isTextClosed = closedCategories.has(textCategoryId); - const isCallClosed = closedCategories.has(voiceCategoryId); - - if (currentCategoryRooms.text.length > 0) { - processed.push({ - type: 'room_header', - title: 'Text Rooms', - categoryId: textCategoryId, - key: `${parentId}-text-header`, - }); - if (!isTextClosed) { - currentCategoryRooms.text.forEach((room) => - processed.push({ type: 'room', room, key: room.roomId }) - ); - } - } - - if (currentCategoryRooms.voice.length > 0) { - processed.push({ - type: 'room_header', - title: 'Call Rooms', - categoryId: voiceCategoryId, - key: `${parentId}-voice-header`, - }); - if (!isCallClosed) { - currentCategoryRooms.voice.forEach((room) => { - processed.push({ type: 'room', room, key: room.roomId }); - - currentCategoryRooms.users.forEach((entry) => { - if (entry.room.roomId === room.roomId) { - processed.push(entry); - } - }); - }); - } - } - - currentCategoryRooms = { text: [], voice: [], users: [] }; - }; - - hierarchy.forEach((item) => { - const room = mx.getRoom(item.roomId); - if (!room) { - logger.warn(`processHierarchyForVirtualizer: Room not found for ID ${item.roomId}`); - } - - if (room.isSpaceRoom()) { - addCollectedRoomsToProcessed(currentParentId); - currentParentId = room.roomId; - if (room.roomId !== spaceRoomId) { - const spaceCategoryId = makeSpaceNavCategoryId(spaceRoomId, room.roomId); - processed.push({ - type: 'category', - room, - categoryId: spaceCategoryId, - key: room.roomId, - }); - } - } else if (room.isCallRoom()) { - currentCategoryRooms.voice.push(room); - getStateEvents(room, 'org.matrix.msc3401.call.member').forEach(({ event }) => { - if (Object.entries(event?.content).length !== 0) { - if (event.origin_server_ts + event.content.expires > Date.now()) { - currentCategoryRooms.users.push({ - type: 'user', - sender: event.sender, - key: event.event_id, - room, - }); - } - } - }); - } else if (!room.isCallRoom()) { - currentCategoryRooms.text.push(room); - } else { - logger.warn(`processHierarchyForVirtualizer: Room ${room.roomId} is neither text nor voice.`); - currentCategoryRooms.text.push(room); - } - }); - - addCollectedRoomsToProcessed(currentParentId); - - return processed; -}; +import { useCallState } from '../call/CallProvider'; type SpaceMenuProps = { room: Room; @@ -203,7 +90,7 @@ const SpaceMenu = forwardRef(({ room, requestClo const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); - const canInvite = canDoAction('invite', mx.getUserId() ?? ''); + const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); const openSpaceSettings = useOpenSpaceSettings(); const { navigateRoom } = useRoomNavigate(); @@ -354,6 +241,7 @@ function SpaceHeader() { return cords; }); }; + return ( <> @@ -412,6 +300,7 @@ export function Space() { const selectedRoomId = useSelectedRoom(); const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); + const { isCallActive, activeCallRoomId } = useCallState(); const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); @@ -430,40 +319,25 @@ export function Space() { getRoom, useCallback( (parentId, roomId) => { - const parentSpaceCategoryId = makeSpaceNavCategoryId(space.roomId, parentId); - if (!closedCategories.has(parentSpaceCategoryId)) { + if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { return false; } - const showRoomAnyway = roomToUnread.has(roomId) || roomId === selectedRoomId; + const showRoomAnyway = + roomToUnread.has(roomId) || + roomId === selectedRoomId || + (isCallActive && activeCallRoomId === roomId); return !showRoomAnyway; }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId] + [space.roomId, closedCategories, roomToUnread, selectedRoomId, activeCallRoomId, isCallActive] ), - useCallback( - (subCategoryId) => closedCategories.has(makeSpaceNavCategoryId(space.roomId, subCategoryId)), + (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), [closedCategories, space.roomId] ) ); - const callRooms = useMemo( - () => - hierarchy - .map((item) => mx.getRoom(item.roomId)) - .filter((room): room is Room => !!room && room.isCallRoom()), - [hierarchy, mx] - ); - - const updateTrigger = useStateEvents(callRooms, StateEvent.GroupCallMemberPrefix); - - const processedHierarchy = useMemo( - () => processHierarchyForVirtualizer(hierarchy, mx, space.roomId, closedCategories), - // eslint-disable-next-line react-hooks/exhaustive-deps - [hierarchy, mx, space.roomId, closedCategories, updateTrigger] - ); - const virtualizer = useVirtualizer({ - count: processedHierarchy.length, + count: hierarchy.length, getScrollElement: () => scrollRef.current, estimateSize: () => 32, overscan: 10, @@ -477,12 +351,9 @@ export function Space() { getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); return ( - + - + @@ -518,91 +389,56 @@ export function Space() { - - - {virtualizer.getVirtualItems().map((vItem) => { - const item = processedHierarchy[vItem.index]; - if (!item) return null; - const renderContent = () => { - switch (item.type) { - case 'category': { - const { room, categoryId } = item; - const { name } = room; - const paddingTop = config?.space?.S400 ?? '1rem'; - return ( -
+ + {virtualizer.getVirtualItems().map((vItem) => { + const { roomId } = hierarchy[vItem.index] ?? {}; + const room = mx.getRoom(roomId); + if (!room) return null; + + if (room.isSpaceRoom()) { + const categoryId = makeNavCategoryId(space.roomId, roomId); + + return ( + +
- {name} + {roomId === space.roomId ? 'Rooms' : room?.name}
- ); - } - case 'room_header': { - const { title, categoryId } = item; - return ( - - - - {title} - - - - ); - } - case 'room': { - const { room } = item; - return ( - - - - ); - } - case 'user': { - const { sender, room } = item; - return ( - - - - ); - } - default: - return null; +
+ ); } - }; - return ( - - {renderContent()} - - ); - })} -
+ return ( + + + + ); + })} + +