mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 23:16:21 +00:00
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:
@@ -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);
|
||||
@@ -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';
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 })
|
||||
}));
|
||||
@@ -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,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');
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useThemes';
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user