Merge remote-tracking branch 'origin/Dev' into feat/messenger-groups-receipts

# Conflicts:
#	public/configuration/UITexts.example
#	src/css/friends/FriendsView.css
This commit is contained in:
simoleo89
2026-06-02 21:52:59 +02:00
225 changed files with 11432 additions and 1872 deletions
+1
View File
@@ -1,4 +1,5 @@
export * from './useCatalog';
export * from './useCatalogClassicStyle';
export * from './useCatalogFavorites';
export * from './useCatalogPlaceMultipleItems';
export * from './useCatalogSkipPurchaseConfirmation';
@@ -0,0 +1,14 @@
import { useBetween } from 'use-between';
import { GetConfigurationValue, LocalStorageKeys } from '../../api';
import { useLocalStorage } from '../useLocalStorage';
// Per-user toggle for the catalog visual style.
// - true => classic (old) catalog look
// - false => modern (rebuilt) catalog look
// The default for users who never touched the toggle comes from the global
// `catalog.classic.style` flag in ui-config.json, so an admin can flip the
// default for everyone (true = classic for all, false = modern for all)
// while still letting each user override it from the settings panel.
const useCatalogClassicStyleState = () => useLocalStorage<boolean>(LocalStorageKeys.CATALOG_CLASSIC_STYLE, GetConfigurationValue<boolean>('catalog.classic.style', false));
export const useCatalogClassicStyle = () => useBetween(useCatalogClassicStyleState);
@@ -0,0 +1,95 @@
import { IWheelAdminPrize, IWheelAdminPrizeEdit, IWheelPrize, IWheelRecentWin, WheelAdminGetPrizesComposer, WheelAdminPrizesEvent, WheelAdminSavePrizesComposer, WheelBuySpinComposer, WheelDataEvent, WheelOpenComposer, WheelRecentWinsEvent, WheelResultEvent, WheelSpinComposer } from '@nitrots/nitro-renderer';
import { useCallback, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
// Fortune wheel state + actions. Shared via useBetween so the event listeners
// register once regardless of how many components read it.
const useFortuneWheelState = () =>
{
const [ freeSpins, setFreeSpins ] = useState(0);
const [ extraSpins, setExtraSpins ] = useState(0);
const [ spinCost, setSpinCost ] = useState(0);
const [ spinCostType, setSpinCostType ] = useState(-1);
const [ prizes, setPrizes ] = useState<IWheelPrize[]>([]);
const [ recentWins, setRecentWins ] = useState<IWheelRecentWin[]>([]);
const [ pendingPrizeId, setPendingPrizeId ] = useState<number>(-1);
const [ isSpinning, setIsSpinning ] = useState(false);
const [ adminPrizes, setAdminPrizes ] = useState<IWheelAdminPrize[]>([]);
// While the wheel is animating we hold back the recent-wins refresh: the
// server pushes the updated list (which already contains the just-won
// prize) the instant it answers the spin, ~5s before the wheel actually
// stops. Showing it immediately would spoil the result in the winners
// panel. We buffer it here and flush it in finishSpin (called when the
// reveal fires).
const spinAnimatingRef = useRef(false);
const bufferedWinsRef = useRef<IWheelRecentWin[] | null>(null);
useMessageEvent<WheelAdminPrizesEvent>(WheelAdminPrizesEvent, event =>
{
setAdminPrizes(event.getParser().prizes);
});
useMessageEvent<WheelDataEvent>(WheelDataEvent, event =>
{
const parser = event.getParser();
setFreeSpins(parser.freeSpins);
setExtraSpins(parser.extraSpins);
setSpinCost(parser.spinCost);
setSpinCostType(parser.spinCostType);
setPrizes(parser.prizes);
});
useMessageEvent<WheelResultEvent>(WheelResultEvent, event =>
{
// Set synchronously before the recent-wins packet (sent right after by
// the server) is processed, so its handler knows a spin is animating.
spinAnimatingRef.current = true;
setPendingPrizeId(event.getParser().prizeId);
setIsSpinning(true);
});
useMessageEvent<WheelRecentWinsEvent>(WheelRecentWinsEvent, event =>
{
const wins = event.getParser().wins;
// Mid-spin: stash the refreshed list and reveal it once the wheel
// stops. Otherwise (initial open, other refreshes) apply immediately.
if(spinAnimatingRef.current) bufferedWinsRef.current = wins;
else setRecentWins(wins);
});
const open = useCallback(() => SendMessageComposer(new WheelOpenComposer()), []);
const spin = useCallback(() =>
{
setIsSpinning(prev =>
{
if(!prev) SendMessageComposer(new WheelSpinComposer());
return prev;
});
}, []);
const buySpin = useCallback(() => SendMessageComposer(new WheelBuySpinComposer()), []);
const finishSpin = useCallback(() =>
{
spinAnimatingRef.current = false;
setIsSpinning(false);
setPendingPrizeId(-1);
// Flush the winners list that arrived during the spin, now that the
// reveal has happened.
if(bufferedWinsRef.current)
{
setRecentWins(bufferedWinsRef.current);
bufferedWinsRef.current = null;
}
}, []);
const loadAdminPrizes = useCallback(() => SendMessageComposer(new WheelAdminGetPrizesComposer()), []);
const saveAdminPrizes = useCallback((prizes: IWheelAdminPrizeEdit[]) => SendMessageComposer(new WheelAdminSavePrizesComposer(prizes)), []);
return { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin, adminPrizes, loadAdminPrizes, saveAdminPrizes };
};
export const useFortuneWheel = () => useBetween(useFortuneWheelState);
+5
View File
@@ -4,6 +4,7 @@ export * from './camera';
export * from './catalog';
export * from './chat-history';
export * from './events';
export * from './fortune-wheel/useFortuneWheel';
export * from './friends';
export * from './game-center';
export * from './groups';
@@ -14,12 +15,16 @@ export * from './mod-tools';
export * from './navigator';
export * from './notification';
export * from './purse';
export * from './radio/useRadio';
export * from './rare-values/useRareValues';
export * from './rooms';
export * from './rooms/engine';
export * from './rooms/promotes';
export * from './rooms/widgets';
export * from './rooms/widgets/furniture';
export * from './session';
export * from './soundboard/useSoundboard';
export * from './theme';
export * from './translation';
export * from './useLocalStorage';
export * from './useSharedVisibility';
+8 -1
View File
@@ -1 +1,8 @@
export * from './useNavigator';
export { useNavigatorData } from './useNavigatorData';
export { useNavigatorFavourite } from './useNavigatorFavourite';
export { useNavigatorSearch } from './useNavigatorSearch';
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,46 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
const reset = () => useNavigatorFavouritesStore.setState({ ids: new Set<number>() });
describe('navigatorFavouritesStore', () =>
{
beforeEach(reset);
it('setAll replaces membership and coerces ids to numbers', () =>
{
useNavigatorFavouritesStore.getState().setAll([ '1', 2, '3' ] as any);
const { ids } = useNavigatorFavouritesStore.getState();
expect(ids.has(1)).toBe(true);
expect(ids.has(2)).toBe(true);
expect(ids.has(3)).toBe(true);
expect(ids.size).toBe(3);
});
it('apply(true) adds and apply(false) removes', () =>
{
const { apply } = useNavigatorFavouritesStore.getState();
apply(7, true);
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(true);
apply(7, false);
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(false);
});
it('apply returns the same state reference when nothing changes (no needless re-render)', () =>
{
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
const before = useNavigatorFavouritesStore.getState().ids;
useNavigatorFavouritesStore.getState().apply(5, true); // already present
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
useNavigatorFavouritesStore.getState().apply(99, false); // already absent
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
});
it('apply creates a new Set reference when membership actually changes', () =>
{
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
const before = useNavigatorFavouritesStore.getState().ids;
useNavigatorFavouritesStore.getState().apply(6, true);
expect(useNavigatorFavouritesStore.getState().ids).not.toBe(before);
});
});
@@ -0,0 +1,25 @@
import { createNitroStore } from '../../state/createNitroStore';
export type NavigatorFavouritesState = {
ids: Set<number>;
};
export type NavigatorFavouritesActions = {
setAll(roomIds: number[]): void;
apply(roomId: number, added: boolean): void;
};
export const useNavigatorFavouritesStore = createNitroStore<NavigatorFavouritesState & NavigatorFavouritesActions>()((set) => ({
ids: new Set<number>(),
setAll: (roomIds) => set({ ids: new Set(roomIds.map(Number)) }),
apply: (roomId, added) => set((s) =>
{
const id = Number(roomId);
if(added ? s.ids.has(id) : !s.ids.has(id)) return s;
const ids = new Set(s.ids);
if(added) ids.add(id);
else ids.delete(id);
return { ids };
})
}));
@@ -0,0 +1,175 @@
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,
currentTabCode: '',
currentFilter: ''
};
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);
expect(s.currentTabCode).toBe('');
expect(s.currentFilter).toBe('');
});
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);
});
});
describe('tab + filter', () =>
{
it("setTab('public') sets currentTabCode and clears currentFilter", () =>
{
useNavigatorUiStore.setState({ currentTabCode: 'events', currentFilter: 'habbo' });
useNavigatorUiStore.getState().setTab('public');
expect(useNavigatorUiStore.getState().currentTabCode).toBe('public');
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
});
it("setFilter('cocco') sets currentFilter without touching tab", () =>
{
useNavigatorUiStore.getState().setTab('events');
useNavigatorUiStore.getState().setFilter('cocco');
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
});
it('setTab on same code still resets currentFilter', () =>
{
useNavigatorUiStore.setState({ currentTabCode: 'public', currentFilter: 'test' });
useNavigatorUiStore.getState().setTab('public');
expect(useNavigatorUiStore.getState().currentTabCode).toBe('public');
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
});
});
});
+69
View File
@@ -0,0 +1,69 @@
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;
currentTabCode: string;
currentFilter: string;
};
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;
setTab(code: string): void;
setFilter(value: string): 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,
currentTabCode: '',
currentFilter: '',
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 }),
setTab: (code) => set({ currentTabCode: code, currentFilter: '' }),
setFilter: (value) => set({ currentFilter: value })
}));
-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);
+17
View File
@@ -0,0 +1,17 @@
import { useBetween } from 'use-between';
import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorData = () =>
{
const {
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
} = useBetween(useNavigatorStore);
return {
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
};
};
@@ -0,0 +1,12 @@
import { useCallback } from 'react';
import { ToggleFavoriteRoom } from '../../api';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
export const useNavigatorFavourite = (roomId: number) =>
{
const isFavourite = useNavigatorFavouritesStore((s) => s.ids.has(Number(roomId)));
const toggle = useCallback(() => ToggleFavoriteRoom(Number(roomId), isFavourite), [ roomId, isFavourite ]);
return { isFavourite, toggle };
};
@@ -0,0 +1,213 @@
/* @vitest-environment jsdom */
import { NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockEventDispatcher } from '../../nitro-renderer.mock';
import { useNavigatorUiStore } from './navigatorUiStore';
import { useNavigatorSearch } from './useNavigatorSearch';
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
// NOTE: useNavigatorSearch uses useMessageEvent + useState (NOT useNitroQuery).
// The one-shot query pattern was reverted upstream (05d71dd1) because it left
// the UI blank when the listener never matched. These tests exercise the
// event-driven implementation directly — no QueryClient scaffolding.
/** Build a fake NavigatorSearchEvent whose getParser() returns a result with `code`. */
const makeSearchEvent = (code: string) =>
{
// Cast constructors as `any` so tsgo doesn't check required args against
// the real renderer SDK types (the mock stubs have no required args).
const result = new (NavigatorSearchResultSet as any)() as any;
result.code = code;
result.data = '';
result.results = [];
const ev = new (NavigatorSearchEvent as any)() as any;
ev.getParser = () => ({ result });
return ev;
};
const INITIAL_UI = {
isVisible: false,
isReady: false,
isCreatorOpen: false,
isRoomInfoOpen: false,
isRoomLinkOpen: false,
isOpenSavesSearches: false,
isLoading: false,
needsInit: true,
needsSearch: false,
currentTabCode: '',
currentFilter: ''
};
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('useNavigatorSearch', () =>
{
beforeEach(() =>
{
useNavigatorUiStore.setState(INITIAL_UI);
});
afterEach(() =>
{
cleanup();
vi.clearAllMocks();
});
it('1. with empty tabCode no fetch starts (the request effect is gated)', () =>
{
const { result } = renderHook(() => useNavigatorSearch());
// No tab selected → the request effect short-circuits, nothing fetches.
expect(result.current.isFetching).toBe(false);
expect(result.current.searchResult).toBeNull();
});
it('2. after setTab("public"), the hook starts fetching and a matching event resolves it', async () =>
{
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
expect((result.current.searchResult as any).code).toBe('public');
expect(result.current.isFetching).toBe(false);
});
it('3. after setFilter("cocco"), a new fetch fires and a matching event resolves it', async () =>
{
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
act(() =>
{
useNavigatorUiStore.getState().setFilter('cocco');
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect((result.current.searchResult as any).code).toBe('public');
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
});
it('4. after setTab("events"), currentFilter resets to "" and a new fetch fires for events', async () =>
{
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
useNavigatorUiStore.getState().setFilter('some-filter');
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
act(() =>
{
useNavigatorUiStore.getState().setTab('events');
});
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any);
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect((result.current.searchResult as any).code).toBe('events');
});
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
{
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() =>
{
expect(result.current.searchResult).not.toBeNull();
expect((result.current.searchResult as any).code).toBe('public');
});
});
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
{
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any);
});
// The wrong-tab event is filtered out by the accept guard.
expect(result.current.searchResult).toBeNull();
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
expect((result.current.searchResult as any).code).toBe('public');
});
});
+69
View File
@@ -0,0 +1,69 @@
import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useNavigatorUiStore } from './navigatorUiStore';
/**
* Navigator search hook.
*
* Fires NavigatorSearchComposer(tabCode, filter) whenever the active tab
* or filter changes (skipped when tabCode is '' — initial state, before
* metadata arrives). Holds the latest NavigatorSearchResultSet that
* matches the active tab.
*
* The TanStack Query variant (see useNitroQuery) was tried earlier but
* its one-shot listener doesn't always reach NavigatorSearchEvent in
* production builds with older renderer SDKs; the persistent
* useMessageEvent listener used here matches the rest of the codebase
* and reliably catches every server push.
*/
export const useNavigatorSearch = () =>
{
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
const filter = useNavigatorUiStore(s => s.currentFilter);
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet | null>(null);
const [ isFetching, setIsFetching ] = useState(false);
useEffect(() =>
{
if(!tabCode) return;
setIsFetching(true);
SendMessageComposer(new NavigatorSearchComposer(tabCode, filter));
}, [ tabCode, filter ]);
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
{
const result = event.getParser()?.result;
if(!result) return;
// No active tab → the search query is disabled, ignore any event.
// Otherwise only accept the event whose code matches the active tab.
if(!tabCode || (result.code !== tabCode)) return;
setSearchResult(result);
setIsFetching(false);
});
// A newly created room refetches the current search.
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, () =>
{
if(!tabCode) return;
setIsFetching(true);
SendMessageComposer(new NavigatorSearchComposer(tabCode, filter));
});
return {
searchResult,
isFetching,
refetch: () =>
{
if(!tabCode) return;
setIsFetching(true);
SendMessageComposer(new NavigatorSearchComposer(tabCode, filter));
}
};
};
@@ -0,0 +1,27 @@
import { renderHook } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { 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',
'navigatorData', 'navigatorSearches',
'topLevelContext', 'topLevelContexts'
].sort());
});
it('useNavigatorUiState returns the 11 documented flags', () =>
{
const { result } = renderHook(() => useNavigatorUiState());
expect(Object.keys(result.current).sort()).toEqual([
'currentFilter', 'currentTabCode',
'isCreatorOpen', 'isLoading', 'isOpenSavesSearches',
'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible',
'needsInit', 'needsSearch'
].sort());
});
});
+279
View File
@@ -0,0 +1,279 @@
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent,
FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent,
FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer,
GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager,
GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer,
HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser,
NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent,
NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch,
NavigatorSearchesEvent,
NavigatorTopLevelContext, NitroEventType,
RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent,
RoomForwardEvent, RoomScoreEvent,
SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent,
UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
import { useCallback, useState } from 'react';
import { CreateRoomSession, GetConfigurationValue, INavigatorData,
LocalizeText, NotificationAlertType, SendMessageComposer,
TryVisitRoom, VisitDesktop } from '../../api';
import { useMessageEvent, useNitroEvent } from '../events';
import { useNotification } from '../notification';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
import { useNavigatorUiStore } from './navigatorUiStore';
export const useNavigatorStore = () =>
{
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(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, useCallback(event =>
{
const parser = event.getParser();
useNavigatorFavouritesStore.getState().setAll(parser.favoriteRoomIds || []);
}, []));
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
{
const parser = event.getParser();
useNavigatorFavouritesStore.getState().apply(parser.flatId, !!parser.added);
}, []));
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);
// Seed the query's tab code so useNavigatorSearch activates immediately
useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '');
}, []));
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,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
};
};
@@ -0,0 +1,21 @@
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);
const currentTabCode = useNavigatorUiStore(s => s.currentTabCode);
const currentFilter = useNavigatorUiStore(s => s.currentFilter);
return {
isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen,
isOpenSavesSearches, isLoading, needsInit, needsSearch,
currentTabCode, currentFilter
};
};
+149
View File
@@ -0,0 +1,149 @@
import { loadGamedata } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { GetConfigurationValue } from '../../api';
export type RadioStation = {
id: string;
name: string;
genre?: string;
url: string;
logo?: string;
};
// Hotel radio: a list of streaming URLs played client-side with HTML5 Audio.
// The station list comes from a JSON5 config file (loadGamedata accepts plain
// JSON and JSON5). Shared via useBetween so playback is a single instance no
// matter how many components read it.
const useRadioState = () =>
{
const [ stations, setStations ] = useState<RadioStation[]>([]);
const [ currentId, setCurrentId ] = useState<string | null>(null);
const [ isPlaying, setIsPlaying ] = useState(false);
const [ loadError, setLoadError ] = useState<string | null>(null);
const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive
const audioRef = useRef<HTMLAudioElement | null>(null);
const loadStartedRef = useRef(false);
const autoStartedRef = useRef(false);
useEffect(() =>
{
if(loadStartedRef.current) return;
loadStartedRef.current = true;
const url = GetConfigurationValue<string>('radio.url')
|| GetConfigurationValue<string>('radio.stations.url')
|| 'configuration/radio-stations.json5';
(async () =>
{
try
{
const json = await loadGamedata<{ stations?: RadioStation[] }>(url);
const list = Array.isArray(json?.stations)
? json.stations.filter(s => s && s.id && s.url)
: [];
setStations(list);
}
catch(error)
{
setLoadError(String((error as Error)?.message ?? error));
}
})();
}, []);
// Tear down the stream when the hook instance goes away.
useEffect(() => () =>
{
if(audioRef.current)
{
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
}, []);
const stop = useCallback(() =>
{
if(audioRef.current)
{
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
setIsPlaying(false);
setCurrentId(null);
}, []);
// Browsers block audio that starts without a user gesture (autoplay policy),
// so the startup autostart may be refused. When that happens, resume on the
// very first click / keypress anywhere.
const armResumeOnGesture = useCallback(() =>
{
const resume = () =>
{
window.removeEventListener('pointerdown', resume);
window.removeEventListener('keydown', resume);
if(audioRef.current) void audioRef.current.play().then(() => setIsPlaying(true)).catch(() => {});
};
window.addEventListener('pointerdown', resume, { once: true });
window.addEventListener('keydown', resume, { once: true });
}, []);
const play = useCallback((station: RadioStation) =>
{
if(!station?.url) return;
if(audioRef.current)
{
audioRef.current.pause();
audioRef.current.src = '';
audioRef.current = null;
}
try
{
const audio = new Audio(station.url);
audio.volume = volume;
audioRef.current = audio;
setCurrentId(station.id);
void audio.play().then(() => setIsPlaying(true)).catch(() =>
{
// Likely autoplay-blocked — keep the station selected and resume
// on the first user interaction instead of dropping it.
setIsPlaying(false);
armResumeOnGesture();
});
}
catch
{
setIsPlaying(false);
setCurrentId(null);
}
}, [ volume, armResumeOnGesture ]);
// Autostart the first station once on client load (quiet, see initial volume).
useEffect(() =>
{
if(autoStartedRef.current || !stations.length) return;
autoStartedRef.current = true;
play(stations[0]);
}, [ stations, play ]);
const toggle = useCallback((station: RadioStation) =>
{
if(currentId === station.id) stop();
else play(station);
}, [ currentId, play, stop ]);
const setVolume = useCallback((value: number) =>
{
const v = Math.max(0, Math.min(1, value));
setVolumeState(v);
if(audioRef.current) audioRef.current.volume = v;
}, []);
return { stations, currentId, isPlaying, volume, loadError, play, stop, toggle, setVolume };
};
export const useRadio = () => useBetween(useRadioState);
+31
View File
@@ -0,0 +1,31 @@
import { IRareValue, RareValuesEvent, RequestRareValuesComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
// spriteId -> catalog value, fetched once from the server (RareValuesComposer).
// Shared across all consumers via useBetween so the request fires a single time.
// Read by both the furni infostand and the toolbar "Valore Rari" panel.
const useRareValuesState = () =>
{
const [ values, setValues ] = useState<Map<number, IRareValue>>(() => new Map());
const [ loaded, setLoaded ] = useState(false);
useMessageEvent<RareValuesEvent>(RareValuesEvent, event =>
{
setValues(event.getParser().values);
setLoaded(true);
});
useEffect(() =>
{
SendMessageComposer(new RequestRareValuesComposer());
}, []);
const getValue = useCallback((spriteId: number): IRareValue => (values.get(spriteId) ?? null), [ values ]);
return { values, loaded, getValue };
};
export const useRareValues = () => useBetween(useRareValuesState);
@@ -1,39 +1,41 @@
import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { CommandDefinition } from '../../../api';
import { CommandDefinition, LocalizeText } from '../../../api';
import { createNitroStore } from '../../../state/createNitroStore';
import { useMessageEvent } from '../../events';
import { getChatCommandQuery, getRankedCommandSuggestions } from './useChatCommandSelector.helpers';
const MAX_VISIBLE_COMMANDS = 8;
// Client-only commands are static; safe to keep at module scope.
const CLIENT_COMMANDS: CommandDefinition[] = [
// Effetti stanza
{ key: 'shake', description: 'Scuoti la stanza' },
{ key: 'rotate', description: 'Ruota la stanza' },
{ key: 'zoom', description: 'Zoom stanza' },
{ key: 'flip', description: 'Reset zoom' },
{ key: 'iddqd', description: 'Reset zoom' },
{ key: 'screenshot', description: 'Screenshot stanza' },
{ key: 'togglefps', description: 'Toggle FPS' },
// Espressioni
{ key: 'd', description: 'Ridi (VIP)' },
{ key: 'kiss', description: 'Manda un bacio (VIP)' },
{ key: 'jump', description: 'Salta (VIP)' },
{ key: 'idle', description: 'Vai in idle' },
{ key: 'sign', description: 'Mostra cartello' },
// Gestione stanza
{ key: 'furni', description: 'Furni chooser' },
{ key: 'chooser', description: 'User chooser' },
{ key: 'floor', description: 'Floor editor' },
{ key: 'bcfloor', description: 'Floor editor' },
{ key: 'pickall', description: 'Raccogli tutti i furni' },
{ key: 'ejectall', description: 'Espelli tutti i furni' },
{ key: 'settings', description: 'Impostazioni stanza' },
// Client-only commands are static; safe to keep at module scope. The
// `descriptionKey` is a LocalizeText slot resolved at merge time so
// hotels in different locales see the right language.
const CLIENT_COMMANDS: { key: string; descriptionKey: string }[] = [
// Room effects
{ key: 'shake', descriptionKey: 'chatcmd.client.shake' },
{ key: 'rotate', descriptionKey: 'chatcmd.client.rotate' },
{ key: 'zoom', descriptionKey: 'chatcmd.client.zoom' },
{ key: 'flip', descriptionKey: 'chatcmd.client.flip' },
{ key: 'iddqd', descriptionKey: 'chatcmd.client.iddqd' },
{ key: 'screenshot', descriptionKey: 'chatcmd.client.screenshot' },
{ key: 'togglefps', descriptionKey: 'chatcmd.client.togglefps' },
// Expressions
{ key: 'd', descriptionKey: 'chatcmd.client.laugh' },
{ key: 'kiss', descriptionKey: 'chatcmd.client.kiss' },
{ key: 'jump', descriptionKey: 'chatcmd.client.jump' },
{ key: 'idle', descriptionKey: 'chatcmd.client.idle' },
{ key: 'sign', descriptionKey: 'chatcmd.client.sign' },
// Room management
{ key: 'furni', descriptionKey: 'chatcmd.client.furni' },
{ key: 'chooser', descriptionKey: 'chatcmd.client.chooser' },
{ key: 'floor', descriptionKey: 'chatcmd.client.floor' },
{ key: 'bcfloor', descriptionKey: 'chatcmd.client.floor' },
{ key: 'pickall', descriptionKey: 'chatcmd.client.pickall' },
{ key: 'ejectall', descriptionKey: 'chatcmd.client.ejectall' },
{ key: 'settings', descriptionKey: 'chatcmd.client.settings' },
// Info
{ key: 'client', description: 'Info client' },
{ key: 'nitro', description: 'Info client' },
{ key: 'client', descriptionKey: 'chatcmd.client.info' },
{ key: 'nitro', descriptionKey: 'chatcmd.client.info' },
];
/**
@@ -113,11 +115,12 @@ export const useChatCommandSelector = (chatValue: string) =>
const allCommands = useMemo(() =>
{
const merged = [ ...serverCommands ];
const merged: CommandDefinition[] = [ ...serverCommands ];
for(const clientCmd of CLIENT_COMMANDS)
{
if(!merged.some(cmd => cmd.key === clientCmd.key)) merged.push(clientCmd);
if(merged.some(cmd => cmd.key === clientCmd.key)) continue;
merged.push({ key: clientCmd.key, description: LocalizeText(clientCmd.descriptionKey) });
}
return merged.sort((a, b) => a.key.localeCompare(b.key));
@@ -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);
+28 -48
View File
@@ -1,44 +1,18 @@
import { GetRoomEngine, GetRoomMessageHandler } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { serializeTilemap } from '../../../components/floorplan-editor/state/encoding';
import { parseTilemap, serializeTilemap } from '../../../components/floorplan-editor/state/encoding';
import { FloorplanState } from '../../../components/floorplan-editor/state/types';
import { useActiveRoomSessionSnapshot } from '../../session/useSessionSnapshots';
/**
* Client-side live preview for the floor-plan editor.
*
* Every tile / door / thickness / wallHeight change in the editor
* is applied IMMEDIATELY to the 3D room behind the editor card
* via the renderer's local `RoomMessageHandler.applyFloorModelLocally`
* (added in the renderer's `feat/floorplan-live-preview` branch).
* Nothing is sent to the server until the user explicitly clicks
* Save — at that point `FloorplanEditorView` fires the
* `UpdateFloorPropertiesMessageComposer` directly.
*
* Closing the editor without saving leaves the live preview
* in place visually. To restore the pre-edit room, call `revert`
* — it re-applies the baseline payload locally. The next
* `FloorHeightMapEvent` from the server (e.g. on room re-enter)
* also wins and overwrites whatever preview is in place.
*
* Thickness changes additionally call
* `RoomEngine.updateRoomInstancePlaneThickness` for zero-latency
* wall/floor depth feedback (the full geometry rebuild that
* `applyFloorModelLocally` performs already reflects the new
* thickness in its plane data, but the dedicated thickness
* setter is cheaper and updates instantly as a slider is dragged).
*/
const normalizeTilemap = (raw: string): string => serializeTilemap(parseTilemap(raw));
export type LivePreviewPayload = {
/** Newline-or-CR-separated tilemap (the renderer parser accepts \r). */
tilemap: string;
doorX: number;
doorY: number;
doorDir: number;
/** Editor-space (0..3). */
thicknessWall: number;
thicknessFloor: number;
/** Editor-space (1..N). Server space is `wallHeight - 1`. */
wallHeight: number;
};
@@ -48,17 +22,8 @@ export type UseFloorplanLiveSyncOptions = {
};
export type UseFloorplanLiveSyncApi = {
/**
* Mark a payload as "currently shown in the room" so subsequent
* state diffs are computed against it. Editors call this on
* every server-driven snapshot push (FloorHeightMapEvent,
* RoomVisualizationSettingsEvent, …).
*/
setBaseline: (payload: LivePreviewPayload) => void;
/**
* Restore the in-room preview to the recorded baseline.
* Use when the user closes the editor without saving.
*/
mergeBaseline: (partial: Partial<LivePreviewPayload>) => void;
revert: () => void;
};
@@ -122,8 +87,6 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo
const baselineRef = useRef<LivePreviewPayload | null>(null);
const lastAppliedRef = useRef<LivePreviewPayload | null>(null);
// Destructure first so the memo deps stay precise without
// triggering exhaustive-deps on `state` as a whole.
const { tiles, door, thickness, wallHeight } = state;
const currentPayload = useMemo<LivePreviewPayload>(() => ({
tilemap: serializeTilemap(tiles),
@@ -137,8 +100,29 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo
const setBaseline = useCallback((payload: LivePreviewPayload) =>
{
baselineRef.current = payload;
lastAppliedRef.current = payload;
const normalized: LivePreviewPayload = {
...payload,
tilemap: normalizeTilemap(payload.tilemap)
};
baselineRef.current = normalized;
lastAppliedRef.current = normalized;
}, []);
const mergeBaseline = useCallback((partial: Partial<LivePreviewPayload>) =>
{
const previous = baselineRef.current;
if(!previous) return;
const next: LivePreviewPayload = {
...previous,
...partial,
tilemap: partial.tilemap !== undefined ? normalizeTilemap(partial.tilemap) : previous.tilemap
};
baselineRef.current = next;
lastAppliedRef.current = next;
}, []);
const revert = useCallback(() =>
@@ -150,21 +134,17 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo
if(applyToRenderer(baseline, roomId)) lastAppliedRef.current = baseline;
}, [ roomId ]);
// Apply the current payload to the renderer whenever it
// diverges from what's already in the room. Synchronous + no
// debounce — the renderer pipeline is fast enough that every
// brush stroke can land a paint.
useEffect(() =>
{
if(!enabled) return;
if(!baselineRef.current) return;
const previous = lastAppliedRef.current;
if(previous && livePreviewPayloadsEqual(currentPayload, previous)) return;
if(!previous && !baselineRef.current) return;
if(applyToRenderer(currentPayload, roomId)) lastAppliedRef.current = currentPayload;
}, [ enabled, currentPayload, roomId ]);
return { setBaseline, revert };
return { setBaseline, mergeBaseline, revert };
};
+122
View File
@@ -0,0 +1,122 @@
import { ISoundboardSound, loadGamedata, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api';
import { useMessageEvent } from '../events';
// A pad as the client uses it. `local` marks pads that came from the JSON5 file
// fallback rather than the server (DB) — those play locally on click because the
// server can't resolve their id to broadcast them.
export type ClientSoundboardSound = ISoundboardSound & { local?: boolean };
const playLocal = (url: string) =>
{
if(!url) return;
try
{
const audio = new Audio(url);
audio.volume = 0.8;
void audio.play().catch(() => {});
}
catch {}
};
// Resolve a stored sound url (which may be relative, like custom badges) to an
// absolute one against the asset host.
const resolveUrl = (url: string): string =>
{
if(!url) return '';
if(/^https?:\/\//i.test(url) || url.startsWith('//') || url.startsWith('/')) return url;
const base = (GetConfigurationValue<string>('soundboard.url.prefix') || GetConfigurationValue<string>('asset.url') || '').replace(/\/+$/, '');
return base ? `${ base }/${ url.replace(/^\/+/, '') }` : url;
};
// Soundboard state + actions. Shared via useBetween so the event listeners
// register once regardless of how many components read it (toolbar + view).
const useSoundboardState = () =>
{
const [ enabled, setEnabled ] = useState(false);
const [ serverSounds, setServerSounds ] = useState<ISoundboardSound[]>([]);
const [ fileSounds, setFileSounds ] = useState<ClientSoundboardSound[]>([]);
const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null);
const fileLoadStartedRef = useRef(false);
useMessageEvent<SoundboardSettingsEvent>(SoundboardSettingsEvent, event =>
{
const parser = event.getParser();
setEnabled(parser.enabled);
setServerSounds(parser.sounds);
setSoundboardRoomEnabled(parser.enabled);
});
useMessageEvent<SoundboardPlayEvent>(SoundboardPlayEvent, event =>
{
const parser = event.getParser();
playLocal(resolveUrl(parser.url));
setLastPlayed({ soundId: parser.soundId, username: parser.username });
});
// Fallback: when the soundboard is on but the server (DB) provided no pads,
// load them from the JSON5 file once. loadGamedata accepts plain JSON and
// JSON5 (// comments) — same loader used for the avatar effect map.
useEffect(() =>
{
if(!enabled || serverSounds.length || fileLoadStartedRef.current) return;
fileLoadStartedRef.current = true;
const url = GetConfigurationValue<string>('soundboard.url')
|| GetConfigurationValue<string>('soundboard.sounds.url')
|| 'configuration/soundboard-sounds.json5';
(async () =>
{
try
{
const json = await loadGamedata<{ sounds?: ISoundboardSound[] }>(url);
const list = Array.isArray(json?.sounds)
? json.sounds
.filter(s => s && s.url)
.map(s => ({ id: s.id, name: s.name, url: s.url, local: true }))
: [];
setFileSounds(list);
}
catch {}
})();
}, [ enabled, serverSounds.length ]);
const sounds: ClientSoundboardSound[] = serverSounds.length ? serverSounds : fileSounds;
const play = useCallback((sound: ClientSoundboardSound) =>
{
if(!sound) return;
// File-defined pad: the server doesn't know it, so play it locally.
if(sound.local)
{
playLocal(resolveUrl(sound.url));
return;
}
// DB-backed pad: let the server broadcast it to everyone in the room.
SendMessageComposer(new SoundboardPlayComposer(sound.id));
}, []);
const setRoomEnabled = useCallback((value: boolean) =>
{
setEnabled(value);
setSoundboardRoomEnabled(value);
SendMessageComposer(new SoundboardSetEnabledComposer(value));
}, []);
// Local-only clear (e.g. when leaving the room) — does not notify the server.
const reset = useCallback(() =>
{
setEnabled(false);
setServerSounds([]);
setLastPlayed(null);
setSoundboardRoomEnabled(false);
}, []);
return { enabled, sounds, lastPlayed, play, setRoomEnabled, reset };
};
export const useSoundboard = () => useBetween(useSoundboardState);
+1
View File
@@ -0,0 +1 @@
export * from './useThemes';
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useMemo, useState } from 'react';
import { useBetween } from 'use-between';
import { ApplyThemePieces, ClearTheme, FetchThemeIndex, FetchThemeManifest, GetConfigurationValue, LocalStorageKeys, ThemeInfo, ThemeManifest } from '../../api';
import { useLocalStorage } from '../useLocalStorage';
// Per-user custom theme selection.
// - activeThemeId: '' = default (no custom theme). Default for new users comes
// from ui-config `theme.default` so the admin can set a hotel-wide default
// (like catalog.classic.style), while each user can override from Settings.
// - enabledPieces[themeId]: which graphic pieces of that theme are active
// (checkboxes). If absent, defaults to ui-config `theme.default.pieces`
// (when on the default theme) or ALL pieces.
const useThemesState = () =>
{
const [ activeThemeId, setActiveThemeId ] = useLocalStorage<string>(LocalStorageKeys.THEME_ACTIVE, GetConfigurationValue<string>('theme.default', ''));
const [ enabledPieces, setEnabledPieces ] = useLocalStorage<Record<string, string[]>>(LocalStorageKeys.THEME_PIECES, {});
const [ themes, setThemes ] = useState<ThemeInfo[]>([]);
const [ manifest, setManifest ] = useState<ThemeManifest>(null);
const [ loaded, setLoaded ] = useState(false);
// Load the theme index once.
useEffect(() =>
{
let alive = true;
FetchThemeIndex().then(list =>
{
if(alive) setThemes(list);
}).finally(() =>
{
if(alive) setLoaded(true);
});
return () => { alive = false; };
}, []);
// Load the manifest whenever the active theme changes.
useEffect(() =>
{
let alive = true;
if(!activeThemeId)
{
setManifest(null);
ClearTheme();
return;
}
FetchThemeManifest(activeThemeId).then(m =>
{
if(!alive) return;
setManifest(m);
if(!m) ClearTheme(); // broken/missing manifest -> full fallback to default
});
return () => { alive = false; };
}, [ activeThemeId ]);
// Which pieces are enabled for the current theme.
const activeEnabled = useMemo(() =>
{
if(!manifest) return [] as string[];
const stored = enabledPieces[activeThemeId];
if(stored) return stored;
const fromConfig = GetConfigurationValue<string[]>('theme.default.pieces', null);
// Default: config list (if this is the default theme) else every piece on.
if(fromConfig && activeThemeId === GetConfigurationValue<string>('theme.default', '')) return fromConfig;
return manifest.pieces.map(p => p.id);
}, [ manifest, enabledPieces, activeThemeId ]);
// Apply (inject/remove <link>s) whenever theme or enabled pieces change.
useEffect(() =>
{
if(!activeThemeId || !manifest)
{
ClearTheme();
return;
}
ApplyThemePieces(activeThemeId, manifest.pieces.filter(p => activeEnabled.includes(p.id)));
}, [ activeThemeId, manifest, activeEnabled ]);
const selectTheme = (id: string) => setActiveThemeId(id || '');
const togglePiece = (pieceId: string) =>
{
if(!activeThemeId || !manifest) return;
setEnabledPieces(prev =>
{
const current = prev[activeThemeId] ?? manifest.pieces.map(p => p.id);
const next = current.includes(pieceId) ? current.filter(x => x !== pieceId) : [ ...current, pieceId ];
return { ...prev, [activeThemeId]: next };
});
};
return { themes, activeThemeId, manifest, activeEnabled, loaded, selectTheme, togglePiece };
};
export const useThemes = () => useBetween(useThemesState);