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 {}