mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge branch 'Dev' into Dev
This commit is contained in:
+39
-26
@@ -10,11 +10,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: ''
|
||||
|
||||
@@ -25,6 +25,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:
|
||||
@@ -40,33 +45,32 @@ 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 }}"
|
||||
@@ -115,9 +119,18 @@ jobs:
|
||||
[ -z "$REF" ] && REF="$AUTO_REF"
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<NavigatorUiState & NavigatorUiActions>()((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<DoorStateSnapshot>(INITIAL);
|
||||
|
||||
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, event => {
|
||||
const parser = event.getParser();
|
||||
if (parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING }));
|
||||
});
|
||||
|
||||
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, event => {
|
||||
const parser = event.getParser();
|
||||
if (parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED }));
|
||||
});
|
||||
|
||||
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, event => {
|
||||
const parser = event.getParser();
|
||||
if (parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER }));
|
||||
});
|
||||
|
||||
useMessageEvent<GenericErrorEvent>(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>(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
|
||||
<simoleo89@users.noreply.github.com>` 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 |
|
||||
@@ -0,0 +1,243 @@
|
||||
# Navigator Modernization — P2: TanStack Query for Search
|
||||
|
||||
**Branch**: `feat/navigator-p2-query` (forked from `feat/navigator-modernization` @ `1148c0a6`)
|
||||
**Date**: 2026-05-27
|
||||
**Depends on**: P1 (hook split) — merged or pending merge
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Migrate Navigator's search request/response from event-driven imperative state to TanStack Query. The user gets:
|
||||
- **Instant tab switching** when the same tab/filter was visited before in the session (cache hit, no round-trip)
|
||||
- **Stale-while-revalidate** on revisit (shows cached results while refetching in background)
|
||||
- **Server-driven refresh** via `useNitroEventInvalidator` on `FlatCreatedEvent` and `RoomSettingsUpdatedEvent` (and possibly `FavouriteChangedEvent` if the active tab is `favorites_view`)
|
||||
- **Single source of truth** for `isFetching` — no separate `isLoading` flag to manage
|
||||
|
||||
## 2. Architecture changes
|
||||
|
||||
### 2.1 New file: `src/hooks/navigator/useNavigatorSearch.ts`
|
||||
|
||||
The query hook. Reads `currentTabCode` + `currentFilter` from `navigatorUiStore`, fires `NavigatorSearchComposer`, waits for `NavigatorSearchEvent`, returns the parsed `NavigatorSearchResultSet`.
|
||||
|
||||
```ts
|
||||
import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorSearch = () =>
|
||||
{
|
||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||
|
||||
const query = useNitroQuery<typeof NavigatorSearchEvent, NavigatorSearchResultSet>({
|
||||
key: [ 'navigator', 'search', tabCode, filter ],
|
||||
request: () => new NavigatorSearchComposer(tabCode, filter),
|
||||
parser: NavigatorSearchEvent,
|
||||
select: e => e.getParser()?.result ?? null,
|
||||
accept: e => {
|
||||
const result = e.getParser()?.result;
|
||||
// accept-filter: only this query's matching tab code
|
||||
return !!result && result.code === tabCode;
|
||||
},
|
||||
enabled: !!tabCode,
|
||||
staleTime: 30_000 // re-fetch after 30s of staleness on revisit
|
||||
});
|
||||
|
||||
useNitroEventInvalidator(FlatCreatedEvent, [ 'navigator', 'search' ]);
|
||||
useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ 'navigator', 'search' ]);
|
||||
|
||||
return {
|
||||
searchResult: query.data,
|
||||
isFetching: query.isFetching,
|
||||
refetch: query.refetch
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 2.2 `navigatorUiStore.ts` additions
|
||||
|
||||
Add 2 new state fields + 2 new actions:
|
||||
|
||||
```ts
|
||||
type NavigatorUiState = {
|
||||
// ...existing 9 flags...
|
||||
currentTabCode: string; // '' until NavigatorMetadataEvent arrives, then first top-level context code
|
||||
currentFilter: string; // '' by default
|
||||
};
|
||||
|
||||
type NavigatorUiActions = {
|
||||
// ...existing 15 actions...
|
||||
setTab(code: string): void; // also clears currentFilter
|
||||
setFilter(value: string): void;
|
||||
};
|
||||
```
|
||||
|
||||
`setTab(code)` resets `currentFilter` to `''` because switching tabs starts a fresh search. `setFilter` updates only the filter — the user is typing in the same tab.
|
||||
|
||||
### 2.3 `useNavigatorStore.ts` — remove search state ownership
|
||||
|
||||
Remove:
|
||||
- `useState<NavigatorSearchResultSet>(null)` for `searchResult`
|
||||
- `useMessageEvent<NavigatorSearchEvent>` listener
|
||||
- `sendSearch` and `reloadCurrentSearch` actions
|
||||
- The `useNavigatorUiStore.getState().setLoading(...)` calls (no longer needed)
|
||||
- The `topLevelContextRef` and `searchResultRef` (only consumed inside `reloadCurrentSearch`)
|
||||
|
||||
Keep:
|
||||
- `topLevelContext` + `topLevelContexts` (these still come from `NavigatorMetadataEvent` and drive the tab list)
|
||||
- The `NavigatorMetadataEvent` listener — but it now ALSO calls `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` on first arrival, to seed the initial tab. The query then activates because `currentTabCode` becomes non-empty (`enabled: !!tabCode`).
|
||||
|
||||
### 2.4 `useNavigatorData.ts` — remove `searchResult` from return shape
|
||||
|
||||
`useNavigatorData()` no longer returns `searchResult`. Consumers that need it call `useNavigatorSearch()` instead.
|
||||
|
||||
### 2.5 `useNavigatorActions.ts` — empty or removed
|
||||
|
||||
Both `sendSearch` and `reloadCurrentSearch` are gone. Either:
|
||||
- Remove the file + the export — consumers use `useNavigatorUiStore.getState().setTab(...)` / `setFilter(...)` directly
|
||||
- Or keep the file as an empty re-export for forward compat. (Decision: REMOVE — minimize dead API).
|
||||
|
||||
### 2.6 `useNavigatorUiState.ts` — add the 2 new flags
|
||||
|
||||
Add `currentTabCode` and `currentFilter` to the per-key selector list and return shape.
|
||||
|
||||
### 2.7 `useNavigatorSearch.test.tsx` — new
|
||||
|
||||
Test cases:
|
||||
- Initial mount with empty tabCode → query is disabled, no request fired
|
||||
- After `setTab('public')` → query fires NavigatorSearchComposer('public', '')
|
||||
- After `setFilter('cocco')` → query fires NavigatorSearchComposer('public', 'cocco')
|
||||
- After `setTab('events')` → currentFilter resets to '', query fires NavigatorSearchComposer('events', '')
|
||||
- `FlatCreatedEvent` invalidates the cache → refetch
|
||||
- `RoomSettingsUpdatedEvent` invalidates the cache → refetch
|
||||
- `NavigatorSearchEvent` with WRONG tabCode (e.g. server pushes an unsolicited result) is REJECTED by `accept` filter — does NOT update query data
|
||||
|
||||
### 2.8 `NavigatorView.tsx` — major rewrite
|
||||
|
||||
Replace:
|
||||
- `useNavigatorActions` import → gone
|
||||
- `useNavigatorData` no longer destructures `searchResult` — get it from `useNavigatorSearch` instead
|
||||
- 4 `useEffect` blocks driving the imperative search flow (`needsSearch`, `needsInit` lifecycle, `reloadCurrentSearch` orchestration) → gone
|
||||
- Tab `onClick={ () => sendSearch('', context.code) }` → `onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }`
|
||||
- `isLoading` from `useNavigatorUiState()` → `isFetching` from `useNavigatorSearch()` query
|
||||
- `NavigatorInitComposer` initial dispatch on first `isVisible` — KEEP (still need it to get `topLevelContexts` populated)
|
||||
- `pendingSearch` ref — gone (linkTracker `case 'search'` directly does `setTab(code); setFilter(value)`)
|
||||
|
||||
Major simplification: the file shrinks ~30 lines.
|
||||
|
||||
### 2.9 `NavigatorSearchView.tsx` — drive setFilter
|
||||
|
||||
Read the file. The component currently exposes a search input that, on enter or button click, calls `sendSearch(value, currentTabCode)`. After P2 it:
|
||||
- Reads `currentFilter` from `useNavigatorUiState`
|
||||
- onChange → `useNavigatorUiStore.getState().setFilter(value)` (debounced 300ms)
|
||||
- No more `sendSearch` reference
|
||||
|
||||
Debounce: use a local `useState` for the input text + a `useEffect` that calls `setFilter(text)` 300ms after the last keystroke. Standard pattern.
|
||||
|
||||
## 3. Backward-compat considerations
|
||||
|
||||
- `useNavigatorActions.sendSearch` and `useNavigatorActions.reloadCurrentSearch` are REMOVED. No consumer outside Navigator depends on them — verified by grepping the previous P1 consumer migration.
|
||||
- `useNavigatorData.searchResult` is REMOVED. Only `NavigatorView` reads it currently — easy to migrate.
|
||||
- The `useNavigatorActions` filter itself becomes empty — consider whether to delete the file entirely. **Decision: delete the file** to minimize the API surface. Tasks 5-8 of P1 migrated `NavigatorSearchView` to use `useNavigatorActions` — that's the only consumer; it migrates to `useNavigatorUiStore` directly.
|
||||
|
||||
## 4. Out of scope (each gets its own future spec)
|
||||
|
||||
- Reactive favourite stars on cards (P3)
|
||||
- Visual rework: empty states, virtualization, chip-based UI (P4)
|
||||
- Form Action on search input (P6)
|
||||
|
||||
## 5. Acceptance criteria
|
||||
|
||||
P2 is complete when:
|
||||
|
||||
1. `src/hooks/navigator/useNavigatorSearch.ts` exists and exports `useNavigatorSearch`
|
||||
2. `useNavigatorStore.ts` no longer owns `searchResult`, no longer subscribes to `NavigatorSearchEvent`, no longer exposes `sendSearch` or `reloadCurrentSearch`
|
||||
3. `navigatorUiStore.ts` has `currentTabCode` + `currentFilter` state and `setTab` + `setFilter` actions
|
||||
4. `useNavigatorActions.ts` is deleted; barrel no longer exports `useNavigatorActions`
|
||||
5. `useNavigatorData.ts` no longer returns `searchResult`
|
||||
6. `useNavigatorUiState.ts` returns `currentTabCode` + `currentFilter`
|
||||
7. `NavigatorView.tsx` reads `searchResult` from `useNavigatorSearch()`, uses `isFetching` for the loading flag, calls `setTab` on tab clicks
|
||||
8. `NavigatorSearchView.tsx` debounces `setFilter` calls
|
||||
9. `yarn typecheck` clean (same pre-existing floorplan errors)
|
||||
10. `yarn test --run` green; smoke test updated; new `useNavigatorSearch.test.tsx` with 7 cases
|
||||
11. `yarn lint:hooks` clean
|
||||
12. Manual smoke: switch tabs rapidly → results cached, no flicker. Type filter → debounced refetch. Create a room → list refreshes.
|
||||
|
||||
## 6. Risk register
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `NavigatorSearchEvent` arrives unsolicited (server-side push) — query wouldn't update | The `accept` filter checks the result's code matches the current tabCode, so only matching events update the query. Unsolicited results to a non-active tab are ignored (acceptable — when the user switches to that tab, the cache is empty and a fresh query fires). |
|
||||
| Removing `useNavigatorActions` breaks an import we missed | Type-checker catches it. The P1 grep showed only Navigator-internal consumers use it. |
|
||||
| Removing the `isLoading`/`isReady`/`needsInit`/`needsSearch` flags from `navigatorUiStore` (they're now derivable from query state) — too aggressive? | KEEP them in P2. Only `searchResult` ownership moves. Future cleanup can remove the obsolete lifecycle flags once we're sure nothing reads them. |
|
||||
| Debounce timing on search input | 300ms is standard; if it feels laggy the user can lower it later — pure UX tune |
|
||||
|
||||
## 7. Plan (executable)
|
||||
|
||||
### Task 1: Add UI store state + actions (TDD)
|
||||
|
||||
**Files**: `src/hooks/navigator/navigatorUiStore.ts`, `src/hooks/navigator/navigatorUiStore.test.ts`
|
||||
|
||||
- [ ] Add `currentTabCode: string` (initial `''`) and `currentFilter: string` (initial `''`) to `NavigatorUiState`
|
||||
- [ ] Add `setTab(code: string): void` and `setFilter(value: string): void` to `NavigatorUiActions`
|
||||
- [ ] `setTab(code)` sets `{ currentTabCode: code, currentFilter: '' }` (atomic reset on tab change)
|
||||
- [ ] `setFilter(value)` sets `{ currentFilter: value }` (no tab side-effect)
|
||||
- [ ] Update test file: 3 new cases — `setTab` updates tab and resets filter; `setFilter` updates filter without touching tab; idempotent `setTab` on same code resets filter to '' regardless
|
||||
- [ ] `yarn test --run src/hooks/navigator/navigatorUiStore.test.ts` → green
|
||||
- [ ] Commit: `feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep)`
|
||||
|
||||
### Task 2: Create `useNavigatorSearch` query hook (TDD)
|
||||
|
||||
**Files**: `src/hooks/navigator/useNavigatorSearch.ts`, `src/hooks/navigator/useNavigatorSearch.test.tsx`
|
||||
|
||||
Implement per §2.1 + §2.7 above. 7 test cases.
|
||||
|
||||
The test will need: `QueryClientProvider` wrapper, mock for `NavigatorSearchComposer` (probably already in mock), `NavigatorSearchEvent` dispatch with parser.result.code matching/non-matching.
|
||||
|
||||
- [ ] Commit: `feat(navigator): useNavigatorSearch query hook (P2 core)`
|
||||
|
||||
### Task 3: Strip search ownership from `useNavigatorStore` + `useNavigatorData` + remove `useNavigatorActions`
|
||||
|
||||
**Files**: `useNavigatorStore.ts`, `useNavigatorData.ts`, `useNavigatorActions.ts` (DELETE), `useNavigatorUiState.ts`, `index.ts`
|
||||
|
||||
- [ ] Remove `searchResult` state + `setSearchResult` from `useNavigatorStore`
|
||||
- [ ] Remove `NavigatorSearchEvent` listener from `useNavigatorStore`
|
||||
- [ ] Remove `sendSearch` and `reloadCurrentSearch` from `useNavigatorStore` return
|
||||
- [ ] Remove `setLoading` calls inside `useNavigatorStore`
|
||||
- [ ] Remove `topLevelContextRef` and `searchResultRef` (no longer used after sendSearch/reload removal)
|
||||
- [ ] In `NavigatorMetadataEvent` handler, add `useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '')` after `setTopLevelContext(...)` — seeds the query when contexts arrive
|
||||
- [ ] Remove `searchResult` from `useNavigatorData` destructure + return
|
||||
- [ ] DELETE `src/hooks/navigator/useNavigatorActions.ts`
|
||||
- [ ] Update `useNavigatorUiState.ts` to expose `currentTabCode` + `currentFilter` per-key selectors
|
||||
- [ ] Update `src/hooks/navigator/index.ts` to remove `useNavigatorActions` export, add `useNavigatorSearch` export
|
||||
- [ ] Update `useNavigatorStore.test.tsx` smoke test: 2 cases that expected `searchResult` in data shape or `sendSearch/reloadCurrentSearch` in actions shape — update accordingly (or just remove the "useNavigatorActions returns ..." test entirely)
|
||||
- [ ] Verify typecheck: ONLY consumer-side errors expected (NavigatorView still references the old API). Hook files clean.
|
||||
- [ ] Commit: `refactor(navigator): remove search ownership from useNavigatorStore`
|
||||
|
||||
### Task 4: Migrate `NavigatorView.tsx` + `NavigatorSearchView.tsx`
|
||||
|
||||
**Files**: `src/components/navigator/NavigatorView.tsx`, `src/components/navigator/views/search/NavigatorSearchView.tsx`
|
||||
|
||||
- [ ] In `NavigatorView`:
|
||||
- Import `useNavigatorSearch`
|
||||
- Replace `useNavigatorData` destructure of `searchResult` with `useNavigatorSearch()` call returning `{ searchResult, isFetching }`
|
||||
- Drop `useNavigatorActions` import + destructure (it's gone)
|
||||
- Drop the 4 lifecycle `useEffect` blocks (needsSearch / needsInit-init / markReady / reloadCurrentSearch); the new flow:
|
||||
- Keep the `NavigatorInitComposer` on first `isVisible` — still needed for metadata
|
||||
- Tab clicks call `useNavigatorUiStore.getState().setTab(context.code)`
|
||||
- linkTracker `case 'search'`: `store.setTab(parts[2]); store.setFilter(parts[3] ?? ''); store.show();` (no more `pendingSearch` ref)
|
||||
- Replace `<NitroCard.Content isLoading={ isLoading }>` with `isFetching` from the query
|
||||
- Drop the `pendingSearch` ref
|
||||
- [ ] In `NavigatorSearchView`:
|
||||
- Read `currentFilter` from `useNavigatorUiState` for the initial input value
|
||||
- Local `useState` for the text being typed (mirrors the store value)
|
||||
- Debounce: `useEffect` with 300ms timer calling `useNavigatorUiStore.getState().setFilter(text)`
|
||||
- Remove all `useNavigatorActions` references — the search submit happens via store, query refires automatically
|
||||
- [ ] `yarn typecheck` clean
|
||||
- [ ] `yarn test --run` green
|
||||
- [ ] `yarn lint:hooks` clean
|
||||
- [ ] Commit: `feat(navigator): drive search via TanStack Query + setTab/setFilter UI store actions`
|
||||
|
||||
### Task 5: PR
|
||||
|
||||
- [ ] Push branch
|
||||
- [ ] Open PR against `duckietm:Dev`: `feat(navigator): TanStack Query for search (P2)`
|
||||
@@ -11,6 +11,7 @@ export class BotSkillsEnum
|
||||
public static NUX_PROCEED: number = 8;
|
||||
public static CHANGE_BOT_MOTTO: number = 9;
|
||||
public static NUX_TAKE_TOUR: number = 10;
|
||||
public static ROTATE: number = 11;
|
||||
public static NO_PICK_UP: number = 12;
|
||||
public static NAVIGATOR_SEARCH: number = 14;
|
||||
public static DONATE_TO_USER: number = 24;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||
import { useNitroQuery } from '../../../../../api/nitro-query';
|
||||
import { Button, Column, Text } from '../../../../../common';
|
||||
import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks';
|
||||
import { useCatalogUiState, useNavigatorData, useRoomPromote } from '../../../../../hooks';
|
||||
import { NitroInput } from '../../../../../layout';
|
||||
import { CatalogLayoutProps } from './CatalogLayout.types';
|
||||
|
||||
@@ -17,7 +17,7 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
|
||||
const [ roomId, setRoomId ] = useState<number>(-1);
|
||||
const [ extended, setExtended ] = useState<boolean>(false);
|
||||
const [ categoryId, setCategoryId ] = useState<number>(1);
|
||||
const { categories = null } = useNavigator();
|
||||
const { categories } = useNavigatorData();
|
||||
const { setIsVisible = null } = useCatalogUiState();
|
||||
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NitroCard } from '@layout/NitroCard';
|
||||
import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, NavigatorSearchComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer';
|
||||
import { FC, useEffect, useRef } from 'react';
|
||||
import { FaPlus } from 'react-icons/fa';
|
||||
import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png';
|
||||
import createRoomImg from '../../assets/images/navigator/create_room.png';
|
||||
import randomRoomImg from '../../assets/images/navigator/random_room.png';
|
||||
import promoteRoomImg from '../../assets/images/navigator/promote_room.png';
|
||||
import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api';
|
||||
import { Flex, Text } from '../../common';
|
||||
import { useNavigator, useNitroEvent } from '../../hooks';
|
||||
import { Flex, Text, WidgetErrorBoundary } from '../../common';
|
||||
import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks';
|
||||
import { NavigatorDoorStateView } from './views/NavigatorDoorStateView';
|
||||
import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView';
|
||||
import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
|
||||
@@ -20,184 +20,87 @@ 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 pendingSearch = useRef<{ value: string, code: string }>(null);
|
||||
const { topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData();
|
||||
const { searchResult, isFetching } = useNavigatorSearch();
|
||||
const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit } = useNavigatorUiState();
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event =>
|
||||
{
|
||||
setIsVisible(false);
|
||||
setCreatorOpen(false);
|
||||
useNavigatorUiStore.getState().hide();
|
||||
useNavigatorUiStore.getState().closeCreator();
|
||||
});
|
||||
|
||||
const sendSearch = useCallback((searchValue: string, contextCode: string) =>
|
||||
{
|
||||
setCreatorOpen(false);
|
||||
|
||||
SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue));
|
||||
|
||||
setIsLoading(true);
|
||||
}, []);
|
||||
|
||||
const reloadCurrentSearch = useCallback(() =>
|
||||
{
|
||||
if(!isReady)
|
||||
{
|
||||
setNeedsSearch(true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(pendingSearch.current)
|
||||
{
|
||||
sendSearch(pendingSearch.current.value, pendingSearch.current.code);
|
||||
|
||||
pendingSearch.current = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(searchResult)
|
||||
{
|
||||
sendSearch(searchResult.data, searchResult.code);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if(!topLevelContext) return;
|
||||
|
||||
sendSearch('', topLevelContext.code);
|
||||
}, [ isReady, searchResult, topLevelContext, sendSearch ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const linkTracker: ILinkEventTracker = {
|
||||
linkReceived: (url: string) =>
|
||||
{
|
||||
const parts = url.split('/');
|
||||
|
||||
if(parts.length < 2) return;
|
||||
|
||||
const store = useNavigatorUiStore.getState();
|
||||
switch(parts[1])
|
||||
{
|
||||
case 'show': {
|
||||
setIsVisible(true);
|
||||
setNeedsSearch(true);
|
||||
case 'show':
|
||||
store.show();
|
||||
return;
|
||||
}
|
||||
case 'hide':
|
||||
setIsVisible(false);
|
||||
store.hide();
|
||||
return;
|
||||
case 'toggle': {
|
||||
if(isVisible)
|
||||
{
|
||||
setIsVisible(false);
|
||||
|
||||
case 'toggle':
|
||||
store.toggle();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsVisible(true);
|
||||
setNeedsSearch(true);
|
||||
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);
|
||||
}
|
||||
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;
|
||||
const code = parts[2];
|
||||
const value = parts.length > 3 ? parts[3] : '';
|
||||
store.setTab(code);
|
||||
if(value) store.setFilter(value);
|
||||
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 ]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(isReady || !topLevelContext) return;
|
||||
|
||||
setIsReady(true);
|
||||
}, [ 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,32 +111,28 @@ export const NavigatorView: FC<{}> = props =>
|
||||
uniqueKey="navigator">
|
||||
<NitroCard.Header
|
||||
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
|
||||
onCloseClick={ event => setIsVisible(false) } />
|
||||
onCloseClick={ () => useNavigatorUiStore.getState().hide() } />
|
||||
<NitroCard.Tabs>
|
||||
<NitroCard.TabItem
|
||||
isActive={ isOpenSavesSearches }
|
||||
title={ LocalizeText('navigator.tooltip.left.show.hide') }
|
||||
onClick={ () => setIsOpenSavesSearches(prev => !prev) }>
|
||||
onClick={ () => useNavigatorUiStore.getState().toggleSavesSearches() }>
|
||||
<img src={ savesSearchIcon } alt="" style={{ width: 18, height: 18 }} />
|
||||
</NitroCard.TabItem>
|
||||
{ topLevelContexts && (topLevelContexts.length > 0) && topLevelContexts.map((context, index) =>
|
||||
{
|
||||
return (
|
||||
{ topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) =>
|
||||
<NitroCard.TabItem
|
||||
key={ index }
|
||||
isActive={ ((topLevelContext === context) && !isCreatorOpen) }
|
||||
onClick={ event => sendSearch('', context.code) }>
|
||||
{ LocalizeText(('navigator.toplevelview.' + context.code)) }
|
||||
</NitroCard.TabItem>
|
||||
);
|
||||
}) }
|
||||
isActive={ topLevelContext === context && !isCreatorOpen }
|
||||
onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }>
|
||||
{ LocalizeText('navigator.toplevelview.' + context.code) }
|
||||
</NitroCard.TabItem>) }
|
||||
<NitroCard.TabItem
|
||||
isActive={ isCreatorOpen }
|
||||
onClick={ event => setCreatorOpen(true) }>
|
||||
onClick={ () => useNavigatorUiStore.getState().openCreator() }>
|
||||
<FaPlus className="fa-icon" />
|
||||
</NitroCard.TabItem>
|
||||
</NitroCard.Tabs>
|
||||
<NitroCard.Content isLoading={ isLoading }>
|
||||
<NitroCard.Content isLoading={ isFetching }>
|
||||
{ !isCreatorOpen &&
|
||||
<div className="flex h-full overflow-hidden gap-2">
|
||||
{ isOpenSavesSearches &&
|
||||
@@ -241,49 +140,37 @@ export const NavigatorView: FC<{}> = props =>
|
||||
<NavigatorSearchSavesResultView searches={ navigatorSearches || [] } />
|
||||
</div> }
|
||||
<div className="flex flex-col w-full overflow-hidden gap-2">
|
||||
<NavigatorSearchView sendSearch={ sendSearch } />
|
||||
<NavigatorSearchView />
|
||||
<div ref={ elementRef } className="flex flex-col flex-1 min-h-0 overflow-auto gap-2">
|
||||
{ (searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />)) }
|
||||
{ (searchResult && (!searchResult.results || (searchResult.results.length === 0))) &&
|
||||
{ searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />) }
|
||||
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
|
||||
<div className="nitro-card-panel px-3 py-2 text-sm text-muted">
|
||||
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
|
||||
</div> }
|
||||
</div>
|
||||
<Flex className="nitro-card-divider pt-2 border-t gap-2">
|
||||
<Flex
|
||||
pointer
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
<Flex pointer alignItems="center" justifyContent="center"
|
||||
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
|
||||
style={ { backgroundImage: `url(${ createRoomImg })`, backgroundSize: '100% 100%' } }
|
||||
onClick={ () => setCreatorOpen(true) }
|
||||
>
|
||||
onClick={ () => useNavigatorUiStore.getState().openCreator() }>
|
||||
<Text variant="white" bold className="text-xs drop-shadow">
|
||||
{ LocalizeText('navigator.createroom.create') }
|
||||
</Text>
|
||||
</Flex>
|
||||
{ (searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view') &&
|
||||
<Flex
|
||||
pointer
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
{ searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view' &&
|
||||
<Flex pointer alignItems="center" justifyContent="center"
|
||||
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
|
||||
style={ { backgroundImage: `url(${ randomRoomImg })`, backgroundSize: '100% 100%' } }
|
||||
onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }
|
||||
>
|
||||
onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }>
|
||||
<Text variant="white" bold className="text-xs drop-shadow">
|
||||
{ LocalizeText('navigator.random.room') }
|
||||
</Text>
|
||||
</Flex> }
|
||||
{ (searchResult?.code === 'myworld_view' || searchResult?.code === 'roomads_view') &&
|
||||
<Flex
|
||||
pointer
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
<Flex pointer alignItems="center" justifyContent="center"
|
||||
className="flex-1 h-[60px] cursor-pointer bg-no-repeat pl-16"
|
||||
style={ { backgroundImage: `url(${ promoteRoomImg })`, backgroundSize: '100% 100%' } }
|
||||
onClick={ () => CreateLinkEvent('catalog/open/room_event') }
|
||||
>
|
||||
onClick={ () => CreateLinkEvent('catalog/open/room_event') }>
|
||||
<Text variant="white" bold className="text-xs drop-shadow">
|
||||
{ LocalizeText('navigator.promote.room') }
|
||||
</Text>
|
||||
@@ -291,13 +178,26 @@ export const NavigatorView: FC<{}> = props =>
|
||||
</Flex>
|
||||
</div>
|
||||
</div> }
|
||||
{ isCreatorOpen && <NavigatorRoomCreatorView /> }
|
||||
{ isCreatorOpen &&
|
||||
<WidgetErrorBoundary name="NavigatorRoomCreator">
|
||||
<NavigatorRoomCreatorView />
|
||||
</WidgetErrorBoundary> }
|
||||
</NitroCard.Content>
|
||||
</NitroCard> }
|
||||
<WidgetErrorBoundary name="NavigatorDoorState">
|
||||
<NavigatorDoorStateView />
|
||||
{ isRoomInfoOpen && <NavigatorRoomInfoView onCloseClick={ () => setRoomInfoOpen(false) } /> }
|
||||
{ isRoomLinkOpen && <NavigatorRoomLinkView onCloseClick={ () => setRoomLinkOpen(false) } /> }
|
||||
</WidgetErrorBoundary>
|
||||
{ isRoomInfoOpen &&
|
||||
<WidgetErrorBoundary name="NavigatorRoomInfo">
|
||||
<NavigatorRoomInfoView onCloseClick={ () => useNavigatorUiStore.getState().setRoomInfoOpen(false) } />
|
||||
</WidgetErrorBoundary> }
|
||||
{ isRoomLinkOpen &&
|
||||
<WidgetErrorBoundary name="NavigatorRoomLink">
|
||||
<NavigatorRoomLinkView onCloseClick={ () => useNavigatorUiStore.getState().setRoomLinkOpen(false) } />
|
||||
</WidgetErrorBoundary> }
|
||||
<WidgetErrorBoundary name="NavigatorRoomSettings">
|
||||
<NavigatorRoomSettingsView />
|
||||
</WidgetErrorBoundary>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,88 +1,68 @@
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { CreateRoomSession, DoorStateType, GoToDesktop, LocalizeText } from '../../../api';
|
||||
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
|
||||
import { useNavigator } from '../../../hooks';
|
||||
import { useDoorState } from '../../../hooks';
|
||||
import { NitroInput } from '../../../layout';
|
||||
|
||||
const VISIBLE_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER, DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ];
|
||||
const DOORBELL_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER ];
|
||||
const PASSWORD_STATES = [ DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ];
|
||||
|
||||
export const NavigatorDoorStateView: FC<{}> = props =>
|
||||
{
|
||||
const [ password, setPassword ] = useState('');
|
||||
const { doorData = null, setDoorData = null } = useNavigator();
|
||||
const { snapshot, setSnapshot, reset } = useDoorState();
|
||||
|
||||
const onClose = () =>
|
||||
{
|
||||
if(doorData && (doorData.state === DoorStateType.STATE_WAITING)) GoToDesktop();
|
||||
|
||||
setDoorData(null);
|
||||
if(snapshot.state === DoorStateType.STATE_WAITING) GoToDesktop();
|
||||
reset();
|
||||
};
|
||||
|
||||
const ring = () =>
|
||||
{
|
||||
if(!doorData || !doorData.roomInfo) return;
|
||||
|
||||
CreateRoomSession(doorData.roomInfo.roomId);
|
||||
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.state = DoorStateType.STATE_PENDING_SERVER;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
if(!snapshot.roomInfo) return;
|
||||
CreateRoomSession(snapshot.roomInfo.roomId);
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER }));
|
||||
};
|
||||
|
||||
const tryEntering = () =>
|
||||
{
|
||||
if(!doorData || !doorData.roomInfo) return;
|
||||
|
||||
CreateRoomSession(doorData.roomInfo.roomId, password);
|
||||
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.state = DoorStateType.STATE_PENDING_SERVER;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
if(!snapshot.roomInfo) return;
|
||||
CreateRoomSession(snapshot.roomInfo.roomId, password);
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER }));
|
||||
};
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!doorData || (doorData.state !== DoorStateType.STATE_NO_ANSWER)) return;
|
||||
|
||||
if(snapshot.state !== DoorStateType.STATE_NO_ANSWER) return;
|
||||
GoToDesktop();
|
||||
}, [ doorData ]);
|
||||
}, [ snapshot.state ]);
|
||||
|
||||
if(!doorData || (doorData.state === DoorStateType.NONE) || (VISIBLE_STATES.indexOf(doorData.state) === -1)) return null;
|
||||
if(snapshot.state === DoorStateType.NONE) return null;
|
||||
if(VISIBLE_STATES.indexOf(snapshot.state) === -1) return null;
|
||||
|
||||
const isDoorbell = (DOORBELL_STATES.indexOf(doorData.state) >= 0);
|
||||
const isDoorbell = DOORBELL_STATES.indexOf(snapshot.state) >= 0;
|
||||
|
||||
return (
|
||||
<NitroCardView className="nitro-navigator-doorbell" theme="primary-slim">
|
||||
<NitroCardHeaderView headerText={ LocalizeText(isDoorbell ? 'navigator.doorbell.title' : 'navigator.password.title') } onCloseClick={ onClose } />
|
||||
<NitroCardContentView>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Text bold>{ doorData && doorData.roomInfo && doorData.roomInfo.roomName }</Text>
|
||||
{ (doorData.state === DoorStateType.START_DOORBELL) &&
|
||||
<Text bold>{ snapshot.roomInfo && snapshot.roomInfo.roomName }</Text>
|
||||
{ snapshot.state === DoorStateType.START_DOORBELL &&
|
||||
<Text>{ LocalizeText('navigator.doorbell.info') }</Text> }
|
||||
{ (doorData.state === DoorStateType.STATE_WAITING) &&
|
||||
{ snapshot.state === DoorStateType.STATE_WAITING &&
|
||||
<Text>{ LocalizeText('navigator.doorbell.waiting') }</Text> }
|
||||
{ (doorData.state === DoorStateType.STATE_NO_ANSWER) &&
|
||||
{ snapshot.state === DoorStateType.STATE_NO_ANSWER &&
|
||||
<Text>{ LocalizeText('navigator.doorbell.no.answer') }</Text> }
|
||||
{ (doorData.state === DoorStateType.START_PASSWORD) &&
|
||||
{ snapshot.state === DoorStateType.START_PASSWORD &&
|
||||
<Text>{ LocalizeText('navigator.password.info') }</Text> }
|
||||
{ (doorData.state === DoorStateType.STATE_WRONG_PASSWORD) &&
|
||||
{ snapshot.state === DoorStateType.STATE_WRONG_PASSWORD &&
|
||||
<Text>{ LocalizeText('navigator.password.retryinfo') }</Text> }
|
||||
</div>
|
||||
{ isDoorbell &&
|
||||
<div className="flex flex-col gap-1">
|
||||
{ (doorData.state === DoorStateType.START_DOORBELL) &&
|
||||
{ snapshot.state === DoorStateType.START_DOORBELL &&
|
||||
<Button variant="success" onClick={ ring }>
|
||||
{ LocalizeText('navigator.doorbell.button.ring') }
|
||||
</Button> }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CreateFlatMessageComposer, HabboClubLevelEnum } from '@nitrots/nitro-re
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { GetClubMemberLevel, GetConfigurationValue, IRoomModel, LocalizeText, SendMessageComposer } from '../../../api';
|
||||
import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '../../../common';
|
||||
import { useNavigator } from '../../../hooks';
|
||||
import { useNavigatorData } from '../../../hooks';
|
||||
import { NitroInput } from '../../../layout';
|
||||
import { useRoomCreatorStore } from './navigatorRoomCreatorStore';
|
||||
|
||||
@@ -25,7 +25,7 @@ export const NavigatorRoomCreatorView: FC = () =>
|
||||
});
|
||||
const isCreating = useRoomCreatorStore(s => s.isCreating);
|
||||
const beginCreate = useRoomCreatorStore(s => s.beginCreate);
|
||||
const { categories = null } = useNavigator();
|
||||
const { categories } = useNavigatorData();
|
||||
|
||||
const hcDisabled = GetConfigurationValue<boolean>('hc.disabled', false);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { FaLink, FaSignOutAlt } from 'react-icons/fa';
|
||||
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api';
|
||||
import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
|
||||
import { RoomWidgetThumbnailEvent } from '../../../events';
|
||||
import { useHasPermission, useHelp, useNavigator, useRoom } from '../../../hooks';
|
||||
import { useHasPermission, useHelp, useNavigatorData, useRoom } from '../../../hooks';
|
||||
import { classNames } from '../../../layout';
|
||||
|
||||
export interface NavigatorRoomInfoViewProps {
|
||||
@@ -17,7 +17,7 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
||||
const [ isRoomPicked, setIsRoomPicked ] = useState(false);
|
||||
const [ isRoomMuted, setIsRoomMuted ] = useState(false);
|
||||
const { report = null } = useHelp();
|
||||
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
|
||||
const { navigatorData, favouriteRoomIds } = useNavigatorData();
|
||||
const { roomSession = null } = useRoom();
|
||||
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
|
||||
const canStaffPick = useHasPermission('acc_staff_pick');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FC } from 'react';
|
||||
import { GetConfigurationValue, LocalizeText } from '../../../api';
|
||||
import { LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
|
||||
import { useNavigator } from '../../../hooks';
|
||||
import { useNavigatorData } from '../../../hooks';
|
||||
|
||||
export class NavigatorRoomLinkViewProps
|
||||
{
|
||||
@@ -11,7 +11,7 @@ export class NavigatorRoomLinkViewProps
|
||||
export const NavigatorRoomLinkView: FC<NavigatorRoomLinkViewProps> = props =>
|
||||
{
|
||||
const { onCloseClick = null } = props;
|
||||
const { navigatorData = null } = useNavigator();
|
||||
const { navigatorData } = useNavigatorData();
|
||||
|
||||
if(!navigatorData.enteredGuestRoom) return null;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { FaTimes } from 'react-icons/fa';
|
||||
import { CreateLinkEvent, GetMaxVisitorsList, IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||
import { Base, Column, Flex, Text } from '../../../../common';
|
||||
import { useMessageEvent, useNavigator, useNotification } from '../../../../hooks';
|
||||
import { useMessageEvent, useNavigatorData, useNotification } from '../../../../hooks';
|
||||
|
||||
const ROOM_NAME_MIN_LENGTH = 3;
|
||||
const ROOM_NAME_MAX_LENGTH = 60;
|
||||
@@ -27,7 +27,7 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
|
||||
const [ tagIndex, setTagIndex ] = useState(0);
|
||||
const [ typeError, setTypeError ] = useState<string>('');
|
||||
const { showConfirm = null } = useNotification();
|
||||
const { categories = null } = useNavigator();
|
||||
const { categories } = useNavigatorData();
|
||||
|
||||
useMessageEvent<RoomSettingsSaveErrorEvent>(RoomSettingsSaveErrorEvent, event =>
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import React, { FC, useRef, useState } from 'react';
|
||||
import { FaUser } from 'react-icons/fa';
|
||||
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api';
|
||||
import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
|
||||
import { useHelp, useNavigator } from '../../../../hooks';
|
||||
import { useHelp, useNavigatorData } from '../../../../hooks';
|
||||
import { classNames } from '../../../../layout';
|
||||
|
||||
interface NavigatorSearchResultItemInfoViewProps
|
||||
@@ -20,7 +20,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
||||
const { roomData = null, isVisible = undefined, onToggle, setIsPopoverActive } = props;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const [ internalVisible, setInternalVisible ] = useState(false);
|
||||
const { navigatorData = null, favouriteRoomIds = [] } = useNavigator();
|
||||
const { navigatorData, favouriteRoomIds } = useNavigatorData();
|
||||
const { report = null } = useHelp();
|
||||
|
||||
const isControlled = isVisible !== undefined;
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { FC, MouseEvent, useEffect } from 'react';
|
||||
import { FaUser } from 'react-icons/fa';
|
||||
import { CreateRoomSession, DoorStateType, TryVisitRoom } from '../../../../api';
|
||||
import { Column, Flex, LayoutBadgeImageView, LayoutGridItemProps, LayoutRoomThumbnailView, Text } from '../../../../common';
|
||||
import { useNavigator } from '../../../../hooks';
|
||||
import { useDoorState } from '../../../../hooks';
|
||||
import { NavigatorSearchResultItemInfoView } from './NavigatorSearchResultItemInfoView';
|
||||
|
||||
export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps
|
||||
@@ -19,7 +19,7 @@ export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps
|
||||
export const NavigatorSearchResultItemView: FC<NavigatorSearchResultItemViewProps> = props =>
|
||||
{
|
||||
const { roomData = null, children = null, thumbnail = false, selectedRoomId, setSelectedRoomId, isPopoverActive, setIsPopoverActive, ...rest } = props;
|
||||
const { setDoorData = null } = useNavigator();
|
||||
const { setSnapshot: setDoorData } = useDoorState();
|
||||
|
||||
const handleMouseEnter = () =>
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react';
|
||||
import { FaBars, FaMinus, FaPlus, FaTh, FaWindowMaximize, FaWindowRestore } from 'react-icons/fa';
|
||||
import { LocalizeText, NavigatorSearchResultViewDisplayMode, SendMessageComposer } from '../../../../api';
|
||||
import { AutoGrid, AutoGridProps, Column, Flex, Grid, LayoutSearchSavesView, Text } from '../../../../common';
|
||||
import { useNavigator } from '../../../../hooks';
|
||||
import { useNavigatorData } from '../../../../hooks';
|
||||
import { NavigatorSearchResultItemView } from './NavigatorSearchResultItemView';
|
||||
|
||||
export interface NavigatorSearchResultViewProps extends AutoGridProps
|
||||
@@ -19,7 +19,7 @@ export const NavigatorSearchResultView: FC<NavigatorSearchResultViewProps> = pro
|
||||
const [ selectedRoomId, setSelectedRoomId ] = useState<number | null>(null);
|
||||
const [ isPopoverActive, setIsPopoverActive ] = useState<boolean>(false);
|
||||
|
||||
const { topLevelContext = null } = useNavigator();
|
||||
const { topLevelContext } = useNavigatorData();
|
||||
|
||||
const getResultTitle = () =>
|
||||
{
|
||||
|
||||
@@ -2,37 +2,17 @@ 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 { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } 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 processSearch = () =>
|
||||
{
|
||||
if(!topLevelContext) return;
|
||||
|
||||
let searchFilter = SearchFilterOptions[searchFilterIndex];
|
||||
|
||||
if(!searchFilter) searchFilter = SearchFilterOptions[0];
|
||||
|
||||
const searchQuery = ((searchFilter.query ? (searchFilter.query + ':') : '') + searchValue);
|
||||
|
||||
sendSearch((searchQuery || ''), topLevelContext.code);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) =>
|
||||
{
|
||||
if(event.key !== 'Enter') return;
|
||||
|
||||
processSearch();
|
||||
};
|
||||
const [ inputText, setInputText ] = useState('');
|
||||
const { topLevelContext } = useNavigatorData();
|
||||
const { searchResult } = useNavigatorSearch();
|
||||
|
||||
// Sync the input text display when a server result arrives (e.g. on tab switch
|
||||
// or deep-link navigation that sets the filter through the store directly).
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!searchResult) return;
|
||||
@@ -57,9 +37,39 @@ export const NavigatorSearchView: FC<{
|
||||
if(!filter) filter = SearchFilterOptions[0];
|
||||
|
||||
setSearchFilterIndex(SearchFilterOptions.findIndex(option => (option === filter)));
|
||||
setSearchValue(value);
|
||||
setInputText(value);
|
||||
}, [ searchResult ]);
|
||||
|
||||
// Debounced filter — 300ms after the user stops typing, push to the store
|
||||
// which updates the query key and triggers a refetch.
|
||||
useEffect(() =>
|
||||
{
|
||||
const timer = setTimeout(() =>
|
||||
{
|
||||
const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
|
||||
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText;
|
||||
useNavigatorUiStore.getState().setFilter(searchQuery);
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [ inputText, searchFilterIndex ]);
|
||||
|
||||
const processSearch = () =>
|
||||
{
|
||||
if(!topLevelContext) return;
|
||||
// Immediate submit — skip the debounce timer
|
||||
const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
|
||||
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText;
|
||||
useNavigatorUiStore.getState().setFilter(searchQuery);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) =>
|
||||
{
|
||||
if(event.key !== 'Enter') return;
|
||||
|
||||
processSearch();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-1">
|
||||
<div className="flex shrink-0">
|
||||
@@ -71,7 +81,7 @@ export const NavigatorSearchView: FC<{
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex w-full gap-1">
|
||||
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ searchValue } onChange={ event => setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
|
||||
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ inputText } onChange={ event => setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
|
||||
<Button variant="primary" onClick={ processSearch }>
|
||||
<FaSearch className="fa-icon" />
|
||||
</Button>
|
||||
|
||||
@@ -109,6 +109,10 @@ export const AvatarInfoWidgetRentableBotView: FC<AvatarInfoWidgetRentableBotView
|
||||
case 'dance':
|
||||
SendMessageComposer(new BotSkillSaveComposer(avatarInfo.webID, BotSkillsEnum.DANCE, ''));
|
||||
break;
|
||||
case 'rotate':
|
||||
SendMessageComposer(new BotSkillSaveComposer(avatarInfo.webID, BotSkillsEnum.ROTATE, ''));
|
||||
hideMenu = false;
|
||||
break;
|
||||
case 'nux_take_tour':
|
||||
CreateLinkEvent('help/tour');
|
||||
SendMessageComposer(new BotSkillSaveComposer(avatarInfo.webID, BotSkillsEnum.NUX_TAKE_TOUR, ''));
|
||||
@@ -170,6 +174,10 @@ export const AvatarInfoWidgetRentableBotView: FC<AvatarInfoWidgetRentableBotView
|
||||
<ContextMenuListItemView onClick={ event => processAction('dance') }>
|
||||
{ LocalizeText('avatar.widget.dance') }
|
||||
</ContextMenuListItemView> }
|
||||
{ (avatarInfo.botSkills.indexOf(BotSkillsEnum.ROTATE) >= 0) &&
|
||||
<ContextMenuListItemView onClick={ event => processAction('rotate') }>
|
||||
{ LocalizeText('tooltip.roombuilding.rotate') }
|
||||
</ContextMenuListItemView> }
|
||||
{ (avatarInfo.botSkills.indexOf(BotSkillsEnum.NO_PICK_UP) === -1) &&
|
||||
<ContextMenuListItemView onClick={ event => processAction('pick') }>
|
||||
{ LocalizeText('avatar.widget.pick_up') }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { UpdateRoomFilterMessageComposer } from '@nitrots/nitro-renderer';
|
||||
import { FC, useState } from 'react';
|
||||
import { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||
import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
|
||||
import { useFilterWordsWidget, useNavigator } from '../../../../hooks';
|
||||
import { useFilterWordsWidget, useNavigatorData } from '../../../../hooks';
|
||||
import { NitroInput, classNames } from '../../../../layout';
|
||||
|
||||
export const RoomFilterWordsWidgetView: FC<{}> = props =>
|
||||
@@ -11,7 +11,7 @@ export const RoomFilterWordsWidgetView: FC<{}> = props =>
|
||||
const [ selectedWord, setSelectedWord ] = useState<string>('');
|
||||
const [ isSelectingWord, setIsSelectingWord ] = useState<boolean>(false);
|
||||
const { wordsFilter = [], isVisible = null, setWordsFilter, onClose = null } = useFilterWordsWidget();
|
||||
const { navigatorData = null } = useNavigator();
|
||||
const { navigatorData } = useNavigatorData();
|
||||
|
||||
const processAction = (isAddingWord: boolean) =>
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@ import { classNames } from '../../../../layout';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
|
||||
import { Text } from '../../../../common';
|
||||
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
|
||||
import { useMessageEvent, useNavigatorData, useRoom } from '../../../../hooks';
|
||||
import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
|
||||
|
||||
export const RoomToolsWidgetView: FC<{}> = props =>
|
||||
@@ -18,7 +18,7 @@ export const RoomToolsWidgetView: FC<{}> = props =>
|
||||
const [isOpenHistory, setIsOpenHistory] = useState<boolean>(false);
|
||||
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
|
||||
const [plugins, setPlugins] = useState<INitroPlugin[]>([]);
|
||||
const { navigatorData = null } = useNavigator();
|
||||
const { navigatorData } = useNavigatorData();
|
||||
const { roomSession = null } = useRoom();
|
||||
|
||||
// Subscribe to external plugin changes
|
||||
|
||||
@@ -1 +1,7 @@
|
||||
export * from './useNavigator';
|
||||
export { useNavigatorData } from './useNavigatorData';
|
||||
export { useNavigatorSearch } from './useNavigatorSearch';
|
||||
export { useNavigatorUiState } from './useNavigatorUiState';
|
||||
export { useNavigatorUiStore } from './navigatorUiStore';
|
||||
export { useDoorState } from '../rooms/widgets/useDoorState';
|
||||
export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState';
|
||||
export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore';
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
const INITIAL = {
|
||||
isVisible: false,
|
||||
isReady: false,
|
||||
isCreatorOpen: false,
|
||||
isRoomInfoOpen: false,
|
||||
isRoomLinkOpen: false,
|
||||
isOpenSavesSearches: false,
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false,
|
||||
currentTabCode: '',
|
||||
currentFilter: ''
|
||||
};
|
||||
|
||||
describe('useNavigatorUiStore', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
useNavigatorUiStore.setState(INITIAL);
|
||||
});
|
||||
|
||||
it('exposes the documented defaults', () =>
|
||||
{
|
||||
const s = useNavigatorUiStore.getState();
|
||||
expect(s.isVisible).toBe(false);
|
||||
expect(s.isReady).toBe(false);
|
||||
expect(s.isCreatorOpen).toBe(false);
|
||||
expect(s.isRoomInfoOpen).toBe(false);
|
||||
expect(s.isRoomLinkOpen).toBe(false);
|
||||
expect(s.isOpenSavesSearches).toBe(false);
|
||||
expect(s.isLoading).toBe(false);
|
||||
expect(s.needsInit).toBe(true);
|
||||
expect(s.needsSearch).toBe(false);
|
||||
expect(s.currentTabCode).toBe('');
|
||||
expect(s.currentFilter).toBe('');
|
||||
});
|
||||
|
||||
describe('show / hide / toggle', () =>
|
||||
{
|
||||
it('show() sets isVisible true and requests a search', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().show();
|
||||
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
|
||||
expect(useNavigatorUiStore.getState().needsSearch).toBe(true);
|
||||
});
|
||||
|
||||
it('hide() sets isVisible false without touching needsSearch', () =>
|
||||
{
|
||||
useNavigatorUiStore.setState({ isVisible: true, needsSearch: false });
|
||||
useNavigatorUiStore.getState().hide();
|
||||
expect(useNavigatorUiStore.getState().isVisible).toBe(false);
|
||||
expect(useNavigatorUiStore.getState().needsSearch).toBe(false);
|
||||
});
|
||||
|
||||
it('toggle() flips visibility and requests a search on show', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().toggle();
|
||||
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
|
||||
expect(useNavigatorUiStore.getState().needsSearch).toBe(true);
|
||||
|
||||
useNavigatorUiStore.setState({ needsSearch: false });
|
||||
useNavigatorUiStore.getState().toggle();
|
||||
expect(useNavigatorUiStore.getState().isVisible).toBe(false);
|
||||
expect(useNavigatorUiStore.getState().needsSearch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('creator panel', () =>
|
||||
{
|
||||
it('openCreator() opens both visible and creator', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().openCreator();
|
||||
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
|
||||
expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('closeCreator() closes only the creator panel', () =>
|
||||
{
|
||||
useNavigatorUiStore.setState({ isVisible: true, isCreatorOpen: true });
|
||||
useNavigatorUiStore.getState().closeCreator();
|
||||
expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(false);
|
||||
expect(useNavigatorUiStore.getState().isVisible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roomInfo / roomLink / savesSearches', () =>
|
||||
{
|
||||
it('setRoomInfoOpen(true) and toggleRoomInfo flip the flag', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setRoomInfoOpen(true);
|
||||
expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(true);
|
||||
useNavigatorUiStore.getState().toggleRoomInfo();
|
||||
expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('setRoomLinkOpen(true) and toggleRoomLink flip the flag', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setRoomLinkOpen(true);
|
||||
expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(true);
|
||||
useNavigatorUiStore.getState().toggleRoomLink();
|
||||
expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('toggleSavesSearches() flips the sidebar flag', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().toggleSavesSearches();
|
||||
expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(true);
|
||||
useNavigatorUiStore.getState().toggleSavesSearches();
|
||||
expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lifecycle flags', () =>
|
||||
{
|
||||
it('setLoading(true) and setLoading(false) toggle isLoading', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setLoading(true);
|
||||
expect(useNavigatorUiStore.getState().isLoading).toBe(true);
|
||||
useNavigatorUiStore.getState().setLoading(false);
|
||||
expect(useNavigatorUiStore.getState().isLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('markReady() sets isReady true and is idempotent', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().markReady();
|
||||
expect(useNavigatorUiStore.getState().isReady).toBe(true);
|
||||
useNavigatorUiStore.getState().markReady();
|
||||
expect(useNavigatorUiStore.getState().isReady).toBe(true);
|
||||
});
|
||||
|
||||
it('markInitDone() flips needsInit to false', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().markInitDone();
|
||||
expect(useNavigatorUiStore.getState().needsInit).toBe(false);
|
||||
});
|
||||
|
||||
it('requestSearch() + consumeSearchRequest() are symmetric', () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().requestSearch();
|
||||
expect(useNavigatorUiStore.getState().needsSearch).toBe(true);
|
||||
useNavigatorUiStore.getState().consumeSearchRequest();
|
||||
expect(useNavigatorUiStore.getState().needsSearch).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tab + filter', () =>
|
||||
{
|
||||
it("setTab('public') sets currentTabCode and clears currentFilter", () =>
|
||||
{
|
||||
useNavigatorUiStore.setState({ currentTabCode: 'events', currentFilter: 'habbo' });
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('public');
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||
});
|
||||
|
||||
it("setFilter('cocco') sets currentFilter without touching tab", () =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('events');
|
||||
useNavigatorUiStore.getState().setFilter('cocco');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
|
||||
});
|
||||
|
||||
it('setTab on same code still resets currentFilter', () =>
|
||||
{
|
||||
useNavigatorUiStore.setState({ currentTabCode: 'public', currentFilter: 'test' });
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('public');
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { createNitroStore } from '../../state/createNitroStore';
|
||||
|
||||
export type NavigatorUiState = {
|
||||
isVisible: boolean;
|
||||
isReady: boolean;
|
||||
isCreatorOpen: boolean;
|
||||
isRoomInfoOpen: boolean;
|
||||
isRoomLinkOpen: boolean;
|
||||
isOpenSavesSearches: boolean;
|
||||
isLoading: boolean;
|
||||
needsInit: boolean;
|
||||
needsSearch: boolean;
|
||||
currentTabCode: string;
|
||||
currentFilter: string;
|
||||
};
|
||||
|
||||
export type NavigatorUiActions = {
|
||||
show(): void;
|
||||
hide(): void;
|
||||
toggle(): void;
|
||||
openCreator(): void;
|
||||
closeCreator(): void;
|
||||
setRoomInfoOpen(open: boolean): void;
|
||||
toggleRoomInfo(): void;
|
||||
setRoomLinkOpen(open: boolean): void;
|
||||
toggleRoomLink(): void;
|
||||
toggleSavesSearches(): void;
|
||||
setLoading(loading: boolean): void;
|
||||
markReady(): void;
|
||||
markInitDone(): void;
|
||||
requestSearch(): void;
|
||||
consumeSearchRequest(): void;
|
||||
setTab(code: string): void;
|
||||
setFilter(value: string): void;
|
||||
};
|
||||
|
||||
export const useNavigatorUiStore = createNitroStore<NavigatorUiState & NavigatorUiActions>()((set) => ({
|
||||
isVisible: false,
|
||||
isReady: false,
|
||||
isCreatorOpen: false,
|
||||
isRoomInfoOpen: false,
|
||||
isRoomLinkOpen: false,
|
||||
isOpenSavesSearches: false,
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false,
|
||||
currentTabCode: '',
|
||||
currentFilter: '',
|
||||
|
||||
show: () => set({ isVisible: true, needsSearch: true }),
|
||||
hide: () => set({ isVisible: false }),
|
||||
toggle: () => set((s) => s.isVisible
|
||||
? { isVisible: false }
|
||||
: { isVisible: true, needsSearch: true }),
|
||||
openCreator: () => set({ isVisible: true, isCreatorOpen: true }),
|
||||
closeCreator: () => set({ isCreatorOpen: false }),
|
||||
setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }),
|
||||
toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })),
|
||||
setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }),
|
||||
toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })),
|
||||
toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })),
|
||||
setLoading: (loading) => set({ isLoading: loading }),
|
||||
markReady: () => set({ isReady: true }),
|
||||
markInitDone: () => set({ needsInit: false }),
|
||||
requestSearch: () => set({ needsSearch: true }),
|
||||
consumeSearchRequest: () => set({ needsSearch: false }),
|
||||
setTab: (code) => set({ currentTabCode: code, currentFilter: '' }),
|
||||
setFilter: (value) => set({ currentFilter: value })
|
||||
}));
|
||||
@@ -1,492 +0,0 @@
|
||||
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, DoorbellMessageEvent, FavouriteChangedEvent, FavouritesEvent, FlatAccessDeniedMessageEvent, FlatCreatedEvent, FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, NavigatorSearchesEvent, NavigatorSearchEvent, NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomDoorbellAcceptedEvent, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
|
||||
import { useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { CreateRoomSession, DoorStateType, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api';
|
||||
import { useMessageEvent, useNitroEvent } from '../events';
|
||||
import { useNotification } from '../notification';
|
||||
|
||||
const useNavigatorState = () =>
|
||||
{
|
||||
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
|
||||
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
|
||||
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
|
||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||
const [ doorData, setDoorData ] = useState<{ roomInfo: RoomDataParser, state: number }>({ roomInfo: null, state: DoorStateType.NONE });
|
||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet>(null);
|
||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||
const [ navigatorData, setNavigatorData ] = useState<INavigatorData>({
|
||||
settingsReceived: false,
|
||||
homeRoomId: 0,
|
||||
enteredGuestRoom: null,
|
||||
currentRoomOwner: false,
|
||||
currentRoomId: 0,
|
||||
currentRoomIsStaffPick: false,
|
||||
createdFlatId: 0,
|
||||
avatarId: 0,
|
||||
roomPicker: false,
|
||||
eventMod: false,
|
||||
currentRoomRating: 0,
|
||||
canRate: true
|
||||
});
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
useMessageEvent<FavouritesEvent>(FavouritesEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
|
||||
setFavouriteRoomIds(favoriteIds);
|
||||
});
|
||||
|
||||
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const roomId = Number(parser.flatId);
|
||||
const added = !!parser.added;
|
||||
|
||||
setFavouriteRoomIds(prev =>
|
||||
{
|
||||
const ids = (prev || []).map((x: any) => Number(x));
|
||||
|
||||
if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ];
|
||||
|
||||
return ids.filter(id => id !== roomId);
|
||||
});
|
||||
});
|
||||
|
||||
useMessageEvent<RoomSettingsUpdatedEvent>(RoomSettingsUpdatedEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false));
|
||||
});
|
||||
|
||||
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.canCreate)
|
||||
{
|
||||
// show room event cvreate
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title'));
|
||||
});
|
||||
|
||||
useMessageEvent<UserInfoEvent>(UserInfoEvent, event =>
|
||||
{
|
||||
SendMessageComposer(new GetUserFlatCatsMessageComposer());
|
||||
SendMessageComposer(new GetUserEventCatsMessageComposer());
|
||||
});
|
||||
|
||||
useMessageEvent<UserPermissionsEvent>(UserPermissionsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setNavigatorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.eventMod = (parser.securityLevel >= SecurityLevel.MODERATOR);
|
||||
newValue.roomPicker = (parser.securityLevel >= SecurityLevel.COMMUNITY);
|
||||
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
|
||||
useMessageEvent<RoomForwardEvent>(RoomForwardEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
TryVisitRoom(parser.roomId);
|
||||
});
|
||||
|
||||
useMessageEvent<RoomEntryInfoMessageEvent>(RoomEntryInfoMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setNavigatorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.enteredGuestRoom = null;
|
||||
newValue.currentRoomOwner = parser.isOwner;
|
||||
newValue.currentRoomId = parser.roomId;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
// close room info
|
||||
// close room settings
|
||||
// close room filter
|
||||
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false));
|
||||
|
||||
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]);
|
||||
});
|
||||
|
||||
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(parser.roomEnter)
|
||||
{
|
||||
setDoorData({ roomInfo: null, state: DoorStateType.NONE });
|
||||
|
||||
setNavigatorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.enteredGuestRoom = parser.data;
|
||||
newValue.currentRoomIsStaffPick = parser.staffPick;
|
||||
|
||||
const isCreated = (newValue.createdFlatId === parser.data.roomId);
|
||||
|
||||
if(!isCreated && parser.data.displayRoomEntryAd)
|
||||
{
|
||||
if(GetConfigurationValue<boolean>('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd();
|
||||
}
|
||||
|
||||
newValue.createdFlatId = 0;
|
||||
|
||||
if(newValue.enteredGuestRoom && (newValue.enteredGuestRoom.habboGroupId > 0))
|
||||
{
|
||||
// close event info
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
}
|
||||
else if(parser.roomForward)
|
||||
{
|
||||
if((parser.data.ownerName !== GetSessionDataManager().userName) && !parser.isGroupMember)
|
||||
{
|
||||
switch(parser.data.doorMode)
|
||||
{
|
||||
case RoomDataParser.DOORBELL_STATE:
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.roomInfo = parser.data;
|
||||
newValue.state = DoorStateType.START_DOORBELL;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
return;
|
||||
case RoomDataParser.PASSWORD_STATE:
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.roomInfo = parser.data;
|
||||
newValue.state = DoorStateType.START_PASSWORD;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return;
|
||||
|
||||
CreateRoomSession(parser.data.roomId);
|
||||
}
|
||||
else
|
||||
{
|
||||
setNavigatorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.enteredGuestRoom = parser.data;
|
||||
newValue.currentRoomIsStaffPick = parser.staffPick;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<RoomScoreEvent>(RoomScoreEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setNavigatorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.currentRoomRating = parser.totalLikes;
|
||||
newValue.canRate = parser.canLike;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
});
|
||||
|
||||
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser.userName || (parser.userName.length === 0))
|
||||
{
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.state = DoorStateType.STATE_WAITING;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser.userName || (parser.userName.length === 0))
|
||||
{
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.state = DoorStateType.STATE_ACCEPTED;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
if(!parser.userName || (parser.userName.length === 0))
|
||||
{
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.state = DoorStateType.STATE_NO_ANSWER;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
switch(parser.errorCode)
|
||||
{
|
||||
case -100002:
|
||||
setDoorData(prevValue =>
|
||||
{
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.state = DoorStateType.STATE_WRONG_PASSWORD;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
return;
|
||||
case 4009:
|
||||
simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
|
||||
return;
|
||||
case 4010:
|
||||
simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
|
||||
return;
|
||||
case 4011:
|
||||
simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
|
||||
return;
|
||||
case 4013:
|
||||
simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<NavigatorMetadataEvent>(NavigatorMetadataEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setTopLevelContexts(parser.topLevelContexts);
|
||||
setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null);
|
||||
});
|
||||
|
||||
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setTopLevelContext(prevValue =>
|
||||
{
|
||||
let newValue = prevValue;
|
||||
|
||||
if(!newValue) newValue = ((topLevelContexts && topLevelContexts.length && topLevelContexts[0]) || null);
|
||||
|
||||
if(!newValue) return null;
|
||||
|
||||
if((parser.result.code !== newValue.code) && topLevelContexts && topLevelContexts.length)
|
||||
{
|
||||
for(const context of topLevelContexts)
|
||||
{
|
||||
if(context.code !== parser.result.code) continue;
|
||||
|
||||
newValue = context;
|
||||
}
|
||||
}
|
||||
|
||||
for(const context of topLevelContexts)
|
||||
{
|
||||
if(context.code !== parser.result.code) continue;
|
||||
|
||||
newValue = context;
|
||||
}
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
setSearchResult(parser.result);
|
||||
});
|
||||
|
||||
useMessageEvent<UserFlatCatsEvent>(UserFlatCatsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setCategories(parser.categories);
|
||||
});
|
||||
|
||||
useMessageEvent<UserEventCatsEvent>(UserEventCatsEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
setEventCategories(parser.categories);
|
||||
});
|
||||
|
||||
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
CreateRoomSession(parser.roomId);
|
||||
});
|
||||
|
||||
// When reconnection starts, reset settingsReceived so the login sequence's
|
||||
// NavigatorHomeRoomEvent is treated as a fresh login. Without this, the
|
||||
// prevSettingsReceived check blocks home room navigation after reconnection,
|
||||
// leaving the user stuck on hotel view.
|
||||
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () =>
|
||||
{
|
||||
setNavigatorData(prevValue => ({ ...prevValue, settingsReceived: false }));
|
||||
});
|
||||
|
||||
useMessageEvent<NavigatorHomeRoomEvent>(NavigatorHomeRoomEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
let prevSettingsReceived = false;
|
||||
|
||||
setNavigatorData(prevValue =>
|
||||
{
|
||||
prevSettingsReceived = prevValue.settingsReceived;
|
||||
|
||||
const newValue = { ...prevValue };
|
||||
|
||||
newValue.homeRoomId = parser.homeRoomId;
|
||||
newValue.settingsReceived = true;
|
||||
|
||||
return newValue;
|
||||
});
|
||||
|
||||
if(prevSettingsReceived)
|
||||
{
|
||||
// refresh room info window
|
||||
return;
|
||||
}
|
||||
|
||||
// If a room session was already restored (from a network disconnect reload),
|
||||
// skip the normal home room navigation to avoid overriding it.
|
||||
if(GetRoomSessionManager().viewerSession) return;
|
||||
|
||||
let forwardType = -1;
|
||||
let forwardId = -1;
|
||||
|
||||
if((GetConfigurationValue<string>('friend.id') !== undefined) && (parseInt(GetConfigurationValue<string>('friend.id')) > 0))
|
||||
{
|
||||
forwardType = 0;
|
||||
SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue<string>('friend.id'))));
|
||||
}
|
||||
|
||||
if((GetConfigurationValue<number>('forward.type') !== undefined) && (GetConfigurationValue<number>('forward.id') !== undefined))
|
||||
{
|
||||
forwardType = parseInt(GetConfigurationValue<string>('forward.type'));
|
||||
forwardId = parseInt(GetConfigurationValue<string>('forward.id'));
|
||||
}
|
||||
|
||||
if(forwardType === 2)
|
||||
{
|
||||
TryVisitRoom(forwardId);
|
||||
}
|
||||
|
||||
else if((forwardType === -1) && (parser.roomIdToEnter > 0))
|
||||
{
|
||||
CreateLinkEvent('navigator/close');
|
||||
|
||||
if(parser.roomIdToEnter !== parser.homeRoomId)
|
||||
{
|
||||
CreateRoomSession(parser.roomIdToEnter);
|
||||
}
|
||||
else
|
||||
{
|
||||
CreateRoomSession(parser.homeRoomId);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
useMessageEvent<RoomEnterErrorEvent>(RoomEnterErrorEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
|
||||
switch(parser.reason)
|
||||
{
|
||||
case CantConnectMessageParser.REASON_FULL:
|
||||
simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title'));
|
||||
|
||||
break;
|
||||
case CantConnectMessageParser.REASON_QUEUE_ERROR:
|
||||
simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title'));
|
||||
|
||||
break;
|
||||
case CantConnectMessageParser.REASON_BANNED:
|
||||
simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title'));
|
||||
|
||||
break;
|
||||
default:
|
||||
simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title'));
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// During reconnection, don't navigate to desktop — the reconnection guard
|
||||
// will handle retrying or cleaning up. Calling VisitDesktop here would
|
||||
// remove the session from the map and send the user to hotel view.
|
||||
if(GetRoomSessionManager().isReconnecting) return;
|
||||
|
||||
VisitDesktop();
|
||||
});
|
||||
|
||||
useMessageEvent<NavigatorOpenRoomCreatorEvent>(NavigatorOpenRoomCreatorEvent, event => CreateLinkEvent('navigator/show'));
|
||||
|
||||
useMessageEvent<NavigatorSearchesEvent>(NavigatorSearchesEvent, event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(!parser) return;
|
||||
setNavigatorSearches(parser.searches);
|
||||
});
|
||||
|
||||
return { categories, doorData, setDoorData, topLevelContext, topLevelContexts, searchResult, navigatorData, favouriteRoomIds, navigatorSearches };
|
||||
};
|
||||
|
||||
export const useNavigator = () => useBetween(useNavigatorState);
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useBetween } from 'use-between';
|
||||
import { useNavigatorStore } from './useNavigatorStore';
|
||||
|
||||
export const useNavigatorData = () =>
|
||||
{
|
||||
const {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
} = useBetween(useNavigatorStore);
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { FlatCreatedEvent, NavigatorSearchEvent,
|
||||
NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mockEventDispatcher } from '../../nitro-renderer.mock';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
import { useNavigatorSearch } from './useNavigatorSearch';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a fresh QueryClient with retries off so failures are immediate. */
|
||||
const makeQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 }
|
||||
}
|
||||
});
|
||||
|
||||
/** Wrapper factory — each test gets its own QueryClient instance. */
|
||||
const makeWrapper = (client: QueryClient) =>
|
||||
({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={ client }>
|
||||
{ children }
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */
|
||||
const makeSearchEvent = (code: string) =>
|
||||
{
|
||||
// Cast constructors as `any` so tsgo doesn't check required args against
|
||||
// the real renderer SDK types (the mock stubs have no required args).
|
||||
const result = new (NavigatorSearchResultSet as any)() as any;
|
||||
result.code = code;
|
||||
result.data = '';
|
||||
result.results = [];
|
||||
|
||||
const ev = new (NavigatorSearchEvent as any)() as any;
|
||||
ev.getParser = () => ({ result });
|
||||
return ev;
|
||||
};
|
||||
|
||||
const INITIAL_UI = {
|
||||
isVisible: false,
|
||||
isReady: false,
|
||||
isCreatorOpen: false,
|
||||
isRoomInfoOpen: false,
|
||||
isRoomLinkOpen: false,
|
||||
isOpenSavesSearches: false,
|
||||
isLoading: false,
|
||||
needsInit: true,
|
||||
needsSearch: false,
|
||||
currentTabCode: '',
|
||||
currentFilter: ''
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useNavigatorSearch', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
// Reset UI store state before each test
|
||||
useNavigatorUiStore.setState(INITIAL_UI);
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Dispatch a search event — should be ignored (query disabled)
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
// Data must stay null
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Activate the query
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
|
||||
// Hook should start fetching
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Simulate server response
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
// Query should resolve with the matching result
|
||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
});
|
||||
|
||||
it('3. after setFilter("cocco"), a new query fires and NavigatorSearchEvent resolves it', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// First establish a tab
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
// Resolve the initial query
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Now set a filter — triggers new query
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setFilter('cocco');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Resolve with matching event
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
|
||||
// Confirm filter is set
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
|
||||
});
|
||||
|
||||
it('4. after setTab("events"), currentFilter resets to "" and new query fires for events', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Establish public tab with a filter
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
useNavigatorUiStore.getState().setFilter('some-filter');
|
||||
});
|
||||
|
||||
// Resolve the public+filter query
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Switch to events tab — should atomically reset filter
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('events');
|
||||
});
|
||||
|
||||
// Filter must be cleared
|
||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
|
||||
|
||||
// New query for 'events' fires
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Resolve with events result
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
expect((result.current.searchResult as any).code).toBe('events');
|
||||
});
|
||||
|
||||
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
{
|
||||
expect(result.current.searchResult).not.toBeNull();
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
});
|
||||
});
|
||||
|
||||
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
|
||||
// Dispatch an event for a DIFFERENT tab — should be rejected by accept filter
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any);
|
||||
});
|
||||
|
||||
// Still fetching — the wrong-tab event was ignored
|
||||
// (the query promise stays pending until it times out or a matching event arrives)
|
||||
// After the wrong-tab dispatch, data should NOT be updated
|
||||
expect(result.current.searchResult).toBeNull();
|
||||
|
||||
// Now dispatch the correct one to unblock the test
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||
// Only the correct-tab result is stored
|
||||
expect((result.current.searchResult as any).code).toBe('public');
|
||||
});
|
||||
|
||||
it('7. dispatching FlatCreatedEvent triggers query invalidation (refetch starts)', async () =>
|
||||
{
|
||||
const client = makeQueryClient();
|
||||
|
||||
// Spy on invalidateQueries to confirm the invalidator calls it
|
||||
const invalidateSpy = vi.spyOn(client, 'invalidateQueries');
|
||||
|
||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
||||
|
||||
// Establish a resolved query so there is something to invalidate
|
||||
act(() =>
|
||||
{
|
||||
useNavigatorUiStore.getState().setTab('public');
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||
});
|
||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||
|
||||
// Dispatch FlatCreatedEvent — should trigger invalidateQueries
|
||||
const flatCreatedEv = new (FlatCreatedEvent as any)() as any;
|
||||
flatCreatedEv.getParser = () => ({ roomId: 999 });
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(flatCreatedEv as any);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(invalidateSpy).toHaveBeenCalled());
|
||||
|
||||
// The invalidation should target the 'navigator', 'search' key prefix
|
||||
const calls = invalidateSpy.mock.calls;
|
||||
const calledWithSearchKey = calls.some(call =>
|
||||
{
|
||||
const opts = call[0] as any;
|
||||
const key: string[] = opts?.queryKey ?? [];
|
||||
return key[0] === 'navigator' && key[1] === 'search';
|
||||
});
|
||||
expect(calledWithSearchKey).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { SendMessageComposer } from '../../api';
|
||||
import { useMessageEvent } from '../events';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
|
||||
export const useNavigatorSearch = () =>
|
||||
{
|
||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||
|
||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet | null>(null);
|
||||
const [ isFetching, setIsFetching ] = useState(false);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if(!tabCode) return;
|
||||
|
||||
setIsFetching(true);
|
||||
SendMessageComposer(new NavigatorSearchComposer(tabCode, filter));
|
||||
}, [ tabCode, filter ]);
|
||||
|
||||
useMessageEvent<NavigatorSearchEvent>(NavigatorSearchEvent, event =>
|
||||
{
|
||||
const result = event.getParser()?.result;
|
||||
if(!result) return;
|
||||
|
||||
if(tabCode && result.code !== tabCode) return;
|
||||
|
||||
setSearchResult(result);
|
||||
setIsFetching(false);
|
||||
});
|
||||
|
||||
return {
|
||||
searchResult,
|
||||
isFetching,
|
||||
refetch: () =>
|
||||
{
|
||||
if(!tabCode) return;
|
||||
setIsFetching(true);
|
||||
SendMessageComposer(new NavigatorSearchComposer(tabCode, filter));
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { useNavigatorData, useNavigatorUiState } from './index';
|
||||
|
||||
describe('navigator filter shapes (smoke)', () =>
|
||||
{
|
||||
it('useNavigatorData returns the documented keys', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorData());
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'categories', 'eventCategories', 'favouriteRoomIds',
|
||||
'navigatorData', 'navigatorSearches',
|
||||
'topLevelContext', 'topLevelContexts'
|
||||
].sort());
|
||||
});
|
||||
|
||||
it('useNavigatorUiState returns the 11 documented flags', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorUiState());
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'currentFilter', 'currentTabCode',
|
||||
'isCreatorOpen', 'isLoading', 'isOpenSavesSearches',
|
||||
'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible',
|
||||
'needsInit', 'needsSearch'
|
||||
].sort());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent,
|
||||
FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent,
|
||||
FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer,
|
||||
GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager,
|
||||
GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer,
|
||||
HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser,
|
||||
NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent,
|
||||
NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch,
|
||||
NavigatorSearchesEvent,
|
||||
NavigatorTopLevelContext, NitroEventType,
|
||||
RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent,
|
||||
RoomForwardEvent, RoomScoreEvent,
|
||||
SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent,
|
||||
UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { CreateRoomSession, GetConfigurationValue, INavigatorData,
|
||||
LocalizeText, NotificationAlertType, SendMessageComposer,
|
||||
TryVisitRoom, VisitDesktop } from '../../api';
|
||||
import { useMessageEvent, useNitroEvent } from '../events';
|
||||
import { useNotification } from '../notification';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorStore = () =>
|
||||
{
|
||||
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
|
||||
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
|
||||
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
|
||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||
const [ navigatorData, setNavigatorData ] = useState<INavigatorData>({
|
||||
settingsReceived: false,
|
||||
homeRoomId: 0,
|
||||
enteredGuestRoom: null,
|
||||
currentRoomOwner: false,
|
||||
currentRoomId: 0,
|
||||
currentRoomIsStaffPick: false,
|
||||
createdFlatId: 0,
|
||||
avatarId: 0,
|
||||
roomPicker: false,
|
||||
eventMod: false,
|
||||
currentRoomRating: 0,
|
||||
canRate: true
|
||||
});
|
||||
|
||||
const { simpleAlert = null } = useNotification();
|
||||
|
||||
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
|
||||
setFavouriteRoomIds(favoriteIds);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const roomId = Number(parser.flatId);
|
||||
const added = !!parser.added;
|
||||
setFavouriteRoomIds(prev =>
|
||||
{
|
||||
const ids = (prev || []).map((x: any) => Number(x));
|
||||
if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ];
|
||||
return ids.filter(id => id !== roomId);
|
||||
});
|
||||
}, []));
|
||||
|
||||
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.canCreate) return;
|
||||
simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title'));
|
||||
}, [ simpleAlert ]));
|
||||
|
||||
useMessageEvent<UserInfoEvent>(UserInfoEvent, useCallback(event =>
|
||||
{
|
||||
SendMessageComposer(new GetUserFlatCatsMessageComposer());
|
||||
SendMessageComposer(new GetUserEventCatsMessageComposer());
|
||||
}, []));
|
||||
|
||||
useMessageEvent<UserPermissionsEvent>(UserPermissionsEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
eventMod: parser.securityLevel >= SecurityLevel.MODERATOR,
|
||||
roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY
|
||||
}));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomForwardEvent>(RoomForwardEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
TryVisitRoom(parser.roomId);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomEntryInfoMessageEvent>(RoomEntryInfoMessageEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
enteredGuestRoom: null,
|
||||
currentRoomOwner: parser.isOwner,
|
||||
currentRoomId: parser.roomId
|
||||
}));
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false));
|
||||
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.roomEnter)
|
||||
{
|
||||
setNavigatorData(prev =>
|
||||
{
|
||||
const next = { ...prev };
|
||||
next.enteredGuestRoom = parser.data;
|
||||
next.currentRoomIsStaffPick = parser.staffPick;
|
||||
const isCreated = next.createdFlatId === parser.data.roomId;
|
||||
if(!isCreated && parser.data.displayRoomEntryAd)
|
||||
{
|
||||
if(GetConfigurationValue<boolean>('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd();
|
||||
}
|
||||
next.createdFlatId = 0;
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if(parser.roomForward)
|
||||
{
|
||||
// Door-mode branches (DOORBELL_STATE / PASSWORD_STATE) are handled by useDoorState — skip them here.
|
||||
const isOwner = parser.data.ownerName === GetSessionDataManager().userName;
|
||||
if(!isOwner && !parser.isGroupMember)
|
||||
{
|
||||
if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) return;
|
||||
if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) return;
|
||||
}
|
||||
if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return;
|
||||
CreateRoomSession(parser.data.roomId);
|
||||
return;
|
||||
}
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
enteredGuestRoom: parser.data,
|
||||
currentRoomIsStaffPick: parser.staffPick
|
||||
}));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomScoreEvent>(RoomScoreEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setNavigatorData(prev => ({
|
||||
...prev,
|
||||
currentRoomRating: parser.totalLikes,
|
||||
canRate: parser.canLike
|
||||
}));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
// -100002 (wrong password) is handled by useDoorState — skip it here.
|
||||
switch(parser.errorCode)
|
||||
{
|
||||
case 4009:
|
||||
simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
case 4010:
|
||||
simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
case 4011:
|
||||
simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
case 4013:
|
||||
simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title'));
|
||||
return;
|
||||
}
|
||||
}, [ simpleAlert ]));
|
||||
|
||||
useMessageEvent<NavigatorMetadataEvent>(NavigatorMetadataEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setTopLevelContexts(parser.topLevelContexts);
|
||||
setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null);
|
||||
// Seed the query's tab code so useNavigatorSearch activates immediately
|
||||
useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? '');
|
||||
}, []));
|
||||
|
||||
useMessageEvent<UserFlatCatsEvent>(UserFlatCatsEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setCategories(parser.categories);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<UserEventCatsEvent>(UserEventCatsEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
setEventCategories(parser.categories);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
CreateRoomSession(parser.roomId);
|
||||
}, []));
|
||||
|
||||
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, useCallback(() =>
|
||||
{
|
||||
setNavigatorData(prev => ({ ...prev, settingsReceived: false }));
|
||||
}, []));
|
||||
|
||||
useMessageEvent<NavigatorHomeRoomEvent>(NavigatorHomeRoomEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
let prevSettingsReceived = false;
|
||||
setNavigatorData(prev =>
|
||||
{
|
||||
prevSettingsReceived = prev.settingsReceived;
|
||||
return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true };
|
||||
});
|
||||
if(prevSettingsReceived) return;
|
||||
if(GetRoomSessionManager().viewerSession) return;
|
||||
|
||||
let forwardType = -1;
|
||||
let forwardId = -1;
|
||||
if((GetConfigurationValue<string>('friend.id') !== undefined) && (parseInt(GetConfigurationValue<string>('friend.id')) > 0))
|
||||
{
|
||||
forwardType = 0;
|
||||
SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue<string>('friend.id'))));
|
||||
}
|
||||
if((GetConfigurationValue<number>('forward.type') !== undefined) && (GetConfigurationValue<number>('forward.id') !== undefined))
|
||||
{
|
||||
forwardType = parseInt(GetConfigurationValue<string>('forward.type'));
|
||||
forwardId = parseInt(GetConfigurationValue<string>('forward.id'));
|
||||
}
|
||||
if(forwardType === 2)
|
||||
{
|
||||
TryVisitRoom(forwardId);
|
||||
}
|
||||
else if((forwardType === -1) && (parser.roomIdToEnter > 0))
|
||||
{
|
||||
CreateLinkEvent('navigator/close');
|
||||
CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId);
|
||||
}
|
||||
}, []));
|
||||
|
||||
useMessageEvent<RoomEnterErrorEvent>(RoomEnterErrorEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
switch(parser.reason)
|
||||
{
|
||||
case CantConnectMessageParser.REASON_FULL:
|
||||
simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title'));
|
||||
break;
|
||||
case CantConnectMessageParser.REASON_QUEUE_ERROR:
|
||||
simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title'));
|
||||
break;
|
||||
case CantConnectMessageParser.REASON_BANNED:
|
||||
simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title'));
|
||||
break;
|
||||
default:
|
||||
simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title'));
|
||||
break;
|
||||
}
|
||||
if(GetRoomSessionManager().isReconnecting) return;
|
||||
VisitDesktop();
|
||||
}, [ simpleAlert ]));
|
||||
|
||||
useMessageEvent<NavigatorOpenRoomCreatorEvent>(NavigatorOpenRoomCreatorEvent, useCallback(_event =>
|
||||
{
|
||||
CreateLinkEvent('navigator/show');
|
||||
}, []));
|
||||
|
||||
useMessageEvent<NavigatorSearchesEvent>(NavigatorSearchesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(!parser) return;
|
||||
setNavigatorSearches(parser.searches);
|
||||
}, []));
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorUiState = () =>
|
||||
{
|
||||
const isVisible = useNavigatorUiStore(s => s.isVisible);
|
||||
const isReady = useNavigatorUiStore(s => s.isReady);
|
||||
const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen);
|
||||
const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen);
|
||||
const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen);
|
||||
const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches);
|
||||
const isLoading = useNavigatorUiStore(s => s.isLoading);
|
||||
const needsInit = useNavigatorUiStore(s => s.needsInit);
|
||||
const needsSearch = useNavigatorUiStore(s => s.needsSearch);
|
||||
const currentTabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||
const currentFilter = useNavigatorUiStore(s => s.currentFilter);
|
||||
return {
|
||||
isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen,
|
||||
isOpenSavesSearches, isLoading, needsInit, needsSearch,
|
||||
currentTabCode, currentFilter
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,181 @@
|
||||
import { act, cleanup, renderHook } from '@testing-library/react';
|
||||
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
|
||||
GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser,
|
||||
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { DoorStateType } from '../../../api';
|
||||
import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock';
|
||||
import { useDoorState } from './useDoorState';
|
||||
|
||||
const makeParserlessEvent = (klass: any, parser: any) =>
|
||||
{
|
||||
const ev = new klass();
|
||||
(ev as any).getParser = () => parser;
|
||||
return ev;
|
||||
};
|
||||
|
||||
describe('useDoorState', () =>
|
||||
{
|
||||
beforeEach(() =>
|
||||
{
|
||||
clearMockEventDispatcher();
|
||||
const { result, unmount } = renderHook(() => useDoorState());
|
||||
act(() => result.current.reset());
|
||||
unmount();
|
||||
});
|
||||
|
||||
afterEach(() =>
|
||||
{
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('exposes the initial NONE snapshot', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
|
||||
expect(result.current.snapshot.roomInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('DoorbellMessageEvent with empty userName -> STATE_WAITING', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
|
||||
});
|
||||
|
||||
it('DoorbellMessageEvent with non-empty userName does NOT change state', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
const before = result.current.snapshot.state;
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: 'someone' }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(before);
|
||||
});
|
||||
|
||||
it('RoomDoorbellAcceptedEvent (empty userName) -> STATE_ACCEPTED', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(RoomDoorbellAcceptedEvent, { userName: '' }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_ACCEPTED);
|
||||
});
|
||||
|
||||
it('FlatAccessDeniedMessageEvent (empty userName) -> STATE_NO_ANSWER', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(FlatAccessDeniedMessageEvent, { userName: '' }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_NO_ANSWER);
|
||||
});
|
||||
|
||||
it('GenericErrorEvent -100002 -> STATE_WRONG_PASSWORD', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: -100002 }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WRONG_PASSWORD);
|
||||
});
|
||||
|
||||
it('GenericErrorEvent 4010 does NOT touch door state', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
const before = result.current.snapshot.state;
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: 4010 }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(before);
|
||||
});
|
||||
|
||||
it('GetGuestRoomResultEvent with roomForward + DOORBELL_STATE -> START_DOORBELL', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.DOORBELL_STATE };
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
|
||||
roomForward: true,
|
||||
isGroupMember: false,
|
||||
data: fakeRoomData
|
||||
}));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.START_DOORBELL);
|
||||
expect(result.current.snapshot.roomInfo).toBe(fakeRoomData);
|
||||
});
|
||||
|
||||
it('GetGuestRoomResultEvent with roomForward + PASSWORD_STATE -> START_PASSWORD', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.PASSWORD_STATE };
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
|
||||
roomForward: true,
|
||||
isGroupMember: false,
|
||||
data: fakeRoomData
|
||||
}));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.START_PASSWORD);
|
||||
});
|
||||
|
||||
it('GetGuestRoomResultEvent with non-bell/password doorMode does NOT change state', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
const before = result.current.snapshot.state;
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
|
||||
roomForward: true,
|
||||
isGroupMember: false,
|
||||
data: { ownerName: 'other', doorMode: 99 }
|
||||
}));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(before);
|
||||
});
|
||||
|
||||
it('GetGuestRoomResultEvent with roomEnter=true resets snapshot to NONE', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
// First put the hook into a non-NONE state via doorbell
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
|
||||
// Then roomEnter event should dismiss it
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, {
|
||||
roomEnter: true,
|
||||
roomForward: false,
|
||||
data: {}
|
||||
}));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
|
||||
expect(result.current.snapshot.roomInfo).toBeNull();
|
||||
});
|
||||
|
||||
it('reset() returns snapshot to NONE', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useDoorState());
|
||||
act(() =>
|
||||
{
|
||||
mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' }));
|
||||
});
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING);
|
||||
act(() => result.current.reset());
|
||||
expect(result.current.snapshot.state).toBe(DoorStateType.NONE);
|
||||
expect(result.current.snapshot.roomInfo).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent,
|
||||
GenericErrorEvent, GetGuestRoomResultEvent,
|
||||
GetSessionDataManager, RoomDataParser,
|
||||
RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useBetween } from 'use-between';
|
||||
import { DoorStateType } from '../../../api';
|
||||
import { useMessageEvent } from '../../events';
|
||||
|
||||
export type DoorStateSnapshot = {
|
||||
roomInfo: RoomDataParser | null;
|
||||
state: number;
|
||||
};
|
||||
|
||||
const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE };
|
||||
|
||||
const useDoorStateStore = () =>
|
||||
{
|
||||
const [ snapshot, setSnapshot ] = useState<DoorStateSnapshot>(INITIAL);
|
||||
|
||||
const handleDoorbell = useCallback((event: DoorbellMessageEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING }));
|
||||
}, []);
|
||||
|
||||
const handleAccepted = useCallback((event: RoomDoorbellAcceptedEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED }));
|
||||
}, []);
|
||||
|
||||
const handleDenied = useCallback((event: FlatAccessDeniedMessageEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.userName && parser.userName.length > 0) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER }));
|
||||
}, []);
|
||||
|
||||
const handleGenericError = useCallback((event: GenericErrorEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.errorCode !== -100002) return;
|
||||
setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD }));
|
||||
}, []);
|
||||
|
||||
const handleGuestRoom = useCallback((event: GetGuestRoomResultEvent) =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
if(parser.roomEnter)
|
||||
{
|
||||
setSnapshot(INITIAL);
|
||||
return;
|
||||
}
|
||||
if(!parser.roomForward) return;
|
||||
if(parser.data.ownerName === GetSessionDataManager().userName) return;
|
||||
if(parser.isGroupMember) return;
|
||||
if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE)
|
||||
{
|
||||
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL });
|
||||
return;
|
||||
}
|
||||
if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE)
|
||||
{
|
||||
setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useMessageEvent<DoorbellMessageEvent>(DoorbellMessageEvent, handleDoorbell);
|
||||
useMessageEvent<RoomDoorbellAcceptedEvent>(RoomDoorbellAcceptedEvent, handleAccepted);
|
||||
useMessageEvent<FlatAccessDeniedMessageEvent>(FlatAccessDeniedMessageEvent, handleDenied);
|
||||
useMessageEvent<GenericErrorEvent>(GenericErrorEvent, handleGenericError);
|
||||
useMessageEvent<GetGuestRoomResultEvent>(GetGuestRoomResultEvent, handleGuestRoom);
|
||||
|
||||
const reset = useCallback(() => setSnapshot(INITIAL), []);
|
||||
|
||||
return { snapshot, setSnapshot, reset };
|
||||
};
|
||||
|
||||
export const useDoorState = () => useBetween(useDoorStateStore);
|
||||
+192
-7
@@ -43,8 +43,20 @@ export const NitroLogger = {
|
||||
|
||||
type Listener = (event: any) => void;
|
||||
|
||||
// NitroEvent listeners — registered via GetEventDispatcher() / useNitroEvent.
|
||||
// Cleared by clearMockEventDispatcher() between test cases.
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
// MessageEvent listeners — registered via GetCommunication().registerMessageEvent
|
||||
// (i.e. useMessageEvent). NOT cleared by clearMockEventDispatcher() so that
|
||||
// useBetween-based hooks (which register effects once and persist the
|
||||
// singleton across tests) keep their subscriptions alive throughout the
|
||||
// suite. State isolation between tests is maintained by the useBetween
|
||||
// instance preserving INITIAL values across renders (each test's renderHook
|
||||
// shares the same useBetween singleton — tests that check a specific
|
||||
// post-dispatch state rely on the event changing it, not on a reset).
|
||||
const msgListeners = new Map<string, Set<Listener>>();
|
||||
|
||||
export const mockEventDispatcher = {
|
||||
addEventListener(type: string, handler: Listener)
|
||||
{
|
||||
@@ -64,18 +76,23 @@ export const mockEventDispatcher = {
|
||||
},
|
||||
dispatchEvent(event: { type: string })
|
||||
{
|
||||
// Fire NitroEvent listeners first, then MessageEvent listeners.
|
||||
const bucket = listeners.get(event.type);
|
||||
if(bucket) for(const handler of bucket) handler(event);
|
||||
|
||||
if(!bucket) return;
|
||||
|
||||
for(const handler of bucket) handler(event);
|
||||
const msgBucket = msgListeners.get(event.type);
|
||||
if(msgBucket) for(const handler of msgBucket) handler(event);
|
||||
},
|
||||
hasListeners(type: string)
|
||||
{
|
||||
return (listeners.get(type)?.size ?? 0) > 0;
|
||||
return (listeners.get(type)?.size ?? 0) > 0 ||
|
||||
(msgListeners.get(type)?.size ?? 0) > 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Clears only the NitroEvent listener map (GetEventDispatcher / useNitroEvent
|
||||
// registrations). MessageEvent listeners (useMessageEvent / GetCommunication)
|
||||
// are intentionally preserved so useBetween-based hooks stay subscribed.
|
||||
export const clearMockEventDispatcher = () =>
|
||||
{
|
||||
listeners.clear();
|
||||
@@ -188,7 +205,117 @@ export class NitroSprite extends StubClass {}
|
||||
export class NitroTexture extends StubClass {}
|
||||
export class NitroSoundEvent extends StubClass {}
|
||||
export class NitroEvent extends StubClass {}
|
||||
export class MessageEvent extends StubClass {}
|
||||
|
||||
// MessageEvent — stores the handler so GetCommunication (below) can
|
||||
// route dispatches through mockEventDispatcher. Each concrete subclass
|
||||
// exposes a `.type` equal to its constructor name so dispatchEvent
|
||||
// can match registered listeners.
|
||||
export class MessageEvent
|
||||
{
|
||||
private _callBack: Function | null;
|
||||
|
||||
constructor(callBack?: Function)
|
||||
{
|
||||
this._callBack = callBack ?? null;
|
||||
}
|
||||
|
||||
public get callBack(): Function | null { return this._callBack; }
|
||||
|
||||
// Each concrete subclass is identified by its class name.
|
||||
public get type(): string { return this.constructor.name; }
|
||||
|
||||
// Concrete subclasses override this; the no-arg construction path used
|
||||
// by makeParserlessEvent in tests leaves it returning null — tests
|
||||
// override it with (ev as any).getParser = () => parser.
|
||||
public getParser(): any { return null; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IMessageEvent-based event classes used by useDoorState
|
||||
//
|
||||
// The real renderer classes take a `callBack` constructor arg and store it
|
||||
// in MessageEvent._callBack. The communication manager later calls
|
||||
// `event.callBack(event)` when the matching packet arrives.
|
||||
//
|
||||
// In tests we construct them with NO args (makeParserlessEvent does
|
||||
// `new klass()`) and override `getParser`. GetCommunication (below)
|
||||
// registers `event.callBack` on mockEventDispatcher under `event.type`
|
||||
// (the class name). When the test calls
|
||||
// `mockEventDispatcher.dispatchEvent(ev)`, listeners for that class name
|
||||
// fire, receiving `ev` — and the implementation reads `ev.getParser()`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class DoorbellMessageEvent extends MessageEvent {}
|
||||
export class RoomDoorbellAcceptedEvent extends MessageEvent {}
|
||||
export class FlatAccessDeniedMessageEvent extends MessageEvent {}
|
||||
export class GenericErrorEvent extends MessageEvent {}
|
||||
export class GetGuestRoomResultEvent extends MessageEvent {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Navigator event classes — MessageEvent subclasses needed by useNavigatorStore
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class CanCreateRoomEventEvent extends MessageEvent {}
|
||||
export class FavouriteChangedEvent extends MessageEvent {}
|
||||
export class FavouritesEvent extends MessageEvent {}
|
||||
export class FlatCreatedEvent extends MessageEvent {}
|
||||
export class NavigatorHomeRoomEvent extends MessageEvent {}
|
||||
export class NavigatorMetadataEvent extends MessageEvent {}
|
||||
export class NavigatorOpenRoomCreatorEvent extends MessageEvent {}
|
||||
export class NavigatorSearchesEvent extends MessageEvent {}
|
||||
export class NavigatorSearchEvent extends MessageEvent {}
|
||||
export class RoomEnterErrorEvent extends MessageEvent {}
|
||||
export class RoomEntryInfoMessageEvent extends MessageEvent {}
|
||||
export class RoomForwardEvent extends MessageEvent {}
|
||||
export class RoomScoreEvent extends MessageEvent {}
|
||||
export class RoomSettingsUpdatedEvent extends MessageEvent {}
|
||||
export class UserEventCatsEvent extends MessageEvent {}
|
||||
export class UserFlatCatsEvent extends MessageEvent {}
|
||||
export class UserInfoEvent extends MessageEvent {}
|
||||
export class UserPermissionsEvent extends MessageEvent {}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Notification event classes — MessageEvent subclasses needed by
|
||||
// useNotificationStore (called via useNotification() inside useNavigatorStore).
|
||||
// The real renderer classes take a `callBack` constructor arg; the pattern
|
||||
// here is the same as the Navigator event stubs above.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class AchievementNotificationMessageEvent extends MessageEvent {}
|
||||
export class ActivityPointNotificationMessageEvent extends MessageEvent {}
|
||||
export class BadgeReceivedEvent extends MessageEvent {}
|
||||
export class ClubGiftNotificationEvent extends MessageEvent {}
|
||||
export class ClubGiftSelectedEvent extends MessageEvent {}
|
||||
export class ConnectionErrorEvent extends MessageEvent {}
|
||||
export class HabboBroadcastMessageEvent extends MessageEvent {}
|
||||
export class HotelClosedAndOpensEvent extends MessageEvent {}
|
||||
export class HotelClosesAndWillOpenAtEvent extends MessageEvent {}
|
||||
export class HotelWillCloseInMinutesEvent extends MessageEvent {}
|
||||
export class InfoFeedEnableMessageEvent extends MessageEvent {}
|
||||
export class MaintenanceStatusMessageEvent extends MessageEvent {}
|
||||
export class ModeratorCautionEvent extends MessageEvent {}
|
||||
export class ModeratorMessageEvent extends MessageEvent {}
|
||||
export class MOTDNotificationEvent extends MessageEvent {}
|
||||
export class NotificationDialogMessageEvent extends MessageEvent {}
|
||||
export class PetLevelNotificationEvent extends MessageEvent {}
|
||||
export class PetReceivedMessageEvent extends MessageEvent {}
|
||||
export class RespectReceivedEvent extends MessageEvent {}
|
||||
export class RoomEnterEvent extends MessageEvent {}
|
||||
export class SimpleAlertMessageEvent extends MessageEvent {}
|
||||
export class UserBannedMessageEvent extends MessageEvent {}
|
||||
export class WiredRewardResultMessageEvent extends MessageEvent
|
||||
{
|
||||
static readonly PRODUCT_DONATED_CODE = 7;
|
||||
static readonly BADGE_DONATED_CODE = 8;
|
||||
}
|
||||
|
||||
// RoomEnterEffect — used by useNotificationStore to check if the room-enter
|
||||
// animation is still running before showing the mod disclaimer bubble.
|
||||
export const RoomEnterEffect = {
|
||||
isRunning: () => false,
|
||||
totalRunningTime: 0
|
||||
};
|
||||
|
||||
export class RoomEngineObjectEvent extends StubClass {}
|
||||
export class CreateLinkEvent extends StubClass {}
|
||||
export class EventDispatcher extends StubClass {}
|
||||
@@ -196,9 +323,35 @@ export class AdvancedMap extends StubClass {}
|
||||
export class AvatarFigureContainer extends StubClass {}
|
||||
export class Vector3d extends StubClass {}
|
||||
export class ObjectDataFactory extends StubClass {}
|
||||
export class RoomDataParser extends StubClass {}
|
||||
|
||||
// RoomDataParser — real static constants needed by useDoorState and its tests.
|
||||
export class RoomDataParser
|
||||
{
|
||||
static readonly DOORBELL_STATE = 1;
|
||||
static readonly PASSWORD_STATE = 2;
|
||||
}
|
||||
|
||||
export class RoomModerationSettings extends StubClass {}
|
||||
export class StringDataType extends StubClass {}
|
||||
|
||||
// Navigator data/parser stubs
|
||||
export class NavigatorCategoryDataParser extends StubClass {}
|
||||
export class NavigatorEventCategoryDataParser extends StubClass {}
|
||||
export class NavigatorSavedSearch extends StubClass {}
|
||||
export class NavigatorSearchResultSet extends StubClass {}
|
||||
export class NavigatorTopLevelContext extends StubClass {}
|
||||
export class CantConnectMessageParser extends StubClass
|
||||
{
|
||||
static readonly REASON_FULL = 1;
|
||||
static readonly REASON_QUEUE_ERROR = 2;
|
||||
static readonly REASON_BANNED = 3;
|
||||
}
|
||||
|
||||
export class LegacyExternalInterface
|
||||
{
|
||||
static readonly available = false;
|
||||
static call(..._args: unknown[]): void {}
|
||||
}
|
||||
export class SellablePetPaletteData extends StubClass {}
|
||||
export class PetFigureData extends StubClass {}
|
||||
export class PetData extends StubClass {}
|
||||
@@ -220,6 +373,10 @@ export class HabboWebTools extends StubClass {}
|
||||
// codebase ("did the SUT call SendMessageComposer(new FooComposer(args))").
|
||||
export class AddFavouriteRoomMessageComposer extends StubClass {}
|
||||
export class DeleteFavouriteRoomMessageComposer extends StubClass {}
|
||||
export class FollowFriendMessageComposer extends StubClass {}
|
||||
export class GetUserEventCatsMessageComposer extends StubClass {}
|
||||
export class GetUserFlatCatsMessageComposer extends StubClass {}
|
||||
export class NavigatorSearchComposer extends StubClass {}
|
||||
export class DesktopViewComposer extends StubClass {}
|
||||
export class FurniturePlacePaintComposer extends StubClass {}
|
||||
export class GetGuestRoomMessageComposer extends StubClass {}
|
||||
@@ -351,7 +508,35 @@ const stubManager = () =>
|
||||
|
||||
export const GetAssetManager = vi.fn(stubManager);
|
||||
export const GetAvatarRenderManager = vi.fn(stubManager);
|
||||
export const GetCommunication = vi.fn(stubManager);
|
||||
// GetCommunication — routes IMessageEvent registration through the
|
||||
// msgListeners map (separate from the NitroEvent listeners map) so that
|
||||
// clearMockEventDispatcher() does NOT wipe these subscriptions. This
|
||||
// keeps useBetween-based hooks (like useDoorState) subscribed across
|
||||
// test cases without needing to recreate the useBetween singleton.
|
||||
//
|
||||
// A WeakMap stores the wrapper fn keyed by the MessageEvent instance so
|
||||
// that removeMessageEvent can remove the exact listener added by
|
||||
// registerMessageEvent.
|
||||
const _msgEventWrappers = new WeakMap<MessageEvent, (ev: any) => void>();
|
||||
|
||||
export const GetCommunication = vi.fn(() => ({
|
||||
registerMessageEvent(event: MessageEvent)
|
||||
{
|
||||
if(!event.callBack) return;
|
||||
const wrapper = (ev: any) => event.callBack!(ev);
|
||||
_msgEventWrappers.set(event, wrapper);
|
||||
let bucket = msgListeners.get(event.type);
|
||||
if(!bucket) { bucket = new Set(); msgListeners.set(event.type, bucket); }
|
||||
bucket.add(wrapper);
|
||||
},
|
||||
removeMessageEvent(event: MessageEvent)
|
||||
{
|
||||
const wrapper = _msgEventWrappers.get(event);
|
||||
if(wrapper) msgListeners.get(event.type)?.delete(wrapper);
|
||||
},
|
||||
// Stub for SendMessageComposer which calls GetCommunication().connection.send(...)
|
||||
connection: { send: vi.fn() }
|
||||
}));
|
||||
export const GetConfiguration = vi.fn(stubManager);
|
||||
export const GetLocalizationManager = vi.fn(stubManager);
|
||||
export const GetRoomEngine = vi.fn(stubManager);
|
||||
|
||||
Reference in New Issue
Block a user