From c11a6c46992313d8d450a870f4ce0b0cfa923e74 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 19 May 2026 18:18:20 +0200 Subject: [PATCH] feat(hooks): generalise security-level family + audit catch + reactivity test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Build on the useIsModerator landing (532cb28c) along three axes: 1. Family. Extract `useHasSecurityLevel(min)` as the primitive, backed by a fresh `useUserSecurityLevel()` raw-level reader. The six SecurityLevel constants (1..9) deserve named wrappers so the "show this only to X-and-up" pattern doesn't get re-derived ad-hoc each time: shipped `useIsModerator` / `useIsPlayerSupport` / `useIsCommunity` / `useIsAdmin` as one-line shims. Also added `useIsAmbassador()` as a sibling — not derived from security level, reads the boolean field on the snapshot directly. 2. Audit. The 532cb28c migration covered 6 React-render reads but missed 5 more discovered by a follow-up grep: - FurniEditorView (top-level `const isMod`) - InfoStandWidgetFurniView (inline JSX, mod-only build-tools button) - NavigatorRoomInfoView (3 reads in hasPermission(): isModerator and securityLevel >= COMMUNITY for the staff-pick gate. The userId read stays imperative — userId doesn't flip at runtime in practice, no reactivity gain.) - AvatarInfoWidgetPetView (inside useMemo with [roomSession] deps; migrated and isModerator added to the deps so a runtime promote/demote re-derives canPickUp without remount) - FurnitureMannequinView (inside useEffect; same treatment — added isModerator to the deps so the mode re-resolves on flip) The remaining ~17 reads (CanManipulateFurniture, AvatarInfoUtilities.populate*, useChatInputActions, useFurnitureDimmerWidget / useFurniturePlaylistEditorWidget / useFurnitureStickieWidget canModify checks, useCatalog admin filter, useNavigator door-mode guard) are click-time / event-time imperative — they read at the moment a user action fires, so a reactive value would be cached at hook execution and stale by the time the action runs. Leaving them on the synchronous manager read is correct. 3. Test. Added four cases pinning the contract: - useUserSecurityLevel returns the raw level. - useHasSecurityLevel does `>=` against the threshold. - Named wrappers map to the right constants (MODERATOR=5, COMMUNITY=7, ADMINISTRATOR=8). - **Reactive flip** — mutate the snapshot, dispatch the SESSION_DATA_UPDATED event on the mock dispatcher, assert the hook re-derives. Locks in the whole point of the snapshot pattern (a static read would pass cases 1-3 but fail case 4). Mock changes: - Added SecurityLevel class (mirrors the renderer enum 0..9) so the family wrappers resolve to actual numbers in jsdom — without it `useIsModerator()` would call `useHasSecurityLevel(undefined)` and the test would silently pass false-positives. Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test 213/213 (209 baseline + 4 new family/reactivity cases). --- .../furni-editor/FurniEditorView.tsx | 4 +- .../navigator/views/NavigatorRoomInfoView.tsx | 10 +- .../infostand/InfoStandWidgetFurniView.tsx | 7 +- .../menu/AvatarInfoWidgetPetView.tsx | 9 +- .../furniture/FurnitureMannequinView.tsx | 7 +- .../session/useSessionSnapshots.test.tsx | 116 +++++++++++++++++- src/hooks/session/useSessionSnapshots.ts | 40 ++++-- src/nitro-renderer.mock.ts | 17 +++ 8 files changed, 182 insertions(+), 28 deletions(-) diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index 2903d95..9ba4e28 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -1,7 +1,7 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useState } from 'react'; -import { GetSessionDataManager } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; +import { useIsModerator } from '../../hooks'; import { useFurniEditor } from '../../hooks/furni-editor'; import { FurniEditorEditView } from './views/FurniEditorEditView'; import { FurniEditorSearchView } from './views/FurniEditorSearchView'; @@ -21,7 +21,7 @@ export const FurniEditorView: FC<{}> = () => searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions } = useFurniEditor(); - const isMod = GetSessionDataManager()?.isModerator; + const isMod = useIsModerator(); // Auto-switch to edit tab when an item is selected useEffect(() => diff --git a/src/components/navigator/views/NavigatorRoomInfoView.tsx b/src/components/navigator/views/NavigatorRoomInfoView.tsx index c67c367..5d3af4b 100644 --- a/src/components/navigator/views/NavigatorRoomInfoView.tsx +++ b/src/components/navigator/views/NavigatorRoomInfoView.tsx @@ -1,10 +1,10 @@ -import { CreateLinkEvent, GetCustomRoomFilterMessageComposer, GetGuestRoomMessageComposer, GetSessionDataManager, NavigatorSearchComposer, RemoveOwnRoomRightsRoomMessageComposer, RoomControllerLevel, RoomMuteComposer, RoomSettingsComposer, SecurityLevel, ToggleStaffPickMessageComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer'; +import { CreateLinkEvent, GetCustomRoomFilterMessageComposer, GetGuestRoomMessageComposer, GetSessionDataManager, NavigatorSearchComposer, RemoveOwnRoomRightsRoomMessageComposer, RoomControllerLevel, RoomMuteComposer, RoomSettingsComposer, ToggleStaffPickMessageComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; import { FaLink, FaSignOutAlt } from 'react-icons/fa'; import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common'; import { RoomWidgetThumbnailEvent } from '../../../events'; -import { useHelp, useNavigator, useRoom } from '../../../hooks'; +import { useHelp, useIsCommunity, useIsModerator, useNavigator, useRoom } from '../../../hooks'; import { classNames } from '../../../layout'; export interface NavigatorRoomInfoViewProps { @@ -19,6 +19,8 @@ export const NavigatorRoomInfoView: FC = props => const { report = null } = useHelp(); const { navigatorData = null, favouriteRoomIds = [] } = useNavigator(); const { roomSession = null } = useRoom(); + const isModerator = useIsModerator(); + const isCommunity = useIsCommunity(); const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0; @@ -51,9 +53,9 @@ export const NavigatorRoomInfoView: FC = props => switch(permission) { case 'settings': - return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || GetSessionDataManager().isModerator); + return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || isModerator); case 'staff_pick': - return GetSessionDataManager().securityLevel >= SecurityLevel.COMMUNITY; + return isCommunity; case 'floor': return roomSession?.controllerLevel >= RoomControllerLevel.GUEST; case 'guest': diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index 32bce68..e7bc723 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -1,10 +1,10 @@ -import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSessionDataManager, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer'; +import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomEngine, GetSoundManager, GroupInformationComposer, GroupInformationEvent, NowPlayingEvent, RoomControllerLevel, RoomObjectCategory, RoomObjectOperationType, RoomObjectVariable, RoomWidgetEnumItemExtradataParameter, RoomWidgetFurniInfoUsagePolicyEnum, SetObjectDataMessageComposer, SongInfoReceivedEvent, StringDataType, UpdateFurniturePositionComposer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaCrosshairs, FaTimes } from 'react-icons/fa'; import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr'; import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; -import { useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; +import { useIsModerator, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; interface InfoStandWidgetFurniViewProps @@ -22,6 +22,7 @@ export const InfoStandWidgetFurniView: FC = props const { avatarInfo = null, onClose = null } = props; const { roomSession = null } = useRoom(); const { openInspectionForFurni, showInspectButton } = useWiredTools(); + const isModerator = useIsModerator(); const [ pickupMode, setPickupMode ] = useState(0); const [ canMove, setCanMove ] = useState(false); @@ -590,7 +591,7 @@ export const InfoStandWidgetFurniView: FC = props onClick={ () => setDropdownOpen(!dropdownOpen) }> { dropdownOpen ? `${LocalizeText('widget.furni.present.close')} Buildtools` : `${LocalizeText('navigator.roomsettings.doormode.open')} Buildtools` } - { GetSessionDataManager().isModerator && + { isModerator &&