From 532cb28ca7e995c97ce639de8424c80c40d73d3e Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 19 May 2026 18:07:17 +0200 Subject: [PATCH] feat(hooks): useIsModerator() + migrate 6 component reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a reactive `useIsModerator()` derived from `useUserDataSnapshot().securityLevel >= SecurityLevel.MODERATOR` (mirrors the renderer-side getter at SessionDataManager.ts:684), and migrates the six React component-body reads of `GetSessionDataManager().isModerator`: - ToolbarView (mod-only chat-input button) - CatalogClassicView, CatalogModernView (admin toggles in catalog header) - ChooserWidgetView (room-object id column visibility) - YouTubePlayerView (room-control affordance — hook moved above the `if (!isOpen) return null` early return so the hook order stays stable when the player opens/closes) - CalendarView (mod-only "open all" affordance) UX impact: any future promote/demote that flips SESSION_DATA_UPDATED now re-renders the mod-only UI live, instead of requiring an F5. Imperative call sites (AvatarInfoUtilities.populate*, CanManipulateFurniture, RoomChatHandler) still read the manager directly — they run at click time, not in a React render, so reactivity has no upside there. Five of the six call sites are top-level component-body reads (no early-return interaction). YouTubePlayerView has an `if (!isOpen) return null` below the hook list, so the hook had to move ABOVE it; same shape as the recent CatalogPurchaseWidgetView and CatalogItemGridWidgetView fixes. Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test 209/209. --- src/components/campaign/CalendarView.tsx | 5 +++-- src/components/catalog/CatalogClassicView.tsx | 6 +++--- src/components/catalog/CatalogModernView.tsx | 6 +++--- .../room/widgets/choosers/ChooserWidgetView.tsx | 5 +++-- src/components/toolbar/ToolbarView.tsx | 6 +++--- src/components/toolbar/YouTubePlayerView.tsx | 9 ++++++--- src/hooks/session/useSessionSnapshots.ts | 16 +++++++++++++++- 7 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/components/campaign/CalendarView.tsx b/src/components/campaign/CalendarView.tsx index 057d088..1cbee1a 100644 --- a/src/components/campaign/CalendarView.tsx +++ b/src/components/campaign/CalendarView.tsx @@ -1,7 +1,7 @@ -import { GetSessionDataManager } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api'; import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; +import { useIsModerator } from '../../hooks'; import { CalendarItemView } from './CalendarItemView'; interface CalendarViewProps @@ -23,6 +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 getDayState = (day: number) => { @@ -109,7 +110,7 @@ export const CalendarView: FC = props => { dayMessage(selectedDay) }
- { GetSessionDataManager().isModerator && + { isModerator && }
diff --git a/src/components/catalog/CatalogClassicView.tsx b/src/components/catalog/CatalogClassicView.tsx index 9c3111c..cd6b2e8 100644 --- a/src/components/catalog/CatalogClassicView.tsx +++ b/src/components/catalog/CatalogClassicView.tsx @@ -1,9 +1,9 @@ -import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +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 { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common'; -import { useCatalogActions, useCatalogData, useCatalogUiState } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogUiState, useIsModerator } 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 = GetSessionDataManager().isModerator; + const isMod = useIsModerator(); 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 12b8ac1..66767d8 100644 --- a/src/components/catalog/CatalogModernView.tsx +++ b/src/components/catalog/CatalogModernView.tsx @@ -1,9 +1,9 @@ -import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +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 { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; -import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState } from '../../hooks'; +import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useIsModerator } 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 = GetSessionDataManager().isModerator; + const isMod = useIsModerator(); 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/room/widgets/choosers/ChooserWidgetView.tsx b/src/components/room/widgets/choosers/ChooserWidgetView.tsx index f747b3b..da4866b 100644 --- a/src/components/room/widgets/choosers/ChooserWidgetView.tsx +++ b/src/components/room/widgets/choosers/ChooserWidgetView.tsx @@ -1,7 +1,8 @@ -import { FurniturePickupAllComposer, GetSessionDataManager } from '@nitrots/nitro-renderer'; +import { FurniturePickupAllComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useEffectEvent, useMemo, useState } from 'react'; import { LocalizeText, RoomObjectItem, SendMessageComposer, chooserSelectionVisualizer } from '../../../../api'; import { Button, Flex, InfiniteScroll, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; +import { useIsModerator } from '../../../../hooks'; import { NitroInput, classNames } from '../../../../layout'; const LIMIT_FURNI_PICKALL = 100; @@ -23,7 +24,7 @@ export const ChooserWidgetView: FC = props => const [ searchValue, setSearchValue ] = useState(''); const [ checkAll, setCheckAll ] = useState(false); const [ checkedIds, setCheckedIds ] = useState([]); - const canSeeId = GetSessionDataManager().isModerator; + const canSeeId = useIsModerator(); const ownerNames = useMemo(() => { diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index d960bd3..04a2128 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -1,9 +1,9 @@ -import { CreateLinkEvent, Dispose, DropBounce, EaseOut, GetSessionDataManager, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; +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 { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; -import { useAchievements, useFriends, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; +import { useAchievements, useFriends, useInventoryUnseenTracker, useIsModerator, 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 = GetSessionDataManager().isModerator; + const isMod = useIsModerator(); const isVisible = (isToolbarOpen || !isInRoom); const visibilityVariant = isVisible ? 'visible' : 'hidden'; diff --git a/src/components/toolbar/YouTubePlayerView.tsx b/src/components/toolbar/YouTubePlayerView.tsx index 3140290..a7de86d 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, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from '../../api'; +import { GetRoomSession, getYoutubeRoomEnabled, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from '../../common'; -import { useFurnitureYoutubeWidget, useMessageEvent } from '../../hooks'; +import { useFurnitureYoutubeWidget, useIsModerator, useMessageEvent } from '../../hooks'; const CONTROL_COMMAND_PREVIOUS_VIDEO = 0; const CONTROL_COMMAND_NEXT_VIDEO = 1; @@ -46,6 +46,9 @@ export const YouTubePlayerView: FC<{}> = () => const [broadcastPlaylist, setBroadcastPlaylist] = useState([]); const [watcherIds, setWatcherIds] = useState>(new Set()); 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(); useMessageEvent(YouTubeRoomSettingsEvent, event => { @@ -270,7 +273,7 @@ export const YouTubePlayerView: FC<{}> = () => const isPlaying = currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING; const isPaused = currentVideoState === YoutubeVideoPlaybackStateEnum.PAUSED; const roomSession = GetRoomSession(); - const isMyRoom = GetSessionDataManager().isModerator || (roomSession && roomSession.isRoomOwner); + const isMyRoom = isModerator || (roomSession && roomSession.isRoomOwner); const QuickVolumeButton = ({ value, diff --git a/src/hooks/session/useSessionSnapshots.ts b/src/hooks/session/useSessionSnapshots.ts index 08051bf..6c9b152 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 } from '@nitrots/nitro-renderer'; +import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType, SecurityLevel } from '@nitrots/nitro-renderer'; import { useMemo } from 'react'; import { useExternalSnapshot } from '../events/useExternalSnapshot'; @@ -123,6 +123,20 @@ export const useIsUserIgnored = (name: string): boolean => return useMemo(() => list.includes(name), [ list, name ]); }; +/** + * Reactive equivalent of `GetSessionDataManager().isModerator`. Derives + * from `useUserDataSnapshot().securityLevel` so any future + * promote/demote that flips the SESSION_DATA_UPDATED event re-renders + * the consumer without an F5. Mirrors the renderer-side getter at + * `SessionDataManager.ts:684` (`securityLevel >= SecurityLevel.MODERATOR`). + */ +export const useIsModerator = (): boolean => +{ + const userData = useUserDataSnapshot(); + + return userData.securityLevel >= SecurityLevel.MODERATOR; +}; + export const useGroupBadgesSnapshot = (): ReadonlyMap => useExternalSnapshot( subscribeTo(NitroEventType.GROUP_BADGES_UPDATED),