mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-04 22:40:29 +03:00
Merge 24b3b9cf52 into 46c02b89de
This commit is contained in:
commit
8e3de1fc61
7 changed files with 414 additions and 71 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import React, { MouseEventHandler, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
|
|
@ -31,7 +31,7 @@ import {
|
|||
useRoomsPowerLevels,
|
||||
} from '../../hooks/usePowerLevels';
|
||||
import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
|
||||
import { makeLobbyCategoryId, getLobbyCategoryIdParts } from '../../state/closedLobbyCategories';
|
||||
import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
|
|
@ -74,6 +74,11 @@ const useCanDropLobbyItem = (
|
|||
|
||||
const containerSpaceId = space.roomId;
|
||||
|
||||
// only allow to be dropped in parent space
|
||||
if (item.parentId !== container.item.roomId && item.parentId !== container.item.parentId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const powerLevels = roomsPowerLevels.get(containerSpaceId) ?? {};
|
||||
const creators = getRoomCreatorsForRoomId(mx, containerSpaceId);
|
||||
const permissions = getRoomPermissionsAPI(creators, powerLevels);
|
||||
|
|
@ -167,6 +172,7 @@ export function Lobby() {
|
|||
const screenSize = useScreenSizeContext();
|
||||
const [onTop, setOnTop] = useState(true);
|
||||
const [closedCategories, setClosedCategories] = useAtom(useClosedLobbyCategoriesAtom());
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const [sidebarItems] = useSidebarItems(
|
||||
useOrphanSpaces(mx, allRoomsAtom, useAtomValue(roomToParentsAtom))
|
||||
);
|
||||
|
|
@ -188,6 +194,83 @@ export function Lobby() {
|
|||
|
||||
const getRoom = useGetRoom(allJoinedRooms);
|
||||
|
||||
const closedCategoriesCache = useRef(new Map());
|
||||
useEffect(() => {
|
||||
closedCategoriesCache.current.clear();
|
||||
}, [closedCategories, roomToParents, getRoom]);
|
||||
|
||||
/**
|
||||
* Recursively checks if a given parentId (or all its ancestors) is in a closed category.
|
||||
*
|
||||
* @param spaceId - The root space ID.
|
||||
* @param parentId - The parent space ID to start the check from.
|
||||
* @param previousId - The last ID checked, only used to ignore root collapse state.
|
||||
* @returns True if parentId or all ancestors is in a closed category.
|
||||
*/
|
||||
const getInClosedCategories = useCallback(
|
||||
(spaceId: string, parentId: string, previousId?: string): boolean => {
|
||||
const categoryId = makeLobbyCategoryId(spaceId, parentId);
|
||||
if (closedCategoriesCache.current.has(categoryId)) {
|
||||
return closedCategoriesCache.current.get(categoryId);
|
||||
}
|
||||
|
||||
// Ignore root space being collapsed if in a subspace,
|
||||
// this is due to many spaces dumping all rooms in the top-level space.
|
||||
if (parentId === spaceId) {
|
||||
if (previousId) {
|
||||
if (getRoom(previousId)?.isSpaceRoom() || spaceRooms.has(previousId)) {
|
||||
closedCategoriesCache.current.set(categoryId, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (closedCategories.has(categoryId)) {
|
||||
closedCategoriesCache.current.set(categoryId, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentParentIds = roomToParents.get(parentId);
|
||||
if (!parentParentIds || parentParentIds.size === 0) {
|
||||
closedCategoriesCache.current.set(categoryId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
let anyOpen = false;
|
||||
parentParentIds.forEach((id) => {
|
||||
if (!getInClosedCategories(spaceId, id, parentId)) {
|
||||
anyOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
closedCategoriesCache.current.set(categoryId, !anyOpen);
|
||||
return !anyOpen;
|
||||
},
|
||||
[closedCategories, getRoom, roomToParents, spaceRooms]
|
||||
);
|
||||
|
||||
/**
|
||||
* Determines whether all parent categories are collapsed.
|
||||
*
|
||||
* @param spaceId - The root space ID.
|
||||
* @param roomId - The room ID to start the check from.
|
||||
* @returns True if every parent category is collapsed; false otherwise.
|
||||
*/
|
||||
const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => {
|
||||
const parentIds = roomToParents.get(roomId);
|
||||
|
||||
if (!parentIds || parentIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let allCollapsed = true;
|
||||
parentIds.forEach((id) => {
|
||||
if (!getInClosedCategories(spaceId, id, roomId)) {
|
||||
allCollapsed = false;
|
||||
}
|
||||
});
|
||||
return allCollapsed;
|
||||
};
|
||||
|
||||
const [draggingItem, setDraggingItem] = useState<HierarchyItem>();
|
||||
const hierarchy = useSpaceHierarchy(
|
||||
space.roomId,
|
||||
|
|
@ -195,9 +278,9 @@ export function Lobby() {
|
|||
getRoom,
|
||||
useCallback(
|
||||
(childId) =>
|
||||
closedCategories.has(makeLobbyCategoryId(space.roomId, childId)) ||
|
||||
getInClosedCategories(space.roomId, childId) ||
|
||||
(draggingItem ? 'space' in draggingItem : false),
|
||||
[closedCategories, space.roomId, draggingItem]
|
||||
[draggingItem, getInClosedCategories, space.roomId]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -298,7 +381,7 @@ export function Lobby() {
|
|||
|
||||
// remove from current space
|
||||
if (item.parentId !== containerParentId) {
|
||||
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
|
||||
await mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
|
||||
}
|
||||
|
||||
if (
|
||||
|
|
@ -318,7 +401,7 @@ export function Lobby() {
|
|||
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
|
||||
[];
|
||||
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
|
||||
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
|
||||
await mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
|
||||
...joinRuleContent,
|
||||
allow,
|
||||
});
|
||||
|
|
@ -404,9 +487,18 @@ export function Lobby() {
|
|||
[setSpaceRooms]
|
||||
);
|
||||
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
);
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => {
|
||||
const collapsed = closedCategories.has(categoryId);
|
||||
const [spaceId, roomId] = getLobbyCategoryIdParts(categoryId);
|
||||
|
||||
// Only prevent collapsing if all parents are collapsed
|
||||
const toggleable = !getAllAncestorsCollapsed(spaceId, roomId);
|
||||
|
||||
if (toggleable) {
|
||||
return collapsed;
|
||||
}
|
||||
return !collapsed;
|
||||
});
|
||||
|
||||
const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const rId = evt.currentTarget.getAttribute('data-room-id');
|
||||
|
|
@ -468,14 +560,20 @@ export function Lobby() {
|
|||
const item = hierarchy[vItem.index];
|
||||
if (!item) return null;
|
||||
const nextSpaceId = hierarchy[vItem.index + 1]?.space.roomId;
|
||||
|
||||
const categoryId = makeLobbyCategoryId(space.roomId, item.space.roomId);
|
||||
const inClosedCategory = getInClosedCategories(
|
||||
space.roomId,
|
||||
item.space.roomId
|
||||
);
|
||||
|
||||
const paddingLeft = `calc((${item.space.depth} - 1) * ${config.space.S200})`;
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
virtualItem={vItem}
|
||||
style={{
|
||||
paddingTop: vItem.index === 0 ? 0 : config.space.S500,
|
||||
paddingLeft,
|
||||
}}
|
||||
ref={virtualizer.measureElement}
|
||||
key={vItem.index}
|
||||
|
|
@ -489,8 +587,7 @@ export function Lobby() {
|
|||
roomsPowerLevels={roomsPowerLevels}
|
||||
categoryId={categoryId}
|
||||
closed={
|
||||
closedCategories.has(categoryId) ||
|
||||
(draggingItem ? 'space' in draggingItem : false)
|
||||
inClosedCategory || (draggingItem ? 'space' in draggingItem : false)
|
||||
}
|
||||
handleClose={handleCategoryClick}
|
||||
draggingItem={draggingItem}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ import {
|
|||
MenuItem,
|
||||
RectCords,
|
||||
config,
|
||||
IconButton,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
|
|
@ -359,15 +362,42 @@ function AddSpaceButton({ item }: { item: HierarchyItem }) {
|
|||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
before={<Icon src={Icons.Plus} size="50" />}
|
||||
onClick={handleAddSpace}
|
||||
aria-pressed={!!cords}
|
||||
>
|
||||
<Text size="B300">Add Space</Text>
|
||||
</Chip>
|
||||
{item.parentId === undefined ? (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
before={<Icon src={Icons.Plus} size="50" />}
|
||||
onClick={handleAddSpace}
|
||||
aria-pressed={!!cords}
|
||||
>
|
||||
<Text size="B300">Add Space</Text>
|
||||
</Chip>
|
||||
) : (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Add Space</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
ref={triggerRef}
|
||||
onClick={handleAddSpace}
|
||||
aria-pressed={!!cords}
|
||||
aria-label="Add Space"
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
>
|
||||
<Icon size="50" src={Icons.SpacePlus} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{addExisting && (
|
||||
<AddExistingModal space parentId={item.roomId} requestClose={() => setAddExisting(false)} />
|
||||
)}
|
||||
|
|
@ -485,7 +515,7 @@ export const SpaceItemCard = as<'div', SpaceItemCardProps>(
|
|||
{space && canEditChild && (
|
||||
<Box shrink="No" alignItems="Inherit" gap="200">
|
||||
<AddRoomButton item={item} />
|
||||
{item.parentId === undefined && <AddSpaceButton item={item} />}
|
||||
<AddSpaceButton item={item} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { atom, useAtom, useAtomValue } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { MatrixError, MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { IHierarchyRoom } from 'matrix-js-sdk/lib/@types/spaces';
|
||||
import { QueryFunction, useInfiniteQuery } from '@tanstack/react-query';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
|
@ -18,6 +18,7 @@ export type HierarchyItemSpace = {
|
|||
ts: number;
|
||||
space: true;
|
||||
parentId?: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type HierarchyItemRoom = {
|
||||
|
|
@ -25,6 +26,7 @@ export type HierarchyItemRoom = {
|
|||
content: MSpaceChildContent;
|
||||
ts: number;
|
||||
parentId: string;
|
||||
depth: number;
|
||||
};
|
||||
|
||||
export type HierarchyItem = HierarchyItemSpace | HierarchyItemRoom;
|
||||
|
|
@ -35,9 +37,14 @@ const hierarchyItemTs: SortFunc<HierarchyItem> = (a, b) => byTsOldToNew(a.ts, b.
|
|||
const hierarchyItemByOrder: SortFunc<HierarchyItem> = (a, b) =>
|
||||
byOrderKey(a.content.order, b.content.order);
|
||||
|
||||
const childEventTs: SortFunc<MatrixEvent> = (a, b) => byTsOldToNew(a.getTs(), b.getTs());
|
||||
const childEventByOrder: SortFunc<MatrixEvent> = (a, b) =>
|
||||
byOrderKey(a.getContent<MSpaceChildContent>().order, b.getContent<MSpaceChildContent>().order);
|
||||
|
||||
const getHierarchySpaces = (
|
||||
rootSpaceId: string,
|
||||
getRoom: GetRoomCallback,
|
||||
excludeRoom: (parentId: string, roomId: string) => boolean,
|
||||
spaceRooms: Set<string>
|
||||
): HierarchyItemSpace[] => {
|
||||
const rootSpaceItem: HierarchyItemSpace = {
|
||||
|
|
@ -45,8 +52,9 @@ const getHierarchySpaces = (
|
|||
content: { via: [] },
|
||||
ts: 0,
|
||||
space: true,
|
||||
depth: 0,
|
||||
};
|
||||
let spaceItems: HierarchyItemSpace[] = [];
|
||||
const spaceItems: HierarchyItemSpace[] = [];
|
||||
|
||||
const findAndCollectHierarchySpaces = (spaceItem: HierarchyItemSpace) => {
|
||||
if (spaceItems.find((item) => item.roomId === spaceItem.roomId)) return;
|
||||
|
|
@ -54,38 +62,38 @@ const getHierarchySpaces = (
|
|||
spaceItems.push(spaceItem);
|
||||
|
||||
if (!space) return;
|
||||
const childEvents = getStateEvents(space, StateEvent.SpaceChild);
|
||||
const childEvents = getStateEvents(space, StateEvent.SpaceChild)
|
||||
.filter((childEvent) => {
|
||||
if (!isValidChild(childEvent)) return false;
|
||||
const childId = childEvent.getStateKey();
|
||||
if (!childId || !isRoomId(childId)) return false;
|
||||
if (excludeRoom(spaceItem.roomId, childId)) return false;
|
||||
|
||||
// because we can not find if a childId is space without joining
|
||||
// or requesting room summary, we will look it into spaceRooms local
|
||||
// cache which we maintain as we load summary in UI.
|
||||
return getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId);
|
||||
})
|
||||
.sort(childEventTs)
|
||||
.sort(childEventByOrder);
|
||||
|
||||
childEvents.forEach((childEvent) => {
|
||||
if (!isValidChild(childEvent)) return;
|
||||
const childId = childEvent.getStateKey();
|
||||
if (!childId || !isRoomId(childId)) return;
|
||||
|
||||
// because we can not find if a childId is space without joining
|
||||
// or requesting room summary, we will look it into spaceRooms local
|
||||
// cache which we maintain as we load summary in UI.
|
||||
if (getRoom(childId)?.isSpaceRoom() || spaceRooms.has(childId)) {
|
||||
const childItem: HierarchyItemSpace = {
|
||||
roomId: childId,
|
||||
content: childEvent.getContent<MSpaceChildContent>(),
|
||||
ts: childEvent.getTs(),
|
||||
space: true,
|
||||
parentId: spaceItem.roomId,
|
||||
};
|
||||
findAndCollectHierarchySpaces(childItem);
|
||||
}
|
||||
const childItem: HierarchyItemSpace = {
|
||||
roomId: childId,
|
||||
content: childEvent.getContent<MSpaceChildContent>(),
|
||||
ts: childEvent.getTs(),
|
||||
space: true,
|
||||
parentId: spaceItem.roomId,
|
||||
depth: spaceItem.depth + 1,
|
||||
};
|
||||
findAndCollectHierarchySpaces(childItem);
|
||||
});
|
||||
};
|
||||
findAndCollectHierarchySpaces(rootSpaceItem);
|
||||
|
||||
spaceItems = [
|
||||
rootSpaceItem,
|
||||
...spaceItems
|
||||
.filter((item) => item.roomId !== rootSpaceId)
|
||||
.sort(hierarchyItemTs)
|
||||
.sort(hierarchyItemByOrder),
|
||||
];
|
||||
|
||||
return spaceItems;
|
||||
};
|
||||
|
||||
|
|
@ -99,7 +107,12 @@ const getSpaceHierarchy = (
|
|||
getRoom: (roomId: string) => Room | undefined,
|
||||
closedCategory: (spaceId: string) => boolean
|
||||
): SpaceHierarchy[] => {
|
||||
const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, spaceRooms);
|
||||
const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(
|
||||
rootSpaceId,
|
||||
getRoom,
|
||||
() => false,
|
||||
spaceRooms
|
||||
);
|
||||
|
||||
const hierarchy: SpaceHierarchy[] = spaceItems.map((spaceItem) => {
|
||||
const space = getRoom(spaceItem.roomId);
|
||||
|
|
@ -121,6 +134,7 @@ const getSpaceHierarchy = (
|
|||
content: childEvent.getContent<MSpaceChildContent>(),
|
||||
ts: childEvent.getTs(),
|
||||
parentId: spaceItem.roomId,
|
||||
depth: spaceItem.depth,
|
||||
};
|
||||
childItems.push(childItem);
|
||||
});
|
||||
|
|
@ -177,7 +191,36 @@ const getSpaceJoinedHierarchy = (
|
|||
excludeRoom: (parentId: string, roomId: string) => boolean,
|
||||
sortRoomItems: (parentId: string, items: HierarchyItem[]) => HierarchyItem[]
|
||||
): HierarchyItem[] => {
|
||||
const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(rootSpaceId, getRoom, new Set());
|
||||
const spaceItems: HierarchyItemSpace[] = getHierarchySpaces(
|
||||
rootSpaceId,
|
||||
getRoom,
|
||||
excludeRoom,
|
||||
new Set()
|
||||
);
|
||||
|
||||
/**
|
||||
* Recursively checks if the given space or any of its descendants contain non-space rooms.
|
||||
*
|
||||
* @param spaceId - The space ID to check.
|
||||
* @returns True if the space or any descendant contains non-space rooms.
|
||||
*/
|
||||
const getContainsRoom = (spaceId: string) => {
|
||||
const space = getRoom(spaceId);
|
||||
if (!space) return false;
|
||||
|
||||
const childEvents = getStateEvents(space, StateEvent.SpaceChild);
|
||||
|
||||
return childEvents.some((childEvent): boolean => {
|
||||
if (!isValidChild(childEvent)) return false;
|
||||
const childId = childEvent.getStateKey();
|
||||
if (!childId || !isRoomId(childId)) return false;
|
||||
const room = getRoom(childId);
|
||||
if (!room) return false;
|
||||
|
||||
if (!room.isSpaceRoom()) return true;
|
||||
return getContainsRoom(childId);
|
||||
});
|
||||
};
|
||||
|
||||
const hierarchy: HierarchyItem[] = spaceItems.flatMap((spaceItem) => {
|
||||
const space = getRoom(spaceItem.roomId);
|
||||
|
|
@ -194,7 +237,7 @@ const getSpaceJoinedHierarchy = (
|
|||
return true;
|
||||
});
|
||||
|
||||
if (joinedRoomEvents.length === 0) return [];
|
||||
if (!getContainsRoom(spaceItem.roomId)) return [];
|
||||
|
||||
const childItems: HierarchyItemRoom[] = [];
|
||||
joinedRoomEvents.forEach((childEvent) => {
|
||||
|
|
@ -208,6 +251,7 @@ const getSpaceJoinedHierarchy = (
|
|||
content: childEvent.getContent<MSpaceChildContent>(),
|
||||
ts: childEvent.getTs(),
|
||||
parentId: spaceItem.roomId,
|
||||
depth: spaceItem.depth,
|
||||
};
|
||||
childItems.push(childItem);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import React, {
|
|||
MouseEventHandler,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
|
|
@ -48,7 +49,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, getNavCategoryIdParts } from '../../../state/closedNavCategories';
|
||||
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
|
||||
import { useCategoryHandler } from '../../../hooks/useCategoryHandler';
|
||||
import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper';
|
||||
|
|
@ -59,6 +60,7 @@ import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page
|
|||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { useRecursiveChildScopeFactory, useSpaceChildren } from '../../../state/hooks/roomList';
|
||||
import { roomToParentsAtom } from '../../../state/room/roomToParents';
|
||||
import { roomToChildrenAtom } from '../../../state/room/roomToChildren';
|
||||
import { markAsRead } from '../../../utils/notifications';
|
||||
import { useRoomsUnread } from '../../../state/hooks/unread';
|
||||
import { UseStateProvider } from '../../../components/UseStateProvider';
|
||||
|
|
@ -382,6 +384,8 @@ export function Space() {
|
|||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||
const roomToChildren = useAtomValue(roomToChildrenAtom);
|
||||
const allRooms = useAtomValue(allRoomsAtom);
|
||||
const allJoinedRooms = useMemo(() => new Set(allRooms), [allRooms]);
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
|
|
@ -404,23 +408,138 @@ export function Space() {
|
|||
[mx, allJoinedRooms]
|
||||
);
|
||||
|
||||
const closedCategoriesCache = useRef(new Map());
|
||||
const ancestorsCollapsedCache = useRef(new Map());
|
||||
useEffect(() => {
|
||||
closedCategoriesCache.current.clear();
|
||||
ancestorsCollapsedCache.current.clear();
|
||||
}, [closedCategories, roomToParents, getRoom]);
|
||||
|
||||
/**
|
||||
* Recursively checks if a given parentId (or all its ancestors) is in a closed category.
|
||||
*
|
||||
* @param spaceId - The root space ID.
|
||||
* @param parentId - The parent space ID to start the check from.
|
||||
* @param previousId - The last ID checked, only used to ignore root collapse state.
|
||||
* @returns True if parentId or all ancestors is in a closed category.
|
||||
*/
|
||||
const getInClosedCategories = useCallback(
|
||||
(spaceId: string, parentId: string, previousId?: string): boolean => {
|
||||
const categoryId = makeNavCategoryId(spaceId, parentId);
|
||||
if (closedCategoriesCache.current.has(categoryId)) {
|
||||
return closedCategoriesCache.current.get(categoryId);
|
||||
}
|
||||
|
||||
// Ignore root space being collapsed if in a subspace,
|
||||
// this is due to many spaces dumping all rooms in the top-level space.
|
||||
if (parentId === spaceId) {
|
||||
if (previousId) {
|
||||
if (getRoom(previousId)?.isSpaceRoom()) {
|
||||
closedCategoriesCache.current.set(categoryId, false);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (closedCategories.has(categoryId)) {
|
||||
closedCategoriesCache.current.set(categoryId, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const parentParentIds = roomToParents.get(parentId);
|
||||
if (!parentParentIds || parentParentIds.size === 0) {
|
||||
closedCategoriesCache.current.set(categoryId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
let anyOpen = false;
|
||||
parentParentIds.forEach((id) => {
|
||||
if (!getInClosedCategories(spaceId, id, parentId)) {
|
||||
anyOpen = true;
|
||||
}
|
||||
});
|
||||
|
||||
closedCategoriesCache.current.set(categoryId, !anyOpen);
|
||||
return !anyOpen;
|
||||
},
|
||||
[closedCategories, getRoom, roomToParents]
|
||||
);
|
||||
|
||||
/**
|
||||
* Recursively checks if the given room or any of its descendants should be visible.
|
||||
*
|
||||
* @param roomId - The room ID to check.
|
||||
* @returns True if the room or any descendant should be visible.
|
||||
*/
|
||||
const getContainsShowRoom = useCallback(
|
||||
(roomId: string): boolean => {
|
||||
if (roomToUnread.has(roomId) || roomId === selectedRoomId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const childIds = roomToChildren.get(roomId);
|
||||
if (!childIds || childIds.size === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let visible = false;
|
||||
childIds.forEach((id) => {
|
||||
if (getContainsShowRoom(id)) {
|
||||
visible = true;
|
||||
}
|
||||
});
|
||||
|
||||
return visible;
|
||||
},
|
||||
[roomToUnread, selectedRoomId, roomToChildren]
|
||||
);
|
||||
|
||||
/**
|
||||
* Determines whether all parent categories are collapsed.
|
||||
*
|
||||
* @param spaceId - The root space ID.
|
||||
* @param roomId - The room ID to start the check from.
|
||||
* @returns True if every parent category is collapsed; false otherwise.
|
||||
*/
|
||||
const getAllAncestorsCollapsed = (spaceId: string, roomId: string): boolean => {
|
||||
const categoryId = makeNavCategoryId(spaceId, roomId);
|
||||
if (ancestorsCollapsedCache.current.has(categoryId)) {
|
||||
return ancestorsCollapsedCache.current.get(categoryId);
|
||||
}
|
||||
|
||||
const parentIds = roomToParents.get(roomId);
|
||||
if (!parentIds || parentIds.size === 0) {
|
||||
ancestorsCollapsedCache.current.set(categoryId, false);
|
||||
return false;
|
||||
}
|
||||
|
||||
let allCollapsed = true;
|
||||
parentIds.forEach((id) => {
|
||||
if (!getInClosedCategories(spaceId, id, roomId)) {
|
||||
allCollapsed = false;
|
||||
}
|
||||
});
|
||||
|
||||
ancestorsCollapsedCache.current.set(categoryId, allCollapsed);
|
||||
return allCollapsed;
|
||||
};
|
||||
|
||||
const hierarchy = useSpaceJoinedHierarchy(
|
||||
space.roomId,
|
||||
getRoom,
|
||||
useCallback(
|
||||
(parentId, roomId) => {
|
||||
if (!closedCategories.has(makeNavCategoryId(space.roomId, parentId))) {
|
||||
if (!getInClosedCategories(space.roomId, parentId, roomId)) {
|
||||
return false;
|
||||
}
|
||||
const showRoom = roomToUnread.has(roomId) || roomId === selectedRoomId;
|
||||
if (showRoom) return false;
|
||||
if (getContainsShowRoom(roomId)) return false;
|
||||
return true;
|
||||
},
|
||||
[space.roomId, closedCategories, roomToUnread, selectedRoomId]
|
||||
[getContainsShowRoom, getInClosedCategories, space.roomId]
|
||||
),
|
||||
useCallback(
|
||||
(sId) => closedCategories.has(makeNavCategoryId(space.roomId, sId)),
|
||||
[closedCategories, space.roomId]
|
||||
(sId) => getInClosedCategories(space.roomId, sId),
|
||||
[getInClosedCategories, space.roomId]
|
||||
)
|
||||
);
|
||||
|
||||
|
|
@ -431,13 +550,28 @@ export function Space() {
|
|||
overscan: 10,
|
||||
});
|
||||
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) =>
|
||||
closedCategories.has(categoryId)
|
||||
);
|
||||
const handleCategoryClick = useCategoryHandler(setClosedCategories, (categoryId) => {
|
||||
const collapsed = closedCategories.has(categoryId);
|
||||
const [spaceId, roomId] = getNavCategoryIdParts(categoryId);
|
||||
|
||||
// Only prevent collapsing if all parents are collapsed
|
||||
const toggleable = !getAllAncestorsCollapsed(spaceId, roomId);
|
||||
|
||||
if (toggleable) {
|
||||
return collapsed;
|
||||
}
|
||||
return !collapsed;
|
||||
});
|
||||
|
||||
const getToLink = (roomId: string) =>
|
||||
getSpaceRoomPath(spaceIdOrAlias, getCanonicalAliasOrRoomId(mx, roomId));
|
||||
|
||||
const getCategoryPadding = (depth: number): string | undefined => {
|
||||
if (depth === 0) return undefined;
|
||||
if (depth === 1) return config.space.S400;
|
||||
return config.space.S200;
|
||||
};
|
||||
|
||||
return (
|
||||
<PageNav>
|
||||
<SpaceHeader />
|
||||
|
|
@ -490,12 +624,18 @@ export function Space() {
|
|||
}}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const { roomId } = hierarchy[vItem.index] ?? {};
|
||||
const { roomId, depth } = hierarchy[vItem.index] ?? {};
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return null;
|
||||
|
||||
const paddingLeft = `calc((${depth} - 1) * ${config.space.S200})`;
|
||||
|
||||
if (room.isSpaceRoom()) {
|
||||
const categoryId = makeNavCategoryId(space.roomId, roomId);
|
||||
const closed = getInClosedCategories(space.roomId, roomId);
|
||||
const toggleable = !getAllAncestorsCollapsed(space.roomId, roomId);
|
||||
|
||||
const paddingTop = getCategoryPadding(depth);
|
||||
|
||||
return (
|
||||
<VirtualTile
|
||||
|
|
@ -503,12 +643,19 @@ export function Space() {
|
|||
key={vItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
<div style={{ paddingTop: vItem.index === 0 ? undefined : config.space.S400 }}>
|
||||
<div
|
||||
style={{
|
||||
paddingTop,
|
||||
paddingLeft,
|
||||
}}
|
||||
>
|
||||
<NavCategoryHeader>
|
||||
<RoomNavCategoryButton
|
||||
data-category-id={categoryId}
|
||||
onClick={handleCategoryClick}
|
||||
closed={closedCategories.has(categoryId)}
|
||||
closed={closed}
|
||||
aria-expanded={!closed}
|
||||
aria-disabled={!toggleable}
|
||||
>
|
||||
{roomId === space.roomId ? 'Rooms' : room?.name}
|
||||
</RoomNavCategoryButton>
|
||||
|
|
@ -520,14 +667,19 @@ export function Space() {
|
|||
|
||||
return (
|
||||
<VirtualTile virtualItem={vItem} key={vItem.index} ref={virtualizer.measureElement}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selectedRoomId === roomId}
|
||||
showAvatar={mDirects.has(roomId)}
|
||||
direct={mDirects.has(roomId)}
|
||||
linkPath={getToLink(roomId)}
|
||||
notificationMode={getRoomNotificationMode(notificationPreferences, room.roomId)}
|
||||
/>
|
||||
<div style={{ paddingLeft }}>
|
||||
<RoomNavItem
|
||||
room={room}
|
||||
selected={selectedRoomId === roomId}
|
||||
showAvatar={mDirects.has(roomId)}
|
||||
direct={mDirects.has(roomId)}
|
||||
linkPath={getToLink(roomId)}
|
||||
notificationMode={getRoomNotificationMode(
|
||||
notificationPreferences,
|
||||
room.roomId
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -66,3 +66,5 @@ export const makeClosedLobbyCategoriesAtom = (userId: string): ClosedLobbyCatego
|
|||
};
|
||||
|
||||
export const makeLobbyCategoryId = (...args: string[]): string => args.join('|');
|
||||
|
||||
export const getLobbyCategoryIdParts = (categoryId: string): string[] => categoryId.split('|');
|
||||
|
|
|
|||
|
|
@ -66,3 +66,5 @@ export const makeClosedNavCategoriesAtom = (userId: string): ClosedNavCategories
|
|||
};
|
||||
|
||||
export const makeNavCategoryId = (...args: string[]): string => args.join('|');
|
||||
|
||||
export const getNavCategoryIdParts = (categoryId: string): string[] => categoryId.split('|');
|
||||
|
|
|
|||
16
src/app/state/room/roomToChildren.ts
Normal file
16
src/app/state/room/roomToChildren.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { atom } from 'jotai';
|
||||
import { roomToParentsAtom } from './roomToParents';
|
||||
|
||||
export const roomToChildrenAtom = atom((get) => {
|
||||
const roomToParents = get(roomToParentsAtom);
|
||||
const map = new Map<string, Set<string>>();
|
||||
|
||||
roomToParents.forEach((parentSet, childId) => {
|
||||
parentSet.forEach((parentId) => {
|
||||
if (!map.has(parentId)) map.set(parentId, new Set());
|
||||
map.get(parentId)?.add(childId);
|
||||
});
|
||||
});
|
||||
|
||||
return map;
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue