1
Fork 0
mirror of https://github.com/RGBCube/cinny synced 2025-07-30 16:37:46 +00:00

Fix rate limit when reordering in space lobby (#2254)

* move can drop lobby item logic to hook

* add comment

* resolve rate limit when reordering space children
This commit is contained in:
Ajay Bura 2025-05-26 14:21:27 +05:30 committed by GitHub
parent 83057ebbd4
commit a23279e633
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 270 additions and 187 deletions

View file

@ -1,5 +1,5 @@
import React, { MouseEventHandler, useCallback, useMemo, useRef, useState } from 'react'; 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 { useVirtualizer } from '@tanstack/react-virtual';
import { useAtom, useAtomValue } from 'jotai'; import { useAtom, useAtomValue } from 'jotai';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
@ -36,7 +36,7 @@ import { makeLobbyCategoryId } from '../../state/closedLobbyCategories';
import { useCategoryHandler } from '../../hooks/useCategoryHandler'; import { useCategoryHandler } from '../../hooks/useCategoryHandler';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { allRoomsAtom } from '../../state/room-list/roomList'; import { allRoomsAtom } from '../../state/room-list/roomList';
import { getCanonicalAliasOrRoomId } from '../../utils/matrix'; import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
import { getSpaceRoomPath } from '../../pages/pathUtils'; import { getSpaceRoomPath } from '../../pages/pathUtils';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { CanDropCallback, useDnDMonitor } from './DnD'; import { CanDropCallback, useDnDMonitor } from './DnD';
@ -53,6 +53,95 @@ import { roomToParentsAtom } from '../../state/room/roomToParents';
import { AccountDataEvent } from '../../../types/matrix/accountData'; import { AccountDataEvent } from '../../../types/matrix/accountData';
import { useRoomMembers } from '../../hooks/useRoomMembers'; import { useRoomMembers } from '../../hooks/useRoomMembers';
import { SpaceHierarchy } from './SpaceHierarchy'; 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() { export function Lobby() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -92,15 +181,7 @@ export function Lobby() {
useCallback((w, height) => setHeroSectionHeight(height), []) useCallback((w, height) => setHeroSectionHeight(height), [])
); );
const getRoom = useCallback( const getRoom = useGetRoom(allJoinedRooms);
(rId: string) => {
if (allJoinedRooms.has(rId)) {
return mx.getRoom(rId) ?? undefined;
}
return undefined;
},
[mx, allJoinedRooms]
);
const canEditSpaceChild = useCallback( const canEditSpaceChild = useCallback(
(powerLevels: IPowerLevels) => (powerLevels: IPowerLevels) =>
@ -150,180 +231,155 @@ export function Lobby() {
) )
); );
const canDrop: CanDropCallback = useCallback( const canDrop: CanDropCallback = useCanDropLobbyItem(
(item, container): boolean => { space,
const restrictedItem = mx.getRoom(item.roomId)?.getJoinRule() === JoinRule.Restricted; roomsPowerLevels,
if (item.roomId === container.item.roomId || item.roomId === container.nextRoomId) { getRoom,
// can not drop before or after itself canEditSpaceChild
return false;
}
if ('space' in item) {
if (!('space' in container.item)) return false;
const containerSpaceId = space.roomId;
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
}
const containerSpaceId =
'space' in container.item ? container.item.roomId : container.item.parentId;
const dropOutsideSpace = item.parentId !== containerSpaceId;
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;
}
}
if (
getRoom(containerSpaceId) === undefined ||
!canEditSpaceChild(roomsPowerLevels.get(containerSpaceId) ?? {})
) {
return false;
}
return true;
},
[getRoom, space.roomId, roomsPowerLevels, canEditSpaceChild, mx]
); );
const reorderSpace = useCallback( const [reorderSpaceState, reorderSpace] = useAsyncCallback(
(item: HierarchyItemSpace, containerItem: HierarchyItem) => { useCallback(
if (!item.parentId) return; async (item: HierarchyItemSpace, containerItem: HierarchyItem) => {
if (!item.parentId) return;
const itemSpaces: HierarchyItemSpace[] = hierarchy const itemSpaces: HierarchyItemSpace[] = hierarchy
.map((i) => i.space) .map((i) => i.space)
.filter((i) => i.roomId !== item.roomId); .filter((i) => i.roomId !== item.roomId);
const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId); const beforeIndex = itemSpaces.findIndex((i) => i.roomId === containerItem.roomId);
const insertIndex = beforeIndex + 1; const insertIndex = beforeIndex + 1;
itemSpaces.splice(insertIndex, 0, { itemSpaces.splice(insertIndex, 0, {
...item, ...item,
content: { ...item.content, order: undefined }, content: { ...item.content, order: undefined },
}); });
const currentOrders = itemSpaces.map((i) => { const currentOrders = itemSpaces.map((i) => {
if (typeof i.content.order === 'string' && lex.has(i.content.order)) { if (typeof i.content.order === 'string' && lex.has(i.content.order)) {
return i.content.order; return i.content.order;
} }
return undefined; return undefined;
}); });
const newOrders = orderKeys(lex, currentOrders); const newOrders = orderKeys(lex, currentOrders);
newOrders?.forEach((orderKey, index) => { const reorders = newOrders
const itm = itemSpaces[index]; ?.map((orderKey, index) => ({
if (!itm || !itm.parentId) return; item: itemSpaces[index],
const parentPL = roomsPowerLevels.get(itm.parentId); orderKey,
const canEdit = parentPL && canEditSpaceChild(parentPL); }))
if (canEdit && orderKey !== currentOrders[index]) { .filter((reorder, index) => {
mx.sendStateEvent( if (!reorder.item.parentId) return false;
itm.parentId, const parentPL = roomsPowerLevels.get(reorder.item.parentId);
StateEvent.SpaceChild as any, const canEdit = parentPL && canEditSpaceChild(parentPL);
{ ...itm.content, order: orderKey }, return canEdit && reorder.orderKey !== currentOrders[index];
itm.roomId });
);
}
});
},
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
);
const reorderRoom = useCallback( if (reorders) {
(item: HierarchyItem, containerItem: HierarchyItem): void => { await rateLimitedActions(reorders, async (reorder) => {
const itemRoom = mx.getRoom(item.roomId); if (!reorder.item.parentId) return;
if (!item.parentId) { await mx.sendStateEvent(
return; reorder.item.parentId,
} StateEvent.SpaceChild as any,
const containerParentId: string = { ...reorder.item.content, order: reorder.orderKey },
'space' in containerItem ? containerItem.roomId : containerItem.parentId; reorder.item.roomId
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,
}); });
} }
} },
[mx, hierarchy, lex, roomsPowerLevels, canEditSpaceChild]
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;
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 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
);
}
});
},
[mx, hierarchy, lex]
); );
const reorderingSpace = reorderSpaceState.status === AsyncStatus.Loading;
const [reorderRoomState, reorderRoom] = useAsyncCallback(
useCallback(
async (item: HierarchyItem, containerItem: HierarchyItem) => {
const itemRoom = mx.getRoom(item.roomId);
if (!item.parentId) {
return;
}
const containerParentId: string =
'space' in containerItem ? containerItem.roomId : containerItem.parentId;
const itemContent = item.content;
// remove from current space
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,
});
}
}
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;
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 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]
)
);
const reorderingRoom = reorderRoomState.status === AsyncStatus.Loading;
const reordering = reorderingRoom || reorderingSpace;
useDnDMonitor( useDnDMonitor(
scrollRef, scrollRef,
@ -449,6 +505,7 @@ export function Lobby() {
draggingItem={draggingItem} draggingItem={draggingItem}
onDragging={setDraggingItem} onDragging={setDraggingItem}
canDrop={canDrop} canDrop={canDrop}
disabledReorder={reordering}
nextSpaceId={nextSpaceId} nextSpaceId={nextSpaceId}
getRoom={getRoom} getRoom={getRoom}
pinned={sidebarSpaces.has(item.space.roomId)} pinned={sidebarSpaces.has(item.space.roomId)}
@ -460,6 +517,28 @@ export function Lobby() {
); );
})} })}
</div> </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> </PageContentCenter>
</PageContent> </PageContent>
</Scroll> </Scroll>

View file

@ -31,6 +31,7 @@ type SpaceHierarchyProps = {
draggingItem?: HierarchyItem; draggingItem?: HierarchyItem;
onDragging: (item?: HierarchyItem) => void; onDragging: (item?: HierarchyItem) => void;
canDrop: CanDropCallback; canDrop: CanDropCallback;
disabledReorder?: boolean;
nextSpaceId?: string; nextSpaceId?: string;
getRoom: (roomId: string) => Room | undefined; getRoom: (roomId: string) => Room | undefined;
pinned: boolean; pinned: boolean;
@ -54,6 +55,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
draggingItem, draggingItem,
onDragging, onDragging,
canDrop, canDrop,
disabledReorder,
nextSpaceId, nextSpaceId,
getRoom, getRoom,
pinned, pinned,
@ -116,7 +118,9 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
handleClose={handleClose} handleClose={handleClose}
getRoom={getRoom} getRoom={getRoom}
canEditChild={canEditSpaceChild(spacePowerLevels)} canEditChild={canEditSpaceChild(spacePowerLevels)}
canReorder={parentPowerLevels ? canEditSpaceChild(parentPowerLevels) : false} canReorder={
parentPowerLevels && !disabledReorder ? canEditSpaceChild(parentPowerLevels) : false
}
options={ options={
parentId && parentId &&
parentPowerLevels && ( parentPowerLevels && (
@ -174,7 +178,7 @@ export const SpaceHierarchy = forwardRef<HTMLDivElement, SpaceHierarchyProps>(
dm={mDirects.has(roomItem.roomId)} dm={mDirects.has(roomItem.roomId)}
onOpen={onOpenRoom} onOpen={onOpenRoom}
getRoom={getRoom} getRoom={getRoom}
canReorder={canEditSpaceChild(spacePowerLevels)} canReorder={canEditSpaceChild(spacePowerLevels) && !disabledReorder}
options={ options={
<HierarchyItemMenu <HierarchyItemMenu
item={roomItem} item={roomItem}

View file

@ -28,9 +28,11 @@ import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList'; import { mDirectAtom } from '../../state/mDirectList';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { getViaServers } from '../../plugins/via-servers'; import { getViaServers } from '../../plugins/via-servers';
import { rateLimitedActions } from '../../utils/matrix';
import { useAlive } from '../../hooks/useAlive';
function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) { function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const mountStore = useStore(roomId); const alive = useAlive();
const [debounce] = useState(new Debounce()); const [debounce] = useState(new Debounce());
const [process, setProcess] = useState(null); const [process, setProcess] = useState(null);
const [allRoomIds, setAllRoomIds] = useState([]); const [allRoomIds, setAllRoomIds] = useState([]);
@ -68,14 +70,14 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
const handleAdd = async () => { const handleAdd = async () => {
setProcess(`Adding ${selected.length} items...`); setProcess(`Adding ${selected.length} items...`);
const promises = selected.map((rId) => { await rateLimitedActions(selected, async (rId) => {
const room = mx.getRoom(rId); const room = mx.getRoom(rId);
const via = getViaServers(room); const via = getViaServers(room);
if (via.length === 0) { if (via.length === 0) {
via.push(getIdServer(rId)); via.push(getIdServer(rId));
} }
return mx.sendStateEvent( await mx.sendStateEvent(
roomId, roomId,
'm.space.child', 'm.space.child',
{ {
@ -87,9 +89,7 @@ function SpaceAddExistingContent({ roomId, spaces: onlySpaces }) {
); );
}); });
mountStore.setItem(true); if (!alive()) return;
await Promise.allSettled(promises);
if (mountStore.getItem() !== true) return;
const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs]; const roomIds = onlySpaces ? [...spaces] : [...rooms, ...directs];
const allIds = roomIds.filter( const allIds = roomIds.filter(

View file

@ -300,7 +300,7 @@ export const downloadEncryptedMedia = async (
export const rateLimitedActions = async <T, R = void>( export const rateLimitedActions = async <T, R = void>(
data: T[], data: T[],
callback: (item: T) => Promise<R>, callback: (item: T, index: number) => Promise<R>,
maxRetryCount?: number maxRetryCount?: number
) => { ) => {
let retryCount = 0; let retryCount = 0;
@ -312,8 +312,8 @@ export const rateLimitedActions = async <T, R = void>(
setTimeout(resolve, ms); setTimeout(resolve, ms);
}); });
const performAction = async (dataItem: T) => { const performAction = async (dataItem: T, index: number) => {
const [err] = await to<R, MatrixError>(callback(dataItem)); const [err] = await to<R, MatrixError>(callback(dataItem, index));
if (err?.httpStatus === 429) { if (err?.httpStatus === 429) {
if (retryCount === maxRetryCount) { if (retryCount === maxRetryCount) {
@ -321,11 +321,11 @@ export const rateLimitedActions = async <T, R = void>(
} }
const waitMS = err.getRetryAfterMs() ?? 3000; const waitMS = err.getRetryAfterMs() ?? 3000;
actionInterval = waitMS + 500; actionInterval = waitMS * 1.5;
await sleepForMs(waitMS); await sleepForMs(waitMS);
retryCount += 1; retryCount += 1;
await performAction(dataItem); await performAction(dataItem, index);
} }
}; };
@ -333,7 +333,7 @@ export const rateLimitedActions = async <T, R = void>(
const dataItem = data[i]; const dataItem = data[i];
retryCount = 0; retryCount = 0;
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await performAction(dataItem); await performAction(dataItem, i);
if (actionInterval > 0) { if (actionInterval > 0) {
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
await sleepForMs(actionInterval); await sleepForMs(actionInterval);