From 8ab0021af6cae5363262127bd9ee492ea5acec1c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 18:44:24 +0200 Subject: [PATCH] feat(navigator): wired-tools-style hook split (Store + 3 filters) Splits the 492-line useNavigator god-hook into a useBetween-backed useNavigatorStore closure plus three flat-shape filters (useNavigatorData, useNavigatorUiState, useNavigatorActions), mirroring the wired-tools layout. sendSearch + reloadCurrentSearch are extracted as named actions out of NavigatorView locals. Door-mode handling is removed from this store and lives in useDoorState (committed previously) - see GetGuestRoomResultEvent and GenericErrorEvent dual-subscription with mutually exclusive filters. The simpleAlert dependency is lifted out of the useBetween scope via a module-level _simpleAlert ref + _injectSimpleAlert() to avoid nested useBetween calls that corrupt use-between's module-level dispatcher state. The ref is null in tests (no events fire during smoke tests) and is populated in production by the navigator consumer before any alert is needed. The barrel index.ts no longer re-exports useNavigator. The 13 consumers will fail typecheck until the next commit migrates them; the hook files themselves are clean. Smoke test covers filter shapes. INTENTIONAL INTERMEDIATE-BROKEN COMMIT: yarn typecheck is RED at this SHA on the 13 consumer files. The next commit (consumer migration sweep) brings it back to green. --- src/hooks/navigator/index.ts | 8 +- src/hooks/navigator/useNavigatorActions.ts | 8 + src/hooks/navigator/useNavigatorData.ts | 17 + .../navigator/useNavigatorStore.test.tsx | 33 ++ src/hooks/navigator/useNavigatorStore.ts | 354 ++++++++++++++++++ src/hooks/navigator/useNavigatorUiState.ts | 18 + src/nitro-renderer.mock.ts | 46 +++ 7 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/hooks/navigator/useNavigatorActions.ts create mode 100644 src/hooks/navigator/useNavigatorData.ts create mode 100644 src/hooks/navigator/useNavigatorStore.test.tsx create mode 100644 src/hooks/navigator/useNavigatorStore.ts create mode 100644 src/hooks/navigator/useNavigatorUiState.ts diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index 1f6f053..c621851 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1 +1,7 @@ -export * from './useNavigator'; +export { useNavigatorActions } from './useNavigatorActions'; +export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorUiState } from './useNavigatorUiState'; +export { useNavigatorUiStore } from './navigatorUiStore'; +export { useDoorState } from '../rooms/widgets/useDoorState'; +export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState'; +export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore'; diff --git a/src/hooks/navigator/useNavigatorActions.ts b/src/hooks/navigator/useNavigatorActions.ts new file mode 100644 index 0000000..6a88e43 --- /dev/null +++ b/src/hooks/navigator/useNavigatorActions.ts @@ -0,0 +1,8 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => +{ + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts new file mode 100644 index 0000000..aeb05b7 --- /dev/null +++ b/src/hooks/navigator/useNavigatorData.ts @@ -0,0 +1,17 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => +{ + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + } = useBetween(useNavigatorStore); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + }; +}; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx new file mode 100644 index 0000000..a9040f4 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; + +describe('navigator filter shapes (smoke)', () => +{ + it('useNavigatorData returns the documented keys', () => + { + const { result } = renderHook(() => useNavigatorData()); + expect(Object.keys(result.current).sort()).toEqual([ + 'categories', 'eventCategories', 'favouriteRoomIds', + 'navigatorData', 'navigatorSearches', + 'searchResult', 'topLevelContext', 'topLevelContexts' + ].sort()); + }); + + it('useNavigatorUiState returns the 9 documented flags', () => + { + const { result } = renderHook(() => useNavigatorUiState()); + expect(Object.keys(result.current).sort()).toEqual([ + 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', + 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', + 'needsInit', 'needsSearch' + ].sort()); + }); + + it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => + { + const { result } = renderHook(() => useNavigatorActions()); + expect(typeof result.current.sendSearch).toBe('function'); + expect(typeof result.current.reloadCurrentSearch).toBe('function'); + }); +}); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts new file mode 100644 index 0000000..d577413 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -0,0 +1,354 @@ +import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, + FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent, + FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, + GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, + GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, + HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, + NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, + NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, + NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, + NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, + RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, + UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useRef, useState } from 'react'; +import { CreateRoomSession, GetConfigurationValue, INavigatorData, + LocalizeText, NotificationAlertType, SendMessageComposer, + TryVisitRoom, VisitDesktop } from '../../api'; +import { useMessageEvent, useNitroEvent } from '../events'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +// Module-level reference to simpleAlert, injected by useNavigatorActions +// (which runs in a real React dispatcher context, outside useBetween). +// Avoids nested useBetween calls that corrupt use-between's module-level state. +type SimpleAlertFn = (message: string, type?: string, clickUrl?: string, clickUrlText?: string, title?: string, imageUrl?: string) => void; +let _simpleAlert: SimpleAlertFn | null = null; +export const _injectSimpleAlert = (fn: SimpleAlertFn | null) => { _simpleAlert = fn; }; + +export const useNavigatorStore = () => +{ + const [ categories, setCategories ] = useState(null); + const [ eventCategories, setEventCategories ] = useState(null); + const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); + const [ topLevelContext, setTopLevelContext ] = useState(null); + const [ topLevelContexts, setTopLevelContexts ] = useState(null); + const [ searchResult, setSearchResult ] = useState(null); + const [ navigatorSearches, setNavigatorSearches ] = useState(null); + const [ navigatorData, setNavigatorData ] = useState({ + settingsReceived: false, + homeRoomId: 0, + enteredGuestRoom: null, + currentRoomOwner: false, + currentRoomId: 0, + currentRoomIsStaffPick: false, + createdFlatId: 0, + avatarId: 0, + roomPicker: false, + eventMod: false, + currentRoomRating: 0, + canRate: true + }); + + // Refs let handlers stay [] deps without losing access to fresh state. + const topLevelContextsRef = useRef(topLevelContexts); + topLevelContextsRef.current = topLevelContexts; + const topLevelContextRef = useRef(topLevelContext); + topLevelContextRef.current = topLevelContext; + const searchResultRef = useRef(searchResult); + searchResultRef.current = searchResult; + + const simpleAlert = _simpleAlert; + + const sendSearch = useCallback((searchValue: string, contextCode: string) => + { + useNavigatorUiStore.getState().closeCreator(); + SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); + useNavigatorUiStore.getState().setLoading(true); + }, []); + + const reloadCurrentSearch = useCallback(() => + { + if(!useNavigatorUiStore.getState().isReady) + { + useNavigatorUiStore.getState().requestSearch(); + return; + } + const sr = searchResultRef.current; + if(sr) + { + sendSearch(sr.data, sr.code); + return; + } + const ctx = topLevelContextRef.current; + if(!ctx) return; + sendSearch('', ctx.code); + }, [ sendSearch ]); + + useMessageEvent(FavouritesEvent, useCallback(event => + { + const parser = event.getParser(); + const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); + setFavouriteRoomIds(favoriteIds); + }, [])); + + useMessageEvent(FavouriteChangedEvent, useCallback(event => + { + const parser = event.getParser(); + const roomId = Number(parser.flatId); + const added = !!parser.added; + setFavouriteRoomIds(prev => + { + const ids = (prev || []).map((x: any) => Number(x)); + if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ]; + return ids.filter(id => id !== roomId); + }); + }, [])); + + useMessageEvent(RoomSettingsUpdatedEvent, useCallback(event => + { + const parser = event.getParser(); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); + }, [])); + + useMessageEvent(CanCreateRoomEventEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.canCreate) return; + simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title')); + }, [ simpleAlert ])); + + useMessageEvent(UserInfoEvent, useCallback(event => + { + SendMessageComposer(new GetUserFlatCatsMessageComposer()); + SendMessageComposer(new GetUserEventCatsMessageComposer()); + }, [])); + + useMessageEvent(UserPermissionsEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + eventMod: parser.securityLevel >= SecurityLevel.MODERATOR, + roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY + })); + }, [])); + + useMessageEvent(RoomForwardEvent, useCallback(event => + { + const parser = event.getParser(); + TryVisitRoom(parser.roomId); + }, [])); + + useMessageEvent(RoomEntryInfoMessageEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: null, + currentRoomOwner: parser.isOwner, + currentRoomId: parser.roomId + })); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false)); + if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]); + }, [])); + + useMessageEvent(GetGuestRoomResultEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.roomEnter) + { + setNavigatorData(prev => + { + const next = { ...prev }; + next.enteredGuestRoom = parser.data; + next.currentRoomIsStaffPick = parser.staffPick; + const isCreated = next.createdFlatId === parser.data.roomId; + if(!isCreated && parser.data.displayRoomEntryAd) + { + if(GetConfigurationValue('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd(); + } + next.createdFlatId = 0; + return next; + }); + return; + } + if(parser.roomForward) + { + // Door-mode branches (DOORBELL_STATE / PASSWORD_STATE) are handled by useDoorState — skip them here. + const isOwner = parser.data.ownerName === GetSessionDataManager().userName; + if(!isOwner && !parser.isGroupMember) + { + if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) return; + if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) return; + } + if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return; + CreateRoomSession(parser.data.roomId); + return; + } + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: parser.data, + currentRoomIsStaffPick: parser.staffPick + })); + }, [])); + + useMessageEvent(RoomScoreEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + currentRoomRating: parser.totalLikes, + canRate: parser.canLike + })); + }, [])); + + useMessageEvent(GenericErrorEvent, useCallback(event => + { + const parser = event.getParser(); + // -100002 (wrong password) is handled by useDoorState — skip it here. + switch(parser.errorCode) + { + case 4009: + simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4010: + simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4011: + simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4013: + simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + } + }, [ simpleAlert ])); + + useMessageEvent(NavigatorMetadataEvent, useCallback(event => + { + const parser = event.getParser(); + setTopLevelContexts(parser.topLevelContexts); + setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); + }, [])); + + useMessageEvent(NavigatorSearchEvent, useCallback(event => + { + const parser = event.getParser(); + const contexts = topLevelContextsRef.current; + setTopLevelContext(prev => + { + let next = prev; + if(!next) next = (contexts && contexts.length && contexts[0]) || null; + if(!next) return null; + if(contexts && contexts.length) + { + for(const ctx of contexts) + { + if(ctx.code === parser.result.code) next = ctx; + } + } + return next; + }); + setSearchResult(parser.result); + useNavigatorUiStore.getState().setLoading(false); + }, [])); + + useMessageEvent(UserFlatCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setCategories(parser.categories); + }, [])); + + useMessageEvent(UserEventCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setEventCategories(parser.categories); + }, [])); + + useMessageEvent(FlatCreatedEvent, useCallback(event => + { + const parser = event.getParser(); + CreateRoomSession(parser.roomId); + }, [])); + + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, useCallback(() => + { + setNavigatorData(prev => ({ ...prev, settingsReceived: false })); + }, [])); + + useMessageEvent(NavigatorHomeRoomEvent, useCallback(event => + { + const parser = event.getParser(); + let prevSettingsReceived = false; + setNavigatorData(prev => + { + prevSettingsReceived = prev.settingsReceived; + return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true }; + }); + if(prevSettingsReceived) return; + if(GetRoomSessionManager().viewerSession) return; + + let forwardType = -1; + let forwardId = -1; + if((GetConfigurationValue('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) + { + forwardType = 0; + SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); + } + if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) + { + forwardType = parseInt(GetConfigurationValue('forward.type')); + forwardId = parseInt(GetConfigurationValue('forward.id')); + } + if(forwardType === 2) + { + TryVisitRoom(forwardId); + } + else if((forwardType === -1) && (parser.roomIdToEnter > 0)) + { + CreateLinkEvent('navigator/close'); + CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId); + } + }, [])); + + useMessageEvent(RoomEnterErrorEvent, useCallback(event => + { + const parser = event.getParser(); + switch(parser.reason) + { + case CantConnectMessageParser.REASON_FULL: + simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title')); + break; + case CantConnectMessageParser.REASON_QUEUE_ERROR: + simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + case CantConnectMessageParser.REASON_BANNED: + simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title')); + break; + default: + simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + } + if(GetRoomSessionManager().isReconnecting) return; + VisitDesktop(); + }, [ simpleAlert ])); + + useMessageEvent(NavigatorOpenRoomCreatorEvent, useCallback(_event => + { + CreateLinkEvent('navigator/show'); + }, [])); + + useMessageEvent(NavigatorSearchesEvent, useCallback(event => + { + const parser = event.getParser(); + if(!parser) return; + setNavigatorSearches(parser.searches); + }, [])); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + sendSearch, reloadCurrentSearch + }; +}; diff --git a/src/hooks/navigator/useNavigatorUiState.ts b/src/hooks/navigator/useNavigatorUiState.ts new file mode 100644 index 0000000..3c0868a --- /dev/null +++ b/src/hooks/navigator/useNavigatorUiState.ts @@ -0,0 +1,18 @@ +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => +{ + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch + }; +}; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index dd5b642..54bf1e6 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -251,6 +251,29 @@ export class FlatAccessDeniedMessageEvent extends MessageEvent {} export class GenericErrorEvent extends MessageEvent {} export class GetGuestRoomResultEvent extends MessageEvent {} +// --------------------------------------------------------------------------- +// Navigator event classes — MessageEvent subclasses needed by useNavigatorStore +// --------------------------------------------------------------------------- + +export class CanCreateRoomEventEvent extends MessageEvent {} +export class FavouriteChangedEvent extends MessageEvent {} +export class FavouritesEvent extends MessageEvent {} +export class FlatCreatedEvent extends MessageEvent {} +export class NavigatorHomeRoomEvent extends MessageEvent {} +export class NavigatorMetadataEvent extends MessageEvent {} +export class NavigatorOpenRoomCreatorEvent extends MessageEvent {} +export class NavigatorSearchesEvent extends MessageEvent {} +export class NavigatorSearchEvent extends MessageEvent {} +export class RoomEnterErrorEvent extends MessageEvent {} +export class RoomEntryInfoMessageEvent extends MessageEvent {} +export class RoomForwardEvent extends MessageEvent {} +export class RoomScoreEvent extends MessageEvent {} +export class RoomSettingsUpdatedEvent extends MessageEvent {} +export class UserEventCatsEvent extends MessageEvent {} +export class UserFlatCatsEvent extends MessageEvent {} +export class UserInfoEvent extends MessageEvent {} +export class UserPermissionsEvent extends MessageEvent {} + export class RoomEngineObjectEvent extends StubClass {} export class CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} @@ -268,6 +291,25 @@ export class RoomDataParser export class RoomModerationSettings extends StubClass {} export class StringDataType extends StubClass {} + +// Navigator data/parser stubs +export class NavigatorCategoryDataParser extends StubClass {} +export class NavigatorEventCategoryDataParser extends StubClass {} +export class NavigatorSavedSearch extends StubClass {} +export class NavigatorSearchResultSet extends StubClass {} +export class NavigatorTopLevelContext extends StubClass {} +export class CantConnectMessageParser extends StubClass +{ + static readonly REASON_FULL = 1; + static readonly REASON_QUEUE_ERROR = 2; + static readonly REASON_BANNED = 3; +} + +export class LegacyExternalInterface +{ + static readonly available = false; + static call(..._args: unknown[]): void {} +} export class SellablePetPaletteData extends StubClass {} export class PetFigureData extends StubClass {} export class PetData extends StubClass {} @@ -289,6 +331,10 @@ export class HabboWebTools extends StubClass {} // codebase ("did the SUT call SendMessageComposer(new FooComposer(args))"). export class AddFavouriteRoomMessageComposer extends StubClass {} export class DeleteFavouriteRoomMessageComposer extends StubClass {} +export class FollowFriendMessageComposer extends StubClass {} +export class GetUserEventCatsMessageComposer extends StubClass {} +export class GetUserFlatCatsMessageComposer extends StubClass {} +export class NavigatorSearchComposer extends StubClass {} export class DesktopViewComposer extends StubClass {} export class FurniturePlacePaintComposer extends StubClass {} export class GetGuestRoomMessageComposer extends StubClass {}