Fix rate limit when reordering in space lobby (#2254)
authorAjay Bura <32841439+ajbura@users.noreply.github.com>
Mon, 26 May 2025 08:51:27 +0000 (14:21 +0530)
committerGitHub <noreply@github.com>
Mon, 26 May 2025 08:51:27 +0000 (14:21 +0530)
* move can drop lobby item logic to hook

* add comment

* resolve rate limit when reordering space children

src/app/features/lobby/Lobby.tsx
src/app/features/lobby/SpaceHierarchy.tsx
src/app/molecules/space-add-existing/SpaceAddExisting.jsx
src/app/utils/matrix.ts

index 757db239110e63dd11077407b540754b7dcae70f..069e925eb99c00727f4d7bca63e3bc3731ba2e6e 100644 (file)
@@ -1,5 +1,5 @@
 import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react';
-import { Box, Icon, IconButton, Icons, Line, Scroll, config } from 'folds';
+import { Box, Chip, Icon, IconButton, Icons, Line, Scroll, Spinner, Text, config } from 'folds';
 import { useVirtualizer } from '@tanstack/react-virtual';
 import { useAtom, useAtomValue } from 'jotai';
 import { useNavigate } from 'react-router-dom';
@@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
 import { useCategoryHandler } from '../../hooks/useCategoryHandler';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { allRoomsAtom } from '../../state/room-list/roomList';
-import { getCanonicalAliasOrRoomId } from '../../utils/matrix';
+import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
 import { getSpaceRoomPath } from '../../pages/pathUtils';
 import { StateEvent } from '../../../types/matrix/room';
 import { CanDropCallback, useDnDMonitor } from './DnD';
@@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
 import { AccountDataEvent } from '../../../types/matrix/accountData';
 import { useRoomMembers } from '../../hooks/useRoomMembers';
 import { SpaceHierarchy } from './SpaceHierarchy';
+import { useGetRoom } from '../../hooks/useGetRoom';
+import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
+
+const useCanDropLobbyItem = (
+  space: Room,
+  roomsPowerLevels: Map<string, IPowerLevels>,
+  getRoom: (roomId: string) => Room | undefined,
+  canEditSpaceChild: (powerLevels: IPowerLevels) => boolean
+): CanDropCallback => {
+  const mx = useMatrixClient();
+
+  const canDropSpace: CanDropCallback = useCallback(
+    (item, container) => {
+      if (!('space' in container.item)) {
+        // can not drop around rooms.
+        // space can only be drop around other spaces
+        return false;
+      }
+
+      const containerSpaceId = space.roomId;
+
+      if (
+        getRoom(containerSpaceId) === undefined ||
+        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+      ) {
+        return false;
+      }
+
+      return true;
+    },
+    [space, roomsPowerLevels, getRoom, canEditSpaceChild]
+  );
+
+  const canDropRoom: CanDropCallback = useCallback(
+    (item, container) => {
+      const containerSpaceId =
+        'space' in container.item ? container.item.roomId : container.item.parentId;
+
+      const draggingOutsideSpace = item.parentId !== containerSpaceId;
+      const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
+
+      // check and do not allow restricted room to be dragged outside
+      // current space if can't change `m.room.join_rules` `content.allow`
+      if (draggingOutsideSpace && restrictedItem) {
+        const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
+        const userPLInItem = powerLevelAPI.getPowerLevel(
+          itemPowerLevel,
+          mx.getUserId() ?? undefined
+        );
+        const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
+          itemPowerLevel,
+          StateEvent.RoomJoinRules,
+          userPLInItem
+        );
+        if (!canChangeJoinRuleAllow) {
+          return false;
+        }
+      }
+
+      if (
+        getRoom(containerSpaceId) === undefined ||
+        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
+      ) {
+        return false;
+      }
+      return true;
+    },
+    [mx, getRoom, canEditSpaceChild, roomsPowerLevels]
+  );
+
+  const canDrop: CanDropCallback = useCallback(
+    (item, container): boolean => {
+      if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
+        // can not drop before or after itself
+        return false;
+      }
+
+      // if we are dragging a space
+      if ('space' in item) {
+        return canDropSpace(item, container);
+      }
+
+      return canDropRoom(item, container);
+    },
+    [canDropSpace, canDropRoom]
+  );
+
+  return canDrop;
+};
 
 export function Lobby() {
   const navigate = useNavigate();
@@ -92,15 +181,7 @@ export function Lobby() {
     useCallback((w, height) => setHeroSectionHeight(height), [])
   );
 
-  const getRoom = useCallback(
-    (rId: string) => {
-      if (allJoinedRooms.has(rId)) {
-        return mx.getRoom(rId) ?? undefined;
-      }
-      return undefined;
-    },
-    [mx, allJoinedRooms]
-  );
+  const getRoom = useGetRoom(allJoinedRooms);
 
   const canEditSpaceChild = useCallback(
     (powerLevels: IPowerLevels) =>
@@ -150,180 +231,155 @@ export function Lobby() {
     )
   );
 
-  const canDrop: CanDropCallback = useCallback(
-    (item, container): boolean => {
-      const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted;
-      if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) {
-        // can not drop before or after itself
-        return false;
-      }
+  const canDrop: CanDropCallback = useCanDropLobbyItem(
+    space,
+    roomsPowerLevels,
+    getRoom,
+    canEditSpaceChild
+  );
 
-      if ('space' in item) {
-        if (!('space' in container.item)) return false;
-        const containerSpaceId = space.roomId;
+  const [reorderSpaceState, reorderSpace] = useAsyncCallback(
+    useCallback(
+      async (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
+        if (!item.parentId) return;
 
-        if (
-          getRoom(containerSpaceId) === undefined ||
-          !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
-        ) {
-          return false;
-        }
+        const itemSpaces: HierarchyItemSpace[] = hierarchy
+          .map((i) => i.space)
+          .filter((i) => i.roomId !== item.roomId);
 
-        return true;
-      }
+        const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
+        const insertIndex = beforeIndex + 1;
 
-      const containerSpaceId =
-        'space' in container.item ? container.item.roomId : container.item.parentId;
+        itemSpaces.splice(insertIndex, 0, {
+          ...item,
+          content: { ...item.content, order: undefined },
+        });
 
-      const dropOutsideSpace = item.parentId !== containerSpaceId;
+        const currentOrders = itemSpaces.map((i) => {
+          if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
+            return i.content.order;
+          }
+          return undefined;
+        });
 
-      if (dropOutsideSpace && restrictedItem) {
-        // do not allow restricted room to drop outside
-        // current space if can't change join rule allow
-        const itemPowerLevel = roomsPowerLevels.get(item.roomId) ?? {};
-        const userPLInItem = powerLevelAPI.getPowerLevel(
-          itemPowerLevel,
-          mx.getUserId() ?? undefined
-        );
-        const canChangeJoinRuleAllow = powerLevelAPI.canSendStateEvent(
-          itemPowerLevel,
-          StateEvent.RoomJoinRules,
-          userPLInItem
-        );
-        if (!canChangeJoinRuleAllow) {
-          return false;
-        }
-      }
+        const newOrders = orderKeys(lex, currentOrders);
+
+        const reorders = newOrders
+          ?.map((orderKey, index) => ({
+            item: itemSpaces[index],
+            orderKey,
+          }))
+          .filter((reorder, index) => {
+            if (!reorder.item.parentId) return false;
+            const parentPL = roomsPowerLevels.get(reorder.item.parentId);
+            const canEdit = parentPL && canEditSpaceChild(parentPL);
+            return canEdit && reorder.orderKey !== currentOrders[index];
+          });
 
-      if (
-        getRoom(containerSpaceId) === undefined ||
-        !canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
-      ) {
-        return false;
-      }
-      return true;
-    },
-    [getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
+        if (reorders) {
+          await rateLimitedActions(reorders, async (reorder) => {
+            if (!reorder.item.parentId) return;
+            await mx.sendStateEvent(
+              reorder.item.parentId,
+              StateEvent.SpaceChild as any,
+              { ...reorder.item.content, order: reorder.orderKey },
+              reorder.item.roomId
+            );
+          });
+        }
+      },
+      [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
+    )
   );
+  const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
 
-  const reorderSpace = useCallback(
-    (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
-      if (!item.parentId) return;
-
-      const itemSpaces: HierarchyItemSpace[] = hierarchy
-        .map((i) => i.space)
-        .filter((i) => i.roomId !== item.roomId);
-
-      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
-      const insertIndex = beforeIndex + 1;
-
-      itemSpaces.splice(insertIndex, 0, {
-        ...item,
-        content: { ...item.content, order: undefined },
-      });
-
-      const currentOrders = itemSpaces.map((i) => {
-        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
-          return i.content.order;
+  const [reorderRoomState, reorderRoom] = useAsyncCallback(
+    useCallback(
+      async (item: HierarchyItem, containerItem: HierarchyItem) => {
+        const itemRoom = mx.getRoom(item.roomId);
+        if (!item.parentId) {
+          return;
         }
-        return undefined;
-      });
+        const containerParentId: string =
+          'space' in containerItem ? containerItem.roomId : containerItem.parentId;
+        const itemContent = item.content;
 
-      const newOrders = orderKeys(lex, currentOrders);
-
-      newOrders?.forEach((orderKey, index) => {
-        const itm = itemSpaces[index];
-        if (!itm || !itm.parentId) return;
-        const parentPL = roomsPowerLevels.get(itm.parentId);
-        const canEdit = parentPL && canEditSpaceChild(parentPL);
-        if (canEdit && orderKey !== currentOrders[index]) {
-          mx.sendStateEvent(
-            itm.parentId,
-            StateEvent.SpaceChild as any,
-            { ...itm.content, order: orderKey },
-            itm.roomId
-          );
+        // remove from current space
+        if (item.parentId !== containerParentId) {
+          mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
         }
-      });
-    },
-    [mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
-  );
-
-  const reorderRoom = useCallback(
-    (item: HierarchyItem, containerItem: HierarchyItem): void => {
-      const itemRoom = mx.getRoom(item.roomId);
-      if (!item.parentId) {
-        return;
-      }
-      const containerParentId: string =
-        'space' in containerItem ? containerItem.roomId : containerItem.parentId;
-      const itemContent = item.content;
-
-      if (item.parentId !== containerParentId) {
-        mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
-      }
 
-      if (
-        itemRoom &&
-        itemRoom.getJoinRule() === JoinRule.Restricted &&
-        item.parentId !== containerParentId
-      ) {
-        // change join rule allow parameter when dragging
-        // restricted room from one space to another
-        const joinRuleContent = getStateEvent(
-          itemRoom,
-          StateEvent.RoomJoinRules
-        )?.getContent<RoomJoinRulesEventContent>();
-
-        if (joinRuleContent) {
-          const allow =
-            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, {
-            ...joinRuleContent,
-            allow,
-          });
+        if (
+          itemRoom &&
+          itemRoom.getJoinRule() === JoinRule.Restricted &&
+          item.parentId !== containerParentId
+        ) {
+          // change join rule allow parameter when dragging
+          // restricted room from one space to another
+          const joinRuleContent = getStateEvent(
+            itemRoom,
+            StateEvent.RoomJoinRules
+          )?.getContent<RoomJoinRulesEventContent>();
+
+          if (joinRuleContent) {
+            const allow =
+              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, {
+              ...joinRuleContent,
+              allow,
+            });
+          }
         }
-      }
 
-      const itemSpaces = Array.from(
-        hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
-      );
+        const itemSpaces = Array.from(
+          hierarchy?.find((i) => i.space.roomId === containerParentId)?.rooms ?? []
+        );
 
-      const beforeItem: HierarchyItem | undefined =
-        'space' in containerItem ? undefined : containerItem;
-      const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
-      const insertIndex = beforeIndex + 1;
+        const beforeItem: HierarchyItem | undefined =
+          'space' in containerItem ? undefined : containerItem;
+        const beforeIndex = itemSpaces.findIndex((i) => i.roomId === beforeItem?.roomId);
+        const insertIndex = beforeIndex + 1;
 
-      itemSpaces.splice(insertIndex, 0, {
-        ...item,
-        parentId: containerParentId,
-        content: { ...itemContent, order: undefined },
-      });
+        itemSpaces.splice(insertIndex, 0, {
+          ...item,
+          parentId: containerParentId,
+          content: { ...itemContent, order: undefined },
+        });
 
-      const currentOrders = itemSpaces.map((i) => {
-        if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
-          return i.content.order;
-        }
-        return undefined;
-      });
+        const currentOrders = itemSpaces.map((i) => {
+          if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
+            return i.content.order;
+          }
+          return undefined;
+        });
 
-      const newOrders = orderKeys(lex, currentOrders);
-
-      newOrders?.forEach((orderKey, index) => {
-        const itm = itemSpaces[index];
-        if (itm && orderKey !== currentOrders[index]) {
-          mx.sendStateEvent(
-            containerParentId,
-            StateEvent.SpaceChild as any,
-            { ...itm.content, order: orderKey },
-            itm.roomId
-          );
+        const newOrders = orderKeys(lex, currentOrders);
+
+        const reorders = newOrders
+          ?.map((orderKey, index) => ({
+            item: itemSpaces[index],
+            orderKey,
+          }))
+          .filter((reorder, index) => reorder.item && reorder.orderKey !== currentOrders[index]);
+
+        if (reorders) {
+          await rateLimitedActions(reorders, async (reorder) => {
+            await mx.sendStateEvent(
+              containerParentId,
+              StateEvent.SpaceChild as any,
+              { ...reorder.item.content, order: reorder.orderKey },
+              reorder.item.roomId
+            );
+          });
         }
-      });
-    },
-    [mx, hierarchy, lex]
+      },
+      [mx, hierarchy, lex]
+    )
   );
+  const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading;
+  const reordering = reorderingRoom || reorderingSpace;
 
   useDnDMonitor(
     scrollRef,
@@ -449,6 +505,7 @@ export function Lobby() {
                             draggingItem={draggingItem}
                             onDragging={setDraggingItem}
                             canDrop={canDrop}
+                            disabledReorder={reordering}
                             nextSpaceId={nextSpaceId}
                             getRoom={getRoom}
                             pinned={sidebarSpaces.has(item.space.roomId)}
@@ -460,6 +517,28 @@ export function Lobby() {
                       );
                     })}
                   </div>
+                  {reordering && (
+                    <Box
+                      style={{
+                        position: 'absolute',
+                        bottom: config.space.S400,
+                        left: 0,
+                        right: 0,
+                        zIndex: 2,
+                        pointerEvents: 'none',
+                      }}
+                      justifyContent="Center"
+                    >
+                      <Chip
+                        variant="Secondary"
+                        outlined
+                        radii="Pill"
+                        before={<Spinner variant="Secondary" fill="Soft" size="100" />}
+                      >
+                        <Text size="L400">Reordering</Text>
+                      </Chip>
+                    </Box>
+                  )}
                 </PageContentCenter>
               </PageContent>
             </Scroll>
index 2c43282fee4c46f53925e073bc5c92b9883192c1..a152bc19aebab0b85e8403f0f1aeb8b0e0959b3b 100644 (file)
@@ -31,6 +31,7 @@ type SpaceHierarchyProps = {
   draggingItem?: HierarchyItem;
   onDragging: (item?: HierarchyItem) => void;
   canDrop: CanDropCallback;
+  disabledReorder?: boolean;
   nextSpaceId?: string;
   getRoom: (roomId: string) => Room | undefined;
   pinned: boolean;
@@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
       draggingItem,
       onDragging,
       canDrop,
+      disabledReorder,
       nextSpaceId,
       getRoom,
       pinned,
@@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
           handleClose={handleClose}
           getRoom={getRoom}
           canEditChild={canEditSpaceChild(spacePowerLevels)}
-          canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false}
+          canReorder={
+            parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
+          }
           options={
             parentId &&
             parentPowerLevels && (
@@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
                   dm={mDirects.has(roomItem.roomId)}
                   onOpen={onOpenRoom}
                   getRoom={getRoom}
-                  canReorder={canEditSpaceChild(spacePowerLevels)}
+                  canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
                   options={
                     <HierarchyItemMenu
                       item={roomItem}
index 83b967bcbdf5b5476a6184d702e202e3bf25d340..b084a1ad37ab95ca254ef63dc0d757c3a32ca463 100644 (file)
@@ -28,9 +28,11 @@ import { allRoomsAtom } from '../../state/room-list/roomList';
 import { mDirectAtom } from '../../state/mDirectList';
 import { useMatrixClient } from '../../hooks/useMatrixClient';
 import { getViaServers } from '../../plugins/via-servers';
+import { rateLimitedActions } from '../../utils/matrix';
+import { useAlive } from '../../hooks/useAlive';
 
 function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
-  const mountStore = useStore(roomId);
+  const alive = useAlive();
   const [debounce] = useState(new Debounce());
   const [process, setProcess] = useState(null);
   const [allRoomIds, setAllRoomIds] = useState([]);
@@ -68,14 +70,14 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
   const handleAdd = async () => {
     setProcess(`Adding ${selected.length} items...`);
 
-    const promises = selected.map((rId) => {
+    await rateLimitedActions(selected, async (rId) => {
       const room = mx.getRoom(rId);
       const via = getViaServers(room);
       if (via.length === 0) {
         via.push(getIdServer(rId));
       }
 
-      return mx.sendStateEvent(
+      await mx.sendStateEvent(
         roomId,
         'm.space.child',
         {
@@ -87,9 +89,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
       );
     });
 
-    mountStore.setItem(true);
-    await Promise.allSettled(promises);
-    if (mountStore.getItem() !== true) return;
+    if (!alive()) return;
 
     const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
     const allIds = roomIds.filter(
index 810f720975b3a0bc0cf101cc25dccb13442e69fe..a495e8d5980adf52d56183be5b443e958c7dfcb0 100644 (file)
@@ -300,7 +300,7 @@ export const downloadEncryptedMedia = async (
 
 export const rateLimitedActions = async <T, R = void>(
   data: T[],
-  callback: (item: T) => Promise<R>,
+  callback: (item: T, index: number) => Promise<R>,
   maxRetryCount?: number
 ) => {
   let retryCount = 0;
@@ -312,8 +312,8 @@ export const rateLimitedActions = async <T, R = void>(
       setTimeout(resolve, ms);
     });
 
-  const performAction = async (dataItem: T) => {
-    const [err] = await to<R, MatrixError>(callback(dataItem));
+  const performAction = async (dataItem: T, index: number) => {
+    const [err] = await to<R, MatrixError>(callback(dataItem, index));
 
     if (err?.httpStatus === 429) {
       if (retryCount === maxRetryCount) {
@@ -321,11 +321,11 @@ export const rateLimitedActions = async <T, R = void>(
       }
 
       const waitMS = err.getRetryAfterMs() ?? 3000;
-      actionInterval = waitMS + 500;
+      actionInterval = waitMS * 1.5;
       await sleepForMs(waitMS);
       retryCount += 1;
 
-      await performAction(dataItem);
+      await performAction(dataItem, index);
     }
   };
 
@@ -333,7 +333,7 @@ export const rateLimitedActions = async <T, R = void>(
     const dataItem = data[i];
     retryCount = 0;
     // eslint-disable-next-line no-await-in-loop
-    await performAction(dataItem);
+    await performAction(dataItem, i);
     if (actionInterval > 0) {
       // eslint-disable-next-line no-await-in-loop
       await sleepForMs(actionInterval);