diff --git a/src/app/pages/client/space/Space.tsx b/src/app/pages/client/space/Space.tsx index fa8c0ea8..0960cf59 100644 --- a/src/app/pages/client/space/Space.tsx +++ b/src/app/pages/client/space/Space.tsx @@ -10,6 +10,7 @@ import { useAtom, useAtomValue } from 'jotai'; import { Avatar, Box, + Button, Icon, IconButton, Icons, @@ -21,7 +22,7 @@ import { Text, config, toRem, -} from 'folds'; +} from 'folds'; // Assuming 'folds' is your UI library import { useVirtualizer } from '@tanstack/react-virtual'; import { JoinRule, Room } from 'matrix-js-sdk'; import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types'; @@ -45,7 +46,7 @@ import { import { useSpace } from '../../../hooks/useSpace'; import { VirtualTile } from '../../../components/virtualizer'; import { RoomNavCategoryButton, RoomNavItem } from '../../../features/room-nav'; -import { makeNavCategoryId } from '../../../state/closedNavCategories'; +import { makeNavCategoryId as makeSpaceNavCategoryId } from '../../../state/closedNavCategories'; import { roomToUnreadAtom } from '../../../state/room/roomToUnread'; import { useCategoryHandler } from '../../../hooks/useCategoryHandler'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; @@ -75,7 +76,137 @@ import { useRoomsNotificationPreferencesContext, } from '../../../hooks/useRoomsNotificationPreferences'; import { useOpenSpaceSettings } from '../../../state/hooks/spaceSettings'; +import { useCallState } from '../CallProvider'; // Assuming path +import { WidgetApiToWidgetAction } from 'matrix-widget-api'; +import { logger } from 'matrix-js-sdk/lib/logger'; +// --- Helper Functions --- + +// Determine if a room is a voice room (assuming Room object has this method) +const isVoiceRoom = (room: Room): boolean => room.isCallRoom?.() ?? false; +// Determine if a room is a text room +const isTextRoom = (room: Room): boolean => !isVoiceRoom(room); + +// Helper function to generate unique category IDs for channel type headers +const makeChannelTypeId = (parentId: string, type: 'text' | 'voice'): string => { + return `${parentId}_${type}_channels`; +}; + +/** + * Processes the raw hierarchy from useSpaceJoinedHierarchy into a flat list + * suitable for the virtualizer, including collapsible headers for text/voice channels. + * Removes the top-level "Channels" 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: [] }; + // Start with the root space as the initial parent context + let currentParentId: string = spaceRoomId; + + // Function to add collected text/voice rooms under their respective headers + const addCollectedRoomsToProcessed = (parentId: string) => { + const textCategoryId = makeChannelTypeId(parentId, 'text'); + const voiceCategoryId = makeChannelTypeId(parentId, 'voice'); + const isTextClosed = closedCategories.has(textCategoryId); + const isVoiceClosed = closedCategories.has(voiceCategoryId); + + // Add Text Channels Header and Rooms (if any exist) + if (currentCategoryRooms.text.length > 0) { + processed.push({ + type: 'channel_header', // Use specific type for collapsible channel headers + title: 'Text Channels', + categoryId: textCategoryId, // ID used for collapse state + key: `${parentId}-text-header`, + }); + // Only add room items if this category is not closed + if (!isTextClosed) { + currentCategoryRooms.text.forEach((room) => + processed.push({ type: 'room', room, key: room.roomId }) + ); + } + } + + // Add Voice Channels Header and Rooms (if any exist) + if (currentCategoryRooms.voice.length > 0) { + processed.push({ + type: 'channel_header', // Use specific type + title: 'Voice Channels', + categoryId: voiceCategoryId, // ID used for collapse state + key: `${parentId}-voice-header`, + }); + // Only add room items if this category is not closed + if (!isVoiceClosed) { + currentCategoryRooms.voice.forEach((room) => + processed.push({ type: 'room', room, key: room.roomId }) + ); + } + } + // Reset collected rooms for the next category/space + currentCategoryRooms = { text: [], voice: [] }; + }; + + // Iterate through the raw hierarchy provided by the hook + hierarchy.forEach((item) => { + const room = mx.getRoom(item.roomId); + if (!room) { + logger.warn(`processHierarchyForVirtualizer: Room not found for ID ${item.roomId}`); + return; // Skip if room data isn't available + } + + if (room.isSpaceRoom()) { + // When encountering a new space, first process the rooms collected under the *previous* parent + addCollectedRoomsToProcessed(currentParentId); + + // Now, set the current parent context to this new space + currentParentId = room.roomId; + + // Add the space category item itself to the processed list, + // *UNLESS* it's the root space (we want to skip the top-level "Channels" header) + if (room.roomId !== spaceRoomId) { + const spaceCategoryId = makeSpaceNavCategoryId(spaceRoomId, room.roomId); // Use original ID generator for spaces + processed.push({ + type: 'category', // Type for main space categories + room, + categoryId: spaceCategoryId, // ID for this space's collapse state + key: room.roomId, + }); + } + // Note: We assume the `hierarchy` list is already filtered based on closed *space* categories. + } else { + // This is a regular room (not a space). Add it to the appropriate list (text/voice) + // for the *current* parent space. + if (isVoiceRoom(room)) { + currentCategoryRooms.voice.push(room); + } else if (isTextRoom(room)) { + currentCategoryRooms.text.push(room); + } else { + // Fallback or handle unexpected room types if necessary + logger.warn( + `processHierarchyForVirtualizer: Room ${room.roomId} is neither text nor voice.` + ); + currentCategoryRooms.text.push(room); // Default to text for now + } + } + }); + + // After iterating through all items, process any remaining rooms collected under the last parent + addCollectedRoomsToProcessed(currentParentId); + + return processed; +}; + +// --- Space Menu Component (Remains Unchanged) --- type SpaceMenuProps = { room: Room; requestClose: () => void; @@ -86,7 +217,7 @@ const SpaceMenu = forwardRef(({ room, requestClo const roomToParents = useAtomValue(roomToParentsAtom); const powerLevels = usePowerLevels(room); const { getPowerLevel, canDoAction } = usePowerLevelsAPI(powerLevels); - const canInvite = canDoAction('invite', getPowerLevel(mx.getUserId() ?? '')); + const canInvite = canDoAction('invite', mx.getUserId() ?? ''); const openSpaceSettings = useOpenSpaceSettings(); const allChild = useSpaceChildren( @@ -202,6 +333,7 @@ const SpaceMenu = forwardRef(({ room, requestClo ); }); +// --- Space Header Component (Remains Unchanged) --- function SpaceHeader() { const space = useSpace(); const spaceName = useRoomName(space); @@ -219,7 +351,6 @@ function SpaceHeader() { return cords; }); }; - return ( <> @@ -255,7 +386,7 @@ function SpaceHeader() { escapeDeactivates: stopPropagation, }} > - setMenuAnchor(undefined)} /> + {space && setMenuAnchor(undefined)} />} } /> @@ -264,9 +395,72 @@ function SpaceHeader() { ); } +// --- Fixed Bottom Nav Area Component (Remains Unchanged) --- +function FixedBottomNavArea() { + const { sendWidgetAction, activeCallRoomId } = useCallState(); + const mx = useMatrixClient(); + const userName = mx.getUser(mx.getUserId() ?? '')?.displayName ?? mx.getUserId() ?? 'User'; + + const handleSendMessageClick = () => { + const action = 'my.custom.action'; // Replace with your actual action + const data = { message: `Hello from ${userName}!` }; + logger.debug(`FixedBottomNavArea: Sending action '${action}'`); + sendWidgetAction(action, data) + .then(() => logger.info(`FixedBottomNavArea: Action '${action}' sent.`)) + .catch((err) => logger.error(`FixedBottomNavArea: Failed action '${action}':`, err)); + }; + + const handleToggleMuteClick = () => { + const action = WidgetApiToWidgetAction.SetAudioInputMuted; + const data = {}; // Sending empty data might imply toggle for some widgets + logger.debug(`FixedBottomNavArea: Sending action '${action}'`); + sendWidgetAction(action, data) + .then(() => logger.info(`FixedBottomNavArea: Action '${action}' sent.`)) + .catch((err) => logger.error(`FixedBottomNavArea: Failed action '${action}':`, err)); + }; + + if (!activeCallRoomId) { + return ( + + + No active call + + + ); + } + + return ( + + {mx.getRoom(activeCallRoomId)?.normalizedName} + + ); +} /* + + + + + + + */ + +// --- Main Space Component (Updated Rendering Logic) --- export function Space() { const mx = useMatrixClient(); - const space = useSpace(); + const space = useSpace(); // The current top-level space being viewed useNavToActivePathMapper(space.roomId); const spaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId); const scrollRef = useRef(null); @@ -280,10 +474,12 @@ export function Space() { const lobbySelected = useSpaceLobbySelected(spaceIdOrAlias); const searchSelected = useSpaceSearchSelected(spaceIdOrAlias); + // State for managing collapsed categories (includes spaces and channel types) const [closedCategories, setClosedCategories] = useAtom(useClosedNavCategoriesAtom()); + // Memoized callback to get room objects const getRoom = useCallback( - (rId: string) => { + (rId: string): Room | undefined => { if (allJoinedRooms.has(rId)) { return mx.getRoom(rId) ?? undefined; } @@ -292,45 +488,76 @@ export function Space() { [mx, allJoinedRooms] ); + // Fetch the raw hierarchy using the hook + // Note: The filtering callbacks passed here primarily affect *which* rooms/spaces + // are included in the raw list *before* processing. const hierarchy = useSpaceJoinedHierarchy( space.roomId, getRoom, + // isRoomHidden callback: Hides room if parent space category is closed, unless room is unread/selected. useCallback( (parentId, roomId) => { - if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) { + // Generate the category ID for the parent *space* + const parentSpaceCategoryId = makeSpaceNavCategoryId(space.roomId, parentId); + // If the parent space category is not closed, the room is not hidden by this rule. + if (!closedCategories.has(parentSpaceCategoryId)) { return false; } - const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId; - if (showRoom) return false; - return true; + // Parent space is closed. Hide the room unless it's unread or currently selected. + const showRoomAnyway = roomToUnread.has(roomId) || roomId === selectedRoomId; + return !showRoomAnyway; // Return true to hide, false to show }, - [space.roomId, closedCategories, roomToUnread, selectedRoomId] + [space.roomId, closedCategories, roomToUnread, selectedRoomId] // Dependencies ), + // isSubCategoryClosed callback: Checks if a *space* subcategory is closed. useCallback( - (sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)), - [closedCategories, space.roomId] + (subCategoryId) => closedCategories.has(makeSpaceNavCategoryId(space.roomId, subCategoryId)), + [closedCategories, space.roomId] // Dependencies ) ); + // Process the raw hierarchy into a list with collapsible channel headers + const processedHierarchy = useMemo( + () => + processHierarchyForVirtualizer( + hierarchy, + mx, + space.roomId, + closedCategories // Pass closed state to the processing function + ), + [hierarchy, mx, space.roomId, closedCategories] // Dependencies for memoization + ); + + // Setup the virtualizer with the processed list const virtualizer = useVirtualizer({ - count: hierarchy.length, + count: processedHierarchy.length, getScrollElement: () => scrollRef.current, - estimateSize: () => 0, - overscan: 10, + estimateSize: () => 32, // Adjust based on average item height + overscan: 10, // Render items slightly outside the viewport }); + // Click handler for toggling category collapse state (works for spaces and channel types) const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => closedCategories.has(categoryId) ); + // Function to generate navigation links for rooms const getToLink = (roomId: string) => getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId)); + // --- Render --- return ( - + + {/* Fixed Header */} - - + + {/* Scrollable Content Area */} + + {/* Static Top Links (Lobby, Search) */} + @@ -365,57 +592,108 @@ export function Space() { - - {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); + {/* Virtualized List Area */} + + {virtualizer.getVirtualItems().map((vItem) => { + const item = processedHierarchy[vItem.index]; + if (!item) return null; // Should not happen with correct processing - return ( - -
+ // --- Render Logic based on Item Type --- + const renderContent = () => { + switch (item.type) { + // Render a main space category header (for nested spaces) + case 'category': { + // item has: room, categoryId, key + const { room, categoryId } = item; + // Determine name: Use the room name for nested spaces + const name = room.name; + // Add padding above subsequent categories + // Removed index === 0 check as root category is gone + const paddingTop = config?.space?.S400 ?? '1rem'; + return ( +
- {roomId === space.roomId ? 'Rooms' : room?.name} + {name}
- - ); + ); + } + // Render a collapsible header for Text or Voice channels + case 'channel_header': { + // item has: title, categoryId, key + const { title, categoryId } = item; + return ( + // Add indentation and padding for visual hierarchy + + + {' '} + {/* Use subtle variant if available */} + + {title} + + + + ); + } + // Render a regular room item (text or voice channel) + case 'room': { + // item has: room, key + const { room } = item; + return ( + // Add indentation for rooms under headers + + + + ); + } + default: + // Log error for unexpected item types + logger.error('Unknown item type in virtualized list:', item); + return null; } + }; - return ( - - - - ); - })} - - + // Render the virtual tile wrapper with the content + return ( + + {renderContent()} + + ); + })} + + + {/* Fixed Bottom Section (Remains Unchanged) */} + ); }