diff --git a/src/api/nitro/session/RankLevels.ts b/src/api/nitro/session/RankLevels.ts new file mode 100644 index 0000000..f68b2b9 --- /dev/null +++ b/src/api/nitro/session/RankLevels.ts @@ -0,0 +1,29 @@ +/** + * Deployment-specific rank-level constants. + * + * Mirrors the `level` column of `permission_ranks` for the operator's + * Arcturus instance. Defined in ONE place so widget code can express + * intent via `useHasRankLevel(STAFF_LEVELS.MOD)` instead of bare + * integers, and so a deployment that re-numbers its ranks (e.g. adds + * a "Super Admin" at level 8) only has to update this file. + * + * Default seed (Arcturus-Morningstar-Extended ≥ 4.2.10): + * 1 Member | 2 VIP | 3 X + * 4 Support | 5 Moderator | 6 Super Mod + * 7 Administrator + * + * Update the constants here to match `permission_ranks.level` in your + * deployment if you customised them. + */ +export const STAFF_LEVELS = { + /** Member level — the floor for non-staff users. */ + MEMBER: 1, + /** Lowest staff tier — Support agents. */ + SUPPORT: 4, + /** Moderator (covers in-room moderation actions). */ + MOD: 5, + /** Super Mod (extended moderation surface). */ + SUPER_MOD: 6, + /** Administrator — full staff privileges. */ + ADMIN: 7 +} as const; diff --git a/src/api/nitro/session/index.ts b/src/api/nitro/session/index.ts index 88ee45a..5028554 100644 --- a/src/api/nitro/session/index.ts +++ b/src/api/nitro/session/index.ts @@ -16,5 +16,6 @@ export * from './HasHabboVip'; export * from './IsOwnerOfFloorFurniture'; export * from './IsOwnerOfFurniture'; export * from './IsRidingHorse'; +export * from './RankLevels'; export * from './StartRoomSession'; export * from './VisitDesktop'; diff --git a/src/components/campaign/CalendarView.tsx b/src/components/campaign/CalendarView.tsx index 1cbee1a..ee231f7 100644 --- a/src/components/campaign/CalendarView.tsx +++ b/src/components/campaign/CalendarView.tsx @@ -1,7 +1,7 @@ import { FC, useState } from 'react'; -import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api'; +import { CalendarItemState, ICalendarItem, LocalizeText, STAFF_LEVELS } from '../../api'; import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; -import { useIsModerator } from '../../hooks'; +import { useHasRankLevel } from '../../hooks'; import { CalendarItemView } from './CalendarItemView'; interface CalendarViewProps @@ -23,7 +23,7 @@ export const CalendarView: FC = props => const { onClose = null, campaignName = null, currentDay = null, numDays = null, missedDays = null, openedDays = null, openPackage = null, receivedProducts = null } = props; const [ selectedDay, setSelectedDay ] = useState(currentDay); const [ index, setIndex ] = useState(Math.max(0, (selectedDay - 1))); - const isModerator = useIsModerator(); + const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); const getDayState = (day: number) => { diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx index cd6b2e8..e72c702 100644 --- a/src/components/catalog/CatalogClassicView.tsx +++ b/src/components/catalog/CatalogClassicView.tsx @@ -1,9 +1,9 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useEffect } from 'react'; import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa'; -import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api'; +import { CatalogType, GetConfigurationValue, LocalizeText, STAFF_LEVELS } from '../../api'; import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useCatalogActions, useCatalogData, useCatalogUiState, useIsModerator } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogUiState, useHasRankLevel } from '../../hooks'; import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; @@ -28,7 +28,7 @@ const CatalogClassicViewInner: FC<{}> = () => {}); const loading = catalogAdmin?.loading ?? false; - const isMod = useIsModerator(); + const isMod = useHasRankLevel(STAFF_LEVELS.MOD); const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER) ? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' } : undefined; diff --git a/src/components/catalog/CatalogModernView.tsx b/src/components/catalog/CatalogModernView.tsx index 66767d8..9c517fb 100644 --- a/src/components/catalog/CatalogModernView.tsx +++ b/src/components/catalog/CatalogModernView.tsx @@ -1,9 +1,9 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa'; -import { CatalogType, LocalizeText } from '../../api'; +import { CatalogType, LocalizeText, STAFF_LEVELS } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; -import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useIsModerator } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasRankLevel } from '../../hooks'; import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext'; import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView'; import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView'; @@ -32,7 +32,7 @@ const CatalogModernViewInner: FC<{}> = () => const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites(); const [ showFavorites, setShowFavorites ] = useState(false); - const isMod = useIsModerator(); + const isMod = useHasRankLevel(STAFF_LEVELS.MOD); const totalFavs = favoriteOfferIds.length + favoritePageIds.length; const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER) ? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' } diff --git a/src/components/furni-editor/FurniEditorView.tsx b/src/components/furni-editor/FurniEditorView.tsx index 9ba4e28..443405c 100644 --- a/src/components/furni-editor/FurniEditorView.tsx +++ b/src/components/furni-editor/FurniEditorView.tsx @@ -1,7 +1,8 @@ import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useState } from 'react'; +import { STAFF_LEVELS } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useIsModerator } from '../../hooks'; +import { useHasRankLevel } from '../../hooks'; import { useFurniEditor } from '../../hooks/furni-editor'; import { FurniEditorEditView } from './views/FurniEditorEditView'; import { FurniEditorSearchView } from './views/FurniEditorSearchView'; @@ -21,7 +22,7 @@ export const FurniEditorView: FC<{}> = () => searchItems, loadDetail, loadBySpriteId, updateItem, deleteItem, loadInteractions } = useFurniEditor(); - const isMod = useIsModerator(); + const isMod = useHasRankLevel(STAFF_LEVELS.MOD); // 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 5d3af4b..c5d909b 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, 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 { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, STAFF_LEVELS, ToggleFavoriteRoom } from '../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common'; import { RoomWidgetThumbnailEvent } from '../../../events'; -import { useHelp, useIsCommunity, useIsModerator, useNavigator, useRoom } from '../../../hooks'; +import { useHasRankLevel, useHelp, useNavigator, useRoom } from '../../../hooks'; import { classNames } from '../../../layout'; export interface NavigatorRoomInfoViewProps { @@ -19,8 +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 isModerator = useHasRankLevel(STAFF_LEVELS.MOD); + const isAdmin = useHasRankLevel(STAFF_LEVELS.ADMIN); const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0; @@ -55,7 +55,7 @@ export const NavigatorRoomInfoView: FC = props => case 'settings': return (GetSessionDataManager().userId === navigatorData.enteredGuestRoom.ownerId || isModerator); case 'staff_pick': - return isCommunity; + return isAdmin; 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 e7bc723..857fa5a 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -2,9 +2,9 @@ import { CrackableDataType, CreateLinkEvent, FurnitureFloorUpdateEvent, GetRoomE 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 { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer, STAFF_LEVELS } from '../../../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; -import { useIsModerator, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; +import { useHasRankLevel, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; interface InfoStandWidgetFurniViewProps @@ -22,7 +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 isModerator = useHasRankLevel(STAFF_LEVELS.MOD); const [ pickupMode, setPickupMode ] = useState(0); const [ canMove, setCanMove ] = useState(false); diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx index 70469a7..a80595b 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetPetView.tsx @@ -1,7 +1,7 @@ import { PetRespectComposer, PetType, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitGiveHandItemPetComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; -import { AvatarInfoPet, GetOwnRoomObject, LocalizeText, SendMessageComposer } from '../../../../../api'; -import { useIsModerator, useRoom, useSessionInfo } from '../../../../../hooks'; +import { AvatarInfoPet, GetOwnRoomObject, LocalizeText, SendMessageComposer, STAFF_LEVELS } from '../../../../../api'; +import { useHasRankLevel, useRoom, useSessionInfo } from '../../../../../hooks'; import { ContextMenuHeaderView } from '../../context-menu/ContextMenuHeaderView'; import { ContextMenuListItemView } from '../../context-menu/ContextMenuListItemView'; import { ContextMenuView } from '../../context-menu/ContextMenuView'; @@ -23,7 +23,7 @@ export const AvatarInfoWidgetPetView: FC = props = const [ mode, setMode ] = useState(MODE_NORMAL); const { roomSession = null, isHandItemBlocked = false } = useRoom(); const { petRespectRemaining = 0, respectPet = null } = useSessionInfo(); - const isModerator = useIsModerator(); + const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); const canPickUp = useMemo(() => { diff --git a/src/components/room/widgets/choosers/ChooserWidgetView.tsx b/src/components/room/widgets/choosers/ChooserWidgetView.tsx index da4866b..58abbe3 100644 --- a/src/components/room/widgets/choosers/ChooserWidgetView.tsx +++ b/src/components/room/widgets/choosers/ChooserWidgetView.tsx @@ -1,8 +1,8 @@ import { FurniturePickupAllComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useEffectEvent, useMemo, useState } from 'react'; -import { LocalizeText, RoomObjectItem, SendMessageComposer, chooserSelectionVisualizer } from '../../../../api'; +import { LocalizeText, RoomObjectItem, SendMessageComposer, STAFF_LEVELS, chooserSelectionVisualizer } from '../../../../api'; import { Button, Flex, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useIsModerator } from '../../../../hooks'; +import { useHasRankLevel } from '../../../../hooks'; import { NitroInput, classNames } from '../../../../layout'; const LIMIT_FURNI_PICKALL = 100; @@ -24,7 +24,7 @@ export const ChooserWidgetView: FC = props => const [ searchValue, setSearchValue ] = useState(''); const [ checkAll, setCheckAll ] = useState(false); const [ checkedIds, setCheckedIds ] = useState([]); - const canSeeId = useIsModerator(); + const canSeeId = useHasRankLevel(STAFF_LEVELS.MOD); const ownerNames = useMemo(() => { diff --git a/src/components/room/widgets/furniture/FurnitureMannequinView.tsx b/src/components/room/widgets/furniture/FurnitureMannequinView.tsx index f908f84..2276531 100644 --- a/src/components/room/widgets/furniture/FurnitureMannequinView.tsx +++ b/src/components/room/widgets/furniture/FurnitureMannequinView.tsx @@ -1,8 +1,8 @@ import { GetAvatarRenderManager, GetSessionDataManager, HabboClubLevelEnum, RoomControllerLevel } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; -import { GetClubMemberLevel, GetRoomSession, LocalizeText, MannequinUtilities } from '../../../../api'; +import { GetClubMemberLevel, GetRoomSession, LocalizeText, MannequinUtilities, STAFF_LEVELS } from '../../../../api'; import { Button, Column, LayoutAvatarImageView, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useFurnitureMannequinWidget, useIsModerator } from '../../../../hooks'; +import { useFurnitureMannequinWidget, useHasRankLevel } from '../../../../hooks'; import { NitroInput } from '../../../../layout'; const MODE_NONE: number = -1; @@ -17,7 +17,7 @@ export const FurnitureMannequinView: FC<{}> = props => const [ renderedFigure, setRenderedFigure ] = useState(null); const [ mode, setMode ] = useState(MODE_NONE); const { objectId = -1, figure = null, gender = null, clubLevel = HabboClubLevelEnum.NO_CLUB, name = null, setName = null, saveFigure = null, wearFigure = null, saveName = null, onClose = null } = useFurnitureMannequinWidget(); - const isModerator = useIsModerator(); + const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); useEffect(() => { diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 04a2128..74eb789 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -1,9 +1,9 @@ import { CreateLinkEvent, Dispose, DropBounce, EaseOut, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion, Variants } from 'framer-motion'; import { FC, useEffect, useState } from 'react'; -import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; +import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, STAFF_LEVELS, VisitDesktop } from '../../api'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; -import { useAchievements, useFriends, useInventoryUnseenTracker, useIsModerator, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; +import { useAchievements, useFriends, useHasRankLevel, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; import { YouTubePlayerView } from './YouTubePlayerView'; @@ -49,7 +49,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { requests = [] } = useFriends(); const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const { openMonitor, showToolbarButton } = useWiredTools(); - const isMod = useIsModerator(); + const isMod = useHasRankLevel(STAFF_LEVELS.MOD); const isVisible = (isToolbarOpen || !isInRoom); const visibilityVariant = isVisible ? 'visible' : 'hidden'; diff --git a/src/components/toolbar/YouTubePlayerView.tsx b/src/components/toolbar/YouTubePlayerView.tsx index a7de86d..a733090 100644 --- a/src/components/toolbar/YouTubePlayerView.tsx +++ b/src/components/toolbar/YouTubePlayerView.tsx @@ -1,9 +1,9 @@ import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useRef, useState } from 'react'; import ReactPlayer from 'react-player/youtube'; -import { GetRoomSession, getYoutubeRoomEnabled, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from '../../api'; +import { GetRoomSession, getYoutubeRoomEnabled, LocalizeText, SendMessageComposer, STAFF_LEVELS, YoutubeVideoPlaybackStateEnum } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from '../../common'; -import { useFurnitureYoutubeWidget, useIsModerator, useMessageEvent } from '../../hooks'; +import { useFurnitureYoutubeWidget, useHasRankLevel, useMessageEvent } from '../../hooks'; const CONTROL_COMMAND_PREVIOUS_VIDEO = 0; const CONTROL_COMMAND_NEXT_VIDEO = 1; @@ -48,7 +48,7 @@ export const YouTubePlayerView: FC<{}> = () => const [youtubeEnabled, setYoutubeEnabled] = useState(getYoutubeRoomEnabled()); // Reactive — must sit above the `if (!isOpen) return null` below // so the hook order stays stable across renders. - const isModerator = useIsModerator(); + const isModerator = useHasRankLevel(STAFF_LEVELS.MOD); useMessageEvent(YouTubeRoomSettingsEvent, event => { diff --git a/src/hooks/session/useSessionSnapshots.test.tsx b/src/hooks/session/useSessionSnapshots.test.tsx index c08822f..61b70cd 100644 --- a/src/hooks/session/useSessionSnapshots.test.tsx +++ b/src/hooks/session/useSessionSnapshots.test.tsx @@ -5,7 +5,7 @@ import { Component, ReactNode, useSyncExternalStore } from 'react'; import { useBetween } from 'use-between'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { GetEventDispatcher, GetSessionDataManager } from '../../nitro-renderer.mock'; -import { useHasSecurityLevel, useIsAdmin, useIsCommunity, useIsModerator, useUserSecurityLevel } from './useSessionSnapshots'; +import { useHasRankLevel, useIsRank, useUserRank } from './useSessionSnapshots'; // Regression guard for the rolled-back snapshot-consumer migration. // @@ -110,15 +110,11 @@ describe('use-between + useSyncExternalStore incompatibility', () => }); // ============================================================================ -// useHasSecurityLevel + named wrappers — reactive flip on snapshot invalidation +// useHasRankLevel / useIsRank / useUserRank — reactive flip on snapshot +// invalidation, tied to the permission_ranks DB table (rankId / rankName / +// rankBadge / rankPrefix / rankPrefixColor are mirrored on the wire by +// the extended UserPermissionsComposer in Arcturus ≥ 4.2.10). // ============================================================================ -// -// The family hangs off useUserDataSnapshot() which is a useSyncExternalStore -// wrapper. The renderer's real SessionDataManager pushes a frozen snapshot -// out of getUserDataSnapshot() and dispatches a SESSION_DATA_UPDATED event -// whenever a mutator invalidates the cache. These tests fake both sides: -// a mock dispatcher with a real .subscribe(), and a mock SessionDataManager -// whose snapshot can be mutated between dispatches. const makeFakeDispatcher = () => { @@ -143,20 +139,37 @@ const makeFakeDispatcher = () => }; }; -describe('useHasSecurityLevel + named wrappers', () => +interface FakeSnapshot { - let snapshot: { securityLevel: number }; + securityLevel: number; + rankId: number; + rankName: string; + rankBadge: string; + rankPrefix: string; + rankPrefixColor: string; +} + +const makeSnapshot = (overrides: Partial = {}): FakeSnapshot => ({ + securityLevel: 0, + rankId: 0, + rankName: '', + rankBadge: '', + rankPrefix: '', + rankPrefixColor: '', + ...overrides +}); + +describe('useHasRankLevel + useIsRank + useUserRank', () => +{ + let snapshot: FakeSnapshot; let fakeDispatcher: ReturnType; beforeEach(() => { - snapshot = { securityLevel: 0 }; + snapshot = makeSnapshot(); fakeDispatcher = makeFakeDispatcher(); vi.mocked(GetSessionDataManager).mockReturnValue({ - // useSessionSnapshots reads getUserDataSnapshot() and guards on - // `typeof manager.getUserDataSnapshot !== 'function'`, so we - // expose it as a real function returning the mutable test snapshot. getUserDataSnapshot: () => snapshot } as any); @@ -170,48 +183,58 @@ describe('useHasSecurityLevel + named wrappers', () => vi.mocked(GetEventDispatcher).mockReset(); }); - it('useUserSecurityLevel reads the raw level', () => + it('useUserRank surfaces the full rank metadata from the snapshot', () => { - snapshot = { securityLevel: 7 }; - const { result } = renderHook(() => useUserSecurityLevel()); - expect(result.current).toBe(7); + snapshot = makeSnapshot({ + securityLevel: 5, + rankId: 5, + rankName: 'Moderator', + rankBadge: 'ADM', + rankPrefix: '[MOD]', + rankPrefixColor: '#327fa8' + }); + + const { result } = renderHook(() => useUserRank()); + + expect(result.current).toEqual({ + id: 5, + name: 'Moderator', + level: 5, + badge: 'ADM', + prefix: '[MOD]', + prefixColor: '#327fa8' + }); }); - it('useHasSecurityLevel compares >= the threshold', () => + it('useHasRankLevel compares >= the threshold (5=Mod, 7=Admin in default seed)', () => { - snapshot = { securityLevel: 5 }; - const { result } = renderHook(() => useHasSecurityLevel(5)); - expect(result.current).toBe(true); - - const { result: lowResult } = renderHook(() => useHasSecurityLevel(8)); - expect(lowResult.current).toBe(false); + snapshot = makeSnapshot({ securityLevel: 5 }); + expect(renderHook(() => useHasRankLevel(5)).result.current).toBe(true); + expect(renderHook(() => useHasRankLevel(6)).result.current).toBe(false); + expect(renderHook(() => useHasRankLevel(7)).result.current).toBe(false); }); - it('named wrappers map to the right thresholds (MODERATOR=5, COMMUNITY=7, ADMINISTRATOR=8)', () => + it('useIsRank matches the exact rank_name from permission_ranks', () => { - snapshot = { securityLevel: 7 }; // COMMUNITY - - expect(renderHook(() => useIsModerator()).result.current).toBe(true); // 7 >= 5 - expect(renderHook(() => useIsCommunity()).result.current).toBe(true); // 7 >= 7 - expect(renderHook(() => useIsAdmin()).result.current).toBe(false); // 7 < 8 + snapshot = makeSnapshot({ rankName: 'Moderator' }); + expect(renderHook(() => useIsRank('Moderator')).result.current).toBe(true); + expect(renderHook(() => useIsRank('Super Mod')).result.current).toBe(false); + expect(renderHook(() => useIsRank('Administrator')).result.current).toBe(false); }); - it('re-renders when SESSION_DATA_UPDATED fires after the snapshot mutates', () => + it('re-renders when SESSION_DATA_UPDATED fires after a runtime promote', () => { - snapshot = { securityLevel: 0 }; - const { result } = renderHook(() => useIsModerator()); + snapshot = makeSnapshot({ securityLevel: 1, rankName: 'Member' }); + const { result } = renderHook(() => useHasRankLevel(5)); expect(result.current).toBe(false); - // Mutate the snapshot reference (renderer invariant: every - // invalidation produces a NEW frozen object) and dispatch the - // event. The hook's getSnapshot closure reads `snapshot`, so a - // fresh object reference flips React's bailout. act(() => { - snapshot = { securityLevel: 5 }; - // The mock's NitroEventType proxy resolves any property to - // `mock:NitroEventType:`, so that's the wire string - // useSessionSnapshots subscribes against. + // Renderer invariant: every invalidation produces a NEW + // frozen snapshot object. The mock's NitroEventType proxy + // resolves any property to `mock:NitroEventType:`, so + // that's the wire string useSessionSnapshots subscribes against. + snapshot = makeSnapshot({ securityLevel: 5, rankName: 'Moderator' }); fakeDispatcher.dispatch('mock:NitroEventType:SESSION_DATA_UPDATED'); }); diff --git a/src/hooks/session/useSessionSnapshots.ts b/src/hooks/session/useSessionSnapshots.ts index 069d0f7..a1121e1 100644 --- a/src/hooks/session/useSessionSnapshots.ts +++ b/src/hooks/session/useSessionSnapshots.ts @@ -1,4 +1,4 @@ -import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType, SecurityLevel } from '@nitrots/nitro-renderer'; +import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType } from '@nitrots/nitro-renderer'; import { useMemo } from 'react'; import { useExternalSnapshot } from '../events/useExternalSnapshot'; @@ -48,7 +48,12 @@ const DEFAULT_USER_DATA: Readonly = Object.freeze({ isSystemOpen: false, isSystemShutdown: false, uiFlags: 0, - tags: Object.freeze([]) as ReadonlyArray + tags: Object.freeze([]) as ReadonlyArray, + rankId: 0, + rankName: '', + rankBadge: '', + rankPrefix: '', + rankPrefixColor: '' }) as Readonly; const EMPTY_IGNORED_LIST: ReadonlyArray = Object.freeze([]) as ReadonlyArray; @@ -124,36 +129,63 @@ export const useIsUserIgnored = (name: string): boolean => }; /** - * Reactive raw security level from the user snapshot. Use this when - * you need the numeric level (e.g. to compare against a threshold not - * covered by the named wrappers below); for the common case of "is - * the user at least ?", prefer the matching `useIsXxx` predicate. + * Reactive view of the current user's rank, mirrored from the + * `permission_ranks` table via the extended `UserPermissionsComposer` + * wire (Arcturus-Morningstar-Extended ≥ 4.2.10). Use this in UI code + * that needs to display rank metadata (badge, prefix, prefix color) + * or to gate behaviour on the actual deployment rank rather than the + * generic SecurityLevel constants the renderer exposes — those don't + * line up with the rank names operators actually use ("Moderator", + * "Super Mod", "Administrator", …). */ -export const useUserSecurityLevel = (): number => useUserDataSnapshot().securityLevel; +export interface IUserRank +{ + readonly id: number; + readonly name: string; + readonly level: number; + readonly badge: string; + readonly prefix: string; + readonly prefixColor: string; +} + +export const useUserRank = (): IUserRank => +{ + const userData = useUserDataSnapshot(); + + return useMemo(() => ({ + id: userData.rankId, + name: userData.rankName, + level: userData.securityLevel, + badge: userData.rankBadge, + prefix: userData.rankPrefix, + prefixColor: userData.rankPrefixColor + }), [ userData.rankId, userData.rankName, userData.securityLevel, userData.rankBadge, userData.rankPrefix, userData.rankPrefixColor ]); +}; /** - * Reactive predicate: does the current user's security level satisfy - * `>= minLevel`? Mirrors the renderer-side comparison used by - * `SessionDataManager.isModerator` (and its peers) and propagates the - * SESSION_DATA_UPDATED invalidation, so a runtime promote/demote - * re-renders the consumer. - * - * The named wrappers below (`useIsModerator`, `useIsAdmin`, …) are - * one-line shims over this primitive — use them in widget bodies for - * readability; reach for `useHasSecurityLevel(level)` directly only - * when the threshold is dynamic or not covered by a named wrapper. + * Reactive predicate: does the current user's rank level satisfy + * `>= minLevel`? Use this when you want "at least " semantics + * and have the rank id from your deployment's `permission_ranks` + * table (e.g. 5 for Moderator in the default seed). Replaces the + * older `useHasSecurityLevel` (same wire data, renamed to match the + * DB table semantics). */ -export const useHasSecurityLevel = (minLevel: number): boolean => - useUserSecurityLevel() >= minLevel; - -export const useIsModerator = (): boolean => useHasSecurityLevel(SecurityLevel.MODERATOR); -export const useIsPlayerSupport = (): boolean => useHasSecurityLevel(SecurityLevel.PLAYER_SUPPORT); -export const useIsCommunity = (): boolean => useHasSecurityLevel(SecurityLevel.COMMUNITY); -export const useIsAdmin = (): boolean => useHasSecurityLevel(SecurityLevel.ADMINISTRATOR); +export const useHasRankLevel = (minLevel: number): boolean => + useUserDataSnapshot().securityLevel >= minLevel; /** - * Reactive ambassador flag. Not derived from security level — it's a - * separate boolean on the snapshot. + * Reactive exact-match predicate against the rank name from + * `permission_ranks.rank_name`. Prefer `useHasRankLevel(min)` when + * the gate is "this rank or higher"; reach for `useIsRank('Foo')` + * only when an action must be specific to one rank. + */ +export const useIsRank = (name: string): boolean => useUserDataSnapshot().rankName === name; + +/** + * Reactive ambassador flag. Not derived from rank level — it's a + * separate boolean on the snapshot (the emulator computes it server- + * side from the `acc_ambassador` permission, which a deployment can + * grant independently of the rank hierarchy). */ export const useIsAmbassador = (): boolean => useUserDataSnapshot().isAmbassador; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 1d38a07..47580dc 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -124,9 +124,9 @@ export class RoomControllerLevel static readonly MODERATOR = 5; } -// Mirrors `packages/api/src/nitro/session/enum/SecurityLevel.ts`. Tests -// that gate behaviour on `securityLevel >= SecurityLevel.MODERATOR` (via -// useIsModerator / useHasSecurityLevel) depend on these constants. +// Mirrors `packages/api/src/nitro/session/enum/SecurityLevel.ts`. Kept +// around so any consumer that still imports the renderer enum +// (non-deprecated code path) compiles cleanly under the mock. export class SecurityLevel { static readonly NONE = 0;