import { GetEventDispatcher, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, IRoomSessionSnapshot, IRoomUserData, ISoundVolumesSnapshot, IUserDataSnapshot, NitroEventType } from '@nitrots/nitro-renderer'; import { useMemo } from 'react'; import { useExternalSnapshot } from '../events/useExternalSnapshot'; /** * React-side consumers for the referentially-stable snapshot getters * the renderer exposes (Nitro_Render_V3 v2.1.0+ pattern). * * Every hook here is a thin `useSyncExternalStore` wrapper: it subscribes * to the corresponding `NitroEventType.*_UPDATED` invalidation event and * reads the matching `getXxxSnapshot()`. Because the renderer guarantees * snapshot reference invariance until invalidation, React's bailout logic * skips re-renders when the snapshot is unchanged — so widgets that read * the same slice across many components share a single subscription and * only re-paint when the underlying state actually changes. * * Prefer these over reaching into the manager directly with * `GetSessionDataManager().userId` etc., which never trigger a re-render * when the value changes. * * The hooks are intentionally defensive: every call site is wrapped in a * "is this method available?" guard so a renderer-version mismatch * (e.g. a stale `dist/` shadowing the source) degrades to a stable empty * fallback instead of crashing the React tree with `(intermediate value)() * is undefined` during the first paint. */ // Module-level frozen defaults — referentially stable so the React bailout // keeps working when a manager method is unavailable. const NOOP_UNSUBSCRIBE = (): void => undefined; const DEFAULT_USER_DATA: Readonly = Object.freeze({ userId: 0, userName: '', figure: '', gender: '', realName: '', respectsReceived: 0, respectsLeft: 0, respectsPetLeft: 0, canChangeName: false, clubLevel: 0, securityLevel: 0, isAmbassador: false, isEmailVerified: false, isNoob: false, isAuthenticHabbo: false, isSystemOpen: false, isSystemShutdown: false, uiFlags: 0, tags: Object.freeze([]) as ReadonlyArray, rankId: 0, rankName: '', rankBadge: '', rankPrefix: '', rankPrefixColor: '' }) as Readonly; const EMPTY_IGNORED_LIST: ReadonlyArray = Object.freeze([]) as ReadonlyArray; const EMPTY_GROUP_BADGES: ReadonlyMap = new Map(); const EMPTY_USER_LIST: ReadonlyArray = Object.freeze([]) as ReadonlyArray; const EMPTY_PERMISSIONS: ReadonlyMap = new Map(); const DEFAULT_VOLUMES: Readonly = Object.freeze({ system: 0.5, furni: 0.5, trax: 0.5 }) as Readonly; const subscribeTo = (eventType: string) => (onChange: () => void): (() => void) => { const dispatcher = GetEventDispatcher(); // Stale renderer (no v2.1.0 subscribe API) — return a no-op // unsubscribe so useSyncExternalStore stays mounted cleanly. if(!dispatcher || typeof dispatcher.subscribe !== 'function') return NOOP_UNSUBSCRIBE; return dispatcher.subscribe(eventType, onChange); }; export const useUserDataSnapshot = (): Readonly => useExternalSnapshot( subscribeTo(NitroEventType.SESSION_DATA_UPDATED), () => { const manager = GetSessionDataManager(); if(!manager || typeof manager.getUserDataSnapshot !== 'function') return DEFAULT_USER_DATA; return manager.getUserDataSnapshot(); } ); export const useActiveRoomSessionSnapshot = (): Readonly | null => useExternalSnapshot( subscribeTo(NitroEventType.ROOM_SESSION_UPDATED), () => { const manager = GetRoomSessionManager(); if(!manager || typeof manager.getActiveRoomSessionSnapshot !== 'function') return null; return manager.getActiveRoomSessionSnapshot(); } ); export const useIgnoredUsersSnapshot = (): ReadonlyArray => useExternalSnapshot( subscribeTo(NitroEventType.IGNORED_USERS_UPDATED), () => { const inner = GetSessionDataManager()?.ignoredUsersManager; if(!inner || typeof inner.getIgnoredUsersSnapshot !== 'function') return EMPTY_IGNORED_LIST; return inner.getIgnoredUsersSnapshot(); } ); /** * Reactive predicate built on top of `useIgnoredUsersSnapshot`. * Re-renders only when the array reference flips (i.e. someone is added * or removed) — not on unrelated session updates. */ export const useIsUserIgnored = (name: string): boolean => { const list = useIgnoredUsersSnapshot(); return useMemo(() => list.includes(name), [ list, name ]); }; /** * Reactive view of the current user's rank metadata — name, badge, * prefix, prefix color — mirrored from `permission_ranks` via the * extended `UserPermissionsComposer` wire (Arcturus ≥ 4.2.10). Use * this in PRESENTATIONAL code only (chat prefix coloring, badge in * the avatar overlay, "rank" line in the user profile). DO NOT use * it for gating UI capabilities: prefer the permission-based family * (`useHasPermission(key)`) below, which is dynamic against * `permission_definitions` and survives rank renumbering. */ 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 ]); }; /** * Resolved permission map for the current user, mirroring * `permission_definitions` filtered to the user's rank. Backed by * `SessionDataManager.getPermissionsSnapshot()` and invalidated by * `USER_PERMISSIONS_UPDATED` (Arcturus dispatches the underlying * packet at login + after every `setRank`). * * Values: 1 = ALLOWED, 2 = ROOM_OWNER (legacy gate that requires * the user to also be the room owner). Absent key = DISALLOWED. * * Empty Map when the connected emulator doesn't ship the extension * (older deployments) — `useHasPermission` then returns false for * every key, which hides mod-only UI by default (safe). */ export const useUserPermissions = (): ReadonlyMap => useExternalSnapshot( subscribeTo(NitroEventType.USER_PERMISSIONS_UPDATED), () => { const manager = GetSessionDataManager(); if(!manager || typeof manager.getPermissionsSnapshot !== 'function') return EMPTY_PERMISSIONS; return manager.getPermissionsSnapshot(); } ); /** * Reactive predicate: does the current user have the named * permission **unconditionally** (ALLOWED only)? `key` must match a * row in `permission_definitions.permission_key` (e.g. * `'acc_supporttool'`, `'acc_anyroomowner'`, `'acc_catalogfurni'`). * * Mirrors the server-side semantics of `Habbo.hasPermission(key)` * (PermissionsManager → `Rank.hasPermission(key, isRoomOwner=false)` * which returns `setting == ALLOWED` — `setting == ROOM_OWNER` * requires the call site to pass `isRoomOwner=true`, which the * client doesn't have ambiently). So this hook returns `true` * only for ALLOWED (value 1) and `false` for ROOM_OWNER (value 2) * — the latter has to be re-checked against the active room * ownership via `usePermissionValue(key) === 2 && roomSession.isRoomOwner`. * * Prefer this over any rank-based gate — it survives rank * renumbering and adding new ranks without touching the React code. */ export const useHasPermission = (key: string): boolean => { const permissions = useUserPermissions(); return useMemo(() => permissions.get(key) === 1, [ permissions, key ]); }; /** * Reactive raw permission value: * 0 = DISALLOWED (also returned for absent keys) * 1 = ALLOWED * 2 = ROOM_OWNER (granted only when the caller is the active * room owner — combine with `roomSession.isRoomOwner`) * * Use this when the gate needs to distinguish ROOM_OWNER from * plain ALLOWED, or for the handful of permissions whose * `permission_definitions.max_value > 1`. */ export const usePermissionValue = (key: string): number => { const permissions = useUserPermissions(); return useMemo(() => permissions.get(key) ?? 0, [ permissions, key ]); }; /** * Reactive ambassador flag. Alias of * `useHasPermission('acc_ambassador')` — the snapshot also carries * an explicit `isAmbassador` boolean (legacy * `UserPermissionsComposer` field), but routing it through the * permission map keeps a single source of truth for runtime * promote/demote. */ export const useIsAmbassador = (): boolean => useHasPermission('acc_ambassador'); export const useGroupBadgesSnapshot = (): ReadonlyMap => useExternalSnapshot( subscribeTo(NitroEventType.GROUP_BADGES_UPDATED), () => { const inner = GetSessionDataManager()?.groupInformationManager; if(!inner || typeof inner.getGroupBadgesSnapshot !== 'function') return EMPTY_GROUP_BADGES; return inner.getGroupBadgesSnapshot(); } ); /** * Returns the badge id for a given group, reactive. Empty string when * the badge isn't known (matches the legacy `getGroupBadge` fallback). */ export const useGroupBadge = (groupId: number): string => { const badges = useGroupBadgesSnapshot(); return useMemo(() => badges.get(groupId) ?? '', [ badges, groupId ]); }; export const useVolumesSnapshot = (): Readonly => useExternalSnapshot( subscribeTo(NitroEventType.SOUND_VOLUMES_UPDATED), () => { const manager = GetSoundManager(); if(!manager || typeof manager.getVolumesSnapshot !== 'function') return DEFAULT_VOLUMES; return manager.getVolumesSnapshot(); } ); /** * Returns the active room's user list, reactive. Returns an empty * frozen array when no room session is active (or when the renderer * doesn't expose the snapshot getter yet). * * Subscribes to BOTH `ROOM_USER_LIST_UPDATED` (join/leave/update inside * the active session) AND `ROOM_SESSION_UPDATED` (because the underlying * `userDataManager` reference flips when the active room changes). */ export const useRoomUserListSnapshot = (): ReadonlyArray => useExternalSnapshot( (onChange) => { const dispatcher = GetEventDispatcher(); if(!dispatcher || typeof dispatcher.subscribe !== 'function') return NOOP_UNSUBSCRIBE; const offList = dispatcher.subscribe(NitroEventType.ROOM_USER_LIST_UPDATED, onChange); const offSession = dispatcher.subscribe(NitroEventType.ROOM_SESSION_UPDATED, onChange); return () => { offList(); offSession(); }; }, () => { const userDataManager = GetRoomSessionManager()?.getActiveRoomSessionSnapshot?.()?.session?.userDataManager; if(!userDataManager || typeof userDataManager.getRoomUserListSnapshot !== 'function') return EMPTY_USER_LIST; return userDataManager.getRoomUserListSnapshot(); } );