Merge pull request #168 from simoleo89/feat/navigator-modernization

feat(navigator): wired-tools-style hook split + Zustand UI store (P1)
This commit is contained in:
DuckieTM
2026-05-28 13:04:48 +02:00
committed by GitHub
28 changed files with 3512 additions and 787 deletions
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { useNitroQuery } from '../../../../../api/nitro-query';
import { Button, Column, Text } from '../../../../../common';
import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks';
import { useCatalogUiState, useNavigatorData, useRoomPromote } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
import { CatalogLayoutProps } from './CatalogLayout.types';
@@ -17,7 +17,7 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
const [ roomId, setRoomId ] = useState<number>(-1);
const [ extended, setExtended ] = useState<boolean>(false);
const [ categoryId, setCategoryId ] = useState<number>(1);
const { categories = null } = useNavigator();
const { categories } = useNavigatorData();
const { setIsVisible = null } = useCatalogUiState();
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
+63 -157
View File
@@ -1,6 +1,6 @@
import { NitroCard } from '@layout/NitroCard';
import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, NavigatorSearchComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef } from 'react';
import { FaPlus } from 'react-icons/fa';
import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png';
import createRoomImg from '../../assets/images/navigator/create_room.png';
@@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png';
import promoteRoomImg from '../../assets/images/navigator/promote_room.png';
import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api';
import { Flex, Text } from '../../common';
import { useNavigator, useNitroEvent } from '../../hooks';
import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks';
import { NavigatorDoorStateView } from './views/NavigatorDoorStateView';
import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView';
import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
@@ -20,184 +20,106 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView';
export const NavigatorView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isReady, setIsReady ] = useState(false);
const [ isCreatorOpen, setCreatorOpen ] = useState(false);
const [ isRoomInfoOpen, setRoomInfoOpen ] = useState(false);
const [ isRoomLinkOpen, setRoomLinkOpen ] = useState(false);
const [ isOpenSavesSearches, setIsOpenSavesSearches ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ needsInit, setNeedsInit ] = useState(true);
const [ needsSearch, setNeedsSearch ] = useState(false);
const { searchResult = null, topLevelContext = null, topLevelContexts = null, navigatorData = null, navigatorSearches = null } = useNavigator();
const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData();
const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState();
const { sendSearch, reloadCurrentSearch } = useNavigatorActions();
const pendingSearch = useRef<{ value: string, code: string }>(null);
const elementRef = useRef<HTMLDivElement>(null);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event =>
{
setIsVisible(false);
setCreatorOpen(false);
useNavigatorUiStore.getState().hide();
useNavigatorUiStore.getState().closeCreator();
});
const sendSearch = useCallback((searchValue: string, contextCode: string) =>
{
setCreatorOpen(false);
SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue));
setIsLoading(true);
}, []);
const reloadCurrentSearch = useCallback(() =>
{
if(!isReady)
{
setNeedsSearch(true);
return;
}
if(pendingSearch.current)
{
sendSearch(pendingSearch.current.value, pendingSearch.current.code);
pendingSearch.current = null;
return;
}
if(searchResult)
{
sendSearch(searchResult.data, searchResult.code);
return;
}
if(!topLevelContext) return;
sendSearch('', topLevelContext.code);
}, [ isReady, searchResult, topLevelContext, sendSearch ]);
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
const store = useNavigatorUiStore.getState();
switch(parts[1])
{
case 'show': {
setIsVisible(true);
setNeedsSearch(true);
case 'show':
store.show();
return;
}
case 'hide':
setIsVisible(false);
store.hide();
return;
case 'toggle': {
if(isVisible)
{
setIsVisible(false);
return;
}
setIsVisible(true);
setNeedsSearch(true);
case 'toggle':
store.toggle();
return;
}
case 'toggle-room-info':
setRoomInfoOpen(value => !value);
store.toggleRoomInfo();
return;
case 'toggle-room-link':
setRoomLinkOpen(value => !value);
store.toggleRoomLink();
return;
case 'goto':
if(parts.length <= 2) return;
switch(parts[2])
if(parts[2] === 'home')
{
case 'home':
if(navigatorData.homeRoomId <= 0) return;
TryVisitRoom(navigatorData.homeRoomId);
break;
default: {
const roomId = parseInt(parts[2]);
TryVisitRoom(roomId);
}
if(navigatorData.homeRoomId <= 0) return;
TryVisitRoom(navigatorData.homeRoomId);
return;
}
TryVisitRoom(parseInt(parts[2]));
return;
case 'create':
setIsVisible(true);
setCreatorOpen(true);
store.openCreator();
return;
case 'search':
if(parts.length > 2)
{
const topLevelContextCode = parts[2];
let searchValue = '';
if(parts.length > 3) searchValue = parts[3];
pendingSearch.current = { value: searchValue, code: topLevelContextCode };
setIsVisible(true);
setNeedsSearch(true);
}
if(parts.length <= 2) return;
pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] };
store.show();
return;
}
},
eventUrlPrefix: 'navigator/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ isVisible, navigatorData ]);
}, [ navigatorData ]);
useEffect(() =>
{
if(!searchResult) return;
setIsLoading(false);
if(elementRef && elementRef.current) elementRef.current.scrollTop = 0;
if(elementRef.current) elementRef.current.scrollTop = 0;
}, [ searchResult ]);
useEffect(() =>
{
if(!isVisible || !isReady || !needsSearch) return;
reloadCurrentSearch();
setNeedsSearch(false);
}, [ isVisible, isReady, needsSearch, reloadCurrentSearch ]);
if(pendingSearch.current)
{
sendSearch(pendingSearch.current.value, pendingSearch.current.code);
pendingSearch.current = null;
}
else
{
reloadCurrentSearch();
}
useNavigatorUiStore.getState().consumeSearchRequest();
}, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]);
useEffect(() =>
{
if(isReady || !topLevelContext) return;
setIsReady(true);
useNavigatorUiStore.getState().markReady();
}, [ isReady, topLevelContext ]);
useEffect(() =>
{
if(!isVisible || !needsInit) return;
SendMessageComposer(new NavigatorInitComposer());
setNeedsInit(false);
useNavigatorUiStore.getState().markInitDone();
}, [ isVisible, needsInit ]);
useEffect(() =>
{
LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string, _arg_2: boolean = false, _arg_3: string = null) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k)));
LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k)));
}, []);
return (
@@ -208,28 +130,24 @@ export const NavigatorView: FC<{}> = props =>
uniqueKey="navigator">
<NitroCard.Header
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
onCloseClick={ event => setIsVisible(false) } />
onCloseClick={ () => useNavigatorUiStore.getState().hide() } />
<NitroCard.Tabs>
<NitroCard.TabItem
isActive={ isOpenSavesSearches }
title={ LocalizeText('navigator.tooltip.left.show.hide') }
onClick={ () => setIsOpenSavesSearches(prev => !prev) }>
onClick={ () => useNavigatorUiStore.getState().toggleSavesSearches() }>
<img src={ savesSearchIcon } alt="" style={{ width: 18, height: 18 }} />
</NitroCard.TabItem>
{ topLevelContexts && (topLevelContexts.length > 0) && topLevelContexts.map((context, index) =>
{
return (
<NitroCard.TabItem
key={ index }
isActive={ ((topLevelContext === context) && !isCreatorOpen) }
onClick={ event => sendSearch('', context.code) }>
{ LocalizeText(('navigator.toplevelview.' + context.code)) }
</NitroCard.TabItem>
);
}) }
{ topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) =>
<NitroCard.TabItem
key={ index }
isActive={ topLevelContext === context && !isCreatorOpen }
onClick={ () => sendSearch('', context.code) }>
{ LocalizeText('navigator.toplevelview.' + context.code) }
</NitroCard.TabItem>) }
<NitroCard.TabItem
isActive={ isCreatorOpen }
onClick={ event => setCreatorOpen(true) }>
onClick={ () => useNavigatorUiStore.getState().openCreator() }>
<FaPlus className="fa-icon" />
</NitroCard.TabItem>
</NitroCard.Tabs>
@@ -241,49 +159,37 @@ export const NavigatorView: FC<{}> = props =>
<NavigatorSearchSavesResultView searches={ navigatorSearches || [] } />
</div> }
<div className="flex flex-col w-full overflow-hidden gap-2">
<NavigatorSearchView sendSearch={ sendSearch } />
<NavigatorSearchView />
<div ref={ elementRef } className="flex flex-col flex-1 min-h-0 overflow-auto gap-2">
{ (searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />)) }
{ (searchResult && (!searchResult.results || (searchResult.results.length === 0))) &&
{ searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />) }
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
<div className="nitro-card-panel px-3 py-2 text-sm text-muted">
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
</div> }
</div>
<Flex className="nitro-card-divider pt-2 border-t gap-2">
<Flex
pointer
alignItems="center"
justifyContent="center"
<Flex pointer alignItems="center" justifyContent="center"
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
style={ { backgroundImage: `url(${ createRoomImg })`, backgroundSize: '100% 100%' } }
onClick={ () => setCreatorOpen(true) }
>
onClick={ () => useNavigatorUiStore.getState().openCreator() }>
<Text variant="white" bold className="text-xs drop-shadow">
{ LocalizeText('navigator.createroom.create') }
</Text>
</Flex>
{ (searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view') &&
<Flex
pointer
alignItems="center"
justifyContent="center"
{ searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view' &&
<Flex pointer alignItems="center" justifyContent="center"
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
style={ { backgroundImage: `url(${ randomRoomImg })`, backgroundSize: '100% 100%' } }
onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }
>
onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }>
<Text variant="white" bold className="text-xs drop-shadow">
{ LocalizeText('navigator.random.room') }
</Text>
</Flex> }
{ (searchResult?.code === 'myworld_view' || searchResult?.code === 'roomads_view') &&
<Flex
pointer
alignItems="center"
justifyContent="center"
<Flex pointer alignItems="center" justifyContent="center"
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
style={ { backgroundImage: `url(${ promoteRoomImg })`, backgroundSize: '100% 100%' } }
onClick={ () => CreateLinkEvent('catalog/open/room_event') }
>
onClick={ () => CreateLinkEvent('catalog/open/room_event') }>
<Text variant="white" bold className="text-xs drop-shadow">
{ LocalizeText('navigator.promote.room') }
</Text>
@@ -295,8 +201,8 @@ export const NavigatorView: FC<{}> = props =>
</NitroCard.Content>
</NitroCard> }
<NavigatorDoorStateView />
{ isRoomInfoOpen && <NavigatorRoomInfoView onCloseClick={ () => setRoomInfoOpen(false) } /> }
{ isRoomLinkOpen && <NavigatorRoomLinkView onCloseClick={ () => setRoomLinkOpen(false) } /> }
{ isRoomInfoOpen && <NavigatorRoomInfoView onCloseClick={ () => useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> }
{ isRoomLinkOpen && <NavigatorRoomLinkView onCloseClick={ () => useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> }
<NavigatorRoomSettingsView />
</>
);
@@ -1,88 +1,68 @@
import { FC, useEffect, useState } from 'react';
import { CreateRoomSession, DoorStateType, GoToDesktop, LocalizeText } from '../../../api';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
import { useNavigator } from '../../../hooks';
import { useDoorState } from '../../../hooks';
import { NitroInput } from '../../../layout';
const VISIBLE_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER, DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ];
const DOORBELL_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER ];
const PASSWORD_STATES = [ DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ];
export const NavigatorDoorStateView: FC<{}> = props =>
{
const [ password, setPassword ] = useState('');
const { doorData = null, setDoorData = null } = useNavigator();
const { snapshot, setSnapshot, reset } = useDoorState();
const onClose = () =>
{
if(doorData && (doorData.state === DoorStateType.STATE_WAITING)) GoToDesktop();
setDoorData(null);
if(snapshot.state === DoorStateType.STATE_WAITING) GoToDesktop();
reset();
};
const ring = () =>
{
if(!doorData || !doorData.roomInfo) return;
CreateRoomSession(doorData.roomInfo.roomId);
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_PENDING_SERVER;
return newValue;
});
if(!snapshot.roomInfo) return;
CreateRoomSession(snapshot.roomInfo.roomId);
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER }));
};
const tryEntering = () =>
{
if(!doorData || !doorData.roomInfo) return;
CreateRoomSession(doorData.roomInfo.roomId, password);
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_PENDING_SERVER;
return newValue;
});
if(!snapshot.roomInfo) return;
CreateRoomSession(snapshot.roomInfo.roomId, password);
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER }));
};
useEffect(() =>
{
if(!doorData || (doorData.state !== DoorStateType.STATE_NO_ANSWER)) return;
if(snapshot.state !== DoorStateType.STATE_NO_ANSWER) return;
GoToDesktop();
}, [ doorData ]);
}, [ snapshot.state ]);
if(!doorData || (doorData.state === DoorStateType.NONE) || (VISIBLE_STATES.indexOf(doorData.state) === -1)) return null;
if(snapshot.state === DoorStateType.NONE) return null;
if(VISIBLE_STATES.indexOf(snapshot.state) === -1) return null;
const isDoorbell = (DOORBELL_STATES.indexOf(doorData.state) >= 0);
const isDoorbell = DOORBELL_STATES.indexOf(snapshot.state) >= 0;
return (
<NitroCardView className="nitro-navigator-doorbell" theme="primary-slim">
<NitroCardHeaderView headerText={ LocalizeText(isDoorbell ? 'navigator.doorbell.title' : 'navigator.password.title') } onCloseClick={ onClose } />
<NitroCardContentView>
<div className="flex flex-col gap-1">
<Text bold>{ doorData && doorData.roomInfo && doorData.roomInfo.roomName }</Text>
{ (doorData.state === DoorStateType.START_DOORBELL) &&
<Text bold>{ snapshot.roomInfo && snapshot.roomInfo.roomName }</Text>
{ snapshot.state === DoorStateType.START_DOORBELL &&
<Text>{ LocalizeText('navigator.doorbell.info') }</Text> }
{ (doorData.state === DoorStateType.STATE_WAITING) &&
{ snapshot.state === DoorStateType.STATE_WAITING &&
<Text>{ LocalizeText('navigator.doorbell.waiting') }</Text> }
{ (doorData.state === DoorStateType.STATE_NO_ANSWER) &&
{ snapshot.state === DoorStateType.STATE_NO_ANSWER &&
<Text>{ LocalizeText('navigator.doorbell.no.answer') }</Text> }
{ (doorData.state === DoorStateType.START_PASSWORD) &&
{ snapshot.state === DoorStateType.START_PASSWORD &&
<Text>{ LocalizeText('navigator.password.info') }</Text> }
{ (doorData.state === DoorStateType.STATE_WRONG_PASSWORD) &&
{ snapshot.state === DoorStateType.STATE_WRONG_PASSWORD &&
<Text>{ LocalizeText('navigator.password.retryinfo') }</Text> }
</div>
{ isDoorbell &&
<div className="flex flex-col gap-1">
{ (doorData.state === DoorStateType.START_DOORBELL) &&
{ snapshot.state === DoorStateType.START_DOORBELL &&
<Button variant="success" onClick={ ring }>
{ LocalizeText('navigator.doorbell.button.ring') }
</Button> }
@@ -3,7 +3,7 @@ import { CreateFlatMessageComposer, HabboClubLevelEnum } from '@nitrots/nitro-re
import { FC, useEffect, useState } from 'react';
import { GetClubMemberLevel, GetConfigurationValue, IRoomModel, LocalizeText, SendMessageComposer } from '../../../api';
import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '../../../common';
import { useNavigator } from '../../../hooks';
import { useNavigatorData } from '../../../hooks';
import { NitroInput } from '../../../layout';
import { useRoomCreatorStore } from './navigatorRoomCreatorStore';
@@ -25,7 +25,7 @@ export const NavigatorRoomCreatorView: FC = () =>
});
const isCreating = useRoomCreatorStore(s => s.isCreating);
const beginCreate = useRoomCreatorStore(s => s.beginCreate);
const { categories = null } = useNavigator();
const { categories } = useNavigatorData();
const hcDisabled = GetConfigurationValue<boolean>('hc.disabled', false);
@@ -4,7 +4,7 @@ import { FaLink, FaSignOutAlt } from 'react-icons/fa';
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
import { RoomWidgetThumbnailEvent } from '../../../events';
import { useHasPermission, useHelp, useNavigator, useRoom } from '../../../hooks';
import { useHasPermission, useHelp, useNavigatorData, useRoom } from '../../../hooks';
import { classNames } from '../../../layout';
export interface NavigatorRoomInfoViewProps {
@@ -17,7 +17,7 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
const [ isRoomPicked, setIsRoomPicked ] = useState(false);
const [ isRoomMuted, setIsRoomMuted ] = useState(false);
const { report = null } = useHelp();
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
const { navigatorData, favouriteRoomIds } = useNavigatorData();
const { roomSession = null } = useRoom();
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
const canStaffPick = useHasPermission('acc_staff_pick');
@@ -1,7 +1,7 @@
import { FC } from 'react';
import { GetConfigurationValue, LocalizeText } from '../../../api';
import { LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
import { useNavigator } from '../../../hooks';
import { useNavigatorData } from '../../../hooks';
export class NavigatorRoomLinkViewProps
{
@@ -11,7 +11,7 @@ export class NavigatorRoomLinkViewProps
export const NavigatorRoomLinkView: FC<NavigatorRoomLinkViewProps> = props =>
{
const { onCloseClick = null } = props;
const { navigatorData = null } = useNavigator();
const { navigatorData } = useNavigatorData();
if(!navigatorData.enteredGuestRoom) return null;
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { CreateLinkEvent, GetMaxVisitorsList, IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Base, Column, Flex, Text } from '../../../../common';
import { useMessageEvent, useNavigator, useNotification } from '../../../../hooks';
import { useMessageEvent, useNavigatorData, useNotification } from '../../../../hooks';
const ROOM_NAME_MIN_LENGTH = 3;
const ROOM_NAME_MAX_LENGTH = 60;
@@ -27,7 +27,7 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
const [ tagIndex, setTagIndex ] = useState(0);
const [ typeError, setTypeError ] = useState<string>('');
const { showConfirm = null } = useNotification();
const { categories = null } = useNavigator();
const { categories } = useNavigatorData();
useMessageEvent<RoomSettingsSaveErrorEvent>(RoomSettingsSaveErrorEvent, event =>
{
@@ -4,7 +4,7 @@ import React, { FC, useRef, useState } from 'react';
import { FaUser } from 'react-icons/fa';
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api';
import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
import { useHelp, useNavigator } from '../../../../hooks';
import { useHelp, useNavigatorData } from '../../../../hooks';
import { classNames } from '../../../../layout';
interface NavigatorSearchResultItemInfoViewProps
@@ -20,7 +20,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
const { roomData = null, isVisible = undefined, onToggle, setIsPopoverActive } = props;
const elementRef = useRef<HTMLDivElement>(null);
const [ internalVisible, setInternalVisible ] = useState(false);
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
const { navigatorData, favouriteRoomIds } = useNavigatorData();
const { report = null } = useHelp();
const isControlled = isVisible !== undefined;
@@ -3,7 +3,7 @@ import React, { FC, MouseEvent, useEffect } from 'react';
import { FaUser } from 'react-icons/fa';
import { CreateRoomSession, DoorStateType, TryVisitRoom } from '../../../../api';
import { Column, Flex, LayoutBadgeImageView, LayoutGridItemProps, LayoutRoomThumbnailView, Text } from '../../../../common';
import { useNavigator } from '../../../../hooks';
import { useDoorState } from '../../../../hooks';
import { NavigatorSearchResultItemInfoView } from './NavigatorSearchResultItemInfoView';
export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps
@@ -19,7 +19,7 @@ export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps
export const NavigatorSearchResultItemView: FC<NavigatorSearchResultItemViewProps> = props =>
{
const { roomData = null, children = null, thumbnail = false, selectedRoomId, setSelectedRoomId, isPopoverActive, setIsPopoverActive, ...rest } = props;
const { setDoorData = null } = useNavigator();
const { setSnapshot: setDoorData } = useDoorState();
const handleMouseEnter = () =>
{
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { FaBars, FaMinus, FaPlus, FaTh, FaWindowMaximize, FaWindowRestore } from 'react-icons/fa';
import { LocalizeText, NavigatorSearchResultViewDisplayMode, SendMessageComposer } from '../../../../api';
import { AutoGrid, AutoGridProps, Column, Flex, Grid, LayoutSearchSavesView, Text } from '../../../../common';
import { useNavigator } from '../../../../hooks';
import { useNavigatorData } from '../../../../hooks';
import { NavigatorSearchResultItemView } from './NavigatorSearchResultItemView';
export interface NavigatorSearchResultViewProps extends AutoGridProps
@@ -19,7 +19,7 @@ export const NavigatorSearchResultView: FC<NavigatorSearchResultViewProps> = pro
const [ selectedRoomId, setSelectedRoomId ] = useState<number | null>(null);
const [ isPopoverActive, setIsPopoverActive ] = useState<boolean>(false);
const { topLevelContext = null } = useNavigator();
const { topLevelContext } = useNavigatorData();
const getResultTitle = () =>
{
@@ -2,16 +2,14 @@ import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { FaSearch } from 'react-icons/fa';
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
import { Button } from '../../../../common';
import { useNavigator } from '../../../../hooks';
import { useNavigatorActions, useNavigatorData } from '../../../../hooks';
export const NavigatorSearchView: FC<{
sendSearch: (searchValue: string, contextCode: string) => void;
}> = props =>
export const NavigatorSearchView: FC<{}> = props =>
{
const { sendSearch = null } = props;
const [ searchFilterIndex, setSearchFilterIndex ] = useState(0);
const [ searchValue, setSearchValue ] = useState('');
const { topLevelContext = null, searchResult = null } = useNavigator();
const { topLevelContext, searchResult } = useNavigatorData();
const { sendSearch } = useNavigatorActions();
const processSearch = () =>
{
@@ -2,7 +2,7 @@ import { UpdateRoomFilterMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useFilterWordsWidget, useNavigator } from '../../../../hooks';
import { useFilterWordsWidget, useNavigatorData } from '../../../../hooks';
import { NitroInput, classNames } from '../../../../layout';
export const RoomFilterWordsWidgetView: FC<{}> = props =>
@@ -11,7 +11,7 @@ export const RoomFilterWordsWidgetView: FC<{}> = props =>
const [ selectedWord, setSelectedWord ] = useState<string>('');
const [ isSelectingWord, setIsSelectingWord ] = useState<boolean>(false);
const { wordsFilter = [], isVisible = null, setWordsFilter, onClose = null } = useFilterWordsWidget();
const { navigatorData = null } = useNavigator();
const { navigatorData } = useNavigatorData();
const processAction = (isAddingWord: boolean) =>
{
@@ -4,7 +4,7 @@ import { classNames } from '../../../../layout';
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
import { Text } from '../../../../common';
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
import { useMessageEvent, useNavigatorData, useRoom } from '../../../../hooks';
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
export const RoomToolsWidgetView: FC<{}> = props =>
@@ -18,7 +18,7 @@ export const RoomToolsWidgetView: FC<{}> = props =>
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
const { navigatorData = null } = useNavigator();
const { navigatorData } = useNavigatorData();
const { roomSession = null } = useRoom();
// Subscribe to external plugin changes
+7 -1
View File
@@ -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';
@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useNavigatorUiStore } from './navigatorUiStore';
const INITIAL = {
isVisible: false,
isReady: false,
isCreatorOpen: false,
isRoomInfoOpen: false,
isRoomLinkOpen: false,
isOpenSavesSearches: false,
isLoading: false,
needsInit: true,
needsSearch: false
};
describe('useNavigatorUiStore', () =>
{
beforeEach(() =>
{
useNavigatorUiStore.setState(INITIAL);
});
it('exposes the documented defaults', () =>
{
const s = useNavigatorUiStore.getState();
expect(s.isVisible).toBe(false);
expect(s.isReady).toBe(false);
expect(s.isCreatorOpen).toBe(false);
expect(s.isRoomInfoOpen).toBe(false);
expect(s.isRoomLinkOpen).toBe(false);
expect(s.isOpenSavesSearches).toBe(false);
expect(s.isLoading).toBe(false);
expect(s.needsInit).toBe(true);
expect(s.needsSearch).toBe(false);
});
describe('show / hide / toggle', () =>
{
it('show() sets isVisible true and requests a search', () =>
{
useNavigatorUiStore.getState().show();
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
expect(useNavigatorUiStore.getState().needsSearch).toBe(true);
});
it('hide() sets isVisible false without touching needsSearch', () =>
{
useNavigatorUiStore.setState({ isVisible: true, needsSearch: false });
useNavigatorUiStore.getState().hide();
expect(useNavigatorUiStore.getState().isVisible).toBe(false);
expect(useNavigatorUiStore.getState().needsSearch).toBe(false);
});
it('toggle() flips visibility and requests a search on show', () =>
{
useNavigatorUiStore.getState().toggle();
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
expect(useNavigatorUiStore.getState().needsSearch).toBe(true);
useNavigatorUiStore.setState({ needsSearch: false });
useNavigatorUiStore.getState().toggle();
expect(useNavigatorUiStore.getState().isVisible).toBe(false);
expect(useNavigatorUiStore.getState().needsSearch).toBe(false);
});
});
describe('creator panel', () =>
{
it('openCreator() opens both visible and creator', () =>
{
useNavigatorUiStore.getState().openCreator();
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(true);
});
it('closeCreator() closes only the creator panel', () =>
{
useNavigatorUiStore.setState({ isVisible: true, isCreatorOpen: true });
useNavigatorUiStore.getState().closeCreator();
expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(false);
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
});
});
describe('roomInfo / roomLink / savesSearches', () =>
{
it('setRoomInfoOpen(true) and toggleRoomInfo flip the flag', () =>
{
useNavigatorUiStore.getState().setRoomInfoOpen(true);
expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(true);
useNavigatorUiStore.getState().toggleRoomInfo();
expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(false);
});
it('setRoomLinkOpen(true) and toggleRoomLink flip the flag', () =>
{
useNavigatorUiStore.getState().setRoomLinkOpen(true);
expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(true);
useNavigatorUiStore.getState().toggleRoomLink();
expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(false);
});
it('toggleSavesSearches() flips the sidebar flag', () =>
{
useNavigatorUiStore.getState().toggleSavesSearches();
expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(true);
useNavigatorUiStore.getState().toggleSavesSearches();
expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(false);
});
});
describe('lifecycle flags', () =>
{
it('setLoading(true) and setLoading(false) toggle isLoading', () =>
{
useNavigatorUiStore.getState().setLoading(true);
expect(useNavigatorUiStore.getState().isLoading).toBe(true);
useNavigatorUiStore.getState().setLoading(false);
expect(useNavigatorUiStore.getState().isLoading).toBe(false);
});
it('markReady() sets isReady true and is idempotent', () =>
{
useNavigatorUiStore.getState().markReady();
expect(useNavigatorUiStore.getState().isReady).toBe(true);
useNavigatorUiStore.getState().markReady();
expect(useNavigatorUiStore.getState().isReady).toBe(true);
});
it('markInitDone() flips needsInit to false', () =>
{
useNavigatorUiStore.getState().markInitDone();
expect(useNavigatorUiStore.getState().needsInit).toBe(false);
});
it('requestSearch() + consumeSearchRequest() are symmetric', () =>
{
useNavigatorUiStore.getState().requestSearch();
expect(useNavigatorUiStore.getState().needsSearch).toBe(true);
useNavigatorUiStore.getState().consumeSearchRequest();
expect(useNavigatorUiStore.getState().needsSearch).toBe(false);
});
});
});
+61
View File
@@ -0,0 +1,61 @@
import { createNitroStore } from '../../state/createNitroStore';
export type NavigatorUiState = {
isVisible: boolean;
isReady: boolean;
isCreatorOpen: boolean;
isRoomInfoOpen: boolean;
isRoomLinkOpen: boolean;
isOpenSavesSearches: boolean;
isLoading: boolean;
needsInit: boolean;
needsSearch: boolean;
};
export type NavigatorUiActions = {
show(): void;
hide(): void;
toggle(): void;
openCreator(): void;
closeCreator(): void;
setRoomInfoOpen(open: boolean): void;
toggleRoomInfo(): void;
setRoomLinkOpen(open: boolean): void;
toggleRoomLink(): void;
toggleSavesSearches(): void;
setLoading(loading: boolean): void;
markReady(): void;
markInitDone(): void;
requestSearch(): void;
consumeSearchRequest(): void;
};
export const useNavigatorUiStore = createNitroStore<NavigatorUiState & NavigatorUiActions>()((set) => ({
isVisible: false,
isReady: false,
isCreatorOpen: false,
isRoomInfoOpen: false,
isRoomLinkOpen: false,
isOpenSavesSearches: false,
isLoading: false,
needsInit: true,
needsSearch: false,
show: () => set({ isVisible: true, needsSearch: true }),
hide: () => set({ isVisible: false }),
toggle: () => set((s) => s.isVisible
? { isVisible: false }
: { isVisible: true, needsSearch: true }),
openCreator: () => set({ isVisible: true, isCreatorOpen: true }),
closeCreator: () => set({ isCreatorOpen: false }),
setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }),
toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })),
setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }),
toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })),
toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })),
setLoading: (loading) => set({ isLoading: loading }),
markReady: () => set({ isReady: true }),
markInitDone: () => set({ needsInit: false }),
requestSearch: () => set({ needsSearch: true }),
consumeSearchRequest: () => set({ needsSearch: false })
}));
-492
View File
@@ -1,492 +0,0 @@
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, DoorbellMessageEvent, FavouriteChangedEvent, FavouritesEvent, FlatAccessDeniedMessageEvent, FlatCreatedEvent, FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, NavigatorSearchesEvent, NavigatorSearchEvent, NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomDoorbellAcceptedEvent, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
import { useState } from 'react';
import { useBetween } from 'use-between';
import { CreateRoomSession, DoorStateType, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api';
import { useMessageEvent, useNitroEvent } from '../events';
import { useNotification } from '../notification';
const useNavigatorState = () =>
{
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
const [ doorData, setDoorData ] = useState<{ roomInfo: RoomDataParser, state: number }>({ roomInfo: null, state: DoorStateType.NONE });
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet>(null);
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
const [ navigatorData, setNavigatorData ] = useState<INavigatorData>({
settingsReceived: false,
homeRoomId: 0,
enteredGuestRoom: null,
currentRoomOwner: false,
currentRoomId: 0,
currentRoomIsStaffPick: false,
createdFlatId: 0,
avatarId: 0,
roomPicker: false,
eventMod: false,
currentRoomRating: 0,
canRate: true
});
const { simpleAlert = null } = useNotification();
useMessageEvent<FavouritesEvent>(FavouritesEvent, event =>
{
const parser = event.getParser();
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
setFavouriteRoomIds(favoriteIds);
});
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, 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>(RoomSettingsUpdatedEvent, event =>
{
const parser = event.getParser();
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false));
});
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, event =>
{
const parser = event.getParser();
if(parser.canCreate)
{
// show room event cvreate
return;
}
simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title'));
});
useMessageEvent<UserInfoEvent>(UserInfoEvent, event =>
{
SendMessageComposer(new GetUserFlatCatsMessageComposer());
SendMessageComposer(new GetUserEventCatsMessageComposer());
});
useMessageEvent<UserPermissionsEvent>(UserPermissionsEvent, event =>
{
const parser = event.getParser();
setNavigatorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.eventMod = (parser.securityLevel >= SecurityLevel.MODERATOR);
newValue.roomPicker = (parser.securityLevel >= SecurityLevel.COMMUNITY);
return newValue;
});
});
useMessageEvent<RoomForwardEvent>(RoomForwardEvent, event =>
{
const parser = event.getParser();
TryVisitRoom(parser.roomId);
});
useMessageEvent<RoomEntryInfoMessageEvent>(RoomEntryInfoMessageEvent, event =>
{
const parser = event.getParser();
setNavigatorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.enteredGuestRoom = null;
newValue.currentRoomOwner = parser.isOwner;
newValue.currentRoomId = parser.roomId;
return newValue;
});
// close room info
// close room settings
// close room filter
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false));
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]);
});
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, event =>
{
const parser = event.getParser();
if(parser.roomEnter)
{
setDoorData({ roomInfo: null, state: DoorStateType.NONE });
setNavigatorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.enteredGuestRoom = parser.data;
newValue.currentRoomIsStaffPick = parser.staffPick;
const isCreated = (newValue.createdFlatId === parser.data.roomId);
if(!isCreated && parser.data.displayRoomEntryAd)
{
if(GetConfigurationValue<boolean>('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd();
}
newValue.createdFlatId = 0;
if(newValue.enteredGuestRoom && (newValue.enteredGuestRoom.habboGroupId > 0))
{
// close event info
}
return newValue;
});
}
else if(parser.roomForward)
{
if((parser.data.ownerName !== GetSessionDataManager().userName) && !parser.isGroupMember)
{
switch(parser.data.doorMode)
{
case RoomDataParser.DOORBELL_STATE:
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.roomInfo = parser.data;
newValue.state = DoorStateType.START_DOORBELL;
return newValue;
});
return;
case RoomDataParser.PASSWORD_STATE:
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.roomInfo = parser.data;
newValue.state = DoorStateType.START_PASSWORD;
return newValue;
});
return;
}
}
if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return;
CreateRoomSession(parser.data.roomId);
}
else
{
setNavigatorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.enteredGuestRoom = parser.data;
newValue.currentRoomIsStaffPick = parser.staffPick;
return newValue;
});
}
});
useMessageEvent<RoomScoreEvent>(RoomScoreEvent, event =>
{
const parser = event.getParser();
setNavigatorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.currentRoomRating = parser.totalLikes;
newValue.canRate = parser.canLike;
return newValue;
});
});
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, event =>
{
const parser = event.getParser();
if(!parser.userName || (parser.userName.length === 0))
{
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_WAITING;
return newValue;
});
}
});
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, event =>
{
const parser = event.getParser();
if(!parser.userName || (parser.userName.length === 0))
{
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_ACCEPTED;
return newValue;
});
}
});
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, event =>
{
const parser = event.getParser();
if(!parser.userName || (parser.userName.length === 0))
{
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_NO_ANSWER;
return newValue;
});
}
});
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, event =>
{
const parser = event.getParser();
switch(parser.errorCode)
{
case -100002:
setDoorData(prevValue =>
{
const newValue = { ...prevValue };
newValue.state = DoorStateType.STATE_WRONG_PASSWORD;
return newValue;
});
return;
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;
}
});
useMessageEvent<NavigatorMetadataEvent>(NavigatorMetadataEvent, event =>
{
const parser = event.getParser();
setTopLevelContexts(parser.topLevelContexts);
setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null);
});
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
{
const parser = event.getParser();
setTopLevelContext(prevValue =>
{
let newValue = prevValue;
if(!newValue) newValue = ((topLevelContexts && topLevelContexts.length && topLevelContexts[0]) || null);
if(!newValue) return null;
if((parser.result.code !== newValue.code) && topLevelContexts && topLevelContexts.length)
{
for(const context of topLevelContexts)
{
if(context.code !== parser.result.code) continue;
newValue = context;
}
}
for(const context of topLevelContexts)
{
if(context.code !== parser.result.code) continue;
newValue = context;
}
return newValue;
});
setSearchResult(parser.result);
});
useMessageEvent<UserFlatCatsEvent>(UserFlatCatsEvent, event =>
{
const parser = event.getParser();
setCategories(parser.categories);
});
useMessageEvent<UserEventCatsEvent>(UserEventCatsEvent, event =>
{
const parser = event.getParser();
setEventCategories(parser.categories);
});
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, event =>
{
const parser = event.getParser();
CreateRoomSession(parser.roomId);
});
// When reconnection starts, reset settingsReceived so the login sequence's
// NavigatorHomeRoomEvent is treated as a fresh login. Without this, the
// prevSettingsReceived check blocks home room navigation after reconnection,
// leaving the user stuck on hotel view.
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () =>
{
setNavigatorData(prevValue => ({ ...prevValue, settingsReceived: false }));
});
useMessageEvent<NavigatorHomeRoomEvent>(NavigatorHomeRoomEvent, event =>
{
const parser = event.getParser();
let prevSettingsReceived = false;
setNavigatorData(prevValue =>
{
prevSettingsReceived = prevValue.settingsReceived;
const newValue = { ...prevValue };
newValue.homeRoomId = parser.homeRoomId;
newValue.settingsReceived = true;
return newValue;
});
if(prevSettingsReceived)
{
// refresh room info window
return;
}
// If a room session was already restored (from a network disconnect reload),
// skip the normal home room navigation to avoid overriding it.
if(GetRoomSessionManager().viewerSession) return;
let forwardType = -1;
let forwardId = -1;
if((GetConfigurationValue<string>('friend.id') !== undefined) && (parseInt(GetConfigurationValue<string>('friend.id')) > 0))
{
forwardType = 0;
SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue<string>('friend.id'))));
}
if((GetConfigurationValue<number>('forward.type') !== undefined) && (GetConfigurationValue<number>('forward.id') !== undefined))
{
forwardType = parseInt(GetConfigurationValue<string>('forward.type'));
forwardId = parseInt(GetConfigurationValue<string>('forward.id'));
}
if(forwardType === 2)
{
TryVisitRoom(forwardId);
}
else if((forwardType === -1) && (parser.roomIdToEnter > 0))
{
CreateLinkEvent('navigator/close');
if(parser.roomIdToEnter !== parser.homeRoomId)
{
CreateRoomSession(parser.roomIdToEnter);
}
else
{
CreateRoomSession(parser.homeRoomId);
}
}
});
useMessageEvent<RoomEnterErrorEvent>(RoomEnterErrorEvent, 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;
}
// During reconnection, don't navigate to desktop — the reconnection guard
// will handle retrying or cleaning up. Calling VisitDesktop here would
// remove the session from the map and send the user to hotel view.
if(GetRoomSessionManager().isReconnecting) return;
VisitDesktop();
});
useMessageEvent<NavigatorOpenRoomCreatorEvent>(NavigatorOpenRoomCreatorEvent, event => CreateLinkEvent('navigator/show'));
useMessageEvent<NavigatorSearchesEvent>(NavigatorSearchesEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
setNavigatorSearches(parser.searches);
});
return { categories, doorData, setDoorData, topLevelContext, topLevelContexts, searchResult, navigatorData, favouriteRoomIds, navigatorSearches };
};
export const useNavigator = () => useBetween(useNavigatorState);
@@ -0,0 +1,8 @@
import { useBetween } from 'use-between';
import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorActions = () =>
{
const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore);
return { sendSearch, reloadCurrentSearch };
};
+17
View File
@@ -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
};
};
@@ -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');
});
});
+348
View File
@@ -0,0 +1,348 @@
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 { useNotification } from '../notification';
import { useNavigatorUiStore } from './navigatorUiStore';
export const useNavigatorStore = () =>
{
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet>(null);
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
const [ navigatorData, setNavigatorData ] = useState<INavigatorData>({
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 = null } = useNotification();
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>(FavouritesEvent, useCallback(event =>
{
const parser = event.getParser();
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
setFavouriteRoomIds(favoriteIds);
}, []));
useMessageEvent<FavouriteChangedEvent>(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>(RoomSettingsUpdatedEvent, useCallback(event =>
{
const parser = event.getParser();
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false));
}, []));
useMessageEvent<CanCreateRoomEventEvent>(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>(UserInfoEvent, useCallback(event =>
{
SendMessageComposer(new GetUserFlatCatsMessageComposer());
SendMessageComposer(new GetUserEventCatsMessageComposer());
}, []));
useMessageEvent<UserPermissionsEvent>(UserPermissionsEvent, useCallback(event =>
{
const parser = event.getParser();
setNavigatorData(prev => ({
...prev,
eventMod: parser.securityLevel >= SecurityLevel.MODERATOR,
roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY
}));
}, []));
useMessageEvent<RoomForwardEvent>(RoomForwardEvent, useCallback(event =>
{
const parser = event.getParser();
TryVisitRoom(parser.roomId);
}, []));
useMessageEvent<RoomEntryInfoMessageEvent>(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>(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<boolean>('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>(RoomScoreEvent, useCallback(event =>
{
const parser = event.getParser();
setNavigatorData(prev => ({
...prev,
currentRoomRating: parser.totalLikes,
canRate: parser.canLike
}));
}, []));
useMessageEvent<GenericErrorEvent>(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>(NavigatorMetadataEvent, useCallback(event =>
{
const parser = event.getParser();
setTopLevelContexts(parser.topLevelContexts);
setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null);
}, []));
useMessageEvent<NavigatorSearchEvent>(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>(UserFlatCatsEvent, useCallback(event =>
{
const parser = event.getParser();
setCategories(parser.categories);
}, []));
useMessageEvent<UserEventCatsEvent>(UserEventCatsEvent, useCallback(event =>
{
const parser = event.getParser();
setEventCategories(parser.categories);
}, []));
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, useCallback(event =>
{
const parser = event.getParser();
CreateRoomSession(parser.roomId);
}, []));
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, useCallback(() =>
{
setNavigatorData(prev => ({ ...prev, settingsReceived: false }));
}, []));
useMessageEvent<NavigatorHomeRoomEvent>(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<string>('friend.id') !== undefined) && (parseInt(GetConfigurationValue<string>('friend.id')) > 0))
{
forwardType = 0;
SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue<string>('friend.id'))));
}
if((GetConfigurationValue<number>('forward.type') !== undefined) && (GetConfigurationValue<number>('forward.id') !== undefined))
{
forwardType = parseInt(GetConfigurationValue<string>('forward.type'));
forwardId = parseInt(GetConfigurationValue<string>('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>(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>(NavigatorOpenRoomCreatorEvent, useCallback(_event =>
{
CreateLinkEvent('navigator/show');
}, []));
useMessageEvent<NavigatorSearchesEvent>(NavigatorSearchesEvent, useCallback(event =>
{
const parser = event.getParser();
if(!parser) return;
setNavigatorSearches(parser.searches);
}, []));
return {
categories, eventCategories, favouriteRoomIds,
topLevelContext, topLevelContexts,
searchResult, navigatorSearches, navigatorData,
sendSearch, reloadCurrentSearch
};
};
@@ -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
};
};
@@ -0,0 +1,181 @@
import { act, cleanup, renderHook } from '@testing-library/react';
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser,
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DoorStateType } from '../../../api';
import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock';
import { useDoorState } from './useDoorState';
const makeParserlessEvent = (klass: any, parser: any) =>
{
const ev = new klass();
(ev as any).getParser = () => parser;
return ev;
};
describe('useDoorState', () =>
{
beforeEach(() =>
{
clearMockEventDispatcher();
const { result, unmount } = renderHook(() => useDoorState());
act(() => result.current.reset());
unmount();
});
afterEach(() =>
{
cleanup();
});
it('exposes the initial NONE snapshot', () =>
{
const { result } = renderHook(() => useDoorState());
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
expect(result.current.snapshot.roomInfo).toBeNull();
});
it('DoorbellMessageEvent with empty userName -> STATE_WAITING', () =>
{
const { result } = renderHook(() => useDoorState());
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
});
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
});
it('DoorbellMessageEvent with non-empty userName does NOT change state', () =>
{
const { result } = renderHook(() => useDoorState());
const before = result.current.snapshot.state;
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: 'someone' }));
});
expect(result.current.snapshot.state).toBe(before);
});
it('RoomDoorbellAcceptedEvent (empty userName) -> STATE_ACCEPTED', () =>
{
const { result } = renderHook(() => useDoorState());
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(RoomDoorbellAcceptedEvent, { userName: '' }));
});
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_ACCEPTED);
});
it('FlatAccessDeniedMessageEvent (empty userName) -> STATE_NO_ANSWER', () =>
{
const { result } = renderHook(() => useDoorState());
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(FlatAccessDeniedMessageEvent, { userName: '' }));
});
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_NO_ANSWER);
});
it('GenericErrorEvent -100002 -> STATE_WRONG_PASSWORD', () =>
{
const { result } = renderHook(() => useDoorState());
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: -100002 }));
});
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WRONG_PASSWORD);
});
it('GenericErrorEvent 4010 does NOT touch door state', () =>
{
const { result } = renderHook(() => useDoorState());
const before = result.current.snapshot.state;
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: 4010 }));
});
expect(result.current.snapshot.state).toBe(before);
});
it('GetGuestRoomResultEvent with roomForward + DOORBELL_STATE -> START_DOORBELL', () =>
{
const { result } = renderHook(() => useDoorState());
const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.DOORBELL_STATE };
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
roomForward: true,
isGroupMember: false,
data: fakeRoomData
}));
});
expect(result.current.snapshot.state).toBe(DoorStateType.START_DOORBELL);
expect(result.current.snapshot.roomInfo).toBe(fakeRoomData);
});
it('GetGuestRoomResultEvent with roomForward + PASSWORD_STATE -> START_PASSWORD', () =>
{
const { result } = renderHook(() => useDoorState());
const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.PASSWORD_STATE };
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
roomForward: true,
isGroupMember: false,
data: fakeRoomData
}));
});
expect(result.current.snapshot.state).toBe(DoorStateType.START_PASSWORD);
});
it('GetGuestRoomResultEvent with non-bell/password doorMode does NOT change state', () =>
{
const { result } = renderHook(() => useDoorState());
const before = result.current.snapshot.state;
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
roomForward: true,
isGroupMember: false,
data: { ownerName: 'other', doorMode: 99 }
}));
});
expect(result.current.snapshot.state).toBe(before);
});
it('GetGuestRoomResultEvent with roomEnter=true resets snapshot to NONE', () =>
{
const { result } = renderHook(() => useDoorState());
// First put the hook into a non-NONE state via doorbell
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
});
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
// Then roomEnter event should dismiss it
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
roomEnter: true,
roomForward: false,
data: {}
}));
});
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
expect(result.current.snapshot.roomInfo).toBeNull();
});
it('reset() returns snapshot to NONE', () =>
{
const { result } = renderHook(() => useDoorState());
act(() =>
{
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
});
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
act(() => result.current.reset());
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
expect(result.current.snapshot.roomInfo).toBeNull();
});
});
+82
View File
@@ -0,0 +1,82 @@
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
GenericErrorEvent, GetGuestRoomResultEvent,
GetSessionDataManager, RoomDataParser,
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { useBetween } from 'use-between';
import { DoorStateType } from '../../../api';
import { useMessageEvent } from '../../events';
export type DoorStateSnapshot = {
roomInfo: RoomDataParser | null;
state: number;
};
const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE };
const useDoorStateStore = () =>
{
const [ snapshot, setSnapshot ] = useState<DoorStateSnapshot>(INITIAL);
const handleDoorbell = useCallback((event: DoorbellMessageEvent) =>
{
const parser = event.getParser();
if(parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING }));
}, []);
const handleAccepted = useCallback((event: RoomDoorbellAcceptedEvent) =>
{
const parser = event.getParser();
if(parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED }));
}, []);
const handleDenied = useCallback((event: FlatAccessDeniedMessageEvent) =>
{
const parser = event.getParser();
if(parser.userName && parser.userName.length > 0) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER }));
}, []);
const handleGenericError = useCallback((event: GenericErrorEvent) =>
{
const parser = event.getParser();
if(parser.errorCode !== -100002) return;
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD }));
}, []);
const handleGuestRoom = useCallback((event: GetGuestRoomResultEvent) =>
{
const parser = event.getParser();
if(parser.roomEnter)
{
setSnapshot(INITIAL);
return;
}
if(!parser.roomForward) return;
if(parser.data.ownerName === GetSessionDataManager().userName) return;
if(parser.isGroupMember) return;
if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE)
{
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL });
return;
}
if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE)
{
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD });
}
}, []);
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, handleDoorbell);
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, handleAccepted);
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, handleDenied);
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, handleGenericError);
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, handleGuestRoom);
const reset = useCallback(() => setSnapshot(INITIAL), []);
return { snapshot, setSnapshot, reset };
};
export const useDoorState = () => useBetween(useDoorStateStore);
+190 -7
View File
@@ -43,8 +43,20 @@ export const NitroLogger = {
type Listener = (event: any) => void;
// NitroEvent listeners — registered via GetEventDispatcher() / useNitroEvent.
// Cleared by clearMockEventDispatcher() between test cases.
const listeners = new Map<string, Set<Listener>>();
// MessageEvent listeners — registered via GetCommunication().registerMessageEvent
// (i.e. useMessageEvent). NOT cleared by clearMockEventDispatcher() so that
// useBetween-based hooks (which register effects once and persist the
// singleton across tests) keep their subscriptions alive throughout the
// suite. State isolation between tests is maintained by the useBetween
// instance preserving INITIAL values across renders (each test's renderHook
// shares the same useBetween singleton — tests that check a specific
// post-dispatch state rely on the event changing it, not on a reset).
const msgListeners = new Map<string, Set<Listener>>();
export const mockEventDispatcher = {
addEventListener(type: string, handler: Listener)
{
@@ -64,18 +76,23 @@ export const mockEventDispatcher = {
},
dispatchEvent(event: { type: string })
{
// Fire NitroEvent listeners first, then MessageEvent listeners.
const bucket = listeners.get(event.type);
if(bucket) for(const handler of bucket) handler(event);
if(!bucket) return;
for(const handler of bucket) handler(event);
const msgBucket = msgListeners.get(event.type);
if(msgBucket) for(const handler of msgBucket) handler(event);
},
hasListeners(type: string)
{
return (listeners.get(type)?.size ?? 0) > 0;
return (listeners.get(type)?.size ?? 0) > 0 ||
(msgListeners.get(type)?.size ?? 0) > 0;
}
};
// Clears only the NitroEvent listener map (GetEventDispatcher / useNitroEvent
// registrations). MessageEvent listeners (useMessageEvent / GetCommunication)
// are intentionally preserved so useBetween-based hooks stay subscribed.
export const clearMockEventDispatcher = () =>
{
listeners.clear();
@@ -188,7 +205,117 @@ export class NitroSprite extends StubClass {}
export class NitroTexture extends StubClass {}
export class NitroSoundEvent extends StubClass {}
export class NitroEvent extends StubClass {}
export class MessageEvent extends StubClass {}
// MessageEvent — stores the handler so GetCommunication (below) can
// route dispatches through mockEventDispatcher. Each concrete subclass
// exposes a `.type` equal to its constructor name so dispatchEvent
// can match registered listeners.
export class MessageEvent
{
private _callBack: Function | null;
constructor(callBack?: Function)
{
this._callBack = callBack ?? null;
}
public get callBack(): Function | null { return this._callBack; }
// Each concrete subclass is identified by its class name.
public get type(): string { return this.constructor.name; }
// Concrete subclasses override this; the no-arg construction path used
// by makeParserlessEvent in tests leaves it returning null — tests
// override it with (ev as any).getParser = () => parser.
public getParser(): any { return null; }
}
// ---------------------------------------------------------------------------
// IMessageEvent-based event classes used by useDoorState
//
// The real renderer classes take a `callBack` constructor arg and store it
// in MessageEvent._callBack. The communication manager later calls
// `event.callBack(event)` when the matching packet arrives.
//
// In tests we construct them with NO args (makeParserlessEvent does
// `new klass()`) and override `getParser`. GetCommunication (below)
// registers `event.callBack` on mockEventDispatcher under `event.type`
// (the class name). When the test calls
// `mockEventDispatcher.dispatchEvent(ev)`, listeners for that class name
// fire, receiving `ev` — and the implementation reads `ev.getParser()`.
// ---------------------------------------------------------------------------
export class DoorbellMessageEvent extends MessageEvent {}
export class RoomDoorbellAcceptedEvent extends MessageEvent {}
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 {}
// ---------------------------------------------------------------------------
// Notification event classes — MessageEvent subclasses needed by
// useNotificationStore (called via useNotification() inside useNavigatorStore).
// The real renderer classes take a `callBack` constructor arg; the pattern
// here is the same as the Navigator event stubs above.
// ---------------------------------------------------------------------------
export class AchievementNotificationMessageEvent extends MessageEvent {}
export class ActivityPointNotificationMessageEvent extends MessageEvent {}
export class BadgeReceivedEvent extends MessageEvent {}
export class ClubGiftNotificationEvent extends MessageEvent {}
export class ClubGiftSelectedEvent extends MessageEvent {}
export class ConnectionErrorEvent extends MessageEvent {}
export class HabboBroadcastMessageEvent extends MessageEvent {}
export class HotelClosedAndOpensEvent extends MessageEvent {}
export class HotelClosesAndWillOpenAtEvent extends MessageEvent {}
export class HotelWillCloseInMinutesEvent extends MessageEvent {}
export class InfoFeedEnableMessageEvent extends MessageEvent {}
export class MaintenanceStatusMessageEvent extends MessageEvent {}
export class ModeratorCautionEvent extends MessageEvent {}
export class ModeratorMessageEvent extends MessageEvent {}
export class MOTDNotificationEvent extends MessageEvent {}
export class NotificationDialogMessageEvent extends MessageEvent {}
export class PetLevelNotificationEvent extends MessageEvent {}
export class PetReceivedMessageEvent extends MessageEvent {}
export class RespectReceivedEvent extends MessageEvent {}
export class RoomEnterEvent extends MessageEvent {}
export class SimpleAlertMessageEvent extends MessageEvent {}
export class UserBannedMessageEvent extends MessageEvent {}
export class WiredRewardResultMessageEvent extends MessageEvent
{
static readonly PRODUCT_DONATED_CODE = 7;
static readonly BADGE_DONATED_CODE = 8;
}
// RoomEnterEffect — used by useNotificationStore to check if the room-enter
// animation is still running before showing the mod disclaimer bubble.
export const RoomEnterEffect = {
isRunning: () => false,
totalRunningTime: 0
};
export class RoomEngineObjectEvent extends StubClass {}
export class CreateLinkEvent extends StubClass {}
export class EventDispatcher extends StubClass {}
@@ -196,9 +323,35 @@ export class AdvancedMap extends StubClass {}
export class AvatarFigureContainer extends StubClass {}
export class Vector3d extends StubClass {}
export class ObjectDataFactory extends StubClass {}
export class RoomDataParser extends StubClass {}
// RoomDataParser — real static constants needed by useDoorState and its tests.
export class RoomDataParser
{
static readonly DOORBELL_STATE = 1;
static readonly PASSWORD_STATE = 2;
}
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 {}
@@ -220,6 +373,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 {}
@@ -351,7 +508,33 @@ const stubManager = () =>
export const GetAssetManager = vi.fn(stubManager);
export const GetAvatarRenderManager = vi.fn(stubManager);
export const GetCommunication = vi.fn(stubManager);
// GetCommunication — routes IMessageEvent registration through the
// msgListeners map (separate from the NitroEvent listeners map) so that
// clearMockEventDispatcher() does NOT wipe these subscriptions. This
// keeps useBetween-based hooks (like useDoorState) subscribed across
// test cases without needing to recreate the useBetween singleton.
//
// A WeakMap stores the wrapper fn keyed by the MessageEvent instance so
// that removeMessageEvent can remove the exact listener added by
// registerMessageEvent.
const _msgEventWrappers = new WeakMap<MessageEvent, (ev: any) => void>();
export const GetCommunication = vi.fn(() => ({
registerMessageEvent(event: MessageEvent)
{
if(!event.callBack) return;
const wrapper = (ev: any) => event.callBack!(ev);
_msgEventWrappers.set(event, wrapper);
let bucket = msgListeners.get(event.type);
if(!bucket) { bucket = new Set(); msgListeners.set(event.type, bucket); }
bucket.add(wrapper);
},
removeMessageEvent(event: MessageEvent)
{
const wrapper = _msgEventWrappers.get(event);
if(wrapper) msgListeners.get(event.type)?.delete(wrapper);
}
}));
export const GetConfiguration = vi.fn(stubManager);
export const GetLocalizationManager = vi.fn(stubManager);
export const GetRoomEngine = vi.fn(stubManager);