feat(hooks): useIsModerator() + migrate 6 component reads

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.
This commit is contained in:
simoleo89
2026-05-19 18:07:17 +02:00
parent 3459400ed7
commit 532cb28ca7
7 changed files with 36 additions and 17 deletions
+3 -2
View File
@@ -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<CalendarViewProps> = 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<CalendarViewProps> = props =>
<Text>{ dayMessage(selectedDay) }</Text>
</div>
<div>
{ GetSessionDataManager().isModerator &&
{ isModerator &&
<Button variant="danger" onClick={ forceOpen }>Force open</Button> }
</div>
</div>
@@ -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;
+3 -3
View File
@@ -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%)' }
@@ -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<ChooserWidgetViewProps> = props =>
const [ searchValue, setSearchValue ] = useState('');
const [ checkAll, setCheckAll ] = useState(false);
const [ checkedIds, setCheckedIds ] = useState<number[]>([]);
const canSeeId = GetSessionDataManager().isModerator;
const canSeeId = useIsModerator();
const ownerNames = useMemo(() =>
{
+3 -3
View File
@@ -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';
+6 -3
View File
@@ -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<string[]>([]);
const [watcherIds, setWatcherIds] = useState<Set<number>>(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>(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,
+15 -1
View File
@@ -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<number, string> =>
useExternalSnapshot(
subscribeTo(NitroEventType.GROUP_BADGES_UPDATED),