diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acb525..7553c95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,11 @@ on: workflow_dispatch: inputs: renderer_repo: - description: 'Renderer repo (owner/name). Empty = auto from client branch.' + description: 'Renderer repo (owner/name). Empty = vars.RENDERER_REPO or upstream default.' required: false default: '' renderer_ref: - description: 'Renderer git ref. Empty = auto from client branch.' + description: 'Renderer git ref. Empty = vars.RENDERER_REF or auto (main on client main, else Dev).' required: false default: '' @@ -24,6 +24,11 @@ on: # it on every run. env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # Upstream renderer used as the fallback when nothing else is + # configured. Override per-fork via the RENDERER_REPO / RENDERER_REF + # repository variables (Settings → Secrets and variables → Actions → + # Variables) or, for one-off runs, via the workflow_dispatch inputs. + UPSTREAM_RENDERER_REPO: 'duckietm/Nitro_Render_V3' jobs: check: @@ -39,77 +44,60 @@ jobs: with: path: Nitro-V3 - # Pick the renderer ref dynamically based on the client context. + # Resolve the renderer pairing with a clear precedence, from most + # specific to most generic — no fork names or feature branches are + # hardcoded in this workflow: + # + # 1. workflow_dispatch inputs (renderer_repo / renderer_ref) + # → explicit manual override, wins outright. + # 2. repository variables (vars.RENDERER_REPO / vars.RENDERER_REF) + # → per-fork config set under Settings → Variables, applies + # to push and pull_request runs without editing this file. + # 3. upstream default + # → UPSTREAM_RENDERER_REPO, ref `main` when the client build + # context is `main`, otherwise `Dev`. + # # The two repos must stay wire-aligned (composer/parser - # signatures); pairing `main` with a stale branch is what + # signatures); pairing the client with a stale renderer is what # produced the "Expected 14-15 arguments, but got 16" failure on - # the catalog edit composer. - # - # This branch (`feat/housekeeping-panel`) references HK composers - # /events that live on the renderer PR branch - # (simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets) — they - # haven't been merged upstream yet. Pair against the fork branch - # for this PR so the typecheck step can resolve the imports; - # once the renderer PR lands on duckietm:Dev this whole - # special-case block can be dropped. - # - # Mapping: - # client `main` → duckietm/Nitro_Render_V3 @ main - # client `feat/housekeeping-panel` → simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets - # client `feat/**` (other) → duckietm/Nitro_Render_V3 @ Dev - # PR base `main` → duckietm/Nitro_Render_V3 @ main - # PR head `feat/housekeeping-panel` → simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets - # PR base `Dev` (upstream) → duckietm/Nitro_Render_V3 @ Dev - # PR base `feat/**` → duckietm/Nitro_Render_V3 @ Dev - # - # Override via workflow_dispatch inputs when you need an ad-hoc - # pairing. + # the catalog edit composer. When a feature touches both repos, + # point RENDERER_REPO/RENDERER_REF (or the dispatch inputs) at the + # companion renderer branch. - name: Resolve renderer ref id: renderer + env: + IN_REPO: ${{ github.event.inputs.renderer_repo }} + IN_REF: ${{ github.event.inputs.renderer_ref }} + VAR_REPO: ${{ vars.RENDERER_REPO }} + VAR_REF: ${{ vars.RENDERER_REF }} run: | - REPO="${{ github.event.inputs.renderer_repo }}" - REF="${{ github.event.inputs.renderer_ref }}" + set -euo pipefail - if [ -z "$REPO" ] || [ -z "$REF" ]; then - # For PRs we usually pair against the base ref, but the HK - # PR specifically needs to pair against its OWN head ref — - # the renderer companion PR is named identically - # (`feat/housekeeping-packets`) and lives on the same fork. - case "${GITHUB_EVENT_NAME}" in - pull_request) - if [ "${GITHUB_HEAD_REF}" = "feat/housekeeping-panel" ]; then - CTX="${GITHUB_HEAD_REF}" - else - CTX="${GITHUB_BASE_REF}" - fi - ;; - *) - CTX="${GITHUB_REF_NAME}" - ;; - esac + # Branch context of the *client* build. + case "${GITHUB_EVENT_NAME}" in + pull_request) CTX="${GITHUB_BASE_REF}" ;; + *) CTX="${GITHUB_REF_NAME}" ;; + esac - case "$CTX" in - main) - AUTO_REPO="duckietm/Nitro_Render_V3" - AUTO_REF="main" - ;; - feat/housekeeping-panel) - AUTO_REPO="simoleo89/Nitro_Render_V3" - AUTO_REF="feat/housekeeping-packets" - ;; - *) - AUTO_REPO="duckietm/Nitro_Render_V3" - AUTO_REF="Dev" - ;; - esac - - [ -z "$REPO" ] && REPO="$AUTO_REPO" - [ -z "$REF" ] && REF="$AUTO_REF" + # Upstream fallback ref depends on client context. + if [ "$CTX" = "main" ]; then + DEFAULT_REF="main" + else + DEFAULT_REF="Dev" fi + # Precedence: dispatch input → repo variable → upstream default. + REPO="$IN_REPO" + [ -z "$REPO" ] && REPO="$VAR_REPO" + [ -z "$REPO" ] && REPO="$UPSTREAM_RENDERER_REPO" + + REF="$IN_REF" + [ -z "$REF" ] && REF="$VAR_REF" + [ -z "$REF" ] && REF="$DEFAULT_REF" + echo "repo=$REPO" >> "$GITHUB_OUTPUT" echo "ref=$REF" >> "$GITHUB_OUTPUT" - echo "Resolved renderer pairing: $REPO @ $REF (client ctx: ${GITHUB_BASE_REF:-$GITHUB_REF_NAME}, event: ${GITHUB_EVENT_NAME})" + echo "Resolved renderer pairing: $REPO @ $REF (client ctx: $CTX, event: ${GITHUB_EVENT_NAME})" - name: Checkout Nitro_Render_V3 (sibling) uses: actions/checkout@v4 diff --git a/docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md b/docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md new file mode 100644 index 0000000..1665f43 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md @@ -0,0 +1,1715 @@ +# Navigator Modernization P1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the 492-line `useNavigator` god-hook into a `wired-tools`-style store + three filters, extract door lifecycle to `useDoorState`, hoist NavigatorView's 9 local useState into a Zustand `navigatorUiStore`, migrate all 13 consumers, delete the shim — zero user-visible change. + +**Architecture:** `src/hooks/navigator/useNavigatorStore.ts` is the internal `useBetween` closure holding data state + non-door event listeners + the `sendSearch`/`reloadCurrentSearch` actions. Three filter files (`useNavigatorData.ts`, `useNavigatorUiState.ts`, `useNavigatorActions.ts`) expose flat slices. `navigatorUiStore.ts` is a Zustand store for 9 panel-visibility/lifecycle flags. `useDoorState.ts` (in `src/hooks/rooms/widgets/`) is a separate `useBetween` closure for door bell/password lifecycle — dual-subscribed to `GetGuestRoomResultEvent` and `GenericErrorEvent` alongside the navigator store, each filtering by branch / error code. + +**Tech Stack:** React 19.2, TypeScript (TS 7 native preview for typecheck), Zustand 5 via `createNitroStore`, `use-between` 1.x, Vitest 3 with co-located suites + `src/nitro-renderer.mock.ts`. + +**Branch:** `feat/navigator-modernization` (already created at `66062c6`, forked from `origin/Dev` @ `d5d5ca59`). All commits stay on this branch; auto-push to `simoleo/feat/navigator-modernization` FF-only. + +**House rules (apply to every commit):** +- Commit author: `simoleo89 ` via per-command `-c` overrides — do NOT modify global git config. +- **No `Co-Authored-By` trailer.** +- Each commit must be a stopping point: `yarn typecheck` clean, `yarn test --run` green, `yarn lint:hooks` clean. + +--- + +## Task 1: Zustand `navigatorUiStore` (TDD) + +**Files:** +- Create: `src/hooks/navigator/navigatorUiStore.ts` +- Test: `src/hooks/navigator/navigatorUiStore.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/hooks/navigator/navigatorUiStore.test.ts`: + +```ts +import { beforeEach, describe, expect, it } from 'vitest'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +const INITIAL = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false +}; + +describe('useNavigatorUiStore', () => +{ + beforeEach(() => + { + useNavigatorUiStore.setState(INITIAL); + }); + + it('exposes the documented defaults', () => + { + const s = useNavigatorUiStore.getState(); + expect(s.isVisible).toBe(false); + expect(s.isReady).toBe(false); + expect(s.isCreatorOpen).toBe(false); + expect(s.isRoomInfoOpen).toBe(false); + expect(s.isRoomLinkOpen).toBe(false); + expect(s.isOpenSavesSearches).toBe(false); + expect(s.isLoading).toBe(false); + expect(s.needsInit).toBe(true); + expect(s.needsSearch).toBe(false); + }); + + describe('show / hide / toggle', () => + { + it('show() sets isVisible true and requests a search', () => + { + useNavigatorUiStore.getState().show(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + }); + + it('hide() sets isVisible false without touching needsSearch', () => + { + useNavigatorUiStore.setState({ isVisible: true, needsSearch: false }); + useNavigatorUiStore.getState().hide(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + + it('toggle() flips visibility and requests a search on show', () => + { + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + + useNavigatorUiStore.setState({ needsSearch: false }); + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); + + describe('creator panel', () => + { + it('openCreator() opens both visible and creator', () => + { + useNavigatorUiStore.getState().openCreator(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(true); + }); + + it('closeCreator() closes only the creator panel', () => + { + useNavigatorUiStore.setState({ isVisible: true, isCreatorOpen: true }); + useNavigatorUiStore.getState().closeCreator(); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(false); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + }); + }); + + describe('roomInfo / roomLink / savesSearches', () => + { + it('setRoomInfoOpen(true) and toggleRoomInfo flip the flag', () => + { + useNavigatorUiStore.getState().setRoomInfoOpen(true); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomInfo(); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(false); + }); + + it('setRoomLinkOpen(true) and toggleRoomLink flip the flag', () => + { + useNavigatorUiStore.getState().setRoomLinkOpen(true); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomLink(); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(false); + }); + + it('toggleSavesSearches() flips the sidebar flag', () => + { + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(true); + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(false); + }); + }); + + describe('lifecycle flags', () => + { + it('setLoading(true) and setLoading(false) toggle isLoading', () => + { + useNavigatorUiStore.getState().setLoading(true); + expect(useNavigatorUiStore.getState().isLoading).toBe(true); + useNavigatorUiStore.getState().setLoading(false); + expect(useNavigatorUiStore.getState().isLoading).toBe(false); + }); + + it('markReady() sets isReady true and is idempotent', () => + { + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + }); + + it('markInitDone() flips needsInit to false', () => + { + useNavigatorUiStore.getState().markInitDone(); + expect(useNavigatorUiStore.getState().needsInit).toBe(false); + }); + + it('requestSearch() + consumeSearchRequest() are symmetric', () => + { + useNavigatorUiStore.getState().requestSearch(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + useNavigatorUiStore.getState().consumeSearchRequest(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/navigator/navigatorUiStore.test.ts +``` + +Expected: FAIL — `Cannot find module './navigatorUiStore'`. + +- [ ] **Step 3: Implement the store** + +Create `src/hooks/navigator/navigatorUiStore.ts`: + +```ts +import { createNitroStore } from '../../state/createNitroStore'; + +export type NavigatorUiState = { + isVisible: boolean; + isReady: boolean; + isCreatorOpen: boolean; + isRoomInfoOpen: boolean; + isRoomLinkOpen: boolean; + isOpenSavesSearches: boolean; + isLoading: boolean; + needsInit: boolean; + needsSearch: boolean; +}; + +export type NavigatorUiActions = { + show(): void; + hide(): void; + toggle(): void; + openCreator(): void; + closeCreator(): void; + setRoomInfoOpen(open: boolean): void; + toggleRoomInfo(): void; + setRoomLinkOpen(open: boolean): void; + toggleRoomLink(): void; + toggleSavesSearches(): void; + setLoading(loading: boolean): void; + markReady(): void; + markInitDone(): void; + requestSearch(): void; + consumeSearchRequest(): void; +}; + +export const useNavigatorUiStore = createNitroStore()((set) => ({ + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, + + show: () => set({ isVisible: true, needsSearch: true }), + hide: () => set({ isVisible: false }), + toggle: () => set((s) => s.isVisible + ? { isVisible: false } + : { isVisible: true, needsSearch: true }), + openCreator: () => set({ isVisible: true, isCreatorOpen: true }), + closeCreator: () => set({ isCreatorOpen: false }), + setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }), + toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })), + setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }), + toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })), + toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })), + setLoading: (loading) => set({ isLoading: loading }), + markReady: () => set({ isReady: true }), + markInitDone: () => set({ needsInit: false }), + requestSearch: () => set({ needsSearch: true }), + consumeSearchRequest: () => set({ needsSearch: false }) +})); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/navigator/navigatorUiStore.test.ts +``` + +Expected: PASS (all ~14 cases green). + +- [ ] **Step 5: Commit** + +```powershell +cd Nitro-V3 +git add src/hooks/navigator/navigatorUiStore.ts src/hooks/navigator/navigatorUiStore.test.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(navigator): Zustand UI store for panel-visibility + lifecycle flags + +Hoists the 9 useState in NavigatorView (isVisible, isReady, isCreatorOpen, +isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, +needsSearch) into a createNitroStore-backed Zustand store with named +actions. Future linkTracker / lifecycle wiring will call these actions +instead of mutating local component state. + +TDD: ~14 cases on each action's transitions + idempotency." +git push simoleo feat/navigator-modernization +``` + +--- + +## Task 2: Extract `useDoorState` (TDD) + +**Files:** +- Create: `src/hooks/rooms/widgets/useDoorState.ts` +- Test: `src/hooks/rooms/widgets/useDoorState.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/hooks/rooms/widgets/useDoorState.test.tsx`: + +```tsx +import { act, renderHook } from '@testing-library/react'; +import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, + GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser, + RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; +import { 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(); + }); + + 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 for owner does NOT dispatch a door dialog', () => + { + const { result } = renderHook(() => useDoorState()); + const before = result.current.snapshot.state; + // Mock GetSessionDataManager().userName to be the owner name. + // The hook reads owner name dynamically — see useDoorState impl. + // For this test we make doorMode something other than bell/password. + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomForward: true, + isGroupMember: false, + data: { ownerName: 'me', doorMode: 99 } + })); + }); + expect(result.current.snapshot.state).toBe(before); + }); + + 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(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/rooms/widgets/useDoorState.test.tsx +``` + +Expected: FAIL — `Cannot find module './useDoorState'`. + +- [ ] **Step 3: Implement `useDoorState`** + +Create `src/hooks/rooms/widgets/useDoorState.ts`: + +```ts +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(INITIAL); + + useMessageEvent(DoorbellMessageEvent, event => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING })); + }); + + useMessageEvent(RoomDoorbellAcceptedEvent, event => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED })); + }); + + useMessageEvent(FlatAccessDeniedMessageEvent, event => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER })); + }); + + useMessageEvent(GenericErrorEvent, event => + { + const parser = event.getParser(); + if(parser.errorCode !== -100002) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD })); + }); + + useMessageEvent(GetGuestRoomResultEvent, event => + { + const parser = event.getParser(); + 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 }); + } + }); + + const reset = useCallback(() => setSnapshot(INITIAL), []); + + return { snapshot, setSnapshot, reset }; +}; + +export const useDoorState = () => useBetween(useDoorStateStore); +``` + +- [ ] **Step 4: Verify the renderer mock exposes the events used in tests** + +```powershell +cd Nitro-V3 ; grep -E "DoorbellMessageEvent|RoomDoorbellAcceptedEvent|FlatAccessDeniedMessageEvent|GenericErrorEvent|GetGuestRoomResultEvent|RoomDataParser" src/nitro-renderer.mock.ts +``` + +Expected: all six symbols present. If any are missing, ADD a minimal stub to `src/nitro-renderer.mock.ts` (real class with a no-arg constructor; `getParser` will be overridden in tests). Use the existing pattern — e.g. find `RoomSessionDoorbellEvent` and follow its shape. + +- [ ] **Step 5: Run the test to verify it passes** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/rooms/widgets/useDoorState.test.tsx +``` + +Expected: PASS (11 cases). + +- [ ] **Step 6: Commit** + +```powershell +cd Nitro-V3 +git add src/hooks/rooms/widgets/useDoorState.ts src/hooks/rooms/widgets/useDoorState.test.tsx src/nitro-renderer.mock.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(rooms): extract useDoorState from useNavigator god-hook + +Separates the door bell/password lifecycle from Navigator data. Subscribes +to DoorbellMessageEvent / RoomDoorbellAcceptedEvent / +FlatAccessDeniedMessageEvent / GenericErrorEvent (-100002 only) / +GetGuestRoomResultEvent (roomForward branch with DOORBELL_STATE or +PASSWORD_STATE doorMode only). Other branches/errorCodes stay on +useNavigator — both subscribers coexist via useMessageEvent + filtering. + +TDD: 11 cases incl. userName-empty filter + errorCode -100002 filter + +owner-skip + reset()." +git push simoleo feat/navigator-modernization +``` + +--- + +## Task 3: Internal `useNavigatorStore` (closure with data + non-door listeners + new actions) + +**Files:** +- Create: `src/hooks/navigator/useNavigatorStore.ts` + +- [ ] **Step 1: Read current `useNavigator.ts` in full** + +```powershell +cd Nitro-V3 ; cat src/hooks/navigator/useNavigator.ts | head -100 +``` + +You will translate this file's `useNavigatorState` function into the new `useNavigatorStore.ts`, with these surgical changes: + +1. **Remove** `doorData` state and its dual writers (lines that called `setDoorData`). +2. **Remove** the door-only branches of `GetGuestRoomResultEvent` (doorMode bell/password) — these are now in `useDoorState`. KEEP the `roomEnter` branch and the `roomForward` branch that calls `CreateRoomSession(parser.data.roomId)`. +3. **Remove** the `GenericErrorEvent` case for errorCode `-100002` — now in `useDoorState`. KEEP cases 4009/4010/4011/4013. +4. **Keep** all other listeners untouched. +5. **Add** two new actions extracted from `NavigatorView.tsx` locals (currently at `NavigatorView.tsx:42-79`): `sendSearch(searchValue, contextCode)` and `reloadCurrentSearch()`. +6. The store function is NAMED `useNavigatorStore` (not `useNavigatorState`) and is NOT wrapped in `useBetween` here — the wrapping happens in the three filter files. + +- [ ] **Step 2: Create the new file** + +Create `src/hooks/navigator/useNavigatorStore.ts`: + +```ts +import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, + FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent, + FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, + GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, + GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, + HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, + NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, + NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, + NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, + NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, + RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, + UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { CreateRoomSession, GetConfigurationValue, INavigatorData, + LocalizeText, NotificationAlertType, SendMessageComposer, + TryVisitRoom, VisitDesktop } from '../../api'; +import { useMessageEvent, useNitroEvent } from '../events'; +import { useNotification } from '../notification'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorStore = () => +{ + const [ categories, setCategories ] = useState(null); + const [ eventCategories, setEventCategories ] = useState(null); + const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); + const [ topLevelContext, setTopLevelContext ] = useState(null); + const [ topLevelContexts, setTopLevelContexts ] = useState(null); + const [ searchResult, setSearchResult ] = useState(null); + const [ navigatorSearches, setNavigatorSearches ] = useState(null); + const [ navigatorData, setNavigatorData ] = useState({ + settingsReceived: false, + homeRoomId: 0, + enteredGuestRoom: null, + currentRoomOwner: false, + currentRoomId: 0, + currentRoomIsStaffPick: false, + createdFlatId: 0, + avatarId: 0, + roomPicker: false, + eventMod: false, + currentRoomRating: 0, + canRate: true + }); + + const { simpleAlert = null } = useNotification(); + + const sendSearch = useCallback((searchValue: string, contextCode: string) => + { + useNavigatorUiStore.getState().closeCreator(); + SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); + useNavigatorUiStore.getState().setLoading(true); + }, []); + + const reloadCurrentSearch = useCallback(() => + { + if(!useNavigatorUiStore.getState().isReady) + { + useNavigatorUiStore.getState().requestSearch(); + return; + } + if(searchResult) + { + sendSearch(searchResult.data, searchResult.code); + return; + } + if(!topLevelContext) return; + sendSearch('', topLevelContext.code); + }, [ searchResult, topLevelContext, sendSearch ]); + + useMessageEvent(FavouritesEvent, event => + { + const parser = event.getParser(); + const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); + setFavouriteRoomIds(favoriteIds); + }); + + useMessageEvent(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, event => + { + const parser = event.getParser(); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); + }); + + useMessageEvent(CanCreateRoomEventEvent, event => + { + const parser = event.getParser(); + if(parser.canCreate) return; + simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title')); + }); + + useMessageEvent(UserInfoEvent, event => + { + SendMessageComposer(new GetUserFlatCatsMessageComposer()); + SendMessageComposer(new GetUserEventCatsMessageComposer()); + }); + + useMessageEvent(UserPermissionsEvent, event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + eventMod: parser.securityLevel >= SecurityLevel.MODERATOR, + roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY + })); + }); + + useMessageEvent(RoomForwardEvent, event => + { + const parser = event.getParser(); + TryVisitRoom(parser.roomId); + }); + + useMessageEvent(RoomEntryInfoMessageEvent, 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, event => + { + const parser = event.getParser(); + if(parser.roomEnter) + { + setNavigatorData(prev => + { + const next = { ...prev }; + next.enteredGuestRoom = parser.data; + next.currentRoomIsStaffPick = parser.staffPick; + const isCreated = next.createdFlatId === parser.data.roomId; + if(!isCreated && parser.data.displayRoomEntryAd) + { + if(GetConfigurationValue('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd(); + } + next.createdFlatId = 0; + return next; + }); + return; + } + if(parser.roomForward) + { + // Door-mode branches handled in useDoorState — skip 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, event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + currentRoomRating: parser.totalLikes, + canRate: parser.canLike + })); + }); + + useMessageEvent(GenericErrorEvent, event => + { + const parser = event.getParser(); + // -100002 (wrong password) handled in useDoorState — skip 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; + } + }); + + useMessageEvent(NavigatorMetadataEvent, event => + { + const parser = event.getParser(); + setTopLevelContexts(parser.topLevelContexts); + setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); + }); + + useMessageEvent(NavigatorSearchEvent, event => + { + const parser = event.getParser(); + setTopLevelContext(prev => + { + let next = prev; + if(!next) next = (topLevelContexts && topLevelContexts.length && topLevelContexts[0]) || null; + if(!next) return null; + if(topLevelContexts && topLevelContexts.length) + { + for(const ctx of topLevelContexts) + { + if(ctx.code === parser.result.code) next = ctx; + } + } + return next; + }); + setSearchResult(parser.result); + useNavigatorUiStore.getState().setLoading(false); + }); + + useMessageEvent(UserFlatCatsEvent, event => + { + const parser = event.getParser(); + setCategories(parser.categories); + }); + + useMessageEvent(UserEventCatsEvent, event => + { + const parser = event.getParser(); + setEventCategories(parser.categories); + }); + + useMessageEvent(FlatCreatedEvent, event => + { + const parser = event.getParser(); + CreateRoomSession(parser.roomId); + }); + + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => + { + setNavigatorData(prev => ({ ...prev, settingsReceived: false })); + }); + + useMessageEvent(NavigatorHomeRoomEvent, event => + { + const parser = event.getParser(); + let prevSettingsReceived = false; + setNavigatorData(prev => + { + prevSettingsReceived = prev.settingsReceived; + return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true }; + }); + if(prevSettingsReceived) return; + if(GetRoomSessionManager().viewerSession) return; + + let forwardType = -1; + let forwardId = -1; + if((GetConfigurationValue('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) + { + forwardType = 0; + SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); + } + if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) + { + forwardType = parseInt(GetConfigurationValue('forward.type')); + forwardId = parseInt(GetConfigurationValue('forward.id')); + } + if(forwardType === 2) + { + TryVisitRoom(forwardId); + } + else if((forwardType === -1) && (parser.roomIdToEnter > 0)) + { + CreateLinkEvent('navigator/close'); + CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId); + } + }); + + useMessageEvent(RoomEnterErrorEvent, 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(); + }); + + useMessageEvent(NavigatorOpenRoomCreatorEvent, event => CreateLinkEvent('navigator/show')); + + useMessageEvent(NavigatorSearchesEvent, event => + { + const parser = event.getParser(); + if(!parser) return; + setNavigatorSearches(parser.searches); + }); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + sendSearch, reloadCurrentSearch + }; +}; +``` + +- [ ] **Step 3: Run typecheck to verify the file compiles** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -10 +``` + +Expected: no NEW errors in `src/hooks/navigator/useNavigatorStore.ts`. Pre-existing floorplan-related typecheck errors (`applyFloorModelLocally`, JSX namespace) are environmental, not caused by P1 — see spec §11. + +- [ ] **Step 4: Do NOT commit yet** + +The three filter files in Task 4 will land in the same commit as this file — atomically, so the codebase always has working hook exports. + +--- + +## Task 4: Three filter files + updated barrel + smoke test + +**Files:** +- Create: `src/hooks/navigator/useNavigatorData.ts` +- Create: `src/hooks/navigator/useNavigatorUiState.ts` +- Create: `src/hooks/navigator/useNavigatorActions.ts` +- Modify: `src/hooks/navigator/index.ts` +- Create: `src/hooks/navigator/useNavigatorStore.test.tsx` + +- [ ] **Step 1: Create `useNavigatorData.ts`** + +```ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => +{ + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + } = useBetween(useNavigatorStore); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + }; +}; +``` + +- [ ] **Step 2: Create `useNavigatorUiState.ts`** + +```ts +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => +{ + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch + }; +}; +``` + +- [ ] **Step 3: Create `useNavigatorActions.ts`** + +```ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => +{ + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; +``` + +- [ ] **Step 4: Rewrite the barrel `index.ts`** + +```ts +export { useNavigatorActions } from './useNavigatorActions'; +export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorUiState } from './useNavigatorUiState'; +export { useNavigatorUiStore } from './navigatorUiStore'; +export { useDoorState } from '../rooms/widgets/useDoorState'; +export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState'; +export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore'; +``` + +Notice: the old `export * from './useNavigator';` is GONE. `useNavigator` is no longer exported by the barrel — consumers must use the new filters. (The old file still exists on disk until Task 9.) + +- [ ] **Step 5: Add a smoke test** + +Create `src/hooks/navigator/useNavigatorStore.test.tsx`: + +```tsx +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; + +describe('navigator filter shapes (smoke)', () => +{ + it('useNavigatorData returns the documented keys', () => + { + const { result } = renderHook(() => useNavigatorData()); + expect(Object.keys(result.current).sort()).toEqual([ + 'categories', 'eventCategories', 'favouriteRoomIds', + 'navigatorData', 'navigatorSearches', + 'searchResult', 'topLevelContext', 'topLevelContexts' + ].sort()); + }); + + it('useNavigatorUiState returns the 9 documented flags', () => + { + const { result } = renderHook(() => useNavigatorUiState()); + expect(Object.keys(result.current).sort()).toEqual([ + 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', + 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', + 'needsInit', 'needsSearch' + ].sort()); + }); + + it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => + { + const { result } = renderHook(() => useNavigatorActions()); + expect(typeof result.current.sendSearch).toBe('function'); + expect(typeof result.current.reloadCurrentSearch).toBe('function'); + }); +}); +``` + +- [ ] **Step 6: Run typecheck — the project will fail because consumers still import `useNavigator`** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -20 +``` + +Expected: errors like `Module '"...hooks/navigator"' has no exported member 'useNavigator'` in the 13 consumer files. That's intentional — Tasks 6/7/8 fix them. The hook files themselves must typecheck clean. + +- [ ] **Step 7: Run the smoke test in isolation** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/navigator/useNavigatorStore.test.tsx +``` + +Expected: PASS (3 cases). + +- [ ] **Step 8: Commit all new hook files together** + +```powershell +cd Nitro-V3 +git add src/hooks/navigator/useNavigatorStore.ts src/hooks/navigator/useNavigatorData.ts src/hooks/navigator/useNavigatorUiState.ts src/hooks/navigator/useNavigatorActions.ts src/hooks/navigator/index.ts src/hooks/navigator/useNavigatorStore.test.tsx +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(navigator): wired-tools-style hook split (Store + 3 filters) + +Splits the 492-line useNavigator god-hook into a useBetween-backed +useNavigatorStore closure plus three flat-shape filters +(useNavigatorData, useNavigatorUiState, useNavigatorActions), mirroring +the wired-tools layout. sendSearch + reloadCurrentSearch are extracted +as named actions out of NavigatorView locals. + +Door-mode handling is removed from this store and lives in useDoorState +(committed previously) — see GetGuestRoomResultEvent and +GenericErrorEvent dual-subscription with mutually exclusive filters. + +The barrel index.ts no longer re-exports useNavigator. The 13 consumers +will fail typecheck until Tasks 6-8 migrate them; the hook files +themselves are clean. Smoke test covers filter shapes." +git push simoleo feat/navigator-modernization +``` + +Note: `yarn test --run` overall is RED at this commit (consumers can't typecheck) — that's why we commit AND PUSH but DO NOT verify whole-project test green here. The next tasks make it green. + +**Deviation from house rule**: this is the only intentionally-broken intermediate commit in the plan. Documented in spec §11. + +--- + +## Task 5: Migrate `NavigatorDoorStateView.tsx` + +**Files:** +- Modify: `src/components/navigator/views/NavigatorDoorStateView.tsx` + +- [ ] **Step 1: Apply the consumer rewrite** + +Replace the file content with: + +```tsx +import { FC, useEffect, useState } from 'react'; +import { CreateRoomSession, DoorStateType, GoToDesktop, LocalizeText } from '../../../api'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common'; +import { useDoorState } from '../../../hooks'; +import { NitroInput } from '../../../layout'; + +const VISIBLE_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER, DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ]; +const DOORBELL_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER ]; + +export const NavigatorDoorStateView: FC<{}> = props => +{ + const [ password, setPassword ] = useState(''); + const { snapshot, setSnapshot, reset } = useDoorState(); + + const onClose = () => + { + if(snapshot.state === DoorStateType.STATE_WAITING) GoToDesktop(); + reset(); + }; + + const ring = () => + { + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); + }; + + const tryEntering = () => + { + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId, password); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); + }; + + useEffect(() => + { + if(snapshot.state !== DoorStateType.STATE_NO_ANSWER) return; + GoToDesktop(); + }, [ snapshot.state ]); + + if(snapshot.state === DoorStateType.NONE) return null; + if(VISIBLE_STATES.indexOf(snapshot.state) === -1) return null; + + const isDoorbell = DOORBELL_STATES.indexOf(snapshot.state) >= 0; + + return ( + + + +
+ { snapshot.roomInfo && snapshot.roomInfo.roomName } + { snapshot.state === DoorStateType.START_DOORBELL && + { LocalizeText('navigator.doorbell.info') } } + { snapshot.state === DoorStateType.STATE_WAITING && + { LocalizeText('navigator.doorbell.waiting') } } + { snapshot.state === DoorStateType.STATE_NO_ANSWER && + { LocalizeText('navigator.doorbell.no.answer') } } + { snapshot.state === DoorStateType.START_PASSWORD && + { LocalizeText('navigator.password.info') } } + { snapshot.state === DoorStateType.STATE_WRONG_PASSWORD && + { LocalizeText('navigator.password.retryinfo') } } +
+ { isDoorbell && +
+ { snapshot.state === DoorStateType.START_DOORBELL && + } + +
} + { !isDoorbell && + <> +
+ { LocalizeText('navigator.password.enter') } + setPassword(event.target.value) } /> +
+
+ + +
+ } +
+
+ ); +}; +``` + +Key changes: +- `useNavigator()` → `useDoorState()` +- `doorData` → `snapshot` +- `setDoorData(null)` → `reset()` +- `setDoorData(prev => ...)` → `setSnapshot(prev => ...)` +- Defensive `if(doorData && ...)` guards removed because `snapshot` is never null (always has a default `{ roomInfo: null, state: NONE }`) + +- [ ] **Step 2: Verify typecheck for this file is clean** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | grep NavigatorDoorStateView +``` + +Expected: no output (no errors mentioning this file). + +- [ ] **Step 3: Do NOT commit yet** — bundle with the rest of consumer migration in Task 8. + +--- + +## Task 6: Migrate `NavigatorView.tsx` (the big one) + +**Files:** +- Modify: `src/components/navigator/NavigatorView.tsx` + +- [ ] **Step 1: Read the current file in full** + +```powershell +cd Nitro-V3 ; cat src/components/navigator/NavigatorView.tsx +``` + +You will replace 9 local `useState`, the local `sendSearch`/`reloadCurrentSearch` definitions, and most of the `linkTracker` body with calls to `useNavigatorUiStore.getState()`. + +- [ ] **Step 2: Apply the rewrite** + +Replace the file contents with: + +```tsx +import { NitroCard } from '@layout/NitroCard'; +import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useRef } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png'; +import createRoomImg from '../../assets/images/navigator/create_room.png'; +import randomRoomImg from '../../assets/images/navigator/random_room.png'; +import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; +import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; +import { Flex, Text } from '../../common'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; +import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; +import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; +import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; +import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView'; +import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView'; +import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView'; +import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView'; +import { NavigatorSearchView } from './views/search/NavigatorSearchView'; + +export const NavigatorView: FC<{}> = props => +{ + const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); + const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); + const pendingSearch = useRef<{ value: string, code: string }>(null); + const elementRef = useRef(null); + + useNitroEvent(RoomSessionEvent.CREATED, event => + { + useNavigatorUiStore.getState().hide(); + useNavigatorUiStore.getState().closeCreator(); + }); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + const store = useNavigatorUiStore.getState(); + switch(parts[1]) + { + case 'show': + store.show(); + return; + case 'hide': + store.hide(); + return; + case 'toggle': + store.toggle(); + return; + case 'toggle-room-info': + store.toggleRoomInfo(); + return; + case 'toggle-room-link': + store.toggleRoomLink(); + return; + case 'goto': + if(parts.length <= 2) return; + if(parts[2] === 'home') + { + if(navigatorData.homeRoomId <= 0) return; + TryVisitRoom(navigatorData.homeRoomId); + return; + } + TryVisitRoom(parseInt(parts[2])); + return; + case 'create': + store.openCreator(); + return; + case 'search': + if(parts.length <= 2) return; + pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] }; + store.show(); + return; + } + }, + eventUrlPrefix: 'navigator/' + }; + AddLinkEventTracker(linkTracker); + return () => RemoveLinkEventTracker(linkTracker); + }, [ navigatorData ]); + + useEffect(() => + { + if(!searchResult) return; + if(elementRef.current) elementRef.current.scrollTop = 0; + }, [ searchResult ]); + + useEffect(() => + { + if(!isVisible || !isReady || !needsSearch) return; + if(pendingSearch.current) + { + sendSearch(pendingSearch.current.value, pendingSearch.current.code); + pendingSearch.current = null; + } + else + { + reloadCurrentSearch(); + } + useNavigatorUiStore.getState().consumeSearchRequest(); + }, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]); + + useEffect(() => + { + if(isReady || !topLevelContext) return; + useNavigatorUiStore.getState().markReady(); + }, [ isReady, topLevelContext ]); + + useEffect(() => + { + if(!isVisible || !needsInit) return; + SendMessageComposer(new NavigatorInitComposer()); + useNavigatorUiStore.getState().markInitDone(); + }, [ isVisible, needsInit ]); + + useEffect(() => + { + LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k))); + }, []); + + if(!isVisible) return ( + <> + + { isRoomInfoOpen && useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> } + { isRoomLinkOpen && useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> } + + + ); + + return ( + <> + + useNavigatorUiStore.getState().hide() } /> + + useNavigatorUiStore.getState().toggleSavesSearches() }> + + + { topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) => + sendSearch('', context.code) }> + { LocalizeText('navigator.toplevelview.' + context.code) } + ) } + useNavigatorUiStore.getState().openCreator() }> + + + + + { !isCreatorOpen && +
+ { isOpenSavesSearches && +
+ +
} +
+ +
+ { searchResult && searchResult.results.map((result, index) => ) } + { searchResult && (!searchResult.results || searchResult.results.length === 0) && +
+ { LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') } +
} +
+ + useNavigatorUiStore.getState().openCreator() }> + + { LocalizeText('navigator.createroom.create') } + + + { searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view' && + SendMessageComposer(new FindNewFriendsMessageComposer()) }> + + { LocalizeText('navigator.random.room') } + + } + { (searchResult?.code === 'myworld_view' || searchResult?.code === 'roomads_view') && + CreateLinkEvent('catalog/open/room_event') }> + + { LocalizeText('navigator.promote.room') } + + } + +
+
} + { isCreatorOpen && } +
+
+ + { isRoomInfoOpen && useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> } + { isRoomLinkOpen && useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> } + + + ); +}; +``` + +Key changes: +- 9 `useState` → 3 filter hooks (`useNavigatorData`, `useNavigatorUiState`, `useNavigatorActions`) + direct `useNavigatorUiStore.getState()` calls in handlers +- `sendSearch` and `reloadCurrentSearch` removed from this file — they're in `useNavigatorStore` now +- `linkTracker` body becomes a clean dispatch table on `store.show()` / `store.hide()` / etc. +- `NavigatorSearchView` no longer receives `sendSearch` as a prop — Task 7 updates that consumer too + +- [ ] **Step 3: Verify typecheck** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | grep NavigatorView +``` + +Expected: no errors in `NavigatorView.tsx`. (Other consumer files still red — fixed in Tasks 7-8.) + +- [ ] **Step 4: Do NOT commit yet** — bundle with the rest in Task 8. + +--- + +## Task 7: Migrate `NavigatorSearchView.tsx` (drop the prop) + +**Files:** +- Modify: `src/components/navigator/views/search/NavigatorSearchView.tsx` + +- [ ] **Step 1: Read the current file** + +```powershell +cd Nitro-V3 ; cat src/components/navigator/views/search/NavigatorSearchView.tsx +``` + +- [ ] **Step 2: Apply the swap** + +Find and replace inside the file: + +| Before | After | +|---|---| +| `import { useNavigator } from '../../../../hooks';` | `import { useNavigatorActions, useNavigatorData } from '../../../../hooks';` | +| `const { topLevelContext = null } = useNavigator();` | `const { topLevelContext } = useNavigatorData();` | +| The `sendSearch` prop from the component's signature | DELETED | +| `sendSearch(value, code)` calls in handlers | replace with destructured local: `const { sendSearch } = useNavigatorActions();` and call `sendSearch(...)` | + +(Exact line-by-line edit — read the file then mechanically apply the table above. If the file uses `sendSearch` from props, the JSX type for the component changes too.) + +- [ ] **Step 3: Verify typecheck** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | grep NavigatorSearchView +``` + +Expected: no errors. + +- [ ] **Step 4: Do NOT commit yet** — bundle in Task 8. + +--- + +## Task 8: Migrate the remaining 10 bulk consumers + +**Files (10 modifications):** +- `src/components/navigator/views/NavigatorRoomCreatorView.tsx` +- `src/components/navigator/views/NavigatorRoomInfoView.tsx` +- `src/components/navigator/views/NavigatorRoomLinkView.tsx` +- `src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx` +- `src/components/navigator/views/search/NavigatorSearchResultItemView.tsx` +- `src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx` +- `src/components/navigator/views/search/NavigatorSearchResultView.tsx` +- `src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx` +- `src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx` +- `src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx` + +- [ ] **Step 1: For each of the 10 files above, apply this mechanical swap** + +| Before | After | +|---|---| +| `import { useNavigator } from '...../hooks';` | `import { useNavigatorData } from '...../hooks';` (keep the same relative path) | +| `const { X = ..., Y = ..., ... } = useNavigator();` | `const { X, Y, ... } = useNavigatorData();` (drop the `= null` / `= []` defaults — the new filter always returns the same shape) | + +**Spot-checks per file** (verify you've changed nothing else): + +- `NavigatorRoomCreatorView`: reads `categories` only +- `NavigatorRoomInfoView`: reads `navigatorData` and `favouriteRoomIds` +- `NavigatorRoomLinkView`: reads `navigatorData.enteredGuestRoom` +- `NavigatorRoomSettingsBasicTabView`: reads `categories` +- `NavigatorSearchResultItemView`: reads `favouriteRoomIds` and `navigatorData` +- `NavigatorSearchResultItemInfoView`: reads `navigatorData` +- `NavigatorSearchResultView`: reads `topLevelContext` +- `CatalogLayoutRoomAdsView`: reads `navigatorData.currentRoomId` +- `RoomFilterWordsWidgetView`: reads `navigatorData.currentRoomId` +- `RoomToolsWidgetView`: reads `navigatorData` + +- [ ] **Step 2: Run full typecheck** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -15 +``` + +Expected: no NEW errors. (Pre-existing floorplan errors `applyFloorModelLocally` / JSX namespace may still appear — they are NOT introduced by P1 and may be present on `origin/Dev` independently of this work.) + +- [ ] **Step 3: Run full test suite** + +```powershell +cd Nitro-V3 ; yarn test --run 2>&1 | tail -10 +``` + +Expected: all suites pass, including the 3 new ones from this PR. + +- [ ] **Step 4: Run lint:hooks** + +```powershell +cd Nitro-V3 ; yarn lint:hooks 2>&1 | tail -5 +``` + +Expected: clean. + +- [ ] **Step 5: Commit the full consumer-migration sweep (Tasks 5, 6, 7, 8 atomic)** + +```powershell +cd Nitro-V3 +git add src/components/navigator/views/NavigatorDoorStateView.tsx src/components/navigator/NavigatorView.tsx src/components/navigator/views/search/NavigatorSearchView.tsx src/components/navigator/views/NavigatorRoomCreatorView.tsx src/components/navigator/views/NavigatorRoomInfoView.tsx src/components/navigator/views/NavigatorRoomLinkView.tsx src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx src/components/navigator/views/search/NavigatorSearchResultItemView.tsx src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx src/components/navigator/views/search/NavigatorSearchResultView.tsx src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "refactor(navigator): migrate all 13 consumers off useNavigator god-hook + +Mechanical swap to the new filter hooks landed in the previous commit: +- NavigatorDoorStateView -> useDoorState (snapshot/setSnapshot/reset) +- NavigatorView -> useNavigatorData + useNavigatorUiState + + useNavigatorActions + direct useNavigatorUiStore.getState() in handlers + (linkTracker collapsed to a dispatch table; 9 useState gone) +- NavigatorSearchView -> useNavigatorData + useNavigatorActions + (sendSearch prop drilling removed) +- 10 bulk consumers (one-line import swap) -> useNavigatorData + +Zero behavioural change intended. yarn typecheck + yarn test --run + +yarn lint:hooks all clean on this commit." +git push simoleo feat/navigator-modernization +``` + +--- + +## Task 9: Delete the old `useNavigator.ts` + final verification + +**Files:** +- Delete: `src/hooks/navigator/useNavigator.ts` + +- [ ] **Step 1: Verify no references remain** + +```powershell +cd Nitro-V3 ; grep -rn "from.*hooks/navigator/useNavigator" src/ --include="*.ts" --include="*.tsx" +cd Nitro-V3 ; grep -rn "useNavigator\b" src/ --include="*.ts" --include="*.tsx" | findstr /V /C:"useNavigatorData" /C:"useNavigatorUiState" /C:"useNavigatorActions" /C:"useNavigatorStore" /C:"useNavigatorUiStore" +``` + +Expected: both commands return no results (or only the deletion target itself). + +- [ ] **Step 2: Delete the file** + +```powershell +cd Nitro-V3 ; git rm src/hooks/navigator/useNavigator.ts +``` + +- [ ] **Step 3: Run the gate trio** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -10 +cd Nitro-V3 ; yarn test --run 2>&1 | tail -10 +cd Nitro-V3 ; yarn lint:hooks 2>&1 | tail -5 +``` + +Expected: all clean. + +- [ ] **Step 4: Manual smoke (development build)** + +Start the dev server. Verify each path renders identically to pre-P1: + +```powershell +cd Nitro-V3 ; yarn start +``` + +Then in the browser: + +- [ ] Open Navigator via toolbar icon → opens at default tab +- [ ] Click each top-level tab (Pubbliche / Tutte le stanze / Eventi / Il mio mondo) → results load, loading spinner shows briefly +- [ ] Type into filter input → search returns +- [ ] Open a room with NO door (your own room or a public) → enters directly +- [ ] Open a room with DOORBELL → doorbell prompt appears, click Ring, then close +- [ ] Open a room with PASSWORD → password prompt appears, type wrong password → "wrong password" message, then close +- [ ] Click favourite ☆ on a search result → star fills/empties +- [ ] Open RoomInfo (`navigator/toggle-room-info` link or in-room button) → opens, close again +- [ ] Open RoomLink (`navigator/toggle-room-link`) → opens, close again +- [ ] Open Room Creator (the `+` tab) → renders, close +- [ ] Close Navigator → all sub-windows hide + +If anything regresses → STOP, do NOT commit, investigate. + +- [ ] **Step 5: Commit + push final** + +```powershell +cd Nitro-V3 +git add src/hooks/navigator/useNavigator.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "refactor(navigator): remove deprecated useNavigator god-hook + +P1 complete. All 13 consumers migrated to the wired-tools-style split: +- useNavigatorData / useNavigatorUiState / useNavigatorActions (filters) +- useNavigatorStore (internal useBetween closure) +- navigatorUiStore (Zustand for 9 UI flags) +- useDoorState (extracted to src/hooks/rooms/widgets) + +Closes spec docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md. +Next phases: P2 (TanStack Query for search), P3 (reactive favourites +via snapshot), P4 (visual rework + virtualization + persistence)." +git push simoleo feat/navigator-modernization +``` + +- [ ] **Step 6: Open PR (optional, but recommended)** + +```powershell +cd Nitro-V3 ; gh pr create --base Dev --head simoleo89:feat/navigator-modernization --title "feat(navigator): wired-tools-style hook split + Zustand UI store (P1)" --body "## Summary +- Splits the 492-line useNavigator god-hook into useNavigatorStore + useNavigatorData / useNavigatorUiState / useNavigatorActions filters (wired-tools layout) +- Extracts door bell/password lifecycle to src/hooks/rooms/widgets/useDoorState +- Hoists the 9 useState in NavigatorView into a Zustand navigatorUiStore via createNitroStore +- Migrates all 13 consumers off useNavigator +- Removes the deprecated useNavigator shim entirely +- Zero user-visible change — spec marks the visual rework as P4 (separate plan) + +Spec: docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md +Plan: docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md + +## Test plan +- [x] yarn typecheck clean +- [x] yarn test --run green (+3 new suites: navigatorUiStore, useDoorState, useNavigatorStore smoke) +- [x] yarn lint:hooks clean +- [x] Manual smoke (see plan §9 step 4 checklist)" +``` + +(If the `gh` PR fails on `--base Dev` mapping, use `Dev` exactly as written; the duckietm upstream uses capital-D `Dev`.) + +--- + +## Self-review against spec + +After completing all tasks, verify: + +- [x] **§3.1 useNavigatorStore** — Task 3 creates this file +- [x] **§3.2 useNavigatorData/UiState/Actions** — Task 4 creates these +- [x] **§3.3 navigatorUiStore** — Task 1 creates this +- [x] **§3.4 useDoorState** — Task 2 creates this +- [x] **§4 13 consumer migration map** — Tasks 5/6/7/8 cover all 13 +- [x] **§5.1-5.3 dual-subscription** — Task 2 and Task 3 implement the mutually exclusive filters +- [x] **§7 testing strategy** — Tasks 1/2/4 create the 3 new suites +- [x] **§10 acceptance criteria** — Task 9 verifies all 9 acceptance items +- [x] **§11 risk register** — the intentionally-broken intermediate commit at Task 4 step 8 is documented and bracketed by a green commit in Task 8 step 5 diff --git a/docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md b/docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md new file mode 100644 index 0000000..3f13b4b --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md @@ -0,0 +1,549 @@ +# Navigator Modernization — P1: Hook Split + UI Store + +**Branch:** `feat/navigator-modernization` (forked from `origin/Dev` @ `d5d5ca59`) +**Date:** 2026-05-26 +**Scope:** P1 of a 4-phase Navigator modernization sweep (P1 → P2 → P3 → P4). +**This spec covers ONLY P1.** P2 (TanStack Query), P3 (reactive snapshots), +and P4 (visual rework + virtualization + persistence) will each get their +own spec when P1 lands. + +## 1. Context + +The Nitro-V3 client has established patterns for god-hook +modernization, all visible on the current `origin/Dev` tip: + +- **God-hook split into filters over a `useBetween` singleton.** Two + precedents: + - `useWiredTools` — 4 files (`useWiredToolsStore` + `useWiredToolsState` + + `useWiredToolsActions` + `useWiredTools` shim). 630-line store. + - `useCatalog` — single 1055-line file holding store + three filters + (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`). +- **Zustand UI stores** via `createNitroStore` (`src/state/createNitroStore.ts`) + for cross-feature UI flags. +- **Renderer snapshot consumer hooks** (`useSyncExternalStore`) — out of + scope for P1, used in P3. +- **`useNitroQuery`** for composer/parser request-response — out of + scope for P1, used in P2. +- **Co-located Vitest suites** under `src/`, sharing the renderer-SDK + stub at `src/nitro-renderer.mock.ts`. + +`src/hooks/navigator/useNavigator.ts` is the largest remaining god-hook +on this branch: 492 lines, 21 event listeners, 9 internal `useState`, +consumed by 13 files (10 inside `src/components/navigator/` + 3 +outside in `room-tools`, `room-filter-words`, and `catalog` views). It +mixes three logically separate concerns: + +1. **Navigator data** — search results, categories, top-level + contexts, favourites, metadata. +2. **Door state** — doorbell, password prompt, accepted / no-answer / + wrong-password lifecycle. +3. **Local UI flags** — 9 `useState` in `NavigatorView.tsx` controlling + panel visibility and search lifecycle. + +P1 separates these three and migrates all consumers. + +## 2. Decisions + +| Topic | Decision | +|---|---| +| Door state | **Extract** to `src/hooks/rooms/widgets/useDoorState.ts` | +| UI store scope | **All 9 flags** into `navigatorUiStore` Zustand | +| Shim retention | **Remove** `useNavigator` after all 13 consumers migrated | +| Filter shape | **Flat objects**, mirroring `useCatalog` and `useWiredTools` | +| File layout | **4 separate files**, mirroring `wired-tools` (not the monolithic `useCatalog.ts`) | +| Scope of P1 | **Pure refactor** — zero user-visible change | +| Branch | `feat/navigator-modernization` (forked from `origin/Dev`, not a sub-branch of any other modernization branch) | + +## 3. Architecture + +Mirrors the `wired-tools` layout exactly — 4 hook files in +`src/hooks/navigator/`, plus a sibling `navigatorUiStore.ts` for the +Zustand UI flags, plus `useDoorState.ts` extracted to +`src/hooks/rooms/widgets/`: + +``` +src/hooks/navigator/ +├── useNavigatorStore.ts ← NEW: internal useBetween closure +│ (data state + non-door listeners + actions) +├── useNavigatorData.ts ← NEW: public filter — read-only data +├── useNavigatorUiState.ts ← NEW: public filter — read-only UI flags +├── useNavigatorActions.ts ← NEW: public filter — imperative actions +├── navigatorUiStore.ts ← NEW: Zustand UI store (9 flags + actions) +├── index.ts ← REWRITTEN: barrel exports the 3 filters, +│ useNavigatorUiStore, and re-exports useDoorState +└── useNavigator.ts ← DELETED at end of P1 (god-hook shim removed) + +src/hooks/rooms/widgets/ +└── useDoorState.ts ← NEW: extracted door lifecycle +``` + +### 3.1 Internal `useNavigatorStore` closure (in `useNavigatorStore.ts`) + +The single `useBetween` singleton's internal function. Holds: + +- All non-door state currently in `useNavigatorState` of the old + `useNavigator.ts`: `categories`, `eventCategories`, + `favouriteRoomIds`, `topLevelContext`, `topLevelContexts`, + `searchResult`, `navigatorSearches`, `navigatorData`. +- All non-door event listeners (16 of them): `FavouritesEvent`, + `FavouriteChangedEvent`, `RoomSettingsUpdatedEvent`, + `CanCreateRoomEventEvent`, `UserInfoEvent`, `UserPermissionsEvent`, + `RoomForwardEvent`, `RoomEntryInfoMessageEvent`, + `NavigatorMetadataEvent`, `NavigatorSearchEvent`, + `UserFlatCatsEvent`, `UserEventCatsEvent`, `FlatCreatedEvent`, + `NavigatorHomeRoomEvent`, `RoomEnterErrorEvent`, + `NavigatorOpenRoomCreatorEvent`, `NavigatorSearchesEvent`, + plus `NitroEventType.SOCKET_RECONNECTING`. +- `GetGuestRoomResultEvent` — dual-subscribed (see §5.2). +- `GenericErrorEvent` — dual-subscribed (see §5.3). +- New imperative actions `sendSearch` and `reloadCurrentSearch`, + extracted from the current `NavigatorView.tsx` locals (today defined + on lines 42-79 of `src/components/navigator/NavigatorView.tsx`). + +### 3.2 The three filters (flat shape, wired-tools layout) + +```ts +// useNavigatorData.ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => { + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + } = useBetween(useNavigatorStore); + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + }; +}; + +// useNavigatorUiState.ts +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => { + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch, + }; +}; + +// useNavigatorActions.ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => { + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; +``` + +`useNavigatorActions` is intentionally small in P1 — favourite +toggles, room visits, and door responses keep flowing through their +existing direct composer calls in consumer components. We only hoist +the two functions that are currently prop-drilled into +`NavigatorSearchView` and the tab `onClick` handlers. + +`useNavigatorUiState` uses per-key Zustand selectors (one selector +per flag) so a component re-renders only when a flag it actually +reads changes. The flat object it returns preserves the API shape +consumers expect. + +### 3.3 `navigatorUiStore` (Zustand) + +```ts +// src/hooks/navigator/navigatorUiStore.ts +import { createNitroStore } from '../../state/createNitroStore'; + +type NavigatorUiState = { + isVisible: boolean; + isReady: boolean; + isCreatorOpen: boolean; + isRoomInfoOpen: boolean; + isRoomLinkOpen: boolean; + isOpenSavesSearches: boolean; + isLoading: boolean; + needsInit: boolean; + needsSearch: boolean; +}; + +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; // sets needsSearch = true + consumeSearchRequest(): void; // sets needsSearch = false +}; + +const INITIAL: NavigatorUiState = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, +}; + +export const useNavigatorUiStore = createNitroStore()((set) => ({ + ...INITIAL, + 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 }), +})); +``` + +The `linkTracker` in `NavigatorView.tsx` calls these actions directly +on `useNavigatorUiStore.getState()` instead of mutating local +`useState`. That collapses the switch statement from 30+ lines to a +clean dispatch table and eliminates the closure-over-stale-state hazard +where the tracker re-registers on every `isVisible` change (today at +`src/components/navigator/NavigatorView.tsx:162`). + +### 3.4 `useDoorState` (extracted to `src/hooks/rooms/widgets/`) + +```ts +// src/hooks/rooms/widgets/useDoorState.ts +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; // DoorStateType.* +}; + +const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE }; + +const useDoorStateStore = () => { + const [snapshot, setSnapshot] = useState(INITIAL); + + useMessageEvent(DoorbellMessageEvent, event => { + const parser = event.getParser(); + if (parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING })); + }); + + useMessageEvent(RoomDoorbellAcceptedEvent, event => { + const parser = event.getParser(); + if (parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED })); + }); + + useMessageEvent(FlatAccessDeniedMessageEvent, event => { + const parser = event.getParser(); + if (parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER })); + }); + + useMessageEvent(GenericErrorEvent, event => { + const parser = event.getParser(); + if (parser.errorCode !== -100002) return; // door-only error code + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD })); + }); + + useMessageEvent(GetGuestRoomResultEvent, event => { + const parser = event.getParser(); + // ONLY handle the roomForward branch with door modes + 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 }); + } else if (parser.data.doorMode === RoomDataParser.PASSWORD_STATE) { + setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD }); + } + }); + + const reset = useCallback(() => setSnapshot(INITIAL), []); + + return { snapshot, setSnapshot, reset }; +}; + +export const useDoorState = () => useBetween(useDoorStateStore); +``` + +The current `NavigatorDoorStateView.tsx` does +`setDoorData({ roomInfo: null, state: DoorStateType.NONE })` to reset +— after P1 it calls `reset()`. + +## 4. Consumer migration map (13 files) + +| File | Reads today | Reads after P1 | +|---|---|---| +| `NavigatorView.tsx` | full `useNavigator()` + 9 local useState | `useNavigatorData` + `useNavigatorActions` + `useNavigatorUiStore` (one selector per flag) | +| `NavigatorDoorStateView.tsx` | `doorData`, `setDoorData` | `useDoorState` (`snapshot`, `setSnapshot`, `reset`) | +| `NavigatorRoomCreatorView.tsx` | `categories` | `useNavigatorData` | +| `NavigatorRoomInfoView.tsx` | `navigatorData`, `favouriteRoomIds` | `useNavigatorData` | +| `NavigatorRoomLinkView.tsx` | `navigatorData.enteredGuestRoom` | `useNavigatorData` | +| `NavigatorRoomSettingsBasicTabView.tsx` | `categories` | `useNavigatorData` | +| `NavigatorSearchResultItemView.tsx` | `favouriteRoomIds`, `navigatorData` | `useNavigatorData` | +| `NavigatorSearchResultItemInfoView.tsx` | `navigatorData` | `useNavigatorData` | +| `NavigatorSearchResultView.tsx` | `topLevelContext` | `useNavigatorData` | +| `NavigatorSearchView.tsx` | `topLevelContext` + `sendSearch` prop | `useNavigatorData` + `useNavigatorActions` | +| `CatalogLayoutRoomAdsView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` | +| `RoomFilterWordsWidgetView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` | +| `RoomToolsWidgetView.tsx` | `navigatorData` | `useNavigatorData` | + +All 13 consumers get a one-line import swap (plus `NavigatorView` +which is more involved since it owns the 9 useState + linkTracker +dispatch + `sendSearch` prop drilling that all go away). No +behavioural change. + +## 5. Dual-subscription edge cases + +### 5.1 `useBetween` guarantee + +`useDoorState` uses `useBetween(useDoorStateStore)`, so multiple +consumers (currently only `NavigatorDoorStateView`) share a single +listener registration — same as how `useNavigatorStore` works. + +### 5.2 `GetGuestRoomResultEvent` — dual subscription + +Today this event is handled in one place (current `useNavigator.ts` +lines 130-209) with three branches: `roomEnter`, `roomForward`, else. +After P1: + +- `useDoorStateStore` subscribes and acts ONLY on the `roomForward` + branch when `doorMode` is `DOORBELL_STATE` or `PASSWORD_STATE` AND + the user is not the owner / not a group member. +- `useNavigatorStore` subscribes and handles `roomEnter`, the + `roomForward` branch WITHOUT door modes (direct `CreateRoomSession` + call), and the `else` branch. + +Multiple subscribers to the same event is an accepted pattern (see +`FlatCreatedEvent` listened in `useNavigator` and elsewhere). Both +listeners register through `useMessageEvent` so the renderer event +bus dispatches to both. + +### 5.3 `GenericErrorEvent` — dual subscription + +- `useDoorStateStore` acts ONLY on `errorCode === -100002` (wrong + password). +- `useNavigatorStore` acts on `4009`, `4010`, `4011`, `4013` (room + management alerts via `simpleAlert`). + +Each side filters by `errorCode` immediately — no cross-effects. + +## 6. Visual direction (anchor for P4 — informational only) + +P1 ships zero visual change. This section documents the visual +target that P4's spec will detail, so the architecture choices in +P1 align with where we are heading. + +### 6.1 Current pain points (from user screenshots, 2026-05-26) + +- **Tab "Pubbliche":** empty state is bare text "No rooms found". +- **Tab "Tutte le stanze":** popular rooms shown as a small thumbnail + grid; the "Party" category uses a compact list mode with no + visual hierarchy or live signal. +- **Tab "Eventi":** empty state is bare text "No rooms found". +- **Tab "Il mio mondo":** sparse list, no per-room preview. +- **Saved searches:** today a 600px-wide sidebar that resizes the + card and pushes content right. +- **Filter dropdown "Qualsiasi":** opaque about what filters exist. + +### 6.2 Target shape (P4 spec will detail) + +**Empty states with illustration + contextual CTA:** + +``` +┌─────────────────────────────────────┐ +│ Navigator @ Habbo [×] │ +│ [⚡][Pubbliche][Tutte][Eventi][Mio] │ +├─────────────────────────────────────┤ +│ [🔓 Aperte] [🚪 Campanello] [🔒] │ +│ [filtra stanze...] 🔍 │ +│ [🔖 staff] [🔖 party] [🔖 chill] + │ +├─────────────────────────────────────┤ +│ ╭──────────╮ │ +│ │ 🏠 ✨ │ │ +│ ╰──────────╯ │ +│ Nessuna stanza pubblica │ +│ ancora attiva │ +│ │ +│ [ Esplora stanze popolari → ] │ +├─────────────────────────────────────┤ +│ [+ Crea stanza] [Da qualche parte] │ +└─────────────────────────────────────┘ +``` + +**Card list with row-level hover-reveal:** + +``` +▼ Stanze più popolari [▦ ☰] [⚡] +┌─────────┐ Big Party Room +│ 🏠 🎵 │ 👤 22 · 🔓 Aperta · ★ 4.7 +│ (img) │ by @Cocco +└─────────┘ [Entra] [ⓘ] [☆ favori] ← shown on row hover +───────────────────────────────────────── +▼ Party [▦ ☰] [⚡] +🟢 fcfcvcvcv 👤2 🔓 [ⓘ] +🔒 aaaaa 👤1 🚪 [ⓘ] +``` + +**Saved searches as horizontal chip row** above the filter input +(replaces the 600px sidebar — no layout shift on toggle). + +**Filter intent as visible chips** instead of "Qualsiasi" dropdown: +`🔓 Aperte` `🚪 Campanello` `🔒 Con password` `👥 Solo amici`. + +**Sticky section headers** when scrolling long lists. + +**Skeleton loaders** during fetch (post-P2 when query state lands). + +**Per-card actions on hover**: favourite ☆, info ⓘ, room link 🔗. + +### 6.3 Why P1 architecture supports this + +- `useNavigatorUiStore` makes future flags (`viewMode: 'compact' | 'expanded'`, + `lastTab`, `lastScrollTop`) trivial to add — they're new state on + the store; persistence can be added with a Zustand `persist` + middleware on a single line. +- Splitting `useDoorState` out means the visual rework of the door + prompt (a separate panel, possibly modal) can evolve independently + of Navigator search UI. +- Three flat filters mean a new card variant (compact-vs-expanded + list) reads `useNavigatorData` only — no risk of re-rendering the + whole Navigator when card-mode toggles. + +## 7. Testing strategy + +Coherent with `CLAUDE.md` "`yarn test` must stay green on every +commit": + +| Suite | New / changed | Cases (target) | +|---|---|---| +| `navigatorUiStore.test.ts` | NEW | ~30: each action idempotent on no-op, transitions valid, `requestSearch`/`consumeSearchRequest` symmetric | +| `useDoorState.test.tsx` | NEW | ~12: each event listener happy path + filter-by-userName + filter-by-errorCode + reset() | +| `useNavigatorStore.test.tsx` | NEW (smoke) | ~5: 3 filters return expected shape, dispatch updates propagate to `useNavigatorData`, GenericError 4010 does NOT touch door state, GenericError -100002 DOES touch door state | +| Existing Vitest suites | Stay green | — | + +All tests co-located under `src/`, alongside their subject. Reuse +`src/nitro-renderer.mock.ts` for event dispatching (the +`mockEventDispatcher` / `clearMockEventDispatcher` helpers). + +CI gates that must stay green: `yarn typecheck` (TS 7 native), +`yarn test`, `yarn lint:hooks` (`react-hooks/rules-of-hooks: error`). + +## 8. Compatibility with project conventions + +`feat/navigator-modernization` is forked from `origin/Dev` @ `d5d5ca59`, +so it carries everything upstream has shipped through the floorplan +editor work + classic catalog view + emustats + housekeeping panel. +The design respects every constraint of this base: + +- **No new dependencies.** Uses `zustand` (present), `use-between` + (present), `vitest` (present), `createNitroStore` (present at + `src/state/createNitroStore.ts`). +- **React 19 idioms** identical to the rest of the codebase. No + manual `useMemo`/`useCallback` unless the React Compiler asks for + them. +- **TypeScript strict** consistent with the rest of the project. +- **Co-located tests** under `src/` per the layout convention. +- **No conflicts with adopted patterns**: `useNitroEvent`, + `useMessageEvent`, `useBetween`, `createNitroStore`. The new + filters expose plain data — they don't call snapshot hooks + (`useSyncExternalStore`) inside `useBetween` scopes, so the + documented "snapshot-outside-useBetween" constraint never + triggers here. +- **Commit author** per house rules: `simoleo89 + ` via per-command `-c` + overrides. **No Co-Authored-By trailer.** +- **Branch policy**: fresh branch off `origin/Dev`, pushable + fast-forward to `simoleo/feat/navigator-modernization` (which + doesn't yet exist on the fork — first push creates it). No + force-push required. + +## 9. Out of scope (explicit) + +- TanStack Query migration of search (P2). +- Reactive favourite icons via snapshot (P3). +- Live user counts via snapshot (P3). +- Virtualization of result list (P4). +- Empty-state component (P4). +- Saved-search chip row (P4). +- Persistence of tab/scroll/filter (P4). +- `useActionState` on search input (P6). +- `WidgetErrorBoundary` wrapping of Navigator sub-views (P5 — + independent, can land in parallel). +- Any visual change. P1 ships byte-identical UI. +- Any change to `NavigatorRoomSettings*` subtree (self-contained, + only reads `categories` in one tab). + +## 10. Acceptance criteria + +P1 is complete when: + +1. `src/hooks/navigator/useNavigator.ts` does NOT exist (god-hook + removed). +2. `src/hooks/navigator/` contains `useNavigatorStore.ts`, + `useNavigatorData.ts`, `useNavigatorUiState.ts`, + `useNavigatorActions.ts`, `navigatorUiStore.ts`, and an updated + `index.ts`. +3. `src/hooks/rooms/widgets/useDoorState.ts` exists. +4. All 13 active consumers compile after their import swap. +5. `yarn typecheck` clean. +6. `yarn lint:hooks` clean. +7. `yarn test --run` green, with at least 3 new suites + (`navigatorUiStore`, `useDoorState`, `useNavigatorStore` smoke). +8. Manual smoke test: open Navigator, switch each top-level tab, run + a search, open a room with a doorbell, get rejected, open a room + with a password, enter the right password, enter wrong password, + open a room you own, click a favourite ☆, open RoomInfo, open + RoomLink. Each path renders identically to pre-P1 behaviour. +9. Branch `feat/navigator-modernization` pushed (fast-forward only) + to `simoleo/feat/navigator-modernization` on the user's fork. + +## 11. Risk register + +| Risk | Likelihood | Mitigation | +|---|---|---| +| A consumer reads a field we forgot to expose on a filter | medium | Type-checker catches it — all 13 consumers re-typecheck on swap | +| Dual-subscription on `GetGuestRoomResultEvent` causes double `CreateRoomSession` | low | `useDoorStateStore` only acts on doorMode bell/password; `useNavigatorStore` only acts on the other branches. Explicit `if` guards on both sides | +| `linkTracker` re-registration leaks because deps changed | low | New tracker reads `useNavigatorUiStore.getState()` instead of closure-captured state, so its `useEffect` deps shrink | +| `useDoorState` consumer in `NavigatorDoorStateView` regresses on `reset()` semantics | low | Smoke test in §10 covers this | +| Per-key Zustand selectors in `useNavigatorUiState` cause stale-closure issues | low | Each selector is one-shot, no derived values; identical pattern to existing Zustand stores in the codebase | +| Renderer SDK mismatch on local dev (e.g. floorplan-live-preview not in renderer's main) | medium | Already exists today regardless of this PR; surface in plan as a `yarn typecheck` caveat, not introduced by P1 | diff --git a/src/api/room/widgets/BotSkillsEnum.ts b/src/api/room/widgets/BotSkillsEnum.ts index b879cdc..8eef2d1 100644 --- a/src/api/room/widgets/BotSkillsEnum.ts +++ b/src/api/room/widgets/BotSkillsEnum.ts @@ -11,6 +11,7 @@ export class BotSkillsEnum public static NUX_PROCEED: number = 8; public static CHANGE_BOT_MOTTO: number = 9; public static NUX_TAKE_TOUR: number = 10; + public static ROTATE: number = 11; public static NO_PICK_UP: number = 12; public static NAVIGATOR_SEARCH: number = 14; public static DONATE_TO_USER: number = 24; diff --git a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx index 8016c40..ade3125 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { LocalizeText, SendMessageComposer } from '../../../../../api'; import { useNitroQuery } from '../../../../../api/nitro-query'; import { Button, Column, Text } from '../../../../../common'; -import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks'; +import { useCatalogUiState, useNavigatorData, useRoomPromote } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; import { CatalogLayoutProps } from './CatalogLayout.types'; @@ -17,7 +17,7 @@ export const CatalogLayoutRoomAdsView: FC = props => const [ roomId, setRoomId ] = useState(-1); const [ extended, setExtended ] = useState(false); const [ categoryId, setCategoryId ] = useState(1); - const { categories = null } = useNavigator(); + const { categories } = useNavigatorData(); const { setIsVisible = null } = useCatalogUiState(); const { promoteInformation, isExtended, setIsExtended } = useRoomPromote(); diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index ec201b5..16c76cb 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -1,14 +1,14 @@ import { NitroCard } from '@layout/NitroCard'; -import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, NavigatorSearchComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useRef } from 'react'; import { FaPlus } from 'react-icons/fa'; import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png'; import createRoomImg from '../../assets/images/navigator/create_room.png'; import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; -import { Flex, Text } from '../../common'; -import { useNavigator, useNitroEvent } from '../../hooks'; +import { Flex, Text, WidgetErrorBoundary } from '../../common'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; @@ -20,184 +20,106 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView'; export const NavigatorView: FC<{}> = props => { - const [ isVisible, setIsVisible ] = useState(false); - const [ isReady, setIsReady ] = useState(false); - const [ isCreatorOpen, setCreatorOpen ] = useState(false); - const [ isRoomInfoOpen, setRoomInfoOpen ] = useState(false); - const [ isRoomLinkOpen, setRoomLinkOpen ] = useState(false); - const [ isOpenSavesSearches, setIsOpenSavesSearches ] = useState(false); - const [ isLoading, setIsLoading ] = useState(false); - const [ needsInit, setNeedsInit ] = useState(true); - const [ needsSearch, setNeedsSearch ] = useState(false); - const { searchResult = null, topLevelContext = null, topLevelContexts = null, navigatorData = null, navigatorSearches = null } = useNavigator(); + const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); + const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); const pendingSearch = useRef<{ value: string, code: string }>(null); const elementRef = useRef(null); useNitroEvent(RoomSessionEvent.CREATED, event => { - setIsVisible(false); - setCreatorOpen(false); + useNavigatorUiStore.getState().hide(); + useNavigatorUiStore.getState().closeCreator(); }); - const sendSearch = useCallback((searchValue: string, contextCode: string) => - { - setCreatorOpen(false); - - SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); - - setIsLoading(true); - }, []); - - const reloadCurrentSearch = useCallback(() => - { - if(!isReady) - { - setNeedsSearch(true); - - return; - } - - if(pendingSearch.current) - { - sendSearch(pendingSearch.current.value, pendingSearch.current.code); - - pendingSearch.current = null; - - return; - } - - if(searchResult) - { - sendSearch(searchResult.data, searchResult.code); - - return; - } - - if(!topLevelContext) return; - - sendSearch('', topLevelContext.code); - }, [ isReady, searchResult, topLevelContext, sendSearch ]); - useEffect(() => { const linkTracker: ILinkEventTracker = { linkReceived: (url: string) => { const parts = url.split('/'); - if(parts.length < 2) return; - + const store = useNavigatorUiStore.getState(); switch(parts[1]) { - case 'show': { - setIsVisible(true); - setNeedsSearch(true); + case 'show': + store.show(); return; - } case 'hide': - setIsVisible(false); + store.hide(); return; - case 'toggle': { - if(isVisible) - { - setIsVisible(false); - - return; - } - - setIsVisible(true); - setNeedsSearch(true); + case 'toggle': + store.toggle(); return; - } case 'toggle-room-info': - setRoomInfoOpen(value => !value); + store.toggleRoomInfo(); return; case 'toggle-room-link': - setRoomLinkOpen(value => !value); + store.toggleRoomLink(); return; case 'goto': if(parts.length <= 2) return; - - switch(parts[2]) + if(parts[2] === 'home') { - case 'home': - if(navigatorData.homeRoomId <= 0) return; - - TryVisitRoom(navigatorData.homeRoomId); - break; - default: { - const roomId = parseInt(parts[2]); - - TryVisitRoom(roomId); - } + if(navigatorData.homeRoomId <= 0) return; + TryVisitRoom(navigatorData.homeRoomId); + return; } + TryVisitRoom(parseInt(parts[2])); return; case 'create': - setIsVisible(true); - setCreatorOpen(true); + store.openCreator(); return; case 'search': - if(parts.length > 2) - { - const topLevelContextCode = parts[2]; - - let searchValue = ''; - - if(parts.length > 3) searchValue = parts[3]; - - pendingSearch.current = { value: searchValue, code: topLevelContextCode }; - - setIsVisible(true); - setNeedsSearch(true); - } + if(parts.length <= 2) return; + pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] }; + store.show(); return; } }, eventUrlPrefix: 'navigator/' }; - AddLinkEventTracker(linkTracker); - return () => RemoveLinkEventTracker(linkTracker); - }, [ isVisible, navigatorData ]); + }, [ navigatorData ]); useEffect(() => { if(!searchResult) return; - - setIsLoading(false); - - if(elementRef && elementRef.current) elementRef.current.scrollTop = 0; + if(elementRef.current) elementRef.current.scrollTop = 0; }, [ searchResult ]); useEffect(() => { if(!isVisible || !isReady || !needsSearch) return; - - reloadCurrentSearch(); - - setNeedsSearch(false); - }, [ isVisible, isReady, needsSearch, reloadCurrentSearch ]); + if(pendingSearch.current) + { + sendSearch(pendingSearch.current.value, pendingSearch.current.code); + pendingSearch.current = null; + } + else + { + reloadCurrentSearch(); + } + useNavigatorUiStore.getState().consumeSearchRequest(); + }, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]); useEffect(() => { if(isReady || !topLevelContext) return; - - setIsReady(true); + useNavigatorUiStore.getState().markReady(); }, [ isReady, topLevelContext ]); useEffect(() => { if(!isVisible || !needsInit) return; - SendMessageComposer(new NavigatorInitComposer()); - - setNeedsInit(false); + useNavigatorUiStore.getState().markInitDone(); }, [ isVisible, needsInit ]); useEffect(() => { - LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string, _arg_2: boolean = false, _arg_3: string = null) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k))); + LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k))); }, []); return ( @@ -208,28 +130,24 @@ export const NavigatorView: FC<{}> = props => uniqueKey="navigator"> setIsVisible(false) } /> + onCloseClick={ () => useNavigatorUiStore.getState().hide() } /> setIsOpenSavesSearches(prev => !prev) }> + onClick={ () => useNavigatorUiStore.getState().toggleSavesSearches() }> - { topLevelContexts && (topLevelContexts.length > 0) && topLevelContexts.map((context, index) => - { - return ( - sendSearch('', context.code) }> - { LocalizeText(('navigator.toplevelview.' + context.code)) } - - ); - }) } + { topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) => + sendSearch('', context.code) }> + { LocalizeText('navigator.toplevelview.' + context.code) } + ) } setCreatorOpen(true) }> + onClick={ () => useNavigatorUiStore.getState().openCreator() }> @@ -241,49 +159,37 @@ export const NavigatorView: FC<{}> = props => }
- +
- { (searchResult && searchResult.results.map((result, index) => )) } - { (searchResult && (!searchResult.results || (searchResult.results.length === 0))) && + { searchResult && searchResult.results.map((result, index) => ) } + { searchResult && (!searchResult.results || searchResult.results.length === 0) &&
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
}
- setCreatorOpen(true) } - > + onClick={ () => useNavigatorUiStore.getState().openCreator() }> { LocalizeText('navigator.createroom.create') } - { (searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view') && - SendMessageComposer(new FindNewFriendsMessageComposer()) } - > + onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }> { LocalizeText('navigator.random.room') } } { (searchResult?.code === 'myworld_view' || searchResult?.code === 'roomads_view') && - CreateLinkEvent('catalog/open/room_event') } - > + onClick={ () => CreateLinkEvent('catalog/open/room_event') }> { LocalizeText('navigator.promote.room') } @@ -291,13 +197,26 @@ export const NavigatorView: FC<{}> = props =>
} - { isCreatorOpen && } + { isCreatorOpen && + + + } } - - { isRoomInfoOpen && setRoomInfoOpen(false) } /> } - { isRoomLinkOpen && setRoomLinkOpen(false) } /> } - + + + + { isRoomInfoOpen && + + useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> + } + { isRoomLinkOpen && + + useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> + } + + + ); }; diff --git a/src/components/navigator/views/NavigatorDoorStateView.tsx b/src/components/navigator/views/NavigatorDoorStateView.tsx index 0dcaa45..709e1e7 100644 --- a/src/components/navigator/views/NavigatorDoorStateView.tsx +++ b/src/components/navigator/views/NavigatorDoorStateView.tsx @@ -1,88 +1,68 @@ import { FC, useEffect, useState } from 'react'; import { CreateRoomSession, DoorStateType, GoToDesktop, LocalizeText } from '../../../api'; import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common'; -import { useNavigator } from '../../../hooks'; +import { useDoorState } from '../../../hooks'; import { NitroInput } from '../../../layout'; const VISIBLE_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER, DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ]; const DOORBELL_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER ]; -const PASSWORD_STATES = [ DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ]; export const NavigatorDoorStateView: FC<{}> = props => { const [ password, setPassword ] = useState(''); - const { doorData = null, setDoorData = null } = useNavigator(); + const { snapshot, setSnapshot, reset } = useDoorState(); const onClose = () => { - if(doorData && (doorData.state === DoorStateType.STATE_WAITING)) GoToDesktop(); - - setDoorData(null); + if(snapshot.state === DoorStateType.STATE_WAITING) GoToDesktop(); + reset(); }; const ring = () => { - if(!doorData || !doorData.roomInfo) return; - - CreateRoomSession(doorData.roomInfo.roomId); - - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_PENDING_SERVER; - - return newValue; - }); + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); }; const tryEntering = () => { - if(!doorData || !doorData.roomInfo) return; - - CreateRoomSession(doorData.roomInfo.roomId, password); - - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_PENDING_SERVER; - - return newValue; - }); + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId, password); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); }; useEffect(() => { - if(!doorData || (doorData.state !== DoorStateType.STATE_NO_ANSWER)) return; - + if(snapshot.state !== DoorStateType.STATE_NO_ANSWER) return; GoToDesktop(); - }, [ doorData ]); + }, [ snapshot.state ]); - if(!doorData || (doorData.state === DoorStateType.NONE) || (VISIBLE_STATES.indexOf(doorData.state) === -1)) return null; + if(snapshot.state === DoorStateType.NONE) return null; + if(VISIBLE_STATES.indexOf(snapshot.state) === -1) return null; - const isDoorbell = (DOORBELL_STATES.indexOf(doorData.state) >= 0); + const isDoorbell = DOORBELL_STATES.indexOf(snapshot.state) >= 0; return (
- { doorData && doorData.roomInfo && doorData.roomInfo.roomName } - { (doorData.state === DoorStateType.START_DOORBELL) && + { snapshot.roomInfo && snapshot.roomInfo.roomName } + { snapshot.state === DoorStateType.START_DOORBELL && { LocalizeText('navigator.doorbell.info') } } - { (doorData.state === DoorStateType.STATE_WAITING) && + { snapshot.state === DoorStateType.STATE_WAITING && { LocalizeText('navigator.doorbell.waiting') } } - { (doorData.state === DoorStateType.STATE_NO_ANSWER) && + { snapshot.state === DoorStateType.STATE_NO_ANSWER && { LocalizeText('navigator.doorbell.no.answer') } } - { (doorData.state === DoorStateType.START_PASSWORD) && + { snapshot.state === DoorStateType.START_PASSWORD && { LocalizeText('navigator.password.info') } } - { (doorData.state === DoorStateType.STATE_WRONG_PASSWORD) && + { snapshot.state === DoorStateType.STATE_WRONG_PASSWORD && { LocalizeText('navigator.password.retryinfo') } }
{ isDoorbell &&
- { (doorData.state === DoorStateType.START_DOORBELL) && + { snapshot.state === DoorStateType.START_DOORBELL && } diff --git a/src/components/navigator/views/NavigatorRoomCreatorView.tsx b/src/components/navigator/views/NavigatorRoomCreatorView.tsx index af278c6..ad47bf0 100644 --- a/src/components/navigator/views/NavigatorRoomCreatorView.tsx +++ b/src/components/navigator/views/NavigatorRoomCreatorView.tsx @@ -3,7 +3,7 @@ import { CreateFlatMessageComposer, HabboClubLevelEnum } from '@nitrots/nitro-re import { FC, useEffect, useState } from 'react'; import { GetClubMemberLevel, GetConfigurationValue, IRoomModel, LocalizeText, SendMessageComposer } from '../../../api'; import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '../../../common'; -import { useNavigator } from '../../../hooks'; +import { useNavigatorData } from '../../../hooks'; import { NitroInput } from '../../../layout'; import { useRoomCreatorStore } from './navigatorRoomCreatorStore'; @@ -25,7 +25,7 @@ export const NavigatorRoomCreatorView: FC = () => }); const isCreating = useRoomCreatorStore(s => s.isCreating); const beginCreate = useRoomCreatorStore(s => s.beginCreate); - const { categories = null } = useNavigator(); + const { categories } = useNavigatorData(); const hcDisabled = GetConfigurationValue('hc.disabled', false); diff --git a/src/components/navigator/views/NavigatorRoomInfoView.tsx b/src/components/navigator/views/NavigatorRoomInfoView.tsx index 2073c15..ca5686c 100644 --- a/src/components/navigator/views/NavigatorRoomInfoView.tsx +++ b/src/components/navigator/views/NavigatorRoomInfoView.tsx @@ -4,7 +4,7 @@ import { FaLink, FaSignOutAlt } from 'react-icons/fa'; import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common'; import { RoomWidgetThumbnailEvent } from '../../../events'; -import { useHasPermission, useHelp, useNavigator, useRoom } from '../../../hooks'; +import { useHasPermission, useHelp, useNavigatorData, useRoom } from '../../../hooks'; import { classNames } from '../../../layout'; export interface NavigatorRoomInfoViewProps { @@ -17,7 +17,7 @@ export const NavigatorRoomInfoView: FC = props => const [ isRoomPicked, setIsRoomPicked ] = useState(false); const [ isRoomMuted, setIsRoomMuted ] = useState(false); const { report = null } = useHelp(); - const { navigatorData = null, favouriteRoomIds = [] } = useNavigator(); + const { navigatorData, favouriteRoomIds } = useNavigatorData(); const { roomSession = null } = useRoom(); const canManageAnyRoom = useHasPermission('acc_anyroomowner'); const canStaffPick = useHasPermission('acc_staff_pick'); diff --git a/src/components/navigator/views/NavigatorRoomLinkView.tsx b/src/components/navigator/views/NavigatorRoomLinkView.tsx index 033507d..0d1a74f 100644 --- a/src/components/navigator/views/NavigatorRoomLinkView.tsx +++ b/src/components/navigator/views/NavigatorRoomLinkView.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { GetConfigurationValue, LocalizeText } from '../../../api'; import { LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common'; -import { useNavigator } from '../../../hooks'; +import { useNavigatorData } from '../../../hooks'; export class NavigatorRoomLinkViewProps { @@ -11,7 +11,7 @@ export class NavigatorRoomLinkViewProps export const NavigatorRoomLinkView: FC = props => { const { onCloseClick = null } = props; - const { navigatorData = null } = useNavigator(); + const { navigatorData } = useNavigatorData(); if(!navigatorData.enteredGuestRoom) return null; diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx index cacaa59..526767c 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { FaTimes } from 'react-icons/fa'; import { CreateLinkEvent, GetMaxVisitorsList, IRoomData, LocalizeText, SendMessageComposer } from '../../../../api'; import { Base, Column, Flex, Text } from '../../../../common'; -import { useMessageEvent, useNavigator, useNotification } from '../../../../hooks'; +import { useMessageEvent, useNavigatorData, useNotification } from '../../../../hooks'; const ROOM_NAME_MIN_LENGTH = 3; const ROOM_NAME_MAX_LENGTH = 60; @@ -27,7 +27,7 @@ export const NavigatorRoomSettingsBasicTabView: FC(''); const { showConfirm = null } = useNotification(); - const { categories = null } = useNavigator(); + const { categories } = useNavigatorData(); useMessageEvent(RoomSettingsSaveErrorEvent, event => { diff --git a/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx b/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx index 764ce1e..c60d21d 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx @@ -4,7 +4,7 @@ import React, { FC, useRef, useState } from 'react'; import { FaUser } from 'react-icons/fa'; import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api'; import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common'; -import { useHelp, useNavigator } from '../../../../hooks'; +import { useHelp, useNavigatorData } from '../../../../hooks'; import { classNames } from '../../../../layout'; interface NavigatorSearchResultItemInfoViewProps @@ -20,7 +20,7 @@ export const NavigatorSearchResultItemInfoView: FC(null); const [ internalVisible, setInternalVisible ] = useState(false); - const { navigatorData = null, favouriteRoomIds = [] } = useNavigator(); + const { navigatorData, favouriteRoomIds } = useNavigatorData(); const { report = null } = useHelp(); const isControlled = isVisible !== undefined; diff --git a/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx b/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx index 686d598..045b33c 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx @@ -3,7 +3,7 @@ import React, { FC, MouseEvent, useEffect } from 'react'; import { FaUser } from 'react-icons/fa'; import { CreateRoomSession, DoorStateType, TryVisitRoom } from '../../../../api'; import { Column, Flex, LayoutBadgeImageView, LayoutGridItemProps, LayoutRoomThumbnailView, Text } from '../../../../common'; -import { useNavigator } from '../../../../hooks'; +import { useDoorState } from '../../../../hooks'; import { NavigatorSearchResultItemInfoView } from './NavigatorSearchResultItemInfoView'; export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps @@ -19,7 +19,7 @@ export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps export const NavigatorSearchResultItemView: FC = props => { const { roomData = null, children = null, thumbnail = false, selectedRoomId, setSelectedRoomId, isPopoverActive, setIsPopoverActive, ...rest } = props; - const { setDoorData = null } = useNavigator(); + const { setSnapshot: setDoorData } = useDoorState(); const handleMouseEnter = () => { diff --git a/src/components/navigator/views/search/NavigatorSearchResultView.tsx b/src/components/navigator/views/search/NavigatorSearchResultView.tsx index e8432c7..ddb2cd1 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { FaBars, FaMinus, FaPlus, FaTh, FaWindowMaximize, FaWindowRestore } from 'react-icons/fa'; import { LocalizeText, NavigatorSearchResultViewDisplayMode, SendMessageComposer } from '../../../../api'; import { AutoGrid, AutoGridProps, Column, Flex, Grid, LayoutSearchSavesView, Text } from '../../../../common'; -import { useNavigator } from '../../../../hooks'; +import { useNavigatorData } from '../../../../hooks'; import { NavigatorSearchResultItemView } from './NavigatorSearchResultItemView'; export interface NavigatorSearchResultViewProps extends AutoGridProps @@ -19,7 +19,7 @@ export const NavigatorSearchResultView: FC = pro const [ selectedRoomId, setSelectedRoomId ] = useState(null); const [ isPopoverActive, setIsPopoverActive ] = useState(false); - const { topLevelContext = null } = useNavigator(); + const { topLevelContext } = useNavigatorData(); const getResultTitle = () => { diff --git a/src/components/navigator/views/search/NavigatorSearchView.tsx b/src/components/navigator/views/search/NavigatorSearchView.tsx index 040e1c0..18980b6 100644 --- a/src/components/navigator/views/search/NavigatorSearchView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchView.tsx @@ -2,16 +2,14 @@ import { FC, KeyboardEvent, useEffect, useState } from 'react'; import { FaSearch } from 'react-icons/fa'; import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api'; import { Button } from '../../../../common'; -import { useNavigator } from '../../../../hooks'; +import { useNavigatorActions, useNavigatorData } from '../../../../hooks'; -export const NavigatorSearchView: FC<{ - sendSearch: (searchValue: string, contextCode: string) => void; -}> = props => +export const NavigatorSearchView: FC<{}> = props => { - const { sendSearch = null } = props; const [ searchFilterIndex, setSearchFilterIndex ] = useState(0); const [ searchValue, setSearchValue ] = useState(''); - const { topLevelContext = null, searchResult = null } = useNavigator(); + const { topLevelContext, searchResult } = useNavigatorData(); + const { sendSearch } = useNavigatorActions(); const processSearch = () => { diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx index f9e962b..9953ace 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx @@ -109,6 +109,10 @@ export const AvatarInfoWidgetRentableBotView: FC processAction('dance') }> { LocalizeText('avatar.widget.dance') } } + { (avatarInfo.botSkills.indexOf(BotSkillsEnum.ROTATE) >= 0) && + processAction('rotate') }> + { LocalizeText('tooltip.roombuilding.rotate') } + } { (avatarInfo.botSkills.indexOf(BotSkillsEnum.NO_PICK_UP) === -1) && processAction('pick') }> { LocalizeText('avatar.widget.pick_up') } diff --git a/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx b/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx index bbed44b..169b808 100644 --- a/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx +++ b/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx @@ -2,7 +2,7 @@ import { UpdateRoomFilterMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; import { LocalizeText, SendMessageComposer } from '../../../../api'; import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useFilterWordsWidget, useNavigator } from '../../../../hooks'; +import { useFilterWordsWidget, useNavigatorData } from '../../../../hooks'; import { NitroInput, classNames } from '../../../../layout'; export const RoomFilterWordsWidgetView: FC<{}> = props => @@ -11,7 +11,7 @@ export const RoomFilterWordsWidgetView: FC<{}> = props => const [ selectedWord, setSelectedWord ] = useState(''); const [ isSelectingWord, setIsSelectingWord ] = useState(false); const { wordsFilter = [], isVisible = null, setWordsFilter, onClose = null } = useFilterWordsWidget(); - const { navigatorData = null } = useNavigator(); + const { navigatorData } = useNavigatorData(); const processAction = (isAddingWord: boolean) => { diff --git a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx index 01582ef..a256afe 100644 --- a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx +++ b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx @@ -4,7 +4,7 @@ import { classNames } from '../../../../layout'; import { FC, useEffect, useState } from 'react'; import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api'; import { Text } from '../../../../common'; -import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks'; +import { useMessageEvent, useNavigatorData, useRoom } from '../../../../hooks'; import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi'; export const RoomToolsWidgetView: FC<{}> = props => @@ -18,7 +18,7 @@ export const RoomToolsWidgetView: FC<{}> = props => const [isOpenHistory, setIsOpenHistory] = useState(false); const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]); const [plugins, setPlugins] = useState([]); - const { navigatorData = null } = useNavigator(); + const { navigatorData } = useNavigatorData(); const { roomSession = null } = useRoom(); // Subscribe to external plugin changes diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index 1f6f053..c621851 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1 +1,7 @@ -export * from './useNavigator'; +export { useNavigatorActions } from './useNavigatorActions'; +export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorUiState } from './useNavigatorUiState'; +export { useNavigatorUiStore } from './navigatorUiStore'; +export { useDoorState } from '../rooms/widgets/useDoorState'; +export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState'; +export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore'; diff --git a/src/hooks/navigator/navigatorUiStore.test.ts b/src/hooks/navigator/navigatorUiStore.test.ts new file mode 100644 index 0000000..ebc41fa --- /dev/null +++ b/src/hooks/navigator/navigatorUiStore.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +const INITIAL = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false +}; + +describe('useNavigatorUiStore', () => +{ + beforeEach(() => + { + useNavigatorUiStore.setState(INITIAL); + }); + + it('exposes the documented defaults', () => + { + const s = useNavigatorUiStore.getState(); + expect(s.isVisible).toBe(false); + expect(s.isReady).toBe(false); + expect(s.isCreatorOpen).toBe(false); + expect(s.isRoomInfoOpen).toBe(false); + expect(s.isRoomLinkOpen).toBe(false); + expect(s.isOpenSavesSearches).toBe(false); + expect(s.isLoading).toBe(false); + expect(s.needsInit).toBe(true); + expect(s.needsSearch).toBe(false); + }); + + describe('show / hide / toggle', () => + { + it('show() sets isVisible true and requests a search', () => + { + useNavigatorUiStore.getState().show(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + }); + + it('hide() sets isVisible false without touching needsSearch', () => + { + useNavigatorUiStore.setState({ isVisible: true, needsSearch: false }); + useNavigatorUiStore.getState().hide(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + + it('toggle() flips visibility and requests a search on show', () => + { + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + + useNavigatorUiStore.setState({ needsSearch: false }); + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); + + describe('creator panel', () => + { + it('openCreator() opens both visible and creator', () => + { + useNavigatorUiStore.getState().openCreator(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(true); + }); + + it('closeCreator() closes only the creator panel', () => + { + useNavigatorUiStore.setState({ isVisible: true, isCreatorOpen: true }); + useNavigatorUiStore.getState().closeCreator(); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(false); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + }); + }); + + describe('roomInfo / roomLink / savesSearches', () => + { + it('setRoomInfoOpen(true) and toggleRoomInfo flip the flag', () => + { + useNavigatorUiStore.getState().setRoomInfoOpen(true); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomInfo(); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(false); + }); + + it('setRoomLinkOpen(true) and toggleRoomLink flip the flag', () => + { + useNavigatorUiStore.getState().setRoomLinkOpen(true); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomLink(); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(false); + }); + + it('toggleSavesSearches() flips the sidebar flag', () => + { + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(true); + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(false); + }); + }); + + describe('lifecycle flags', () => + { + it('setLoading(true) and setLoading(false) toggle isLoading', () => + { + useNavigatorUiStore.getState().setLoading(true); + expect(useNavigatorUiStore.getState().isLoading).toBe(true); + useNavigatorUiStore.getState().setLoading(false); + expect(useNavigatorUiStore.getState().isLoading).toBe(false); + }); + + it('markReady() sets isReady true and is idempotent', () => + { + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + }); + + it('markInitDone() flips needsInit to false', () => + { + useNavigatorUiStore.getState().markInitDone(); + expect(useNavigatorUiStore.getState().needsInit).toBe(false); + }); + + it('requestSearch() + consumeSearchRequest() are symmetric', () => + { + useNavigatorUiStore.getState().requestSearch(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + useNavigatorUiStore.getState().consumeSearchRequest(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); +}); diff --git a/src/hooks/navigator/navigatorUiStore.ts b/src/hooks/navigator/navigatorUiStore.ts new file mode 100644 index 0000000..527aab7 --- /dev/null +++ b/src/hooks/navigator/navigatorUiStore.ts @@ -0,0 +1,61 @@ +import { createNitroStore } from '../../state/createNitroStore'; + +export type NavigatorUiState = { + isVisible: boolean; + isReady: boolean; + isCreatorOpen: boolean; + isRoomInfoOpen: boolean; + isRoomLinkOpen: boolean; + isOpenSavesSearches: boolean; + isLoading: boolean; + needsInit: boolean; + needsSearch: boolean; +}; + +export type NavigatorUiActions = { + show(): void; + hide(): void; + toggle(): void; + openCreator(): void; + closeCreator(): void; + setRoomInfoOpen(open: boolean): void; + toggleRoomInfo(): void; + setRoomLinkOpen(open: boolean): void; + toggleRoomLink(): void; + toggleSavesSearches(): void; + setLoading(loading: boolean): void; + markReady(): void; + markInitDone(): void; + requestSearch(): void; + consumeSearchRequest(): void; +}; + +export const useNavigatorUiStore = createNitroStore()((set) => ({ + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, + + show: () => set({ isVisible: true, needsSearch: true }), + hide: () => set({ isVisible: false }), + toggle: () => set((s) => s.isVisible + ? { isVisible: false } + : { isVisible: true, needsSearch: true }), + openCreator: () => set({ isVisible: true, isCreatorOpen: true }), + closeCreator: () => set({ isCreatorOpen: false }), + setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }), + toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })), + setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }), + toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })), + toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })), + setLoading: (loading) => set({ isLoading: loading }), + markReady: () => set({ isReady: true }), + markInitDone: () => set({ needsInit: false }), + requestSearch: () => set({ needsSearch: true }), + consumeSearchRequest: () => set({ needsSearch: false }) +})); diff --git a/src/hooks/navigator/useNavigator.ts b/src/hooks/navigator/useNavigator.ts deleted file mode 100644 index a934aff..0000000 --- a/src/hooks/navigator/useNavigator.ts +++ /dev/null @@ -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(null); - const [ eventCategories, setEventCategories ] = useState(null); - const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); - const [ topLevelContext, setTopLevelContext ] = useState(null); - const [ topLevelContexts, setTopLevelContexts ] = useState(null); - const [ doorData, setDoorData ] = useState<{ roomInfo: RoomDataParser, state: number }>({ roomInfo: null, state: DoorStateType.NONE }); - const [ searchResult, setSearchResult ] = useState(null); - const [ navigatorSearches, setNavigatorSearches ] = useState(null); - const [ navigatorData, setNavigatorData ] = useState({ - settingsReceived: false, - homeRoomId: 0, - enteredGuestRoom: null, - currentRoomOwner: false, - currentRoomId: 0, - currentRoomIsStaffPick: false, - createdFlatId: 0, - avatarId: 0, - roomPicker: false, - eventMod: false, - currentRoomRating: 0, - canRate: true - }); - const { simpleAlert = null } = useNotification(); - - useMessageEvent(FavouritesEvent, event => - { - const parser = event.getParser(); - const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); - setFavouriteRoomIds(favoriteIds); - }); - - useMessageEvent(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, event => - { - const parser = event.getParser(); - - SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); - }); - - useMessageEvent(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, event => - { - SendMessageComposer(new GetUserFlatCatsMessageComposer()); - SendMessageComposer(new GetUserEventCatsMessageComposer()); - }); - - useMessageEvent(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, event => - { - const parser = event.getParser(); - - TryVisitRoom(parser.roomId); - }); - - useMessageEvent(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, 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('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, event => - { - const parser = event.getParser(); - - setNavigatorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.currentRoomRating = parser.totalLikes; - newValue.canRate = parser.canLike; - - return newValue; - }); - }); - - useMessageEvent(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, 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, 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, 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, event => - { - const parser = event.getParser(); - - setTopLevelContexts(parser.topLevelContexts); - setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); - }); - - useMessageEvent(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, event => - { - const parser = event.getParser(); - - setCategories(parser.categories); - }); - - useMessageEvent(UserEventCatsEvent, event => - { - const parser = event.getParser(); - - setEventCategories(parser.categories); - }); - - useMessageEvent(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, 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('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) - { - forwardType = 0; - SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); - } - - if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) - { - forwardType = parseInt(GetConfigurationValue('forward.type')); - forwardId = parseInt(GetConfigurationValue('forward.id')); - } - - if(forwardType === 2) - { - TryVisitRoom(forwardId); - } - - else if((forwardType === -1) && (parser.roomIdToEnter > 0)) - { - CreateLinkEvent('navigator/close'); - - if(parser.roomIdToEnter !== parser.homeRoomId) - { - CreateRoomSession(parser.roomIdToEnter); - } - else - { - CreateRoomSession(parser.homeRoomId); - } - } - }); - - useMessageEvent(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, event => CreateLinkEvent('navigator/show')); - - useMessageEvent(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); diff --git a/src/hooks/navigator/useNavigatorActions.ts b/src/hooks/navigator/useNavigatorActions.ts new file mode 100644 index 0000000..6a88e43 --- /dev/null +++ b/src/hooks/navigator/useNavigatorActions.ts @@ -0,0 +1,8 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => +{ + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts new file mode 100644 index 0000000..aeb05b7 --- /dev/null +++ b/src/hooks/navigator/useNavigatorData.ts @@ -0,0 +1,17 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => +{ + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + } = useBetween(useNavigatorStore); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + }; +}; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx new file mode 100644 index 0000000..a9040f4 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; + +describe('navigator filter shapes (smoke)', () => +{ + it('useNavigatorData returns the documented keys', () => + { + const { result } = renderHook(() => useNavigatorData()); + expect(Object.keys(result.current).sort()).toEqual([ + 'categories', 'eventCategories', 'favouriteRoomIds', + 'navigatorData', 'navigatorSearches', + 'searchResult', 'topLevelContext', 'topLevelContexts' + ].sort()); + }); + + it('useNavigatorUiState returns the 9 documented flags', () => + { + const { result } = renderHook(() => useNavigatorUiState()); + expect(Object.keys(result.current).sort()).toEqual([ + 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', + 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', + 'needsInit', 'needsSearch' + ].sort()); + }); + + it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => + { + const { result } = renderHook(() => useNavigatorActions()); + expect(typeof result.current.sendSearch).toBe('function'); + expect(typeof result.current.reloadCurrentSearch).toBe('function'); + }); +}); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts new file mode 100644 index 0000000..e3165d9 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -0,0 +1,348 @@ +import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, + FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent, + FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, + GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, + GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, + HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, + NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, + NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, + NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, + NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, + RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, + UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useRef, useState } from 'react'; +import { CreateRoomSession, GetConfigurationValue, INavigatorData, + LocalizeText, NotificationAlertType, SendMessageComposer, + TryVisitRoom, VisitDesktop } from '../../api'; +import { useMessageEvent, useNitroEvent } from '../events'; +import { useNotification } from '../notification'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorStore = () => +{ + const [ categories, setCategories ] = useState(null); + const [ eventCategories, setEventCategories ] = useState(null); + const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); + const [ topLevelContext, setTopLevelContext ] = useState(null); + const [ topLevelContexts, setTopLevelContexts ] = useState(null); + const [ searchResult, setSearchResult ] = useState(null); + const [ navigatorSearches, setNavigatorSearches ] = useState(null); + const [ navigatorData, setNavigatorData ] = useState({ + settingsReceived: false, + homeRoomId: 0, + enteredGuestRoom: null, + currentRoomOwner: false, + currentRoomId: 0, + currentRoomIsStaffPick: false, + createdFlatId: 0, + avatarId: 0, + roomPicker: false, + eventMod: false, + currentRoomRating: 0, + canRate: true + }); + + // Refs let handlers stay [] deps without losing access to fresh state. + const topLevelContextsRef = useRef(topLevelContexts); + topLevelContextsRef.current = topLevelContexts; + const topLevelContextRef = useRef(topLevelContext); + topLevelContextRef.current = topLevelContext; + const searchResultRef = useRef(searchResult); + searchResultRef.current = searchResult; + + const { simpleAlert = null } = useNotification(); + + const sendSearch = useCallback((searchValue: string, contextCode: string) => + { + useNavigatorUiStore.getState().closeCreator(); + SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); + useNavigatorUiStore.getState().setLoading(true); + }, []); + + const reloadCurrentSearch = useCallback(() => + { + if(!useNavigatorUiStore.getState().isReady) + { + useNavigatorUiStore.getState().requestSearch(); + return; + } + const sr = searchResultRef.current; + if(sr) + { + sendSearch(sr.data, sr.code); + return; + } + const ctx = topLevelContextRef.current; + if(!ctx) return; + sendSearch('', ctx.code); + }, [ sendSearch ]); + + useMessageEvent(FavouritesEvent, useCallback(event => + { + const parser = event.getParser(); + const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); + setFavouriteRoomIds(favoriteIds); + }, [])); + + useMessageEvent(FavouriteChangedEvent, useCallback(event => + { + const parser = event.getParser(); + const roomId = Number(parser.flatId); + const added = !!parser.added; + setFavouriteRoomIds(prev => + { + const ids = (prev || []).map((x: any) => Number(x)); + if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ]; + return ids.filter(id => id !== roomId); + }); + }, [])); + + useMessageEvent(RoomSettingsUpdatedEvent, useCallback(event => + { + const parser = event.getParser(); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); + }, [])); + + useMessageEvent(CanCreateRoomEventEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.canCreate) return; + simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title')); + }, [ simpleAlert ])); + + useMessageEvent(UserInfoEvent, useCallback(event => + { + SendMessageComposer(new GetUserFlatCatsMessageComposer()); + SendMessageComposer(new GetUserEventCatsMessageComposer()); + }, [])); + + useMessageEvent(UserPermissionsEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + eventMod: parser.securityLevel >= SecurityLevel.MODERATOR, + roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY + })); + }, [])); + + useMessageEvent(RoomForwardEvent, useCallback(event => + { + const parser = event.getParser(); + TryVisitRoom(parser.roomId); + }, [])); + + useMessageEvent(RoomEntryInfoMessageEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: null, + currentRoomOwner: parser.isOwner, + currentRoomId: parser.roomId + })); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false)); + if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]); + }, [])); + + useMessageEvent(GetGuestRoomResultEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.roomEnter) + { + setNavigatorData(prev => + { + const next = { ...prev }; + next.enteredGuestRoom = parser.data; + next.currentRoomIsStaffPick = parser.staffPick; + const isCreated = next.createdFlatId === parser.data.roomId; + if(!isCreated && parser.data.displayRoomEntryAd) + { + if(GetConfigurationValue('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd(); + } + next.createdFlatId = 0; + return next; + }); + return; + } + if(parser.roomForward) + { + // Door-mode branches (DOORBELL_STATE / PASSWORD_STATE) are handled by useDoorState — skip them here. + const isOwner = parser.data.ownerName === GetSessionDataManager().userName; + if(!isOwner && !parser.isGroupMember) + { + if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) return; + if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) return; + } + if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return; + CreateRoomSession(parser.data.roomId); + return; + } + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: parser.data, + currentRoomIsStaffPick: parser.staffPick + })); + }, [])); + + useMessageEvent(RoomScoreEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + currentRoomRating: parser.totalLikes, + canRate: parser.canLike + })); + }, [])); + + useMessageEvent(GenericErrorEvent, useCallback(event => + { + const parser = event.getParser(); + // -100002 (wrong password) is handled by useDoorState — skip it here. + switch(parser.errorCode) + { + case 4009: + simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4010: + simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4011: + simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4013: + simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + } + }, [ simpleAlert ])); + + useMessageEvent(NavigatorMetadataEvent, useCallback(event => + { + const parser = event.getParser(); + setTopLevelContexts(parser.topLevelContexts); + setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); + }, [])); + + useMessageEvent(NavigatorSearchEvent, useCallback(event => + { + const parser = event.getParser(); + const contexts = topLevelContextsRef.current; + setTopLevelContext(prev => + { + let next = prev; + if(!next) next = (contexts && contexts.length && contexts[0]) || null; + if(!next) return null; + if(contexts && contexts.length) + { + for(const ctx of contexts) + { + if(ctx.code === parser.result.code) next = ctx; + } + } + return next; + }); + setSearchResult(parser.result); + useNavigatorUiStore.getState().setLoading(false); + }, [])); + + useMessageEvent(UserFlatCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setCategories(parser.categories); + }, [])); + + useMessageEvent(UserEventCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setEventCategories(parser.categories); + }, [])); + + useMessageEvent(FlatCreatedEvent, useCallback(event => + { + const parser = event.getParser(); + CreateRoomSession(parser.roomId); + }, [])); + + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, useCallback(() => + { + setNavigatorData(prev => ({ ...prev, settingsReceived: false })); + }, [])); + + useMessageEvent(NavigatorHomeRoomEvent, useCallback(event => + { + const parser = event.getParser(); + let prevSettingsReceived = false; + setNavigatorData(prev => + { + prevSettingsReceived = prev.settingsReceived; + return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true }; + }); + if(prevSettingsReceived) return; + if(GetRoomSessionManager().viewerSession) return; + + let forwardType = -1; + let forwardId = -1; + if((GetConfigurationValue('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) + { + forwardType = 0; + SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); + } + if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) + { + forwardType = parseInt(GetConfigurationValue('forward.type')); + forwardId = parseInt(GetConfigurationValue('forward.id')); + } + if(forwardType === 2) + { + TryVisitRoom(forwardId); + } + else if((forwardType === -1) && (parser.roomIdToEnter > 0)) + { + CreateLinkEvent('navigator/close'); + CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId); + } + }, [])); + + useMessageEvent(RoomEnterErrorEvent, useCallback(event => + { + const parser = event.getParser(); + switch(parser.reason) + { + case CantConnectMessageParser.REASON_FULL: + simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title')); + break; + case CantConnectMessageParser.REASON_QUEUE_ERROR: + simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + case CantConnectMessageParser.REASON_BANNED: + simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title')); + break; + default: + simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + } + if(GetRoomSessionManager().isReconnecting) return; + VisitDesktop(); + }, [ simpleAlert ])); + + useMessageEvent(NavigatorOpenRoomCreatorEvent, useCallback(_event => + { + CreateLinkEvent('navigator/show'); + }, [])); + + useMessageEvent(NavigatorSearchesEvent, useCallback(event => + { + const parser = event.getParser(); + if(!parser) return; + setNavigatorSearches(parser.searches); + }, [])); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + sendSearch, reloadCurrentSearch + }; +}; diff --git a/src/hooks/navigator/useNavigatorUiState.ts b/src/hooks/navigator/useNavigatorUiState.ts new file mode 100644 index 0000000..3c0868a --- /dev/null +++ b/src/hooks/navigator/useNavigatorUiState.ts @@ -0,0 +1,18 @@ +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => +{ + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch + }; +}; diff --git a/src/hooks/rooms/widgets/useDoorState.test.tsx b/src/hooks/rooms/widgets/useDoorState.test.tsx new file mode 100644 index 0000000..2655230 --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.test.tsx @@ -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(); + }); +}); diff --git a/src/hooks/rooms/widgets/useDoorState.ts b/src/hooks/rooms/widgets/useDoorState.ts new file mode 100644 index 0000000..b9dbb05 --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.ts @@ -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(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, handleDoorbell); + useMessageEvent(RoomDoorbellAcceptedEvent, handleAccepted); + useMessageEvent(FlatAccessDeniedMessageEvent, handleDenied); + useMessageEvent(GenericErrorEvent, handleGenericError); + useMessageEvent(GetGuestRoomResultEvent, handleGuestRoom); + + const reset = useCallback(() => setSnapshot(INITIAL), []); + + return { snapshot, setSnapshot, reset }; +}; + +export const useDoorState = () => useBetween(useDoorStateStore); diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 1cfbfa1..28abd00 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -43,8 +43,20 @@ export const NitroLogger = { type Listener = (event: any) => void; +// NitroEvent listeners — registered via GetEventDispatcher() / useNitroEvent. +// Cleared by clearMockEventDispatcher() between test cases. const listeners = new Map>(); +// MessageEvent listeners — registered via GetCommunication().registerMessageEvent +// (i.e. useMessageEvent). NOT cleared by clearMockEventDispatcher() so that +// useBetween-based hooks (which register effects once and persist the +// singleton across tests) keep their subscriptions alive throughout the +// suite. State isolation between tests is maintained by the useBetween +// instance preserving INITIAL values across renders (each test's renderHook +// shares the same useBetween singleton — tests that check a specific +// post-dispatch state rely on the event changing it, not on a reset). +const msgListeners = new Map>(); + export const mockEventDispatcher = { addEventListener(type: string, handler: Listener) { @@ -64,18 +76,23 @@ export const mockEventDispatcher = { }, dispatchEvent(event: { type: string }) { + // Fire NitroEvent listeners first, then MessageEvent listeners. const bucket = listeners.get(event.type); + if(bucket) for(const handler of bucket) handler(event); - if(!bucket) return; - - for(const handler of bucket) handler(event); + const msgBucket = msgListeners.get(event.type); + if(msgBucket) for(const handler of msgBucket) handler(event); }, hasListeners(type: string) { - return (listeners.get(type)?.size ?? 0) > 0; + return (listeners.get(type)?.size ?? 0) > 0 || + (msgListeners.get(type)?.size ?? 0) > 0; } }; +// Clears only the NitroEvent listener map (GetEventDispatcher / useNitroEvent +// registrations). MessageEvent listeners (useMessageEvent / GetCommunication) +// are intentionally preserved so useBetween-based hooks stay subscribed. export const clearMockEventDispatcher = () => { listeners.clear(); @@ -188,7 +205,117 @@ export class NitroSprite extends StubClass {} export class NitroTexture extends StubClass {} export class NitroSoundEvent extends StubClass {} export class NitroEvent extends StubClass {} -export class MessageEvent extends StubClass {} + +// MessageEvent — stores the handler so GetCommunication (below) can +// route dispatches through mockEventDispatcher. Each concrete subclass +// exposes a `.type` equal to its constructor name so dispatchEvent +// can match registered listeners. +export class MessageEvent +{ + private _callBack: Function | null; + + constructor(callBack?: Function) + { + this._callBack = callBack ?? null; + } + + public get callBack(): Function | null { return this._callBack; } + + // Each concrete subclass is identified by its class name. + public get type(): string { return this.constructor.name; } + + // Concrete subclasses override this; the no-arg construction path used + // by makeParserlessEvent in tests leaves it returning null — tests + // override it with (ev as any).getParser = () => parser. + public getParser(): any { return null; } +} + +// --------------------------------------------------------------------------- +// IMessageEvent-based event classes used by useDoorState +// +// The real renderer classes take a `callBack` constructor arg and store it +// in MessageEvent._callBack. The communication manager later calls +// `event.callBack(event)` when the matching packet arrives. +// +// In tests we construct them with NO args (makeParserlessEvent does +// `new klass()`) and override `getParser`. GetCommunication (below) +// registers `event.callBack` on mockEventDispatcher under `event.type` +// (the class name). When the test calls +// `mockEventDispatcher.dispatchEvent(ev)`, listeners for that class name +// fire, receiving `ev` — and the implementation reads `ev.getParser()`. +// --------------------------------------------------------------------------- + +export class DoorbellMessageEvent extends MessageEvent {} +export class RoomDoorbellAcceptedEvent extends MessageEvent {} +export class FlatAccessDeniedMessageEvent extends MessageEvent {} +export class GenericErrorEvent extends MessageEvent {} +export class GetGuestRoomResultEvent extends MessageEvent {} + +// --------------------------------------------------------------------------- +// Navigator event classes — MessageEvent subclasses needed by useNavigatorStore +// --------------------------------------------------------------------------- + +export class CanCreateRoomEventEvent extends MessageEvent {} +export class FavouriteChangedEvent extends MessageEvent {} +export class FavouritesEvent extends MessageEvent {} +export class FlatCreatedEvent extends MessageEvent {} +export class NavigatorHomeRoomEvent extends MessageEvent {} +export class NavigatorMetadataEvent extends MessageEvent {} +export class NavigatorOpenRoomCreatorEvent extends MessageEvent {} +export class NavigatorSearchesEvent extends MessageEvent {} +export class NavigatorSearchEvent extends MessageEvent {} +export class RoomEnterErrorEvent extends MessageEvent {} +export class RoomEntryInfoMessageEvent extends MessageEvent {} +export class RoomForwardEvent extends MessageEvent {} +export class RoomScoreEvent extends MessageEvent {} +export class RoomSettingsUpdatedEvent extends MessageEvent {} +export class UserEventCatsEvent extends MessageEvent {} +export class UserFlatCatsEvent extends MessageEvent {} +export class UserInfoEvent extends MessageEvent {} +export class UserPermissionsEvent extends MessageEvent {} + +// --------------------------------------------------------------------------- +// Notification event classes — MessageEvent subclasses needed by +// useNotificationStore (called via useNotification() inside useNavigatorStore). +// The real renderer classes take a `callBack` constructor arg; the pattern +// here is the same as the Navigator event stubs above. +// --------------------------------------------------------------------------- + +export class AchievementNotificationMessageEvent extends MessageEvent {} +export class ActivityPointNotificationMessageEvent extends MessageEvent {} +export class BadgeReceivedEvent extends MessageEvent {} +export class ClubGiftNotificationEvent extends MessageEvent {} +export class ClubGiftSelectedEvent extends MessageEvent {} +export class ConnectionErrorEvent extends MessageEvent {} +export class HabboBroadcastMessageEvent extends MessageEvent {} +export class HotelClosedAndOpensEvent extends MessageEvent {} +export class HotelClosesAndWillOpenAtEvent extends MessageEvent {} +export class HotelWillCloseInMinutesEvent extends MessageEvent {} +export class InfoFeedEnableMessageEvent extends MessageEvent {} +export class MaintenanceStatusMessageEvent extends MessageEvent {} +export class ModeratorCautionEvent extends MessageEvent {} +export class ModeratorMessageEvent extends MessageEvent {} +export class MOTDNotificationEvent extends MessageEvent {} +export class NotificationDialogMessageEvent extends MessageEvent {} +export class PetLevelNotificationEvent extends MessageEvent {} +export class PetReceivedMessageEvent extends MessageEvent {} +export class RespectReceivedEvent extends MessageEvent {} +export class RoomEnterEvent extends MessageEvent {} +export class SimpleAlertMessageEvent extends MessageEvent {} +export class UserBannedMessageEvent extends MessageEvent {} +export class WiredRewardResultMessageEvent extends MessageEvent +{ + static readonly PRODUCT_DONATED_CODE = 7; + static readonly BADGE_DONATED_CODE = 8; +} + +// RoomEnterEffect — used by useNotificationStore to check if the room-enter +// animation is still running before showing the mod disclaimer bubble. +export const RoomEnterEffect = { + isRunning: () => false, + totalRunningTime: 0 +}; + export class RoomEngineObjectEvent extends StubClass {} export class CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} @@ -196,9 +323,35 @@ export class AdvancedMap extends StubClass {} export class AvatarFigureContainer extends StubClass {} export class Vector3d extends StubClass {} export class ObjectDataFactory extends StubClass {} -export class RoomDataParser extends StubClass {} + +// RoomDataParser — real static constants needed by useDoorState and its tests. +export class RoomDataParser +{ + static readonly DOORBELL_STATE = 1; + static readonly PASSWORD_STATE = 2; +} + export class RoomModerationSettings extends StubClass {} export class StringDataType extends StubClass {} + +// Navigator data/parser stubs +export class NavigatorCategoryDataParser extends StubClass {} +export class NavigatorEventCategoryDataParser extends StubClass {} +export class NavigatorSavedSearch extends StubClass {} +export class NavigatorSearchResultSet extends StubClass {} +export class NavigatorTopLevelContext extends StubClass {} +export class CantConnectMessageParser extends StubClass +{ + static readonly REASON_FULL = 1; + static readonly REASON_QUEUE_ERROR = 2; + static readonly REASON_BANNED = 3; +} + +export class LegacyExternalInterface +{ + static readonly available = false; + static call(..._args: unknown[]): void {} +} export class SellablePetPaletteData extends StubClass {} export class PetFigureData extends StubClass {} export class PetData extends StubClass {} @@ -220,6 +373,10 @@ export class HabboWebTools extends StubClass {} // codebase ("did the SUT call SendMessageComposer(new FooComposer(args))"). export class AddFavouriteRoomMessageComposer extends StubClass {} export class DeleteFavouriteRoomMessageComposer extends StubClass {} +export class FollowFriendMessageComposer extends StubClass {} +export class GetUserEventCatsMessageComposer extends StubClass {} +export class GetUserFlatCatsMessageComposer extends StubClass {} +export class NavigatorSearchComposer extends StubClass {} export class DesktopViewComposer extends StubClass {} export class FurniturePlacePaintComposer extends StubClass {} export class GetGuestRoomMessageComposer extends StubClass {} @@ -351,7 +508,33 @@ const stubManager = () => export const GetAssetManager = vi.fn(stubManager); export const GetAvatarRenderManager = vi.fn(stubManager); -export const GetCommunication = vi.fn(stubManager); +// GetCommunication — routes IMessageEvent registration through the +// msgListeners map (separate from the NitroEvent listeners map) so that +// clearMockEventDispatcher() does NOT wipe these subscriptions. This +// keeps useBetween-based hooks (like useDoorState) subscribed across +// test cases without needing to recreate the useBetween singleton. +// +// A WeakMap stores the wrapper fn keyed by the MessageEvent instance so +// that removeMessageEvent can remove the exact listener added by +// registerMessageEvent. +const _msgEventWrappers = new WeakMap void>(); + +export const GetCommunication = vi.fn(() => ({ + registerMessageEvent(event: MessageEvent) + { + if(!event.callBack) return; + const wrapper = (ev: any) => event.callBack!(ev); + _msgEventWrappers.set(event, wrapper); + let bucket = msgListeners.get(event.type); + if(!bucket) { bucket = new Set(); msgListeners.set(event.type, bucket); } + bucket.add(wrapper); + }, + removeMessageEvent(event: MessageEvent) + { + const wrapper = _msgEventWrappers.get(event); + if(wrapper) msgListeners.get(event.type)?.delete(wrapper); + } +})); export const GetConfiguration = vi.fn(stubManager); export const GetLocalizationManager = vi.fn(stubManager); export const GetRoomEngine = vi.fn(stubManager);