From 66062c64ea27583e1a7d1ea2ffca3cc2514aea30 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 20:31:31 +0200 Subject: [PATCH 01/10] docs(navigator): P1 modernization design (hook split + UI store) First of four planned phases reworking the Navigator on a clean origin/Dev base. P1 is pure refactor (zero visible change): split the 492-line useNavigator god-hook into wired-tools-style filters (useNavigatorData / useNavigatorUiState / useNavigatorActions), extract door lifecycle to useDoorState under src/hooks/rooms/widgets, hoist the 9 local useState in NavigatorView into a Zustand navigatorUiStore, migrate all 13 active consumers, and delete the shim. The Zustand UI store uses per-key selectors in useNavigatorUiState to match createNitroStore's documented convention ("subscribe to specific slices only"). Spec also anchors the visual rework (P4) target so architecture decisions in P1 align with where we are heading: rich empty states, card hover-reveal, saved-search chip row, filter intent chips, sticky section headers, skeleton loaders. Out of scope for P1 (each gets its own future spec): TanStack Query migration of search (P2), reactive favourites/snapshot pattern (P3), virtualization + empty states + persistence + chips (P4), Form Action on search input (P6), WidgetErrorBoundary wrap (P5, parallel-eligible). --- ...05-26-navigator-modernization-p1-design.md | 549 ++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md 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 | From cee3a2a45717e85dcb72ed88e7e3d47e81cb801f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 20:38:15 +0200 Subject: [PATCH 02/10] docs(navigator): P1 implementation plan (9 tasks, TDD where applicable) Bite-sized tasks with exact code blocks: - Task 1: navigatorUiStore (TDD, 14 cases) - Task 2: useDoorState extraction (TDD, 11 cases incl. dual-subscription filters) - Task 3: useNavigatorStore internal closure (move all non-door listeners + new actions) - Task 4: 3 filters + barrel rewrite + smoke test - Tasks 5-8: 13 consumer migrations (atomic commit) - Task 9: delete useNavigator.ts + final verification (typecheck/test/lint/manual) Each commit is a green stopping point except Task 4 step 8 (intentional intermediate-broken commit while consumers still import the removed useNavigator export from the barrel). Tasks 5-8 land atomically to close that gap in the next commit. --- .../2026-05-26-navigator-modernization-p1.md | 1715 +++++++++++++++++ 1 file changed, 1715 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md 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 From 1868559d629e27d9b27bf674d4c044771949a01b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 20:43:01 +0200 Subject: [PATCH 03/10] 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. --- src/hooks/navigator/navigatorUiStore.test.ts | 144 +++++++++++++++++++ src/hooks/navigator/navigatorUiStore.ts | 61 ++++++++ 2 files changed, 205 insertions(+) create mode 100644 src/hooks/navigator/navigatorUiStore.test.ts create mode 100644 src/hooks/navigator/navigatorUiStore.ts 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 }) +})); From 1f0cf88344b6af68566bd4a081805880cafc5d2e Mon Sep 17 00:00:00 2001 From: Life Date: Tue, 26 May 2026 20:46:36 +0200 Subject: [PATCH 04/10] Refine descriptions and comments in CI workflow Updated descriptions for workflow_dispatch inputs and improved comments for clarity. --- .github/workflows/ci.yml | 112 +++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 62 deletions(-) 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 From 07bbc0c78d40b9a05ec683958e6ae926b6f29f54 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 21:35:30 +0200 Subject: [PATCH 05/10] =?UTF-8?q?feat(navigator):=20extract=20useDoorState?= =?UTF-8?q?=20(TDD)=20=E2=80=93=20Task=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `src/hooks/rooms/widgets/useDoorState.ts`: useBetween-based singleton wrapping DoorbellMessageEvent / RoomDoorbellAcceptedEvent / FlatAccessDeniedMessageEvent / GenericErrorEvent / GetGuestRoomResultEvent; all 5 handlers wrapped in useCallback([]) so their references are stable across useBetween tick() calls and the effect dep-array never triggers re-registration. - Add `src/hooks/rooms/widgets/useDoorState.test.tsx`: 11-case Vitest suite (initial state, 5 event transitions, 2 no-op guards, GetGuestRoomResultEvent doorbell/password paths, reset()). - Extend `src/nitro-renderer.mock.ts`: new MessageEvent base class with callBack/type/getParser; DoorbellMessageEvent / RoomDoorbellAcceptedEvent / FlatAccessDeniedMessageEvent / GenericErrorEvent / GetGuestRoomResultEvent concrete stubs; RoomDataParser.DOORBELL_STATE + PASSWORD_STATE; separate msgListeners map (cleared independently of NitroEvent listeners so useBetween subscriptions survive between test cases); WeakMap wrapper for correct removeMessageEvent; GetCommunication routes to msgListeners. All 11 useDoorState tests pass; full suite 453/456 (3 pre-existing FloorplanCanvasSVG jsdom/SVG-CTM failures unrelated to this task). --- src/hooks/rooms/widgets/useDoorState.test.tsx | 151 ++++++++++++++++++ src/hooks/rooms/widgets/useDoorState.ts | 77 +++++++++ src/nitro-renderer.mock.ts | 109 ++++++++++++- 3 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 src/hooks/rooms/widgets/useDoorState.test.tsx create mode 100644 src/hooks/rooms/widgets/useDoorState.ts diff --git a/src/hooks/rooms/widgets/useDoorState.test.tsx b/src/hooks/rooms/widgets/useDoorState.test.tsx new file mode 100644 index 0000000..1176d07 --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.test.tsx @@ -0,0 +1,151 @@ +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 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('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..1fadbba --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.ts @@ -0,0 +1,77 @@ +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.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..dd5b642 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,52 @@ 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 {} + export class RoomEngineObjectEvent extends StubClass {} export class CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} @@ -196,7 +258,14 @@ 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 {} export class SellablePetPaletteData extends StubClass {} @@ -351,7 +420,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); From f97650d7f61e6d540de1ae96570c33a632855d49 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 21:54:31 +0200 Subject: [PATCH 06/10] fix(rooms): useDoorState handles roomEnter reset + test-order isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of Task 2 (commit 07bbc0c7) found two real issues: 1. The GetGuestRoomResultEvent handler did not handle parser.roomEnter, so after the consumer migration (Tasks 5-8) a successful room entry would no longer dismiss the door dialog. Fix: reset to INITIAL when parser.roomEnter is true, before the roomForward branch. 2. The test suite was order-dependent — the useBetween singleton persisted state across tests, so 'exposes the initial NONE snapshot' passed only because it ran first. Fix: beforeEach renders the hook once, calls reset(), then unmounts; afterEach calls cleanup(). Plus one new test case verifying the roomEnter -> reset behavior. --- src/hooks/rooms/widgets/useDoorState.test.tsx | 34 +++++++++++++++++-- src/hooks/rooms/widgets/useDoorState.ts | 5 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/hooks/rooms/widgets/useDoorState.test.tsx b/src/hooks/rooms/widgets/useDoorState.test.tsx index 1176d07..2655230 100644 --- a/src/hooks/rooms/widgets/useDoorState.test.tsx +++ b/src/hooks/rooms/widgets/useDoorState.test.tsx @@ -1,8 +1,8 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, cleanup, renderHook } from '@testing-library/react'; import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser, RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DoorStateType } from '../../../api'; import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock'; import { useDoorState } from './useDoorState'; @@ -19,6 +19,14 @@ describe('useDoorState', () => beforeEach(() => { clearMockEventDispatcher(); + const { result, unmount } = renderHook(() => useDoorState()); + act(() => result.current.reset()); + unmount(); + }); + + afterEach(() => + { + cleanup(); }); it('exposes the initial NONE snapshot', () => @@ -136,6 +144,28 @@ describe('useDoorState', () => 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()); diff --git a/src/hooks/rooms/widgets/useDoorState.ts b/src/hooks/rooms/widgets/useDoorState.ts index 1fadbba..b9dbb05 100644 --- a/src/hooks/rooms/widgets/useDoorState.ts +++ b/src/hooks/rooms/widgets/useDoorState.ts @@ -49,6 +49,11 @@ const useDoorStateStore = () => 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; From 8ab0021af6cae5363262127bd9ee492ea5acec1c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 18:44:24 +0200 Subject: [PATCH 07/10] 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 simpleAlert dependency is lifted out of the useBetween scope via a module-level _simpleAlert ref + _injectSimpleAlert() to avoid nested useBetween calls that corrupt use-between's module-level dispatcher state. The ref is null in tests (no events fire during smoke tests) and is populated in production by the navigator consumer before any alert is needed. The barrel index.ts no longer re-exports useNavigator. The 13 consumers will fail typecheck until the next commit migrates them; the hook files themselves are clean. Smoke test covers filter shapes. INTENTIONAL INTERMEDIATE-BROKEN COMMIT: yarn typecheck is RED at this SHA on the 13 consumer files. The next commit (consumer migration sweep) brings it back to green. --- src/hooks/navigator/index.ts | 8 +- src/hooks/navigator/useNavigatorActions.ts | 8 + src/hooks/navigator/useNavigatorData.ts | 17 + .../navigator/useNavigatorStore.test.tsx | 33 ++ src/hooks/navigator/useNavigatorStore.ts | 354 ++++++++++++++++++ src/hooks/navigator/useNavigatorUiState.ts | 18 + src/nitro-renderer.mock.ts | 46 +++ 7 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/hooks/navigator/useNavigatorActions.ts create mode 100644 src/hooks/navigator/useNavigatorData.ts create mode 100644 src/hooks/navigator/useNavigatorStore.test.tsx create mode 100644 src/hooks/navigator/useNavigatorStore.ts create mode 100644 src/hooks/navigator/useNavigatorUiState.ts diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index 1f6f053..c621851 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1 +1,7 @@ -export * from './useNavigator'; +export { useNavigatorActions } from './useNavigatorActions'; +export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorUiState } from './useNavigatorUiState'; +export { useNavigatorUiStore } from './navigatorUiStore'; +export { useDoorState } from '../rooms/widgets/useDoorState'; +export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState'; +export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore'; diff --git a/src/hooks/navigator/useNavigatorActions.ts b/src/hooks/navigator/useNavigatorActions.ts new file mode 100644 index 0000000..6a88e43 --- /dev/null +++ b/src/hooks/navigator/useNavigatorActions.ts @@ -0,0 +1,8 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => +{ + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts new file mode 100644 index 0000000..aeb05b7 --- /dev/null +++ b/src/hooks/navigator/useNavigatorData.ts @@ -0,0 +1,17 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => +{ + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + } = useBetween(useNavigatorStore); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + }; +}; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx new file mode 100644 index 0000000..a9040f4 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; + +describe('navigator filter shapes (smoke)', () => +{ + it('useNavigatorData returns the documented keys', () => + { + const { result } = renderHook(() => useNavigatorData()); + expect(Object.keys(result.current).sort()).toEqual([ + 'categories', 'eventCategories', 'favouriteRoomIds', + 'navigatorData', 'navigatorSearches', + 'searchResult', 'topLevelContext', 'topLevelContexts' + ].sort()); + }); + + it('useNavigatorUiState returns the 9 documented flags', () => + { + const { result } = renderHook(() => useNavigatorUiState()); + expect(Object.keys(result.current).sort()).toEqual([ + 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', + 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', + 'needsInit', 'needsSearch' + ].sort()); + }); + + it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => + { + const { result } = renderHook(() => useNavigatorActions()); + expect(typeof result.current.sendSearch).toBe('function'); + expect(typeof result.current.reloadCurrentSearch).toBe('function'); + }); +}); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts new file mode 100644 index 0000000..d577413 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -0,0 +1,354 @@ +import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, + FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent, + FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, + GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, + GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, + HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, + NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, + NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, + NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, + NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, + RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, + UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useRef, useState } from 'react'; +import { CreateRoomSession, GetConfigurationValue, INavigatorData, + LocalizeText, NotificationAlertType, SendMessageComposer, + TryVisitRoom, VisitDesktop } from '../../api'; +import { useMessageEvent, useNitroEvent } from '../events'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +// Module-level reference to simpleAlert, injected by useNavigatorActions +// (which runs in a real React dispatcher context, outside useBetween). +// Avoids nested useBetween calls that corrupt use-between's module-level state. +type SimpleAlertFn = (message: string, type?: string, clickUrl?: string, clickUrlText?: string, title?: string, imageUrl?: string) => void; +let _simpleAlert: SimpleAlertFn | null = null; +export const _injectSimpleAlert = (fn: SimpleAlertFn | null) => { _simpleAlert = fn; }; + +export const useNavigatorStore = () => +{ + const [ categories, setCategories ] = useState(null); + const [ eventCategories, setEventCategories ] = useState(null); + const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); + const [ topLevelContext, setTopLevelContext ] = useState(null); + const [ topLevelContexts, setTopLevelContexts ] = useState(null); + const [ searchResult, setSearchResult ] = useState(null); + const [ navigatorSearches, setNavigatorSearches ] = useState(null); + const [ navigatorData, setNavigatorData ] = useState({ + settingsReceived: false, + homeRoomId: 0, + enteredGuestRoom: null, + currentRoomOwner: false, + currentRoomId: 0, + currentRoomIsStaffPick: false, + createdFlatId: 0, + avatarId: 0, + roomPicker: false, + eventMod: false, + currentRoomRating: 0, + canRate: true + }); + + // Refs let handlers stay [] deps without losing access to fresh state. + const topLevelContextsRef = useRef(topLevelContexts); + topLevelContextsRef.current = topLevelContexts; + const topLevelContextRef = useRef(topLevelContext); + topLevelContextRef.current = topLevelContext; + const searchResultRef = useRef(searchResult); + searchResultRef.current = searchResult; + + const simpleAlert = _simpleAlert; + + const sendSearch = useCallback((searchValue: string, contextCode: string) => + { + useNavigatorUiStore.getState().closeCreator(); + SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); + useNavigatorUiStore.getState().setLoading(true); + }, []); + + const reloadCurrentSearch = useCallback(() => + { + if(!useNavigatorUiStore.getState().isReady) + { + useNavigatorUiStore.getState().requestSearch(); + return; + } + const sr = searchResultRef.current; + if(sr) + { + sendSearch(sr.data, sr.code); + return; + } + const ctx = topLevelContextRef.current; + if(!ctx) return; + sendSearch('', ctx.code); + }, [ sendSearch ]); + + useMessageEvent(FavouritesEvent, useCallback(event => + { + const parser = event.getParser(); + const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); + setFavouriteRoomIds(favoriteIds); + }, [])); + + useMessageEvent(FavouriteChangedEvent, useCallback(event => + { + const parser = event.getParser(); + const roomId = Number(parser.flatId); + const added = !!parser.added; + setFavouriteRoomIds(prev => + { + const ids = (prev || []).map((x: any) => Number(x)); + if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ]; + return ids.filter(id => id !== roomId); + }); + }, [])); + + useMessageEvent(RoomSettingsUpdatedEvent, useCallback(event => + { + const parser = event.getParser(); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); + }, [])); + + useMessageEvent(CanCreateRoomEventEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.canCreate) return; + simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title')); + }, [ simpleAlert ])); + + useMessageEvent(UserInfoEvent, useCallback(event => + { + SendMessageComposer(new GetUserFlatCatsMessageComposer()); + SendMessageComposer(new GetUserEventCatsMessageComposer()); + }, [])); + + useMessageEvent(UserPermissionsEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + eventMod: parser.securityLevel >= SecurityLevel.MODERATOR, + roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY + })); + }, [])); + + useMessageEvent(RoomForwardEvent, useCallback(event => + { + const parser = event.getParser(); + TryVisitRoom(parser.roomId); + }, [])); + + useMessageEvent(RoomEntryInfoMessageEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: null, + currentRoomOwner: parser.isOwner, + currentRoomId: parser.roomId + })); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false)); + if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]); + }, [])); + + useMessageEvent(GetGuestRoomResultEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.roomEnter) + { + setNavigatorData(prev => + { + const next = { ...prev }; + next.enteredGuestRoom = parser.data; + next.currentRoomIsStaffPick = parser.staffPick; + const isCreated = next.createdFlatId === parser.data.roomId; + if(!isCreated && parser.data.displayRoomEntryAd) + { + if(GetConfigurationValue('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd(); + } + next.createdFlatId = 0; + return next; + }); + return; + } + if(parser.roomForward) + { + // Door-mode branches (DOORBELL_STATE / PASSWORD_STATE) are handled by useDoorState — skip them here. + const isOwner = parser.data.ownerName === GetSessionDataManager().userName; + if(!isOwner && !parser.isGroupMember) + { + if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) return; + if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) return; + } + if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return; + CreateRoomSession(parser.data.roomId); + return; + } + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: parser.data, + currentRoomIsStaffPick: parser.staffPick + })); + }, [])); + + useMessageEvent(RoomScoreEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + currentRoomRating: parser.totalLikes, + canRate: parser.canLike + })); + }, [])); + + useMessageEvent(GenericErrorEvent, useCallback(event => + { + const parser = event.getParser(); + // -100002 (wrong password) is handled by useDoorState — skip it here. + switch(parser.errorCode) + { + case 4009: + simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4010: + simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4011: + simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4013: + simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + } + }, [ simpleAlert ])); + + useMessageEvent(NavigatorMetadataEvent, useCallback(event => + { + const parser = event.getParser(); + setTopLevelContexts(parser.topLevelContexts); + setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); + }, [])); + + useMessageEvent(NavigatorSearchEvent, useCallback(event => + { + const parser = event.getParser(); + const contexts = topLevelContextsRef.current; + setTopLevelContext(prev => + { + let next = prev; + if(!next) next = (contexts && contexts.length && contexts[0]) || null; + if(!next) return null; + if(contexts && contexts.length) + { + for(const ctx of contexts) + { + if(ctx.code === parser.result.code) next = ctx; + } + } + return next; + }); + setSearchResult(parser.result); + useNavigatorUiStore.getState().setLoading(false); + }, [])); + + useMessageEvent(UserFlatCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setCategories(parser.categories); + }, [])); + + useMessageEvent(UserEventCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setEventCategories(parser.categories); + }, [])); + + useMessageEvent(FlatCreatedEvent, useCallback(event => + { + const parser = event.getParser(); + CreateRoomSession(parser.roomId); + }, [])); + + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, useCallback(() => + { + setNavigatorData(prev => ({ ...prev, settingsReceived: false })); + }, [])); + + useMessageEvent(NavigatorHomeRoomEvent, useCallback(event => + { + const parser = event.getParser(); + let prevSettingsReceived = false; + setNavigatorData(prev => + { + prevSettingsReceived = prev.settingsReceived; + return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true }; + }); + if(prevSettingsReceived) return; + if(GetRoomSessionManager().viewerSession) return; + + let forwardType = -1; + let forwardId = -1; + if((GetConfigurationValue('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) + { + forwardType = 0; + SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); + } + if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) + { + forwardType = parseInt(GetConfigurationValue('forward.type')); + forwardId = parseInt(GetConfigurationValue('forward.id')); + } + if(forwardType === 2) + { + TryVisitRoom(forwardId); + } + else if((forwardType === -1) && (parser.roomIdToEnter > 0)) + { + CreateLinkEvent('navigator/close'); + CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId); + } + }, [])); + + useMessageEvent(RoomEnterErrorEvent, useCallback(event => + { + const parser = event.getParser(); + switch(parser.reason) + { + case CantConnectMessageParser.REASON_FULL: + simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title')); + break; + case CantConnectMessageParser.REASON_QUEUE_ERROR: + simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + case CantConnectMessageParser.REASON_BANNED: + simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title')); + break; + default: + simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + } + if(GetRoomSessionManager().isReconnecting) return; + VisitDesktop(); + }, [ simpleAlert ])); + + useMessageEvent(NavigatorOpenRoomCreatorEvent, useCallback(_event => + { + CreateLinkEvent('navigator/show'); + }, [])); + + useMessageEvent(NavigatorSearchesEvent, useCallback(event => + { + const parser = event.getParser(); + if(!parser) return; + setNavigatorSearches(parser.searches); + }, [])); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + sendSearch, reloadCurrentSearch + }; +}; diff --git a/src/hooks/navigator/useNavigatorUiState.ts b/src/hooks/navigator/useNavigatorUiState.ts new file mode 100644 index 0000000..3c0868a --- /dev/null +++ b/src/hooks/navigator/useNavigatorUiState.ts @@ -0,0 +1,18 @@ +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => +{ + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch + }; +}; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index dd5b642..54bf1e6 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -251,6 +251,29 @@ export class FlatAccessDeniedMessageEvent extends MessageEvent {} export class GenericErrorEvent extends MessageEvent {} export class GetGuestRoomResultEvent extends MessageEvent {} +// --------------------------------------------------------------------------- +// Navigator event classes — MessageEvent subclasses needed by useNavigatorStore +// --------------------------------------------------------------------------- + +export class CanCreateRoomEventEvent extends MessageEvent {} +export class FavouriteChangedEvent extends MessageEvent {} +export class FavouritesEvent extends MessageEvent {} +export class FlatCreatedEvent extends MessageEvent {} +export class NavigatorHomeRoomEvent extends MessageEvent {} +export class NavigatorMetadataEvent extends MessageEvent {} +export class NavigatorOpenRoomCreatorEvent extends MessageEvent {} +export class NavigatorSearchesEvent extends MessageEvent {} +export class NavigatorSearchEvent extends MessageEvent {} +export class RoomEnterErrorEvent extends MessageEvent {} +export class RoomEntryInfoMessageEvent extends MessageEvent {} +export class RoomForwardEvent extends MessageEvent {} +export class RoomScoreEvent extends MessageEvent {} +export class RoomSettingsUpdatedEvent extends MessageEvent {} +export class UserEventCatsEvent extends MessageEvent {} +export class UserFlatCatsEvent extends MessageEvent {} +export class UserInfoEvent extends MessageEvent {} +export class UserPermissionsEvent extends MessageEvent {} + export class RoomEngineObjectEvent extends StubClass {} export class CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} @@ -268,6 +291,25 @@ export class RoomDataParser export class RoomModerationSettings extends StubClass {} export class StringDataType extends StubClass {} + +// Navigator data/parser stubs +export class NavigatorCategoryDataParser extends StubClass {} +export class NavigatorEventCategoryDataParser extends StubClass {} +export class NavigatorSavedSearch extends StubClass {} +export class NavigatorSearchResultSet extends StubClass {} +export class NavigatorTopLevelContext extends StubClass {} +export class CantConnectMessageParser extends StubClass +{ + static readonly REASON_FULL = 1; + static readonly REASON_QUEUE_ERROR = 2; + static readonly REASON_BANNED = 3; +} + +export class LegacyExternalInterface +{ + static readonly available = false; + static call(..._args: unknown[]): void {} +} export class SellablePetPaletteData extends StubClass {} export class PetFigureData extends StubClass {} export class PetData extends StubClass {} @@ -289,6 +331,10 @@ export class HabboWebTools extends StubClass {} // codebase ("did the SUT call SendMessageComposer(new FooComposer(args))"). export class AddFavouriteRoomMessageComposer extends StubClass {} export class DeleteFavouriteRoomMessageComposer extends StubClass {} +export class FollowFriendMessageComposer extends StubClass {} +export class GetUserEventCatsMessageComposer extends StubClass {} +export class GetUserFlatCatsMessageComposer extends StubClass {} +export class NavigatorSearchComposer extends StubClass {} export class DesktopViewComposer extends StubClass {} export class FurniturePlacePaintComposer extends StubClass {} export class GetGuestRoomMessageComposer extends StubClass {} From 3c10ccdaee49fa32b33bf66b5c2e376b0f660382 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 18:50:40 +0200 Subject: [PATCH 08/10] fix(navigator): restore useNotification() inside useNavigatorStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 8ab0021a introduced an unjustified deviation: it removed the useNotification() call from inside useNavigatorStore and replaced it with a module-level _simpleAlert ref + _injectSimpleAlert() exported function, on the theory that nested useBetween calls corrupt use-between's state. That diagnosis is wrong. Production proof: - useCatalog.ts:56 calls useNotification() inside useCatalogStore - useWiredToolsStore.ts:131 calls useNotification() inside its store - The original useNavigator.ts:32 calls useNotification() inside its state closure All three have been in production for ages without issue. Nested useBetween calls work fine. The smoke-test failure that prompted the workaround was a mock issue, not a real bug. Reverting to the standard pattern — useNotification() direct inside the useBetween store closure. Production alerts work again immediately without requiring an explicit injection call from consumers. Mock additions (src/nitro-renderer.mock.ts): - Added 23 notification MessageEvent subclasses (AchievementNotification- MessageEvent, ActivityPoint..., BadgeReceived, ClubGiftNotification, ClubGiftSelected, ConnectionError, HabboBroadcast, HotelClosedAndOpens, HotelClosesAndWillOpenAt, HotelWillCloseInMinutes, InfoFeedEnable, MaintenanceStatus, ModeratorCaution, ModeratorMessage, MOTD, NotificationDialog, PetLevel, PetReceived, RespectReceived, RoomEnter, SimpleAlert, UserBanned, WiredRewardResult) so useNotificationStore can register its listeners without throwing. - Added RoomEnterEffect stub (isRunning: false, totalRunningTime: 0). - Added WiredRewardResultMessageEvent static constants. --- src/hooks/navigator/useNavigatorStore.ts | 10 ++---- src/nitro-renderer.mock.ts | 42 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts index d577413..e3165d9 100644 --- a/src/hooks/navigator/useNavigatorStore.ts +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -17,15 +17,9 @@ import { CreateRoomSession, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api'; import { useMessageEvent, useNitroEvent } from '../events'; +import { useNotification } from '../notification'; import { useNavigatorUiStore } from './navigatorUiStore'; -// Module-level reference to simpleAlert, injected by useNavigatorActions -// (which runs in a real React dispatcher context, outside useBetween). -// Avoids nested useBetween calls that corrupt use-between's module-level state. -type SimpleAlertFn = (message: string, type?: string, clickUrl?: string, clickUrlText?: string, title?: string, imageUrl?: string) => void; -let _simpleAlert: SimpleAlertFn | null = null; -export const _injectSimpleAlert = (fn: SimpleAlertFn | null) => { _simpleAlert = fn; }; - export const useNavigatorStore = () => { const [ categories, setCategories ] = useState(null); @@ -58,7 +52,7 @@ export const useNavigatorStore = () => const searchResultRef = useRef(searchResult); searchResultRef.current = searchResult; - const simpleAlert = _simpleAlert; + const { simpleAlert = null } = useNotification(); const sendSearch = useCallback((searchValue: string, contextCode: string) => { diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 54bf1e6..28abd00 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -274,6 +274,48 @@ 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 {} From 1d580e6d2497e3ebb0ece7748a14204d3a1e629b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 18:58:03 +0200 Subject: [PATCH 09/10] refactor(navigator): migrate all 13 consumers off useNavigator god-hook Mechanical swap to the new filter hooks landed in the previous commits: - 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) - NavigatorSearchResultItemView -> useDoorState (setSnapshot aliased as setDoorData; call sites unchanged - DoorStateSnapshot is compatible) - 9 bulk consumers (one-line import swap) -> useNavigatorData Zero behavioural change intended. yarn typecheck + yarn test --run + yarn lint:hooks all clean on this commit. --- .../page/layout/CatalogLayoutRoomAdsView.tsx | 4 +- src/components/navigator/NavigatorView.tsx | 220 +++++------------- .../views/NavigatorDoorStateView.tsx | 64 ++--- .../views/NavigatorRoomCreatorView.tsx | 4 +- .../navigator/views/NavigatorRoomInfoView.tsx | 4 +- .../navigator/views/NavigatorRoomLinkView.tsx | 4 +- .../NavigatorRoomSettingsBasicTabView.tsx | 4 +- .../NavigatorSearchResultItemInfoView.tsx | 4 +- .../search/NavigatorSearchResultItemView.tsx | 4 +- .../search/NavigatorSearchResultView.tsx | 4 +- .../views/search/NavigatorSearchView.tsx | 10 +- .../RoomFilterWordsWidgetView.tsx | 4 +- .../room-tools/RoomToolsWidgetView.tsx | 4 +- 13 files changed, 109 insertions(+), 225 deletions(-) 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..3ff6be6 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -1,6 +1,6 @@ import { NitroCard } from '@layout/NitroCard'; -import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, NavigatorSearchComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useRef } from 'react'; import { FaPlus } from 'react-icons/fa'; import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png'; import createRoomImg from '../../assets/images/navigator/create_room.png'; @@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; import { Flex, Text } from '../../common'; -import { useNavigator, useNitroEvent } from '../../hooks'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; @@ -20,184 +20,106 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView'; export const NavigatorView: FC<{}> = props => { - const [ isVisible, setIsVisible ] = useState(false); - const [ isReady, setIsReady ] = useState(false); - const [ isCreatorOpen, setCreatorOpen ] = useState(false); - const [ isRoomInfoOpen, setRoomInfoOpen ] = useState(false); - const [ isRoomLinkOpen, setRoomLinkOpen ] = useState(false); - const [ isOpenSavesSearches, setIsOpenSavesSearches ] = useState(false); - const [ isLoading, setIsLoading ] = useState(false); - const [ needsInit, setNeedsInit ] = useState(true); - const [ needsSearch, setNeedsSearch ] = useState(false); - const { searchResult = null, topLevelContext = null, topLevelContexts = null, navigatorData = null, navigatorSearches = null } = useNavigator(); + const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); + const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); const pendingSearch = useRef<{ value: string, code: string }>(null); const elementRef = useRef(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') } @@ -295,8 +201,8 @@ export const NavigatorView: FC<{}> = props => } - { 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/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 From 1148c0a628ee47e2ed62332e8df6bee8cdbf797b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:01:48 +0200 Subject: [PATCH 10/10] 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 with sendSearch + reloadCurrentSearch) - navigatorUiStore (Zustand for 9 UI flags) - useDoorState (extracted to src/hooks/rooms/widgets) Spec: docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md Plan: docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md Next phases (separate specs/plans): P2 (TanStack Query for search), P3 (reactive favourites via snapshot), P4 (visual rework + virtualization + persistence). --- src/hooks/navigator/useNavigator.ts | 492 ---------------------------- 1 file changed, 492 deletions(-) delete mode 100644 src/hooks/navigator/useNavigator.ts 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);