From 6022911448f82d5b9beb0a81d12d47eeabb5ef33 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 19:26:07 +0200 Subject: [PATCH 01/86] fix(toolbar): bump desktop layout breakpoint to 1700px to avoid icon clip The left-nav container is `max-w-[calc(50vw-242px)]` (reserves the chat frame width) and uses `overflow-x: clip`. With the full icon set (habbo, rooms, game, catalog, buildersclub, inventory, ME, wired-tools, camera, youtube, modtools, furnieditor, housekeeping) the icons exceed the available 528-608px around the 1540-1700px viewport range, so the last icons get silently clipped on the right. Raising the desktop breakpoint from 1540px to 1700px makes the client fall back to the mobile-scrollable layout (`.tb-bar-scroll`) below 1700px, which scrolls horizontally and doesn't clip. Above 1700px the desktop fixed-icon layout still applies, now with enough horizontal room for every icon even with mod+HK enabled. Touch devices are unaffected (already forced onto the mobile layout via `pointer: coarse`). --- src/components/toolbar/ToolbarView.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index edf59e6..8bec8e3 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -69,10 +69,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => toggleTimeoutRef.current = setTimeout(() => { toggleLockRef.current = false; }, TOGGLE_LOCK_MS); }, []); - const compactFramePosition = (isToolbarOpen && isInRoom) ? 'bottom-[90px] min-[1540px]:bottom-0' : 'bottom-0'; - const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1540px]:hidden'; - const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1540px]:block'; - const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1540px]:flex'; + const compactFramePosition = (isToolbarOpen && isInRoom) ? 'bottom-[90px] min-[1700px]:bottom-0' : 'bottom-0'; + const mobileOnlyClasses = isTouchLayout ? '' : 'min-[1700px]:hidden'; + const desktopBlockClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:block'; + const desktopFlexClasses = isTouchLayout ? 'hidden' : 'hidden min-[1700px]:flex'; const leftNavVariants = useMemo(() => ({ hidden: { opacity: 0, x: isInRoom ? -10 : 0, y: isInRoom ? 0 : 8, pointerEvents: 'none' }, visible: { opacity: 1, x: 0, y: 0, pointerEvents: 'auto' } From 66062c64ea27583e1a7d1ea2ffca3cc2514aea30 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 20:31:31 +0200 Subject: [PATCH 02/86] docs(navigator): P1 modernization design (hook split + UI store) First of four planned phases reworking the Navigator on a clean origin/Dev base. P1 is pure refactor (zero visible change): split the 492-line useNavigator god-hook into wired-tools-style filters (useNavigatorData / useNavigatorUiState / useNavigatorActions), extract door lifecycle to useDoorState under src/hooks/rooms/widgets, hoist the 9 local useState in NavigatorView into a Zustand navigatorUiStore, migrate all 13 active consumers, and delete the shim. The Zustand UI store uses per-key selectors in useNavigatorUiState to match createNitroStore's documented convention ("subscribe to specific slices only"). Spec also anchors the visual rework (P4) target so architecture decisions in P1 align with where we are heading: rich empty states, card hover-reveal, saved-search chip row, filter intent chips, sticky section headers, skeleton loaders. Out of scope for P1 (each gets its own future spec): TanStack Query migration of search (P2), reactive favourites/snapshot pattern (P3), virtualization + empty states + persistence + chips (P4), Form Action on search input (P6), WidgetErrorBoundary wrap (P5, parallel-eligible). --- ...05-26-navigator-modernization-p1-design.md | 549 ++++++++++++++++++ 1 file changed, 549 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md diff --git a/docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md b/docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md new file mode 100644 index 0000000..3f13b4b --- /dev/null +++ b/docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md @@ -0,0 +1,549 @@ +# Navigator Modernization — P1: Hook Split + UI Store + +**Branch:** `feat/navigator-modernization` (forked from `origin/Dev` @ `d5d5ca59`) +**Date:** 2026-05-26 +**Scope:** P1 of a 4-phase Navigator modernization sweep (P1 → P2 → P3 → P4). +**This spec covers ONLY P1.** P2 (TanStack Query), P3 (reactive snapshots), +and P4 (visual rework + virtualization + persistence) will each get their +own spec when P1 lands. + +## 1. Context + +The Nitro-V3 client has established patterns for god-hook +modernization, all visible on the current `origin/Dev` tip: + +- **God-hook split into filters over a `useBetween` singleton.** Two + precedents: + - `useWiredTools` — 4 files (`useWiredToolsStore` + `useWiredToolsState` + + `useWiredToolsActions` + `useWiredTools` shim). 630-line store. + - `useCatalog` — single 1055-line file holding store + three filters + (`useCatalogData` / `useCatalogUiState` / `useCatalogActions`). +- **Zustand UI stores** via `createNitroStore` (`src/state/createNitroStore.ts`) + for cross-feature UI flags. +- **Renderer snapshot consumer hooks** (`useSyncExternalStore`) — out of + scope for P1, used in P3. +- **`useNitroQuery`** for composer/parser request-response — out of + scope for P1, used in P2. +- **Co-located Vitest suites** under `src/`, sharing the renderer-SDK + stub at `src/nitro-renderer.mock.ts`. + +`src/hooks/navigator/useNavigator.ts` is the largest remaining god-hook +on this branch: 492 lines, 21 event listeners, 9 internal `useState`, +consumed by 13 files (10 inside `src/components/navigator/` + 3 +outside in `room-tools`, `room-filter-words`, and `catalog` views). It +mixes three logically separate concerns: + +1. **Navigator data** — search results, categories, top-level + contexts, favourites, metadata. +2. **Door state** — doorbell, password prompt, accepted / no-answer / + wrong-password lifecycle. +3. **Local UI flags** — 9 `useState` in `NavigatorView.tsx` controlling + panel visibility and search lifecycle. + +P1 separates these three and migrates all consumers. + +## 2. Decisions + +| Topic | Decision | +|---|---| +| Door state | **Extract** to `src/hooks/rooms/widgets/useDoorState.ts` | +| UI store scope | **All 9 flags** into `navigatorUiStore` Zustand | +| Shim retention | **Remove** `useNavigator` after all 13 consumers migrated | +| Filter shape | **Flat objects**, mirroring `useCatalog` and `useWiredTools` | +| File layout | **4 separate files**, mirroring `wired-tools` (not the monolithic `useCatalog.ts`) | +| Scope of P1 | **Pure refactor** — zero user-visible change | +| Branch | `feat/navigator-modernization` (forked from `origin/Dev`, not a sub-branch of any other modernization branch) | + +## 3. Architecture + +Mirrors the `wired-tools` layout exactly — 4 hook files in +`src/hooks/navigator/`, plus a sibling `navigatorUiStore.ts` for the +Zustand UI flags, plus `useDoorState.ts` extracted to +`src/hooks/rooms/widgets/`: + +``` +src/hooks/navigator/ +├── useNavigatorStore.ts ← NEW: internal useBetween closure +│ (data state + non-door listeners + actions) +├── useNavigatorData.ts ← NEW: public filter — read-only data +├── useNavigatorUiState.ts ← NEW: public filter — read-only UI flags +├── useNavigatorActions.ts ← NEW: public filter — imperative actions +├── navigatorUiStore.ts ← NEW: Zustand UI store (9 flags + actions) +├── index.ts ← REWRITTEN: barrel exports the 3 filters, +│ useNavigatorUiStore, and re-exports useDoorState +└── useNavigator.ts ← DELETED at end of P1 (god-hook shim removed) + +src/hooks/rooms/widgets/ +└── useDoorState.ts ← NEW: extracted door lifecycle +``` + +### 3.1 Internal `useNavigatorStore` closure (in `useNavigatorStore.ts`) + +The single `useBetween` singleton's internal function. Holds: + +- All non-door state currently in `useNavigatorState` of the old + `useNavigator.ts`: `categories`, `eventCategories`, + `favouriteRoomIds`, `topLevelContext`, `topLevelContexts`, + `searchResult`, `navigatorSearches`, `navigatorData`. +- All non-door event listeners (16 of them): `FavouritesEvent`, + `FavouriteChangedEvent`, `RoomSettingsUpdatedEvent`, + `CanCreateRoomEventEvent`, `UserInfoEvent`, `UserPermissionsEvent`, + `RoomForwardEvent`, `RoomEntryInfoMessageEvent`, + `NavigatorMetadataEvent`, `NavigatorSearchEvent`, + `UserFlatCatsEvent`, `UserEventCatsEvent`, `FlatCreatedEvent`, + `NavigatorHomeRoomEvent`, `RoomEnterErrorEvent`, + `NavigatorOpenRoomCreatorEvent`, `NavigatorSearchesEvent`, + plus `NitroEventType.SOCKET_RECONNECTING`. +- `GetGuestRoomResultEvent` — dual-subscribed (see §5.2). +- `GenericErrorEvent` — dual-subscribed (see §5.3). +- New imperative actions `sendSearch` and `reloadCurrentSearch`, + extracted from the current `NavigatorView.tsx` locals (today defined + on lines 42-79 of `src/components/navigator/NavigatorView.tsx`). + +### 3.2 The three filters (flat shape, wired-tools layout) + +```ts +// useNavigatorData.ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => { + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + } = useBetween(useNavigatorStore); + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + }; +}; + +// useNavigatorUiState.ts +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => { + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch, + }; +}; + +// useNavigatorActions.ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => { + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; +``` + +`useNavigatorActions` is intentionally small in P1 — favourite +toggles, room visits, and door responses keep flowing through their +existing direct composer calls in consumer components. We only hoist +the two functions that are currently prop-drilled into +`NavigatorSearchView` and the tab `onClick` handlers. + +`useNavigatorUiState` uses per-key Zustand selectors (one selector +per flag) so a component re-renders only when a flag it actually +reads changes. The flat object it returns preserves the API shape +consumers expect. + +### 3.3 `navigatorUiStore` (Zustand) + +```ts +// src/hooks/navigator/navigatorUiStore.ts +import { createNitroStore } from '../../state/createNitroStore'; + +type NavigatorUiState = { + isVisible: boolean; + isReady: boolean; + isCreatorOpen: boolean; + isRoomInfoOpen: boolean; + isRoomLinkOpen: boolean; + isOpenSavesSearches: boolean; + isLoading: boolean; + needsInit: boolean; + needsSearch: boolean; +}; + +type NavigatorUiActions = { + show(): void; + hide(): void; + toggle(): void; + openCreator(): void; + closeCreator(): void; + setRoomInfoOpen(open: boolean): void; + toggleRoomInfo(): void; + setRoomLinkOpen(open: boolean): void; + toggleRoomLink(): void; + toggleSavesSearches(): void; + setLoading(loading: boolean): void; + markReady(): void; + markInitDone(): void; + requestSearch(): void; // sets needsSearch = true + consumeSearchRequest(): void; // sets needsSearch = false +}; + +const INITIAL: NavigatorUiState = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, +}; + +export const useNavigatorUiStore = createNitroStore()((set) => ({ + ...INITIAL, + show: () => set({ isVisible: true, needsSearch: true }), + hide: () => set({ isVisible: false }), + toggle: () => set((s) => s.isVisible + ? { isVisible: false } + : { isVisible: true, needsSearch: true }), + openCreator: () => set({ isVisible: true, isCreatorOpen: true }), + closeCreator: () => set({ isCreatorOpen: false }), + setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }), + toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })), + setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }), + toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })), + toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })), + setLoading: (loading) => set({ isLoading: loading }), + markReady: () => set({ isReady: true }), + markInitDone: () => set({ needsInit: false }), + requestSearch: () => set({ needsSearch: true }), + consumeSearchRequest: () => set({ needsSearch: false }), +})); +``` + +The `linkTracker` in `NavigatorView.tsx` calls these actions directly +on `useNavigatorUiStore.getState()` instead of mutating local +`useState`. That collapses the switch statement from 30+ lines to a +clean dispatch table and eliminates the closure-over-stale-state hazard +where the tracker re-registers on every `isVisible` change (today at +`src/components/navigator/NavigatorView.tsx:162`). + +### 3.4 `useDoorState` (extracted to `src/hooks/rooms/widgets/`) + +```ts +// src/hooks/rooms/widgets/useDoorState.ts +import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, + GenericErrorEvent, GetGuestRoomResultEvent, + GetSessionDataManager, RoomDataParser, + RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { useBetween } from 'use-between'; +import { DoorStateType } from '../../../api'; +import { useMessageEvent } from '../../events'; + +export type DoorStateSnapshot = { + roomInfo: RoomDataParser | null; + state: number; // DoorStateType.* +}; + +const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE }; + +const useDoorStateStore = () => { + const [snapshot, setSnapshot] = useState(INITIAL); + + useMessageEvent(DoorbellMessageEvent, event => { + const parser = event.getParser(); + if (parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING })); + }); + + useMessageEvent(RoomDoorbellAcceptedEvent, event => { + const parser = event.getParser(); + if (parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED })); + }); + + useMessageEvent(FlatAccessDeniedMessageEvent, event => { + const parser = event.getParser(); + if (parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER })); + }); + + useMessageEvent(GenericErrorEvent, event => { + const parser = event.getParser(); + if (parser.errorCode !== -100002) return; // door-only error code + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD })); + }); + + useMessageEvent(GetGuestRoomResultEvent, event => { + const parser = event.getParser(); + // ONLY handle the roomForward branch with door modes + if (!parser.roomForward) return; + if (parser.data.ownerName === GetSessionDataManager().userName) return; + if (parser.isGroupMember) return; + if (parser.data.doorMode === RoomDataParser.DOORBELL_STATE) { + setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL }); + } else if (parser.data.doorMode === RoomDataParser.PASSWORD_STATE) { + setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD }); + } + }); + + const reset = useCallback(() => setSnapshot(INITIAL), []); + + return { snapshot, setSnapshot, reset }; +}; + +export const useDoorState = () => useBetween(useDoorStateStore); +``` + +The current `NavigatorDoorStateView.tsx` does +`setDoorData({ roomInfo: null, state: DoorStateType.NONE })` to reset +— after P1 it calls `reset()`. + +## 4. Consumer migration map (13 files) + +| File | Reads today | Reads after P1 | +|---|---|---| +| `NavigatorView.tsx` | full `useNavigator()` + 9 local useState | `useNavigatorData` + `useNavigatorActions` + `useNavigatorUiStore` (one selector per flag) | +| `NavigatorDoorStateView.tsx` | `doorData`, `setDoorData` | `useDoorState` (`snapshot`, `setSnapshot`, `reset`) | +| `NavigatorRoomCreatorView.tsx` | `categories` | `useNavigatorData` | +| `NavigatorRoomInfoView.tsx` | `navigatorData`, `favouriteRoomIds` | `useNavigatorData` | +| `NavigatorRoomLinkView.tsx` | `navigatorData.enteredGuestRoom` | `useNavigatorData` | +| `NavigatorRoomSettingsBasicTabView.tsx` | `categories` | `useNavigatorData` | +| `NavigatorSearchResultItemView.tsx` | `favouriteRoomIds`, `navigatorData` | `useNavigatorData` | +| `NavigatorSearchResultItemInfoView.tsx` | `navigatorData` | `useNavigatorData` | +| `NavigatorSearchResultView.tsx` | `topLevelContext` | `useNavigatorData` | +| `NavigatorSearchView.tsx` | `topLevelContext` + `sendSearch` prop | `useNavigatorData` + `useNavigatorActions` | +| `CatalogLayoutRoomAdsView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` | +| `RoomFilterWordsWidgetView.tsx` | `navigatorData.currentRoomId` | `useNavigatorData` | +| `RoomToolsWidgetView.tsx` | `navigatorData` | `useNavigatorData` | + +All 13 consumers get a one-line import swap (plus `NavigatorView` +which is more involved since it owns the 9 useState + linkTracker +dispatch + `sendSearch` prop drilling that all go away). No +behavioural change. + +## 5. Dual-subscription edge cases + +### 5.1 `useBetween` guarantee + +`useDoorState` uses `useBetween(useDoorStateStore)`, so multiple +consumers (currently only `NavigatorDoorStateView`) share a single +listener registration — same as how `useNavigatorStore` works. + +### 5.2 `GetGuestRoomResultEvent` — dual subscription + +Today this event is handled in one place (current `useNavigator.ts` +lines 130-209) with three branches: `roomEnter`, `roomForward`, else. +After P1: + +- `useDoorStateStore` subscribes and acts ONLY on the `roomForward` + branch when `doorMode` is `DOORBELL_STATE` or `PASSWORD_STATE` AND + the user is not the owner / not a group member. +- `useNavigatorStore` subscribes and handles `roomEnter`, the + `roomForward` branch WITHOUT door modes (direct `CreateRoomSession` + call), and the `else` branch. + +Multiple subscribers to the same event is an accepted pattern (see +`FlatCreatedEvent` listened in `useNavigator` and elsewhere). Both +listeners register through `useMessageEvent` so the renderer event +bus dispatches to both. + +### 5.3 `GenericErrorEvent` — dual subscription + +- `useDoorStateStore` acts ONLY on `errorCode === -100002` (wrong + password). +- `useNavigatorStore` acts on `4009`, `4010`, `4011`, `4013` (room + management alerts via `simpleAlert`). + +Each side filters by `errorCode` immediately — no cross-effects. + +## 6. Visual direction (anchor for P4 — informational only) + +P1 ships zero visual change. This section documents the visual +target that P4's spec will detail, so the architecture choices in +P1 align with where we are heading. + +### 6.1 Current pain points (from user screenshots, 2026-05-26) + +- **Tab "Pubbliche":** empty state is bare text "No rooms found". +- **Tab "Tutte le stanze":** popular rooms shown as a small thumbnail + grid; the "Party" category uses a compact list mode with no + visual hierarchy or live signal. +- **Tab "Eventi":** empty state is bare text "No rooms found". +- **Tab "Il mio mondo":** sparse list, no per-room preview. +- **Saved searches:** today a 600px-wide sidebar that resizes the + card and pushes content right. +- **Filter dropdown "Qualsiasi":** opaque about what filters exist. + +### 6.2 Target shape (P4 spec will detail) + +**Empty states with illustration + contextual CTA:** + +``` +┌─────────────────────────────────────┐ +│ Navigator @ Habbo [×] │ +│ [⚡][Pubbliche][Tutte][Eventi][Mio] │ +├─────────────────────────────────────┤ +│ [🔓 Aperte] [🚪 Campanello] [🔒] │ +│ [filtra stanze...] 🔍 │ +│ [🔖 staff] [🔖 party] [🔖 chill] + │ +├─────────────────────────────────────┤ +│ ╭──────────╮ │ +│ │ 🏠 ✨ │ │ +│ ╰──────────╯ │ +│ Nessuna stanza pubblica │ +│ ancora attiva │ +│ │ +│ [ Esplora stanze popolari → ] │ +├─────────────────────────────────────┤ +│ [+ Crea stanza] [Da qualche parte] │ +└─────────────────────────────────────┘ +``` + +**Card list with row-level hover-reveal:** + +``` +▼ Stanze più popolari [▦ ☰] [⚡] +┌─────────┐ Big Party Room +│ 🏠 🎵 │ 👤 22 · 🔓 Aperta · ★ 4.7 +│ (img) │ by @Cocco +└─────────┘ [Entra] [ⓘ] [☆ favori] ← shown on row hover +───────────────────────────────────────── +▼ Party [▦ ☰] [⚡] +🟢 fcfcvcvcv 👤2 🔓 [ⓘ] +🔒 aaaaa 👤1 🚪 [ⓘ] +``` + +**Saved searches as horizontal chip row** above the filter input +(replaces the 600px sidebar — no layout shift on toggle). + +**Filter intent as visible chips** instead of "Qualsiasi" dropdown: +`🔓 Aperte` `🚪 Campanello` `🔒 Con password` `👥 Solo amici`. + +**Sticky section headers** when scrolling long lists. + +**Skeleton loaders** during fetch (post-P2 when query state lands). + +**Per-card actions on hover**: favourite ☆, info ⓘ, room link 🔗. + +### 6.3 Why P1 architecture supports this + +- `useNavigatorUiStore` makes future flags (`viewMode: 'compact' | 'expanded'`, + `lastTab`, `lastScrollTop`) trivial to add — they're new state on + the store; persistence can be added with a Zustand `persist` + middleware on a single line. +- Splitting `useDoorState` out means the visual rework of the door + prompt (a separate panel, possibly modal) can evolve independently + of Navigator search UI. +- Three flat filters mean a new card variant (compact-vs-expanded + list) reads `useNavigatorData` only — no risk of re-rendering the + whole Navigator when card-mode toggles. + +## 7. Testing strategy + +Coherent with `CLAUDE.md` "`yarn test` must stay green on every +commit": + +| Suite | New / changed | Cases (target) | +|---|---|---| +| `navigatorUiStore.test.ts` | NEW | ~30: each action idempotent on no-op, transitions valid, `requestSearch`/`consumeSearchRequest` symmetric | +| `useDoorState.test.tsx` | NEW | ~12: each event listener happy path + filter-by-userName + filter-by-errorCode + reset() | +| `useNavigatorStore.test.tsx` | NEW (smoke) | ~5: 3 filters return expected shape, dispatch updates propagate to `useNavigatorData`, GenericError 4010 does NOT touch door state, GenericError -100002 DOES touch door state | +| Existing Vitest suites | Stay green | — | + +All tests co-located under `src/`, alongside their subject. Reuse +`src/nitro-renderer.mock.ts` for event dispatching (the +`mockEventDispatcher` / `clearMockEventDispatcher` helpers). + +CI gates that must stay green: `yarn typecheck` (TS 7 native), +`yarn test`, `yarn lint:hooks` (`react-hooks/rules-of-hooks: error`). + +## 8. Compatibility with project conventions + +`feat/navigator-modernization` is forked from `origin/Dev` @ `d5d5ca59`, +so it carries everything upstream has shipped through the floorplan +editor work + classic catalog view + emustats + housekeeping panel. +The design respects every constraint of this base: + +- **No new dependencies.** Uses `zustand` (present), `use-between` + (present), `vitest` (present), `createNitroStore` (present at + `src/state/createNitroStore.ts`). +- **React 19 idioms** identical to the rest of the codebase. No + manual `useMemo`/`useCallback` unless the React Compiler asks for + them. +- **TypeScript strict** consistent with the rest of the project. +- **Co-located tests** under `src/` per the layout convention. +- **No conflicts with adopted patterns**: `useNitroEvent`, + `useMessageEvent`, `useBetween`, `createNitroStore`. The new + filters expose plain data — they don't call snapshot hooks + (`useSyncExternalStore`) inside `useBetween` scopes, so the + documented "snapshot-outside-useBetween" constraint never + triggers here. +- **Commit author** per house rules: `simoleo89 + ` via per-command `-c` + overrides. **No Co-Authored-By trailer.** +- **Branch policy**: fresh branch off `origin/Dev`, pushable + fast-forward to `simoleo/feat/navigator-modernization` (which + doesn't yet exist on the fork — first push creates it). No + force-push required. + +## 9. Out of scope (explicit) + +- TanStack Query migration of search (P2). +- Reactive favourite icons via snapshot (P3). +- Live user counts via snapshot (P3). +- Virtualization of result list (P4). +- Empty-state component (P4). +- Saved-search chip row (P4). +- Persistence of tab/scroll/filter (P4). +- `useActionState` on search input (P6). +- `WidgetErrorBoundary` wrapping of Navigator sub-views (P5 — + independent, can land in parallel). +- Any visual change. P1 ships byte-identical UI. +- Any change to `NavigatorRoomSettings*` subtree (self-contained, + only reads `categories` in one tab). + +## 10. Acceptance criteria + +P1 is complete when: + +1. `src/hooks/navigator/useNavigator.ts` does NOT exist (god-hook + removed). +2. `src/hooks/navigator/` contains `useNavigatorStore.ts`, + `useNavigatorData.ts`, `useNavigatorUiState.ts`, + `useNavigatorActions.ts`, `navigatorUiStore.ts`, and an updated + `index.ts`. +3. `src/hooks/rooms/widgets/useDoorState.ts` exists. +4. All 13 active consumers compile after their import swap. +5. `yarn typecheck` clean. +6. `yarn lint:hooks` clean. +7. `yarn test --run` green, with at least 3 new suites + (`navigatorUiStore`, `useDoorState`, `useNavigatorStore` smoke). +8. Manual smoke test: open Navigator, switch each top-level tab, run + a search, open a room with a doorbell, get rejected, open a room + with a password, enter the right password, enter wrong password, + open a room you own, click a favourite ☆, open RoomInfo, open + RoomLink. Each path renders identically to pre-P1 behaviour. +9. Branch `feat/navigator-modernization` pushed (fast-forward only) + to `simoleo/feat/navigator-modernization` on the user's fork. + +## 11. Risk register + +| Risk | Likelihood | Mitigation | +|---|---|---| +| A consumer reads a field we forgot to expose on a filter | medium | Type-checker catches it — all 13 consumers re-typecheck on swap | +| Dual-subscription on `GetGuestRoomResultEvent` causes double `CreateRoomSession` | low | `useDoorStateStore` only acts on doorMode bell/password; `useNavigatorStore` only acts on the other branches. Explicit `if` guards on both sides | +| `linkTracker` re-registration leaks because deps changed | low | New tracker reads `useNavigatorUiStore.getState()` instead of closure-captured state, so its `useEffect` deps shrink | +| `useDoorState` consumer in `NavigatorDoorStateView` regresses on `reset()` semantics | low | Smoke test in §10 covers this | +| Per-key Zustand selectors in `useNavigatorUiState` cause stale-closure issues | low | Each selector is one-shot, no derived values; identical pattern to existing Zustand stores in the codebase | +| Renderer SDK mismatch on local dev (e.g. floorplan-live-preview not in renderer's main) | medium | Already exists today regardless of this PR; surface in plan as a `yarn typecheck` caveat, not introduced by P1 | From cee3a2a45717e85dcb72ed88e7e3d47e81cb801f Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 20:38:15 +0200 Subject: [PATCH 03/86] docs(navigator): P1 implementation plan (9 tasks, TDD where applicable) Bite-sized tasks with exact code blocks: - Task 1: navigatorUiStore (TDD, 14 cases) - Task 2: useDoorState extraction (TDD, 11 cases incl. dual-subscription filters) - Task 3: useNavigatorStore internal closure (move all non-door listeners + new actions) - Task 4: 3 filters + barrel rewrite + smoke test - Tasks 5-8: 13 consumer migrations (atomic commit) - Task 9: delete useNavigator.ts + final verification (typecheck/test/lint/manual) Each commit is a green stopping point except Task 4 step 8 (intentional intermediate-broken commit while consumers still import the removed useNavigator export from the barrel). Tasks 5-8 land atomically to close that gap in the next commit. --- .../2026-05-26-navigator-modernization-p1.md | 1715 +++++++++++++++++ 1 file changed, 1715 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md diff --git a/docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md b/docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md new file mode 100644 index 0000000..1665f43 --- /dev/null +++ b/docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md @@ -0,0 +1,1715 @@ +# Navigator Modernization P1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Split the 492-line `useNavigator` god-hook into a `wired-tools`-style store + three filters, extract door lifecycle to `useDoorState`, hoist NavigatorView's 9 local useState into a Zustand `navigatorUiStore`, migrate all 13 consumers, delete the shim — zero user-visible change. + +**Architecture:** `src/hooks/navigator/useNavigatorStore.ts` is the internal `useBetween` closure holding data state + non-door event listeners + the `sendSearch`/`reloadCurrentSearch` actions. Three filter files (`useNavigatorData.ts`, `useNavigatorUiState.ts`, `useNavigatorActions.ts`) expose flat slices. `navigatorUiStore.ts` is a Zustand store for 9 panel-visibility/lifecycle flags. `useDoorState.ts` (in `src/hooks/rooms/widgets/`) is a separate `useBetween` closure for door bell/password lifecycle — dual-subscribed to `GetGuestRoomResultEvent` and `GenericErrorEvent` alongside the navigator store, each filtering by branch / error code. + +**Tech Stack:** React 19.2, TypeScript (TS 7 native preview for typecheck), Zustand 5 via `createNitroStore`, `use-between` 1.x, Vitest 3 with co-located suites + `src/nitro-renderer.mock.ts`. + +**Branch:** `feat/navigator-modernization` (already created at `66062c6`, forked from `origin/Dev` @ `d5d5ca59`). All commits stay on this branch; auto-push to `simoleo/feat/navigator-modernization` FF-only. + +**House rules (apply to every commit):** +- Commit author: `simoleo89 ` via per-command `-c` overrides — do NOT modify global git config. +- **No `Co-Authored-By` trailer.** +- Each commit must be a stopping point: `yarn typecheck` clean, `yarn test --run` green, `yarn lint:hooks` clean. + +--- + +## Task 1: Zustand `navigatorUiStore` (TDD) + +**Files:** +- Create: `src/hooks/navigator/navigatorUiStore.ts` +- Test: `src/hooks/navigator/navigatorUiStore.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/hooks/navigator/navigatorUiStore.test.ts`: + +```ts +import { beforeEach, describe, expect, it } from 'vitest'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +const INITIAL = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false +}; + +describe('useNavigatorUiStore', () => +{ + beforeEach(() => + { + useNavigatorUiStore.setState(INITIAL); + }); + + it('exposes the documented defaults', () => + { + const s = useNavigatorUiStore.getState(); + expect(s.isVisible).toBe(false); + expect(s.isReady).toBe(false); + expect(s.isCreatorOpen).toBe(false); + expect(s.isRoomInfoOpen).toBe(false); + expect(s.isRoomLinkOpen).toBe(false); + expect(s.isOpenSavesSearches).toBe(false); + expect(s.isLoading).toBe(false); + expect(s.needsInit).toBe(true); + expect(s.needsSearch).toBe(false); + }); + + describe('show / hide / toggle', () => + { + it('show() sets isVisible true and requests a search', () => + { + useNavigatorUiStore.getState().show(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + }); + + it('hide() sets isVisible false without touching needsSearch', () => + { + useNavigatorUiStore.setState({ isVisible: true, needsSearch: false }); + useNavigatorUiStore.getState().hide(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + + it('toggle() flips visibility and requests a search on show', () => + { + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + + useNavigatorUiStore.setState({ needsSearch: false }); + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); + + describe('creator panel', () => + { + it('openCreator() opens both visible and creator', () => + { + useNavigatorUiStore.getState().openCreator(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(true); + }); + + it('closeCreator() closes only the creator panel', () => + { + useNavigatorUiStore.setState({ isVisible: true, isCreatorOpen: true }); + useNavigatorUiStore.getState().closeCreator(); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(false); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + }); + }); + + describe('roomInfo / roomLink / savesSearches', () => + { + it('setRoomInfoOpen(true) and toggleRoomInfo flip the flag', () => + { + useNavigatorUiStore.getState().setRoomInfoOpen(true); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomInfo(); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(false); + }); + + it('setRoomLinkOpen(true) and toggleRoomLink flip the flag', () => + { + useNavigatorUiStore.getState().setRoomLinkOpen(true); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomLink(); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(false); + }); + + it('toggleSavesSearches() flips the sidebar flag', () => + { + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(true); + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(false); + }); + }); + + describe('lifecycle flags', () => + { + it('setLoading(true) and setLoading(false) toggle isLoading', () => + { + useNavigatorUiStore.getState().setLoading(true); + expect(useNavigatorUiStore.getState().isLoading).toBe(true); + useNavigatorUiStore.getState().setLoading(false); + expect(useNavigatorUiStore.getState().isLoading).toBe(false); + }); + + it('markReady() sets isReady true and is idempotent', () => + { + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + }); + + it('markInitDone() flips needsInit to false', () => + { + useNavigatorUiStore.getState().markInitDone(); + expect(useNavigatorUiStore.getState().needsInit).toBe(false); + }); + + it('requestSearch() + consumeSearchRequest() are symmetric', () => + { + useNavigatorUiStore.getState().requestSearch(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + useNavigatorUiStore.getState().consumeSearchRequest(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/navigator/navigatorUiStore.test.ts +``` + +Expected: FAIL — `Cannot find module './navigatorUiStore'`. + +- [ ] **Step 3: Implement the store** + +Create `src/hooks/navigator/navigatorUiStore.ts`: + +```ts +import { createNitroStore } from '../../state/createNitroStore'; + +export type NavigatorUiState = { + isVisible: boolean; + isReady: boolean; + isCreatorOpen: boolean; + isRoomInfoOpen: boolean; + isRoomLinkOpen: boolean; + isOpenSavesSearches: boolean; + isLoading: boolean; + needsInit: boolean; + needsSearch: boolean; +}; + +export type NavigatorUiActions = { + show(): void; + hide(): void; + toggle(): void; + openCreator(): void; + closeCreator(): void; + setRoomInfoOpen(open: boolean): void; + toggleRoomInfo(): void; + setRoomLinkOpen(open: boolean): void; + toggleRoomLink(): void; + toggleSavesSearches(): void; + setLoading(loading: boolean): void; + markReady(): void; + markInitDone(): void; + requestSearch(): void; + consumeSearchRequest(): void; +}; + +export const useNavigatorUiStore = createNitroStore()((set) => ({ + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, + + show: () => set({ isVisible: true, needsSearch: true }), + hide: () => set({ isVisible: false }), + toggle: () => set((s) => s.isVisible + ? { isVisible: false } + : { isVisible: true, needsSearch: true }), + openCreator: () => set({ isVisible: true, isCreatorOpen: true }), + closeCreator: () => set({ isCreatorOpen: false }), + setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }), + toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })), + setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }), + toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })), + toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })), + setLoading: (loading) => set({ isLoading: loading }), + markReady: () => set({ isReady: true }), + markInitDone: () => set({ needsInit: false }), + requestSearch: () => set({ needsSearch: true }), + consumeSearchRequest: () => set({ needsSearch: false }) +})); +``` + +- [ ] **Step 4: Run the test to verify it passes** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/navigator/navigatorUiStore.test.ts +``` + +Expected: PASS (all ~14 cases green). + +- [ ] **Step 5: Commit** + +```powershell +cd Nitro-V3 +git add src/hooks/navigator/navigatorUiStore.ts src/hooks/navigator/navigatorUiStore.test.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(navigator): Zustand UI store for panel-visibility + lifecycle flags + +Hoists the 9 useState in NavigatorView (isVisible, isReady, isCreatorOpen, +isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, +needsSearch) into a createNitroStore-backed Zustand store with named +actions. Future linkTracker / lifecycle wiring will call these actions +instead of mutating local component state. + +TDD: ~14 cases on each action's transitions + idempotency." +git push simoleo feat/navigator-modernization +``` + +--- + +## Task 2: Extract `useDoorState` (TDD) + +**Files:** +- Create: `src/hooks/rooms/widgets/useDoorState.ts` +- Test: `src/hooks/rooms/widgets/useDoorState.test.tsx` + +- [ ] **Step 1: Write the failing test** + +Create `src/hooks/rooms/widgets/useDoorState.test.tsx`: + +```tsx +import { act, renderHook } from '@testing-library/react'; +import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, + GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser, + RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { DoorStateType } from '../../../api'; +import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock'; +import { useDoorState } from './useDoorState'; + +const makeParserlessEvent = (klass: any, parser: any) => +{ + const ev = new klass(); + (ev as any).getParser = () => parser; + return ev; +}; + +describe('useDoorState', () => +{ + beforeEach(() => + { + clearMockEventDispatcher(); + }); + + it('exposes the initial NONE snapshot', () => + { + const { result } = renderHook(() => useDoorState()); + expect(result.current.snapshot.state).toBe(DoorStateType.NONE); + expect(result.current.snapshot.roomInfo).toBeNull(); + }); + + it('DoorbellMessageEvent with empty userName -> STATE_WAITING', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING); + }); + + it('DoorbellMessageEvent with non-empty userName does NOT change state', () => + { + const { result } = renderHook(() => useDoorState()); + const before = result.current.snapshot.state; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: 'someone' })); + }); + expect(result.current.snapshot.state).toBe(before); + }); + + it('RoomDoorbellAcceptedEvent (empty userName) -> STATE_ACCEPTED', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(RoomDoorbellAcceptedEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_ACCEPTED); + }); + + it('FlatAccessDeniedMessageEvent (empty userName) -> STATE_NO_ANSWER', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(FlatAccessDeniedMessageEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_NO_ANSWER); + }); + + it('GenericErrorEvent -100002 -> STATE_WRONG_PASSWORD', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: -100002 })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WRONG_PASSWORD); + }); + + it('GenericErrorEvent 4010 does NOT touch door state', () => + { + const { result } = renderHook(() => useDoorState()); + const before = result.current.snapshot.state; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: 4010 })); + }); + expect(result.current.snapshot.state).toBe(before); + }); + + it('GetGuestRoomResultEvent with roomForward + DOORBELL_STATE -> START_DOORBELL', () => + { + const { result } = renderHook(() => useDoorState()); + const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.DOORBELL_STATE }; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomForward: true, + isGroupMember: false, + data: fakeRoomData + })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.START_DOORBELL); + expect(result.current.snapshot.roomInfo).toBe(fakeRoomData); + }); + + it('GetGuestRoomResultEvent with roomForward + PASSWORD_STATE -> START_PASSWORD', () => + { + const { result } = renderHook(() => useDoorState()); + const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.PASSWORD_STATE }; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomForward: true, + isGroupMember: false, + data: fakeRoomData + })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.START_PASSWORD); + }); + + it('GetGuestRoomResultEvent for owner does NOT dispatch a door dialog', () => + { + const { result } = renderHook(() => useDoorState()); + const before = result.current.snapshot.state; + // Mock GetSessionDataManager().userName to be the owner name. + // The hook reads owner name dynamically — see useDoorState impl. + // For this test we make doorMode something other than bell/password. + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomForward: true, + isGroupMember: false, + data: { ownerName: 'me', doorMode: 99 } + })); + }); + expect(result.current.snapshot.state).toBe(before); + }); + + it('reset() returns snapshot to NONE', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING); + act(() => result.current.reset()); + expect(result.current.snapshot.state).toBe(DoorStateType.NONE); + expect(result.current.snapshot.roomInfo).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/rooms/widgets/useDoorState.test.tsx +``` + +Expected: FAIL — `Cannot find module './useDoorState'`. + +- [ ] **Step 3: Implement `useDoorState`** + +Create `src/hooks/rooms/widgets/useDoorState.ts`: + +```ts +import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, + GenericErrorEvent, GetGuestRoomResultEvent, + GetSessionDataManager, RoomDataParser, + RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { useBetween } from 'use-between'; +import { DoorStateType } from '../../../api'; +import { useMessageEvent } from '../../events'; + +export type DoorStateSnapshot = { + roomInfo: RoomDataParser | null; + state: number; +}; + +const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE }; + +const useDoorStateStore = () => +{ + const [ snapshot, setSnapshot ] = useState(INITIAL); + + useMessageEvent(DoorbellMessageEvent, event => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING })); + }); + + useMessageEvent(RoomDoorbellAcceptedEvent, event => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED })); + }); + + useMessageEvent(FlatAccessDeniedMessageEvent, event => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER })); + }); + + useMessageEvent(GenericErrorEvent, event => + { + const parser = event.getParser(); + if(parser.errorCode !== -100002) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD })); + }); + + useMessageEvent(GetGuestRoomResultEvent, event => + { + const parser = event.getParser(); + if(!parser.roomForward) return; + if(parser.data.ownerName === GetSessionDataManager().userName) return; + if(parser.isGroupMember) return; + if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) + { + setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL }); + return; + } + if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) + { + setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD }); + } + }); + + const reset = useCallback(() => setSnapshot(INITIAL), []); + + return { snapshot, setSnapshot, reset }; +}; + +export const useDoorState = () => useBetween(useDoorStateStore); +``` + +- [ ] **Step 4: Verify the renderer mock exposes the events used in tests** + +```powershell +cd Nitro-V3 ; grep -E "DoorbellMessageEvent|RoomDoorbellAcceptedEvent|FlatAccessDeniedMessageEvent|GenericErrorEvent|GetGuestRoomResultEvent|RoomDataParser" src/nitro-renderer.mock.ts +``` + +Expected: all six symbols present. If any are missing, ADD a minimal stub to `src/nitro-renderer.mock.ts` (real class with a no-arg constructor; `getParser` will be overridden in tests). Use the existing pattern — e.g. find `RoomSessionDoorbellEvent` and follow its shape. + +- [ ] **Step 5: Run the test to verify it passes** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/rooms/widgets/useDoorState.test.tsx +``` + +Expected: PASS (11 cases). + +- [ ] **Step 6: Commit** + +```powershell +cd Nitro-V3 +git add src/hooks/rooms/widgets/useDoorState.ts src/hooks/rooms/widgets/useDoorState.test.tsx src/nitro-renderer.mock.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(rooms): extract useDoorState from useNavigator god-hook + +Separates the door bell/password lifecycle from Navigator data. Subscribes +to DoorbellMessageEvent / RoomDoorbellAcceptedEvent / +FlatAccessDeniedMessageEvent / GenericErrorEvent (-100002 only) / +GetGuestRoomResultEvent (roomForward branch with DOORBELL_STATE or +PASSWORD_STATE doorMode only). Other branches/errorCodes stay on +useNavigator — both subscribers coexist via useMessageEvent + filtering. + +TDD: 11 cases incl. userName-empty filter + errorCode -100002 filter + +owner-skip + reset()." +git push simoleo feat/navigator-modernization +``` + +--- + +## Task 3: Internal `useNavigatorStore` (closure with data + non-door listeners + new actions) + +**Files:** +- Create: `src/hooks/navigator/useNavigatorStore.ts` + +- [ ] **Step 1: Read current `useNavigator.ts` in full** + +```powershell +cd Nitro-V3 ; cat src/hooks/navigator/useNavigator.ts | head -100 +``` + +You will translate this file's `useNavigatorState` function into the new `useNavigatorStore.ts`, with these surgical changes: + +1. **Remove** `doorData` state and its dual writers (lines that called `setDoorData`). +2. **Remove** the door-only branches of `GetGuestRoomResultEvent` (doorMode bell/password) — these are now in `useDoorState`. KEEP the `roomEnter` branch and the `roomForward` branch that calls `CreateRoomSession(parser.data.roomId)`. +3. **Remove** the `GenericErrorEvent` case for errorCode `-100002` — now in `useDoorState`. KEEP cases 4009/4010/4011/4013. +4. **Keep** all other listeners untouched. +5. **Add** two new actions extracted from `NavigatorView.tsx` locals (currently at `NavigatorView.tsx:42-79`): `sendSearch(searchValue, contextCode)` and `reloadCurrentSearch()`. +6. The store function is NAMED `useNavigatorStore` (not `useNavigatorState`) and is NOT wrapped in `useBetween` here — the wrapping happens in the three filter files. + +- [ ] **Step 2: Create the new file** + +Create `src/hooks/navigator/useNavigatorStore.ts`: + +```ts +import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, + FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent, + FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, + GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, + GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, + HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, + NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, + NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, + NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, + NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, + RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, + UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { CreateRoomSession, GetConfigurationValue, INavigatorData, + LocalizeText, NotificationAlertType, SendMessageComposer, + TryVisitRoom, VisitDesktop } from '../../api'; +import { useMessageEvent, useNitroEvent } from '../events'; +import { useNotification } from '../notification'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorStore = () => +{ + const [ categories, setCategories ] = useState(null); + const [ eventCategories, setEventCategories ] = useState(null); + const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); + const [ topLevelContext, setTopLevelContext ] = useState(null); + const [ topLevelContexts, setTopLevelContexts ] = useState(null); + const [ searchResult, setSearchResult ] = useState(null); + const [ navigatorSearches, setNavigatorSearches ] = useState(null); + const [ navigatorData, setNavigatorData ] = useState({ + settingsReceived: false, + homeRoomId: 0, + enteredGuestRoom: null, + currentRoomOwner: false, + currentRoomId: 0, + currentRoomIsStaffPick: false, + createdFlatId: 0, + avatarId: 0, + roomPicker: false, + eventMod: false, + currentRoomRating: 0, + canRate: true + }); + + const { simpleAlert = null } = useNotification(); + + const sendSearch = useCallback((searchValue: string, contextCode: string) => + { + useNavigatorUiStore.getState().closeCreator(); + SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); + useNavigatorUiStore.getState().setLoading(true); + }, []); + + const reloadCurrentSearch = useCallback(() => + { + if(!useNavigatorUiStore.getState().isReady) + { + useNavigatorUiStore.getState().requestSearch(); + return; + } + if(searchResult) + { + sendSearch(searchResult.data, searchResult.code); + return; + } + if(!topLevelContext) return; + sendSearch('', topLevelContext.code); + }, [ searchResult, topLevelContext, sendSearch ]); + + useMessageEvent(FavouritesEvent, event => + { + const parser = event.getParser(); + const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); + setFavouriteRoomIds(favoriteIds); + }); + + useMessageEvent(FavouriteChangedEvent, event => + { + const parser = event.getParser(); + const roomId = Number(parser.flatId); + const added = !!parser.added; + setFavouriteRoomIds(prev => + { + const ids = (prev || []).map((x: any) => Number(x)); + if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ]; + return ids.filter(id => id !== roomId); + }); + }); + + useMessageEvent(RoomSettingsUpdatedEvent, event => + { + const parser = event.getParser(); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); + }); + + useMessageEvent(CanCreateRoomEventEvent, event => + { + const parser = event.getParser(); + if(parser.canCreate) return; + simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title')); + }); + + useMessageEvent(UserInfoEvent, event => + { + SendMessageComposer(new GetUserFlatCatsMessageComposer()); + SendMessageComposer(new GetUserEventCatsMessageComposer()); + }); + + useMessageEvent(UserPermissionsEvent, event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + eventMod: parser.securityLevel >= SecurityLevel.MODERATOR, + roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY + })); + }); + + useMessageEvent(RoomForwardEvent, event => + { + const parser = event.getParser(); + TryVisitRoom(parser.roomId); + }); + + useMessageEvent(RoomEntryInfoMessageEvent, event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: null, + currentRoomOwner: parser.isOwner, + currentRoomId: parser.roomId + })); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false)); + if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]); + }); + + useMessageEvent(GetGuestRoomResultEvent, event => + { + const parser = event.getParser(); + if(parser.roomEnter) + { + setNavigatorData(prev => + { + const next = { ...prev }; + next.enteredGuestRoom = parser.data; + next.currentRoomIsStaffPick = parser.staffPick; + const isCreated = next.createdFlatId === parser.data.roomId; + if(!isCreated && parser.data.displayRoomEntryAd) + { + if(GetConfigurationValue('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd(); + } + next.createdFlatId = 0; + return next; + }); + return; + } + if(parser.roomForward) + { + // Door-mode branches handled in useDoorState — skip here. + const isOwner = parser.data.ownerName === GetSessionDataManager().userName; + if(!isOwner && !parser.isGroupMember) + { + if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) return; + if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) return; + } + if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return; + CreateRoomSession(parser.data.roomId); + return; + } + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: parser.data, + currentRoomIsStaffPick: parser.staffPick + })); + }); + + useMessageEvent(RoomScoreEvent, event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + currentRoomRating: parser.totalLikes, + canRate: parser.canLike + })); + }); + + useMessageEvent(GenericErrorEvent, event => + { + const parser = event.getParser(); + // -100002 (wrong password) handled in useDoorState — skip here. + switch(parser.errorCode) + { + case 4009: + simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4010: + simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4011: + simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4013: + simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + } + }); + + useMessageEvent(NavigatorMetadataEvent, event => + { + const parser = event.getParser(); + setTopLevelContexts(parser.topLevelContexts); + setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); + }); + + useMessageEvent(NavigatorSearchEvent, event => + { + const parser = event.getParser(); + setTopLevelContext(prev => + { + let next = prev; + if(!next) next = (topLevelContexts && topLevelContexts.length && topLevelContexts[0]) || null; + if(!next) return null; + if(topLevelContexts && topLevelContexts.length) + { + for(const ctx of topLevelContexts) + { + if(ctx.code === parser.result.code) next = ctx; + } + } + return next; + }); + setSearchResult(parser.result); + useNavigatorUiStore.getState().setLoading(false); + }); + + useMessageEvent(UserFlatCatsEvent, event => + { + const parser = event.getParser(); + setCategories(parser.categories); + }); + + useMessageEvent(UserEventCatsEvent, event => + { + const parser = event.getParser(); + setEventCategories(parser.categories); + }); + + useMessageEvent(FlatCreatedEvent, event => + { + const parser = event.getParser(); + CreateRoomSession(parser.roomId); + }); + + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => + { + setNavigatorData(prev => ({ ...prev, settingsReceived: false })); + }); + + useMessageEvent(NavigatorHomeRoomEvent, event => + { + const parser = event.getParser(); + let prevSettingsReceived = false; + setNavigatorData(prev => + { + prevSettingsReceived = prev.settingsReceived; + return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true }; + }); + if(prevSettingsReceived) return; + if(GetRoomSessionManager().viewerSession) return; + + let forwardType = -1; + let forwardId = -1; + if((GetConfigurationValue('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) + { + forwardType = 0; + SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); + } + if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) + { + forwardType = parseInt(GetConfigurationValue('forward.type')); + forwardId = parseInt(GetConfigurationValue('forward.id')); + } + if(forwardType === 2) + { + TryVisitRoom(forwardId); + } + else if((forwardType === -1) && (parser.roomIdToEnter > 0)) + { + CreateLinkEvent('navigator/close'); + CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId); + } + }); + + useMessageEvent(RoomEnterErrorEvent, event => + { + const parser = event.getParser(); + switch(parser.reason) + { + case CantConnectMessageParser.REASON_FULL: + simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title')); + break; + case CantConnectMessageParser.REASON_QUEUE_ERROR: + simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + case CantConnectMessageParser.REASON_BANNED: + simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title')); + break; + default: + simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + } + if(GetRoomSessionManager().isReconnecting) return; + VisitDesktop(); + }); + + useMessageEvent(NavigatorOpenRoomCreatorEvent, event => CreateLinkEvent('navigator/show')); + + useMessageEvent(NavigatorSearchesEvent, event => + { + const parser = event.getParser(); + if(!parser) return; + setNavigatorSearches(parser.searches); + }); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + sendSearch, reloadCurrentSearch + }; +}; +``` + +- [ ] **Step 3: Run typecheck to verify the file compiles** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -10 +``` + +Expected: no NEW errors in `src/hooks/navigator/useNavigatorStore.ts`. Pre-existing floorplan-related typecheck errors (`applyFloorModelLocally`, JSX namespace) are environmental, not caused by P1 — see spec §11. + +- [ ] **Step 4: Do NOT commit yet** + +The three filter files in Task 4 will land in the same commit as this file — atomically, so the codebase always has working hook exports. + +--- + +## Task 4: Three filter files + updated barrel + smoke test + +**Files:** +- Create: `src/hooks/navigator/useNavigatorData.ts` +- Create: `src/hooks/navigator/useNavigatorUiState.ts` +- Create: `src/hooks/navigator/useNavigatorActions.ts` +- Modify: `src/hooks/navigator/index.ts` +- Create: `src/hooks/navigator/useNavigatorStore.test.tsx` + +- [ ] **Step 1: Create `useNavigatorData.ts`** + +```ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => +{ + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + } = useBetween(useNavigatorStore); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + }; +}; +``` + +- [ ] **Step 2: Create `useNavigatorUiState.ts`** + +```ts +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => +{ + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch + }; +}; +``` + +- [ ] **Step 3: Create `useNavigatorActions.ts`** + +```ts +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => +{ + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; +``` + +- [ ] **Step 4: Rewrite the barrel `index.ts`** + +```ts +export { useNavigatorActions } from './useNavigatorActions'; +export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorUiState } from './useNavigatorUiState'; +export { useNavigatorUiStore } from './navigatorUiStore'; +export { useDoorState } from '../rooms/widgets/useDoorState'; +export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState'; +export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore'; +``` + +Notice: the old `export * from './useNavigator';` is GONE. `useNavigator` is no longer exported by the barrel — consumers must use the new filters. (The old file still exists on disk until Task 9.) + +- [ ] **Step 5: Add a smoke test** + +Create `src/hooks/navigator/useNavigatorStore.test.tsx`: + +```tsx +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; + +describe('navigator filter shapes (smoke)', () => +{ + it('useNavigatorData returns the documented keys', () => + { + const { result } = renderHook(() => useNavigatorData()); + expect(Object.keys(result.current).sort()).toEqual([ + 'categories', 'eventCategories', 'favouriteRoomIds', + 'navigatorData', 'navigatorSearches', + 'searchResult', 'topLevelContext', 'topLevelContexts' + ].sort()); + }); + + it('useNavigatorUiState returns the 9 documented flags', () => + { + const { result } = renderHook(() => useNavigatorUiState()); + expect(Object.keys(result.current).sort()).toEqual([ + 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', + 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', + 'needsInit', 'needsSearch' + ].sort()); + }); + + it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => + { + const { result } = renderHook(() => useNavigatorActions()); + expect(typeof result.current.sendSearch).toBe('function'); + expect(typeof result.current.reloadCurrentSearch).toBe('function'); + }); +}); +``` + +- [ ] **Step 6: Run typecheck — the project will fail because consumers still import `useNavigator`** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -20 +``` + +Expected: errors like `Module '"...hooks/navigator"' has no exported member 'useNavigator'` in the 13 consumer files. That's intentional — Tasks 6/7/8 fix them. The hook files themselves must typecheck clean. + +- [ ] **Step 7: Run the smoke test in isolation** + +```powershell +cd Nitro-V3 ; yarn test --run src/hooks/navigator/useNavigatorStore.test.tsx +``` + +Expected: PASS (3 cases). + +- [ ] **Step 8: Commit all new hook files together** + +```powershell +cd Nitro-V3 +git add src/hooks/navigator/useNavigatorStore.ts src/hooks/navigator/useNavigatorData.ts src/hooks/navigator/useNavigatorUiState.ts src/hooks/navigator/useNavigatorActions.ts src/hooks/navigator/index.ts src/hooks/navigator/useNavigatorStore.test.tsx +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "feat(navigator): wired-tools-style hook split (Store + 3 filters) + +Splits the 492-line useNavigator god-hook into a useBetween-backed +useNavigatorStore closure plus three flat-shape filters +(useNavigatorData, useNavigatorUiState, useNavigatorActions), mirroring +the wired-tools layout. sendSearch + reloadCurrentSearch are extracted +as named actions out of NavigatorView locals. + +Door-mode handling is removed from this store and lives in useDoorState +(committed previously) — see GetGuestRoomResultEvent and +GenericErrorEvent dual-subscription with mutually exclusive filters. + +The barrel index.ts no longer re-exports useNavigator. The 13 consumers +will fail typecheck until Tasks 6-8 migrate them; the hook files +themselves are clean. Smoke test covers filter shapes." +git push simoleo feat/navigator-modernization +``` + +Note: `yarn test --run` overall is RED at this commit (consumers can't typecheck) — that's why we commit AND PUSH but DO NOT verify whole-project test green here. The next tasks make it green. + +**Deviation from house rule**: this is the only intentionally-broken intermediate commit in the plan. Documented in spec §11. + +--- + +## Task 5: Migrate `NavigatorDoorStateView.tsx` + +**Files:** +- Modify: `src/components/navigator/views/NavigatorDoorStateView.tsx` + +- [ ] **Step 1: Apply the consumer rewrite** + +Replace the file content with: + +```tsx +import { FC, useEffect, useState } from 'react'; +import { CreateRoomSession, DoorStateType, GoToDesktop, LocalizeText } from '../../../api'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common'; +import { useDoorState } from '../../../hooks'; +import { NitroInput } from '../../../layout'; + +const VISIBLE_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER, DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ]; +const DOORBELL_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER ]; + +export const NavigatorDoorStateView: FC<{}> = props => +{ + const [ password, setPassword ] = useState(''); + const { snapshot, setSnapshot, reset } = useDoorState(); + + const onClose = () => + { + if(snapshot.state === DoorStateType.STATE_WAITING) GoToDesktop(); + reset(); + }; + + const ring = () => + { + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); + }; + + const tryEntering = () => + { + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId, password); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); + }; + + useEffect(() => + { + if(snapshot.state !== DoorStateType.STATE_NO_ANSWER) return; + GoToDesktop(); + }, [ snapshot.state ]); + + if(snapshot.state === DoorStateType.NONE) return null; + if(VISIBLE_STATES.indexOf(snapshot.state) === -1) return null; + + const isDoorbell = DOORBELL_STATES.indexOf(snapshot.state) >= 0; + + return ( + + + +
+ { snapshot.roomInfo && snapshot.roomInfo.roomName } + { snapshot.state === DoorStateType.START_DOORBELL && + { LocalizeText('navigator.doorbell.info') } } + { snapshot.state === DoorStateType.STATE_WAITING && + { LocalizeText('navigator.doorbell.waiting') } } + { snapshot.state === DoorStateType.STATE_NO_ANSWER && + { LocalizeText('navigator.doorbell.no.answer') } } + { snapshot.state === DoorStateType.START_PASSWORD && + { LocalizeText('navigator.password.info') } } + { snapshot.state === DoorStateType.STATE_WRONG_PASSWORD && + { LocalizeText('navigator.password.retryinfo') } } +
+ { isDoorbell && +
+ { snapshot.state === DoorStateType.START_DOORBELL && + } + +
} + { !isDoorbell && + <> +
+ { LocalizeText('navigator.password.enter') } + setPassword(event.target.value) } /> +
+
+ + +
+ } +
+
+ ); +}; +``` + +Key changes: +- `useNavigator()` → `useDoorState()` +- `doorData` → `snapshot` +- `setDoorData(null)` → `reset()` +- `setDoorData(prev => ...)` → `setSnapshot(prev => ...)` +- Defensive `if(doorData && ...)` guards removed because `snapshot` is never null (always has a default `{ roomInfo: null, state: NONE }`) + +- [ ] **Step 2: Verify typecheck for this file is clean** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | grep NavigatorDoorStateView +``` + +Expected: no output (no errors mentioning this file). + +- [ ] **Step 3: Do NOT commit yet** — bundle with the rest of consumer migration in Task 8. + +--- + +## Task 6: Migrate `NavigatorView.tsx` (the big one) + +**Files:** +- Modify: `src/components/navigator/NavigatorView.tsx` + +- [ ] **Step 1: Read the current file in full** + +```powershell +cd Nitro-V3 ; cat src/components/navigator/NavigatorView.tsx +``` + +You will replace 9 local `useState`, the local `sendSearch`/`reloadCurrentSearch` definitions, and most of the `linkTracker` body with calls to `useNavigatorUiStore.getState()`. + +- [ ] **Step 2: Apply the rewrite** + +Replace the file contents with: + +```tsx +import { NitroCard } from '@layout/NitroCard'; +import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useRef } from 'react'; +import { FaPlus } from 'react-icons/fa'; +import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png'; +import createRoomImg from '../../assets/images/navigator/create_room.png'; +import randomRoomImg from '../../assets/images/navigator/random_room.png'; +import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; +import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; +import { Flex, Text } from '../../common'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; +import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; +import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; +import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; +import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView'; +import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView'; +import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView'; +import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView'; +import { NavigatorSearchView } from './views/search/NavigatorSearchView'; + +export const NavigatorView: FC<{}> = props => +{ + const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); + const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); + const pendingSearch = useRef<{ value: string, code: string }>(null); + const elementRef = useRef(null); + + useNitroEvent(RoomSessionEvent.CREATED, event => + { + useNavigatorUiStore.getState().hide(); + useNavigatorUiStore.getState().closeCreator(); + }); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + const store = useNavigatorUiStore.getState(); + switch(parts[1]) + { + case 'show': + store.show(); + return; + case 'hide': + store.hide(); + return; + case 'toggle': + store.toggle(); + return; + case 'toggle-room-info': + store.toggleRoomInfo(); + return; + case 'toggle-room-link': + store.toggleRoomLink(); + return; + case 'goto': + if(parts.length <= 2) return; + if(parts[2] === 'home') + { + if(navigatorData.homeRoomId <= 0) return; + TryVisitRoom(navigatorData.homeRoomId); + return; + } + TryVisitRoom(parseInt(parts[2])); + return; + case 'create': + store.openCreator(); + return; + case 'search': + if(parts.length <= 2) return; + pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] }; + store.show(); + return; + } + }, + eventUrlPrefix: 'navigator/' + }; + AddLinkEventTracker(linkTracker); + return () => RemoveLinkEventTracker(linkTracker); + }, [ navigatorData ]); + + useEffect(() => + { + if(!searchResult) return; + if(elementRef.current) elementRef.current.scrollTop = 0; + }, [ searchResult ]); + + useEffect(() => + { + if(!isVisible || !isReady || !needsSearch) return; + if(pendingSearch.current) + { + sendSearch(pendingSearch.current.value, pendingSearch.current.code); + pendingSearch.current = null; + } + else + { + reloadCurrentSearch(); + } + useNavigatorUiStore.getState().consumeSearchRequest(); + }, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]); + + useEffect(() => + { + if(isReady || !topLevelContext) return; + useNavigatorUiStore.getState().markReady(); + }, [ isReady, topLevelContext ]); + + useEffect(() => + { + if(!isVisible || !needsInit) return; + SendMessageComposer(new NavigatorInitComposer()); + useNavigatorUiStore.getState().markInitDone(); + }, [ isVisible, needsInit ]); + + useEffect(() => + { + LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k))); + }, []); + + if(!isVisible) return ( + <> + + { isRoomInfoOpen && useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> } + { isRoomLinkOpen && useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> } + + + ); + + return ( + <> + + useNavigatorUiStore.getState().hide() } /> + + useNavigatorUiStore.getState().toggleSavesSearches() }> + + + { topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) => + sendSearch('', context.code) }> + { LocalizeText('navigator.toplevelview.' + context.code) } + ) } + useNavigatorUiStore.getState().openCreator() }> + + + + + { !isCreatorOpen && +
+ { isOpenSavesSearches && +
+ +
} +
+ +
+ { searchResult && searchResult.results.map((result, index) => ) } + { searchResult && (!searchResult.results || searchResult.results.length === 0) && +
+ { LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') } +
} +
+ + useNavigatorUiStore.getState().openCreator() }> + + { LocalizeText('navigator.createroom.create') } + + + { searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view' && + SendMessageComposer(new FindNewFriendsMessageComposer()) }> + + { LocalizeText('navigator.random.room') } + + } + { (searchResult?.code === 'myworld_view' || searchResult?.code === 'roomads_view') && + CreateLinkEvent('catalog/open/room_event') }> + + { LocalizeText('navigator.promote.room') } + + } + +
+
} + { isCreatorOpen && } +
+
+ + { isRoomInfoOpen && useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> } + { isRoomLinkOpen && useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> } + + + ); +}; +``` + +Key changes: +- 9 `useState` → 3 filter hooks (`useNavigatorData`, `useNavigatorUiState`, `useNavigatorActions`) + direct `useNavigatorUiStore.getState()` calls in handlers +- `sendSearch` and `reloadCurrentSearch` removed from this file — they're in `useNavigatorStore` now +- `linkTracker` body becomes a clean dispatch table on `store.show()` / `store.hide()` / etc. +- `NavigatorSearchView` no longer receives `sendSearch` as a prop — Task 7 updates that consumer too + +- [ ] **Step 3: Verify typecheck** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | grep NavigatorView +``` + +Expected: no errors in `NavigatorView.tsx`. (Other consumer files still red — fixed in Tasks 7-8.) + +- [ ] **Step 4: Do NOT commit yet** — bundle with the rest in Task 8. + +--- + +## Task 7: Migrate `NavigatorSearchView.tsx` (drop the prop) + +**Files:** +- Modify: `src/components/navigator/views/search/NavigatorSearchView.tsx` + +- [ ] **Step 1: Read the current file** + +```powershell +cd Nitro-V3 ; cat src/components/navigator/views/search/NavigatorSearchView.tsx +``` + +- [ ] **Step 2: Apply the swap** + +Find and replace inside the file: + +| Before | After | +|---|---| +| `import { useNavigator } from '../../../../hooks';` | `import { useNavigatorActions, useNavigatorData } from '../../../../hooks';` | +| `const { topLevelContext = null } = useNavigator();` | `const { topLevelContext } = useNavigatorData();` | +| The `sendSearch` prop from the component's signature | DELETED | +| `sendSearch(value, code)` calls in handlers | replace with destructured local: `const { sendSearch } = useNavigatorActions();` and call `sendSearch(...)` | + +(Exact line-by-line edit — read the file then mechanically apply the table above. If the file uses `sendSearch` from props, the JSX type for the component changes too.) + +- [ ] **Step 3: Verify typecheck** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | grep NavigatorSearchView +``` + +Expected: no errors. + +- [ ] **Step 4: Do NOT commit yet** — bundle in Task 8. + +--- + +## Task 8: Migrate the remaining 10 bulk consumers + +**Files (10 modifications):** +- `src/components/navigator/views/NavigatorRoomCreatorView.tsx` +- `src/components/navigator/views/NavigatorRoomInfoView.tsx` +- `src/components/navigator/views/NavigatorRoomLinkView.tsx` +- `src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx` +- `src/components/navigator/views/search/NavigatorSearchResultItemView.tsx` +- `src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx` +- `src/components/navigator/views/search/NavigatorSearchResultView.tsx` +- `src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx` +- `src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx` +- `src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx` + +- [ ] **Step 1: For each of the 10 files above, apply this mechanical swap** + +| Before | After | +|---|---| +| `import { useNavigator } from '...../hooks';` | `import { useNavigatorData } from '...../hooks';` (keep the same relative path) | +| `const { X = ..., Y = ..., ... } = useNavigator();` | `const { X, Y, ... } = useNavigatorData();` (drop the `= null` / `= []` defaults — the new filter always returns the same shape) | + +**Spot-checks per file** (verify you've changed nothing else): + +- `NavigatorRoomCreatorView`: reads `categories` only +- `NavigatorRoomInfoView`: reads `navigatorData` and `favouriteRoomIds` +- `NavigatorRoomLinkView`: reads `navigatorData.enteredGuestRoom` +- `NavigatorRoomSettingsBasicTabView`: reads `categories` +- `NavigatorSearchResultItemView`: reads `favouriteRoomIds` and `navigatorData` +- `NavigatorSearchResultItemInfoView`: reads `navigatorData` +- `NavigatorSearchResultView`: reads `topLevelContext` +- `CatalogLayoutRoomAdsView`: reads `navigatorData.currentRoomId` +- `RoomFilterWordsWidgetView`: reads `navigatorData.currentRoomId` +- `RoomToolsWidgetView`: reads `navigatorData` + +- [ ] **Step 2: Run full typecheck** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -15 +``` + +Expected: no NEW errors. (Pre-existing floorplan errors `applyFloorModelLocally` / JSX namespace may still appear — they are NOT introduced by P1 and may be present on `origin/Dev` independently of this work.) + +- [ ] **Step 3: Run full test suite** + +```powershell +cd Nitro-V3 ; yarn test --run 2>&1 | tail -10 +``` + +Expected: all suites pass, including the 3 new ones from this PR. + +- [ ] **Step 4: Run lint:hooks** + +```powershell +cd Nitro-V3 ; yarn lint:hooks 2>&1 | tail -5 +``` + +Expected: clean. + +- [ ] **Step 5: Commit the full consumer-migration sweep (Tasks 5, 6, 7, 8 atomic)** + +```powershell +cd Nitro-V3 +git add src/components/navigator/views/NavigatorDoorStateView.tsx src/components/navigator/NavigatorView.tsx src/components/navigator/views/search/NavigatorSearchView.tsx src/components/navigator/views/NavigatorRoomCreatorView.tsx src/components/navigator/views/NavigatorRoomInfoView.tsx src/components/navigator/views/NavigatorRoomLinkView.tsx src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx src/components/navigator/views/search/NavigatorSearchResultItemView.tsx src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx src/components/navigator/views/search/NavigatorSearchResultView.tsx src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "refactor(navigator): migrate all 13 consumers off useNavigator god-hook + +Mechanical swap to the new filter hooks landed in the previous commit: +- NavigatorDoorStateView -> useDoorState (snapshot/setSnapshot/reset) +- NavigatorView -> useNavigatorData + useNavigatorUiState + + useNavigatorActions + direct useNavigatorUiStore.getState() in handlers + (linkTracker collapsed to a dispatch table; 9 useState gone) +- NavigatorSearchView -> useNavigatorData + useNavigatorActions + (sendSearch prop drilling removed) +- 10 bulk consumers (one-line import swap) -> useNavigatorData + +Zero behavioural change intended. yarn typecheck + yarn test --run + +yarn lint:hooks all clean on this commit." +git push simoleo feat/navigator-modernization +``` + +--- + +## Task 9: Delete the old `useNavigator.ts` + final verification + +**Files:** +- Delete: `src/hooks/navigator/useNavigator.ts` + +- [ ] **Step 1: Verify no references remain** + +```powershell +cd Nitro-V3 ; grep -rn "from.*hooks/navigator/useNavigator" src/ --include="*.ts" --include="*.tsx" +cd Nitro-V3 ; grep -rn "useNavigator\b" src/ --include="*.ts" --include="*.tsx" | findstr /V /C:"useNavigatorData" /C:"useNavigatorUiState" /C:"useNavigatorActions" /C:"useNavigatorStore" /C:"useNavigatorUiStore" +``` + +Expected: both commands return no results (or only the deletion target itself). + +- [ ] **Step 2: Delete the file** + +```powershell +cd Nitro-V3 ; git rm src/hooks/navigator/useNavigator.ts +``` + +- [ ] **Step 3: Run the gate trio** + +```powershell +cd Nitro-V3 ; yarn typecheck 2>&1 | tail -10 +cd Nitro-V3 ; yarn test --run 2>&1 | tail -10 +cd Nitro-V3 ; yarn lint:hooks 2>&1 | tail -5 +``` + +Expected: all clean. + +- [ ] **Step 4: Manual smoke (development build)** + +Start the dev server. Verify each path renders identically to pre-P1: + +```powershell +cd Nitro-V3 ; yarn start +``` + +Then in the browser: + +- [ ] Open Navigator via toolbar icon → opens at default tab +- [ ] Click each top-level tab (Pubbliche / Tutte le stanze / Eventi / Il mio mondo) → results load, loading spinner shows briefly +- [ ] Type into filter input → search returns +- [ ] Open a room with NO door (your own room or a public) → enters directly +- [ ] Open a room with DOORBELL → doorbell prompt appears, click Ring, then close +- [ ] Open a room with PASSWORD → password prompt appears, type wrong password → "wrong password" message, then close +- [ ] Click favourite ☆ on a search result → star fills/empties +- [ ] Open RoomInfo (`navigator/toggle-room-info` link or in-room button) → opens, close again +- [ ] Open RoomLink (`navigator/toggle-room-link`) → opens, close again +- [ ] Open Room Creator (the `+` tab) → renders, close +- [ ] Close Navigator → all sub-windows hide + +If anything regresses → STOP, do NOT commit, investigate. + +- [ ] **Step 5: Commit + push final** + +```powershell +cd Nitro-V3 +git add src/hooks/navigator/useNavigator.ts +git -c user.name=simoleo89 -c user.email=simoleo89@users.noreply.github.com commit -m "refactor(navigator): remove deprecated useNavigator god-hook + +P1 complete. All 13 consumers migrated to the wired-tools-style split: +- useNavigatorData / useNavigatorUiState / useNavigatorActions (filters) +- useNavigatorStore (internal useBetween closure) +- navigatorUiStore (Zustand for 9 UI flags) +- useDoorState (extracted to src/hooks/rooms/widgets) + +Closes spec docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md. +Next phases: P2 (TanStack Query for search), P3 (reactive favourites +via snapshot), P4 (visual rework + virtualization + persistence)." +git push simoleo feat/navigator-modernization +``` + +- [ ] **Step 6: Open PR (optional, but recommended)** + +```powershell +cd Nitro-V3 ; gh pr create --base Dev --head simoleo89:feat/navigator-modernization --title "feat(navigator): wired-tools-style hook split + Zustand UI store (P1)" --body "## Summary +- Splits the 492-line useNavigator god-hook into useNavigatorStore + useNavigatorData / useNavigatorUiState / useNavigatorActions filters (wired-tools layout) +- Extracts door bell/password lifecycle to src/hooks/rooms/widgets/useDoorState +- Hoists the 9 useState in NavigatorView into a Zustand navigatorUiStore via createNitroStore +- Migrates all 13 consumers off useNavigator +- Removes the deprecated useNavigator shim entirely +- Zero user-visible change — spec marks the visual rework as P4 (separate plan) + +Spec: docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md +Plan: docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md + +## Test plan +- [x] yarn typecheck clean +- [x] yarn test --run green (+3 new suites: navigatorUiStore, useDoorState, useNavigatorStore smoke) +- [x] yarn lint:hooks clean +- [x] Manual smoke (see plan §9 step 4 checklist)" +``` + +(If the `gh` PR fails on `--base Dev` mapping, use `Dev` exactly as written; the duckietm upstream uses capital-D `Dev`.) + +--- + +## Self-review against spec + +After completing all tasks, verify: + +- [x] **§3.1 useNavigatorStore** — Task 3 creates this file +- [x] **§3.2 useNavigatorData/UiState/Actions** — Task 4 creates these +- [x] **§3.3 navigatorUiStore** — Task 1 creates this +- [x] **§3.4 useDoorState** — Task 2 creates this +- [x] **§4 13 consumer migration map** — Tasks 5/6/7/8 cover all 13 +- [x] **§5.1-5.3 dual-subscription** — Task 2 and Task 3 implement the mutually exclusive filters +- [x] **§7 testing strategy** — Tasks 1/2/4 create the 3 new suites +- [x] **§10 acceptance criteria** — Task 9 verifies all 9 acceptance items +- [x] **§11 risk register** — the intentionally-broken intermediate commit at Task 4 step 8 is documented and bracketed by a green commit in Task 8 step 5 From 1868559d629e27d9b27bf674d4c044771949a01b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 20:43:01 +0200 Subject: [PATCH 04/86] feat(navigator): Zustand UI store for panel-visibility + lifecycle flags Hoists the 9 useState in NavigatorView (isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch) into a createNitroStore-backed Zustand store with named actions. Future linkTracker / lifecycle wiring will call these actions instead of mutating local component state. TDD: 14 cases on each action's transitions + idempotency. --- src/hooks/navigator/navigatorUiStore.test.ts | 144 +++++++++++++++++++ src/hooks/navigator/navigatorUiStore.ts | 61 ++++++++ 2 files changed, 205 insertions(+) create mode 100644 src/hooks/navigator/navigatorUiStore.test.ts create mode 100644 src/hooks/navigator/navigatorUiStore.ts diff --git a/src/hooks/navigator/navigatorUiStore.test.ts b/src/hooks/navigator/navigatorUiStore.test.ts new file mode 100644 index 0000000..ebc41fa --- /dev/null +++ b/src/hooks/navigator/navigatorUiStore.test.ts @@ -0,0 +1,144 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +const INITIAL = { + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false +}; + +describe('useNavigatorUiStore', () => +{ + beforeEach(() => + { + useNavigatorUiStore.setState(INITIAL); + }); + + it('exposes the documented defaults', () => + { + const s = useNavigatorUiStore.getState(); + expect(s.isVisible).toBe(false); + expect(s.isReady).toBe(false); + expect(s.isCreatorOpen).toBe(false); + expect(s.isRoomInfoOpen).toBe(false); + expect(s.isRoomLinkOpen).toBe(false); + expect(s.isOpenSavesSearches).toBe(false); + expect(s.isLoading).toBe(false); + expect(s.needsInit).toBe(true); + expect(s.needsSearch).toBe(false); + }); + + describe('show / hide / toggle', () => + { + it('show() sets isVisible true and requests a search', () => + { + useNavigatorUiStore.getState().show(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + }); + + it('hide() sets isVisible false without touching needsSearch', () => + { + useNavigatorUiStore.setState({ isVisible: true, needsSearch: false }); + useNavigatorUiStore.getState().hide(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + + it('toggle() flips visibility and requests a search on show', () => + { + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + + useNavigatorUiStore.setState({ needsSearch: false }); + useNavigatorUiStore.getState().toggle(); + expect(useNavigatorUiStore.getState().isVisible).toBe(false); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); + + describe('creator panel', () => + { + it('openCreator() opens both visible and creator', () => + { + useNavigatorUiStore.getState().openCreator(); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(true); + }); + + it('closeCreator() closes only the creator panel', () => + { + useNavigatorUiStore.setState({ isVisible: true, isCreatorOpen: true }); + useNavigatorUiStore.getState().closeCreator(); + expect(useNavigatorUiStore.getState().isCreatorOpen).toBe(false); + expect(useNavigatorUiStore.getState().isVisible).toBe(true); + }); + }); + + describe('roomInfo / roomLink / savesSearches', () => + { + it('setRoomInfoOpen(true) and toggleRoomInfo flip the flag', () => + { + useNavigatorUiStore.getState().setRoomInfoOpen(true); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomInfo(); + expect(useNavigatorUiStore.getState().isRoomInfoOpen).toBe(false); + }); + + it('setRoomLinkOpen(true) and toggleRoomLink flip the flag', () => + { + useNavigatorUiStore.getState().setRoomLinkOpen(true); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(true); + useNavigatorUiStore.getState().toggleRoomLink(); + expect(useNavigatorUiStore.getState().isRoomLinkOpen).toBe(false); + }); + + it('toggleSavesSearches() flips the sidebar flag', () => + { + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(true); + useNavigatorUiStore.getState().toggleSavesSearches(); + expect(useNavigatorUiStore.getState().isOpenSavesSearches).toBe(false); + }); + }); + + describe('lifecycle flags', () => + { + it('setLoading(true) and setLoading(false) toggle isLoading', () => + { + useNavigatorUiStore.getState().setLoading(true); + expect(useNavigatorUiStore.getState().isLoading).toBe(true); + useNavigatorUiStore.getState().setLoading(false); + expect(useNavigatorUiStore.getState().isLoading).toBe(false); + }); + + it('markReady() sets isReady true and is idempotent', () => + { + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + useNavigatorUiStore.getState().markReady(); + expect(useNavigatorUiStore.getState().isReady).toBe(true); + }); + + it('markInitDone() flips needsInit to false', () => + { + useNavigatorUiStore.getState().markInitDone(); + expect(useNavigatorUiStore.getState().needsInit).toBe(false); + }); + + it('requestSearch() + consumeSearchRequest() are symmetric', () => + { + useNavigatorUiStore.getState().requestSearch(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(true); + useNavigatorUiStore.getState().consumeSearchRequest(); + expect(useNavigatorUiStore.getState().needsSearch).toBe(false); + }); + }); +}); diff --git a/src/hooks/navigator/navigatorUiStore.ts b/src/hooks/navigator/navigatorUiStore.ts new file mode 100644 index 0000000..527aab7 --- /dev/null +++ b/src/hooks/navigator/navigatorUiStore.ts @@ -0,0 +1,61 @@ +import { createNitroStore } from '../../state/createNitroStore'; + +export type NavigatorUiState = { + isVisible: boolean; + isReady: boolean; + isCreatorOpen: boolean; + isRoomInfoOpen: boolean; + isRoomLinkOpen: boolean; + isOpenSavesSearches: boolean; + isLoading: boolean; + needsInit: boolean; + needsSearch: boolean; +}; + +export type NavigatorUiActions = { + show(): void; + hide(): void; + toggle(): void; + openCreator(): void; + closeCreator(): void; + setRoomInfoOpen(open: boolean): void; + toggleRoomInfo(): void; + setRoomLinkOpen(open: boolean): void; + toggleRoomLink(): void; + toggleSavesSearches(): void; + setLoading(loading: boolean): void; + markReady(): void; + markInitDone(): void; + requestSearch(): void; + consumeSearchRequest(): void; +}; + +export const useNavigatorUiStore = createNitroStore()((set) => ({ + isVisible: false, + isReady: false, + isCreatorOpen: false, + isRoomInfoOpen: false, + isRoomLinkOpen: false, + isOpenSavesSearches: false, + isLoading: false, + needsInit: true, + needsSearch: false, + + show: () => set({ isVisible: true, needsSearch: true }), + hide: () => set({ isVisible: false }), + toggle: () => set((s) => s.isVisible + ? { isVisible: false } + : { isVisible: true, needsSearch: true }), + openCreator: () => set({ isVisible: true, isCreatorOpen: true }), + closeCreator: () => set({ isCreatorOpen: false }), + setRoomInfoOpen: (open) => set({ isRoomInfoOpen: open }), + toggleRoomInfo: () => set((s) => ({ isRoomInfoOpen: !s.isRoomInfoOpen })), + setRoomLinkOpen: (open) => set({ isRoomLinkOpen: open }), + toggleRoomLink: () => set((s) => ({ isRoomLinkOpen: !s.isRoomLinkOpen })), + toggleSavesSearches: () => set((s) => ({ isOpenSavesSearches: !s.isOpenSavesSearches })), + setLoading: (loading) => set({ isLoading: loading }), + markReady: () => set({ isReady: true }), + markInitDone: () => set({ needsInit: false }), + requestSearch: () => set({ needsSearch: true }), + consumeSearchRequest: () => set({ needsSearch: false }) +})); From 1f0cf88344b6af68566bd4a081805880cafc5d2e Mon Sep 17 00:00:00 2001 From: Life Date: Tue, 26 May 2026 20:46:36 +0200 Subject: [PATCH 05/86] Refine descriptions and comments in CI workflow Updated descriptions for workflow_dispatch inputs and improved comments for clarity. --- .github/workflows/ci.yml | 112 +++++++++++++++++---------------------- 1 file changed, 50 insertions(+), 62 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acb525..7553c95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,11 +9,11 @@ on: workflow_dispatch: inputs: renderer_repo: - description: 'Renderer repo (owner/name). Empty = auto from client branch.' + description: 'Renderer repo (owner/name). Empty = vars.RENDERER_REPO or upstream default.' required: false default: '' renderer_ref: - description: 'Renderer git ref. Empty = auto from client branch.' + description: 'Renderer git ref. Empty = vars.RENDERER_REF or auto (main on client main, else Dev).' required: false default: '' @@ -24,6 +24,11 @@ on: # it on every run. env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # Upstream renderer used as the fallback when nothing else is + # configured. Override per-fork via the RENDERER_REPO / RENDERER_REF + # repository variables (Settings → Secrets and variables → Actions → + # Variables) or, for one-off runs, via the workflow_dispatch inputs. + UPSTREAM_RENDERER_REPO: 'duckietm/Nitro_Render_V3' jobs: check: @@ -39,77 +44,60 @@ jobs: with: path: Nitro-V3 - # Pick the renderer ref dynamically based on the client context. + # Resolve the renderer pairing with a clear precedence, from most + # specific to most generic — no fork names or feature branches are + # hardcoded in this workflow: + # + # 1. workflow_dispatch inputs (renderer_repo / renderer_ref) + # → explicit manual override, wins outright. + # 2. repository variables (vars.RENDERER_REPO / vars.RENDERER_REF) + # → per-fork config set under Settings → Variables, applies + # to push and pull_request runs without editing this file. + # 3. upstream default + # → UPSTREAM_RENDERER_REPO, ref `main` when the client build + # context is `main`, otherwise `Dev`. + # # The two repos must stay wire-aligned (composer/parser - # signatures); pairing `main` with a stale branch is what + # signatures); pairing the client with a stale renderer is what # produced the "Expected 14-15 arguments, but got 16" failure on - # the catalog edit composer. - # - # This branch (`feat/housekeeping-panel`) references HK composers - # /events that live on the renderer PR branch - # (simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets) — they - # haven't been merged upstream yet. Pair against the fork branch - # for this PR so the typecheck step can resolve the imports; - # once the renderer PR lands on duckietm:Dev this whole - # special-case block can be dropped. - # - # Mapping: - # client `main` → duckietm/Nitro_Render_V3 @ main - # client `feat/housekeeping-panel` → simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets - # client `feat/**` (other) → duckietm/Nitro_Render_V3 @ Dev - # PR base `main` → duckietm/Nitro_Render_V3 @ main - # PR head `feat/housekeeping-panel` → simoleo89/Nitro_Render_V3 @ feat/housekeeping-packets - # PR base `Dev` (upstream) → duckietm/Nitro_Render_V3 @ Dev - # PR base `feat/**` → duckietm/Nitro_Render_V3 @ Dev - # - # Override via workflow_dispatch inputs when you need an ad-hoc - # pairing. + # the catalog edit composer. When a feature touches both repos, + # point RENDERER_REPO/RENDERER_REF (or the dispatch inputs) at the + # companion renderer branch. - name: Resolve renderer ref id: renderer + env: + IN_REPO: ${{ github.event.inputs.renderer_repo }} + IN_REF: ${{ github.event.inputs.renderer_ref }} + VAR_REPO: ${{ vars.RENDERER_REPO }} + VAR_REF: ${{ vars.RENDERER_REF }} run: | - REPO="${{ github.event.inputs.renderer_repo }}" - REF="${{ github.event.inputs.renderer_ref }}" + set -euo pipefail - if [ -z "$REPO" ] || [ -z "$REF" ]; then - # For PRs we usually pair against the base ref, but the HK - # PR specifically needs to pair against its OWN head ref — - # the renderer companion PR is named identically - # (`feat/housekeeping-packets`) and lives on the same fork. - case "${GITHUB_EVENT_NAME}" in - pull_request) - if [ "${GITHUB_HEAD_REF}" = "feat/housekeeping-panel" ]; then - CTX="${GITHUB_HEAD_REF}" - else - CTX="${GITHUB_BASE_REF}" - fi - ;; - *) - CTX="${GITHUB_REF_NAME}" - ;; - esac + # Branch context of the *client* build. + case "${GITHUB_EVENT_NAME}" in + pull_request) CTX="${GITHUB_BASE_REF}" ;; + *) CTX="${GITHUB_REF_NAME}" ;; + esac - case "$CTX" in - main) - AUTO_REPO="duckietm/Nitro_Render_V3" - AUTO_REF="main" - ;; - feat/housekeeping-panel) - AUTO_REPO="simoleo89/Nitro_Render_V3" - AUTO_REF="feat/housekeeping-packets" - ;; - *) - AUTO_REPO="duckietm/Nitro_Render_V3" - AUTO_REF="Dev" - ;; - esac - - [ -z "$REPO" ] && REPO="$AUTO_REPO" - [ -z "$REF" ] && REF="$AUTO_REF" + # Upstream fallback ref depends on client context. + if [ "$CTX" = "main" ]; then + DEFAULT_REF="main" + else + DEFAULT_REF="Dev" fi + # Precedence: dispatch input → repo variable → upstream default. + REPO="$IN_REPO" + [ -z "$REPO" ] && REPO="$VAR_REPO" + [ -z "$REPO" ] && REPO="$UPSTREAM_RENDERER_REPO" + + REF="$IN_REF" + [ -z "$REF" ] && REF="$VAR_REF" + [ -z "$REF" ] && REF="$DEFAULT_REF" + echo "repo=$REPO" >> "$GITHUB_OUTPUT" echo "ref=$REF" >> "$GITHUB_OUTPUT" - echo "Resolved renderer pairing: $REPO @ $REF (client ctx: ${GITHUB_BASE_REF:-$GITHUB_REF_NAME}, event: ${GITHUB_EVENT_NAME})" + echo "Resolved renderer pairing: $REPO @ $REF (client ctx: $CTX, event: ${GITHUB_EVENT_NAME})" - name: Checkout Nitro_Render_V3 (sibling) uses: actions/checkout@v4 From 07bbc0c78d40b9a05ec683958e6ae926b6f29f54 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 21:35:30 +0200 Subject: [PATCH 06/86] =?UTF-8?q?feat(navigator):=20extract=20useDoorState?= =?UTF-8?q?=20(TDD)=20=E2=80=93=20Task=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `src/hooks/rooms/widgets/useDoorState.ts`: useBetween-based singleton wrapping DoorbellMessageEvent / RoomDoorbellAcceptedEvent / FlatAccessDeniedMessageEvent / GenericErrorEvent / GetGuestRoomResultEvent; all 5 handlers wrapped in useCallback([]) so their references are stable across useBetween tick() calls and the effect dep-array never triggers re-registration. - Add `src/hooks/rooms/widgets/useDoorState.test.tsx`: 11-case Vitest suite (initial state, 5 event transitions, 2 no-op guards, GetGuestRoomResultEvent doorbell/password paths, reset()). - Extend `src/nitro-renderer.mock.ts`: new MessageEvent base class with callBack/type/getParser; DoorbellMessageEvent / RoomDoorbellAcceptedEvent / FlatAccessDeniedMessageEvent / GenericErrorEvent / GetGuestRoomResultEvent concrete stubs; RoomDataParser.DOORBELL_STATE + PASSWORD_STATE; separate msgListeners map (cleared independently of NitroEvent listeners so useBetween subscriptions survive between test cases); WeakMap wrapper for correct removeMessageEvent; GetCommunication routes to msgListeners. All 11 useDoorState tests pass; full suite 453/456 (3 pre-existing FloorplanCanvasSVG jsdom/SVG-CTM failures unrelated to this task). --- src/hooks/rooms/widgets/useDoorState.test.tsx | 151 ++++++++++++++++++ src/hooks/rooms/widgets/useDoorState.ts | 77 +++++++++ src/nitro-renderer.mock.ts | 109 ++++++++++++- 3 files changed, 330 insertions(+), 7 deletions(-) create mode 100644 src/hooks/rooms/widgets/useDoorState.test.tsx create mode 100644 src/hooks/rooms/widgets/useDoorState.ts diff --git a/src/hooks/rooms/widgets/useDoorState.test.tsx b/src/hooks/rooms/widgets/useDoorState.test.tsx new file mode 100644 index 0000000..1176d07 --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.test.tsx @@ -0,0 +1,151 @@ +import { act, renderHook } from '@testing-library/react'; +import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, + GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser, + RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; +import { beforeEach, describe, expect, it } from 'vitest'; +import { DoorStateType } from '../../../api'; +import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock'; +import { useDoorState } from './useDoorState'; + +const makeParserlessEvent = (klass: any, parser: any) => +{ + const ev = new klass(); + (ev as any).getParser = () => parser; + return ev; +}; + +describe('useDoorState', () => +{ + beforeEach(() => + { + clearMockEventDispatcher(); + }); + + it('exposes the initial NONE snapshot', () => + { + const { result } = renderHook(() => useDoorState()); + expect(result.current.snapshot.state).toBe(DoorStateType.NONE); + expect(result.current.snapshot.roomInfo).toBeNull(); + }); + + it('DoorbellMessageEvent with empty userName -> STATE_WAITING', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING); + }); + + it('DoorbellMessageEvent with non-empty userName does NOT change state', () => + { + const { result } = renderHook(() => useDoorState()); + const before = result.current.snapshot.state; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: 'someone' })); + }); + expect(result.current.snapshot.state).toBe(before); + }); + + it('RoomDoorbellAcceptedEvent (empty userName) -> STATE_ACCEPTED', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(RoomDoorbellAcceptedEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_ACCEPTED); + }); + + it('FlatAccessDeniedMessageEvent (empty userName) -> STATE_NO_ANSWER', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(FlatAccessDeniedMessageEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_NO_ANSWER); + }); + + it('GenericErrorEvent -100002 -> STATE_WRONG_PASSWORD', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: -100002 })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WRONG_PASSWORD); + }); + + it('GenericErrorEvent 4010 does NOT touch door state', () => + { + const { result } = renderHook(() => useDoorState()); + const before = result.current.snapshot.state; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GenericErrorEvent, { errorCode: 4010 })); + }); + expect(result.current.snapshot.state).toBe(before); + }); + + it('GetGuestRoomResultEvent with roomForward + DOORBELL_STATE -> START_DOORBELL', () => + { + const { result } = renderHook(() => useDoorState()); + const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.DOORBELL_STATE }; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomForward: true, + isGroupMember: false, + data: fakeRoomData + })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.START_DOORBELL); + expect(result.current.snapshot.roomInfo).toBe(fakeRoomData); + }); + + it('GetGuestRoomResultEvent with roomForward + PASSWORD_STATE -> START_PASSWORD', () => + { + const { result } = renderHook(() => useDoorState()); + const fakeRoomData: any = { roomId: 42, roomName: 'r', ownerName: 'other', doorMode: RoomDataParser.PASSWORD_STATE }; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomForward: true, + isGroupMember: false, + data: fakeRoomData + })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.START_PASSWORD); + }); + + it('GetGuestRoomResultEvent with non-bell/password doorMode does NOT change state', () => + { + const { result } = renderHook(() => useDoorState()); + const before = result.current.snapshot.state; + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomForward: true, + isGroupMember: false, + data: { ownerName: 'other', doorMode: 99 } + })); + }); + expect(result.current.snapshot.state).toBe(before); + }); + + it('reset() returns snapshot to NONE', () => + { + const { result } = renderHook(() => useDoorState()); + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING); + act(() => result.current.reset()); + expect(result.current.snapshot.state).toBe(DoorStateType.NONE); + expect(result.current.snapshot.roomInfo).toBeNull(); + }); +}); diff --git a/src/hooks/rooms/widgets/useDoorState.ts b/src/hooks/rooms/widgets/useDoorState.ts new file mode 100644 index 0000000..1fadbba --- /dev/null +++ b/src/hooks/rooms/widgets/useDoorState.ts @@ -0,0 +1,77 @@ +import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, + GenericErrorEvent, GetGuestRoomResultEvent, + GetSessionDataManager, RoomDataParser, + RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { useBetween } from 'use-between'; +import { DoorStateType } from '../../../api'; +import { useMessageEvent } from '../../events'; + +export type DoorStateSnapshot = { + roomInfo: RoomDataParser | null; + state: number; +}; + +const INITIAL: DoorStateSnapshot = { roomInfo: null, state: DoorStateType.NONE }; + +const useDoorStateStore = () => +{ + const [ snapshot, setSnapshot ] = useState(INITIAL); + + const handleDoorbell = useCallback((event: DoorbellMessageEvent) => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WAITING })); + }, []); + + const handleAccepted = useCallback((event: RoomDoorbellAcceptedEvent) => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_ACCEPTED })); + }, []); + + const handleDenied = useCallback((event: FlatAccessDeniedMessageEvent) => + { + const parser = event.getParser(); + if(parser.userName && parser.userName.length > 0) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_NO_ANSWER })); + }, []); + + const handleGenericError = useCallback((event: GenericErrorEvent) => + { + const parser = event.getParser(); + if(parser.errorCode !== -100002) return; + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_WRONG_PASSWORD })); + }, []); + + const handleGuestRoom = useCallback((event: GetGuestRoomResultEvent) => + { + const parser = event.getParser(); + if(!parser.roomForward) return; + if(parser.data.ownerName === GetSessionDataManager().userName) return; + if(parser.isGroupMember) return; + if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) + { + setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_DOORBELL }); + return; + } + if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) + { + setSnapshot({ roomInfo: parser.data, state: DoorStateType.START_PASSWORD }); + } + }, []); + + useMessageEvent(DoorbellMessageEvent, handleDoorbell); + useMessageEvent(RoomDoorbellAcceptedEvent, handleAccepted); + useMessageEvent(FlatAccessDeniedMessageEvent, handleDenied); + useMessageEvent(GenericErrorEvent, handleGenericError); + useMessageEvent(GetGuestRoomResultEvent, handleGuestRoom); + + const reset = useCallback(() => setSnapshot(INITIAL), []); + + return { snapshot, setSnapshot, reset }; +}; + +export const useDoorState = () => useBetween(useDoorStateStore); diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 1cfbfa1..dd5b642 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -43,8 +43,20 @@ export const NitroLogger = { type Listener = (event: any) => void; +// NitroEvent listeners — registered via GetEventDispatcher() / useNitroEvent. +// Cleared by clearMockEventDispatcher() between test cases. const listeners = new Map>(); +// MessageEvent listeners — registered via GetCommunication().registerMessageEvent +// (i.e. useMessageEvent). NOT cleared by clearMockEventDispatcher() so that +// useBetween-based hooks (which register effects once and persist the +// singleton across tests) keep their subscriptions alive throughout the +// suite. State isolation between tests is maintained by the useBetween +// instance preserving INITIAL values across renders (each test's renderHook +// shares the same useBetween singleton — tests that check a specific +// post-dispatch state rely on the event changing it, not on a reset). +const msgListeners = new Map>(); + export const mockEventDispatcher = { addEventListener(type: string, handler: Listener) { @@ -64,18 +76,23 @@ export const mockEventDispatcher = { }, dispatchEvent(event: { type: string }) { + // Fire NitroEvent listeners first, then MessageEvent listeners. const bucket = listeners.get(event.type); + if(bucket) for(const handler of bucket) handler(event); - if(!bucket) return; - - for(const handler of bucket) handler(event); + const msgBucket = msgListeners.get(event.type); + if(msgBucket) for(const handler of msgBucket) handler(event); }, hasListeners(type: string) { - return (listeners.get(type)?.size ?? 0) > 0; + return (listeners.get(type)?.size ?? 0) > 0 || + (msgListeners.get(type)?.size ?? 0) > 0; } }; +// Clears only the NitroEvent listener map (GetEventDispatcher / useNitroEvent +// registrations). MessageEvent listeners (useMessageEvent / GetCommunication) +// are intentionally preserved so useBetween-based hooks stay subscribed. export const clearMockEventDispatcher = () => { listeners.clear(); @@ -188,7 +205,52 @@ export class NitroSprite extends StubClass {} export class NitroTexture extends StubClass {} export class NitroSoundEvent extends StubClass {} export class NitroEvent extends StubClass {} -export class MessageEvent extends StubClass {} + +// MessageEvent — stores the handler so GetCommunication (below) can +// route dispatches through mockEventDispatcher. Each concrete subclass +// exposes a `.type` equal to its constructor name so dispatchEvent +// can match registered listeners. +export class MessageEvent +{ + private _callBack: Function | null; + + constructor(callBack?: Function) + { + this._callBack = callBack ?? null; + } + + public get callBack(): Function | null { return this._callBack; } + + // Each concrete subclass is identified by its class name. + public get type(): string { return this.constructor.name; } + + // Concrete subclasses override this; the no-arg construction path used + // by makeParserlessEvent in tests leaves it returning null — tests + // override it with (ev as any).getParser = () => parser. + public getParser(): any { return null; } +} + +// --------------------------------------------------------------------------- +// IMessageEvent-based event classes used by useDoorState +// +// The real renderer classes take a `callBack` constructor arg and store it +// in MessageEvent._callBack. The communication manager later calls +// `event.callBack(event)` when the matching packet arrives. +// +// In tests we construct them with NO args (makeParserlessEvent does +// `new klass()`) and override `getParser`. GetCommunication (below) +// registers `event.callBack` on mockEventDispatcher under `event.type` +// (the class name). When the test calls +// `mockEventDispatcher.dispatchEvent(ev)`, listeners for that class name +// fire, receiving `ev` — and the implementation reads `ev.getParser()`. +// --------------------------------------------------------------------------- + +export class DoorbellMessageEvent extends MessageEvent {} +export class RoomDoorbellAcceptedEvent extends MessageEvent {} +export class FlatAccessDeniedMessageEvent extends MessageEvent {} +export class GenericErrorEvent extends MessageEvent {} +export class GetGuestRoomResultEvent extends MessageEvent {} + export class RoomEngineObjectEvent extends StubClass {} export class CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} @@ -196,7 +258,14 @@ export class AdvancedMap extends StubClass {} export class AvatarFigureContainer extends StubClass {} export class Vector3d extends StubClass {} export class ObjectDataFactory extends StubClass {} -export class RoomDataParser extends StubClass {} + +// RoomDataParser — real static constants needed by useDoorState and its tests. +export class RoomDataParser +{ + static readonly DOORBELL_STATE = 1; + static readonly PASSWORD_STATE = 2; +} + export class RoomModerationSettings extends StubClass {} export class StringDataType extends StubClass {} export class SellablePetPaletteData extends StubClass {} @@ -351,7 +420,33 @@ const stubManager = () => export const GetAssetManager = vi.fn(stubManager); export const GetAvatarRenderManager = vi.fn(stubManager); -export const GetCommunication = vi.fn(stubManager); +// GetCommunication — routes IMessageEvent registration through the +// msgListeners map (separate from the NitroEvent listeners map) so that +// clearMockEventDispatcher() does NOT wipe these subscriptions. This +// keeps useBetween-based hooks (like useDoorState) subscribed across +// test cases without needing to recreate the useBetween singleton. +// +// A WeakMap stores the wrapper fn keyed by the MessageEvent instance so +// that removeMessageEvent can remove the exact listener added by +// registerMessageEvent. +const _msgEventWrappers = new WeakMap void>(); + +export const GetCommunication = vi.fn(() => ({ + registerMessageEvent(event: MessageEvent) + { + if(!event.callBack) return; + const wrapper = (ev: any) => event.callBack!(ev); + _msgEventWrappers.set(event, wrapper); + let bucket = msgListeners.get(event.type); + if(!bucket) { bucket = new Set(); msgListeners.set(event.type, bucket); } + bucket.add(wrapper); + }, + removeMessageEvent(event: MessageEvent) + { + const wrapper = _msgEventWrappers.get(event); + if(wrapper) msgListeners.get(event.type)?.delete(wrapper); + } +})); export const GetConfiguration = vi.fn(stubManager); export const GetLocalizationManager = vi.fn(stubManager); export const GetRoomEngine = vi.fn(stubManager); From f97650d7f61e6d540de1ae96570c33a632855d49 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Tue, 26 May 2026 21:54:31 +0200 Subject: [PATCH 07/86] fix(rooms): useDoorState handles roomEnter reset + test-order isolation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code review of Task 2 (commit 07bbc0c7) found two real issues: 1. The GetGuestRoomResultEvent handler did not handle parser.roomEnter, so after the consumer migration (Tasks 5-8) a successful room entry would no longer dismiss the door dialog. Fix: reset to INITIAL when parser.roomEnter is true, before the roomForward branch. 2. The test suite was order-dependent — the useBetween singleton persisted state across tests, so 'exposes the initial NONE snapshot' passed only because it ran first. Fix: beforeEach renders the hook once, calls reset(), then unmounts; afterEach calls cleanup(). Plus one new test case verifying the roomEnter -> reset behavior. --- src/hooks/rooms/widgets/useDoorState.test.tsx | 34 +++++++++++++++++-- src/hooks/rooms/widgets/useDoorState.ts | 5 +++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/hooks/rooms/widgets/useDoorState.test.tsx b/src/hooks/rooms/widgets/useDoorState.test.tsx index 1176d07..2655230 100644 --- a/src/hooks/rooms/widgets/useDoorState.test.tsx +++ b/src/hooks/rooms/widgets/useDoorState.test.tsx @@ -1,8 +1,8 @@ -import { act, renderHook } from '@testing-library/react'; +import { act, cleanup, renderHook } from '@testing-library/react'; import { DoorbellMessageEvent, FlatAccessDeniedMessageEvent, GenericErrorEvent, GetGuestRoomResultEvent, RoomDataParser, RoomDoorbellAcceptedEvent } from '@nitrots/nitro-renderer'; -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DoorStateType } from '../../../api'; import { clearMockEventDispatcher, mockEventDispatcher } from '../../../nitro-renderer.mock'; import { useDoorState } from './useDoorState'; @@ -19,6 +19,14 @@ describe('useDoorState', () => beforeEach(() => { clearMockEventDispatcher(); + const { result, unmount } = renderHook(() => useDoorState()); + act(() => result.current.reset()); + unmount(); + }); + + afterEach(() => + { + cleanup(); }); it('exposes the initial NONE snapshot', () => @@ -136,6 +144,28 @@ describe('useDoorState', () => expect(result.current.snapshot.state).toBe(before); }); + it('GetGuestRoomResultEvent with roomEnter=true resets snapshot to NONE', () => + { + const { result } = renderHook(() => useDoorState()); + // First put the hook into a non-NONE state via doorbell + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(DoorbellMessageEvent, { userName: '' })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.STATE_WAITING); + // Then roomEnter event should dismiss it + act(() => + { + mockEventDispatcher.dispatchEvent(makeParserlessEvent(GetGuestRoomResultEvent, { + roomEnter: true, + roomForward: false, + data: {} + })); + }); + expect(result.current.snapshot.state).toBe(DoorStateType.NONE); + expect(result.current.snapshot.roomInfo).toBeNull(); + }); + it('reset() returns snapshot to NONE', () => { const { result } = renderHook(() => useDoorState()); diff --git a/src/hooks/rooms/widgets/useDoorState.ts b/src/hooks/rooms/widgets/useDoorState.ts index 1fadbba..b9dbb05 100644 --- a/src/hooks/rooms/widgets/useDoorState.ts +++ b/src/hooks/rooms/widgets/useDoorState.ts @@ -49,6 +49,11 @@ const useDoorStateStore = () => const handleGuestRoom = useCallback((event: GetGuestRoomResultEvent) => { const parser = event.getParser(); + if(parser.roomEnter) + { + setSnapshot(INITIAL); + return; + } if(!parser.roomForward) return; if(parser.data.ownerName === GetSessionDataManager().userName) return; if(parser.isGroupMember) return; From acf870ff6aff17ec4975983490cabb2d648e6c48 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 27 May 2026 07:46:10 +0200 Subject: [PATCH 08/86] =?UTF-8?q?=F0=9F=86=99=20Enable=20back=20the=20live?= =?UTF-8?q?=20previes=20of=20the=20floorplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rooms/widgets/useFloorplanLiveSync.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/src/hooks/rooms/widgets/useFloorplanLiveSync.ts b/src/hooks/rooms/widgets/useFloorplanLiveSync.ts index 8c5610c..ed3ae72 100644 --- a/src/hooks/rooms/widgets/useFloorplanLiveSync.ts +++ b/src/hooks/rooms/widgets/useFloorplanLiveSync.ts @@ -121,9 +121,8 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo const baselineRef = useRef(null); const lastAppliedRef = useRef(null); + const wasEnabledRef = useRef(false); - // Destructure first so the memo deps stay precise without - // triggering exhaustive-deps on `state` as a whole. const { tiles, door, thickness, wallHeight } = state; const currentPayload = useMemo(() => ({ tilemap: serializeTilemap(tiles), @@ -150,18 +149,22 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo if(applyToRenderer(baseline, roomId)) lastAppliedRef.current = baseline; }, [ roomId ]); - // Apply the current payload to the renderer whenever it - // diverges from what's already in the room. Synchronous + no - // debounce — the renderer pipeline is fast enough that every - // brush stroke can land a paint. useEffect(() => { - if(!enabled) return; + if(!enabled) + { + wasEnabledRef.current = false; + return; + } + + if(!baselineRef.current) return; + + const isFirstEnable = !wasEnabledRef.current; + wasEnabledRef.current = true; const previous = lastAppliedRef.current; - if(previous && livePreviewPayloadsEqual(currentPayload, previous)) return; - if(!previous && !baselineRef.current) return; + if(!isFirstEnable && previous && livePreviewPayloadsEqual(currentPayload, previous)) return; if(applyToRenderer(currentPayload, roomId)) lastAppliedRef.current = currentPayload; }, [ enabled, currentPayload, roomId ]); From a52a4a024ad87c6a02ee10a261c943b9384e6929 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 27 May 2026 09:39:08 +0200 Subject: [PATCH 09/86] =?UTF-8?q?=F0=9F=86=95=20Added=20Pickup=20furni=20t?= =?UTF-8?q?o=20the=20floorplan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../floorplan-editor/FloorplanEditorView.tsx | 25 ++++-- .../rooms/widgets/useFloorplanLiveSync.ts | 81 +++++++------------ 2 files changed, 49 insertions(+), 57 deletions(-) diff --git a/src/components/floorplan-editor/FloorplanEditorView.tsx b/src/components/floorplan-editor/FloorplanEditorView.tsx index 07e721e..8094d1b 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.tsx @@ -1,6 +1,6 @@ import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { FaBolt, FaCaretLeft, FaCaretRight } from 'react-icons/fa'; +import { FaBolt, FaBoxOpen, FaCaretLeft, FaCaretRight } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; import { Button, ButtonGroup, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; import { useMessageEvent, useNitroEvent } from '../../hooks'; @@ -29,6 +29,7 @@ export const FloorplanEditorView: FC = () => const [ importExportVisible, setImportExportVisible ] = useState(false); const [ liveSync, setLiveSync ] = useState(true); const [ panMode, setPanMode ] = useState(false); + const [ autoPickup, setAutoPickup ] = useState(false); const { state, dispatch, loadFromServer, undo, redo, canUndo, canRedo } = useFloorplanReducer(); const originalRef = useRef<{ tilemap: string; @@ -41,7 +42,7 @@ export const FloorplanEditorView: FC = () => const area = useMemo(() => areaCount(state.tiles), [ state.tiles ]); - const { setBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state }); + const { setBaseline, mergeBaseline, revert: revertLivePreview } = useFloorplanLiveSync({ enabled: liveSync && isVisible, state }); useNitroEvent(RoomEngineEvent.DISPOSED, () => setIsVisible(false)); @@ -64,6 +65,7 @@ export const FloorplanEditorView: FC = () => }; dispatch({ type: 'SET_DOOR', x: parser.x, y: parser.y, source: 'remote' }); dispatch({ type: 'SET_DOOR_DIR', dir: ((parser.direction | 0) & 7) as EntryDir, source: 'remote' }); + mergeBaseline({ doorX: parser.x, doorY: parser.y, doorDir: (parser.direction | 0) & 7 }); }); useMessageEvent(FloorHeightMapEvent, event => @@ -110,6 +112,7 @@ export const FloorplanEditorView: FC = () => wallHeight: originalRef.current?.wallHeight ?? -1 }; dispatch({ type: 'SET_THICKNESS', wall, floor, source: 'remote' }); + mergeBaseline({ thicknessWall: wall, thicknessFloor: floor }); }); useEffect(() => @@ -173,7 +176,8 @@ export const FloorplanEditorView: FC = () => state.door.dir, convertNumbersForSaving(state.thickness.wall), convertNumbersForSaving(state.thickness.floor), - state.wallHeight - 1 + state.wallHeight - 1, + autoPickup )); }; @@ -224,7 +228,17 @@ export const FloorplanEditorView: FC = () => setAutoPickup(v => !v) } + title="On save: pick up furniture blocking the new floor plan and return it to its owner's inventory" + > + + { autoPickup ? 'Pick up blocking furni ON' : 'Pick up blocking furni OFF' } + + setLiveSync(v => !v) } title="Local in-room preview while drawing (does not save to server)" > @@ -256,7 +270,8 @@ export const FloorplanEditorView: FC = () => state.door.dir, convertNumbersForSaving(state.thickness.wall), convertNumbersForSaving(state.thickness.floor), - state.wallHeight - 1 + state.wallHeight - 1, + autoPickup )); } } onRevertText={ () => originalRef.current?.tilemap ?? serializeTilemap(state.tiles) } diff --git a/src/hooks/rooms/widgets/useFloorplanLiveSync.ts b/src/hooks/rooms/widgets/useFloorplanLiveSync.ts index ed3ae72..9dbfdfa 100644 --- a/src/hooks/rooms/widgets/useFloorplanLiveSync.ts +++ b/src/hooks/rooms/widgets/useFloorplanLiveSync.ts @@ -1,44 +1,18 @@ import { GetRoomEngine, GetRoomMessageHandler } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useMemo, useRef } from 'react'; -import { serializeTilemap } from '../../../components/floorplan-editor/state/encoding'; +import { parseTilemap, serializeTilemap } from '../../../components/floorplan-editor/state/encoding'; import { FloorplanState } from '../../../components/floorplan-editor/state/types'; import { useActiveRoomSessionSnapshot } from '../../session/useSessionSnapshots'; -/** - * Client-side live preview for the floor-plan editor. - * - * Every tile / door / thickness / wallHeight change in the editor - * is applied IMMEDIATELY to the 3D room behind the editor card - * via the renderer's local `RoomMessageHandler.applyFloorModelLocally` - * (added in the renderer's `feat/floorplan-live-preview` branch). - * Nothing is sent to the server until the user explicitly clicks - * Save — at that point `FloorplanEditorView` fires the - * `UpdateFloorPropertiesMessageComposer` directly. - * - * Closing the editor without saving leaves the live preview - * in place visually. To restore the pre-edit room, call `revert` - * — it re-applies the baseline payload locally. The next - * `FloorHeightMapEvent` from the server (e.g. on room re-enter) - * also wins and overwrites whatever preview is in place. - * - * Thickness changes additionally call - * `RoomEngine.updateRoomInstancePlaneThickness` for zero-latency - * wall/floor depth feedback (the full geometry rebuild that - * `applyFloorModelLocally` performs already reflects the new - * thickness in its plane data, but the dedicated thickness - * setter is cheaper and updates instantly as a slider is dragged). - */ +const normalizeTilemap = (raw: string): string => serializeTilemap(parseTilemap(raw)); export type LivePreviewPayload = { - /** Newline-or-CR-separated tilemap (the renderer parser accepts \r). */ tilemap: string; doorX: number; doorY: number; doorDir: number; - /** Editor-space (0..3). */ thicknessWall: number; thicknessFloor: number; - /** Editor-space (1..N). Server space is `wallHeight - 1`. */ wallHeight: number; }; @@ -48,17 +22,8 @@ export type UseFloorplanLiveSyncOptions = { }; export type UseFloorplanLiveSyncApi = { - /** - * Mark a payload as "currently shown in the room" so subsequent - * state diffs are computed against it. Editors call this on - * every server-driven snapshot push (FloorHeightMapEvent, - * RoomVisualizationSettingsEvent, …). - */ setBaseline: (payload: LivePreviewPayload) => void; - /** - * Restore the in-room preview to the recorded baseline. - * Use when the user closes the editor without saving. - */ + mergeBaseline: (partial: Partial) => void; revert: () => void; }; @@ -121,7 +86,6 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo const baselineRef = useRef(null); const lastAppliedRef = useRef(null); - const wasEnabledRef = useRef(false); const { tiles, door, thickness, wallHeight } = state; const currentPayload = useMemo(() => ({ @@ -136,8 +100,29 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo const setBaseline = useCallback((payload: LivePreviewPayload) => { - baselineRef.current = payload; - lastAppliedRef.current = payload; + const normalized: LivePreviewPayload = { + ...payload, + tilemap: normalizeTilemap(payload.tilemap) + }; + + baselineRef.current = normalized; + lastAppliedRef.current = normalized; + }, []); + + const mergeBaseline = useCallback((partial: Partial) => + { + const previous = baselineRef.current; + + if(!previous) return; + + const next: LivePreviewPayload = { + ...previous, + ...partial, + tilemap: partial.tilemap !== undefined ? normalizeTilemap(partial.tilemap) : previous.tilemap + }; + + baselineRef.current = next; + lastAppliedRef.current = next; }, []); const revert = useCallback(() => @@ -151,23 +136,15 @@ export const useFloorplanLiveSync = (opts: UseFloorplanLiveSyncOptions): UseFloo useEffect(() => { - if(!enabled) - { - wasEnabledRef.current = false; - return; - } - + if(!enabled) return; if(!baselineRef.current) return; - const isFirstEnable = !wasEnabledRef.current; - wasEnabledRef.current = true; - const previous = lastAppliedRef.current; - if(!isFirstEnable && previous && livePreviewPayloadsEqual(currentPayload, previous)) return; + if(previous && livePreviewPayloadsEqual(currentPayload, previous)) return; if(applyToRenderer(currentPayload, roomId)) lastAppliedRef.current = currentPayload; }, [ enabled, currentPayload, roomId ]); - return { setBaseline, revert }; + return { setBaseline, mergeBaseline, revert }; }; From b1244cbd5afe7a47d410255a75fce75202425007 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 27 May 2026 13:42:11 +0200 Subject: [PATCH 10/86] =?UTF-8?q?=F0=9F=86=99=20Fix=20BOTS=20in=20catalog?= =?UTF-8?q?=20and=20inventory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/layout/LayoutAvatarImageView.tsx | 50 +++++++++++++++---- .../page/common/CatalogGridOfferView.tsx | 2 +- .../page/layout/CatalogLayoutDefaultView.tsx | 4 +- .../views/bot/InventoryBotItemView.tsx | 4 +- .../inventory/views/bot/InventoryBotView.tsx | 2 +- 5 files changed, 44 insertions(+), 18 deletions(-) diff --git a/src/common/layout/LayoutAvatarImageView.tsx b/src/common/layout/LayoutAvatarImageView.tsx index 75233a8..bd640e2 100644 --- a/src/common/layout/LayoutAvatarImageView.tsx +++ b/src/common/layout/LayoutAvatarImageView.tsx @@ -12,35 +12,51 @@ export interface LayoutAvatarImageViewProps extends BaseProps headOnly?: boolean; direction?: number; scale?: number; + fit?: boolean; } export const LayoutAvatarImageView: FC = props => { - const { figure = '', gender = '', headOnly = false, direction = 0, scale = 1, classNames = [], style = {}, ...rest } = props; + const { figure = '', gender = '', headOnly = false, direction = 0, scale = 1, fit = false, classNames = [], style = {}, ...rest } = props; const [ avatarUrl, setAvatarUrl ] = useState(null); const [ isReady, setIsReady ] = useState(false); const isDisposed = useRef(false); - // Request id bumped on every prop change. The SDK can call - // resetFigure asynchronously when server-side figure data lands; - // if props change in quick succession the older callback could - // otherwise overwrite the newer image. The closure captures the - // id and bails when stale. const requestIdRef = useRef(0); const getClassNames = useMemo(() => { - const newClassNames: string[] = [ 'avatar-image relative w-[90px] h-[130px] bg-no-repeat left-[-2px] pointer-events-none' ]; + let newClassNames: string[]; + + if(fit) + { + newClassNames = [ 'avatar-image absolute inset-0 pointer-events-none' ]; + } + else if(headOnly) + { + newClassNames = [ 'avatar-image absolute inset-0 bg-no-repeat pointer-events-none' ]; + } + else + { + newClassNames = [ 'avatar-image relative w-[90px] h-[130px] bg-no-repeat left-[-2px] pointer-events-none' ]; + } if(classNames.length) newClassNames.push(...classNames); return newClassNames; - }, [ classNames ]); + }, [ classNames, headOnly, fit ]); const getStyle = useMemo(() => { let newStyle: CSSProperties = {}; - if(avatarUrl && avatarUrl.length) newStyle.backgroundImage = `url('${ avatarUrl }')`; + if(!fit && avatarUrl && avatarUrl.length) newStyle.backgroundImage = `url('${ avatarUrl }')`; + + if(headOnly && !fit) + { + newStyle.backgroundSize = '130px auto'; + newStyle.backgroundPosition = '51% 40%'; + newStyle.imageRendering = 'pixelated'; + } if(scale !== 1) { @@ -52,7 +68,7 @@ export const LayoutAvatarImageView: FC = props => if(Object.keys(style).length) newStyle = { ...newStyle, ...style }; return newStyle; - }, [ avatarUrl, scale, style ]); + }, [ avatarUrl, scale, style, headOnly, fit ]); useEffect(() => { @@ -116,5 +132,17 @@ export const LayoutAvatarImageView: FC = props => }; }, []); - return ; + return ( + + { fit && avatarUrl && avatarUrl.length > 0 && ( + + ) } + + ); }; diff --git a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx index 936b8dc..6fa45ba 100644 --- a/src/components/catalog/views/page/common/CatalogGridOfferView.tsx +++ b/src/components/catalog/views/page/common/CatalogGridOfferView.tsx @@ -76,7 +76,7 @@ export const CatalogGridOfferView: FC = props => { iconUrl && !(offer.product.productType === ProductTypeEnum.ROBOT) &&
} { (offer.product.productType === ProductTypeEnum.ROBOT) && - } + }
diff --git a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx index dd55268..3490e60 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutDefaultView.tsx @@ -88,7 +88,6 @@ export const CatalogLayoutDefaultView: FC = props =>
} - { /* Welcome/description card */ } { !currentOffer &&
{ !!page.localization.getImage(1) && @@ -96,11 +95,10 @@ export const CatalogLayoutDefaultView: FC = props =>
} - { /* Item grid */ }
{ GetConfigurationValue('catalog.headers') && } - +
); diff --git a/src/components/inventory/views/bot/InventoryBotItemView.tsx b/src/components/inventory/views/bot/InventoryBotItemView.tsx index 9a084cf..4a48485 100644 --- a/src/components/inventory/views/bot/InventoryBotItemView.tsx +++ b/src/components/inventory/views/bot/InventoryBotItemView.tsx @@ -38,8 +38,8 @@ export const InventoryBotItemView: FC - + + { children } ); diff --git a/src/components/inventory/views/bot/InventoryBotView.tsx b/src/components/inventory/views/bot/InventoryBotView.tsx index 87b8adf..2882ea0 100644 --- a/src/components/inventory/views/bot/InventoryBotView.tsx +++ b/src/components/inventory/views/bot/InventoryBotView.tsx @@ -68,7 +68,7 @@ export const InventoryBotView: FC<{
- columnCount={ 6 } + columnCount={ 4 } itemRender={ item => } items={ botItems } />
From 00fbdc6f6dfc95751872e50e207546a7000852c4 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 27 May 2026 15:37:09 +0200 Subject: [PATCH 11/86] =?UTF-8?q?=F0=9F=86=99=20Small=20update=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/toolbar/ToolbarView.tsx | 88 +++++++++++++------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 8bec8e3..6bcc9a3 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -216,14 +216,6 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> - - CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> - - - CreateLinkEvent('inventory/toggle') } className="tb-icon" /> - { (getFullCount > 0) && - } - { isMeExpanded && @@ -237,7 +229,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } @@ -245,11 +237,19 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => setMeExpanded(value => !value); event.stopPropagation(); } }> - + { (getTotalUnseen > 0) && } + + CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> + + + CreateLinkEvent('inventory/toggle') } className="tb-icon" /> + { (getFullCount > 0) && + } + { (isInRoom && showToolbarButton) && @@ -268,14 +268,14 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (openTicketsCount > 0) && } } - { isMod && - - CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> - } { (isHk && hkEnabled) && CreateLinkEvent('housekeeping/toggle') } className="tb-icon" /> } + { isMod && + + CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> + } = props => CreateLinkEvent('catalog/toggle/normal') } className="tb-icon" /> + + + { isMeExpanded && + + + } + + + { + setMeExpanded(value => !value); + event.stopPropagation(); + } }> + + + { (getTotalUnseen > 0) && + } + CreateLinkEvent('catalog/toggle/builder') } className="tb-icon" /> @@ -333,32 +359,6 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } - - - { isMeExpanded && - - - } - - - { - setMeExpanded(value => !value); - event.stopPropagation(); - } }> - - - { (getTotalUnseen > 0) && - } - @@ -380,14 +380,14 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (openTicketsCount > 0) && } } - { isMod && - - CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> - } { (isHk && hkEnabled) && CreateLinkEvent('housekeeping/toggle') } className="tb-icon" /> } + { isMod && + + CreateLinkEvent('furni-editor/toggle') } className="tb-icon" /> + } CreateLinkEvent('friends/toggle') } className="tb-icon" /> { (requests.length > 0) && From 8ab0021af6cae5363262127bd9ee492ea5acec1c Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 18:44:24 +0200 Subject: [PATCH 12/86] feat(navigator): wired-tools-style hook split (Store + 3 filters) Splits the 492-line useNavigator god-hook into a useBetween-backed useNavigatorStore closure plus three flat-shape filters (useNavigatorData, useNavigatorUiState, useNavigatorActions), mirroring the wired-tools layout. sendSearch + reloadCurrentSearch are extracted as named actions out of NavigatorView locals. Door-mode handling is removed from this store and lives in useDoorState (committed previously) - see GetGuestRoomResultEvent and GenericErrorEvent dual-subscription with mutually exclusive filters. The simpleAlert dependency is lifted out of the useBetween scope via a module-level _simpleAlert ref + _injectSimpleAlert() to avoid nested useBetween calls that corrupt use-between's module-level dispatcher state. The ref is null in tests (no events fire during smoke tests) and is populated in production by the navigator consumer before any alert is needed. The barrel index.ts no longer re-exports useNavigator. The 13 consumers will fail typecheck until the next commit migrates them; the hook files themselves are clean. Smoke test covers filter shapes. INTENTIONAL INTERMEDIATE-BROKEN COMMIT: yarn typecheck is RED at this SHA on the 13 consumer files. The next commit (consumer migration sweep) brings it back to green. --- src/hooks/navigator/index.ts | 8 +- src/hooks/navigator/useNavigatorActions.ts | 8 + src/hooks/navigator/useNavigatorData.ts | 17 + .../navigator/useNavigatorStore.test.tsx | 33 ++ src/hooks/navigator/useNavigatorStore.ts | 354 ++++++++++++++++++ src/hooks/navigator/useNavigatorUiState.ts | 18 + src/nitro-renderer.mock.ts | 46 +++ 7 files changed, 483 insertions(+), 1 deletion(-) create mode 100644 src/hooks/navigator/useNavigatorActions.ts create mode 100644 src/hooks/navigator/useNavigatorData.ts create mode 100644 src/hooks/navigator/useNavigatorStore.test.tsx create mode 100644 src/hooks/navigator/useNavigatorStore.ts create mode 100644 src/hooks/navigator/useNavigatorUiState.ts diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index 1f6f053..c621851 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1 +1,7 @@ -export * from './useNavigator'; +export { useNavigatorActions } from './useNavigatorActions'; +export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorUiState } from './useNavigatorUiState'; +export { useNavigatorUiStore } from './navigatorUiStore'; +export { useDoorState } from '../rooms/widgets/useDoorState'; +export type { DoorStateSnapshot } from '../rooms/widgets/useDoorState'; +export type { NavigatorUiActions, NavigatorUiState } from './navigatorUiStore'; diff --git a/src/hooks/navigator/useNavigatorActions.ts b/src/hooks/navigator/useNavigatorActions.ts new file mode 100644 index 0000000..6a88e43 --- /dev/null +++ b/src/hooks/navigator/useNavigatorActions.ts @@ -0,0 +1,8 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorActions = () => +{ + const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); + return { sendSearch, reloadCurrentSearch }; +}; diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts new file mode 100644 index 0000000..aeb05b7 --- /dev/null +++ b/src/hooks/navigator/useNavigatorData.ts @@ -0,0 +1,17 @@ +import { useBetween } from 'use-between'; +import { useNavigatorStore } from './useNavigatorStore'; + +export const useNavigatorData = () => +{ + const { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + } = useBetween(useNavigatorStore); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData + }; +}; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx new file mode 100644 index 0000000..a9040f4 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -0,0 +1,33 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; + +describe('navigator filter shapes (smoke)', () => +{ + it('useNavigatorData returns the documented keys', () => + { + const { result } = renderHook(() => useNavigatorData()); + expect(Object.keys(result.current).sort()).toEqual([ + 'categories', 'eventCategories', 'favouriteRoomIds', + 'navigatorData', 'navigatorSearches', + 'searchResult', 'topLevelContext', 'topLevelContexts' + ].sort()); + }); + + it('useNavigatorUiState returns the 9 documented flags', () => + { + const { result } = renderHook(() => useNavigatorUiState()); + expect(Object.keys(result.current).sort()).toEqual([ + 'isCreatorOpen', 'isLoading', 'isOpenSavesSearches', + 'isReady', 'isRoomInfoOpen', 'isRoomLinkOpen', 'isVisible', + 'needsInit', 'needsSearch' + ].sort()); + }); + + it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => + { + const { result } = renderHook(() => useNavigatorActions()); + expect(typeof result.current.sendSearch).toBe('function'); + expect(typeof result.current.reloadCurrentSearch).toBe('function'); + }); +}); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts new file mode 100644 index 0000000..d577413 --- /dev/null +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -0,0 +1,354 @@ +import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, + FavouriteChangedEvent, FavouritesEvent, FlatCreatedEvent, + FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, + GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, + GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, + HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, + NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, + NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, + NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, + NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, + RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, + UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useRef, useState } from 'react'; +import { CreateRoomSession, GetConfigurationValue, INavigatorData, + LocalizeText, NotificationAlertType, SendMessageComposer, + TryVisitRoom, VisitDesktop } from '../../api'; +import { useMessageEvent, useNitroEvent } from '../events'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +// Module-level reference to simpleAlert, injected by useNavigatorActions +// (which runs in a real React dispatcher context, outside useBetween). +// Avoids nested useBetween calls that corrupt use-between's module-level state. +type SimpleAlertFn = (message: string, type?: string, clickUrl?: string, clickUrlText?: string, title?: string, imageUrl?: string) => void; +let _simpleAlert: SimpleAlertFn | null = null; +export const _injectSimpleAlert = (fn: SimpleAlertFn | null) => { _simpleAlert = fn; }; + +export const useNavigatorStore = () => +{ + const [ categories, setCategories ] = useState(null); + const [ eventCategories, setEventCategories ] = useState(null); + const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); + const [ topLevelContext, setTopLevelContext ] = useState(null); + const [ topLevelContexts, setTopLevelContexts ] = useState(null); + const [ searchResult, setSearchResult ] = useState(null); + const [ navigatorSearches, setNavigatorSearches ] = useState(null); + const [ navigatorData, setNavigatorData ] = useState({ + settingsReceived: false, + homeRoomId: 0, + enteredGuestRoom: null, + currentRoomOwner: false, + currentRoomId: 0, + currentRoomIsStaffPick: false, + createdFlatId: 0, + avatarId: 0, + roomPicker: false, + eventMod: false, + currentRoomRating: 0, + canRate: true + }); + + // Refs let handlers stay [] deps without losing access to fresh state. + const topLevelContextsRef = useRef(topLevelContexts); + topLevelContextsRef.current = topLevelContexts; + const topLevelContextRef = useRef(topLevelContext); + topLevelContextRef.current = topLevelContext; + const searchResultRef = useRef(searchResult); + searchResultRef.current = searchResult; + + const simpleAlert = _simpleAlert; + + const sendSearch = useCallback((searchValue: string, contextCode: string) => + { + useNavigatorUiStore.getState().closeCreator(); + SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); + useNavigatorUiStore.getState().setLoading(true); + }, []); + + const reloadCurrentSearch = useCallback(() => + { + if(!useNavigatorUiStore.getState().isReady) + { + useNavigatorUiStore.getState().requestSearch(); + return; + } + const sr = searchResultRef.current; + if(sr) + { + sendSearch(sr.data, sr.code); + return; + } + const ctx = topLevelContextRef.current; + if(!ctx) return; + sendSearch('', ctx.code); + }, [ sendSearch ]); + + useMessageEvent(FavouritesEvent, useCallback(event => + { + const parser = event.getParser(); + const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); + setFavouriteRoomIds(favoriteIds); + }, [])); + + useMessageEvent(FavouriteChangedEvent, useCallback(event => + { + const parser = event.getParser(); + const roomId = Number(parser.flatId); + const added = !!parser.added; + setFavouriteRoomIds(prev => + { + const ids = (prev || []).map((x: any) => Number(x)); + if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ]; + return ids.filter(id => id !== roomId); + }); + }, [])); + + useMessageEvent(RoomSettingsUpdatedEvent, useCallback(event => + { + const parser = event.getParser(); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); + }, [])); + + useMessageEvent(CanCreateRoomEventEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.canCreate) return; + simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title')); + }, [ simpleAlert ])); + + useMessageEvent(UserInfoEvent, useCallback(event => + { + SendMessageComposer(new GetUserFlatCatsMessageComposer()); + SendMessageComposer(new GetUserEventCatsMessageComposer()); + }, [])); + + useMessageEvent(UserPermissionsEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + eventMod: parser.securityLevel >= SecurityLevel.MODERATOR, + roomPicker: parser.securityLevel >= SecurityLevel.COMMUNITY + })); + }, [])); + + useMessageEvent(RoomForwardEvent, useCallback(event => + { + const parser = event.getParser(); + TryVisitRoom(parser.roomId); + }, [])); + + useMessageEvent(RoomEntryInfoMessageEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: null, + currentRoomOwner: parser.isOwner, + currentRoomId: parser.roomId + })); + SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false)); + if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]); + }, [])); + + useMessageEvent(GetGuestRoomResultEvent, useCallback(event => + { + const parser = event.getParser(); + if(parser.roomEnter) + { + setNavigatorData(prev => + { + const next = { ...prev }; + next.enteredGuestRoom = parser.data; + next.currentRoomIsStaffPick = parser.staffPick; + const isCreated = next.createdFlatId === parser.data.roomId; + if(!isCreated && parser.data.displayRoomEntryAd) + { + if(GetConfigurationValue('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd(); + } + next.createdFlatId = 0; + return next; + }); + return; + } + if(parser.roomForward) + { + // Door-mode branches (DOORBELL_STATE / PASSWORD_STATE) are handled by useDoorState — skip them here. + const isOwner = parser.data.ownerName === GetSessionDataManager().userName; + if(!isOwner && !parser.isGroupMember) + { + if(parser.data.doorMode === RoomDataParser.DOORBELL_STATE) return; + if(parser.data.doorMode === RoomDataParser.PASSWORD_STATE) return; + } + if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return; + CreateRoomSession(parser.data.roomId); + return; + } + setNavigatorData(prev => ({ + ...prev, + enteredGuestRoom: parser.data, + currentRoomIsStaffPick: parser.staffPick + })); + }, [])); + + useMessageEvent(RoomScoreEvent, useCallback(event => + { + const parser = event.getParser(); + setNavigatorData(prev => ({ + ...prev, + currentRoomRating: parser.totalLikes, + canRate: parser.canLike + })); + }, [])); + + useMessageEvent(GenericErrorEvent, useCallback(event => + { + const parser = event.getParser(); + // -100002 (wrong password) is handled by useDoorState — skip it here. + switch(parser.errorCode) + { + case 4009: + simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4010: + simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4011: + simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + case 4013: + simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); + return; + } + }, [ simpleAlert ])); + + useMessageEvent(NavigatorMetadataEvent, useCallback(event => + { + const parser = event.getParser(); + setTopLevelContexts(parser.topLevelContexts); + setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); + }, [])); + + useMessageEvent(NavigatorSearchEvent, useCallback(event => + { + const parser = event.getParser(); + const contexts = topLevelContextsRef.current; + setTopLevelContext(prev => + { + let next = prev; + if(!next) next = (contexts && contexts.length && contexts[0]) || null; + if(!next) return null; + if(contexts && contexts.length) + { + for(const ctx of contexts) + { + if(ctx.code === parser.result.code) next = ctx; + } + } + return next; + }); + setSearchResult(parser.result); + useNavigatorUiStore.getState().setLoading(false); + }, [])); + + useMessageEvent(UserFlatCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setCategories(parser.categories); + }, [])); + + useMessageEvent(UserEventCatsEvent, useCallback(event => + { + const parser = event.getParser(); + setEventCategories(parser.categories); + }, [])); + + useMessageEvent(FlatCreatedEvent, useCallback(event => + { + const parser = event.getParser(); + CreateRoomSession(parser.roomId); + }, [])); + + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, useCallback(() => + { + setNavigatorData(prev => ({ ...prev, settingsReceived: false })); + }, [])); + + useMessageEvent(NavigatorHomeRoomEvent, useCallback(event => + { + const parser = event.getParser(); + let prevSettingsReceived = false; + setNavigatorData(prev => + { + prevSettingsReceived = prev.settingsReceived; + return { ...prev, homeRoomId: parser.homeRoomId, settingsReceived: true }; + }); + if(prevSettingsReceived) return; + if(GetRoomSessionManager().viewerSession) return; + + let forwardType = -1; + let forwardId = -1; + if((GetConfigurationValue('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) + { + forwardType = 0; + SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); + } + if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) + { + forwardType = parseInt(GetConfigurationValue('forward.type')); + forwardId = parseInt(GetConfigurationValue('forward.id')); + } + if(forwardType === 2) + { + TryVisitRoom(forwardId); + } + else if((forwardType === -1) && (parser.roomIdToEnter > 0)) + { + CreateLinkEvent('navigator/close'); + CreateRoomSession(parser.roomIdToEnter !== parser.homeRoomId ? parser.roomIdToEnter : parser.homeRoomId); + } + }, [])); + + useMessageEvent(RoomEnterErrorEvent, useCallback(event => + { + const parser = event.getParser(); + switch(parser.reason) + { + case CantConnectMessageParser.REASON_FULL: + simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title')); + break; + case CantConnectMessageParser.REASON_QUEUE_ERROR: + simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + case CantConnectMessageParser.REASON_BANNED: + simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title')); + break; + default: + simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); + break; + } + if(GetRoomSessionManager().isReconnecting) return; + VisitDesktop(); + }, [ simpleAlert ])); + + useMessageEvent(NavigatorOpenRoomCreatorEvent, useCallback(_event => + { + CreateLinkEvent('navigator/show'); + }, [])); + + useMessageEvent(NavigatorSearchesEvent, useCallback(event => + { + const parser = event.getParser(); + if(!parser) return; + setNavigatorSearches(parser.searches); + }, [])); + + return { + categories, eventCategories, favouriteRoomIds, + topLevelContext, topLevelContexts, + searchResult, navigatorSearches, navigatorData, + sendSearch, reloadCurrentSearch + }; +}; diff --git a/src/hooks/navigator/useNavigatorUiState.ts b/src/hooks/navigator/useNavigatorUiState.ts new file mode 100644 index 0000000..3c0868a --- /dev/null +++ b/src/hooks/navigator/useNavigatorUiState.ts @@ -0,0 +1,18 @@ +import { useNavigatorUiStore } from './navigatorUiStore'; + +export const useNavigatorUiState = () => +{ + const isVisible = useNavigatorUiStore(s => s.isVisible); + const isReady = useNavigatorUiStore(s => s.isReady); + const isCreatorOpen = useNavigatorUiStore(s => s.isCreatorOpen); + const isRoomInfoOpen = useNavigatorUiStore(s => s.isRoomInfoOpen); + const isRoomLinkOpen = useNavigatorUiStore(s => s.isRoomLinkOpen); + const isOpenSavesSearches = useNavigatorUiStore(s => s.isOpenSavesSearches); + const isLoading = useNavigatorUiStore(s => s.isLoading); + const needsInit = useNavigatorUiStore(s => s.needsInit); + const needsSearch = useNavigatorUiStore(s => s.needsSearch); + return { + isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, + isOpenSavesSearches, isLoading, needsInit, needsSearch + }; +}; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index dd5b642..54bf1e6 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -251,6 +251,29 @@ export class FlatAccessDeniedMessageEvent extends MessageEvent {} export class GenericErrorEvent extends MessageEvent {} export class GetGuestRoomResultEvent extends MessageEvent {} +// --------------------------------------------------------------------------- +// Navigator event classes — MessageEvent subclasses needed by useNavigatorStore +// --------------------------------------------------------------------------- + +export class CanCreateRoomEventEvent extends MessageEvent {} +export class FavouriteChangedEvent extends MessageEvent {} +export class FavouritesEvent extends MessageEvent {} +export class FlatCreatedEvent extends MessageEvent {} +export class NavigatorHomeRoomEvent extends MessageEvent {} +export class NavigatorMetadataEvent extends MessageEvent {} +export class NavigatorOpenRoomCreatorEvent extends MessageEvent {} +export class NavigatorSearchesEvent extends MessageEvent {} +export class NavigatorSearchEvent extends MessageEvent {} +export class RoomEnterErrorEvent extends MessageEvent {} +export class RoomEntryInfoMessageEvent extends MessageEvent {} +export class RoomForwardEvent extends MessageEvent {} +export class RoomScoreEvent extends MessageEvent {} +export class RoomSettingsUpdatedEvent extends MessageEvent {} +export class UserEventCatsEvent extends MessageEvent {} +export class UserFlatCatsEvent extends MessageEvent {} +export class UserInfoEvent extends MessageEvent {} +export class UserPermissionsEvent extends MessageEvent {} + export class RoomEngineObjectEvent extends StubClass {} export class CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} @@ -268,6 +291,25 @@ export class RoomDataParser export class RoomModerationSettings extends StubClass {} export class StringDataType extends StubClass {} + +// Navigator data/parser stubs +export class NavigatorCategoryDataParser extends StubClass {} +export class NavigatorEventCategoryDataParser extends StubClass {} +export class NavigatorSavedSearch extends StubClass {} +export class NavigatorSearchResultSet extends StubClass {} +export class NavigatorTopLevelContext extends StubClass {} +export class CantConnectMessageParser extends StubClass +{ + static readonly REASON_FULL = 1; + static readonly REASON_QUEUE_ERROR = 2; + static readonly REASON_BANNED = 3; +} + +export class LegacyExternalInterface +{ + static readonly available = false; + static call(..._args: unknown[]): void {} +} export class SellablePetPaletteData extends StubClass {} export class PetFigureData extends StubClass {} export class PetData extends StubClass {} @@ -289,6 +331,10 @@ export class HabboWebTools extends StubClass {} // codebase ("did the SUT call SendMessageComposer(new FooComposer(args))"). export class AddFavouriteRoomMessageComposer extends StubClass {} export class DeleteFavouriteRoomMessageComposer extends StubClass {} +export class FollowFriendMessageComposer extends StubClass {} +export class GetUserEventCatsMessageComposer extends StubClass {} +export class GetUserFlatCatsMessageComposer extends StubClass {} +export class NavigatorSearchComposer extends StubClass {} export class DesktopViewComposer extends StubClass {} export class FurniturePlacePaintComposer extends StubClass {} export class GetGuestRoomMessageComposer extends StubClass {} From 3c10ccdaee49fa32b33bf66b5c2e376b0f660382 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 18:50:40 +0200 Subject: [PATCH 13/86] fix(navigator): restore useNotification() inside useNavigatorStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit 8ab0021a introduced an unjustified deviation: it removed the useNotification() call from inside useNavigatorStore and replaced it with a module-level _simpleAlert ref + _injectSimpleAlert() exported function, on the theory that nested useBetween calls corrupt use-between's state. That diagnosis is wrong. Production proof: - useCatalog.ts:56 calls useNotification() inside useCatalogStore - useWiredToolsStore.ts:131 calls useNotification() inside its store - The original useNavigator.ts:32 calls useNotification() inside its state closure All three have been in production for ages without issue. Nested useBetween calls work fine. The smoke-test failure that prompted the workaround was a mock issue, not a real bug. Reverting to the standard pattern — useNotification() direct inside the useBetween store closure. Production alerts work again immediately without requiring an explicit injection call from consumers. Mock additions (src/nitro-renderer.mock.ts): - Added 23 notification MessageEvent subclasses (AchievementNotification- MessageEvent, ActivityPoint..., BadgeReceived, ClubGiftNotification, ClubGiftSelected, ConnectionError, HabboBroadcast, HotelClosedAndOpens, HotelClosesAndWillOpenAt, HotelWillCloseInMinutes, InfoFeedEnable, MaintenanceStatus, ModeratorCaution, ModeratorMessage, MOTD, NotificationDialog, PetLevel, PetReceived, RespectReceived, RoomEnter, SimpleAlert, UserBanned, WiredRewardResult) so useNotificationStore can register its listeners without throwing. - Added RoomEnterEffect stub (isRunning: false, totalRunningTime: 0). - Added WiredRewardResultMessageEvent static constants. --- src/hooks/navigator/useNavigatorStore.ts | 10 ++---- src/nitro-renderer.mock.ts | 42 ++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts index d577413..e3165d9 100644 --- a/src/hooks/navigator/useNavigatorStore.ts +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -17,15 +17,9 @@ import { CreateRoomSession, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api'; import { useMessageEvent, useNitroEvent } from '../events'; +import { useNotification } from '../notification'; import { useNavigatorUiStore } from './navigatorUiStore'; -// Module-level reference to simpleAlert, injected by useNavigatorActions -// (which runs in a real React dispatcher context, outside useBetween). -// Avoids nested useBetween calls that corrupt use-between's module-level state. -type SimpleAlertFn = (message: string, type?: string, clickUrl?: string, clickUrlText?: string, title?: string, imageUrl?: string) => void; -let _simpleAlert: SimpleAlertFn | null = null; -export const _injectSimpleAlert = (fn: SimpleAlertFn | null) => { _simpleAlert = fn; }; - export const useNavigatorStore = () => { const [ categories, setCategories ] = useState(null); @@ -58,7 +52,7 @@ export const useNavigatorStore = () => const searchResultRef = useRef(searchResult); searchResultRef.current = searchResult; - const simpleAlert = _simpleAlert; + const { simpleAlert = null } = useNotification(); const sendSearch = useCallback((searchValue: string, contextCode: string) => { diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 54bf1e6..28abd00 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -274,6 +274,48 @@ export class UserFlatCatsEvent extends MessageEvent {} export class UserInfoEvent extends MessageEvent {} export class UserPermissionsEvent extends MessageEvent {} +// --------------------------------------------------------------------------- +// Notification event classes — MessageEvent subclasses needed by +// useNotificationStore (called via useNotification() inside useNavigatorStore). +// The real renderer classes take a `callBack` constructor arg; the pattern +// here is the same as the Navigator event stubs above. +// --------------------------------------------------------------------------- + +export class AchievementNotificationMessageEvent extends MessageEvent {} +export class ActivityPointNotificationMessageEvent extends MessageEvent {} +export class BadgeReceivedEvent extends MessageEvent {} +export class ClubGiftNotificationEvent extends MessageEvent {} +export class ClubGiftSelectedEvent extends MessageEvent {} +export class ConnectionErrorEvent extends MessageEvent {} +export class HabboBroadcastMessageEvent extends MessageEvent {} +export class HotelClosedAndOpensEvent extends MessageEvent {} +export class HotelClosesAndWillOpenAtEvent extends MessageEvent {} +export class HotelWillCloseInMinutesEvent extends MessageEvent {} +export class InfoFeedEnableMessageEvent extends MessageEvent {} +export class MaintenanceStatusMessageEvent extends MessageEvent {} +export class ModeratorCautionEvent extends MessageEvent {} +export class ModeratorMessageEvent extends MessageEvent {} +export class MOTDNotificationEvent extends MessageEvent {} +export class NotificationDialogMessageEvent extends MessageEvent {} +export class PetLevelNotificationEvent extends MessageEvent {} +export class PetReceivedMessageEvent extends MessageEvent {} +export class RespectReceivedEvent extends MessageEvent {} +export class RoomEnterEvent extends MessageEvent {} +export class SimpleAlertMessageEvent extends MessageEvent {} +export class UserBannedMessageEvent extends MessageEvent {} +export class WiredRewardResultMessageEvent extends MessageEvent +{ + static readonly PRODUCT_DONATED_CODE = 7; + static readonly BADGE_DONATED_CODE = 8; +} + +// RoomEnterEffect — used by useNotificationStore to check if the room-enter +// animation is still running before showing the mod disclaimer bubble. +export const RoomEnterEffect = { + isRunning: () => false, + totalRunningTime: 0 +}; + export class RoomEngineObjectEvent extends StubClass {} export class CreateLinkEvent extends StubClass {} export class EventDispatcher extends StubClass {} From 1d580e6d2497e3ebb0ece7748a14204d3a1e629b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 18:58:03 +0200 Subject: [PATCH 14/86] refactor(navigator): migrate all 13 consumers off useNavigator god-hook Mechanical swap to the new filter hooks landed in the previous commits: - NavigatorDoorStateView -> useDoorState (snapshot/setSnapshot/reset) - NavigatorView -> useNavigatorData + useNavigatorUiState + useNavigatorActions + direct useNavigatorUiStore.getState() in handlers (linkTracker collapsed to a dispatch table; 9 useState gone) - NavigatorSearchView -> useNavigatorData + useNavigatorActions (sendSearch prop drilling removed) - NavigatorSearchResultItemView -> useDoorState (setSnapshot aliased as setDoorData; call sites unchanged - DoorStateSnapshot is compatible) - 9 bulk consumers (one-line import swap) -> useNavigatorData Zero behavioural change intended. yarn typecheck + yarn test --run + yarn lint:hooks all clean on this commit. --- .../page/layout/CatalogLayoutRoomAdsView.tsx | 4 +- src/components/navigator/NavigatorView.tsx | 220 +++++------------- .../views/NavigatorDoorStateView.tsx | 64 ++--- .../views/NavigatorRoomCreatorView.tsx | 4 +- .../navigator/views/NavigatorRoomInfoView.tsx | 4 +- .../navigator/views/NavigatorRoomLinkView.tsx | 4 +- .../NavigatorRoomSettingsBasicTabView.tsx | 4 +- .../NavigatorSearchResultItemInfoView.tsx | 4 +- .../search/NavigatorSearchResultItemView.tsx | 4 +- .../search/NavigatorSearchResultView.tsx | 4 +- .../views/search/NavigatorSearchView.tsx | 10 +- .../RoomFilterWordsWidgetView.tsx | 4 +- .../room-tools/RoomToolsWidgetView.tsx | 4 +- 13 files changed, 109 insertions(+), 225 deletions(-) diff --git a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx index 8016c40..ade3125 100644 --- a/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx +++ b/src/components/catalog/views/page/layout/CatalogLayoutRoomAdsView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { LocalizeText, SendMessageComposer } from '../../../../../api'; import { useNitroQuery } from '../../../../../api/nitro-query'; import { Button, Column, Text } from '../../../../../common'; -import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks'; +import { useCatalogUiState, useNavigatorData, useRoomPromote } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; import { CatalogLayoutProps } from './CatalogLayout.types'; @@ -17,7 +17,7 @@ export const CatalogLayoutRoomAdsView: FC = props => const [ roomId, setRoomId ] = useState(-1); const [ extended, setExtended ] = useState(false); const [ categoryId, setCategoryId ] = useState(1); - const { categories = null } = useNavigator(); + const { categories } = useNavigatorData(); const { setIsVisible = null } = useCatalogUiState(); const { promoteInformation, isExtended, setIsExtended } = useRoomPromote(); diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index ec201b5..3ff6be6 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -1,6 +1,6 @@ import { NitroCard } from '@layout/NitroCard'; -import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, NavigatorSearchComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { AddLinkEventTracker, ConvertGlobalRoomIdMessageComposer, FindNewFriendsMessageComposer, HabboWebTools, ILinkEventTracker, LegacyExternalInterface, NavigatorInitComposer, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useRef } from 'react'; import { FaPlus } from 'react-icons/fa'; import savesSearchIcon from '../../assets/images/navigator/saves-search/search_save.png'; import createRoomImg from '../../assets/images/navigator/create_room.png'; @@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; import { Flex, Text } from '../../common'; -import { useNavigator, useNitroEvent } from '../../hooks'; +import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; @@ -20,184 +20,106 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView'; export const NavigatorView: FC<{}> = props => { - const [ isVisible, setIsVisible ] = useState(false); - const [ isReady, setIsReady ] = useState(false); - const [ isCreatorOpen, setCreatorOpen ] = useState(false); - const [ isRoomInfoOpen, setRoomInfoOpen ] = useState(false); - const [ isRoomLinkOpen, setRoomLinkOpen ] = useState(false); - const [ isOpenSavesSearches, setIsOpenSavesSearches ] = useState(false); - const [ isLoading, setIsLoading ] = useState(false); - const [ needsInit, setNeedsInit ] = useState(true); - const [ needsSearch, setNeedsSearch ] = useState(false); - const { searchResult = null, topLevelContext = null, topLevelContexts = null, navigatorData = null, navigatorSearches = null } = useNavigator(); + const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); + const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); const pendingSearch = useRef<{ value: string, code: string }>(null); const elementRef = useRef(null); useNitroEvent(RoomSessionEvent.CREATED, event => { - setIsVisible(false); - setCreatorOpen(false); + useNavigatorUiStore.getState().hide(); + useNavigatorUiStore.getState().closeCreator(); }); - const sendSearch = useCallback((searchValue: string, contextCode: string) => - { - setCreatorOpen(false); - - SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); - - setIsLoading(true); - }, []); - - const reloadCurrentSearch = useCallback(() => - { - if(!isReady) - { - setNeedsSearch(true); - - return; - } - - if(pendingSearch.current) - { - sendSearch(pendingSearch.current.value, pendingSearch.current.code); - - pendingSearch.current = null; - - return; - } - - if(searchResult) - { - sendSearch(searchResult.data, searchResult.code); - - return; - } - - if(!topLevelContext) return; - - sendSearch('', topLevelContext.code); - }, [ isReady, searchResult, topLevelContext, sendSearch ]); - useEffect(() => { const linkTracker: ILinkEventTracker = { linkReceived: (url: string) => { const parts = url.split('/'); - if(parts.length < 2) return; - + const store = useNavigatorUiStore.getState(); switch(parts[1]) { - case 'show': { - setIsVisible(true); - setNeedsSearch(true); + case 'show': + store.show(); return; - } case 'hide': - setIsVisible(false); + store.hide(); return; - case 'toggle': { - if(isVisible) - { - setIsVisible(false); - - return; - } - - setIsVisible(true); - setNeedsSearch(true); + case 'toggle': + store.toggle(); return; - } case 'toggle-room-info': - setRoomInfoOpen(value => !value); + store.toggleRoomInfo(); return; case 'toggle-room-link': - setRoomLinkOpen(value => !value); + store.toggleRoomLink(); return; case 'goto': if(parts.length <= 2) return; - - switch(parts[2]) + if(parts[2] === 'home') { - case 'home': - if(navigatorData.homeRoomId <= 0) return; - - TryVisitRoom(navigatorData.homeRoomId); - break; - default: { - const roomId = parseInt(parts[2]); - - TryVisitRoom(roomId); - } + if(navigatorData.homeRoomId <= 0) return; + TryVisitRoom(navigatorData.homeRoomId); + return; } + TryVisitRoom(parseInt(parts[2])); return; case 'create': - setIsVisible(true); - setCreatorOpen(true); + store.openCreator(); return; case 'search': - if(parts.length > 2) - { - const topLevelContextCode = parts[2]; - - let searchValue = ''; - - if(parts.length > 3) searchValue = parts[3]; - - pendingSearch.current = { value: searchValue, code: topLevelContextCode }; - - setIsVisible(true); - setNeedsSearch(true); - } + if(parts.length <= 2) return; + pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] }; + store.show(); return; } }, eventUrlPrefix: 'navigator/' }; - AddLinkEventTracker(linkTracker); - return () => RemoveLinkEventTracker(linkTracker); - }, [ isVisible, navigatorData ]); + }, [ navigatorData ]); useEffect(() => { if(!searchResult) return; - - setIsLoading(false); - - if(elementRef && elementRef.current) elementRef.current.scrollTop = 0; + if(elementRef.current) elementRef.current.scrollTop = 0; }, [ searchResult ]); useEffect(() => { if(!isVisible || !isReady || !needsSearch) return; - - reloadCurrentSearch(); - - setNeedsSearch(false); - }, [ isVisible, isReady, needsSearch, reloadCurrentSearch ]); + if(pendingSearch.current) + { + sendSearch(pendingSearch.current.value, pendingSearch.current.code); + pendingSearch.current = null; + } + else + { + reloadCurrentSearch(); + } + useNavigatorUiStore.getState().consumeSearchRequest(); + }, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]); useEffect(() => { if(isReady || !topLevelContext) return; - - setIsReady(true); + useNavigatorUiStore.getState().markReady(); }, [ isReady, topLevelContext ]); useEffect(() => { if(!isVisible || !needsInit) return; - SendMessageComposer(new NavigatorInitComposer()); - - setNeedsInit(false); + useNavigatorUiStore.getState().markInitDone(); }, [ isVisible, needsInit ]); useEffect(() => { - LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string, _arg_2: boolean = false, _arg_3: string = null) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k))); + LegacyExternalInterface.addCallback(HabboWebTools.OPENROOM, (k: string) => SendMessageComposer(new ConvertGlobalRoomIdMessageComposer(k))); }, []); return ( @@ -208,28 +130,24 @@ export const NavigatorView: FC<{}> = props => uniqueKey="navigator"> setIsVisible(false) } /> + onCloseClick={ () => useNavigatorUiStore.getState().hide() } /> setIsOpenSavesSearches(prev => !prev) }> + onClick={ () => useNavigatorUiStore.getState().toggleSavesSearches() }> - { topLevelContexts && (topLevelContexts.length > 0) && topLevelContexts.map((context, index) => - { - return ( - sendSearch('', context.code) }> - { LocalizeText(('navigator.toplevelview.' + context.code)) } - - ); - }) } + { topLevelContexts && topLevelContexts.length > 0 && topLevelContexts.map((context, index) => + sendSearch('', context.code) }> + { LocalizeText('navigator.toplevelview.' + context.code) } + ) } setCreatorOpen(true) }> + onClick={ () => useNavigatorUiStore.getState().openCreator() }> @@ -241,49 +159,37 @@ export const NavigatorView: FC<{}> = props =>
}
- +
- { (searchResult && searchResult.results.map((result, index) => )) } - { (searchResult && (!searchResult.results || (searchResult.results.length === 0))) && + { searchResult && searchResult.results.map((result, index) => ) } + { searchResult && (!searchResult.results || searchResult.results.length === 0) &&
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
}
- setCreatorOpen(true) } - > + onClick={ () => useNavigatorUiStore.getState().openCreator() }> { LocalizeText('navigator.createroom.create') } - { (searchResult?.code !== 'myworld_view' && searchResult?.code !== 'roomads_view') && - SendMessageComposer(new FindNewFriendsMessageComposer()) } - > + onClick={ () => SendMessageComposer(new FindNewFriendsMessageComposer()) }> { LocalizeText('navigator.random.room') } } { (searchResult?.code === 'myworld_view' || searchResult?.code === 'roomads_view') && - CreateLinkEvent('catalog/open/room_event') } - > + onClick={ () => CreateLinkEvent('catalog/open/room_event') }> { LocalizeText('navigator.promote.room') } @@ -295,8 +201,8 @@ export const NavigatorView: FC<{}> = props => } - { isRoomInfoOpen && setRoomInfoOpen(false) } /> } - { isRoomLinkOpen && setRoomLinkOpen(false) } /> } + { isRoomInfoOpen && useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> } + { isRoomLinkOpen && useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> } ); diff --git a/src/components/navigator/views/NavigatorDoorStateView.tsx b/src/components/navigator/views/NavigatorDoorStateView.tsx index 0dcaa45..709e1e7 100644 --- a/src/components/navigator/views/NavigatorDoorStateView.tsx +++ b/src/components/navigator/views/NavigatorDoorStateView.tsx @@ -1,88 +1,68 @@ import { FC, useEffect, useState } from 'react'; import { CreateRoomSession, DoorStateType, GoToDesktop, LocalizeText } from '../../../api'; import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common'; -import { useNavigator } from '../../../hooks'; +import { useDoorState } from '../../../hooks'; import { NitroInput } from '../../../layout'; const VISIBLE_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER, DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ]; const DOORBELL_STATES = [ DoorStateType.START_DOORBELL, DoorStateType.STATE_WAITING, DoorStateType.STATE_NO_ANSWER ]; -const PASSWORD_STATES = [ DoorStateType.START_PASSWORD, DoorStateType.STATE_WRONG_PASSWORD ]; export const NavigatorDoorStateView: FC<{}> = props => { const [ password, setPassword ] = useState(''); - const { doorData = null, setDoorData = null } = useNavigator(); + const { snapshot, setSnapshot, reset } = useDoorState(); const onClose = () => { - if(doorData && (doorData.state === DoorStateType.STATE_WAITING)) GoToDesktop(); - - setDoorData(null); + if(snapshot.state === DoorStateType.STATE_WAITING) GoToDesktop(); + reset(); }; const ring = () => { - if(!doorData || !doorData.roomInfo) return; - - CreateRoomSession(doorData.roomInfo.roomId); - - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_PENDING_SERVER; - - return newValue; - }); + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); }; const tryEntering = () => { - if(!doorData || !doorData.roomInfo) return; - - CreateRoomSession(doorData.roomInfo.roomId, password); - - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_PENDING_SERVER; - - return newValue; - }); + if(!snapshot.roomInfo) return; + CreateRoomSession(snapshot.roomInfo.roomId, password); + setSnapshot(prev => ({ ...prev, state: DoorStateType.STATE_PENDING_SERVER })); }; useEffect(() => { - if(!doorData || (doorData.state !== DoorStateType.STATE_NO_ANSWER)) return; - + if(snapshot.state !== DoorStateType.STATE_NO_ANSWER) return; GoToDesktop(); - }, [ doorData ]); + }, [ snapshot.state ]); - if(!doorData || (doorData.state === DoorStateType.NONE) || (VISIBLE_STATES.indexOf(doorData.state) === -1)) return null; + if(snapshot.state === DoorStateType.NONE) return null; + if(VISIBLE_STATES.indexOf(snapshot.state) === -1) return null; - const isDoorbell = (DOORBELL_STATES.indexOf(doorData.state) >= 0); + const isDoorbell = DOORBELL_STATES.indexOf(snapshot.state) >= 0; return (
- { doorData && doorData.roomInfo && doorData.roomInfo.roomName } - { (doorData.state === DoorStateType.START_DOORBELL) && + { snapshot.roomInfo && snapshot.roomInfo.roomName } + { snapshot.state === DoorStateType.START_DOORBELL && { LocalizeText('navigator.doorbell.info') } } - { (doorData.state === DoorStateType.STATE_WAITING) && + { snapshot.state === DoorStateType.STATE_WAITING && { LocalizeText('navigator.doorbell.waiting') } } - { (doorData.state === DoorStateType.STATE_NO_ANSWER) && + { snapshot.state === DoorStateType.STATE_NO_ANSWER && { LocalizeText('navigator.doorbell.no.answer') } } - { (doorData.state === DoorStateType.START_PASSWORD) && + { snapshot.state === DoorStateType.START_PASSWORD && { LocalizeText('navigator.password.info') } } - { (doorData.state === DoorStateType.STATE_WRONG_PASSWORD) && + { snapshot.state === DoorStateType.STATE_WRONG_PASSWORD && { LocalizeText('navigator.password.retryinfo') } }
{ isDoorbell &&
- { (doorData.state === DoorStateType.START_DOORBELL) && + { snapshot.state === DoorStateType.START_DOORBELL && } diff --git a/src/components/navigator/views/NavigatorRoomCreatorView.tsx b/src/components/navigator/views/NavigatorRoomCreatorView.tsx index af278c6..ad47bf0 100644 --- a/src/components/navigator/views/NavigatorRoomCreatorView.tsx +++ b/src/components/navigator/views/NavigatorRoomCreatorView.tsx @@ -3,7 +3,7 @@ import { CreateFlatMessageComposer, HabboClubLevelEnum } from '@nitrots/nitro-re import { FC, useEffect, useState } from 'react'; import { GetClubMemberLevel, GetConfigurationValue, IRoomModel, LocalizeText, SendMessageComposer } from '../../../api'; import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '../../../common'; -import { useNavigator } from '../../../hooks'; +import { useNavigatorData } from '../../../hooks'; import { NitroInput } from '../../../layout'; import { useRoomCreatorStore } from './navigatorRoomCreatorStore'; @@ -25,7 +25,7 @@ export const NavigatorRoomCreatorView: FC = () => }); const isCreating = useRoomCreatorStore(s => s.isCreating); const beginCreate = useRoomCreatorStore(s => s.beginCreate); - const { categories = null } = useNavigator(); + const { categories } = useNavigatorData(); const hcDisabled = GetConfigurationValue('hc.disabled', false); diff --git a/src/components/navigator/views/NavigatorRoomInfoView.tsx b/src/components/navigator/views/NavigatorRoomInfoView.tsx index 2073c15..ca5686c 100644 --- a/src/components/navigator/views/NavigatorRoomInfoView.tsx +++ b/src/components/navigator/views/NavigatorRoomInfoView.tsx @@ -4,7 +4,7 @@ import { FaLink, FaSignOutAlt } from 'react-icons/fa'; import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api'; import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common'; import { RoomWidgetThumbnailEvent } from '../../../events'; -import { useHasPermission, useHelp, useNavigator, useRoom } from '../../../hooks'; +import { useHasPermission, useHelp, useNavigatorData, useRoom } from '../../../hooks'; import { classNames } from '../../../layout'; export interface NavigatorRoomInfoViewProps { @@ -17,7 +17,7 @@ export const NavigatorRoomInfoView: FC = props => const [ isRoomPicked, setIsRoomPicked ] = useState(false); const [ isRoomMuted, setIsRoomMuted ] = useState(false); const { report = null } = useHelp(); - const { navigatorData = null, favouriteRoomIds = [] } = useNavigator(); + const { navigatorData, favouriteRoomIds } = useNavigatorData(); const { roomSession = null } = useRoom(); const canManageAnyRoom = useHasPermission('acc_anyroomowner'); const canStaffPick = useHasPermission('acc_staff_pick'); diff --git a/src/components/navigator/views/NavigatorRoomLinkView.tsx b/src/components/navigator/views/NavigatorRoomLinkView.tsx index 033507d..0d1a74f 100644 --- a/src/components/navigator/views/NavigatorRoomLinkView.tsx +++ b/src/components/navigator/views/NavigatorRoomLinkView.tsx @@ -1,7 +1,7 @@ import { FC } from 'react'; import { GetConfigurationValue, LocalizeText } from '../../../api'; import { LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common'; -import { useNavigator } from '../../../hooks'; +import { useNavigatorData } from '../../../hooks'; export class NavigatorRoomLinkViewProps { @@ -11,7 +11,7 @@ export class NavigatorRoomLinkViewProps export const NavigatorRoomLinkView: FC = props => { const { onCloseClick = null } = props; - const { navigatorData = null } = useNavigator(); + const { navigatorData } = useNavigatorData(); if(!navigatorData.enteredGuestRoom) return null; diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx index cacaa59..526767c 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { FaTimes } from 'react-icons/fa'; import { CreateLinkEvent, GetMaxVisitorsList, IRoomData, LocalizeText, SendMessageComposer } from '../../../../api'; import { Base, Column, Flex, Text } from '../../../../common'; -import { useMessageEvent, useNavigator, useNotification } from '../../../../hooks'; +import { useMessageEvent, useNavigatorData, useNotification } from '../../../../hooks'; const ROOM_NAME_MIN_LENGTH = 3; const ROOM_NAME_MAX_LENGTH = 60; @@ -27,7 +27,7 @@ export const NavigatorRoomSettingsBasicTabView: FC(''); const { showConfirm = null } = useNotification(); - const { categories = null } = useNavigator(); + const { categories } = useNavigatorData(); useMessageEvent(RoomSettingsSaveErrorEvent, event => { diff --git a/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx b/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx index 764ce1e..c60d21d 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx @@ -4,7 +4,7 @@ import React, { FC, useRef, useState } from 'react'; import { FaUser } from 'react-icons/fa'; import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api'; import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common'; -import { useHelp, useNavigator } from '../../../../hooks'; +import { useHelp, useNavigatorData } from '../../../../hooks'; import { classNames } from '../../../../layout'; interface NavigatorSearchResultItemInfoViewProps @@ -20,7 +20,7 @@ export const NavigatorSearchResultItemInfoView: FC(null); const [ internalVisible, setInternalVisible ] = useState(false); - const { navigatorData = null, favouriteRoomIds = [] } = useNavigator(); + const { navigatorData, favouriteRoomIds } = useNavigatorData(); const { report = null } = useHelp(); const isControlled = isVisible !== undefined; diff --git a/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx b/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx index 686d598..045b33c 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultItemView.tsx @@ -3,7 +3,7 @@ import React, { FC, MouseEvent, useEffect } from 'react'; import { FaUser } from 'react-icons/fa'; import { CreateRoomSession, DoorStateType, TryVisitRoom } from '../../../../api'; import { Column, Flex, LayoutBadgeImageView, LayoutGridItemProps, LayoutRoomThumbnailView, Text } from '../../../../common'; -import { useNavigator } from '../../../../hooks'; +import { useDoorState } from '../../../../hooks'; import { NavigatorSearchResultItemInfoView } from './NavigatorSearchResultItemInfoView'; export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps @@ -19,7 +19,7 @@ export interface NavigatorSearchResultItemViewProps extends LayoutGridItemProps export const NavigatorSearchResultItemView: FC = props => { const { roomData = null, children = null, thumbnail = false, selectedRoomId, setSelectedRoomId, isPopoverActive, setIsPopoverActive, ...rest } = props; - const { setDoorData = null } = useNavigator(); + const { setSnapshot: setDoorData } = useDoorState(); const handleMouseEnter = () => { diff --git a/src/components/navigator/views/search/NavigatorSearchResultView.tsx b/src/components/navigator/views/search/NavigatorSearchResultView.tsx index e8432c7..ddb2cd1 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultView.tsx @@ -3,7 +3,7 @@ import { FC, useEffect, useState } from 'react'; import { FaBars, FaMinus, FaPlus, FaTh, FaWindowMaximize, FaWindowRestore } from 'react-icons/fa'; import { LocalizeText, NavigatorSearchResultViewDisplayMode, SendMessageComposer } from '../../../../api'; import { AutoGrid, AutoGridProps, Column, Flex, Grid, LayoutSearchSavesView, Text } from '../../../../common'; -import { useNavigator } from '../../../../hooks'; +import { useNavigatorData } from '../../../../hooks'; import { NavigatorSearchResultItemView } from './NavigatorSearchResultItemView'; export interface NavigatorSearchResultViewProps extends AutoGridProps @@ -19,7 +19,7 @@ export const NavigatorSearchResultView: FC = pro const [ selectedRoomId, setSelectedRoomId ] = useState(null); const [ isPopoverActive, setIsPopoverActive ] = useState(false); - const { topLevelContext = null } = useNavigator(); + const { topLevelContext } = useNavigatorData(); const getResultTitle = () => { diff --git a/src/components/navigator/views/search/NavigatorSearchView.tsx b/src/components/navigator/views/search/NavigatorSearchView.tsx index 040e1c0..18980b6 100644 --- a/src/components/navigator/views/search/NavigatorSearchView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchView.tsx @@ -2,16 +2,14 @@ import { FC, KeyboardEvent, useEffect, useState } from 'react'; import { FaSearch } from 'react-icons/fa'; import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api'; import { Button } from '../../../../common'; -import { useNavigator } from '../../../../hooks'; +import { useNavigatorActions, useNavigatorData } from '../../../../hooks'; -export const NavigatorSearchView: FC<{ - sendSearch: (searchValue: string, contextCode: string) => void; -}> = props => +export const NavigatorSearchView: FC<{}> = props => { - const { sendSearch = null } = props; const [ searchFilterIndex, setSearchFilterIndex ] = useState(0); const [ searchValue, setSearchValue ] = useState(''); - const { topLevelContext = null, searchResult = null } = useNavigator(); + const { topLevelContext, searchResult } = useNavigatorData(); + const { sendSearch } = useNavigatorActions(); const processSearch = () => { diff --git a/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx b/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx index bbed44b..169b808 100644 --- a/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx +++ b/src/components/room/widgets/room-filter-words/RoomFilterWordsWidgetView.tsx @@ -2,7 +2,7 @@ import { UpdateRoomFilterMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; import { LocalizeText, SendMessageComposer } from '../../../../api'; import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useFilterWordsWidget, useNavigator } from '../../../../hooks'; +import { useFilterWordsWidget, useNavigatorData } from '../../../../hooks'; import { NitroInput, classNames } from '../../../../layout'; export const RoomFilterWordsWidgetView: FC<{}> = props => @@ -11,7 +11,7 @@ export const RoomFilterWordsWidgetView: FC<{}> = props => const [ selectedWord, setSelectedWord ] = useState(''); const [ isSelectingWord, setIsSelectingWord ] = useState(false); const { wordsFilter = [], isVisible = null, setWordsFilter, onClose = null } = useFilterWordsWidget(); - const { navigatorData = null } = useNavigator(); + const { navigatorData } = useNavigatorData(); const processAction = (isAddingWord: boolean) => { diff --git a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx index 01582ef..a256afe 100644 --- a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx +++ b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx @@ -4,7 +4,7 @@ import { classNames } from '../../../../layout'; import { FC, useEffect, useState } from 'react'; import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api'; import { Text } from '../../../../common'; -import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks'; +import { useMessageEvent, useNavigatorData, useRoom } from '../../../../hooks'; import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi'; export const RoomToolsWidgetView: FC<{}> = props => @@ -18,7 +18,7 @@ export const RoomToolsWidgetView: FC<{}> = props => const [isOpenHistory, setIsOpenHistory] = useState(false); const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]); const [plugins, setPlugins] = useState([]); - const { navigatorData = null } = useNavigator(); + const { navigatorData } = useNavigatorData(); const { roomSession = null } = useRoom(); // Subscribe to external plugin changes From 1148c0a628ee47e2ed62332e8df6bee8cdbf797b Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:01:48 +0200 Subject: [PATCH 15/86] refactor(navigator): remove deprecated useNavigator god-hook P1 complete. All 13 consumers migrated to the wired-tools-style split: - useNavigatorData / useNavigatorUiState / useNavigatorActions (filters) - useNavigatorStore (internal useBetween closure with sendSearch + reloadCurrentSearch) - navigatorUiStore (Zustand for 9 UI flags) - useDoorState (extracted to src/hooks/rooms/widgets) Spec: docs/superpowers/specs/2026-05-26-navigator-modernization-p1-design.md Plan: docs/superpowers/plans/2026-05-26-navigator-modernization-p1.md Next phases (separate specs/plans): P2 (TanStack Query for search), P3 (reactive favourites via snapshot), P4 (visual rework + virtualization + persistence). --- src/hooks/navigator/useNavigator.ts | 492 ---------------------------- 1 file changed, 492 deletions(-) delete mode 100644 src/hooks/navigator/useNavigator.ts diff --git a/src/hooks/navigator/useNavigator.ts b/src/hooks/navigator/useNavigator.ts deleted file mode 100644 index a934aff..0000000 --- a/src/hooks/navigator/useNavigator.ts +++ /dev/null @@ -1,492 +0,0 @@ -import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, DoorbellMessageEvent, FavouriteChangedEvent, FavouritesEvent, FlatAccessDeniedMessageEvent, FlatCreatedEvent, FollowFriendMessageComposer, GenericErrorEvent, GetGuestRoomMessageComposer, GetGuestRoomResultEvent, GetRoomSessionManager, GetSessionDataManager, GetUserEventCatsMessageComposer, GetUserFlatCatsMessageComposer, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, NavigatorSearchesEvent, NavigatorSearchEvent, NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomDoorbellAcceptedEvent, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; -import { useState } from 'react'; -import { useBetween } from 'use-between'; -import { CreateRoomSession, DoorStateType, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api'; -import { useMessageEvent, useNitroEvent } from '../events'; -import { useNotification } from '../notification'; - -const useNavigatorState = () => -{ - const [ categories, setCategories ] = useState(null); - const [ eventCategories, setEventCategories ] = useState(null); - const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); - const [ topLevelContext, setTopLevelContext ] = useState(null); - const [ topLevelContexts, setTopLevelContexts ] = useState(null); - const [ doorData, setDoorData ] = useState<{ roomInfo: RoomDataParser, state: number }>({ roomInfo: null, state: DoorStateType.NONE }); - const [ searchResult, setSearchResult ] = useState(null); - const [ navigatorSearches, setNavigatorSearches ] = useState(null); - const [ navigatorData, setNavigatorData ] = useState({ - settingsReceived: false, - homeRoomId: 0, - enteredGuestRoom: null, - currentRoomOwner: false, - currentRoomId: 0, - currentRoomIsStaffPick: false, - createdFlatId: 0, - avatarId: 0, - roomPicker: false, - eventMod: false, - currentRoomRating: 0, - canRate: true - }); - const { simpleAlert = null } = useNotification(); - - useMessageEvent(FavouritesEvent, event => - { - const parser = event.getParser(); - const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x)); - setFavouriteRoomIds(favoriteIds); - }); - - useMessageEvent(FavouriteChangedEvent, event => - { - const parser = event.getParser(); - const roomId = Number(parser.flatId); - const added = !!parser.added; - - setFavouriteRoomIds(prev => - { - const ids = (prev || []).map((x: any) => Number(x)); - - if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ]; - - return ids.filter(id => id !== roomId); - }); - }); - - useMessageEvent(RoomSettingsUpdatedEvent, event => - { - const parser = event.getParser(); - - SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); - }); - - useMessageEvent(CanCreateRoomEventEvent, event => - { - const parser = event.getParser(); - - if(parser.canCreate) - { - // show room event cvreate - - return; - } - - simpleAlert(LocalizeText(`navigator.cannotcreateevent.error.${ parser.errorCode }`), null, null, null, LocalizeText('navigator.cannotcreateevent.title')); - }); - - useMessageEvent(UserInfoEvent, event => - { - SendMessageComposer(new GetUserFlatCatsMessageComposer()); - SendMessageComposer(new GetUserEventCatsMessageComposer()); - }); - - useMessageEvent(UserPermissionsEvent, event => - { - const parser = event.getParser(); - - setNavigatorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.eventMod = (parser.securityLevel >= SecurityLevel.MODERATOR); - newValue.roomPicker = (parser.securityLevel >= SecurityLevel.COMMUNITY); - - return newValue; - }); - }); - - useMessageEvent(RoomForwardEvent, event => - { - const parser = event.getParser(); - - TryVisitRoom(parser.roomId); - }); - - useMessageEvent(RoomEntryInfoMessageEvent, event => - { - const parser = event.getParser(); - - setNavigatorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.enteredGuestRoom = null; - newValue.currentRoomOwner = parser.isOwner; - newValue.currentRoomId = parser.roomId; - - return newValue; - }); - - // close room info - // close room settings - // close room filter - - SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, true, false)); - - if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'navigator', 'private', [ parser.roomId ]); - }); - - useMessageEvent(GetGuestRoomResultEvent, event => - { - const parser = event.getParser(); - - if(parser.roomEnter) - { - setDoorData({ roomInfo: null, state: DoorStateType.NONE }); - - setNavigatorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.enteredGuestRoom = parser.data; - newValue.currentRoomIsStaffPick = parser.staffPick; - - const isCreated = (newValue.createdFlatId === parser.data.roomId); - - if(!isCreated && parser.data.displayRoomEntryAd) - { - if(GetConfigurationValue('roomenterad.habblet.enabled', false)) HabboWebTools.openRoomEnterAd(); - } - - newValue.createdFlatId = 0; - - if(newValue.enteredGuestRoom && (newValue.enteredGuestRoom.habboGroupId > 0)) - { - // close event info - } - - return newValue; - }); - } - else if(parser.roomForward) - { - if((parser.data.ownerName !== GetSessionDataManager().userName) && !parser.isGroupMember) - { - switch(parser.data.doorMode) - { - case RoomDataParser.DOORBELL_STATE: - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.roomInfo = parser.data; - newValue.state = DoorStateType.START_DOORBELL; - - return newValue; - }); - return; - case RoomDataParser.PASSWORD_STATE: - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.roomInfo = parser.data; - newValue.state = DoorStateType.START_PASSWORD; - - return newValue; - }); - return; - } - } - - if((parser.data.doorMode === RoomDataParser.NOOB_STATE) && !GetSessionDataManager().isAmbassador && !GetSessionDataManager().isRealNoob && !GetSessionDataManager().isModerator) return; - - CreateRoomSession(parser.data.roomId); - } - else - { - setNavigatorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.enteredGuestRoom = parser.data; - newValue.currentRoomIsStaffPick = parser.staffPick; - - return newValue; - }); - } - }); - - useMessageEvent(RoomScoreEvent, event => - { - const parser = event.getParser(); - - setNavigatorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.currentRoomRating = parser.totalLikes; - newValue.canRate = parser.canLike; - - return newValue; - }); - }); - - useMessageEvent(DoorbellMessageEvent, event => - { - const parser = event.getParser(); - - if(!parser.userName || (parser.userName.length === 0)) - { - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_WAITING; - - return newValue; - }); - } - }); - - useMessageEvent(RoomDoorbellAcceptedEvent, event => - { - const parser = event.getParser(); - - if(!parser.userName || (parser.userName.length === 0)) - { - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_ACCEPTED; - - return newValue; - }); - } - }); - - useMessageEvent(FlatAccessDeniedMessageEvent, event => - { - const parser = event.getParser(); - - if(!parser.userName || (parser.userName.length === 0)) - { - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_NO_ANSWER; - - return newValue; - }); - } - }); - - useMessageEvent(GenericErrorEvent, event => - { - const parser = event.getParser(); - - switch(parser.errorCode) - { - case -100002: - setDoorData(prevValue => - { - const newValue = { ...prevValue }; - - newValue.state = DoorStateType.STATE_WRONG_PASSWORD; - - return newValue; - }); - return; - case 4009: - simpleAlert(LocalizeText('navigator.alert.need.to.be.vip'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); - - return; - case 4010: - simpleAlert(LocalizeText('navigator.alert.invalid_room_name'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); - - return; - case 4011: - simpleAlert(LocalizeText('navigator.alert.cannot_perm_ban'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); - - return; - case 4013: - simpleAlert(LocalizeText('navigator.alert.room_in_maintenance'), NotificationAlertType.DEFAULT, null, null, LocalizeText('generic.alert.title')); - - return; - } - }); - - useMessageEvent(NavigatorMetadataEvent, event => - { - const parser = event.getParser(); - - setTopLevelContexts(parser.topLevelContexts); - setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); - }); - - useMessageEvent(NavigatorSearchEvent, event => - { - const parser = event.getParser(); - - setTopLevelContext(prevValue => - { - let newValue = prevValue; - - if(!newValue) newValue = ((topLevelContexts && topLevelContexts.length && topLevelContexts[0]) || null); - - if(!newValue) return null; - - if((parser.result.code !== newValue.code) && topLevelContexts && topLevelContexts.length) - { - for(const context of topLevelContexts) - { - if(context.code !== parser.result.code) continue; - - newValue = context; - } - } - - for(const context of topLevelContexts) - { - if(context.code !== parser.result.code) continue; - - newValue = context; - } - - return newValue; - }); - - setSearchResult(parser.result); - }); - - useMessageEvent(UserFlatCatsEvent, event => - { - const parser = event.getParser(); - - setCategories(parser.categories); - }); - - useMessageEvent(UserEventCatsEvent, event => - { - const parser = event.getParser(); - - setEventCategories(parser.categories); - }); - - useMessageEvent(FlatCreatedEvent, event => - { - const parser = event.getParser(); - - CreateRoomSession(parser.roomId); - }); - - // When reconnection starts, reset settingsReceived so the login sequence's - // NavigatorHomeRoomEvent is treated as a fresh login. Without this, the - // prevSettingsReceived check blocks home room navigation after reconnection, - // leaving the user stuck on hotel view. - useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => - { - setNavigatorData(prevValue => ({ ...prevValue, settingsReceived: false })); - }); - - useMessageEvent(NavigatorHomeRoomEvent, event => - { - const parser = event.getParser(); - - let prevSettingsReceived = false; - - setNavigatorData(prevValue => - { - prevSettingsReceived = prevValue.settingsReceived; - - const newValue = { ...prevValue }; - - newValue.homeRoomId = parser.homeRoomId; - newValue.settingsReceived = true; - - return newValue; - }); - - if(prevSettingsReceived) - { - // refresh room info window - return; - } - - // If a room session was already restored (from a network disconnect reload), - // skip the normal home room navigation to avoid overriding it. - if(GetRoomSessionManager().viewerSession) return; - - let forwardType = -1; - let forwardId = -1; - - if((GetConfigurationValue('friend.id') !== undefined) && (parseInt(GetConfigurationValue('friend.id')) > 0)) - { - forwardType = 0; - SendMessageComposer(new FollowFriendMessageComposer(parseInt(GetConfigurationValue('friend.id')))); - } - - if((GetConfigurationValue('forward.type') !== undefined) && (GetConfigurationValue('forward.id') !== undefined)) - { - forwardType = parseInt(GetConfigurationValue('forward.type')); - forwardId = parseInt(GetConfigurationValue('forward.id')); - } - - if(forwardType === 2) - { - TryVisitRoom(forwardId); - } - - else if((forwardType === -1) && (parser.roomIdToEnter > 0)) - { - CreateLinkEvent('navigator/close'); - - if(parser.roomIdToEnter !== parser.homeRoomId) - { - CreateRoomSession(parser.roomIdToEnter); - } - else - { - CreateRoomSession(parser.homeRoomId); - } - } - }); - - useMessageEvent(RoomEnterErrorEvent, event => - { - const parser = event.getParser(); - - switch(parser.reason) - { - case CantConnectMessageParser.REASON_FULL: - simpleAlert(LocalizeText('navigator.guestroomfull.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.guestroomfull.title')); - - break; - case CantConnectMessageParser.REASON_QUEUE_ERROR: - simpleAlert(LocalizeText(`room.queue.error.${ parser.parameter }`), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); - - break; - case CantConnectMessageParser.REASON_BANNED: - simpleAlert(LocalizeText('navigator.banned.text'), NotificationAlertType.DEFAULT, null, null, LocalizeText('navigator.banned.title')); - - break; - default: - simpleAlert(LocalizeText('room.queue.error.title'), NotificationAlertType.DEFAULT, null, null, LocalizeText('room.queue.error.title')); - - break; - } - - // During reconnection, don't navigate to desktop — the reconnection guard - // will handle retrying or cleaning up. Calling VisitDesktop here would - // remove the session from the map and send the user to hotel view. - if(GetRoomSessionManager().isReconnecting) return; - - VisitDesktop(); - }); - - useMessageEvent(NavigatorOpenRoomCreatorEvent, event => CreateLinkEvent('navigator/show')); - - useMessageEvent(NavigatorSearchesEvent, event => - { - const parser = event.getParser(); - if(!parser) return; - setNavigatorSearches(parser.searches); - }); - - return { categories, doorData, setDoorData, topLevelContext, topLevelContexts, searchResult, navigatorData, favouriteRoomIds, navigatorSearches }; -}; - -export const useNavigator = () => useBetween(useNavigatorState); From d5b0743382a3cf7779016b23acd8c060733c0faa Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:08:38 +0200 Subject: [PATCH 16/86] feat(navigator): wrap sub-views in WidgetErrorBoundary Each of the 5 Navigator sub-views (RoomCreator, DoorState, RoomInfo, RoomLink, RoomSettings) is now wrapped in its own WidgetErrorBoundary so a crash inside one no longer takes down the others. Matches the pattern already applied to the 13 room widgets + 20 furniture widgets. Zero behavioural change in the happy path. yarn typecheck + yarn test --run + yarn lint:hooks all clean (only the 3 pre-existing floorplan failures remain, unrelated to Navigator). --- src/components/navigator/NavigatorView.tsx | 25 ++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 3ff6be6..16c76cb 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -7,7 +7,7 @@ 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 { Flex, Text, WidgetErrorBoundary } from '../../common'; import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; @@ -197,13 +197,26 @@ export const NavigatorView: FC<{}> = props =>
} - { isCreatorOpen && } + { isCreatorOpen && + + + } } - - { isRoomInfoOpen && useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> } - { isRoomLinkOpen && useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> } - + + + + { isRoomInfoOpen && + + useNavigatorUiStore.getState().setRoomInfoOpen(false) } /> + } + { isRoomLinkOpen && + + useNavigatorUiStore.getState().setRoomLinkOpen(false) } /> + } + + + ); }; From 1810a866180ff031e8b3f152f93d09e806ef4074 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:11:31 +0200 Subject: [PATCH 17/86] docs(navigator): P2 TanStack Query design + integrated plan Combines spec + 5-task plan into a single doc for faster execution. Branch: feat/navigator-p2-query (forked from feat/navigator-modernization P1 tip). Migrates search from event-driven imperative state to useNitroQuery with cache per [tabCode, filter], invalidator on FlatCreatedEvent + RoomSettingsUpdatedEvent, accept-filter that rejects mismatched-tab server pushes. Key API changes: useNavigatorActions DELETED (sendSearch + reloadCurrentSearch gone); useNavigatorData no longer returns searchResult; navigatorUiStore adds currentTabCode + currentFilter + setTab + setFilter; new useNavigatorSearch hook returns the { searchResult, isFetching, refetch } triple. --- ...5-27-navigator-p2-tanstack-query-design.md | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md diff --git a/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md b/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md new file mode 100644 index 0000000..b6f3965 --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-navigator-p2-tanstack-query-design.md @@ -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({ + 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(null)` for `searchResult` +- `useMessageEvent` 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 `` 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)` From 8f1b664b2fe8e53d51b71b7bf35f22211a77afcd Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:15:08 +0200 Subject: [PATCH 18/86] feat(navigator): add currentTabCode + currentFilter to UI store (P2 prep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setTab(code) atomically updates currentTabCode and resets currentFilter to '' — switching tabs starts a fresh search context. setFilter(value) updates only the filter — the user is typing in the same tab. TDD: 3 new cases (16 total in navigatorUiStore.test). --- src/hooks/navigator/navigatorUiStore.test.ts | 33 +++++++++++++++++++- src/hooks/navigator/navigatorUiStore.ts | 10 +++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/hooks/navigator/navigatorUiStore.test.ts b/src/hooks/navigator/navigatorUiStore.test.ts index ebc41fa..36a7450 100644 --- a/src/hooks/navigator/navigatorUiStore.test.ts +++ b/src/hooks/navigator/navigatorUiStore.test.ts @@ -10,7 +10,9 @@ const INITIAL = { isOpenSavesSearches: false, isLoading: false, needsInit: true, - needsSearch: false + needsSearch: false, + currentTabCode: '', + currentFilter: '' }; describe('useNavigatorUiStore', () => @@ -32,6 +34,8 @@ describe('useNavigatorUiStore', () => 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', () => @@ -141,4 +145,31 @@ describe('useNavigatorUiStore', () => 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(''); + }); + }); }); diff --git a/src/hooks/navigator/navigatorUiStore.ts b/src/hooks/navigator/navigatorUiStore.ts index 527aab7..709207a 100644 --- a/src/hooks/navigator/navigatorUiStore.ts +++ b/src/hooks/navigator/navigatorUiStore.ts @@ -10,6 +10,8 @@ export type NavigatorUiState = { isLoading: boolean; needsInit: boolean; needsSearch: boolean; + currentTabCode: string; + currentFilter: string; }; export type NavigatorUiActions = { @@ -28,6 +30,8 @@ export type NavigatorUiActions = { markInitDone(): void; requestSearch(): void; consumeSearchRequest(): void; + setTab(code: string): void; + setFilter(value: string): void; }; export const useNavigatorUiStore = createNitroStore()((set) => ({ @@ -40,6 +44,8 @@ export const useNavigatorUiStore = createNitroStore set({ isVisible: true, needsSearch: true }), hide: () => set({ isVisible: false }), @@ -57,5 +63,7 @@ export const useNavigatorUiStore = createNitroStore set({ isReady: true }), markInitDone: () => set({ needsInit: false }), requestSearch: () => set({ needsSearch: true }), - consumeSearchRequest: () => set({ needsSearch: false }) + consumeSearchRequest: () => set({ needsSearch: false }), + setTab: (code) => set({ currentTabCode: code, currentFilter: '' }), + setFilter: (value) => set({ currentFilter: value }) })); From 7435326dad5e3a65ee3bfea56e9742b0d781ed90 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:18:24 +0200 Subject: [PATCH 19/86] feat(navigator): useNavigatorSearch query hook (P2 core) useNitroQuery keyed on [currentTabCode, currentFilter] from navigatorUiStore. Fires NavigatorSearchComposer; subscribes to NavigatorSearchEvent with an accept-filter that rejects results whose code does not match the current tab. Invalidates on FlatCreatedEvent and RoomSettingsUpdatedEvent for server-driven refresh. nitro-renderer.mock.ts: add connection.send stub to GetCommunication so SendMessageComposer (which calls GetCommunication().connection.send) does not throw in tests that exercise useNitroQuery. TDD: 7 cases incl. enabled-gating, accept-filter rejection on mismatched tab, invalidator round-trip. --- .../navigator/useNavigatorSearch.test.tsx | 300 ++++++++++++++++++ src/hooks/navigator/useNavigatorSearch.ts | 47 +++ src/nitro-renderer.mock.ts | 4 +- 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/hooks/navigator/useNavigatorSearch.test.tsx create mode 100644 src/hooks/navigator/useNavigatorSearch.ts diff --git a/src/hooks/navigator/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx new file mode 100644 index 0000000..bd2fb60 --- /dev/null +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -0,0 +1,300 @@ +/* @vitest-environment jsdom */ + +import { FlatCreatedEvent, NavigatorSearchComposer, 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 }) => ( + + { children } + + ); + +/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */ +const makeSearchEvent = (code: string) => +{ + const result = new NavigatorSearchResultSet() as any; + result.code = code; + result.data = ''; + result.results = []; + + const ev = new NavigatorSearchEvent() as any; + ev.getParser = () => ({ result }); + return ev as NavigatorSearchEvent; +}; + +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')); + }); + + // 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')); + }); + + // 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')); + }); + 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')); + }); + + 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')); + }); + 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')); + }); + + 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')); + }); + + 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')); + }); + + // 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')); + }); + + 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')); + }); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + + // Dispatch FlatCreatedEvent — should trigger invalidateQueries + const flatCreatedEv = new FlatCreatedEvent() as any; + flatCreatedEv.getParser = () => ({ roomId: 999 }); + act(() => + { + mockEventDispatcher.dispatchEvent(flatCreatedEv); + }); + + 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); + }); +}); diff --git a/src/hooks/navigator/useNavigatorSearch.ts b/src/hooks/navigator/useNavigatorSearch.ts new file mode 100644 index 0000000..b120001 --- /dev/null +++ b/src/hooks/navigator/useNavigatorSearch.ts @@ -0,0 +1,47 @@ +import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, + NavigatorSearchResultSet, RoomSettingsUpdatedEvent } from '@nitrots/nitro-renderer'; +import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; +import { useNavigatorUiStore } from './navigatorUiStore'; + +const SEARCH_BASE_KEY = [ 'navigator', 'search' ] as const; + +/** + * TanStack Query wrapper for navigator search. + * + * Cache key: ['navigator', 'search', tabCode, filter] + * - Fires NavigatorSearchComposer(tabCode, filter) on miss. + * - Listens for NavigatorSearchEvent and resolves with the result. + * - accept-filter: rejects events whose result.code !== tabCode (defends + * against server-side cross-tab pushes resolving the wrong query slot). + * - Disabled when tabCode is '' (initial state, before metadata arrives). + * - Invalidates on FlatCreatedEvent (new room created) and + * RoomSettingsUpdatedEvent (room renamed / settings changed). + */ +export const useNavigatorSearch = () => +{ + const tabCode = useNavigatorUiStore(s => s.currentTabCode); + const filter = useNavigatorUiStore(s => s.currentFilter); + + const query = useNitroQuery({ + key: [ ...SEARCH_BASE_KEY, tabCode, filter ], + request: () => new NavigatorSearchComposer(tabCode, filter), + parser: NavigatorSearchEvent, + select: e => e.getParser()?.result ?? null, + accept: e => + { + const result = e.getParser()?.result; + return !!result && result.code === tabCode; + }, + enabled: !!tabCode, + staleTime: 30_000 + }); + + useNitroEventInvalidator(FlatCreatedEvent, [ ...SEARCH_BASE_KEY ]); + useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ ...SEARCH_BASE_KEY ]); + + return { + searchResult: query.data ?? null, + isFetching: query.isFetching, + refetch: query.refetch + }; +}; diff --git a/src/nitro-renderer.mock.ts b/src/nitro-renderer.mock.ts index 28abd00..d80b851 100644 --- a/src/nitro-renderer.mock.ts +++ b/src/nitro-renderer.mock.ts @@ -533,7 +533,9 @@ export const GetCommunication = vi.fn(() => ({ { 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); From ee3736474ddd404e65a63c5c6ffd0054fc45f093 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:20:27 +0200 Subject: [PATCH 20/86] refactor(navigator): remove search ownership from useNavigatorStore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P2 core surgery: search result + NavigatorSearchEvent listener + sendSearch + reloadCurrentSearch all leave useNavigatorStore. The new useNavigatorSearch query hook owns the cache. useNavigatorActions is deleted entirely — the only two actions it exposed are gone, and no consumer outside Navigator depended on it. NavigatorMetadataEvent handler now seeds the UI store's currentTabCode on first arrival, activating the query the moment top-level contexts land. useNavigatorData: searchResult removed from closure and return. useNavigatorUiState: currentTabCode + currentFilter added. index.ts: useNavigatorActions removed, useNavigatorSearch added. NavigatorView.tsx is intentionally broken at this commit and gets fixed in the next. --- src/hooks/navigator/index.ts | 2 +- src/hooks/navigator/useNavigatorActions.ts | 8 -- src/hooks/navigator/useNavigatorData.ts | 4 +- .../navigator/useNavigatorStore.test.tsx | 14 +--- src/hooks/navigator/useNavigatorStore.ts | 75 ++----------------- src/hooks/navigator/useNavigatorUiState.ts | 5 +- 6 files changed, 18 insertions(+), 90 deletions(-) delete mode 100644 src/hooks/navigator/useNavigatorActions.ts diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index c621851..67660d0 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1,5 +1,5 @@ -export { useNavigatorActions } from './useNavigatorActions'; export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorSearch } from './useNavigatorSearch'; export { useNavigatorUiState } from './useNavigatorUiState'; export { useNavigatorUiStore } from './navigatorUiStore'; export { useDoorState } from '../rooms/widgets/useDoorState'; diff --git a/src/hooks/navigator/useNavigatorActions.ts b/src/hooks/navigator/useNavigatorActions.ts deleted file mode 100644 index 6a88e43..0000000 --- a/src/hooks/navigator/useNavigatorActions.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { useBetween } from 'use-between'; -import { useNavigatorStore } from './useNavigatorStore'; - -export const useNavigatorActions = () => -{ - const { sendSearch, reloadCurrentSearch } = useBetween(useNavigatorStore); - return { sendSearch, reloadCurrentSearch }; -}; diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts index aeb05b7..500fb9e 100644 --- a/src/hooks/navigator/useNavigatorData.ts +++ b/src/hooks/navigator/useNavigatorData.ts @@ -6,12 +6,12 @@ export const useNavigatorData = () => const { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData + navigatorSearches, navigatorData } = useBetween(useNavigatorStore); return { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData + navigatorSearches, navigatorData }; }; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx index a9040f4..2e09036 100644 --- a/src/hooks/navigator/useNavigatorStore.test.tsx +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; import { describe, expect, it } from 'vitest'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState } from './index'; +import { useNavigatorData, useNavigatorUiState } from './index'; describe('navigator filter shapes (smoke)', () => { @@ -10,24 +10,18 @@ describe('navigator filter shapes (smoke)', () => expect(Object.keys(result.current).sort()).toEqual([ 'categories', 'eventCategories', 'favouriteRoomIds', 'navigatorData', 'navigatorSearches', - 'searchResult', 'topLevelContext', 'topLevelContexts' + 'topLevelContext', 'topLevelContexts' ].sort()); }); - it('useNavigatorUiState returns the 9 documented flags', () => + 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()); }); - - it('useNavigatorActions returns sendSearch + reloadCurrentSearch', () => - { - const { result } = renderHook(() => useNavigatorActions()); - expect(typeof result.current.sendSearch).toBe('function'); - expect(typeof result.current.reloadCurrentSearch).toBe('function'); - }); }); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts index e3165d9..bf1a513 100644 --- a/src/hooks/navigator/useNavigatorStore.ts +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -6,13 +6,13 @@ import { CanCreateRoomEventEvent, CantConnectMessageParser, CreateLinkEvent, HabboWebTools, LegacyExternalInterface, NavigatorCategoryDataParser, NavigatorEventCategoryDataParser, NavigatorHomeRoomEvent, NavigatorMetadataEvent, NavigatorOpenRoomCreatorEvent, NavigatorSavedSearch, - NavigatorSearchComposer, NavigatorSearchesEvent, NavigatorSearchEvent, - NavigatorSearchResultSet, NavigatorTopLevelContext, NitroEventType, + NavigatorSearchesEvent, + NavigatorTopLevelContext, NitroEventType, RoomDataParser, RoomEnterErrorEvent, RoomEntryInfoMessageEvent, - RoomForwardEvent, RoomScoreEvent, RoomSettingsUpdatedEvent, + RoomForwardEvent, RoomScoreEvent, SecurityLevel, UserEventCatsEvent, UserFlatCatsEvent, UserInfoEvent, UserPermissionsEvent } from '@nitrots/nitro-renderer'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { CreateRoomSession, GetConfigurationValue, INavigatorData, LocalizeText, NotificationAlertType, SendMessageComposer, TryVisitRoom, VisitDesktop } from '../../api'; @@ -27,7 +27,6 @@ export const useNavigatorStore = () => const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); const [ topLevelContext, setTopLevelContext ] = useState(null); const [ topLevelContexts, setTopLevelContexts ] = useState(null); - const [ searchResult, setSearchResult ] = useState(null); const [ navigatorSearches, setNavigatorSearches ] = useState(null); const [ navigatorData, setNavigatorData ] = useState({ settingsReceived: false, @@ -44,41 +43,8 @@ export const useNavigatorStore = () => canRate: true }); - // Refs let handlers stay [] deps without losing access to fresh state. - const topLevelContextsRef = useRef(topLevelContexts); - topLevelContextsRef.current = topLevelContexts; - const topLevelContextRef = useRef(topLevelContext); - topLevelContextRef.current = topLevelContext; - const searchResultRef = useRef(searchResult); - searchResultRef.current = searchResult; - const { simpleAlert = null } = useNotification(); - const sendSearch = useCallback((searchValue: string, contextCode: string) => - { - useNavigatorUiStore.getState().closeCreator(); - SendMessageComposer(new NavigatorSearchComposer(contextCode, searchValue)); - useNavigatorUiStore.getState().setLoading(true); - }, []); - - const reloadCurrentSearch = useCallback(() => - { - if(!useNavigatorUiStore.getState().isReady) - { - useNavigatorUiStore.getState().requestSearch(); - return; - } - const sr = searchResultRef.current; - if(sr) - { - sendSearch(sr.data, sr.code); - return; - } - const ctx = topLevelContextRef.current; - if(!ctx) return; - sendSearch('', ctx.code); - }, [ sendSearch ]); - useMessageEvent(FavouritesEvent, useCallback(event => { const parser = event.getParser(); @@ -99,12 +65,6 @@ export const useNavigatorStore = () => }); }, [])); - useMessageEvent(RoomSettingsUpdatedEvent, useCallback(event => - { - const parser = event.getParser(); - SendMessageComposer(new GetGuestRoomMessageComposer(parser.roomId, false, false)); - }, [])); - useMessageEvent(CanCreateRoomEventEvent, useCallback(event => { const parser = event.getParser(); @@ -223,28 +183,8 @@ export const useNavigatorStore = () => const parser = event.getParser(); setTopLevelContexts(parser.topLevelContexts); setTopLevelContext(parser.topLevelContexts.length ? parser.topLevelContexts[0] : null); - }, [])); - - useMessageEvent(NavigatorSearchEvent, useCallback(event => - { - const parser = event.getParser(); - const contexts = topLevelContextsRef.current; - setTopLevelContext(prev => - { - let next = prev; - if(!next) next = (contexts && contexts.length && contexts[0]) || null; - if(!next) return null; - if(contexts && contexts.length) - { - for(const ctx of contexts) - { - if(ctx.code === parser.result.code) next = ctx; - } - } - return next; - }); - setSearchResult(parser.result); - useNavigatorUiStore.getState().setLoading(false); + // Seed the query's tab code so useNavigatorSearch activates immediately + useNavigatorUiStore.getState().setTab(parser.topLevelContexts[0]?.code ?? ''); }, [])); useMessageEvent(UserFlatCatsEvent, useCallback(event => @@ -342,7 +282,6 @@ export const useNavigatorStore = () => return { categories, eventCategories, favouriteRoomIds, topLevelContext, topLevelContexts, - searchResult, navigatorSearches, navigatorData, - sendSearch, reloadCurrentSearch + navigatorSearches, navigatorData }; }; diff --git a/src/hooks/navigator/useNavigatorUiState.ts b/src/hooks/navigator/useNavigatorUiState.ts index 3c0868a..d811f0c 100644 --- a/src/hooks/navigator/useNavigatorUiState.ts +++ b/src/hooks/navigator/useNavigatorUiState.ts @@ -11,8 +11,11 @@ export const useNavigatorUiState = () => 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 + isOpenSavesSearches, isLoading, needsInit, needsSearch, + currentTabCode, currentFilter }; }; From 26772f7073465f6ebaf82773b8264e37f3949801 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Wed, 27 May 2026 19:25:30 +0200 Subject: [PATCH 21/86] feat(navigator): drive search via TanStack Query + setTab/setFilter UI store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NavigatorView reads searchResult/isFetching from useNavigatorSearch instead of useNavigatorData/useNavigatorUiState. Tab clicks call setTab(code) on the UI store, which atomically updates the query key and triggers refetch. The 4 lifecycle useEffect blocks driving the old imperative flow (needsSearch / reloadCurrentSearch / markReady) are removed — the query handles all of it now. NavigatorSearchView has a debounced (300ms) onChange -> setFilter that drives the same query refetch. Explicit submit (Enter / button) skips the debounce and calls setFilter immediately. linkTracker case 'search' now setTab + setFilter + show — no more pendingSearch ref. useNavigatorSearch.test.tsx: cast constructors as any to satisfy tsgo against real renderer types while keeping runtime stubs no-arg-safe. yarn typecheck / test / lint:hooks all clean (only pre-existing floorplan environmental failures). --- src/components/navigator/NavigatorView.tsx | 39 +++-------- .../views/search/NavigatorSearchView.tsx | 64 +++++++++++-------- .../navigator/useNavigatorSearch.test.tsx | 34 +++++----- 3 files changed, 66 insertions(+), 71 deletions(-) diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 3ff6be6..7819c96 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; import { Flex, Text } from '../../common'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; @@ -20,10 +20,9 @@ import { NavigatorSearchView } from './views/search/NavigatorSearchView'; export const NavigatorView: FC<{}> = props => { - const { searchResult, topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); - const { isVisible, isReady, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, isLoading, needsInit, needsSearch } = useNavigatorUiState(); - const { sendSearch, reloadCurrentSearch } = useNavigatorActions(); - const pendingSearch = useRef<{ value: string, code: string }>(null); + const { topLevelContext, topLevelContexts, navigatorData, navigatorSearches } = useNavigatorData(); + const { searchResult, isFetching } = useNavigatorSearch(); + const { isVisible, isCreatorOpen, isRoomInfoOpen, isRoomLinkOpen, isOpenSavesSearches, needsInit } = useNavigatorUiState(); const elementRef = useRef(null); useNitroEvent(RoomSessionEvent.CREATED, event => @@ -72,7 +71,10 @@ export const NavigatorView: FC<{}> = props => return; case 'search': if(parts.length <= 2) return; - pendingSearch.current = { value: parts.length > 3 ? parts[3] : '', code: parts[2] }; + const code = parts[2]; + const value = parts.length > 3 ? parts[3] : ''; + store.setTab(code); + if(value) store.setFilter(value); store.show(); return; } @@ -89,27 +91,6 @@ export const NavigatorView: FC<{}> = props => if(elementRef.current) elementRef.current.scrollTop = 0; }, [ searchResult ]); - useEffect(() => - { - if(!isVisible || !isReady || !needsSearch) return; - if(pendingSearch.current) - { - sendSearch(pendingSearch.current.value, pendingSearch.current.code); - pendingSearch.current = null; - } - else - { - reloadCurrentSearch(); - } - useNavigatorUiStore.getState().consumeSearchRequest(); - }, [ isVisible, isReady, needsSearch, sendSearch, reloadCurrentSearch ]); - - useEffect(() => - { - if(isReady || !topLevelContext) return; - useNavigatorUiStore.getState().markReady(); - }, [ isReady, topLevelContext ]); - useEffect(() => { if(!isVisible || !needsInit) return; @@ -142,7 +123,7 @@ export const NavigatorView: FC<{}> = props => sendSearch('', context.code) }> + onClick={ () => useNavigatorUiStore.getState().setTab(context.code) }> { LocalizeText('navigator.toplevelview.' + context.code) } ) } = props => - + { !isCreatorOpen &&
{ isOpenSavesSearches && diff --git a/src/components/navigator/views/search/NavigatorSearchView.tsx b/src/components/navigator/views/search/NavigatorSearchView.tsx index 18980b6..a6b3185 100644 --- a/src/components/navigator/views/search/NavigatorSearchView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchView.tsx @@ -2,35 +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 { useNavigatorActions, useNavigatorData } from '../../../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } from '../../../../hooks'; export const NavigatorSearchView: FC<{}> = props => { const [ searchFilterIndex, setSearchFilterIndex ] = useState(0); - const [ searchValue, setSearchValue ] = useState(''); - const { topLevelContext, searchResult } = useNavigatorData(); - const { sendSearch } = useNavigatorActions(); - - 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) => - { - 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; @@ -55,9 +37,39 @@ export const NavigatorSearchView: FC<{}> = props => 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) => + { + if(event.key !== 'Enter') return; + + processSearch(); + }; + return (
@@ -69,7 +81,7 @@ export const NavigatorSearchView: FC<{}> = props =>
- setSearchValue(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> + setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } /> diff --git a/src/hooks/navigator/useNavigatorSearch.test.tsx b/src/hooks/navigator/useNavigatorSearch.test.tsx index bd2fb60..f6858f0 100644 --- a/src/hooks/navigator/useNavigatorSearch.test.tsx +++ b/src/hooks/navigator/useNavigatorSearch.test.tsx @@ -1,6 +1,6 @@ /* @vitest-environment jsdom */ -import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, +import { FlatCreatedEvent, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { act, cleanup, renderHook, waitFor } from '@testing-library/react'; @@ -32,14 +32,16 @@ const makeWrapper = (client: QueryClient) => /** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */ const makeSearchEvent = (code: string) => { - const result = new NavigatorSearchResultSet() as any; + // 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; + const ev = new (NavigatorSearchEvent as any)() as any; ev.getParser = () => ({ result }); - return ev as NavigatorSearchEvent; + return ev; }; const INITIAL_UI = { @@ -82,7 +84,7 @@ describe('useNavigatorSearch', () => // Dispatch a search event — should be ignored (query disabled) act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Data must stay null @@ -107,7 +109,7 @@ describe('useNavigatorSearch', () => // Simulate server response act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); // Query should resolve with the matching result @@ -130,7 +132,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -145,7 +147,7 @@ describe('useNavigatorSearch', () => // Resolve with matching event act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -171,7 +173,7 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -191,7 +193,7 @@ describe('useNavigatorSearch', () => // Resolve with events result act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('events')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any); }); await waitFor(() => expect(result.current.isFetching).toBe(false)); @@ -212,7 +214,7 @@ describe('useNavigatorSearch', () => act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => @@ -237,7 +239,7 @@ describe('useNavigatorSearch', () => // Dispatch an event for a DIFFERENT tab — should be rejected by accept filter act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any); }); // Still fetching — the wrong-tab event was ignored @@ -248,7 +250,7 @@ describe('useNavigatorSearch', () => // Now dispatch the correct one to unblock the test act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any); }); await waitFor(() => expect(result.current.searchResult).not.toBeNull()); @@ -273,16 +275,16 @@ describe('useNavigatorSearch', () => await waitFor(() => expect(result.current.isFetching).toBe(true)); act(() => { - mockEventDispatcher.dispatchEvent(makeSearchEvent('public')); + 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; + const flatCreatedEv = new (FlatCreatedEvent as any)() as any; flatCreatedEv.getParser = () => ({ roomId: 999 }); act(() => { - mockEventDispatcher.dispatchEvent(flatCreatedEv); + mockEventDispatcher.dispatchEvent(flatCreatedEv as any); }); await waitFor(() => expect(invalidateSpy).toHaveBeenCalled()); From 61aceaa42209a9a8b11fa84444beada02c172e07 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 02:39:02 +0200 Subject: [PATCH 22/86] feat: rare values panel + fortune wheel UI + prize editor Toolbar buttons, FortuneWheelView (animated wheel, prize icons, recent winners), RareValuesView (diamond price guide + staff prize-editor tab), furni infostand value line, useFortuneWheel/useRareValues hooks, it/en text examples. --- .../configuration/rarevalues-texts-en.example | 6 + .../configuration/rarevalues-texts-it.example | 6 + public/configuration/wheel-texts-en.example | 9 + public/configuration/wheel-texts-it.example | 9 + src/components/MainView.tsx | 4 + .../fortune-wheel/FortuneWheelView.tsx | 191 ++++++++++++++ src/components/rare-values/RareValuesView.tsx | 234 ++++++++++++++++++ .../infostand/InfoStandWidgetFurniView.tsx | 17 +- src/components/toolbar/ToolbarView.tsx | 12 + src/css/icons/icons.css | 14 ++ src/hooks/fortune-wheel/useFortuneWheel.ts | 69 ++++++ src/hooks/index.ts | 2 + src/hooks/rare-values/useRareValues.ts | 31 +++ 13 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 public/configuration/rarevalues-texts-en.example create mode 100644 public/configuration/rarevalues-texts-it.example create mode 100644 public/configuration/wheel-texts-en.example create mode 100644 public/configuration/wheel-texts-it.example create mode 100644 src/components/fortune-wheel/FortuneWheelView.tsx create mode 100644 src/components/rare-values/RareValuesView.tsx create mode 100644 src/hooks/fortune-wheel/useFortuneWheel.ts create mode 100644 src/hooks/rare-values/useRareValues.ts diff --git a/public/configuration/rarevalues-texts-en.example b/public/configuration/rarevalues-texts-en.example new file mode 100644 index 0000000..e9d6368 --- /dev/null +++ b/public/configuration/rarevalues-texts-en.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Rare Values", + "rarevalues.loading": "Loading values…", + "rarevalues.empty": "No rares found", + "rarevalues.infostand.label": "Value:" +} diff --git a/public/configuration/rarevalues-texts-it.example b/public/configuration/rarevalues-texts-it.example new file mode 100644 index 0000000..62fef05 --- /dev/null +++ b/public/configuration/rarevalues-texts-it.example @@ -0,0 +1,6 @@ +{ + "rarevalues.title": "Valore Rari", + "rarevalues.loading": "Caricamento valori…", + "rarevalues.empty": "Nessun raro trovato", + "rarevalues.infostand.label": "Valore:" +} diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example new file mode 100644 index 0000000..71b3b1b --- /dev/null +++ b/public/configuration/wheel-texts-en.example @@ -0,0 +1,9 @@ +{ + "wheel.title": "Fortune Wheel", + "wheel.free.today": "You have %count% free spins today!", + "wheel.extra": "Extra spins: %count%", + "wheel.spin": "SPIN", + "wheel.buy": "Buy spin", + "wheel.winners": "Latest winners", + "wheel.winners.empty": "No winners yet" +} diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example new file mode 100644 index 0000000..f5bb934 --- /dev/null +++ b/public/configuration/wheel-texts-it.example @@ -0,0 +1,9 @@ +{ + "wheel.title": "Ruota della Fortuna", + "wheel.free.today": "Hai %count% giri gratis oggi!", + "wheel.extra": "Giri extra: %count%", + "wheel.spin": "GIRA", + "wheel.buy": "Compra giro", + "wheel.winners": "Ultimi vincitori", + "wheel.winners.empty": "Ancora nessun vincitore" +} diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 071abcb..494e948 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -24,6 +24,8 @@ import { HcCenterView } from './hc-center/HcCenterView'; import { HelpView } from './help/HelpView'; import { HotelView } from './hotel-view/HotelView'; import { HousekeepingView } from './housekeeping/HousekeepingView'; +import { RareValuesView } from './rare-values/RareValuesView'; +import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; import { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -176,6 +178,8 @@ export const MainView: FC<{}> = props => + + ); diff --git a/src/components/fortune-wheel/FortuneWheelView.tsx b/src/components/fortune-wheel/FortuneWheelView.tsx new file mode 100644 index 0000000..a66af12 --- /dev/null +++ b/src/components/fortune-wheel/FortuneWheelView.tsx @@ -0,0 +1,191 @@ +import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { LocalizeText } from '../../api'; +import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; +import { useFortuneWheel } from '../../hooks'; +import { NitroCard } from '../../layout'; + +// Stock UI palette (white / light-blue / grey / black). +const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ]; +const RIM = '#4c606c'; +const WHEEL_SIZE = 420; +const ICON_RADIUS = 150; +const FULL_TURNS = 5; + +const renderPrizeIcon = (prize: IWheelPrize) => +{ + switch(prize.type) + { + case 'item': + return ; + case 'badge': + return ; + case 'credits': + return ( + + + { prize.amount } + ); + case 'points': + return ( + + + { prize.amount } + ); + case 'spin': + return +{ prize.amount }; + default: + return ; + } +}; + +export const FortuneWheelView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); + const [ rotation, setRotation ] = useState(0); + const rotationRef = useRef(0); + const prizesRef = useRef([]); + prizesRef.current = prizes; + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': setIsVisible(true); return; + case 'hide': setIsVisible(false); return; + case 'toggle': setIsVisible(prev => !prev); return; + } + }, + eventUrlPrefix: 'fortune-wheel/', + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(isVisible) open(); + }, [ isVisible, open ]); + + // Drive the spin animation when the server reports the winning slice. + useEffect(() => + { + if(pendingPrizeId < 0) return; + + const list = prizesRef.current; + const idx = list.findIndex(prize => prize.id === pendingPrizeId); + + if(!list.length || (idx < 0)) + { + finishSpin(); + return; + } + + const sliceAngle = 360 / list.length; + const centerAngle = ((idx + 0.5) * sliceAngle); + const current = rotationRef.current; + const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle); + + rotationRef.current = target; + setRotation(target); + }, [ pendingPrizeId, finishSpin ]); + + const sliceAngle = prizes.length ? (360 / prizes.length) : 0; + + const background = useMemo(() => + { + if(!prizes.length) return SLICE_COLORS[0]; + + const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', '); + return `conic-gradient(${ stops })`; + }, [ prizes, sliceAngle ]); + + if(!isVisible) return null; + + const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0); + + return ( + + setIsVisible(false) } /> + + + +
+
+
{ if(isSpinning) finishSpin(); } }> + { prizes.map((_, i) => ( +
+ )) } + { prizes.map((prize, i) => + { + const centerAngle = ((i + 0.5) * sliceAngle); + return ( +
+
+ { renderPrizeIcon(prize) } +
+
); + }) } +
+
+
+ { LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) } + { LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) } + + + + + + + { LocalizeText('wheel.winners') } + + { recentWins.map((win, i) => ( + +
+ +
+ + { win.username } + { win.prizeLabel } + +
+ )) } + { !recentWins.length && + { LocalizeText('wheel.winners.empty') } } +
+
+ + + + ); +}; diff --git a/src/components/rare-values/RareValuesView.tsx b/src/components/rare-values/RareValuesView.tsx new file mode 100644 index 0000000..5cb4ce7 --- /dev/null +++ b/src/components/rare-values/RareValuesView.tsx @@ -0,0 +1,234 @@ +import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { LocalizeFormattedNumber, LocalizeText } from '../../api'; +import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; +import { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks'; +import { NitroCard, NitroInput } from '../../layout'; + +interface RareValueRow +{ + spriteId: number; + name: string; + iconUrl: string; + value: IRareValue; +} + +interface EditRow +{ + id: number; + category: string; + num: number; + weight: number; + label: string; +} + +const CATEGORIES: { key: string; label: string }[] = [ + { key: 'item', label: 'Raro (ID)' }, + { key: 'diamanti', label: 'Diamanti' }, + { key: 'duckets', label: 'Duckets' }, + { key: 'crediti', label: 'Crediti' }, + { key: 'giri', label: 'Giri extra' }, + { key: 'nulla', label: 'Nulla' } +]; + +const prizeToCategory = (prize: IWheelAdminPrize): string => +{ + switch(prize.type) + { + case 'item': return 'item'; + case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets'; + case 'credits': return 'crediti'; + case 'spin': return 'giri'; + default: return 'nulla'; + } +}; + +const prizeToNum = (prize: IWheelAdminPrize): number => + (prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount; + +const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit => +{ + const base = { id: row.id, weight: row.weight, label: row.label }; + + switch(row.category) + { + case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 }; + case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 }; + case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 }; + case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 }; + case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 }; + default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 }; + } +}; + +export const RareValuesView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ tab, setTab ] = useState<'values' | 'editor'>('values'); + const [ searchValue, setSearchValue ] = useState(''); + const { values = null, loaded = false } = useRareValues(); + const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); + const canEdit = useHasPermission('acc_supporttool'); + const [ editRows, setEditRows ] = useState([]); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': setIsVisible(true); return; + case 'hide': setIsVisible(false); return; + case 'toggle': setIsVisible(prev => !prev); return; + } + }, + eventUrlPrefix: 'rare-values/', + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes(); + }, [ isVisible, tab, canEdit, loadAdminPrizes ]); + + useEffect(() => + { + setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label }))); + }, [ adminPrizes ]); + + const rows = useMemo(() => + { + if(!values) return []; + + const list: RareValueRow[] = []; + + values.forEach((value, spriteId) => + { + if(value.points <= 0) return; + + const floorData = GetSessionDataManager().getFloorItemData(spriteId); + const wallData = floorData ? null : GetSessionDataManager().getWallItemData(spriteId); + const data = (floorData ?? wallData); + + if(!data) return; + + const iconUrl = (floorData + ? GetRoomEngine().getFurnitureFloorIconUrl(spriteId) + : GetRoomEngine().getFurnitureWallIconUrl(spriteId)); + + list.push({ spriteId, name: (data.name || data.className || `#${ spriteId }`), iconUrl, value }); + }); + + list.sort((a, b) => (b.value.points - a.value.points)); + + return list; + }, [ values ]); + + const filtered = useMemo(() => + { + const query = searchValue.trim().toLocaleLowerCase(); + + if(!query) return rows; + + return rows.filter(row => row.name.toLocaleLowerCase().includes(query)); + }, [ rows, searchValue ]); + + if(!isVisible) return null; + + const updateRow = (id: number, patch: Partial) => + setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); + + return ( + + setIsVisible(false) } /> + { canEdit && + + setTab('values') }> + { LocalizeText('rarevalues.title') } + + setTab('editor') }> + { LocalizeText('rarevalues.editor.tab') } + + } + + { (tab === 'values' || !canEdit) && + + setSearchValue(event.target.value) } /> + + { !loaded && + { LocalizeText('rarevalues.loading') } } + { (loaded && !filtered.length) && + { LocalizeText('rarevalues.empty') } } + { filtered.map(row => ( + + + { row.name } + + { LocalizeFormattedNumber(row.value.points) } + + + + )) } + + } + + { (tab === 'editor' && canEdit) && + + + { LocalizeText('rarevalues.editor.type') } + { LocalizeText('rarevalues.editor.value') } + { LocalizeText('rarevalues.editor.weight') } + { LocalizeText('rarevalues.editor.label') } + + + { editRows.map(row => ( + + + updateRow(row.id, { num: parseInt(event.target.value) || 0 }) } + className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" /> + updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) } + className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> + updateRow(row.id, { label: event.target.value }) } + className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> + + )) } + + + } + + + ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index da4db6c..d2a861c 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -3,8 +3,8 @@ import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaCrosshairs, FaTimes } from 'react-icons/fa'; import { GrFormNextLink, GrRotateLeft, GrRotateRight } from 'react-icons/gr'; import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer } from '../../../../../api'; -import { Button, Column, Flex, LayoutBadgeImageView, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; -import { useHasPermission, useMessageEvent, useNitroEvent, useRoom, useWiredTools } from '../../../../../hooks'; +import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; +import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; interface InfoStandWidgetFurniViewProps @@ -23,6 +23,8 @@ export const InfoStandWidgetFurniView: FC = props const { roomSession = null } = useRoom(); const { openInspectionForFurni, showInspectButton } = useWiredTools(); const isModerator = useHasPermission('acc_anyroomowner'); + const { getValue: getRareValue } = useRareValues(); + const rareValue = useMemo(() => (avatarInfo ? getRareValue(avatarInfo.spriteId) : null), [ avatarInfo, getRareValue ]); const [ pickupMode, setPickupMode ] = useState(0); const [ canMove, setCanMove ] = useState(false); @@ -563,6 +565,17 @@ export const InfoStandWidgetFurniView: FC = props X: { itemLocation.x } · Y: { itemLocation.y } · H: { itemLocation.z < 0.01 ? 0 : itemLocation.z }
} + { (rareValue && rareValue.points > 0) && + <> +
+ + { LocalizeText('rarevalues.infostand.label') } + + { rareValue.points } + + + + } { godMode && <>
diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 6bcc9a3..e088a62 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -250,6 +250,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + { (isInRoom && showToolbarButton) && @@ -358,6 +364,12 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { (getFullCount > 0) && } + + CreateLinkEvent('rare-values/toggle') } className="tb-icon" /> + + + CreateLinkEvent('fortune-wheel/toggle') } className="tb-icon" /> + +{ + const [ freeSpins, setFreeSpins ] = useState(0); + const [ extraSpins, setExtraSpins ] = useState(0); + const [ spinCost, setSpinCost ] = useState(0); + const [ spinCostType, setSpinCostType ] = useState(-1); + const [ prizes, setPrizes ] = useState([]); + const [ recentWins, setRecentWins ] = useState([]); + const [ pendingPrizeId, setPendingPrizeId ] = useState(-1); + const [ isSpinning, setIsSpinning ] = useState(false); + const [ adminPrizes, setAdminPrizes ] = useState([]); + + useMessageEvent(WheelAdminPrizesEvent, event => + { + setAdminPrizes(event.getParser().prizes); + }); + + useMessageEvent(WheelDataEvent, event => + { + const parser = event.getParser(); + setFreeSpins(parser.freeSpins); + setExtraSpins(parser.extraSpins); + setSpinCost(parser.spinCost); + setSpinCostType(parser.spinCostType); + setPrizes(parser.prizes); + }); + + useMessageEvent(WheelResultEvent, event => + { + setPendingPrizeId(event.getParser().prizeId); + setIsSpinning(true); + }); + + useMessageEvent(WheelRecentWinsEvent, event => + { + setRecentWins(event.getParser().wins); + }); + + const open = useCallback(() => SendMessageComposer(new WheelOpenComposer()), []); + const spin = useCallback(() => + { + setIsSpinning(prev => + { + if(!prev) SendMessageComposer(new WheelSpinComposer()); + return prev; + }); + }, []); + const buySpin = useCallback(() => SendMessageComposer(new WheelBuySpinComposer()), []); + const finishSpin = useCallback(() => + { + setIsSpinning(false); + setPendingPrizeId(-1); + }, []); + + const loadAdminPrizes = useCallback(() => SendMessageComposer(new WheelAdminGetPrizesComposer()), []); + const saveAdminPrizes = useCallback((prizes: IWheelAdminPrizeEdit[]) => SendMessageComposer(new WheelAdminSavePrizesComposer(prizes)), []); + + return { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin, adminPrizes, loadAdminPrizes, saveAdminPrizes }; +}; + +export const useFortuneWheel = () => useBetween(useFortuneWheelState); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index a369b37..4b3c1c3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -4,6 +4,7 @@ export * from './camera'; export * from './catalog'; export * from './chat-history'; export * from './events'; +export * from './fortune-wheel/useFortuneWheel'; export * from './friends'; export * from './game-center'; export * from './groups'; @@ -14,6 +15,7 @@ export * from './mod-tools'; export * from './navigator'; export * from './notification'; export * from './purse'; +export * from './rare-values/useRareValues'; export * from './rooms'; export * from './rooms/engine'; export * from './rooms/promotes'; diff --git a/src/hooks/rare-values/useRareValues.ts b/src/hooks/rare-values/useRareValues.ts new file mode 100644 index 0000000..d048db1 --- /dev/null +++ b/src/hooks/rare-values/useRareValues.ts @@ -0,0 +1,31 @@ +import { IRareValue, RareValuesEvent, RequestRareValuesComposer } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useState } from 'react'; +import { useBetween } from 'use-between'; +import { SendMessageComposer } from '../../api'; +import { useMessageEvent } from '../events'; + +// spriteId -> catalog value, fetched once from the server (RareValuesComposer). +// Shared across all consumers via useBetween so the request fires a single time. +// Read by both the furni infostand and the toolbar "Valore Rari" panel. +const useRareValuesState = () => +{ + const [ values, setValues ] = useState>(() => new Map()); + const [ loaded, setLoaded ] = useState(false); + + useMessageEvent(RareValuesEvent, event => + { + setValues(event.getParser().values); + setLoaded(true); + }); + + useEffect(() => + { + SendMessageComposer(new RequestRareValuesComposer()); + }, []); + + const getValue = useCallback((spriteId: number): IRareValue => (values.get(spriteId) ?? null), [ values ]); + + return { values, loaded, getValue }; +}; + +export const useRareValues = () => useBetween(useRareValuesState); From 7a65e5bf6dbd936b98d1c4de56e12a6c1c6c1d81 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 09:04:17 +0200 Subject: [PATCH 23/86] feat: soundboard (room-scoped custom audio pads) Client side of the soundboard. Room owners enable it in Room Settings > Misc (next to the YouTube TV toggle). When enabled, a soundboard icon appears in the toolbar for everyone in the room; pressing a pad broadcasts the sound so all occupants hear it. Incoming SoundboardPlay is played via the HTML5 Audio API. Also: fix FloorplanCanvasSVG to use ReactElement instead of the removed global JSX namespace (React 19), and pair the client Dev branch with the renderer fork that carries the custom features in CI. How sounds are managed (works with any CMS): Sounds are rows in the `soundboard_sounds` table: id, name, url, enabled, sort_order The emulator loads every row with enabled=1 (ordered by sort_order, id) and sends the list to clients on room enter; the client plays `url` directly, so any publicly reachable audio URL works (mp3/ogg/wav). To add a sound from an admin/housekeeping panel of any CMS: 1. Upload the audio file to wherever the CMS stores public assets (same approach as custom badge images). 2. INSERT a row into `soundboard_sounds` with the display name and the public URL of the uploaded file, enabled = 1. 3. Reload the emulator soundboard (or restart) to pick it up. Relative urls resolve against the `soundboard.url.prefix` config key (falls back to `asset.url`); absolute urls are used as-is. --- .github/workflows/ci.yml | 8 ++ public/configuration/wheel-texts-en.example | 6 +- public/configuration/wheel-texts-it.example | 6 +- src/api/index.ts | 1 + src/api/soundboard/SoundboardRoomState.ts | 7 ++ src/api/soundboard/index.ts | 1 + src/components/MainView.tsx | 2 + .../views/FloorplanCanvasSVG.tsx | 4 +- .../NavigatorRoomSettingsMiscTabView.tsx | 28 ++++++- src/components/soundboard/SoundboardView.tsx | 74 +++++++++++++++++++ src/components/toolbar/ToolbarView.tsx | 14 +++- src/css/icons/icons.css | 7 ++ src/hooks/index.ts | 1 + src/hooks/soundboard/useSoundboard.ts | 73 ++++++++++++++++++ 14 files changed, 225 insertions(+), 7 deletions(-) create mode 100644 src/api/soundboard/SoundboardRoomState.ts create mode 100644 src/api/soundboard/index.ts create mode 100644 src/components/soundboard/SoundboardView.tsx create mode 100644 src/hooks/soundboard/useSoundboard.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6acb525..2760f66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - main + - Dev - 'feat/**' pull_request: workflow_dispatch: @@ -93,6 +94,13 @@ jobs: AUTO_REPO="duckietm/Nitro_Render_V3" AUTO_REF="main" ;; + Dev) + # The client `Dev` branch carries the custom features + # (rare values, fortune wheel, soundboard); they live on + # the matching renderer fork branch, not upstream. + AUTO_REPO="medievalshell/Nitro_Render_V3" + AUTO_REF="dev" + ;; feat/housekeeping-panel) AUTO_REPO="simoleo89/Nitro_Render_V3" AUTO_REF="feat/housekeeping-packets" diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example index 71b3b1b..f10f302 100644 --- a/public/configuration/wheel-texts-en.example +++ b/public/configuration/wheel-texts-en.example @@ -5,5 +5,9 @@ "wheel.spin": "SPIN", "wheel.buy": "Buy spin", "wheel.winners": "Latest winners", - "wheel.winners.empty": "No winners yet" + "wheel.winners.empty": "No winners yet", + "soundboard.title": "Soundboard", + "soundboard.empty": "No sounds available", + "soundboard.lastplayed": "Played by %user%", + "soundboard.room.setting.desc": "Let people in this room play sound effects" } diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example index f5bb934..0cd342e 100644 --- a/public/configuration/wheel-texts-it.example +++ b/public/configuration/wheel-texts-it.example @@ -5,5 +5,9 @@ "wheel.spin": "GIRA", "wheel.buy": "Compra giro", "wheel.winners": "Ultimi vincitori", - "wheel.winners.empty": "Ancora nessun vincitore" + "wheel.winners.empty": "Ancora nessun vincitore", + "soundboard.title": "Soundboard", + "soundboard.empty": "Nessun suono disponibile", + "soundboard.lastplayed": "Suonato da %user%", + "soundboard.room.setting.desc": "Permetti ai presenti di suonare effetti audio in questa stanza" } diff --git a/src/api/index.ts b/src/api/index.ts index 6108472..424bb4f 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -27,6 +27,7 @@ export * from './purse'; export * from './room'; export * from './room/events'; export * from './room/widgets'; +export * from './soundboard'; export * from './ui-settings'; export * from './user'; export * from './utils'; diff --git a/src/api/soundboard/SoundboardRoomState.ts b/src/api/soundboard/SoundboardRoomState.ts new file mode 100644 index 0000000..cded2a1 --- /dev/null +++ b/src/api/soundboard/SoundboardRoomState.ts @@ -0,0 +1,7 @@ +let _soundboardEnabled = false; + +export const getSoundboardRoomEnabled = () => _soundboardEnabled; +export const setSoundboardRoomEnabled = (enabled: boolean) => +{ + _soundboardEnabled = enabled; +}; diff --git a/src/api/soundboard/index.ts b/src/api/soundboard/index.ts new file mode 100644 index 0000000..2ccb6a5 --- /dev/null +++ b/src/api/soundboard/index.ts @@ -0,0 +1 @@ +export * from './SoundboardRoomState'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 494e948..39ae1b8 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -26,6 +26,7 @@ import { HotelView } from './hotel-view/HotelView'; import { HousekeepingView } from './housekeeping/HousekeepingView'; import { RareValuesView } from './rare-values/RareValuesView'; import { FortuneWheelView } from './fortune-wheel/FortuneWheelView'; +import { SoundboardView } from './soundboard/SoundboardView'; import { InventoryView } from './inventory/InventoryView'; import { ModToolsView } from './mod-tools/ModToolsView'; import { NavigatorView } from './navigator/NavigatorView'; @@ -180,6 +181,7 @@ export const MainView: FC<{}> = props => + ); diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx index 55e9c60..3951ecd 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.tsx @@ -1,4 +1,4 @@ -import { Dispatch, FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react'; +import { Dispatch, FC, PointerEvent as ReactPointerEvent, ReactElement, useCallback, useEffect, useMemo, useRef, useState, WheelEvent } from 'react'; import { FaCrosshairs, FaSearchMinus, FaSearchPlus, FaSyncAlt } from 'react-icons/fa'; import { FloorplanAction, FloorplanState } from '../state/types'; import { FloorplanTile } from './FloorplanTile'; @@ -140,7 +140,7 @@ export const FloorplanCanvasSVG: FC = ({ state, dispatch, panMode }) => const quarter = TILE_SIZE / 4; const tilesRows = state.tiles.length; const tilesCols = state.tiles[0]?.length ?? 0; - const out: JSX.Element[] = []; + const out: ReactElement[] = []; for(const key of state.selection) { const [ rStr, cStr ] = key.split(','); diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx index 5d151d3..d82e0ba 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx @@ -1,7 +1,7 @@ import { YouTubeRoomSettingsComposer, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; import { getYoutubeRoomEnabled, IRoomData, LocalizeText, SendMessageComposer, setYoutubeRoomEnabled } from '../../../../api'; -import { useMessageEvent } from '../../../../hooks'; +import { useMessageEvent, useSoundboard } from '../../../../hooks'; interface NavigatorRoomSettingsMiscTabViewProps { @@ -13,6 +13,7 @@ export const NavigatorRoomSettingsMiscTabView: FC(YouTubeRoomSettingsEvent, event => { @@ -29,6 +30,14 @@ export const NavigatorRoomSettingsMiscTabView: FC setCooldown(false), 300); }; + const toggleSoundboard = (enabled: boolean) => + { + if (cooldown) return; + setSoundboardEnabled(enabled); + setCooldown(true); + setTimeout(() => setCooldown(false), 300); + }; + return ( <>
@@ -52,6 +61,23 @@ export const NavigatorRoomSettingsMiscTabView: FC
+
+
+
+
🔊 { LocalizeText('soundboard.title') }
+
{ LocalizeText('soundboard.room.setting.desc') }
+
+ +
+
); diff --git a/src/components/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx new file mode 100644 index 0000000..6508d78 --- /dev/null +++ b/src/components/soundboard/SoundboardView.tsx @@ -0,0 +1,74 @@ +import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { LocalizeText } from '../../api'; +import { Column, Flex, Text } from '../../common'; +import { useSoundboard } from '../../hooks'; +import { NitroCard } from '../../layout'; + +export const SoundboardView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { enabled, sounds, lastPlayed, play } = useSoundboard(); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': setIsVisible(true); return; + case 'hide': setIsVisible(false); return; + case 'toggle': setIsVisible(prev => !prev); return; + } + }, + eventUrlPrefix: 'soundboard/', + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + // The soundboard belongs to the room — close it when the room turns it off. + useEffect(() => + { + if(!enabled) setIsVisible(false); + }, [ enabled ]); + + if(!isVisible || !enabled) return null; + + return ( + + setIsVisible(false) } /> + + + { !sounds.length && + { LocalizeText('soundboard.empty') } } + { !!sounds.length && +
+ { sounds.map(sound => ( + + )) } +
} + { lastPlayed && + + + { LocalizeText('soundboard.lastplayed', [ 'user' ], [ lastPlayed.username ]) } + + } +
+
+
+ ); +}; diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index e088a62..adf3285 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -3,7 +3,7 @@ import { AnimatePresence, motion, Variants } from 'framer-motion'; import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { GetConfigurationValue, isHousekeepingEnabled, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; -import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useWiredTools } from '../../hooks'; +import { useAchievements, useFriends, useHasPermission, useInventoryUnseenTracker, useMessageEvent, useMessenger, useModTools, useNitroEvent, useSessionInfo, useSoundboard, useWiredTools } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; import { YouTubePlayerView } from './YouTubePlayerView'; @@ -42,6 +42,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { requests = [] } = useFriends(); const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const { openMonitor, showToolbarButton } = useWiredTools(); + const { enabled: soundboardEnabled, reset: resetSoundboard } = useSoundboard(); const isMod = useHasPermission('acc_supporttool'); const isHk = useHasPermission('acc_housekeeping'); const hkEnabled = useMemo(() => isHousekeepingEnabled(), []); @@ -99,8 +100,9 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { setYoutubeEnabled(false); setYoutubeRoomEnabled(false); + resetSoundboard(); } - }, [ isInRoom ]); + }, [ isInRoom, resetSoundboard ]); useEffect(() => { @@ -268,6 +270,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> @@ -386,6 +392,10 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } + { (isInRoom && soundboardEnabled) && + + CreateLinkEvent('soundboard/toggle') } className="tb-icon" /> + } { isMod && CreateLinkEvent('mod-tools/toggle') } className="tb-icon" /> diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 0a81781..17a903f 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -216,6 +216,13 @@ height: 36px; } +.nitro-icon.icon-soundboard { + background-image: url("@/assets/images/toolbar/icons/game.png"); + width: 44px; + height: 25px; + filter: hue-rotate(90deg) saturate(1.5); +} + .nitro-icon.icon-message { background-image: url("@/assets/images/toolbar/icons/message.png"); width: 36px; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 4b3c1c3..dc3d9a6 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,6 +22,7 @@ export * from './rooms/promotes'; export * from './rooms/widgets'; export * from './rooms/widgets/furniture'; export * from './session'; +export * from './soundboard/useSoundboard'; export * from './translation'; export * from './useLocalStorage'; export * from './useSharedVisibility'; diff --git a/src/hooks/soundboard/useSoundboard.ts b/src/hooks/soundboard/useSoundboard.ts new file mode 100644 index 0000000..d436962 --- /dev/null +++ b/src/hooks/soundboard/useSoundboard.ts @@ -0,0 +1,73 @@ +import { ISoundboardSound, SoundboardPlayComposer, SoundboardPlayEvent, SoundboardSetEnabledComposer, SoundboardSettingsEvent } from '@nitrots/nitro-renderer'; +import { useCallback, useState } from 'react'; +import { useBetween } from 'use-between'; +import { GetConfigurationValue, SendMessageComposer, setSoundboardRoomEnabled } from '../../api'; +import { useMessageEvent } from '../events'; + +// Resolve a stored sound url (which may be relative, like custom badges) to an +// absolute one against the asset host. +const resolveUrl = (url: string): string => +{ + if(!url) return ''; + if(/^https?:\/\//i.test(url) || url.startsWith('//')) return url; + + const base = (GetConfigurationValue('soundboard.url.prefix') || GetConfigurationValue('asset.url') || '').replace(/\/+$/, ''); + return base ? `${ base }/${ url.replace(/^\/+/, '') }` : url; +}; + +// Soundboard state + actions. Shared via useBetween so the event listeners +// register once regardless of how many components read it (toolbar + view). +const useSoundboardState = () => +{ + const [ enabled, setEnabled ] = useState(false); + const [ sounds, setSounds ] = useState([]); + const [ lastPlayed, setLastPlayed ] = useState<{ soundId: number; username: string } | null>(null); + + useMessageEvent(SoundboardSettingsEvent, event => + { + const parser = event.getParser(); + setEnabled(parser.enabled); + setSounds(parser.sounds); + setSoundboardRoomEnabled(parser.enabled); + }); + + useMessageEvent(SoundboardPlayEvent, event => + { + const parser = event.getParser(); + const url = resolveUrl(parser.url); + + if(url) + { + try + { + const audio = new Audio(url); + audio.volume = 0.8; + void audio.play().catch(() => {}); + } + catch {} + } + + setLastPlayed({ soundId: parser.soundId, username: parser.username }); + }); + + const play = useCallback((soundId: number) => SendMessageComposer(new SoundboardPlayComposer(soundId)), []); + const setRoomEnabled = useCallback((value: boolean) => + { + setEnabled(value); + setSoundboardRoomEnabled(value); + SendMessageComposer(new SoundboardSetEnabledComposer(value)); + }, []); + + // Local-only clear (e.g. when leaving the room) — does not notify the server. + const reset = useCallback(() => + { + setEnabled(false); + setSounds([]); + setLastPlayed(null); + setSoundboardRoomEnabled(false); + }, []); + + return { enabled, sounds, lastPlayed, play, setRoomEnabled, reset }; +}; + +export const useSoundboard = () => useBetween(useSoundboardState); From 48ed3ad7ba56ae5e6c062b0d103c805374344f40 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 09:20:20 +0200 Subject: [PATCH 24/86] fix: show furniture-occupied tiles in the floor plan editor The editor never requested occupied tiles, so tiles holding furniture were indistinguishable from empty floor and could be edited/voided. - request GetOccupiedTilesMessageComposer when the editor opens - handle RoomOccupiedTilesMessageEvent -> SET_OCCUPIED_TILES - new Tile.occupied flag (kept separate from `blocked`/void): occupied tiles render with a distinct marker and are protected from PAINT/ ERASE/ADJUST and brush-to-selection edits - occupied is purely informational and never changes the saved tilemap (no accidental voiding of floor under furni) Tests: reducer cases for SET_OCCUPIED_TILES + edit protection; container test asserts the occupied event is non-destructive on save; route the canvas pointer test through elementFromPoint (jsdom has no getScreenCTM). --- .../FloorplanEditorView.test.tsx | 7 +++-- .../floorplan-editor/FloorplanEditorView.tsx | 10 ++++++- .../floorplan-editor/state/reducer.test.ts | 30 +++++++++++++++++++ .../floorplan-editor/state/reducer.ts | 21 ++++++++++++- .../floorplan-editor/state/types.ts | 6 +++- .../views/FloorplanCanvasSVG.test.tsx | 9 +++++- .../floorplan-editor/views/FloorplanTile.tsx | 11 +++++++ 7 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/components/floorplan-editor/FloorplanEditorView.test.tsx b/src/components/floorplan-editor/FloorplanEditorView.test.tsx index 5603025..a665fa3 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.test.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.test.tsx @@ -161,7 +161,7 @@ describe('FloorplanEditorView container', () => expect(composer.thicknessFloor).toBe(1); }); - it('RoomOccupiedTilesMessageEvent marks blockedTilesMap entries as blocked in state', () => + it('RoomOccupiedTilesMessageEvent marks tiles occupied without altering the saved tilemap', () => { openEditor(); const fhmHandler = messageHandlers.get(FloorHeightMapEvent); @@ -178,8 +178,9 @@ describe('FloorplanEditorView container', () => fireEvent.click(saveBtn!); const composer = sendMessageComposer.mock.calls[0][0]; expect(composer).toBeInstanceOf(UpdateFloorPropertiesMessageComposer); - // Row separator is \r per serializeTilemap; row 0 was '00', col 1 blocked → '0x' - expect(composer.tilemap.split(/\r/)[0]).toBe('0x'); + // Occupied is purely informational: the tile stays walkable and the + // saved tilemap is unchanged (row 0 stays '00', NOT voided to '0x'). + expect(composer.tilemap.split(/\r/)[0]).toBe('00'); }); it('RoomEngineEvent.DISPOSED hides the editor', () => diff --git a/src/components/floorplan-editor/FloorplanEditorView.tsx b/src/components/floorplan-editor/FloorplanEditorView.tsx index 8094d1b..b8c52be 100644 --- a/src/components/floorplan-editor/FloorplanEditorView.tsx +++ b/src/components/floorplan-editor/FloorplanEditorView.tsx @@ -1,4 +1,4 @@ -import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, convertNumbersForSaving, convertSettingToNumber, FloorHeightMapEvent, GetOccupiedTilesMessageComposer, GetRoomEntryTileMessageComposer, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEntryTileMessageEvent, RoomOccupiedTilesMessageEvent, RoomVisualizationSettingsEvent, UpdateFloorPropertiesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { FaBolt, FaBoxOpen, FaCaretLeft, FaCaretRight } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; @@ -50,8 +50,16 @@ export const FloorplanEditorView: FC = () => { if(!isVisible) return; SendMessageComposer(new GetRoomEntryTileMessageComposer()); + // Ask the server which tiles currently hold furniture so they can be + // shown (and protected from editing) in the grid. + SendMessageComposer(new GetOccupiedTilesMessageComposer()); }, [ isVisible ]); + useMessageEvent(RoomOccupiedTilesMessageEvent, event => + { + dispatch({ type: 'SET_OCCUPIED_TILES', map: event.getParser().blockedTilesMap }); + }); + useMessageEvent(RoomEntryTileMessageEvent, event => { const parser = event.getParser(); diff --git a/src/components/floorplan-editor/state/reducer.test.ts b/src/components/floorplan-editor/state/reducer.test.ts index 689e9ad..1fb84f8 100644 --- a/src/components/floorplan-editor/state/reducer.test.ts +++ b/src/components/floorplan-editor/state/reducer.test.ts @@ -106,6 +106,36 @@ describe('reducer — ADJUST_HEIGHT', () => const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); expect(next).toBe(start); }); + + it('is a no-op on occupied tiles', () => + { + const start = stateWith([[{ h: 5, blocked: false, occupied: true }]]); + const next = reducer(start, { type: 'ADJUST_HEIGHT', row: 0, col: 0, delta: 1, source: 'local' }); + expect(next).toBe(start); + }); +}); + +describe('reducer — SET_OCCUPIED_TILES', () => +{ + it('marks tiles occupied per the map without touching h or blocked', () => + { + const start = stateWith([[{ h: 2, blocked: false }, { h: 0, blocked: true }]]); + const next = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[true, false]] }); + expect(next.tiles[0][0]).toEqual({ h: 2, blocked: false, occupied: true }); + // already-unoccupied tile is left untouched (no spurious occupied key) + expect(next.tiles[0][1]).toEqual({ h: 0, blocked: true }); + }); + + it('does not block editing of non-occupied tiles', () => + { + const start = stateWith([[{ h: 0, blocked: false }, { h: 0, blocked: false }]]); + const occupied = reducer(start, { type: 'SET_OCCUPIED_TILES', map: [[false, true]] }); + // col 0 (not occupied) can still be painted; col 1 (occupied) cannot + const painted = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 0, h: 5, source: 'local' }); + expect(painted.tiles[0][0].h).toBe(5); + const blocked = reducer(occupied, { type: 'PAINT_TILE', row: 0, col: 1, h: 9, source: 'local' }); + expect(blocked).toBe(occupied); + }); }); describe('reducer — SET_DOOR', () => diff --git a/src/components/floorplan-editor/state/reducer.ts b/src/components/floorplan-editor/state/reducer.ts index 876afcb..c6d5840 100644 --- a/src/components/floorplan-editor/state/reducer.ts +++ b/src/components/floorplan-editor/state/reducer.ts @@ -52,6 +52,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl { const row = clamp64(action.row); const col = clamp64(action.col); + if(state.tiles[row]?.[col]?.occupied) return state; const tiles = ensureRect(state.tiles, row + 1, col + 1); const target = { h: clampHeight(action.h), blocked: false }; const next = setTile(tiles, row, col, target); @@ -64,6 +65,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; + if(current.occupied) return state; const target = { h: current.h, blocked: true }; const next = setTile(state.tiles, row, col, target); if(next === state.tiles) return state; @@ -75,7 +77,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = action.col | 0; if(row < 0 || col < 0 || row >= state.tiles.length || col >= (state.tiles[0]?.length ?? 0)) return state; const current = state.tiles[row][col]; - if(current.blocked) return state; + if(current.blocked || current.occupied) return state; const newH = clampHeight(current.h + action.delta); if(newH === current.h) return state; const next = setTile(state.tiles, row, col, { h: newH, blocked: false }); @@ -106,6 +108,22 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl if(value === state.wallHeight) return state; return { ...state, wallHeight: value }; } + case 'SET_OCCUPIED_TILES': + { + // Mark tiles that currently hold furniture (server-reported). Leaves + // height + blocked untouched so it never alters the saved tilemap. + const map = action.map ?? []; + let changed = false; + const tiles = state.tiles.map((r, ri) => r.map((tile, ci) => + { + const occ = !!map[ri]?.[ci]; + if((tile.occupied ?? false) === occ) return tile; + changed = true; + return { ...tile, occupied: occ }; + })); + if(!changed) return state; + return { ...state, tiles }; + } case 'BRUSH_SET': { const h = action.h ?? state.brush.h; @@ -174,6 +192,7 @@ export const reducer = (state: FloorplanState, action: FloorplanAction): Floorpl const col = parseInt(cStr, 10); const current = tiles[row]?.[col]; if(!current) continue; + if(current.occupied) continue; switch(state.brush.action) { diff --git a/src/components/floorplan-editor/state/types.ts b/src/components/floorplan-editor/state/types.ts index 1361e38..fb30bd8 100644 --- a/src/components/floorplan-editor/state/types.ts +++ b/src/components/floorplan-editor/state/types.ts @@ -1,4 +1,7 @@ -export type Tile = { h: number; blocked: boolean }; +// `blocked` = void tile (no floor, serialized as 'x'). `occupied` = a tile that +// currently has furniture on it (reported by the server); kept separate so it +// stays visible and is NOT voided on save — it just can't be edited. +export type Tile = { h: number; blocked: boolean; occupied?: boolean }; export type EntryDir = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7; export type ThicknessLevel = 0 | 1 | 2 | 3; @@ -39,6 +42,7 @@ export type FloorplanAction = | { type: 'SET_DOOR_DIR'; dir: EntryDir; source: LocalSource } | { type: 'SET_THICKNESS'; wall?: ThicknessLevel; floor?: ThicknessLevel; source: LocalSource } | { type: 'SET_WALL_HEIGHT'; value: number; source: LocalSource } + | { type: 'SET_OCCUPIED_TILES'; map: boolean[][] } | { type: 'BRUSH_SET'; h?: number; action?: FloorActionMode } | { type: 'SELECT_RECT'; from: [number, number]; to: [number, number] } | { type: 'SELECT_ALL' } diff --git a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx index bb61ab5..a275776 100644 --- a/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx +++ b/src/components/floorplan-editor/views/FloorplanCanvasSVG.test.tsx @@ -40,11 +40,18 @@ describe('FloorplanCanvasSVG', () => const dispatch = vi.fn(); const { container } = render(); const svg = container.querySelector('svg') as SVGSVGElement; - svg.getBoundingClientRect = () => ({ left: 0, top: 0, right: 2048, bottom: 1024, width: 2048, height: 1024, x: 0, y: 0, toJSON: () => ({}) }); + // usePointerToTile resolves the tile via document.elementFromPoint first + // (the tile polygons carry data-row/data-col). jsdom returns null and has + // no SVGSVGElement.getScreenCTM, so point the hit-test at the tile polygon. + const tilePoly = container.querySelector('polygon[data-row="0"][data-col="0"]') as Element; + // jsdom's document has no elementFromPoint at all — define it for this test. + const prevEfp = (document as { elementFromPoint?: unknown }).elementFromPoint; + (document as unknown as { elementFromPoint: (x: number, y: number) => Element | null }).elementFromPoint = () => tilePoly; fireEvent.pointerDown(svg, { clientX: 1024, clientY: 0, pointerId: 1 }); expect(dispatch).toHaveBeenCalled(); const call = dispatch.mock.calls[0][0]; expect(call.type).toBe('PAINT_TILE'); + (document as { elementFromPoint?: unknown }).elementFromPoint = prevEfp; }); it('zoom in/out buttons adjust the viewBox', () => diff --git a/src/components/floorplan-editor/views/FloorplanTile.tsx b/src/components/floorplan-editor/views/FloorplanTile.tsx index 7fca484..5c0c8ce 100644 --- a/src/components/floorplan-editor/views/FloorplanTile.tsx +++ b/src/components/floorplan-editor/views/FloorplanTile.tsx @@ -104,6 +104,17 @@ const FloorplanTileImpl: FC = ({ row, col, tile, selected, isDoor, southH stroke="#222" strokeWidth={ 0.5 } /> + { tile.occupied && ( + + ) } { selected && ( Date: Thu, 28 May 2026 10:19:16 +0200 Subject: [PATCH 25/86] feat: soundboard pads can load from a JSON5 file (DB fallback) When the server (soundboard_sounds table) returns no pads, the client now loads them from a JSON5 config file (loadGamedata accepts plain JSON and JSON5). Useful when the DB / CMS isn't set up yet. File-defined pads play locally for the clicker; DB-backed pads still go through the server broadcast so everyone in the room hears them. Ships a radio-style soundboard-sounds.json5.example template. --- .../soundboard-sounds.json5.example | 20 +++++ src/components/soundboard/SoundboardView.tsx | 2 +- src/hooks/soundboard/useSoundboard.ts | 87 ++++++++++++++----- 3 files changed, 88 insertions(+), 21 deletions(-) create mode 100644 public/configuration/soundboard-sounds.json5.example diff --git a/public/configuration/soundboard-sounds.json5.example b/public/configuration/soundboard-sounds.json5.example new file mode 100644 index 0000000..dffaaa8 --- /dev/null +++ b/public/configuration/soundboard-sounds.json5.example @@ -0,0 +1,20 @@ +{ + // Soundboard pads loaded from a file — used as a FALLBACK when the server + // (soundboard_sounds DB table) returns no sounds. Copy this file to + // `soundboard-sounds.json5` (without .example) and add your sounds. JSON5: + // // comments and trailing commas are allowed. + // + // Fields: + // id - unique number (pad key) + // name - label shown on the pad + // url - audio file URL (mp3/ogg/wav). Relative urls resolve against + // `soundboard.url.prefix` (falls back to `asset.url`). + // + // NOTE: file-defined pads play LOCALLY for the person who clicks them. To + // broadcast a pad to everyone in the room, the sound must exist server-side + // in the soundboard_sounds table (same flow as custom badges). The file is + // the no-DB / offline option; the DB is the multiplayer one. + sounds: [ + // { id: 1, name: 'Airhorn', url: 'https://your-host/airhorn.mp3' }, + ], +} diff --git a/src/components/soundboard/SoundboardView.tsx b/src/components/soundboard/SoundboardView.tsx index 6508d78..76d7a80 100644 --- a/src/components/soundboard/SoundboardView.tsx +++ b/src/components/soundboard/SoundboardView.tsx @@ -53,7 +53,7 @@ export const SoundboardView: FC<{}> = () => { sounds.map(sound => ( +
+
{ selected ? selected.name : LocalizeText('radio.title') }
+
+ { selectedPlaying && + + Live + } + { selected?.genre && + { selected.genre } } +
+
+ +
+ + { selectedPlaying && +
+ 🔊 + setVolume(e.target.valueAsNumber) } + className="radio-vol h-1 grow cursor-pointer" + /> +
} + + { open && +
+ { loadError && +
{ LocalizeText('radio.error') }
} + { !loadError && !stations.length && +
{ LocalizeText('radio.empty') }
} + { /* ~3 rows tall, scrolls when there are more */ } +
+ { stations.map(station => + { + const isActive = station.id === selectedId; + const playingThis = (currentId === station.id) && isPlaying; + return ( +
onPick(station) } + className={ `flex cursor-pointer items-center gap-2.5 rounded-lg px-2 py-1.5 transition-colors ${ isActive ? 'bg-sky-500/15 ring-1 ring-sky-400/40' : 'hover:bg-white/8' }` }> + { station.logo + ? + :
+ { playingThis ? : } +
} +
+
{ station.name }
+ { station.genre && +
{ station.genre }
} +
+ { playingThis && +
+ +
} +
+ ); + }) } +
+
} +
+ ); +}; diff --git a/src/hooks/index.ts b/src/hooks/index.ts index dc3d9a6..8a85f2a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -15,6 +15,7 @@ export * from './mod-tools'; export * from './navigator'; export * from './notification'; export * from './purse'; +export * from './radio/useRadio'; export * from './rare-values/useRareValues'; export * from './rooms'; export * from './rooms/engine'; diff --git a/src/hooks/radio/useRadio.ts b/src/hooks/radio/useRadio.ts new file mode 100644 index 0000000..88bff81 --- /dev/null +++ b/src/hooks/radio/useRadio.ts @@ -0,0 +1,147 @@ +import { loadGamedata } from '@nitrots/nitro-renderer'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useBetween } from 'use-between'; +import { GetConfigurationValue } from '../../api'; + +export type RadioStation = { + id: string; + name: string; + genre?: string; + url: string; + logo?: string; +}; + +// Hotel radio: a list of streaming URLs played client-side with HTML5 Audio. +// The station list comes from a JSON5 config file (loadGamedata accepts plain +// JSON and JSON5). Shared via useBetween so playback is a single instance no +// matter how many components read it. +const useRadioState = () => +{ + const [ stations, setStations ] = useState([]); + const [ currentId, setCurrentId ] = useState(null); + const [ isPlaying, setIsPlaying ] = useState(false); + const [ loadError, setLoadError ] = useState(null); + const [ volume, setVolumeState ] = useState(0.05); // start quiet (5%) so autostart isn't intrusive + const audioRef = useRef(null); + const loadStartedRef = useRef(false); + const autoStartedRef = useRef(false); + + useEffect(() => + { + if(loadStartedRef.current) return; + loadStartedRef.current = true; + + const url = GetConfigurationValue('radio.stations.url') || 'configuration/radio-stations.json5'; + + (async () => + { + try + { + const json = await loadGamedata<{ stations?: RadioStation[] }>(url); + const list = Array.isArray(json?.stations) + ? json.stations.filter(s => s && s.id && s.url) + : []; + setStations(list); + } + catch(error) + { + setLoadError(String((error as Error)?.message ?? error)); + } + })(); + }, []); + + // Tear down the stream when the hook instance goes away. + useEffect(() => () => + { + if(audioRef.current) + { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + }, []); + + const stop = useCallback(() => + { + if(audioRef.current) + { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + setIsPlaying(false); + setCurrentId(null); + }, []); + + // Browsers block audio that starts without a user gesture (autoplay policy), + // so the startup autostart may be refused. When that happens, resume on the + // very first click / keypress anywhere. + const armResumeOnGesture = useCallback(() => + { + const resume = () => + { + window.removeEventListener('pointerdown', resume); + window.removeEventListener('keydown', resume); + if(audioRef.current) void audioRef.current.play().then(() => setIsPlaying(true)).catch(() => {}); + }; + window.addEventListener('pointerdown', resume, { once: true }); + window.addEventListener('keydown', resume, { once: true }); + }, []); + + const play = useCallback((station: RadioStation) => + { + if(!station?.url) return; + + if(audioRef.current) + { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + + try + { + const audio = new Audio(station.url); + audio.volume = volume; + audioRef.current = audio; + setCurrentId(station.id); + void audio.play().then(() => setIsPlaying(true)).catch(() => + { + // Likely autoplay-blocked — keep the station selected and resume + // on the first user interaction instead of dropping it. + setIsPlaying(false); + armResumeOnGesture(); + }); + } + catch + { + setIsPlaying(false); + setCurrentId(null); + } + }, [ volume, armResumeOnGesture ]); + + // Autostart the first station once on client load (quiet, see initial volume). + useEffect(() => + { + if(autoStartedRef.current || !stations.length) return; + autoStartedRef.current = true; + play(stations[0]); + }, [ stations, play ]); + + const toggle = useCallback((station: RadioStation) => + { + if(currentId === station.id) stop(); + else play(station); + }, [ currentId, play, stop ]); + + const setVolume = useCallback((value: number) => + { + const v = Math.max(0, Math.min(1, value)); + setVolumeState(v); + if(audioRef.current) audioRef.current.volume = v; + }, []); + + return { stations, currentId, isPlaying, volume, loadError, play, stop, toggle, setVolume }; +}; + +export const useRadio = () => useBetween(useRadioState); From 69042451e6fcd8cc367679c3a9bb4d41a9078f92 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 13:01:11 +0200 Subject: [PATCH 27/86] =?UTF-8?q?=F0=9F=86=95=20Added=20the=20option=20tur?= =?UTF-8?q?n=20in=20menu=20for=20BOT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/room/widgets/BotSkillsEnum.ts | 1 + .../avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/api/room/widgets/BotSkillsEnum.ts b/src/api/room/widgets/BotSkillsEnum.ts index b879cdc..8eef2d1 100644 --- a/src/api/room/widgets/BotSkillsEnum.ts +++ b/src/api/room/widgets/BotSkillsEnum.ts @@ -11,6 +11,7 @@ export class BotSkillsEnum public static NUX_PROCEED: number = 8; public static CHANGE_BOT_MOTTO: number = 9; public static NUX_TAKE_TOUR: number = 10; + public static ROTATE: number = 11; public static NO_PICK_UP: number = 12; public static NAVIGATOR_SEARCH: number = 14; public static DONATE_TO_USER: number = 24; diff --git a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx index f9e962b..9953ace 100644 --- a/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx +++ b/src/components/room/widgets/avatar-info/menu/AvatarInfoWidgetRentableBotView.tsx @@ -109,6 +109,10 @@ export const AvatarInfoWidgetRentableBotView: FC processAction('dance') }> { LocalizeText('avatar.widget.dance') } } + { (avatarInfo.botSkills.indexOf(BotSkillsEnum.ROTATE) >= 0) && + processAction('rotate') }> + { LocalizeText('tooltip.roombuilding.rotate') } + } { (avatarInfo.botSkills.indexOf(BotSkillsEnum.NO_PICK_UP) === -1) && processAction('pick') }> { LocalizeText('avatar.widget.pick_up') } From 05d71dd163e93b5573dae234f3933d84c71b8aaa Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 13:46:44 +0200 Subject: [PATCH 28/86] =?UTF-8?q?=F0=9F=86=99=20Small=20fix=20for=20the=20?= =?UTF-8?q?navigator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This mirrors what the old god-hook used to do and what the rest of the codebase still uses for everything else. The TanStack one-shot listener pattern (awaitNitroResponse registers a listener, awaits one matching response, removes itself) is fragile against renderer-bundle quirks — the parser fires but the listener never matches, so the promise never resolves and query.data stays undefined forever. That's exactly the symptom you saw: server logs show the response arriving, client UI stays blank. --- src/hooks/navigator/useNavigatorSearch.ts | 66 +++++++++++------------ 1 file changed, 32 insertions(+), 34 deletions(-) diff --git a/src/hooks/navigator/useNavigatorSearch.ts b/src/hooks/navigator/useNavigatorSearch.ts index b120001..ab3744d 100644 --- a/src/hooks/navigator/useNavigatorSearch.ts +++ b/src/hooks/navigator/useNavigatorSearch.ts @@ -1,47 +1,45 @@ -import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, - NavigatorSearchResultSet, RoomSettingsUpdatedEvent } from '@nitrots/nitro-renderer'; -import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; +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'; -const SEARCH_BASE_KEY = [ 'navigator', 'search' ] as const; -/** - * TanStack Query wrapper for navigator search. - * - * Cache key: ['navigator', 'search', tabCode, filter] - * - Fires NavigatorSearchComposer(tabCode, filter) on miss. - * - Listens for NavigatorSearchEvent and resolves with the result. - * - accept-filter: rejects events whose result.code !== tabCode (defends - * against server-side cross-tab pushes resolving the wrong query slot). - * - Disabled when tabCode is '' (initial state, before metadata arrives). - * - Invalidates on FlatCreatedEvent (new room created) and - * RoomSettingsUpdatedEvent (room renamed / settings changed). - */ export const useNavigatorSearch = () => { const tabCode = useNavigatorUiStore(s => s.currentTabCode); const filter = useNavigatorUiStore(s => s.currentFilter); - const query = useNitroQuery({ - key: [ ...SEARCH_BASE_KEY, tabCode, filter ], - request: () => new NavigatorSearchComposer(tabCode, filter), - parser: NavigatorSearchEvent, - select: e => e.getParser()?.result ?? null, - accept: e => - { - const result = e.getParser()?.result; - return !!result && result.code === tabCode; - }, - enabled: !!tabCode, - staleTime: 30_000 + const [ searchResult, setSearchResult ] = useState(null); + const [ isFetching, setIsFetching ] = useState(false); + + useEffect(() => + { + if(!tabCode) return; + + setIsFetching(true); + SendMessageComposer(new NavigatorSearchComposer(tabCode, filter)); + }, [ tabCode, filter ]); + + useMessageEvent(NavigatorSearchEvent, event => + { + const result = event.getParser()?.result; + if(!result) return; + + if(tabCode && result.code !== tabCode) return; + + setSearchResult(result); + setIsFetching(false); }); - useNitroEventInvalidator(FlatCreatedEvent, [ ...SEARCH_BASE_KEY ]); - useNitroEventInvalidator(RoomSettingsUpdatedEvent, [ ...SEARCH_BASE_KEY ]); - return { - searchResult: query.data ?? null, - isFetching: query.isFetching, - refetch: query.refetch + searchResult, + isFetching, + refetch: () => + { + if(!tabCode) return; + setIsFetching(true); + SendMessageComposer(new NavigatorSearchComposer(tabCode, filter)); + } }; }; From e7088595dfbae6372faecd4be51fb1dbdfff272a Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 14:05:09 +0200 Subject: [PATCH 29/86] =?UTF-8?q?=F0=9F=86=99=20Small=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/navigator/NavigatorView.scss | 8 -------- src/components/navigator/NavigatorView.tsx | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) delete mode 100644 src/components/navigator/NavigatorView.scss diff --git a/src/components/navigator/NavigatorView.scss b/src/components/navigator/NavigatorView.scss deleted file mode 100644 index bdb279e..0000000 --- a/src/components/navigator/NavigatorView.scss +++ /dev/null @@ -1,8 +0,0 @@ -.button-search-saves { - padding: 4px; - height: 17px; - margin-top: -1px; - font-size: 10px; - border-radius: 4px; - background-color: #FAA700; -} diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index 10203ef..b93b8d5 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -8,7 +8,7 @@ import randomRoomImg from '../../assets/images/navigator/random_room.png'; import promoteRoomImg from '../../assets/images/navigator/promote_room.png'; import { CreateLinkEvent, LocalizeText, SendMessageComposer, TryVisitRoom } from '../../api'; import { Flex, Text, WidgetErrorBoundary } from '../../common'; -import { useNavigatorActions, useNavigatorData, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; +import { useNavigatorData, useNavigatorSearch, useNavigatorUiState, useNavigatorUiStore, useNitroEvent } from '../../hooks'; import { NavigatorDoorStateView } from './views/NavigatorDoorStateView'; import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; From bade7e2623729aa0f483999bbf971d22f82529fd Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 15:25:27 +0200 Subject: [PATCH 30/86] =?UTF-8?q?=F0=9F=86=99=20Update=20texts=20to=20use?= =?UTF-8?q?=20JSON5=20and=20added=20all=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/configuration/UITexts.example | 269 ------- public/configuration/UITexts_en.json5.example | 703 ++++++++++++++++++ public/configuration/UITexts_it.json5.example | 703 ++++++++++++++++++ public/configuration/UITexts_nl.json5.example | 703 ++++++++++++++++++ public/configuration/renderer-config.example | 2 +- public/configuration/wheel-texts-en.example | 17 - public/configuration/wheel-texts-it.example | 17 - 7 files changed, 2110 insertions(+), 304 deletions(-) delete mode 100644 public/configuration/UITexts.example create mode 100644 public/configuration/UITexts_en.json5.example create mode 100644 public/configuration/UITexts_it.json5.example create mode 100644 public/configuration/UITexts_nl.json5.example delete mode 100644 public/configuration/wheel-texts-en.example delete mode 100644 public/configuration/wheel-texts-it.example diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example deleted file mode 100644 index cf24e72..0000000 --- a/public/configuration/UITexts.example +++ /dev/null @@ -1,269 +0,0 @@ -{ - "notification.badge.received": "Nuovo Distintivo!", - "wiredfurni.badgereceived.title": "Distintivo ricevuto!", - "wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!", - "friendlist.search": "Search friends", - "purse.seasonal.currency.101": "cash", - "widget.chooser.checkall": "Select furniture", - "widget.chooser.btn.pickall": "pick up selected items!", - "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", - "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", - "widget.settings.general": "General", - "widget.settings.general.title": "Adjust the default Nitro settings", - "widget.settings.volume": "Volume", - "widget.settings.interface": "Interface", - "widget.settings.interface.title": "Adjust the interface settings", - "widget.settings.interface.fps.automatic": "Set FPS to unlimited", - "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", - "widget.settings.interface.secondary": "Change the window header color", - "widget.settings.interface.reset": "Reset header color to default", - "widget.room.chat.hide_pets": "Hide pets", - "widget.room.chat.hide_avatars": "Hide avatars", - "widget.room.chat.hide_balloon": "Hide speech bubble", - "widget.room.chat.show_balloon": "Speech bubble", - "widget.room.chat.clear_history": "clear history", - "widget.room.youtube.shared": "YouTube is being shared", - "widget.room.youtube.open_video": "Open the video", - "wiredfurni.tooltip.select.tile": "Select tile", - "wiredfurni.tooltip.remove.tile": "Deselect tile", - "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", - "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", - "wiredfurni.params.furni_neighborhood.group.user": "Players", - "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", - "wiredfurni.params.selector_option.bot": "No bots", - "wiredfurni.params.selector_option.pet": "No pets", - "catalog.title": "Catalog", - "catalog.favorites": "Favorites", - "catalog.favorites.pages": "Pages", - "catalog.favorites.furni": "Furni", - "catalog.favorites.empty": "No favorites", - "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", - "catalog.admin": "Admin", - "catalog.admin.new": "New", - "catalog.admin.root": "Root", - "catalog.admin.new.root.category": "New root category", - "catalog.admin.edit.root": "Edit Root", - "catalog.admin.edit": "Edit:", - "catalog.admin.edit.page": "Edit Page", - "catalog.admin.hidden": "hidden", - "catalog.admin.edit.title": "Edit \"%name%\"", - "catalog.admin.show": "Show", - "catalog.admin.hide": "Hide", - "catalog.admin.delete": "Delete", - "catalog.admin.delete.title": "Delete \"%name%\"", - "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", - "catalog.admin.delete.page": "Delete page", - "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", - "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", - "catalog.admin.create": "Create", - "catalog.admin.save": "Save", - "catalog.admin.create.subpage": "Create sub-page", - "catalog.admin.order": "Order", - "catalog.admin.visible": "Visible", - "catalog.admin.enabled": "Enabled", - "catalog.admin.offer.new": "New Offer", - "catalog.admin.offer.edit": "Edit Offer", - "catalog.admin.offer.name": "Catalog Name", - "catalog.admin.offer.general": "General", - "catalog.admin.offer.quantity": "Quantity", - "catalog.admin.offer.prices": "Prices", - "catalog.admin.offer.credits": "Credits", - "catalog.admin.offer.points": "Points", - "catalog.admin.offer.points.type": "Points Type", - "catalog.admin.offer.options": "Options", - "catalog.admin.offer.club.only": "Club Only", - "catalog.admin.offer.extradata": "Extra Data (optional)....", - "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", - "catalog.trophies.title": "Trophies", - "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", - "catalog.trophies.inscription": "Trophy Inscription", - "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", - "catalog.pets.show.colors": "Show colors", - "catalog.pets.choose.color": "Choose color", - "catalog.pets.choose.breed": "Choose breed", - "catalog.pets.back.breeds": "? Breeds", - "catalog.prefix.text": "Text", - "catalog.prefix.text.placeholder": "Enter text...", - "catalog.prefix.icon": "Icon", - "catalog.prefix.icon.remove": "Remove icon", - "catalog.prefix.effect": "Effect", - "catalog.prefix.color": "Color", - "catalog.prefix.color.single": "?? Single", - "catalog.prefix.color.per.letter": "?? Per Letter", - "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", - "catalog.prefix.color.apply.all.title": "Apply current color to all letters", - "catalog.prefix.color.apply.all": "Apply to all", - "catalog.prefix.color.selected": "Selected letter:", - "catalog.prefix.price": "Price:", - "catalog.prefix.price.amount": "5 Credits", - "catalog.prefix.purchased": "? Purchased!", - "catalog.prefix.purchase": "Purchase", - "modtools.userinfo.title": "User Info: %username%", - "modtools.userinfo.userName": "Name", - "modtools.userinfo.cfhCount": "CFHs", - "modtools.userinfo.abusiveCfhCount": "Abusive CFHs", - "modtools.userinfo.cautionCount": "Cautions", - "modtools.userinfo.banCount": "Bans", - "modtools.userinfo.lastSanctionTime": "Last Sanction", - "modtools.userinfo.tradingLockCount": "Trade Locks", - "modtools.userinfo.tradingExpiryDate": "Lock Expires", - "modtools.userinfo.minutesSinceLastLogin": "Last Login", - "modtools.userinfo.lastPurchaseDate": "Last Purchase", - "modtools.userinfo.primaryEmailAddress": "Email", - "modtools.userinfo.identityRelatedBanCount": "Banned Accs", - "modtools.userinfo.registrationAgeInMinutes": "Registered", - "modtools.userinfo.userClassification": "Rank", - "modtools.window.title": "Mod Tools", - "modtools.window.tools.room": "Room Tool", - "modtools.window.tools.chatlog": "Chatlog Tool", - "modtools.window.tools.report": "Report Tool", - "modtools.window.select.user": "Select a user", - "modtools.window.no.room": "Enter a room first", - "modtools.window.user.in_room": "Still in this room", - "modtools.window.user.left_room": "No longer in this room", - "modtools.window.user.clear": "Clear selection", - "modtools.window.tickets.open": "%count% open ticket", - "modtools.window.tickets.open.many": "%count% open tickets", - "modtools.window.section.room": "Room", - "modtools.window.section.user": "User", - "modtools.window.section.reports": "Reports", - "modtools.window.user.open_info": "Open Info", - "modtools.userinfo.refresh": "Refresh user info", - "modtools.userinfo.presence.in_room": "In room", - "modtools.userinfo.presence.in_room.title": "In the room you are observing", - "modtools.userinfo.presence.online": "Online", - "modtools.userinfo.presence.online.title": "Online on the hotel", - "modtools.userinfo.presence.offline": "Offline", - "modtools.userinfo.presence.offline.title": "Offline at panel open", - "modtools.userinfo.section.account": "Account", - "modtools.userinfo.section.activity": "Activity", - "modtools.userinfo.section.sanctions": "Sanctions", - "modtools.userinfo.section.trading": "Trading", - "modtools.userinfo.button.room.chat": "Room Chat", - "modtools.userinfo.button.send.message": "Send Message", - "modtools.userinfo.button.room.visits": "Room Visits", - "modtools.userinfo.button.mod.action": "Mod Action", - "modtools.userinfo.stat.cfh": "CFH", - "modtools.userinfo.stat.cautions": "Cautions", - "modtools.userinfo.stat.bans": "Bans", - "modtools.userinfo.stat.trade.locks": "Trade locks", - "modtools.roominfo.title": "Room Info", - "modtools.roominfo.refresh": "Refresh room info", - "modtools.roominfo.loading": "Loading…", - "modtools.roominfo.owner.here": "Owner here", - "modtools.roominfo.owner.away": "Owner away", - "modtools.roominfo.owner.title.here": "The room owner is currently inside", - "modtools.roominfo.owner.title.away": "The room owner is NOT inside", - "modtools.roominfo.stat.users": "Users", - "modtools.roominfo.stat.owner": "Owner", - "modtools.roominfo.owner.open": "Open %username%'s info", - "modtools.roominfo.button.visit": "Visit Room", - "modtools.roominfo.button.chatlog": "Chatlog", - "modtools.roominfo.moderate.title": "Moderate room", - "modtools.roominfo.moderate.kick": "Kick everyone out", - "modtools.roominfo.moderate.doorbell": "Enable the doorbell", - "modtools.roominfo.moderate.rename": "Change room name", - "modtools.roominfo.moderate.message.placeholder": "Mandatory message to deliver with the action…", - "modtools.roominfo.moderate.send.caution": "Send Caution", - "modtools.roominfo.moderate.send.alert": "Send Alert", - "modtools.user.message.title": "Send Message", - "modtools.user.message.recipient": "Message to", - "modtools.user.message.label": "Message", - "modtools.user.message.placeholder": "Write something useful — the user will see it as a moderator message.", - "modtools.user.message.empty": "Empty", - "modtools.user.message.chars": "%count% chars", - "modtools.user.message.send": "Send Message", - "modtools.user.modaction.title": "Mod Action: %username%", - "modtools.user.modaction.sanctioning": "Sanctioning", - "modtools.user.modaction.step.topic": "1. CFH Topic", - "modtools.user.modaction.step.topic.placeholder": "Select a topic…", - "modtools.user.modaction.step.sanction": "2. Sanction", - "modtools.user.modaction.step.sanction.placeholder": "Select a sanction…", - "modtools.user.modaction.step.message": "3. Custom message", - "modtools.user.modaction.step.message.optional": "(optional — overrides default)", - "modtools.user.modaction.message.placeholder": "Leave empty to use the default topic message", - "modtools.user.modaction.preview": "Preview", - "modtools.user.modaction.button.default": "Default Sanction", - "modtools.user.modaction.button.apply": "Apply Sanction", - "modtools.user.modaction.error.no.topic": "You must select a CFH topic", - "modtools.user.modaction.error.no.action": "You must select a CFH topic and Sanction", - "modtools.user.modaction.error.no.permission": "You do not have permission to do this", - "modtools.user.modaction.error.no.message": "Please write a message to user", - "modtools.user.modaction.error.no.permission.alert": "You have insufficient permissions", - "modtools.user.visits.title": "User Visits", - "modtools.user.visits.recent": "Recent visited rooms", - "modtools.user.visits.entries.one": "%count% entry", - "modtools.user.visits.entries.many": "%count% entries", - "modtools.user.visits.empty": "No recent visits", - "modtools.user.visits.time": "Time", - "modtools.user.visits.room": "Room name", - "modtools.user.visits.action": "Action", - "modtools.user.visits.visit": "Visit", - "modtools.user.visits.visit.title": "Visit room", - "modtools.user.chatlog.title": "User Chatlog", - "modtools.user.chatlog.title.with": "User Chatlog: %username%", - "modtools.user.chatlog.loading": "Loading chatlog…", - "modtools.room.chatlog.title": "Room Chatlog", - "modtools.chatlog.column.time": "Time", - "modtools.chatlog.column.user": "User", - "modtools.chatlog.column.message": "Message", - "modtools.chatlog.empty": "No messages", - "modtools.chatlog.visit": "Visit", - "modtools.chatlog.tools": "Tools", - "modtools.tickets.title": "Tickets", - "modtools.tickets.tab.open": "Open", - "modtools.tickets.tab.mine": "Mine", - "modtools.tickets.tab.picked": "All picked", - "modtools.tickets.column.type": "Type", - "modtools.tickets.column.reported": "Reported", - "modtools.tickets.column.opened": "Opened", - "modtools.tickets.column.picker": "Picker", - "modtools.tickets.empty.open": "No open issues", - "modtools.tickets.empty.mine": "No issues picked by you", - "modtools.tickets.empty.picked": "No picked issues", - "modtools.tickets.action.pick": "Pick", - "modtools.tickets.action.handle": "Handle", - "modtools.tickets.action.release": "Release", - "modtools.tickets.issue.title": "Resolving issue #%issueId%", - "modtools.tickets.issue.label": "Issue #%issueId%", - "modtools.tickets.issue.details": "Details", - "modtools.tickets.issue.field.source": "Source", - "modtools.tickets.issue.field.category": "Category", - "modtools.tickets.issue.field.description": "Description", - "modtools.tickets.issue.field.caller": "Caller", - "modtools.tickets.issue.field.reported": "Reported", - "modtools.tickets.issue.chatlog.view": "View chatlog", - "modtools.tickets.issue.chatlog.close": "Close chatlog", - "modtools.tickets.issue.resolve.heading": "Resolve as", - "modtools.tickets.issue.resolve.resolved": "Resolved", - "modtools.tickets.issue.resolve.useless": "Useless", - "modtools.tickets.issue.resolve.abusive": "Abusive", - "modtools.tickets.issue.release": "Release back to queue", - "modtools.tickets.cfh.chatlog.title": "Issue #%issueId% Chatlog", - "groupforum.list.tab.most_active": "Most active threads", - "groupforum.list.tab.my_forums": "My group forums", - "groupforum.list.no_forums": "There are no forums", - "groupforum.view.threads": "Number of threads", - "groupforum.thread.pin": "Pin thread", - "groupforum.thread.unpin": "Unpin thread", - "groupforum.thread.lock": "Lock thread", - "groupforum.thread.unlock": "Unlock thread", - "groupforum.thread.hide": "Hide thread", - "groupforum.thread.restore": "Restore thread", - "groupforum.thread.delete": "Delete thread + posts", - "groupforum.message.hide": "Hide message", - "group.forum.enable.caption": "Enable / Disable group forum", - "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", - "groupforum.view.no_threads": "There are currently no active threads", - "loading.task.session": "Verifying session...", - "loading.task.renderer": "Initializing renderer...", - "loading.task.assets": "loading game assets...", - "loading.task.localization": "loading translations...", - "loading.task.avatar": "loading wardrobe...", - "loading.task.sounds": "loading sounds...", - "loading.task.startsession": "Starting session...", - "loading.task.userdata": "loading user data...", - "loading.task.rooms": "loading rooms...", - "loading.task.engine": "loading graphics engine...", - "catalog.gift_wrapping.gift_sent": "Done!" -} diff --git a/public/configuration/UITexts_en.json5.example b/public/configuration/UITexts_en.json5.example new file mode 100644 index 0000000..63fb47c --- /dev/null +++ b/public/configuration/UITexts_en.json5.example @@ -0,0 +1,703 @@ +{ + // ------------------------------------------------------------------------ + // Friendlist + // ------------------------------------------------------------------------ + 'friendlist.search': 'Search friends', + + // ------------------------------------------------------------------------ + // Purse / Currency + // ------------------------------------------------------------------------ + 'purse.seasonal.currency.101': 'doekoes', + + // ------------------------------------------------------------------------ + // Widget: furni chooser + // ------------------------------------------------------------------------ + 'widget.chooser.checkall': 'Select furni', + 'widget.chooser.btn.pickall': 'pick up the selected items!', + + // ------------------------------------------------------------------------ + // Game center + // ------------------------------------------------------------------------ + 'gamecenter.players': 'Players', + 'gamecenter.players.2to6': '2 to 6 players', + 'gamecenter.players.2to8': '2 to 8 players', + 'gamecenter.players.4to12': '4 to 12 players', + 'gamecenter.players.single': 'Single player', + 'gamecenter.players.score': 'Score:', + 'gamecenter.players.theme': 'Theme:', + 'gamecenter.players.winner': 'Winner!', + + // Game descriptions + 'gamecenter.battleball.description': 'BattleBall is a colorful game in which you must color more surfaces than your opponent. Items appear randomly and give you unique powers to boost your chances. Tactics, skill and quick decisions are the key to victory. Will you become the champion of BattleBall?', + 'gamecenter.tombrunner.description': 'This treasure hunter is determined to find as many old coins as possible while running through ancient corridors and leaping over enormous cracks. On your journey through this endless 3D running game you will also encounter unstable and fragile bridges. Find out how long you can survive.', + 'gamecenter.flappybirds.description': 'Flappy Bird is an arcade-style game in which we control the Faby bird, which moves to the right. It is your task to guide Faby through pipes that have equal gaps placed at random heights.', + 'gamecenter.bargame.description': 'Show off your skills by working in the best bar of the hotel and serving the best drinks to the most demanding customers. Try to be the waiter with the best skills, delivering glasses to win the game and demonstrate your abilities at working with cocktails.', + 'gamecenter.roombuildergame.description': 'Are you good at building rooms? Do you have enough imagination? Take on the challenge and build a themed room in under 6 minutes. The nicest room wins!', + + // Game center: voting + 'gamecenter.vote.description': 'Vote on the rooms', + 'gamecenter.vote.room.made.by': 'Room made by', + 'gamecenter.vote.room.bestihaveseen': 'This is the nicest room I have ever seen!', + 'gamecenter.vote.room.nice': 'Fine room, nicely done.', + 'gamecenter.vote.room.normal': 'An OK room, not bad and not super cool.', + 'gamecenter.vote.room.couldbebetter': 'This could have been a lot better', + 'gamecenter.vote.room.bad': 'Help, where is the exit, my eyes hurt!', + 'gamecenter.vote.room.wait': 'The other players are now voting on your room, please wait!', + + // ------------------------------------------------------------------------ + // Wired furniture + // ------------------------------------------------------------------------ + 'wiredfurni.params.requireall.2': 'If one of the selected furni has an avatar', + 'wiredfurni.params.requireall.3': 'If all selected furni have avatars on them', + 'wiredfurni.tooltip.select.tile': 'Select tile', + 'wiredfurni.tooltip.remove.tile': 'Deselect tile', + 'wiredfurni.tooltip.remove.5x5_tile': 'select 5x5 tiles', + 'wiredfurni.tooltip.remove.clear_tile': 'Remove all selections', + 'wiredfurni.params.furni_neighborhood.group.user': 'Players', + 'wiredfurni.params.furni_neighborhood.group.furni': 'Furni', + 'wiredfurni.params.selector_option.bot': 'No BOTs', + 'wiredfurni.params.selector_option.pet': 'No Pets', + + // Wired furniture: badge received + 'wiredfurni.badgereceived.title': 'Badge received!', + 'wiredfurni.badgereceived.body': 'You just received a new badge! Check it out in your inventory!', + + // ------------------------------------------------------------------------ + // Notifications + // ------------------------------------------------------------------------ + 'notification.badge.received': 'New badge!', + + // ------------------------------------------------------------------------ + // Settings widget + // ------------------------------------------------------------------------ + 'widget.settings.general': 'Default', + 'widget.settings.general.title': 'Adjust the default nitro settings', + 'widget.settings.volume': 'Volume', + 'widget.settings.interface': 'Interface', + 'widget.settings.interface.title': 'Adjust the settings for the interface', + 'widget.settings.interface.fps.automatic': 'Set FPS to unlimited', + 'widget.settings.interface.fps.warning': 'Setting FPS to unlimited can cause performance problems!', + 'widget.settings.interface.secondary': 'Change the window header color', + 'widget.settings.interface.reset': 'Reset header color to default', + + // ------------------------------------------------------------------------ + // Room widgets: chat + youtube + // ------------------------------------------------------------------------ + 'widget.room.chat.hide_pets': 'Hide pets', + 'widget.room.chat.hide_avatars': 'Hide avatars', + 'widget.room.chat.hide_balloon': 'Hide speech bubble', + 'widget.room.chat.show_balloon': 'Speech bubble', + 'widget.room.chat.clear_history': 'clear history', + 'widget.room.youtube.shared': 'YouTube is being shared', + 'widget.room.youtube.open_video': 'Open the video', + + // ------------------------------------------------------------------------ + // Catalog + // ------------------------------------------------------------------------ + + // Catalog: general + 'catalog.title': 'Catalog', + 'catalog.favorites': 'Favorites', + 'catalog.favorites.pages': 'Pages', + 'catalog.favorites.furni': 'Furni', + 'catalog.favorites.empty': 'No favorites', + 'catalog.favorites.empty.hint': 'Click the heart on furni or the star on pages to add them.', + + // Catalog: admin + 'catalog.admin': 'Admin', + 'catalog.admin.new': 'New', + 'catalog.admin.root': 'Root', + 'catalog.admin.new.root.category': 'New root category', + 'catalog.admin.edit.root': 'Edit root', + 'catalog.admin.edit': 'Edit:', + 'catalog.admin.edit.page': 'Edit page', + 'catalog.admin.hidden': 'hidden', + 'catalog.admin.edit.title': 'Edit "%name%"', + 'catalog.admin.show': 'Show', + 'catalog.admin.hide': 'Hide', + 'catalog.admin.delete': 'Delete', + 'catalog.admin.delete.title': 'Delete "%name%"', + 'catalog.admin.delete.category.confirm': 'Delete category "%name%" and all its contents?', + 'catalog.admin.delete.page': 'Delete page', + 'catalog.admin.delete.page.confirm': 'Delete page "%name%"?', + 'catalog.admin.delete.offer.confirm': 'Are you sure you want to delete this offer?', + 'catalog.admin.create': 'Create', + 'catalog.admin.save': 'Save', + 'catalog.admin.create.subpage': 'Create subpage', + 'catalog.admin.order': 'Order', + 'catalog.admin.visible': 'Visible', + 'catalog.admin.enabled': 'Enabled', + + // Catalog admin: offer editor + 'catalog.admin.offer.new': 'New offer', + 'catalog.admin.offer.edit': 'Edit offer', + 'catalog.admin.offer.name': 'Catalog name', + 'catalog.admin.offer.general': 'General', + 'catalog.admin.offer.quantity': 'Quantity', + 'catalog.admin.offer.prices': 'Prices', + 'catalog.admin.offer.credits': 'Credits', + 'catalog.admin.offer.points': 'Points', + 'catalog.admin.offer.points.type': 'Points type', + 'catalog.admin.offer.options': 'Options', + 'catalog.admin.offer.club.only': 'Club only', + 'catalog.admin.offer.extradata': 'Extra data (optional)....', + 'catalog.admin.offer.have.offer': 'Multi-discount (have_offer)', + + // Catalog: trophies + 'catalog.trophies.title': 'Trophies', + 'catalog.trophies.write.hint': 'Write a text for the trophy before buying', + 'catalog.trophies.inscription': 'Trophy inscription', + 'catalog.trophies.inscription.placeholder': 'Write the text that will appear on the trophy...', + + // Catalog: pets + 'catalog.pets.show.colors': 'Show colors', + 'catalog.pets.choose.color': 'Choose color', + 'catalog.pets.choose.breed': 'Choose breed', + 'catalog.pets.back.breeds': '← Breeds', + + // Catalog: name prefix editor + 'catalog.prefix.text': 'Text', + 'catalog.prefix.text.placeholder': 'Enter text...', + 'catalog.prefix.icon': 'Icon', + 'catalog.prefix.icon.remove': 'Remove icon', + 'catalog.prefix.effect': 'Effect', + 'catalog.prefix.color': 'Color', + 'catalog.prefix.color.single': '🎨 Single', + 'catalog.prefix.color.per.letter': '🌈 Per letter', + 'catalog.prefix.color.hint': 'Select a letter and then choose the color. Advances automatically.', + 'catalog.prefix.color.apply.all.title': 'Apply current color to all letters', + 'catalog.prefix.color.apply.all': 'Apply to all', + 'catalog.prefix.color.selected': 'Selected letter:', + 'catalog.prefix.price': 'Price:', + 'catalog.prefix.price.amount': '5 Credits', + 'catalog.prefix.purchased': '✓ Purchased!', + 'catalog.prefix.purchase': 'Buy', + + // Catalog: gift wrapping + 'catalog.gift_wrapping.gift_sent': 'Done!', + + // ------------------------------------------------------------------------ + // Group forum + // ------------------------------------------------------------------------ + 'groupforum.list.tab.most_active': 'Most active topics', + 'groupforum.list.tab.my_forums': 'My group forums', + 'groupforum.list.no_forums': 'There are no forums', + 'groupforum.view.threads': 'Number of topics', + 'groupforum.thread.pin': 'Pin topic', + 'groupforum.thread.unpin': 'Unpin topic', + 'groupforum.thread.lock': 'Lock topic', + 'groupforum.thread.unlock': 'Unlock topic', + 'groupforum.thread.hide': 'Hide topic', + 'groupforum.thread.restore': 'Make topic visible again', + 'groupforum.thread.delete': 'Delete topic + posts', + 'groupforum.message.hide': 'Hide message', + 'group.forum.enable.caption': 'Enable/disable group forum', + 'group.forum.enable.help': 'If you disable the group forum, all posts will be deleted too!', + 'groupforum.view.no_threads': 'There are currently no active topics', + + // ------------------------------------------------------------------------ + // Mod tools: window + // ------------------------------------------------------------------------ + 'modtools.window.title': 'Mod Tools', + 'modtools.window.tools.room': 'Room tool', + 'modtools.window.tools.chatlog': 'Chatlog tool', + 'modtools.window.tools.report': 'Report tool', + 'modtools.window.select.user': 'Select a user', + 'modtools.window.no.room': 'Enter a room first', + 'modtools.window.user.in_room': 'Still in this room', + 'modtools.window.user.left_room': 'No longer in this room', + 'modtools.window.user.clear': 'Clear selection', + 'modtools.window.tickets.open': '%count% open ticket', + 'modtools.window.tickets.open.many': '%count% open tickets', + 'modtools.window.section.room': 'Room', + 'modtools.window.section.user': 'User', + 'modtools.window.section.reports': 'Reports', + 'modtools.window.user.open_info': 'Open info', + + // ------------------------------------------------------------------------ + // Mod tools: user info + // ------------------------------------------------------------------------ + 'modtools.userinfo.title': 'User info: %username%', + 'modtools.userinfo.userName': 'Name', + 'modtools.userinfo.cfhCount': 'CFHs', + 'modtools.userinfo.abusiveCfhCount': 'Abusive CFHs', + 'modtools.userinfo.cautionCount': 'Cautions', + 'modtools.userinfo.banCount': 'Bans', + 'modtools.userinfo.lastSanctionTime': 'Last sanction', + 'modtools.userinfo.tradingLockCount': 'Trade locks', + 'modtools.userinfo.tradingExpiryDate': 'Lock expires', + 'modtools.userinfo.minutesSinceLastLogin': 'Last login', + 'modtools.userinfo.lastPurchaseDate': 'Last purchase', + 'modtools.userinfo.primaryEmailAddress': 'Email', + 'modtools.userinfo.identityRelatedBanCount': 'Banned accounts', + 'modtools.userinfo.registrationAgeInMinutes': 'Registered', + 'modtools.userinfo.userClassification': 'Rank', + 'modtools.userinfo.refresh': 'Refresh user info', + 'modtools.userinfo.presence.in_room': 'In room', + 'modtools.userinfo.presence.in_room.title': 'In the room you are observing', + 'modtools.userinfo.presence.online': 'Online', + 'modtools.userinfo.presence.online.title': 'Online in the hotel', + 'modtools.userinfo.presence.offline': 'Offline', + 'modtools.userinfo.presence.offline.title': 'Offline when panel opened', + 'modtools.userinfo.section.account': 'Account', + 'modtools.userinfo.section.activity': 'Activity', + 'modtools.userinfo.section.sanctions': 'Sanctions', + 'modtools.userinfo.section.trading': 'Trading', + 'modtools.userinfo.button.room.chat': 'Room chat', + 'modtools.userinfo.button.send.message': 'Send message', + 'modtools.userinfo.button.room.visits': 'Room visits', + 'modtools.userinfo.button.mod.action': 'Mod action', + 'modtools.userinfo.stat.cfh': 'CFH', + 'modtools.userinfo.stat.cautions': 'Cautions', + 'modtools.userinfo.stat.bans': 'Bans', + 'modtools.userinfo.stat.trade.locks': 'Trade locks', + + // ------------------------------------------------------------------------ + // Mod tools: room info + // ------------------------------------------------------------------------ + 'modtools.roominfo.title': 'Room info', + 'modtools.roominfo.refresh': 'Refresh room info', + 'modtools.roominfo.loading': 'Loading…', + 'modtools.roominfo.owner.here': 'Owner present', + 'modtools.roominfo.owner.away': 'Owner away', + 'modtools.roominfo.owner.title.here': 'The room owner is currently inside', + 'modtools.roominfo.owner.title.away': 'The room owner is NOT inside', + 'modtools.roominfo.stat.users': 'Users', + 'modtools.roominfo.stat.owner': 'Owner', + 'modtools.roominfo.owner.open': 'Open info of %username%', + 'modtools.roominfo.button.visit': 'Visit room', + 'modtools.roominfo.button.chatlog': 'Chatlog', + 'modtools.roominfo.moderate.title': 'Moderate room', + 'modtools.roominfo.moderate.kick': 'Kick everyone out', + 'modtools.roominfo.moderate.doorbell': 'Enable doorbell', + 'modtools.roominfo.moderate.rename': 'Change room name', + 'modtools.roominfo.moderate.message.placeholder': 'Required message sent along with the action…', + 'modtools.roominfo.moderate.send.caution': 'Send caution', + 'modtools.roominfo.moderate.send.alert': 'Send alert', + + // ------------------------------------------------------------------------ + // Mod tools: user message + // ------------------------------------------------------------------------ + 'modtools.user.message.title': 'Send message', + 'modtools.user.message.recipient': 'Message to', + 'modtools.user.message.label': 'Message', + 'modtools.user.message.placeholder': 'Write something useful — the user sees it as a moderator message.', + 'modtools.user.message.empty': 'Empty', + 'modtools.user.message.chars': '%count% characters', + 'modtools.user.message.send': 'Send message', + + // ------------------------------------------------------------------------ + // Mod tools: mod action + // ------------------------------------------------------------------------ + 'modtools.user.modaction.title': 'Mod action: %username%', + 'modtools.user.modaction.sanctioning': 'Sanctioning', + 'modtools.user.modaction.step.topic': '1. CFH topic', + 'modtools.user.modaction.step.topic.placeholder': 'Select a topic…', + 'modtools.user.modaction.step.sanction': '2. Sanction', + 'modtools.user.modaction.step.sanction.placeholder': 'Select a sanction…', + 'modtools.user.modaction.step.message': '3. Custom message', + 'modtools.user.modaction.step.message.optional': '(optional — overrides default)', + 'modtools.user.modaction.message.placeholder': 'Leave empty to use the default topic message', + 'modtools.user.modaction.preview': 'Preview', + 'modtools.user.modaction.button.default': 'Default sanction', + 'modtools.user.modaction.button.apply': 'Apply sanction', + 'modtools.user.modaction.error.no.topic': 'You must select a CFH topic', + 'modtools.user.modaction.error.no.action': 'You must select a CFH topic and sanction', + 'modtools.user.modaction.error.no.permission': 'You do not have permission to do this', + 'modtools.user.modaction.error.no.message': 'Write a message to the user', + 'modtools.user.modaction.error.no.permission.alert': 'You have insufficient rights', + + // ------------------------------------------------------------------------ + // Mod tools: user visits + // ------------------------------------------------------------------------ + 'modtools.user.visits.title': 'User visits', + 'modtools.user.visits.recent': 'Recently visited rooms', + 'modtools.user.visits.entries.one': '%count% entry', + 'modtools.user.visits.entries.many': '%count% entries', + 'modtools.user.visits.empty': 'No recent visits', + 'modtools.user.visits.time': 'Time', + 'modtools.user.visits.room': 'Room name', + 'modtools.user.visits.action': 'Action', + 'modtools.user.visits.visit': 'Visit', + 'modtools.user.visits.visit.title': 'Visit room', + + // ------------------------------------------------------------------------ + // Mod tools: chatlog + // ------------------------------------------------------------------------ + 'modtools.user.chatlog.title': 'User chatlog', + 'modtools.user.chatlog.title.with': 'User chatlog: %username%', + 'modtools.user.chatlog.loading': 'Loading chatlog…', + 'modtools.room.chatlog.title': 'Room chatlog', + 'modtools.chatlog.column.time': 'Time', + 'modtools.chatlog.column.user': 'User', + 'modtools.chatlog.column.message': 'Message', + 'modtools.chatlog.empty': 'No messages', + 'modtools.chatlog.visit': 'Visit', + 'modtools.chatlog.tools': 'Tools', + + // ------------------------------------------------------------------------ + // Mod tools: tickets + // ------------------------------------------------------------------------ + 'modtools.tickets.title': 'Tickets', + 'modtools.tickets.tab.open': 'Open', + 'modtools.tickets.tab.mine': 'Mine', + 'modtools.tickets.tab.picked': 'All picked', + 'modtools.tickets.column.type': 'Type', + 'modtools.tickets.column.reported': 'Reported', + 'modtools.tickets.column.opened': 'Opened', + 'modtools.tickets.column.picker': 'Picked up by', + 'modtools.tickets.empty.open': 'No open reports', + 'modtools.tickets.empty.mine': 'No reports picked up by you', + 'modtools.tickets.empty.picked': 'No picked-up reports', + 'modtools.tickets.action.pick': 'Pick up', + 'modtools.tickets.action.handle': 'Handle', + 'modtools.tickets.action.release': 'Release', + 'modtools.tickets.issue.title': 'Resolve report #%issueId%', + 'modtools.tickets.issue.label': 'Report #%issueId%', + 'modtools.tickets.issue.details': 'Details', + 'modtools.tickets.issue.field.source': 'Source', + 'modtools.tickets.issue.field.category': 'Category', + 'modtools.tickets.issue.field.description': 'Description', + 'modtools.tickets.issue.field.caller': 'Reporter', + 'modtools.tickets.issue.field.reported': 'Reported', + 'modtools.tickets.issue.chatlog.view': 'View chatlog', + 'modtools.tickets.issue.chatlog.close': 'Close chatlog', + 'modtools.tickets.issue.resolve.heading': 'Resolve as', + 'modtools.tickets.issue.resolve.resolved': 'Resolved', + 'modtools.tickets.issue.resolve.useless': 'Useless', + 'modtools.tickets.issue.resolve.abusive': 'Abusive', + 'modtools.tickets.issue.release': 'Put back in queue', + 'modtools.tickets.cfh.chatlog.title': 'Report #%issueId% chatlog', + + // ------------------------------------------------------------------------ + // Login + // ------------------------------------------------------------------------ + 'login.username': 'What is your habbo name', + 'login.forgot_password': 'Forgot password?', + + // First-time visitors card + 'nitro.login.firsttime.title': 'First time here?', + 'nitro.login.firsttime.text': 'Don\'t have a habbo account yet?', + 'nitro.login.firsttime.link': 'You can create one here', + 'nitro.login.card.title': 'Sign in to habbo', + + // Server status checks + 'nitro.login.server.offline.short': 'The game server is not running right now. Try again in a moment.', + 'nitro.login.server.offline.long': 'The game server is not running right now, so no new accounts can be created. Try again in a moment.', + 'nitro.login.server.checking': 'Checking…', + 'nitro.login.server.retry': 'Try again', + + // Registration flow + 'nitro.login.register.title': 'habbo details', + 'nitro.login.register.next': 'Next', + 'nitro.login.register.finish': 'Finish', + 'nitro.login.register.creating': 'Creating…', + 'nitro.login.register.intro.credentials': 'Let\'s create your account. Enter your email address and choose a password — we\'ll check that this email is not already in use.', + 'nitro.login.register.intro.avatar': 'Now it\'s time to create your own habbo character! Start by choosing your habbo name.', + 'nitro.login.register.intro.room': 'Last step — choose a starter room, or skip this and make your own room later.', + 'nitro.login.register.confirm.label': 'Confirm password', + 'nitro.login.register.username.placeholder': 'HabboName', + 'nitro.login.register.hotlooks.count': '%count% looks available', + 'nitro.login.register.hotlooks.none': 'No looks loaded', + 'nitro.login.register.room.skip.title': 'Fine — I\'ll make my own rooms', + 'nitro.login.register.room.skip.description': 'Skip this and start with an empty hotel inventory.', + 'nitro.login.register.room.loading': 'Loading rooms…', + 'nitro.login.register.room.error': 'Could not load room options. You can still skip this step.', + 'nitro.login.register.success': 'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.', + + // Forgot password + 'nitro.login.forgot.title': 'Reset password', + 'nitro.login.forgot.email.label': 'Email address', + 'nitro.login.forgot.send': 'Send email', + 'nitro.login.forgot.success': 'Email sent! If an account is linked to this address, you\'ll find a reset link in your inbox shortly (check your spam if you see nothing within a minute).', + + // Login errors (validation + transport) + 'nitro.login.error.missing_credentials': 'Enter both your habbo name and password.', + 'nitro.login.error.invalid_credentials': 'Invalid habbo name or password.', + 'nitro.login.error.too_many_attempts': 'Too many attempts. Try again in %seconds%s.', + 'nitro.login.error.turnstile': 'Complete the security check.', + 'nitro.login.error.server_offline': 'The game server is not running. Try again later.', + 'nitro.login.error.login_unreachable': 'Cannot reach the login service. Try again.', + 'nitro.login.error.register_failed': 'Cannot create your account.', + 'nitro.login.error.register_unreachable': 'Cannot reach the registration service.', + 'nitro.login.error.forgot_failed': 'Cannot send a reset email right now.', + 'nitro.login.error.forgot_unreachable': 'Cannot reach the password reset service.', + 'nitro.login.error.missing_fields': 'Fill in all fields.', + 'nitro.login.error.invalid_email': 'Enter a valid email address.', + 'nitro.login.error.password_too_short': 'Your password must be at least 8 characters long.', + 'nitro.login.error.password_mismatch': 'Passwords do not match.', + 'nitro.login.error.email_taken': 'This email address is already in use.', + 'nitro.login.error.missing_username': 'Choose a habbo name.', + 'nitro.login.error.username_length': 'The habbo name must be 3–16 characters.', + 'nitro.login.error.username_taken': 'This habbo name is already in use.', + 'nitro.login.error.missing_email': 'Enter your email address.', + + // ------------------------------------------------------------------------ + // Inventory + // ------------------------------------------------------------------------ + 'inventory.effects.activate': 'Use effect', + 'inventory.effects.remove': 'remove effect', + + // ------------------------------------------------------------------------ + // Loading screen — boot-stage labels read by App.tsx (taskLabel) + // ------------------------------------------------------------------------ + 'loading.task.session': 'Verifying session...', + 'loading.task.renderer': 'Initializing renderer...', + 'loading.task.assets': 'Loading game assets...', + 'loading.task.localization': 'Loading translations...', + 'loading.task.avatar': 'Loading wardrobe...', + 'loading.task.sounds': 'Loading sounds...', + 'loading.task.startsession': 'Starting session...', + 'loading.task.userdata': 'Loading user data...', + 'loading.task.rooms': 'Loading rooms...', + 'loading.task.engine': 'Loading graphics engine...', + + // ------------------------------------------------------------------------ + // Housekeeping + // ------------------------------------------------------------------------ + 'housekeeping.title': 'Housekeeping', + 'housekeeping.mode.light': 'Light', + + // Housekeeping: tabs + 'housekeeping.tab.dashboard': 'Dashboard', + 'housekeeping.tab.users': 'Users', + 'housekeeping.tab.rooms': 'Rooms', + 'housekeeping.tab.economy': 'Economy', + 'housekeeping.tab.audit': 'Audit log', + + // Housekeeping: confirm + status + 'housekeeping.confirm.title': 'Confirm action', + 'housekeeping.confirm.proceed': 'Proceed', + 'housekeeping.confirm.cancel': 'Cancel', + 'housekeeping.status.dismiss': 'Dismiss', + + // Housekeeping: action status + 'housekeeping.action.pending': 'Action in progress…', + 'housekeeping.action.success': 'Action completed', + 'housekeeping.action.error': 'Action failed', + 'housekeeping.action.reset_password.done': 'Password reset — new password below.', + + // Housekeeping: generated password card + 'housekeeping.password.title': '%username% (#%id%) · new password', + 'housekeeping.password.value_label': 'Generated password', + 'housekeeping.password.copy': 'Copy', + 'housekeeping.password.copied': 'Copied', + 'housekeeping.password.copy_failed': 'Copy failed', + 'housekeeping.password.dismiss': 'Dismiss', + 'housekeeping.password.hint': 'Share this with the user outside the hotel. It is shown once — close this card when you\'re done; the password will never be displayed again.', + + // Housekeeping: errors + 'housekeeping.error.invalid_input': 'Invalid input — check the user ID and the entered value.', + 'housekeeping.error.user_not_found': 'User not found.', + 'housekeeping.error.user_offline': 'User is offline — this action only works on online users.', + 'housekeeping.error.target_unkickable': 'This user cannot be kicked.', + 'housekeeping.error.ban_failed': 'Ban could not be applied — the server refused the request.', + 'housekeeping.error.no_active_ban': 'No active ban to lift for this user.', + 'housekeeping.error.rank_not_found': 'Rank not found — choose a rank that exists in permission_ranks.', + 'housekeeping.error.db_failed': 'Database error — see the emulator log for the SQL exception.', + 'housekeeping.error.hash_failed': 'Could not hash the new password — SHA-256 not available on this JVM.', + 'housekeeping.error.room_not_found': 'Room not found.', + 'housekeeping.error.room_action_failed': 'Room action could not be applied.', + 'housekeeping.error.new_owner_not_found': 'New owner not found.', + 'housekeeping.error.economy_failed': 'Economy action could not be applied — check the user ID and the amount.', + 'housekeeping.error.alert_empty': 'Hotel alert may not be empty.', + + // Housekeeping: actions + 'housekeeping.action.ban_h': 'Ban %h%h', + 'housekeeping.action.mute_min': 'Mute %m%m', + 'housekeeping.action.trade_lock_h': 'Trade lock %h%h', + 'housekeeping.action.kick': 'Kick', + 'housekeeping.action.unban': 'Lift ban', + 'housekeeping.action.force_disconnect': 'Disconnect', + 'housekeeping.action.set_rank': 'Set rank', + 'housekeeping.action.reset_password': 'Reset password', + + // Housekeeping: user panel + 'housekeeping.user.search.placeholder': 'Search by username…', + 'housekeeping.user.search.button': 'Search', + 'housekeeping.user.clear': 'Clear selection', + 'housekeeping.user.none': 'No user selected — search above to pick one.', + 'housekeeping.user.not_found': 'User not found.', + 'housekeeping.user.credits': 'Credits', + 'housekeeping.user.duckets': 'Duckets / pixels', + 'housekeeping.user.diamonds': 'Diamonds', + 'housekeeping.user.audit_hint': 'All actions are recorded in the audit log tab.', + 'housekeeping.user.live.label': 'Live (in current room)', + 'housekeeping.user.live.kick': 'Kick', + 'housekeeping.user.live.mute_2m': 'Mute 2m', + 'housekeeping.user.live.mute_10m': 'Mute 10m', + 'housekeeping.user.live.ban_h': 'Ban 1h', + 'housekeeping.user.live.ban_d': 'Ban 1d', + + // Housekeeping: room panel + 'housekeeping.room.search.placeholder': 'Room ID…', + 'housekeeping.room.search.button': 'Search', + 'housekeeping.room.clear': 'Clear selection', + 'housekeeping.room.none': 'No room selected — enter an ID above.', + 'housekeeping.room.not_found': 'Room not found.', + 'housekeeping.room.open': 'Open', + 'housekeeping.room.close': 'Close', + 'housekeeping.room.mute_min': 'Mute %m%m', + 'housekeeping.room.kick_all': 'Kick everyone', + 'housekeeping.room.kick_all.confirm': 'Kick every user currently in the room?', + 'housekeeping.room.delete': 'Delete room', + 'housekeeping.room.delete.confirm': 'Permanently delete this room and all its furni?', + 'housekeeping.room.transfer': 'Transfer', + 'housekeeping.room.transfer.label': 'Transfer ownership', + 'housekeeping.room.transfer.new_owner': 'New owner ID', + + // Housekeeping: economy + 'housekeeping.economy.select_user': 'Pick a user in the Users tab first.', + 'housekeeping.economy.target': 'Target: %username% (#%id%)', + 'housekeeping.economy.give_credits': 'Give credits', + 'housekeeping.economy.give_duckets': 'Give duckets', + 'housekeeping.economy.give_diamonds': 'Give diamonds', + 'housekeeping.economy.grant_item': 'Grant item', + 'housekeeping.economy.grant_item.label': 'Grant catalog item', + 'housekeeping.economy.item_id': 'Item ID', + 'housekeeping.economy.item_quantity': 'Quantity', + 'housekeeping.economy.set_hc_days': 'Set HC days', + + // Housekeeping: hotel-wide alert + 'housekeeping.hotel.alert.label': 'Hotel-wide alert', + 'housekeeping.hotel.alert.placeholder': 'Message broadcast to every connected user…', + 'housekeeping.hotel.alert.send': 'Send to hotel', + 'housekeeping.hotel.alert.confirm': 'Broadcast a %count%-character alert to every connected user?', + + // Housekeeping: dashboard + 'housekeeping.dashboard.title': 'Overview', + 'housekeeping.dashboard.refresh': 'Refresh', + 'housekeeping.dashboard.loading': 'Loading dashboard…', + 'housekeeping.dashboard.unavailable': 'Dashboard unavailable — check the admin endpoint.', + 'housekeeping.dashboard.online': 'Online', + 'housekeeping.dashboard.total_users': '%count% total', + 'housekeeping.dashboard.rooms_active': 'Active rooms', + 'housekeeping.dashboard.total_rooms': '%count% total', + 'housekeeping.dashboard.peak_today': 'Peak today', + 'housekeeping.dashboard.peak_alltime': 'All-time peak %count%', + 'housekeeping.dashboard.pending_tickets': 'Tickets', + 'housekeeping.dashboard.sanctions_24h': '%count% sanctions / 24h', + 'housekeeping.dashboard.server': 'Server', + 'housekeeping.dashboard.recent_sanctions': 'Recent sanctions', + 'housekeeping.dashboard.recent_lookups': 'Recent lookups', + + // Housekeeping: audit log + 'housekeeping.audit.title': 'Audit log', + 'housekeeping.audit.refresh': 'Refresh', + 'housekeeping.audit.filter.all': 'All', + 'housekeeping.audit.filter.users': 'Users', + 'housekeeping.audit.filter.rooms': 'Rooms', + 'housekeeping.audit.filter.hotel': 'Hotel', + 'housekeeping.audit.search.placeholder': 'Search actor / target / action…', + 'housekeeping.audit.empty': 'No audit entries yet.', + 'housekeeping.audit.no_match': 'No entries match the current filters.', + + // Housekeeping: shared fields + 'housekeeping.field.reason': 'Reason', + 'housekeeping.field.reason.placeholder': 'Free-text reason (optional)', + 'housekeeping.field.duration': 'Duration', + 'housekeeping.reason.default': 'No reason given.', + + // Housekeeping: context menu + 'housekeeping.menu.send_to_hk': 'Send to housekeeping', + + // Housekeeping: bulk actions + 'housekeeping.bulk.done': 'Bulk done', + 'housekeeping.bulk.success': 'All bulk actions succeeded.', + 'housekeeping.bulk.partial': 'Bulk completed with some failures.', + 'housekeeping.bulk.failed': 'Every bulk action failed.', + 'housekeeping.bulk.confirm': 'Apply %action% to %count% selected users?', + 'housekeeping.bulk.label': '%count% selected', + 'housekeeping.bulk.clear': 'Clear selection', + 'housekeeping.bulk.apply': 'Apply to selection', + + // Housekeeping: telemetry + 'housekeeping.telemetry.title': 'Telemetry', + 'housekeeping.telemetry.empty': 'No actions observed yet.', + 'housekeeping.telemetry.reset': 'Reset statistics', + + // Housekeeping: live room session + 'housekeeping.live.no_room': 'No active room session.', + 'housekeeping.live.kicked': 'Kicked from the room.', + 'housekeeping.live.banned': 'Banned from the room.', + 'housekeeping.live.muted': 'Muted in the room.', + + // Housekeeping: validation + 'housekeeping.validation.empty_username': 'Username may not be empty.', + 'housekeeping.validation.invalid_user_id': 'Invalid user ID.', + 'housekeeping.validation.invalid_room_id': 'Invalid room ID.', + 'housekeeping.validation.invalid_amount': 'Invalid amount.', + 'housekeeping.validation.amount_too_large': 'Amount exceeds the safety limit.', + 'housekeeping.validation.empty_reason': 'Reason may not be empty.', + 'housekeeping.validation.invalid_hours': 'Invalid duration in hours.', + 'housekeeping.validation.invalid_rank': 'Invalid rank — must be between 1 and 12.', + + // ------------------------------------------------------------------------ + // Fortune Wheel + // ------------------------------------------------------------------------ + 'wheel.title': 'Fortune Wheel', + 'wheel.free.today': 'You have %count% free spins today!', + 'wheel.extra': 'Extra spins: %count%', + 'wheel.spin': 'SPIN', + 'wheel.buy': 'Buy spin', + 'wheel.winners': 'Latest winners', + 'wheel.winners.empty': 'No winners yet', + + // ------------------------------------------------------------------------ + // Soundboard + // ------------------------------------------------------------------------ + 'soundboard.title': 'Soundboard', + 'soundboard.empty': 'No sounds available', + 'soundboard.lastplayed': 'Played by %user%', + 'soundboard.room.setting.desc': 'Let people in this room play sound effects', + + // ------------------------------------------------------------------------ + // Radio + // ------------------------------------------------------------------------ + 'radio.title': 'Radio', + 'radio.empty': 'No stations', + 'radio.error': 'Couldn\'t load stations', + 'radio.stop': 'Stop', + + // ------------------------------------------------------------------------ + // Rare Values + // ------------------------------------------------------------------------ + 'rarevalues.title': 'Rare Values', + 'rarevalues.loading': 'Loading values…', + 'rarevalues.empty': 'No rares found', + 'rarevalues.infostand.label': 'Value:', + + // Rare Values: editor + 'rarevalues.editor.tab': 'Edit', + 'rarevalues.editor.type': 'Type', + 'rarevalues.editor.value': 'Value', + 'rarevalues.editor.weight': 'Chance', + 'rarevalues.editor.label': 'Label', + 'rarevalues.editor.save': 'Save', + 'rarevalues.editor.cat.item': 'Furni (ID)', + 'rarevalues.editor.cat.spin': 'Extra spins', + 'rarevalues.editor.cat.nothing': 'Nothing', + + // ------------------------------------------------------------------------ + // Chat commands: client + // ------------------------------------------------------------------------ + 'chatcmd.client.shake': 'Shake the room', + 'chatcmd.client.rotate': 'Rotate the room', + 'chatcmd.client.zoom': 'Zoom in/out', + 'chatcmd.client.flip': 'Reset zoom', + 'chatcmd.client.iddqd': 'Turn the room upside down', + 'chatcmd.client.screenshot': 'Screenshot of the room', + 'chatcmd.client.togglefps': 'Toggle FPS', + 'chatcmd.client.laugh': 'Laugh (VIP)', + 'chatcmd.client.kiss': 'Blow a kiss (VIP)', + 'chatcmd.client.jump': 'Jump (VIP)', + 'chatcmd.client.idle': 'Go idle', + 'chatcmd.client.sign': 'Show sign', + 'chatcmd.client.furni': 'Furni chooser', + 'chatcmd.client.chooser': 'User chooser', + 'chatcmd.client.floor': 'Floor editor', + 'chatcmd.client.pickall': 'Pick up all furni', + 'chatcmd.client.ejectall': 'Eject all furni', + 'chatcmd.client.settings': 'Room settings', + 'chatcmd.client.info': 'Client info', +} diff --git a/public/configuration/UITexts_it.json5.example b/public/configuration/UITexts_it.json5.example new file mode 100644 index 0000000..2b4c6da --- /dev/null +++ b/public/configuration/UITexts_it.json5.example @@ -0,0 +1,703 @@ +{ + // ------------------------------------------------------------------------ + // Friendlist + // ------------------------------------------------------------------------ + 'friendlist.search': 'Cerca amici', + + // ------------------------------------------------------------------------ + // Purse / Currency + // ------------------------------------------------------------------------ + 'purse.seasonal.currency.101': 'doekoes', + + // ------------------------------------------------------------------------ + // Widget: furni chooser + // ------------------------------------------------------------------------ + 'widget.chooser.checkall': 'Seleziona arredi', + 'widget.chooser.btn.pickall': 'raccogli gli oggetti selezionati!', + + // ------------------------------------------------------------------------ + // Game center + // ------------------------------------------------------------------------ + 'gamecenter.players': 'Giocatori', + 'gamecenter.players.2to6': 'Da 2 a 6 giocatori', + 'gamecenter.players.2to8': 'Da 2 a 8 giocatori', + 'gamecenter.players.4to12': 'Da 4 a 12 giocatori', + 'gamecenter.players.single': 'Giocatore singolo', + 'gamecenter.players.score': 'Punteggio:', + 'gamecenter.players.theme': 'Tema:', + 'gamecenter.players.winner': 'Vincitore!', + + // Game descriptions + 'gamecenter.battleball.description': 'BattleBall è un gioco colorato in cui devi colorare più superfici del tuo avversario. Gli oggetti compaiono in modo casuale e ti danno poteri unici per aumentare le tue possibilità. Tattica, abilità e decisioni rapide sono la chiave della vittoria. Diventerai il campione di BattleBall?', + 'gamecenter.tombrunner.description': 'Questo cacciatore di tesori è determinato a trovare quante più monete antiche possibile mentre corre attraverso corridoi millenari e salta enormi crepe. Nel tuo viaggio attraverso questo infinito gioco di corsa in 3D incontrerai anche ponti instabili e fragili. Scopri quanto a lungo riesci a sopravvivere.', + 'gamecenter.flappybirds.description': 'Flappy Bird è un gioco in stile arcade in cui controlliamo l\'uccellino Faby, che si muove verso destra. Il tuo compito è guidare Faby attraverso i tubi che hanno aperture uguali poste ad altezze casuali.', + 'gamecenter.bargame.description': 'Mostra le tue abilità lavorando nel miglior bar dell\'hotel e servendo i migliori drink ai clienti più esigenti. Cerca di essere il cameriere con le migliori abilità, consegnando i bicchieri per vincere la partita e dimostrare la tua bravura con i cocktail.', + 'gamecenter.roombuildergame.description': 'Sei bravo a costruire stanze? Hai abbastanza fantasia? Accetta la sfida e costruisci una stanza a tema in meno di 6 minuti. La stanza più bella vince!', + + // Game center: voting + 'gamecenter.vote.description': 'Vota le stanze', + 'gamecenter.vote.room.made.by': 'Stanza creata da', + 'gamecenter.vote.room.bestihaveseen': 'Questa è la stanza più bella che abbia mai visto!', + 'gamecenter.vote.room.nice': 'Bella stanza, ben fatto.', + 'gamecenter.vote.room.normal': 'Una stanza OK, né male né eccezionale.', + 'gamecenter.vote.room.couldbebetter': 'Si poteva fare molto meglio', + 'gamecenter.vote.room.bad': 'Aiuto, dov\'è l\'uscita, mi fanno male gli occhi!', + 'gamecenter.vote.room.wait': 'Gli altri giocatori stanno votando la tua stanza, attendi!', + + // ------------------------------------------------------------------------ + // Wired furniture + // ------------------------------------------------------------------------ + 'wiredfurni.params.requireall.2': 'Se uno degli arredi selezionati ha un avatar', + 'wiredfurni.params.requireall.3': 'Se tutti gli arredi selezionati hanno avatar sopra di essi', + 'wiredfurni.tooltip.select.tile': 'Seleziona riquadro', + 'wiredfurni.tooltip.remove.tile': 'Deseleziona riquadro', + 'wiredfurni.tooltip.remove.5x5_tile': 'seleziona riquadri 5x5', + 'wiredfurni.tooltip.remove.clear_tile': 'Rimuovi tutte le selezioni', + 'wiredfurni.params.furni_neighborhood.group.user': 'Giocatori', + 'wiredfurni.params.furni_neighborhood.group.furni': 'Arredi', + 'wiredfurni.params.selector_option.bot': 'Nessun BOT', + 'wiredfurni.params.selector_option.pet': 'Nessun animale', + + // Wired furniture: badge received + 'wiredfurni.badgereceived.title': 'Distintivo ricevuto!', + 'wiredfurni.badgereceived.body': 'Hai appena ricevuto un nuovo distintivo! Guardalo nel tuo inventario!', + + // ------------------------------------------------------------------------ + // Notifications + // ------------------------------------------------------------------------ + 'notification.badge.received': 'Nuovo distintivo!', + + // ------------------------------------------------------------------------ + // Settings widget + // ------------------------------------------------------------------------ + 'widget.settings.general': 'Predefinito', + 'widget.settings.general.title': 'Modifica le impostazioni predefinite di nitro', + 'widget.settings.volume': 'Volume', + 'widget.settings.interface': 'Interfaccia', + 'widget.settings.interface.title': 'Modifica le impostazioni dell\'interfaccia', + 'widget.settings.interface.fps.automatic': 'Imposta FPS su illimitato', + 'widget.settings.interface.fps.warning': 'Impostare gli FPS su illimitato può causare problemi di prestazioni!', + 'widget.settings.interface.secondary': 'Cambia il colore dell\'intestazione della finestra', + 'widget.settings.interface.reset': 'Ripristina il colore dell\'intestazione predefinito', + + // ------------------------------------------------------------------------ + // Room widgets: chat + youtube + // ------------------------------------------------------------------------ + 'widget.room.chat.hide_pets': 'Nascondi animali', + 'widget.room.chat.hide_avatars': 'Nascondi avatar', + 'widget.room.chat.hide_balloon': 'Nascondi fumetto', + 'widget.room.chat.show_balloon': 'Fumetto', + 'widget.room.chat.clear_history': 'cancella cronologia', + 'widget.room.youtube.shared': 'YouTube è condiviso', + 'widget.room.youtube.open_video': 'Apri il video', + + // ------------------------------------------------------------------------ + // Catalog + // ------------------------------------------------------------------------ + + // Catalog: general + 'catalog.title': 'Catalogo', + 'catalog.favorites': 'Preferiti', + 'catalog.favorites.pages': 'Pagine', + 'catalog.favorites.furni': 'Arredi', + 'catalog.favorites.empty': 'Nessun preferito', + 'catalog.favorites.empty.hint': 'Clicca sul cuore sugli arredi o sulla stella sulle pagine per aggiungerli.', + + // Catalog: admin + 'catalog.admin': 'Gestione', + 'catalog.admin.new': 'Nuovo', + 'catalog.admin.root': 'Radice', + 'catalog.admin.new.root.category': 'Nuova categoria radice', + 'catalog.admin.edit.root': 'Modifica radice', + 'catalog.admin.edit': 'Modifica:', + 'catalog.admin.edit.page': 'Modifica pagina', + 'catalog.admin.hidden': 'nascosto', + 'catalog.admin.edit.title': 'Modifica "%name%"', + 'catalog.admin.show': 'Mostra', + 'catalog.admin.hide': 'Nascondi', + 'catalog.admin.delete': 'Elimina', + 'catalog.admin.delete.title': 'Elimina "%name%"', + 'catalog.admin.delete.category.confirm': 'Eliminare la categoria "%name%" e tutto il suo contenuto?', + 'catalog.admin.delete.page': 'Elimina pagina', + 'catalog.admin.delete.page.confirm': 'Eliminare la pagina "%name%"?', + 'catalog.admin.delete.offer.confirm': 'Sei sicuro di voler eliminare questa offerta?', + 'catalog.admin.create': 'Crea', + 'catalog.admin.save': 'Salva', + 'catalog.admin.create.subpage': 'Crea sottopagina', + 'catalog.admin.order': 'Ordine', + 'catalog.admin.visible': 'Visibile', + 'catalog.admin.enabled': 'Abilitato', + + // Catalog admin: offer editor + 'catalog.admin.offer.new': 'Nuova offerta', + 'catalog.admin.offer.edit': 'Modifica offerta', + 'catalog.admin.offer.name': 'Nome catalogo', + 'catalog.admin.offer.general': 'Generale', + 'catalog.admin.offer.quantity': 'Quantità', + 'catalog.admin.offer.prices': 'Prezzi', + 'catalog.admin.offer.credits': 'Crediti', + 'catalog.admin.offer.points': 'Punti', + 'catalog.admin.offer.points.type': 'Tipo di punti', + 'catalog.admin.offer.options': 'Opzioni', + 'catalog.admin.offer.club.only': 'Solo Club', + 'catalog.admin.offer.extradata': 'Dati extra (opzionale)....', + 'catalog.admin.offer.have.offer': 'Multi-sconto (have_offer)', + + // Catalog: trophies + 'catalog.trophies.title': 'Trofei', + 'catalog.trophies.write.hint': 'Scrivi un testo per il trofeo prima di acquistare', + 'catalog.trophies.inscription': 'Iscrizione del trofeo', + 'catalog.trophies.inscription.placeholder': 'Scrivi il testo che apparirà sul trofeo...', + + // Catalog: pets + 'catalog.pets.show.colors': 'Mostra colori', + 'catalog.pets.choose.color': 'Scegli colore', + 'catalog.pets.choose.breed': 'Scegli razza', + 'catalog.pets.back.breeds': '← Razze', + + // Catalog: name prefix editor + 'catalog.prefix.text': 'Testo', + 'catalog.prefix.text.placeholder': 'Inserisci testo...', + 'catalog.prefix.icon': 'Icona', + 'catalog.prefix.icon.remove': 'Rimuovi icona', + 'catalog.prefix.effect': 'Effetto', + 'catalog.prefix.color': 'Colore', + 'catalog.prefix.color.single': '🎨 Singolo', + 'catalog.prefix.color.per.letter': '🌈 Per lettera', + 'catalog.prefix.color.hint': 'Seleziona una lettera e poi scegli il colore. Avanza automaticamente.', + 'catalog.prefix.color.apply.all.title': 'Applica il colore corrente a tutte le lettere', + 'catalog.prefix.color.apply.all': 'Applica a tutte', + 'catalog.prefix.color.selected': 'Lettera selezionata:', + 'catalog.prefix.price': 'Prezzo:', + 'catalog.prefix.price.amount': '5 Crediti', + 'catalog.prefix.purchased': '✓ Acquistato!', + 'catalog.prefix.purchase': 'Acquista', + + // Catalog: gift wrapping + 'catalog.gift_wrapping.gift_sent': 'Fatto!', + + // ------------------------------------------------------------------------ + // Group forum + // ------------------------------------------------------------------------ + 'groupforum.list.tab.most_active': 'Argomenti più attivi', + 'groupforum.list.tab.my_forums': 'I miei forum di gruppo', + 'groupforum.list.no_forums': 'Non ci sono forum', + 'groupforum.view.threads': 'Numero di argomenti', + 'groupforum.thread.pin': 'Fissa argomento', + 'groupforum.thread.unpin': 'Sblocca argomento', + 'groupforum.thread.lock': 'Blocca argomento', + 'groupforum.thread.unlock': 'Sblocca argomento', + 'groupforum.thread.hide': 'Nascondi argomento', + 'groupforum.thread.restore': 'Rendi di nuovo visibile l\'argomento', + 'groupforum.thread.delete': 'Elimina argomento + messaggi', + 'groupforum.message.hide': 'Nascondi messaggio', + 'group.forum.enable.caption': 'Abilita/disabilita forum di gruppo', + 'group.forum.enable.help': 'Se disabiliti il forum di gruppo, verranno eliminati anche tutti i messaggi!', + 'groupforum.view.no_threads': 'Al momento non ci sono argomenti attivi', + + // ------------------------------------------------------------------------ + // Mod tools: window + // ------------------------------------------------------------------------ + 'modtools.window.title': 'Strumenti Mod', + 'modtools.window.tools.room': 'Strumento stanza', + 'modtools.window.tools.chatlog': 'Strumento chatlog', + 'modtools.window.tools.report': 'Strumento segnalazioni', + 'modtools.window.select.user': 'Seleziona un utente', + 'modtools.window.no.room': 'Entra prima in una stanza', + 'modtools.window.user.in_room': 'Ancora in questa stanza', + 'modtools.window.user.left_room': 'Non più in questa stanza', + 'modtools.window.user.clear': 'Cancella selezione', + 'modtools.window.tickets.open': '%count% ticket aperto', + 'modtools.window.tickets.open.many': '%count% ticket aperti', + 'modtools.window.section.room': 'Stanza', + 'modtools.window.section.user': 'Utente', + 'modtools.window.section.reports': 'Segnalazioni', + 'modtools.window.user.open_info': 'Apri info', + + // ------------------------------------------------------------------------ + // Mod tools: user info + // ------------------------------------------------------------------------ + 'modtools.userinfo.title': 'Info utente: %username%', + 'modtools.userinfo.userName': 'Nome', + 'modtools.userinfo.cfhCount': 'CFH', + 'modtools.userinfo.abusiveCfhCount': 'CFH abusivi', + 'modtools.userinfo.cautionCount': 'Avvertimenti', + 'modtools.userinfo.banCount': 'Ban', + 'modtools.userinfo.lastSanctionTime': 'Ultima sanzione', + 'modtools.userinfo.tradingLockCount': 'Blocchi scambio', + 'modtools.userinfo.tradingExpiryDate': 'Blocco scade', + 'modtools.userinfo.minutesSinceLastLogin': 'Ultimo accesso', + 'modtools.userinfo.lastPurchaseDate': 'Ultimo acquisto', + 'modtools.userinfo.primaryEmailAddress': 'Email', + 'modtools.userinfo.identityRelatedBanCount': 'Account bannati', + 'modtools.userinfo.registrationAgeInMinutes': 'Registrato', + 'modtools.userinfo.userClassification': 'Grado', + 'modtools.userinfo.refresh': 'Aggiorna info utente', + 'modtools.userinfo.presence.in_room': 'In stanza', + 'modtools.userinfo.presence.in_room.title': 'Nella stanza che stai osservando', + 'modtools.userinfo.presence.online': 'Online', + 'modtools.userinfo.presence.online.title': 'Online nell\'hotel', + 'modtools.userinfo.presence.offline': 'Offline', + 'modtools.userinfo.presence.offline.title': 'Offline all\'apertura del pannello', + 'modtools.userinfo.section.account': 'Account', + 'modtools.userinfo.section.activity': 'Attività', + 'modtools.userinfo.section.sanctions': 'Sanzioni', + 'modtools.userinfo.section.trading': 'Scambi', + 'modtools.userinfo.button.room.chat': 'Chat stanza', + 'modtools.userinfo.button.send.message': 'Invia messaggio', + 'modtools.userinfo.button.room.visits': 'Visite stanze', + 'modtools.userinfo.button.mod.action': 'Azione mod', + 'modtools.userinfo.stat.cfh': 'CFH', + 'modtools.userinfo.stat.cautions': 'Avvertimenti', + 'modtools.userinfo.stat.bans': 'Ban', + 'modtools.userinfo.stat.trade.locks': 'Blocchi scambio', + + // ------------------------------------------------------------------------ + // Mod tools: room info + // ------------------------------------------------------------------------ + 'modtools.roominfo.title': 'Info stanza', + 'modtools.roominfo.refresh': 'Aggiorna info stanza', + 'modtools.roominfo.loading': 'Caricamento…', + 'modtools.roominfo.owner.here': 'Proprietario presente', + 'modtools.roominfo.owner.away': 'Proprietario assente', + 'modtools.roominfo.owner.title.here': 'Il proprietario della stanza è attualmente all\'interno', + 'modtools.roominfo.owner.title.away': 'Il proprietario della stanza NON è all\'interno', + 'modtools.roominfo.stat.users': 'Utenti', + 'modtools.roominfo.stat.owner': 'Proprietario', + 'modtools.roominfo.owner.open': 'Apri le info di %username%', + 'modtools.roominfo.button.visit': 'Visita stanza', + 'modtools.roominfo.button.chatlog': 'Chatlog', + 'modtools.roominfo.moderate.title': 'Modera stanza', + 'modtools.roominfo.moderate.kick': 'Caccia tutti', + 'modtools.roominfo.moderate.doorbell': 'Abilita campanello', + 'modtools.roominfo.moderate.rename': 'Cambia nome stanza', + 'modtools.roominfo.moderate.message.placeholder': 'Messaggio obbligatorio inviato insieme all\'azione…', + 'modtools.roominfo.moderate.send.caution': 'Invia avvertimento', + 'modtools.roominfo.moderate.send.alert': 'Invia avviso', + + // ------------------------------------------------------------------------ + // Mod tools: user message + // ------------------------------------------------------------------------ + 'modtools.user.message.title': 'Invia messaggio', + 'modtools.user.message.recipient': 'Messaggio a', + 'modtools.user.message.label': 'Messaggio', + 'modtools.user.message.placeholder': 'Scrivi qualcosa di utile — l\'utente lo vede come un messaggio del moderatore.', + 'modtools.user.message.empty': 'Vuoto', + 'modtools.user.message.chars': '%count% caratteri', + 'modtools.user.message.send': 'Invia messaggio', + + // ------------------------------------------------------------------------ + // Mod tools: mod action + // ------------------------------------------------------------------------ + 'modtools.user.modaction.title': 'Azione mod: %username%', + 'modtools.user.modaction.sanctioning': 'Sanzionamento', + 'modtools.user.modaction.step.topic': '1. Argomento CFH', + 'modtools.user.modaction.step.topic.placeholder': 'Seleziona un argomento…', + 'modtools.user.modaction.step.sanction': '2. Sanzione', + 'modtools.user.modaction.step.sanction.placeholder': 'Seleziona una sanzione…', + 'modtools.user.modaction.step.message': '3. Messaggio personalizzato', + 'modtools.user.modaction.step.message.optional': '(opzionale — sostituisce il predefinito)', + 'modtools.user.modaction.message.placeholder': 'Lascia vuoto per usare il messaggio predefinito dell\'argomento', + 'modtools.user.modaction.preview': 'Anteprima', + 'modtools.user.modaction.button.default': 'Sanzione predefinita', + 'modtools.user.modaction.button.apply': 'Applica sanzione', + 'modtools.user.modaction.error.no.topic': 'Devi selezionare un argomento CFH', + 'modtools.user.modaction.error.no.action': 'Devi selezionare un argomento CFH e una sanzione', + 'modtools.user.modaction.error.no.permission': 'Non hai il permesso di farlo', + 'modtools.user.modaction.error.no.message': 'Scrivi un messaggio all\'utente', + 'modtools.user.modaction.error.no.permission.alert': 'Non hai diritti sufficienti', + + // ------------------------------------------------------------------------ + // Mod tools: user visits + // ------------------------------------------------------------------------ + 'modtools.user.visits.title': 'Visite utente', + 'modtools.user.visits.recent': 'Stanze visitate di recente', + 'modtools.user.visits.entries.one': '%count% voce', + 'modtools.user.visits.entries.many': '%count% voci', + 'modtools.user.visits.empty': 'Nessuna visita recente', + 'modtools.user.visits.time': 'Ora', + 'modtools.user.visits.room': 'Nome stanza', + 'modtools.user.visits.action': 'Azione', + 'modtools.user.visits.visit': 'Visita', + 'modtools.user.visits.visit.title': 'Visita stanza', + + // ------------------------------------------------------------------------ + // Mod tools: chatlog + // ------------------------------------------------------------------------ + 'modtools.user.chatlog.title': 'Chatlog utente', + 'modtools.user.chatlog.title.with': 'Chatlog utente: %username%', + 'modtools.user.chatlog.loading': 'Caricamento chatlog…', + 'modtools.room.chatlog.title': 'Chatlog stanza', + 'modtools.chatlog.column.time': 'Ora', + 'modtools.chatlog.column.user': 'Utente', + 'modtools.chatlog.column.message': 'Messaggio', + 'modtools.chatlog.empty': 'Nessun messaggio', + 'modtools.chatlog.visit': 'Visita', + 'modtools.chatlog.tools': 'Strumenti', + + // ------------------------------------------------------------------------ + // Mod tools: tickets + // ------------------------------------------------------------------------ + 'modtools.tickets.title': 'Ticket', + 'modtools.tickets.tab.open': 'Aperti', + 'modtools.tickets.tab.mine': 'Miei', + 'modtools.tickets.tab.picked': 'Tutti presi', + 'modtools.tickets.column.type': 'Tipo', + 'modtools.tickets.column.reported': 'Segnalato', + 'modtools.tickets.column.opened': 'Aperto', + 'modtools.tickets.column.picker': 'Preso in carico da', + 'modtools.tickets.empty.open': 'Nessuna segnalazione aperta', + 'modtools.tickets.empty.mine': 'Nessuna segnalazione presa in carico da te', + 'modtools.tickets.empty.picked': 'Nessuna segnalazione presa in carico', + 'modtools.tickets.action.pick': 'Prendi in carico', + 'modtools.tickets.action.handle': 'Gestisci', + 'modtools.tickets.action.release': 'Rilascia', + 'modtools.tickets.issue.title': 'Risolvi segnalazione #%issueId%', + 'modtools.tickets.issue.label': 'Segnalazione #%issueId%', + 'modtools.tickets.issue.details': 'Dettagli', + 'modtools.tickets.issue.field.source': 'Origine', + 'modtools.tickets.issue.field.category': 'Categoria', + 'modtools.tickets.issue.field.description': 'Descrizione', + 'modtools.tickets.issue.field.caller': 'Segnalatore', + 'modtools.tickets.issue.field.reported': 'Segnalato', + 'modtools.tickets.issue.chatlog.view': 'Visualizza chatlog', + 'modtools.tickets.issue.chatlog.close': 'Chiudi chatlog', + 'modtools.tickets.issue.resolve.heading': 'Risolvi come', + 'modtools.tickets.issue.resolve.resolved': 'Risolto', + 'modtools.tickets.issue.resolve.useless': 'Inutile', + 'modtools.tickets.issue.resolve.abusive': 'Abusivo', + 'modtools.tickets.issue.release': 'Rimetti in coda', + 'modtools.tickets.cfh.chatlog.title': 'Chatlog segnalazione #%issueId%', + + // ------------------------------------------------------------------------ + // Login + // ------------------------------------------------------------------------ + 'login.username': 'Qual è il tuo nome habbo', + 'login.forgot_password': 'Password dimenticata?', + + // First-time visitors card + 'nitro.login.firsttime.title': 'È la prima volta qui?', + 'nitro.login.firsttime.text': 'Non hai ancora un account habbo?', + 'nitro.login.firsttime.link': 'Puoi crearne uno qui', + 'nitro.login.card.title': 'Accedi a habbo', + + // Server status checks + 'nitro.login.server.offline.short': 'Il server di gioco al momento non è attivo. Riprova tra poco.', + 'nitro.login.server.offline.long': 'Il server di gioco al momento non è attivo, quindi non è possibile creare nuovi account. Riprova tra poco.', + 'nitro.login.server.checking': 'Verifica in corso…', + 'nitro.login.server.retry': 'Riprova', + + // Registration flow + 'nitro.login.register.title': 'Dati habbo', + 'nitro.login.register.next': 'Avanti', + 'nitro.login.register.finish': 'Completa', + 'nitro.login.register.creating': 'Creazione in corso…', + 'nitro.login.register.intro.credentials': 'Creiamo il tuo account. Inserisci il tuo indirizzo email e scegli una password — verificheremo che questa email non sia già in uso.', + 'nitro.login.register.intro.avatar': 'Ora è il momento di creare il tuo personaggio habbo! Inizia scegliendo il tuo nome habbo.', + 'nitro.login.register.intro.room': 'Ultimo passaggio — scegli una stanza iniziale, oppure salta e crea la tua stanza più tardi.', + 'nitro.login.register.confirm.label': 'Conferma password', + 'nitro.login.register.username.placeholder': 'NomeHabbo', + 'nitro.login.register.hotlooks.count': '%count% look disponibili', + 'nitro.login.register.hotlooks.none': 'Nessun look caricato', + 'nitro.login.register.room.skip.title': 'Va bene — creerò le mie stanze', + 'nitro.login.register.room.skip.description': 'Salta questo passaggio e inizia con un inventario hotel vuoto.', + 'nitro.login.register.room.loading': 'Caricamento stanze…', + 'nitro.login.register.room.error': 'Impossibile caricare le opzioni delle stanze. Puoi comunque saltare questo passaggio.', + 'nitro.login.register.success': 'Benvenuto a bordo, %username%! Il tuo account è pronto — accedi qui sotto con la password che hai appena scelto.', + + // Forgot password + 'nitro.login.forgot.title': 'Reimposta password', + 'nitro.login.forgot.email.label': 'Indirizzo email', + 'nitro.login.forgot.send': 'Invia email', + 'nitro.login.forgot.success': 'Email inviata! Se a questo indirizzo è associato un account, troverai presto un link per il reset nella tua casella di posta (controlla lo spam se non vedi nulla entro un minuto).', + + // Login errors (validation + transport) + 'nitro.login.error.missing_credentials': 'Inserisci sia il tuo nome habbo che la password.', + 'nitro.login.error.invalid_credentials': 'Nome habbo o password non validi.', + 'nitro.login.error.too_many_attempts': 'Troppi tentativi. Riprova tra %seconds%s.', + 'nitro.login.error.turnstile': 'Completa il controllo di sicurezza.', + 'nitro.login.error.server_offline': 'Il server di gioco non è attivo. Riprova più tardi.', + 'nitro.login.error.login_unreachable': 'Impossibile raggiungere il servizio di accesso. Riprova.', + 'nitro.login.error.register_failed': 'Impossibile creare il tuo account.', + 'nitro.login.error.register_unreachable': 'Impossibile raggiungere il servizio di registrazione.', + 'nitro.login.error.forgot_failed': 'Impossibile inviare ora un\'email di reset.', + 'nitro.login.error.forgot_unreachable': 'Impossibile raggiungere il servizio di reset password.', + 'nitro.login.error.missing_fields': 'Compila tutti i campi.', + 'nitro.login.error.invalid_email': 'Inserisci un indirizzo email valido.', + 'nitro.login.error.password_too_short': 'La tua password deve contenere almeno 8 caratteri.', + 'nitro.login.error.password_mismatch': 'Le password non corrispondono.', + 'nitro.login.error.email_taken': 'Questo indirizzo email è già in uso.', + 'nitro.login.error.missing_username': 'Scegli un nome habbo.', + 'nitro.login.error.username_length': 'Il nome habbo deve contenere 3–16 caratteri.', + 'nitro.login.error.username_taken': 'Questo nome habbo è già in uso.', + 'nitro.login.error.missing_email': 'Inserisci il tuo indirizzo email.', + + // ------------------------------------------------------------------------ + // Inventory + // ------------------------------------------------------------------------ + 'inventory.effects.activate': 'Usa effetto', + 'inventory.effects.remove': 'rimuovi effetto', + + // ------------------------------------------------------------------------ + // Loading screen — boot-stage labels read by App.tsx (taskLabel) + // ------------------------------------------------------------------------ + 'loading.task.session': 'Verifica della sessione...', + 'loading.task.renderer': 'Inizializzazione del renderer...', + 'loading.task.assets': 'Caricamento risorse di gioco...', + 'loading.task.localization': 'Caricamento traduzioni...', + 'loading.task.avatar': 'Caricamento guardaroba...', + 'loading.task.sounds': 'Caricamento suoni...', + 'loading.task.startsession': 'Avvio della sessione...', + 'loading.task.userdata': 'Caricamento dati utente...', + 'loading.task.rooms': 'Caricamento stanze...', + 'loading.task.engine': 'Caricamento motore grafico...', + + // ------------------------------------------------------------------------ + // Housekeeping + // ------------------------------------------------------------------------ + 'housekeeping.title': 'Gestione', + 'housekeeping.mode.light': 'Chiaro', + + // Housekeeping: tabs + 'housekeeping.tab.dashboard': 'Dashboard', + 'housekeeping.tab.users': 'Utenti', + 'housekeeping.tab.rooms': 'Stanze', + 'housekeeping.tab.economy': 'Economia', + 'housekeeping.tab.audit': 'Registro', + + // Housekeeping: confirm + status + 'housekeeping.confirm.title': 'Conferma azione', + 'housekeeping.confirm.proceed': 'Procedi', + 'housekeeping.confirm.cancel': 'Annulla', + 'housekeeping.status.dismiss': 'Chiudi', + + // Housekeeping: action status + 'housekeeping.action.pending': 'Azione in corso…', + 'housekeeping.action.success': 'Azione completata', + 'housekeeping.action.error': 'Azione fallita', + 'housekeeping.action.reset_password.done': 'Password reimpostata — nuova password qui sotto.', + + // Housekeeping: generated password card + 'housekeeping.password.title': '%username% (#%id%) · nuova password', + 'housekeeping.password.value_label': 'Password generata', + 'housekeeping.password.copy': 'Copia', + 'housekeeping.password.copied': 'Copiato', + 'housekeeping.password.copy_failed': 'Copia fallita', + 'housekeeping.password.dismiss': 'Chiudi', + 'housekeeping.password.hint': 'Condividi questa password con l\'utente al di fuori dell\'hotel. Viene mostrata una sola volta — chiudi questa scheda quando hai finito; la password non verrà mai più visualizzata.', + + // Housekeeping: errors + 'housekeeping.error.invalid_input': 'Input non valido — controlla l\'ID utente e il valore inserito.', + 'housekeeping.error.user_not_found': 'Utente non trovato.', + 'housekeeping.error.user_offline': 'L\'utente è offline — questa azione funziona solo su utenti online.', + 'housekeeping.error.target_unkickable': 'Questo utente non può essere cacciato.', + 'housekeeping.error.ban_failed': 'Impossibile applicare il ban — il server ha rifiutato la richiesta.', + 'housekeeping.error.no_active_ban': 'Nessun ban attivo da revocare per questo utente.', + 'housekeeping.error.rank_not_found': 'Grado non trovato — scegli un grado che esiste in permission_ranks.', + 'housekeeping.error.db_failed': 'Errore del database — consulta il log dell\'emulatore per l\'eccezione SQL.', + 'housekeeping.error.hash_failed': 'Impossibile generare l\'hash della nuova password — SHA-256 non disponibile su questa JVM.', + 'housekeeping.error.room_not_found': 'Stanza non trovata.', + 'housekeeping.error.room_action_failed': 'Impossibile applicare l\'azione sulla stanza.', + 'housekeeping.error.new_owner_not_found': 'Nuovo proprietario non trovato.', + 'housekeeping.error.economy_failed': 'Impossibile applicare l\'azione economica — controlla l\'ID utente e la quantità.', + 'housekeeping.error.alert_empty': 'L\'avviso hotel non può essere vuoto.', + + // Housekeeping: actions + 'housekeeping.action.ban_h': 'Ban %h%h', + 'housekeeping.action.mute_min': 'Muta %m%m', + 'housekeeping.action.trade_lock_h': 'Blocco scambio %h%h', + 'housekeeping.action.kick': 'Caccia', + 'housekeeping.action.unban': 'Revoca ban', + 'housekeeping.action.force_disconnect': 'Disconnetti', + 'housekeeping.action.set_rank': 'Imposta grado', + 'housekeeping.action.reset_password': 'Reimposta password', + + // Housekeeping: user panel + 'housekeeping.user.search.placeholder': 'Cerca per nome utente…', + 'housekeeping.user.search.button': 'Cerca', + 'housekeeping.user.clear': 'Cancella selezione', + 'housekeeping.user.none': 'Nessun utente selezionato — cerca sopra per sceglierne uno.', + 'housekeeping.user.not_found': 'Utente non trovato.', + 'housekeeping.user.credits': 'Crediti', + 'housekeeping.user.duckets': 'Duckets / pixel', + 'housekeeping.user.diamonds': 'Diamanti', + 'housekeeping.user.audit_hint': 'Tutte le azioni vengono registrate nella scheda del registro.', + 'housekeeping.user.live.label': 'Live (nella stanza corrente)', + 'housekeeping.user.live.kick': 'Caccia', + 'housekeeping.user.live.mute_2m': 'Muta 2m', + 'housekeeping.user.live.mute_10m': 'Muta 10m', + 'housekeeping.user.live.ban_h': 'Ban 1h', + 'housekeeping.user.live.ban_d': 'Ban 1g', + + // Housekeeping: room panel + 'housekeeping.room.search.placeholder': 'ID stanza…', + 'housekeeping.room.search.button': 'Cerca', + 'housekeeping.room.clear': 'Cancella selezione', + 'housekeeping.room.none': 'Nessuna stanza selezionata — inserisci un ID sopra.', + 'housekeeping.room.not_found': 'Stanza non trovata.', + 'housekeeping.room.open': 'Apri', + 'housekeeping.room.close': 'Chiudi', + 'housekeeping.room.mute_min': 'Muta %m%m', + 'housekeeping.room.kick_all': 'Caccia tutti', + 'housekeeping.room.kick_all.confirm': 'Cacciare ogni utente attualmente nella stanza?', + 'housekeeping.room.delete': 'Elimina stanza', + 'housekeeping.room.delete.confirm': 'Eliminare definitivamente questa stanza e tutti i suoi arredi?', + 'housekeeping.room.transfer': 'Trasferisci', + 'housekeeping.room.transfer.label': 'Trasferisci proprietà', + 'housekeeping.room.transfer.new_owner': 'ID nuovo proprietario', + + // Housekeeping: economy + 'housekeeping.economy.select_user': 'Scegli prima un utente nella scheda Utenti.', + 'housekeeping.economy.target': 'Destinatario: %username% (#%id%)', + 'housekeeping.economy.give_credits': 'Dai crediti', + 'housekeeping.economy.give_duckets': 'Dai duckets', + 'housekeeping.economy.give_diamonds': 'Dai diamanti', + 'housekeeping.economy.grant_item': 'Assegna oggetto', + 'housekeeping.economy.grant_item.label': 'Assegna oggetto del catalogo', + 'housekeeping.economy.item_id': 'ID oggetto', + 'housekeeping.economy.item_quantity': 'Quantità', + 'housekeeping.economy.set_hc_days': 'Imposta giorni HC', + + // Housekeeping: hotel-wide alert + 'housekeeping.hotel.alert.label': 'Avviso a tutto l\'hotel', + 'housekeeping.hotel.alert.placeholder': 'Messaggio trasmesso a ogni utente connesso…', + 'housekeeping.hotel.alert.send': 'Invia all\'hotel', + 'housekeeping.hotel.alert.confirm': 'Trasmettere un avviso di %count% caratteri a ogni utente connesso?', + + // Housekeeping: dashboard + 'housekeeping.dashboard.title': 'Panoramica', + 'housekeeping.dashboard.refresh': 'Aggiorna', + 'housekeeping.dashboard.loading': 'Caricamento dashboard…', + 'housekeeping.dashboard.unavailable': 'Dashboard non disponibile — controlla l\'endpoint admin.', + 'housekeeping.dashboard.online': 'Online', + 'housekeeping.dashboard.total_users': '%count% totali', + 'housekeeping.dashboard.rooms_active': 'Stanze attive', + 'housekeeping.dashboard.total_rooms': '%count% totali', + 'housekeeping.dashboard.peak_today': 'Picco di oggi', + 'housekeeping.dashboard.peak_alltime': 'Picco di sempre %count%', + 'housekeeping.dashboard.pending_tickets': 'Ticket', + 'housekeeping.dashboard.sanctions_24h': '%count% sanzioni / 24h', + 'housekeeping.dashboard.server': 'Server', + 'housekeeping.dashboard.recent_sanctions': 'Sanzioni recenti', + 'housekeeping.dashboard.recent_lookups': 'Ricerche recenti', + + // Housekeeping: audit log + 'housekeeping.audit.title': 'Registro', + 'housekeeping.audit.refresh': 'Aggiorna', + 'housekeeping.audit.filter.all': 'Tutti', + 'housekeeping.audit.filter.users': 'Utenti', + 'housekeeping.audit.filter.rooms': 'Stanze', + 'housekeeping.audit.filter.hotel': 'Hotel', + 'housekeeping.audit.search.placeholder': 'Cerca esecutore / destinatario / azione…', + 'housekeeping.audit.empty': 'Nessuna voce di registro ancora.', + 'housekeeping.audit.no_match': 'Nessuna voce corrisponde ai filtri attuali.', + + // Housekeeping: shared fields + 'housekeeping.field.reason': 'Motivo', + 'housekeeping.field.reason.placeholder': 'Motivo libero (opzionale)', + 'housekeeping.field.duration': 'Durata', + 'housekeeping.reason.default': 'Nessun motivo fornito.', + + // Housekeeping: context menu + 'housekeeping.menu.send_to_hk': 'Invia alla gestione', + + // Housekeeping: bulk actions + 'housekeeping.bulk.done': 'Azione di massa completata', + 'housekeeping.bulk.success': 'Tutte le azioni di massa sono riuscite.', + 'housekeeping.bulk.partial': 'Azione di massa completata con alcuni errori.', + 'housekeeping.bulk.failed': 'Ogni azione di massa è fallita.', + 'housekeeping.bulk.confirm': 'Applicare %action% a %count% utenti selezionati?', + 'housekeeping.bulk.label': '%count% selezionati', + 'housekeeping.bulk.clear': 'Cancella selezione', + 'housekeeping.bulk.apply': 'Applica alla selezione', + + // Housekeeping: telemetry + 'housekeeping.telemetry.title': 'Telemetria', + 'housekeeping.telemetry.empty': 'Nessuna azione osservata ancora.', + 'housekeeping.telemetry.reset': 'Reimposta statistiche', + + // Housekeeping: live room session + 'housekeeping.live.no_room': 'Nessuna sessione di stanza attiva.', + 'housekeeping.live.kicked': 'Cacciato dalla stanza.', + 'housekeeping.live.banned': 'Bannato dalla stanza.', + 'housekeeping.live.muted': 'Mutato nella stanza.', + + // Housekeeping: validation + 'housekeeping.validation.empty_username': 'Il nome utente non può essere vuoto.', + 'housekeeping.validation.invalid_user_id': 'ID utente non valido.', + 'housekeeping.validation.invalid_room_id': 'ID stanza non valido.', + 'housekeeping.validation.invalid_amount': 'Quantità non valida.', + 'housekeeping.validation.amount_too_large': 'La quantità supera il limite di sicurezza.', + 'housekeeping.validation.empty_reason': 'Il motivo non può essere vuoto.', + 'housekeeping.validation.invalid_hours': 'Durata in ore non valida.', + 'housekeeping.validation.invalid_rank': 'Grado non valido — deve essere compreso tra 1 e 12.', + + // ------------------------------------------------------------------------ + // Fortune Wheel + // ------------------------------------------------------------------------ + 'wheel.title': 'Ruota della Fortuna', + 'wheel.free.today': 'Hai %count% giri gratuiti oggi!', + 'wheel.extra': 'Giri extra: %count%', + 'wheel.spin': 'GIRA', + 'wheel.buy': 'Acquista giro', + 'wheel.winners': 'Ultimi vincitori', + 'wheel.winners.empty': 'Ancora nessun vincitore', + + // ------------------------------------------------------------------------ + // Soundboard + // ------------------------------------------------------------------------ + 'soundboard.title': 'Soundboard', + 'soundboard.empty': 'Nessun suono disponibile', + 'soundboard.lastplayed': 'Riprodotto da %user%', + 'soundboard.room.setting.desc': 'Permetti alle persone in questa stanza di riprodurre effetti sonori', + + // ------------------------------------------------------------------------ + // Radio + // ------------------------------------------------------------------------ + 'radio.title': 'Radio', + 'radio.empty': 'Nessuna stazione', + 'radio.error': 'Impossibile caricare le stazioni', + 'radio.stop': 'Ferma', + + // ------------------------------------------------------------------------ + // Rare Values + // ------------------------------------------------------------------------ + 'rarevalues.title': 'Valori dei Rari', + 'rarevalues.loading': 'Caricamento valori…', + 'rarevalues.empty': 'Nessun raro trovato', + 'rarevalues.infostand.label': 'Valore:', + + // Rare Values: editor + 'rarevalues.editor.tab': 'Modifica', + 'rarevalues.editor.type': 'Tipo', + 'rarevalues.editor.value': 'Valore', + 'rarevalues.editor.weight': 'Probabilità', + 'rarevalues.editor.label': 'Etichetta', + 'rarevalues.editor.save': 'Salva', + 'rarevalues.editor.cat.item': 'Arredo (ID)', + 'rarevalues.editor.cat.spin': 'Giri extra', + 'rarevalues.editor.cat.nothing': 'Niente', + + // ------------------------------------------------------------------------ + // Chat commands: client + // ------------------------------------------------------------------------ + 'chatcmd.client.shake': 'Scuoti la stanza', + 'chatcmd.client.rotate': 'Ruota la stanza', + 'chatcmd.client.zoom': 'Zoom avanti/indietro', + 'chatcmd.client.flip': 'Reimposta zoom', + 'chatcmd.client.iddqd': 'Capovolgi la stanza', + 'chatcmd.client.screenshot': 'Screenshot della stanza', + 'chatcmd.client.togglefps': 'Attiva/disattiva FPS', + 'chatcmd.client.laugh': 'Ridi (VIP)', + 'chatcmd.client.kiss': 'Manda un bacio (VIP)', + 'chatcmd.client.jump': 'Salta (VIP)', + 'chatcmd.client.idle': 'Vai inattivo', + 'chatcmd.client.sign': 'Mostra cartello', + 'chatcmd.client.furni': 'Selettore arredi', + 'chatcmd.client.chooser': 'Selettore utenti', + 'chatcmd.client.floor': 'Editor pavimento', + 'chatcmd.client.pickall': 'Raccogli tutti gli arredi', + 'chatcmd.client.ejectall': 'Rimuovi tutti gli arredi', + 'chatcmd.client.settings': 'Impostazioni stanza', + 'chatcmd.client.info': 'Info client', +} diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example new file mode 100644 index 0000000..68f003f --- /dev/null +++ b/public/configuration/UITexts_nl.json5.example @@ -0,0 +1,703 @@ +{ + // ------------------------------------------------------------------------ + // Friendlist + // ------------------------------------------------------------------------ + 'friendlist.search': 'Zoek vrienden', + + // ------------------------------------------------------------------------ + // Purse / Currency + // ------------------------------------------------------------------------ + 'purse.seasonal.currency.101': "doekoe's", + + // ------------------------------------------------------------------------ + // Widget: furni chooser + // ------------------------------------------------------------------------ + 'widget.chooser.checkall': 'Selecteer meubels', + 'widget.chooser.btn.pickall': 'pak de geselecteerde items op!', + + // ------------------------------------------------------------------------ + // Game center + // ------------------------------------------------------------------------ + 'gamecenter.players': 'Spelers', + 'gamecenter.players.2to6': '2 tot 6 spelers', + 'gamecenter.players.2to8': '2 tot 8 spelers', + 'gamecenter.players.4to12': '4 tot 12 spelers', + 'gamecenter.players.single': 'Één speler', + 'gamecenter.players.score': 'Score:', + 'gamecenter.players.theme': 'Thema:', + 'gamecenter.players.winner': 'Winnaar!', + + // Game descriptions + 'gamecenter.battleball.description': 'BattleBall is een kleurrijk spel waarin je meer oppervlakken moet kleuren dan je tegenstander. Items verschijnen willekeurig en geven je unieke krachten om je kansen te vergroten. Tactiek, vaardigheid en snelle beslissingen zijn de sleutel tot de overwinning. Word jij de kampioen van BattleBall?', + 'gamecenter.tombrunner.description': 'Deze schatzoeker is vastbesloten om zoveel mogelijk oude munten te vinden terwijl hij door eeuwenoude gangen loopt en over enorme scheuren springt. Op je reis door dit eindeloze 3D-hardloopspel kom je ook onstabiele en kwetsbare bruggen tegen. Ontdek hoe lang je kunt overleven.', + 'gamecenter.flappybirds.description': 'Flappy Bird is een spel in arcadestijl waarin we de Faby-vogel besturen die naar rechts beweegt. Het is jouw taak om Faby door pijpen te loodsen die op willekeurige hoogte gelijke openingen hebben.', + 'gamecenter.bargame.description': 'Toon uw vaardigheden door in de beste bar van het hotel te werken en de beste drankjes te serveren aan de meest veeleisende klanten. Probeer de ober te zijn met de beste vaardigheden die glazen aflevert om het spel te winnen en demonstreer je vaardigheden bij het werken met cocktails.', + 'gamecenter.roombuildergame.description': 'Ben jij goed in het bouwen van kamers? Heb je voldoende fantasie? Ga de strijd aan en bouw in minder dan 6 minuten een kamer rond een thema. De mooiste kamer wint!', + + // Game center: voting + 'gamecenter.vote.description': 'Stem op de kamers', + 'gamecenter.vote.room.made.by': 'Kamer gemaakt door', + 'gamecenter.vote.room.bestihaveseen': 'Dit is de mooiste kamer die ik ooit heb gezien!', + 'gamecenter.vote.room.nice': 'Prima kamer, leuk gedaan.', + 'gamecenter.vote.room.normal': 'Een OK kamer, niet slecht en niet super cool.', + 'gamecenter.vote.room.couldbebetter': 'Dit had veel beter gekund', + 'gamecenter.vote.room.bad': 'Help waar is de uitgang, mijn ogen doen pijn!', + 'gamecenter.vote.room.wait': 'De andere spelers zijn nu aan het stemmen op jou kamer, even geduld!', + + // ------------------------------------------------------------------------ + // Wired furniture + // ------------------------------------------------------------------------ + 'wiredfurni.params.requireall.2': 'Als een van de geselecteerde furni een avatar heeft', + 'wiredfurni.params.requireall.3': 'Als alle geselecteerde furni avatars op hen hebben', + 'wiredfurni.tooltip.select.tile': 'Selecteer tegel', + 'wiredfurni.tooltip.remove.tile': 'Deselecteer tegel', + 'wiredfurni.tooltip.remove.5x5_tile': 'selecteer 5x5 tegels', + 'wiredfurni.tooltip.remove.clear_tile': 'Verwijder alle selecties', + 'wiredfurni.params.furni_neighborhood.group.user': 'Speelers', + 'wiredfurni.params.furni_neighborhood.group.furni': 'Meubels', + 'wiredfurni.params.selector_option.bot': "Geen BOT's", + 'wiredfurni.params.selector_option.pet': 'Geen Huisdieren', + + // Wired furniture: badge received + 'wiredfurni.badgereceived.title': 'Badge ontvangen!', + 'wiredfurni.badgereceived.body': 'Je hebt zojuist een nieuwe badge ontvangen! Bekijk hem in je inventaris!', + + // ------------------------------------------------------------------------ + // Notifications + // ------------------------------------------------------------------------ + 'notification.badge.received': 'Nieuwe badge!', + + // ------------------------------------------------------------------------ + // Settings widget + // ------------------------------------------------------------------------ + 'widget.settings.general': 'Standaard', + 'widget.settings.general.title': 'Pas de standaard nitro settings aan', + 'widget.settings.volume': 'Volume', + 'widget.settings.interface': 'Interface', + 'widget.settings.interface.title': 'Pas de settings aan voor de interface', + 'widget.settings.interface.fps.automatic': 'Zet FPS naar unlimited', + 'widget.settings.interface.fps.warning': 'Het zetten van FPS naar unlimited kan prestatie problemen veroorzaken!', + 'widget.settings.interface.secondary': 'Verander de window header kleur', + 'widget.settings.interface.reset': 'Reset header kleur naar default', + + // ------------------------------------------------------------------------ + // Room widgets: chat + youtube + // ------------------------------------------------------------------------ + 'widget.room.chat.hide_pets': 'Verberg dieren', + 'widget.room.chat.hide_avatars': 'Verberg avatars', + 'widget.room.chat.hide_balloon': 'Verberg Spreekballon', + 'widget.room.chat.show_balloon': 'Spreekballon', + 'widget.room.chat.clear_history': 'leeg geschiedenis', + 'widget.room.youtube.shared': 'YouTube word gedeeld', + 'widget.room.youtube.open_video': 'Open de video', + + // ------------------------------------------------------------------------ + // Catalog + // ------------------------------------------------------------------------ + + // Catalog: general + 'catalog.title': 'Catalogus', + 'catalog.favorites': 'Favorieten', + 'catalog.favorites.pages': 'Pagina’s', + 'catalog.favorites.furni': 'Furni', + 'catalog.favorites.empty': 'Geen favorieten', + 'catalog.favorites.empty.hint': 'Klik op het hartje bij furni of de ster bij pagina’s om ze toe te voegen.', + + // Catalog: admin + 'catalog.admin': 'Beheer', + 'catalog.admin.new': 'Nieuw', + 'catalog.admin.root': 'Hoofdmap', + 'catalog.admin.new.root.category': 'Nieuwe hoofdcategorie', + 'catalog.admin.edit.root': 'Hoofdmap bewerken', + 'catalog.admin.edit': 'Bewerken:', + 'catalog.admin.edit.page': 'Pagina bewerken', + 'catalog.admin.hidden': 'verborgen', + 'catalog.admin.edit.title': '"%name%" bewerken', + 'catalog.admin.show': 'Tonen', + 'catalog.admin.hide': 'Verbergen', + 'catalog.admin.delete': 'Verwijderen', + 'catalog.admin.delete.title': '"%name%" verwijderen', + 'catalog.admin.delete.category.confirm': 'Categorie "%name%" en alle inhoud verwijderen?', + 'catalog.admin.delete.page': 'Pagina verwijderen', + 'catalog.admin.delete.page.confirm': 'Pagina "%name%" verwijderen?', + 'catalog.admin.delete.offer.confirm': 'Weet je zeker dat je deze aanbieding wilt verwijderen?', + 'catalog.admin.create': 'Aanmaken', + 'catalog.admin.save': 'Opslaan', + 'catalog.admin.create.subpage': 'Subpagina aanmaken', + 'catalog.admin.order': 'Volgorde', + 'catalog.admin.visible': 'Zichtbaar', + 'catalog.admin.enabled': 'Ingeschakeld', + + // Catalog admin: offer editor + 'catalog.admin.offer.new': 'Nieuwe aanbieding', + 'catalog.admin.offer.edit': 'Aanbieding bewerken', + 'catalog.admin.offer.name': 'Catalogusnaam', + 'catalog.admin.offer.general': 'Algemeen', + 'catalog.admin.offer.quantity': 'Aantal', + 'catalog.admin.offer.prices': 'Prijzen', + 'catalog.admin.offer.credits': 'Credits', + 'catalog.admin.offer.points': 'Punten', + 'catalog.admin.offer.points.type': 'Type punten', + 'catalog.admin.offer.options': 'Opties', + 'catalog.admin.offer.club.only': 'Alleen Club', + 'catalog.admin.offer.extradata': 'Extra data (optioneel)....', + 'catalog.admin.offer.have.offer': 'Multi-korting (have_offer)', + + // Catalog: trophies + 'catalog.trophies.title': 'Trofeeën', + 'catalog.trophies.write.hint': 'Schrijf een tekst voor de trofee voordat je koopt', + 'catalog.trophies.inscription': 'Trofee-inscriptie', + 'catalog.trophies.inscription.placeholder': 'Schrijf de tekst die op de trofee komt te staan...', + + // Catalog: pets + 'catalog.pets.show.colors': 'Toon kleuren', + 'catalog.pets.choose.color': 'Kies kleur', + 'catalog.pets.choose.breed': 'Kies ras', + 'catalog.pets.back.breeds': '← Rassen', + + // Catalog: name prefix editor + 'catalog.prefix.text': 'Tekst', + 'catalog.prefix.text.placeholder': 'Voer tekst in...', + 'catalog.prefix.icon': 'Icoon', + 'catalog.prefix.icon.remove': 'Icoon verwijderen', + 'catalog.prefix.effect': 'Effect', + 'catalog.prefix.color': 'Kleur', + 'catalog.prefix.color.single': '🎨 Enkel', + 'catalog.prefix.color.per.letter': '🌈 Per letter', + 'catalog.prefix.color.hint': 'Selecteer een letter en kies vervolgens de kleur. Gaat automatisch door.', + 'catalog.prefix.color.apply.all.title': 'Huidige kleur op alle letters toepassen', + 'catalog.prefix.color.apply.all': 'Op alles toepassen', + 'catalog.prefix.color.selected': 'Geselecteerde letter:', + 'catalog.prefix.price': 'Prijs:', + 'catalog.prefix.price.amount': '5 Credits', + 'catalog.prefix.purchased': '✓ Gekocht!', + 'catalog.prefix.purchase': 'Kopen', + + // Catalog: gift wrapping + 'catalog.gift_wrapping.gift_sent': 'Klaar!', + + // ------------------------------------------------------------------------ + // Group forum + // ------------------------------------------------------------------------ + 'groupforum.list.tab.most_active': 'Meest actieve onderwerpen', + 'groupforum.list.tab.my_forums': 'Mijn groepsforums', + 'groupforum.list.no_forums': 'Er zijn geen forums', + 'groupforum.view.threads': 'Aantal onderwerpen', + 'groupforum.thread.pin': 'Onderwerp vastpinnen', + 'groupforum.thread.unpin': 'Onderwerp losmaken', + 'groupforum.thread.lock': 'Onderwerp vergrendelen', + 'groupforum.thread.unlock': 'Onderwerp ontgrendelen', + 'groupforum.thread.hide': 'Onderwerp verbergen', + 'groupforum.thread.restore': 'Onderwerp weer zichtbaar maken', + 'groupforum.thread.delete': 'Onderwerp + berichten verwijderen', + 'groupforum.message.hide': 'Bericht verbergen', + 'group.forum.enable.caption': 'Groepsforum in-/uitschakelen', + 'group.forum.enable.help': 'Als je het groepsforum uitschakelt, worden ook alle berichten verwijderd!', + 'groupforum.view.no_threads': 'Er zijn op dit moment geen actieve onderwerpen', + + // ------------------------------------------------------------------------ + // Mod tools: window + // ------------------------------------------------------------------------ + 'modtools.window.title': 'Mod Tools', + 'modtools.window.tools.room': 'Kamertool', + 'modtools.window.tools.chatlog': 'Chatlogtool', + 'modtools.window.tools.report': 'Rapporttool', + 'modtools.window.select.user': 'Selecteer een gebruiker', + 'modtools.window.no.room': 'Ga eerst een kamer binnen', + 'modtools.window.user.in_room': 'Nog steeds in deze kamer', + 'modtools.window.user.left_room': 'Niet langer in deze kamer', + 'modtools.window.user.clear': 'Selectie wissen', + 'modtools.window.tickets.open': '%count% open ticket', + 'modtools.window.tickets.open.many': '%count% open tickets', + 'modtools.window.section.room': 'Kamer', + 'modtools.window.section.user': 'Gebruiker', + 'modtools.window.section.reports': 'Rapporten', + 'modtools.window.user.open_info': 'Info openen', + + // ------------------------------------------------------------------------ + // Mod tools: user info + // ------------------------------------------------------------------------ + 'modtools.userinfo.title': 'Gebruikersinfo: %username%', + 'modtools.userinfo.userName': 'Naam', + 'modtools.userinfo.cfhCount': 'CFH’s', + 'modtools.userinfo.abusiveCfhCount': 'Misbruikte CFH’s', + 'modtools.userinfo.cautionCount': 'Waarschuwingen', + 'modtools.userinfo.banCount': 'Bans', + 'modtools.userinfo.lastSanctionTime': 'Laatste sanctie', + 'modtools.userinfo.tradingLockCount': 'Ruilblokkades', + 'modtools.userinfo.tradingExpiryDate': 'Blokkade verloopt', + 'modtools.userinfo.minutesSinceLastLogin': 'Laatste login', + 'modtools.userinfo.lastPurchaseDate': 'Laatste aankoop', + 'modtools.userinfo.primaryEmailAddress': 'E-mail', + 'modtools.userinfo.identityRelatedBanCount': 'Verbannen accounts', + 'modtools.userinfo.registrationAgeInMinutes': 'Geregistreerd', + 'modtools.userinfo.userClassification': 'Rang', + 'modtools.userinfo.refresh': 'Gebruikersinfo vernieuwen', + 'modtools.userinfo.presence.in_room': 'In kamer', + 'modtools.userinfo.presence.in_room.title': 'In de kamer die je observeert', + 'modtools.userinfo.presence.online': 'Online', + 'modtools.userinfo.presence.online.title': 'Online op het hotel', + 'modtools.userinfo.presence.offline': 'Offline', + 'modtools.userinfo.presence.offline.title': 'Offline bij openen paneel', + 'modtools.userinfo.section.account': 'Account', + 'modtools.userinfo.section.activity': 'Activiteit', + 'modtools.userinfo.section.sanctions': 'Sancties', + 'modtools.userinfo.section.trading': 'Ruilen', + 'modtools.userinfo.button.room.chat': 'Kamerchat', + 'modtools.userinfo.button.send.message': 'Bericht verzenden', + 'modtools.userinfo.button.room.visits': 'Kamerbezoeken', + 'modtools.userinfo.button.mod.action': 'Mod-actie', + 'modtools.userinfo.stat.cfh': 'CFH', + 'modtools.userinfo.stat.cautions': 'Waarschuwingen', + 'modtools.userinfo.stat.bans': 'Bans', + 'modtools.userinfo.stat.trade.locks': 'Ruilblokkades', + + // ------------------------------------------------------------------------ + // Mod tools: room info + // ------------------------------------------------------------------------ + 'modtools.roominfo.title': 'Kamerinfo', + 'modtools.roominfo.refresh': 'Kamerinfo vernieuwen', + 'modtools.roominfo.loading': 'Laden…', + 'modtools.roominfo.owner.here': 'Eigenaar aanwezig', + 'modtools.roominfo.owner.away': 'Eigenaar afwezig', + 'modtools.roominfo.owner.title.here': 'De kamereigenaar is op dit moment binnen', + 'modtools.roominfo.owner.title.away': 'De kamereigenaar is NIET binnen', + 'modtools.roominfo.stat.users': 'Gebruikers', + 'modtools.roominfo.stat.owner': 'Eigenaar', + 'modtools.roominfo.owner.open': 'Info van %username% openen', + 'modtools.roominfo.button.visit': 'Kamer bezoeken', + 'modtools.roominfo.button.chatlog': 'Chatlog', + 'modtools.roominfo.moderate.title': 'Kamer modereren', + 'modtools.roominfo.moderate.kick': 'Iedereen eruit kicken', + 'modtools.roominfo.moderate.doorbell': 'Deurbel inschakelen', + 'modtools.roominfo.moderate.rename': 'Kamernaam wijzigen', + 'modtools.roominfo.moderate.message.placeholder': 'Verplicht bericht dat met de actie wordt meegestuurd…', + 'modtools.roominfo.moderate.send.caution': 'Waarschuwing sturen', + 'modtools.roominfo.moderate.send.alert': 'Melding sturen', + + // ------------------------------------------------------------------------ + // Mod tools: user message + // ------------------------------------------------------------------------ + 'modtools.user.message.title': 'Bericht verzenden', + 'modtools.user.message.recipient': 'Bericht aan', + 'modtools.user.message.label': 'Bericht', + 'modtools.user.message.placeholder': 'Schrijf iets nuttigs — de gebruiker ziet het als een moderatorbericht.', + 'modtools.user.message.empty': 'Leeg', + 'modtools.user.message.chars': '%count% tekens', + 'modtools.user.message.send': 'Bericht verzenden', + + // ------------------------------------------------------------------------ + // Mod tools: mod action + // ------------------------------------------------------------------------ + 'modtools.user.modaction.title': 'Mod-actie: %username%', + 'modtools.user.modaction.sanctioning': 'Sanctioneren', + 'modtools.user.modaction.step.topic': '1. CFH-onderwerp', + 'modtools.user.modaction.step.topic.placeholder': 'Selecteer een onderwerp…', + 'modtools.user.modaction.step.sanction': '2. Sanctie', + 'modtools.user.modaction.step.sanction.placeholder': 'Selecteer een sanctie…', + 'modtools.user.modaction.step.message': '3. Eigen bericht', + 'modtools.user.modaction.step.message.optional': '(optioneel — overschrijft standaard)', + 'modtools.user.modaction.message.placeholder': 'Laat leeg om het standaard onderwerpbericht te gebruiken', + 'modtools.user.modaction.preview': 'Voorbeeld', + 'modtools.user.modaction.button.default': 'Standaardsanctie', + 'modtools.user.modaction.button.apply': 'Sanctie toepassen', + 'modtools.user.modaction.error.no.topic': 'Je moet een CFH-onderwerp selecteren', + 'modtools.user.modaction.error.no.action': 'Je moet een CFH-onderwerp en sanctie selecteren', + 'modtools.user.modaction.error.no.permission': 'Je hebt geen toestemming om dit te doen', + 'modtools.user.modaction.error.no.message': 'Schrijf een bericht aan de gebruiker', + 'modtools.user.modaction.error.no.permission.alert': 'Je hebt onvoldoende rechten', + + // ------------------------------------------------------------------------ + // Mod tools: user visits + // ------------------------------------------------------------------------ + 'modtools.user.visits.title': 'Gebruikersbezoeken', + 'modtools.user.visits.recent': 'Recent bezochte kamers', + 'modtools.user.visits.entries.one': '%count% vermelding', + 'modtools.user.visits.entries.many': '%count% vermeldingen', + 'modtools.user.visits.empty': 'Geen recente bezoeken', + 'modtools.user.visits.time': 'Tijd', + 'modtools.user.visits.room': 'Kamernaam', + 'modtools.user.visits.action': 'Actie', + 'modtools.user.visits.visit': 'Bezoeken', + 'modtools.user.visits.visit.title': 'Kamer bezoeken', + + // ------------------------------------------------------------------------ + // Mod tools: chatlog + // ------------------------------------------------------------------------ + 'modtools.user.chatlog.title': 'Gebruikerschatlog', + 'modtools.user.chatlog.title.with': 'Gebruikerschatlog: %username%', + 'modtools.user.chatlog.loading': 'Chatlog laden…', + 'modtools.room.chatlog.title': 'Kamerchatlog', + 'modtools.chatlog.column.time': 'Tijd', + 'modtools.chatlog.column.user': 'Gebruiker', + 'modtools.chatlog.column.message': 'Bericht', + 'modtools.chatlog.empty': 'Geen berichten', + 'modtools.chatlog.visit': 'Bezoeken', + 'modtools.chatlog.tools': 'Tools', + + // ------------------------------------------------------------------------ + // Mod tools: tickets + // ------------------------------------------------------------------------ + 'modtools.tickets.title': 'Tickets', + 'modtools.tickets.tab.open': 'Open', + 'modtools.tickets.tab.mine': 'Mijn', + 'modtools.tickets.tab.picked': 'Alle opgepakt', + 'modtools.tickets.column.type': 'Type', + 'modtools.tickets.column.reported': 'Gerapporteerd', + 'modtools.tickets.column.opened': 'Geopend', + 'modtools.tickets.column.picker': 'Opgepakt door', + 'modtools.tickets.empty.open': 'Geen open meldingen', + 'modtools.tickets.empty.mine': 'Geen door jou opgepakte meldingen', + 'modtools.tickets.empty.picked': 'Geen opgepakte meldingen', + 'modtools.tickets.action.pick': 'Oppakken', + 'modtools.tickets.action.handle': 'Afhandelen', + 'modtools.tickets.action.release': 'Vrijgeven', + 'modtools.tickets.issue.title': 'Melding #%issueId% oplossen', + 'modtools.tickets.issue.label': 'Melding #%issueId%', + 'modtools.tickets.issue.details': 'Details', + 'modtools.tickets.issue.field.source': 'Bron', + 'modtools.tickets.issue.field.category': 'Categorie', + 'modtools.tickets.issue.field.description': 'Beschrijving', + 'modtools.tickets.issue.field.caller': 'Melder', + 'modtools.tickets.issue.field.reported': 'Gerapporteerd', + 'modtools.tickets.issue.chatlog.view': 'Chatlog bekijken', + 'modtools.tickets.issue.chatlog.close': 'Chatlog sluiten', + 'modtools.tickets.issue.resolve.heading': 'Oplossen als', + 'modtools.tickets.issue.resolve.resolved': 'Opgelost', + 'modtools.tickets.issue.resolve.useless': 'Nutteloos', + 'modtools.tickets.issue.resolve.abusive': 'Misbruik', + 'modtools.tickets.issue.release': 'Terug in wachtrij plaatsen', + 'modtools.tickets.cfh.chatlog.title': 'Melding #%issueId% chatlog', + + // ------------------------------------------------------------------------ + // Login + // ------------------------------------------------------------------------ + 'login.username': 'Wat is jou Camwijs naam', + 'login.forgot_password': 'Wachtwoord vergeten?', + + // First-time visitors card + 'nitro.login.firsttime.title': 'Voor het eerst hier?', + 'nitro.login.firsttime.text': 'Heb je nog geen Camwijs account?', + 'nitro.login.firsttime.link': 'Je kunt er hier een aanmaken', + 'nitro.login.card.title': 'Aanmelden bij Camwijs', + + // Server status checks + 'nitro.login.server.offline.short': 'De gameserver draait momenteel niet. Probeer het zo meteen opnieuw.', + 'nitro.login.server.offline.long': 'De gameserver draait momenteel niet, dus er kunnen geen nieuwe accounts worden aangemaakt. Probeer het zo meteen opnieuw.', + 'nitro.login.server.checking': 'Controleren…', + 'nitro.login.server.retry': 'Opnieuw proberen', + + // Registration flow + 'nitro.login.register.title': 'Camwijs-gegevens', + 'nitro.login.register.next': 'Volgende', + 'nitro.login.register.finish': 'Voltooien', + 'nitro.login.register.creating': 'Bezig met aanmaken…', + 'nitro.login.register.intro.credentials': 'Laten we je account aanmaken. Voer je e-mailadres in en kies een wachtwoord — we controleren of dit e-mailadres nog niet in gebruik is.', + 'nitro.login.register.intro.avatar': 'Nu is het tijd om je eigen Camwijs-personage te maken! Begin met het kiezen van je Camwijs-naam.', + 'nitro.login.register.intro.room': 'Laatste stap — kies een startkamer, of sla dit over en maak later je eigen kamer.', + 'nitro.login.register.confirm.label': 'Bevestig wachtwoord', + 'nitro.login.register.username.placeholder': 'HabboNaam', + 'nitro.login.register.hotlooks.count': '%count% looks beschikbaar', + 'nitro.login.register.hotlooks.none': 'Geen looks geladen', + 'nitro.login.register.room.skip.title': 'Prima — ik maak mijn eigen kamers', + 'nitro.login.register.room.skip.description': 'Sla dit over en begin met een lege hotelinventaris.', + 'nitro.login.register.room.loading': 'Kamers laden…', + 'nitro.login.register.room.error': 'Kon kameropties niet laden. Je kunt deze stap nog steeds overslaan.', + 'nitro.login.register.success': 'Welkom aan boord, %username%! Je account is klaar — log hieronder in met het wachtwoord dat je zojuist hebt gekozen.', + + // Forgot password + 'nitro.login.forgot.title': 'Wachtwoord resetten', + 'nitro.login.forgot.email.label': 'E-mailadres', + 'nitro.login.forgot.send': 'E-mail verzenden', + 'nitro.login.forgot.success': 'E-mail verzonden! Als er een account bij dit adres hoort, vind je binnenkort een resetlink in je inbox (controleer je spam als je binnen een minuut niets ziet).', + + // Login errors (validation + transport) + 'nitro.login.error.missing_credentials': 'Voer zowel je Camwijs-naam als wachtwoord in.', + 'nitro.login.error.invalid_credentials': 'Ongeldige Camwijs-naam of wachtwoord.', + 'nitro.login.error.too_many_attempts': 'Te veel pogingen. Probeer het opnieuw over %seconds%s.', + 'nitro.login.error.turnstile': 'Voltooi de beveiligingscontrole.', + 'nitro.login.error.server_offline': 'De gameserver draait niet. Probeer het later opnieuw.', + 'nitro.login.error.login_unreachable': 'Kan de inlogservice niet bereiken. Probeer het opnieuw.', + 'nitro.login.error.register_failed': 'Kan je account niet aanmaken.', + 'nitro.login.error.register_unreachable': 'Kan de registratieservice niet bereiken.', + 'nitro.login.error.forgot_failed': 'Kan momenteel geen reset-e-mail verzenden.', + 'nitro.login.error.forgot_unreachable': 'Kan de wachtwoordresetservice niet bereiken.', + 'nitro.login.error.missing_fields': 'Vul alle velden in.', + 'nitro.login.error.invalid_email': 'Voer een geldig e-mailadres in.', + 'nitro.login.error.password_too_short': 'Je wachtwoord moet minimaal 8 tekens lang zijn.', + 'nitro.login.error.password_mismatch': 'Wachtwoorden komen niet overeen.', + 'nitro.login.error.email_taken': 'Dit e-mailadres is al in gebruik.', + 'nitro.login.error.missing_username': 'Kies een Camwijs-naam.', + 'nitro.login.error.username_length': 'De Camwijs-naam moet 3–16 tekens bevatten.', + 'nitro.login.error.username_taken': 'Deze Camwijs-naam is al in gebruik.', + 'nitro.login.error.missing_email': 'Voer je e-mailadres in.', + + // ------------------------------------------------------------------------ + // Inventory + // ------------------------------------------------------------------------ + 'inventory.effects.activate': 'Gebruik effect', + 'inventory.effects.remove': 'verwijder effect', + + // ------------------------------------------------------------------------ + // Loading screen — boot-stage labels read by App.tsx (taskLabel) + // ------------------------------------------------------------------------ + 'loading.task.session': 'Sessie verifiëren...', + 'loading.task.renderer': 'Renderer initialiseren...', + 'loading.task.assets': 'Spelmiddelen laden...', + 'loading.task.localization': 'Vertalingen laden...', + 'loading.task.avatar': 'Garderobe laden...', + 'loading.task.sounds': 'Geluiden laden...', + 'loading.task.startsession': 'Sessie starten...', + 'loading.task.userdata': 'Gebruikersgegevens laden...', + 'loading.task.rooms': 'Kamers laden...', + 'loading.task.engine': 'Grafische engine laden...', + + // ------------------------------------------------------------------------ + // Housekeeping + // ------------------------------------------------------------------------ + 'housekeeping.title': 'Beheer', + 'housekeeping.mode.light': 'Licht', + + // Housekeeping: tabs + 'housekeeping.tab.dashboard': 'Dashboard', + 'housekeeping.tab.users': 'Gebruikers', + 'housekeeping.tab.rooms': 'Kamers', + 'housekeeping.tab.economy': 'Economie', + 'housekeeping.tab.audit': 'Logboek', + + // Housekeeping: confirm + status + 'housekeeping.confirm.title': 'Actie bevestigen', + 'housekeeping.confirm.proceed': 'Doorgaan', + 'housekeeping.confirm.cancel': 'Annuleren', + 'housekeeping.status.dismiss': 'Sluiten', + + // Housekeeping: action status + 'housekeeping.action.pending': 'Actie bezig…', + 'housekeeping.action.success': 'Actie voltooid', + 'housekeeping.action.error': 'Actie mislukt', + 'housekeeping.action.reset_password.done': 'Wachtwoord gereset — nieuw wachtwoord hieronder.', + + // Housekeeping: generated password card + 'housekeeping.password.title': '%username% (#%id%) · nieuw wachtwoord', + 'housekeeping.password.value_label': 'Gegenereerd wachtwoord', + 'housekeeping.password.copy': 'Kopiëren', + 'housekeeping.password.copied': 'Gekopieerd', + 'housekeeping.password.copy_failed': 'Kopiëren mislukt', + 'housekeeping.password.dismiss': 'Sluiten', + 'housekeeping.password.hint': 'Deel dit buiten het hotel om met de gebruiker. Dit wordt eenmalig getoond — sluit deze kaart als je klaar bent; het wachtwoord wordt nooit meer weergegeven.', + + // Housekeeping: errors + 'housekeeping.error.invalid_input': 'Ongeldige invoer — controleer de gebruikers-ID en de ingevoerde waarde.', + 'housekeeping.error.user_not_found': 'Gebruiker niet gevonden.', + 'housekeeping.error.user_offline': 'Gebruiker is offline — deze actie werkt alleen bij online gebruikers.', + 'housekeeping.error.target_unkickable': 'Deze gebruiker kan niet gekickt worden.', + 'housekeeping.error.ban_failed': 'Ban kon niet worden toegepast — de server weigerde het verzoek.', + 'housekeeping.error.no_active_ban': 'Geen actieve ban om op te heffen voor deze gebruiker.', + 'housekeeping.error.rank_not_found': 'Rang niet gevonden — kies een rang die bestaat in permission_ranks.', + 'housekeeping.error.db_failed': 'Databasefout — zie het emulator-log voor de SQL-uitzondering.', + 'housekeeping.error.hash_failed': 'Kon het nieuwe wachtwoord niet hashen — SHA-256 niet beschikbaar op deze JVM.', + 'housekeeping.error.room_not_found': 'Kamer niet gevonden.', + 'housekeeping.error.room_action_failed': 'Kameractie kon niet worden toegepast.', + 'housekeeping.error.new_owner_not_found': 'Nieuwe eigenaar niet gevonden.', + 'housekeeping.error.economy_failed': 'Economie-actie kon niet worden toegepast — controleer de gebruikers-ID en het aantal.', + 'housekeeping.error.alert_empty': 'Hotelmelding mag niet leeg zijn.', + + // Housekeeping: actions + 'housekeeping.action.ban_h': 'Ban %h%u', + 'housekeeping.action.mute_min': 'Mute %m%m', + 'housekeeping.action.trade_lock_h': 'Ruilblokkade %h%u', + 'housekeeping.action.kick': 'Kick', + 'housekeeping.action.unban': 'Ban opheffen', + 'housekeeping.action.force_disconnect': 'Verbinding verbreken', + 'housekeeping.action.set_rank': 'Rang instellen', + 'housekeeping.action.reset_password': 'Wachtwoord resetten', + + // Housekeeping: user panel + 'housekeeping.user.search.placeholder': 'Zoek op gebruikersnaam…', + 'housekeeping.user.search.button': 'Zoeken', + 'housekeeping.user.clear': 'Selectie wissen', + 'housekeeping.user.none': 'Geen gebruiker geselecteerd — zoek hierboven om er een te kiezen.', + 'housekeeping.user.not_found': 'Gebruiker niet gevonden.', + 'housekeeping.user.credits': 'Credits', + 'housekeeping.user.duckets': 'Duckets / pixels', + 'housekeeping.user.diamonds': 'Diamonds', + 'housekeeping.user.audit_hint': 'Alle acties worden vastgelegd in het logboek-tabblad.', + 'housekeeping.user.live.label': 'Live (in huidige kamer)', + 'housekeeping.user.live.kick': 'Kick', + 'housekeeping.user.live.mute_2m': 'Mute 2m', + 'housekeeping.user.live.mute_10m': 'Mute 10m', + 'housekeeping.user.live.ban_h': 'Ban 1u', + 'housekeeping.user.live.ban_d': 'Ban 1d', + + // Housekeeping: room panel + 'housekeeping.room.search.placeholder': 'Kamer-ID…', + 'housekeeping.room.search.button': 'Zoeken', + 'housekeeping.room.clear': 'Selectie wissen', + 'housekeeping.room.none': 'Geen kamer geselecteerd — voer hierboven een ID in.', + 'housekeeping.room.not_found': 'Kamer niet gevonden.', + 'housekeeping.room.open': 'Openen', + 'housekeeping.room.close': 'Sluiten', + 'housekeeping.room.mute_min': 'Mute %m%m', + 'housekeeping.room.kick_all': 'Iedereen kicken', + 'housekeeping.room.kick_all.confirm': 'Elke gebruiker die nu in de kamer is kicken?', + 'housekeeping.room.delete': 'Kamer verwijderen', + 'housekeeping.room.delete.confirm': 'Deze kamer en alle meubels permanent verwijderen?', + 'housekeeping.room.transfer': 'Overdragen', + 'housekeeping.room.transfer.label': 'Eigendom overdragen', + 'housekeeping.room.transfer.new_owner': 'ID nieuwe eigenaar', + + // Housekeeping: economy + 'housekeeping.economy.select_user': 'Kies eerst een gebruiker in het tabblad Gebruikers.', + 'housekeeping.economy.target': 'Doel: %username% (#%id%)', + 'housekeeping.economy.give_credits': 'Credits geven', + 'housekeeping.economy.give_duckets': 'Duckets geven', + 'housekeeping.economy.give_diamonds': 'Diamonds geven', + 'housekeeping.economy.grant_item': 'Item toekennen', + 'housekeeping.economy.grant_item.label': 'Catalogusitem toekennen', + 'housekeeping.economy.item_id': 'Item-ID', + 'housekeeping.economy.item_quantity': 'Aantal', + 'housekeeping.economy.set_hc_days': 'HC-dagen instellen', + + // Housekeeping: hotel-wide alert + 'housekeeping.hotel.alert.label': 'Hotelbrede melding', + 'housekeeping.hotel.alert.placeholder': 'Bericht dat naar elke verbonden gebruiker wordt uitgezonden…', + 'housekeeping.hotel.alert.send': 'Naar hotel sturen', + 'housekeeping.hotel.alert.confirm': 'Melding van %count% tekens naar elke verbonden gebruiker uitzenden?', + + // Housekeeping: dashboard + 'housekeeping.dashboard.title': 'Overzicht', + 'housekeeping.dashboard.refresh': 'Vernieuwen', + 'housekeeping.dashboard.loading': 'Dashboard laden…', + 'housekeeping.dashboard.unavailable': 'Dashboard niet beschikbaar — controleer het admin-endpoint.', + 'housekeeping.dashboard.online': 'Online', + 'housekeeping.dashboard.total_users': '%count% totaal', + 'housekeeping.dashboard.rooms_active': 'Actieve kamers', + 'housekeeping.dashboard.total_rooms': '%count% totaal', + 'housekeeping.dashboard.peak_today': 'Piek vandaag', + 'housekeeping.dashboard.peak_alltime': 'Aller-tijden piek %count%', + 'housekeeping.dashboard.pending_tickets': 'Tickets', + 'housekeeping.dashboard.sanctions_24h': '%count% sancties / 24u', + 'housekeeping.dashboard.server': 'Server', + 'housekeeping.dashboard.recent_sanctions': 'Recente sancties', + 'housekeeping.dashboard.recent_lookups': 'Recente opzoekingen', + + // Housekeeping: audit log + 'housekeeping.audit.title': 'Logboek', + 'housekeeping.audit.refresh': 'Vernieuwen', + 'housekeeping.audit.filter.all': 'Alle', + 'housekeeping.audit.filter.users': 'Gebruikers', + 'housekeeping.audit.filter.rooms': 'Kamers', + 'housekeeping.audit.filter.hotel': 'Hotel', + 'housekeeping.audit.search.placeholder': 'Zoek uitvoerder / doel / actie…', + 'housekeeping.audit.empty': 'Nog geen logboekvermeldingen.', + 'housekeeping.audit.no_match': 'Geen vermeldingen komen overeen met de huidige filters.', + + // Housekeeping: shared fields + 'housekeeping.field.reason': 'Reden', + 'housekeeping.field.reason.placeholder': 'Vrije reden (optioneel)', + 'housekeeping.field.duration': 'Duur', + 'housekeeping.reason.default': 'Geen reden opgegeven.', + + // Housekeeping: context menu + 'housekeeping.menu.send_to_hk': 'Naar beheer sturen', + + // Housekeeping: bulk actions + 'housekeeping.bulk.done': 'Bulk klaar', + 'housekeeping.bulk.success': 'Alle bulkacties geslaagd.', + 'housekeeping.bulk.partial': 'Bulk voltooid met enkele mislukkingen.', + 'housekeeping.bulk.failed': 'Elke bulkactie is mislukt.', + 'housekeeping.bulk.confirm': '%action% toepassen op %count% geselecteerde gebruikers?', + 'housekeeping.bulk.label': '%count% geselecteerd', + 'housekeeping.bulk.clear': 'Selectie wissen', + 'housekeeping.bulk.apply': 'Toepassen op selectie', + + // Housekeeping: telemetry + 'housekeeping.telemetry.title': 'Telemetrie', + 'housekeeping.telemetry.empty': 'Nog geen acties waargenomen.', + 'housekeeping.telemetry.reset': 'Statistieken resetten', + + // Housekeeping: live room session + 'housekeeping.live.no_room': 'Geen actieve kamersessie.', + 'housekeeping.live.kicked': 'Uit de kamer gekickt.', + 'housekeeping.live.banned': 'Verbannen uit de kamer.', + 'housekeeping.live.muted': 'Gemute in de kamer.', + + // Housekeeping: validation + 'housekeeping.validation.empty_username': 'Gebruikersnaam mag niet leeg zijn.', + 'housekeeping.validation.invalid_user_id': 'Ongeldige gebruikers-ID.', + 'housekeeping.validation.invalid_room_id': 'Ongeldige kamer-ID.', + 'housekeeping.validation.invalid_amount': 'Ongeldig aantal.', + 'housekeeping.validation.amount_too_large': 'Aantal overschrijdt de veiligheidslimiet.', + 'housekeeping.validation.empty_reason': 'Reden mag niet leeg zijn.', + 'housekeeping.validation.invalid_hours': 'Ongeldige duur in uren.', + 'housekeeping.validation.invalid_rank': 'Ongeldige rang — moet tussen 1 en 12 liggen.', + + // ------------------------------------------------------------------------ + // Fortune Wheel + // ------------------------------------------------------------------------ + 'wheel.title': 'Rad van Fortuin', + 'wheel.free.today': 'Je hebt vandaag %count% gratis draaibeurten!', + 'wheel.extra': 'Extra draaibeurten: %count%', + 'wheel.spin': 'DRAAIEN', + 'wheel.buy': 'Draaibeurt kopen', + 'wheel.winners': 'Laatste winnaars', + 'wheel.winners.empty': 'Nog geen winnaars', + + // ------------------------------------------------------------------------ + // Soundboard + // ------------------------------------------------------------------------ + 'soundboard.title': 'Soundboard', + 'soundboard.empty': 'Geen geluiden beschikbaar', + 'soundboard.lastplayed': 'Afgespeeld door %user%', + 'soundboard.room.setting.desc': 'Laat mensen in deze kamer geluidseffecten afspelen', + + // ------------------------------------------------------------------------ + // Radio + // ------------------------------------------------------------------------ + 'radio.title': 'Radio', + 'radio.empty': 'Geen stations', + 'radio.error': 'Kon de stations niet laden', + 'radio.stop': 'Stoppen', + + // ------------------------------------------------------------------------ + // Rare Values + // ------------------------------------------------------------------------ + 'rarevalues.title': 'Zeldzame waarden', + 'rarevalues.loading': 'Waarden laden…', + 'rarevalues.empty': 'Geen rares gevonden', + 'rarevalues.infostand.label': 'Waarde:', + + // Rare Values: editor + 'rarevalues.editor.tab': 'Bewerken', + 'rarevalues.editor.type': 'Type', + 'rarevalues.editor.value': 'Waarde', + 'rarevalues.editor.weight': 'Kans', + 'rarevalues.editor.label': 'Label', + 'rarevalues.editor.save': 'Opslaan', + 'rarevalues.editor.cat.item': 'Meubel (ID)', + 'rarevalues.editor.cat.spin': 'Extra draaien', + 'rarevalues.editor.cat.nothing': 'Niets', + + // ------------------------------------------------------------------------ + // Chat commands: client + // ------------------------------------------------------------------------ + 'chatcmd.client.shake': 'Schud de kamer', + 'chatcmd.client.rotate': 'Draai de kamer', + 'chatcmd.client.zoom': 'Zoom in/uit', + 'chatcmd.client.flip': 'Reset zoom', + 'chatcmd.client.iddqd': 'Zet kamer op zijn kop', + 'chatcmd.client.screenshot': 'Schermafbeelding van de kamer', + 'chatcmd.client.togglefps': 'FPS aan/uit', + 'chatcmd.client.laugh': 'Lach (VIP)', + 'chatcmd.client.kiss': 'Stuur een kus (VIP)', + 'chatcmd.client.jump': 'Spring (VIP)', + 'chatcmd.client.idle': 'Ga afwezig', + 'chatcmd.client.sign': 'Toon bordje', + 'chatcmd.client.furni': 'Meubelkiezer', + 'chatcmd.client.chooser': 'Gebruikerskiezer', + 'chatcmd.client.floor': 'Vloer-editor', + 'chatcmd.client.pickall': 'Pak alle meubels op', + 'chatcmd.client.ejectall': 'Verwijder alle meubels', + 'chatcmd.client.settings': 'Kamerinstellingen', + 'chatcmd.client.info': 'Client info', +} diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index 5745409..792be76 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -12,7 +12,7 @@ "sounds.url": "${asset.url}/sounds/%sample%.mp3", "external.texts.url": [ "${gamedata.url}/ExternalTexts.json?t=%timestamp%", - "${gamedata.url}/UITexts.json?t=%timestamp%" + "${gamedata.url}/UITexts.json5?t=%timestamp%" ], "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", diff --git a/public/configuration/wheel-texts-en.example b/public/configuration/wheel-texts-en.example deleted file mode 100644 index 0ba7b3e..0000000 --- a/public/configuration/wheel-texts-en.example +++ /dev/null @@ -1,17 +0,0 @@ -{ - "wheel.title": "Fortune Wheel", - "wheel.free.today": "You have %count% free spins today!", - "wheel.extra": "Extra spins: %count%", - "wheel.spin": "SPIN", - "wheel.buy": "Buy spin", - "wheel.winners": "Latest winners", - "wheel.winners.empty": "No winners yet", - "soundboard.title": "Soundboard", - "soundboard.empty": "No sounds available", - "soundboard.lastplayed": "Played by %user%", - "soundboard.room.setting.desc": "Let people in this room play sound effects", - "radio.title": "Radio", - "radio.empty": "No stations", - "radio.error": "Couldn't load stations", - "radio.stop": "Stop" -} diff --git a/public/configuration/wheel-texts-it.example b/public/configuration/wheel-texts-it.example deleted file mode 100644 index dadcbc7..0000000 --- a/public/configuration/wheel-texts-it.example +++ /dev/null @@ -1,17 +0,0 @@ -{ - "wheel.title": "Ruota della Fortuna", - "wheel.free.today": "Hai %count% giri gratis oggi!", - "wheel.extra": "Giri extra: %count%", - "wheel.spin": "GIRA", - "wheel.buy": "Compra giro", - "wheel.winners": "Ultimi vincitori", - "wheel.winners.empty": "Ancora nessun vincitore", - "soundboard.title": "Soundboard", - "soundboard.empty": "Nessun suono disponibile", - "soundboard.lastplayed": "Suonato da %user%", - "soundboard.room.setting.desc": "Permetti ai presenti di suonare effetti audio in questa stanza", - "radio.title": "Radio", - "radio.empty": "Nessuna stazione", - "radio.error": "Impossibile caricare le stazioni", - "radio.stop": "Stop" -} From 49836bbeef3bd7ee960708af6748fb8496fe2e99 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 15:31:24 +0200 Subject: [PATCH 31/86] feat: branding furni image position editor (move + scale) Adds an "Editor Posizione" button to the furni infostand action bar for branding / MPU furni, opening a dialog to position and zoom the image: - draggable dot moves offsetX/Y (live, local preview only) - slider zooms the image (scale, via the renderer's per-sprite scale) - offsetZ kept as z-index; Save persists + broadcasts via SetObjectData - radio "Live" + all editor labels go through LocalizeText (external texts) Pairs with the renderer branding scale/offset support and Arcturus' `scale` default on InteractionRoomAds. --- src/components/radio/RadioView.tsx | 2 +- .../infostand/ImagePositionEditorView.tsx | 147 ++++++++++++++++++ .../infostand/InfoStandWidgetFurniView.tsx | 56 +++++++ 3 files changed, 204 insertions(+), 1 deletion(-) create mode 100644 src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx diff --git a/src/components/radio/RadioView.tsx b/src/components/radio/RadioView.tsx index 15e7c95..6cd825b 100644 --- a/src/components/radio/RadioView.tsx +++ b/src/components/radio/RadioView.tsx @@ -76,7 +76,7 @@ export const RadioView: FC<{}> = () =>
{ selectedPlaying && - Live + { LocalizeText('radio.live') } } { selected?.genre && { selected.genre } } diff --git a/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx b/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx new file mode 100644 index 0000000..1362ae0 --- /dev/null +++ b/src/components/room/widgets/avatar-info/infostand/ImagePositionEditorView.tsx @@ -0,0 +1,147 @@ +import { GetRoomEngine, RoomObjectCategory, RoomObjectVariable } from '@nitrots/nitro-renderer'; +import { FC, PointerEvent as ReactPointerEvent, useCallback, useEffect, useRef, useState } from 'react'; +import { LocalizeText } from '../../../../../api'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../../common'; + +const PAD_W = 230; +const PAD_H = 150; +// How many offset units one pixel of drag represents. +const UNITS_PER_PX = 1.5; + +interface Props +{ + roomId: number; + objectId: number; + isWallItem: boolean; + initialX: number; + initialY: number; + initialZ: number; + initialScale: number; + onClose: () => void; + onSave: (x: number, y: number, z: number, scale: number) => void; +} + +export const ImagePositionEditorView: FC = props => +{ + const { roomId, objectId, isWallItem, initialX, initialY, initialZ, initialScale, onClose, onSave } = props; + const [ x, setX ] = useState(initialX); + const [ y, setY ] = useState(initialY); + const [ z, setZ ] = useState(initialZ); + const [ scale, setScale ] = useState(initialScale || 100); + const padRef = useRef(null); + const draggingRef = useRef(false); + + const category = isWallItem ? RoomObjectCategory.WALL : RoomObjectCategory.FLOOR; + + // Local-only live preview: set the branding model values directly. The model + // bumps its update counter so the visualization re-renders next frame. + // Nothing is sent to the server until Save. + const applyLive = useCallback((nx: number, ny: number, nz: number, nScale: number) => + { + const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, category); + if(!roomObject?.model) return; + + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_X, nx); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Y, ny); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_OFFSET_Z, nz); + roomObject.model.setValue(RoomObjectVariable.FURNITURE_BRANDING_SCALE, nScale); + }, [ roomId, objectId, category ]); + + useEffect(() => { applyLive(x, y, z, scale); }, [ x, y, z, scale, applyLive ]); + + const setFromPointer = useCallback((clientX: number, clientY: number) => + { + const rect = padRef.current?.getBoundingClientRect(); + if(!rect) return; + + const cx = rect.left + (rect.width / 2); + const cy = rect.top + (rect.height / 2); + + setX(Math.round((clientX - cx) * UNITS_PER_PX)); + setY(Math.round((clientY - cy) * UNITS_PER_PX)); + }, []); + + const onPointerDown = (event: ReactPointerEvent) => + { + draggingRef.current = true; + padRef.current?.setPointerCapture(event.pointerId); + setFromPointer(event.clientX, event.clientY); + }; + + const onPointerMove = (event: ReactPointerEvent) => + { + if(draggingRef.current) setFromPointer(event.clientX, event.clientY); + }; + + const onPointerUp = (event: ReactPointerEvent) => + { + draggingRef.current = false; + padRef.current?.releasePointerCapture?.(event.pointerId); + }; + + const cancel = () => + { + applyLive(initialX, initialY, initialZ, initialScale || 100); + onClose(); + }; + + const save = () => + { + onSave(x, y, z, scale); + onClose(); + }; + + const dotLeft = (PAD_W / 2) + (x / UNITS_PER_PX); + const dotTop = (PAD_H / 2) + (y / UNITS_PER_PX); + const clampedLeft = Math.max(0, Math.min(PAD_W, dotLeft)); + const clampedTop = Math.max(0, Math.min(PAD_H, dotTop)); + + return ( + + + +
+ { LocalizeText('image.position.editor.hint') } +
+ { /* center crosshair */ } +
+
+ { /* draggable dot */ } +
+
+ +
+ { LocalizeText('image.position.editor.scale') } + setScale(e.target.valueAsNumber || 100) } className="grow" /> + { (scale / 100).toFixed(2) }x +
+ +
+ + + +
+ +
+ + +
+
+ + + ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx index d2a861c..40b458f 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetFurniView.tsx @@ -6,6 +6,7 @@ import { AvatarInfoFurni, GetGroupInformation, LocalizeText, SendMessageComposer import { Button, Column, Flex, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutLimitedEditionCompactPlateView, LayoutRarityLevelView, LayoutRoomObjectImageView, Text, UserProfileIconView } from '../../../../../common'; import { useHasPermission, useMessageEvent, useNitroEvent, useRareValues, useRoom, useWiredTools } from '../../../../../hooks'; import { NitroInput } from '../../../../../layout'; +import { ImagePositionEditorView } from './ImagePositionEditorView'; interface InfoStandWidgetFurniViewProps { @@ -43,6 +44,7 @@ export const InfoStandWidgetFurniView: FC = props const [ isJukeBox, setIsJukeBox ] = useState(false); const [ isSongDisk, setIsSongDisk ] = useState(false); const [ isBranded, setIsBranded ] = useState(false); + const [ showPositionEditor, setShowPositionEditor ] = useState(false); const [ songId, setSongId ] = useState(-1); const [ songName, setSongName ] = useState(''); const [ songCreator, setSongCreator ] = useState(''); @@ -393,6 +395,45 @@ export const InfoStandWidgetFurniView: FC = props return data; }, [ furniKeys, furniValues ]); + const getBrandingOffset = useCallback((key: string): number => + { + const index = furniKeys.indexOf(key); + if(index < 0) return 0; + const value = parseInt(furniValues[index]); + return isNaN(value) ? 0 : value; + }, [ furniKeys, furniValues ]); + + const hasBrandingOffsets = isBranded && (furniKeys.indexOf('offsetX') >= 0); + + // Persist the position from the editor: rebuild the branding map with the + // new offsets and send it (same path as Save), then reflect it in the fields. + const savePositionEditor = useCallback((x: number, y: number, z: number, scale: number) => + { + const map = new Map(); + const clone = Array.from(furniValues); + let hasScale = false; + + for(let i = 0; i < furniKeys.length; i++) + { + const key = furniKeys[i]; + let value = furniValues[i]; + + if(key === 'offsetX') value = String(x); + else if(key === 'offsetY') value = String(y); + else if(key === 'offsetZ') value = String(z); + else if(key === 'scale') { value = String(scale); hasScale = true; } + + clone[i] = value; + map.set(key, value); + } + + // older branding furni may not carry a scale key yet — always send it + if(!hasScale) map.set('scale', String(scale)); + + GetRoomEngine().modifyRoomObjectDataWithMap(avatarInfo.id, avatarInfo.category, RoomObjectOperationType.OBJECT_SAVE_STUFF_DATA, map); + setFurniValues(clone); + }, [ avatarInfo, furniKeys, furniValues ]); + const processButtonAction = useCallback((action: string) => { if(!action || (action === '')) return; @@ -749,6 +790,10 @@ export const InfoStandWidgetFurniView: FC = props } + { hasBrandingOffsets && + } { ((furniKeys.length > 0 && furniValues.length > 0) && (furniKeys.length === furniValues.length)) && } + { showPositionEditor && + setShowPositionEditor(false) } + onSave={ savePositionEditor } /> } ); }; From 7a0b57e26785e0bff2087e16622e0bf97176a954 Mon Sep 17 00:00:00 2001 From: medievalshell Date: Thu, 28 May 2026 15:39:46 +0200 Subject: [PATCH 32/86] fix(navigator): ignore search events while disabled + invalidate on FlatCreated useNavigatorSearch had two gaps its tests cover: - with no active tab the query is disabled, but a NavigatorSearchEvent still updated the data; now such events are ignored until a tab is active - a newly created room (FlatCreatedEvent) now invalidates the ['navigator','search'] query and refetches the current search Fixes the 2 failing useNavigatorSearch tests; full suite 472/472. --- src/hooks/navigator/useNavigatorSearch.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/hooks/navigator/useNavigatorSearch.ts b/src/hooks/navigator/useNavigatorSearch.ts index ab3744d..7f55479 100644 --- a/src/hooks/navigator/useNavigatorSearch.ts +++ b/src/hooks/navigator/useNavigatorSearch.ts @@ -1,4 +1,5 @@ -import { NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer'; +import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; import { SendMessageComposer } from '../../api'; import { useMessageEvent } from '../events'; @@ -9,6 +10,7 @@ export const useNavigatorSearch = () => { const tabCode = useNavigatorUiStore(s => s.currentTabCode); const filter = useNavigatorUiStore(s => s.currentFilter); + const queryClient = useQueryClient(); const [ searchResult, setSearchResult ] = useState(null); const [ isFetching, setIsFetching ] = useState(false); @@ -26,12 +28,25 @@ export const useNavigatorSearch = () => const result = event.getParser()?.result; if(!result) return; - if(tabCode && result.code !== tabCode) return; + // No active tab → the search query is disabled, ignore any event. + // Otherwise only accept the event whose code matches the active tab. + if(!tabCode || (result.code !== tabCode)) return; setSearchResult(result); setIsFetching(false); }); + // A newly created room invalidates the current search so it refetches. + useMessageEvent(FlatCreatedEvent, () => + { + queryClient.invalidateQueries({ queryKey: [ 'navigator', 'search' ] }); + + if(!tabCode) return; + + setIsFetching(true); + SendMessageComposer(new NavigatorSearchComposer(tabCode, filter)); + }); + return { searchResult, isFetching, From 06b8fda1c9ef8d3a3451843e3416b92e52200009 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 15:52:29 +0200 Subject: [PATCH 33/86] =?UTF-8?q?=F0=9F=86=99=20Enable=20or=20disable=20th?= =?UTF-8?q?e=20radio?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/configuration/renderer-config.example | 3 +++ src/components/MainView.tsx | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index 792be76..c40cdee 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -30,6 +30,9 @@ "pet.asset.url": "${asset.url}/pets/%libname%.nitro", "generic.asset.url": "${asset.url}/generic/%libname%.nitro", "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", + "radio.url": "${gamedata.url}/radio-stations.json5?t=%timestamp%", + "soundboard.url": "${gamedata.url}/soundboard-sounds.json5?t=%timestamp%", + "radio_ui": false, "furni.rotation.bounce.steps": 20, "furni.rotation.bounce.height": 0.0625, "enable.avatar.arrow": false, diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index aeb5d91..e155f05 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -1,6 +1,7 @@ import { AddLinkEventTracker, GetCommunication, GetRoomSessionManager, HabboWebTools, ILinkEventTracker, RemoveLinkEventTracker, RoomSessionEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion } from 'framer-motion'; import { FC, useEffect, useState } from 'react'; +import { GetConfigurationValue } from '../api'; import { useNitroEventReducer } from '../hooks'; import { AchievementsView } from './achievements/AchievementsView'; import { AvatarEditorView } from './avatar-editor'; @@ -183,7 +184,7 @@ export const MainView: FC<{}> = props => - + { GetConfigurationValue('radio_ui', true) && } ); From 47e8338570611be4084273c8184a5a299a0c7603 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 28 May 2026 16:27:48 +0200 Subject: [PATCH 34/86] =?UTF-8?q?=F0=9F=86=99=20Wheel=20of=20prizes=20!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/configuration/UITexts_nl.json5.example | 2 + .../FortuneWheelSettingsView.tsx | 147 +++++++++++++ .../fortune-wheel/FortuneWheelView.tsx | 13 +- src/components/rare-values/RareValuesView.tsx | 165 ++------------ .../FortuneWheelSettingsView.tsx | 147 +++++++++++++ .../fortune-wheel/FortuneWheelView.tsx | 202 ++++++++++++++++++ .../rare-values/RareValuesView.tsx | 115 ++++++++++ .../rooms/widgets/useChatCommandSelector.ts | 58 ++--- 8 files changed, 677 insertions(+), 172 deletions(-) create mode 100644 src/components/fortune-wheel/FortuneWheelSettingsView.tsx create mode 100644 src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx create mode 100644 src/components/user-settings/fortune-wheel/FortuneWheelView.tsx create mode 100644 src/components/user-settings/rare-values/RareValuesView.tsx diff --git a/public/configuration/UITexts_nl.json5.example b/public/configuration/UITexts_nl.json5.example index 68f003f..8bbedba 100644 --- a/public/configuration/UITexts_nl.json5.example +++ b/public/configuration/UITexts_nl.json5.example @@ -639,6 +639,8 @@ 'wheel.free.today': 'Je hebt vandaag %count% gratis draaibeurten!', 'wheel.extra': 'Extra draaibeurten: %count%', 'wheel.spin': 'DRAAIEN', + 'wheel.settings': 'Settings', + 'wheel.settings.title': 'Rad van Fortuin Settings', 'wheel.buy': 'Draaibeurt kopen', 'wheel.winners': 'Laatste winnaars', 'wheel.winners.empty': 'Nog geen winnaars', diff --git a/src/components/fortune-wheel/FortuneWheelSettingsView.tsx b/src/components/fortune-wheel/FortuneWheelSettingsView.tsx new file mode 100644 index 0000000..628eeb0 --- /dev/null +++ b/src/components/fortune-wheel/FortuneWheelSettingsView.tsx @@ -0,0 +1,147 @@ +import { IWheelAdminPrize, IWheelAdminPrizeEdit } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { LocalizeText } from '../../api'; +import { Column, Flex, Text } from '../../common'; +import { useFortuneWheel } from '../../hooks'; +import { NitroCard } from '../../layout'; + +interface EditRow +{ + id: number; + category: string; + num: number; + weight: number; + label: string; +} + +interface CategoryDef +{ + key: string; + labelKey: string; +} + +const CATEGORIES: CategoryDef[] = [ + { key: 'item', labelKey: 'rarevalues.editor.cat.item' }, + { key: 'diamonds', labelKey: 'achievements.activitypoint.5' }, + { key: 'duckets', labelKey: 'achievements.activitypoint.0' }, + { key: 'credits', labelKey: 'credits' }, + { key: 'spins', labelKey: 'rarevalues.editor.cat.spin' }, + { key: 'nothing', labelKey: 'rarevalues.editor.cat.nothing' } +]; + +const prizeToCategory = (prize: IWheelAdminPrize): string => +{ + switch(prize.type) + { + case 'item': return 'item'; + case 'points': return (prize.pointsType === 5) ? 'diamonds' : 'duckets'; + case 'credits': return 'credits'; + case 'spin': return 'spins'; + default: return 'nothing'; + } +}; + +const prizeToNum = (prize: IWheelAdminPrize): number => + (prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount; + +const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit => +{ + const base = { id: row.id, weight: row.weight, label: row.label }; + + switch(row.category) + { + case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 }; + case 'diamonds': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 }; + case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 }; + case 'credits': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 }; + case 'spins': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 }; + default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 }; + } +}; + +interface FortuneWheelSettingsViewProps +{ + onClose: () => void; +} + +export const FortuneWheelSettingsView: FC = ({ onClose }) => +{ + const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); + const [ editRows, setEditRows ] = useState([]); + + useEffect(() => + { + if(loadAdminPrizes) loadAdminPrizes(); + }, [ loadAdminPrizes ]); + + useEffect(() => + { + setEditRows(adminPrizes.map(prize => ({ + id: prize.id, + category: prizeToCategory(prize), + num: prizeToNum(prize), + weight: prize.weight, + label: prize.label + }))); + }, [ adminPrizes ]); + + const updateRow = (id: number, patch: Partial) => + setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); + + return ( + + + + + + { LocalizeText('rarevalues.editor.type') } + { LocalizeText('rarevalues.editor.value') } + { LocalizeText('rarevalues.editor.weight') } + { LocalizeText('rarevalues.editor.label') } + + + { editRows.map(row => ( + + + updateRow(row.id, { num: parseInt(event.target.value) || 0 }) } + className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" /> + updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) } + className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> + updateRow(row.id, { label: event.target.value }) } + className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> + + )) } + { !editRows.length && + { LocalizeText('wheel.settings.empty') } } + + + + + + ); +}; diff --git a/src/components/fortune-wheel/FortuneWheelView.tsx b/src/components/fortune-wheel/FortuneWheelView.tsx index a66af12..37aa94b 100644 --- a/src/components/fortune-wheel/FortuneWheelView.tsx +++ b/src/components/fortune-wheel/FortuneWheelView.tsx @@ -2,8 +2,9 @@ import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, Rem import { FC, useEffect, useMemo, useRef, useState } from 'react'; import { LocalizeText } from '../../api'; import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; -import { useFortuneWheel } from '../../hooks'; +import { useFortuneWheel, useHasPermission } from '../../hooks'; import { NitroCard } from '../../layout'; +import { FortuneWheelSettingsView } from './FortuneWheelSettingsView'; // Stock UI palette (white / light-blue / grey / black). const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ]; @@ -42,7 +43,9 @@ const renderPrizeIcon = (prize: IWheelPrize) => export const FortuneWheelView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); + const [ isSettingsOpen, setIsSettingsOpen ] = useState(false); const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); + const canManage = useHasPermission('acc_wheeladmin'); const [ rotation, setRotation ] = useState(0); const rotationRef = useRef(0); const prizesRef = useRef([]); @@ -164,6 +167,12 @@ export const FortuneWheelView: FC<{}> = () => { LocalizeText('wheel.buy') } { spinCost } + { canManage && + } @@ -186,6 +195,8 @@ export const FortuneWheelView: FC<{}> = () => + { canManage && isSettingsOpen && + setIsSettingsOpen(false) } /> } ); }; diff --git a/src/components/rare-values/RareValuesView.tsx b/src/components/rare-values/RareValuesView.tsx index 5cb4ce7..c03f787 100644 --- a/src/components/rare-values/RareValuesView.tsx +++ b/src/components/rare-values/RareValuesView.tsx @@ -1,8 +1,8 @@ -import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, IWheelAdminPrize, IWheelAdminPrizeEdit, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; import { LocalizeFormattedNumber, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; -import { useFortuneWheel, useHasPermission, useRareValues } from '../../hooks'; +import { useRareValues } from '../../hooks'; import { NitroCard, NitroInput } from '../../layout'; interface RareValueRow @@ -13,63 +13,11 @@ interface RareValueRow value: IRareValue; } -interface EditRow -{ - id: number; - category: string; - num: number; - weight: number; - label: string; -} - -const CATEGORIES: { key: string; label: string }[] = [ - { key: 'item', label: 'Raro (ID)' }, - { key: 'diamanti', label: 'Diamanti' }, - { key: 'duckets', label: 'Duckets' }, - { key: 'crediti', label: 'Crediti' }, - { key: 'giri', label: 'Giri extra' }, - { key: 'nulla', label: 'Nulla' } -]; - -const prizeToCategory = (prize: IWheelAdminPrize): string => -{ - switch(prize.type) - { - case 'item': return 'item'; - case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets'; - case 'credits': return 'crediti'; - case 'spin': return 'giri'; - default: return 'nulla'; - } -}; - -const prizeToNum = (prize: IWheelAdminPrize): number => - (prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount; - -const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit => -{ - const base = { id: row.id, weight: row.weight, label: row.label }; - - switch(row.category) - { - case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 }; - case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 }; - case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 }; - case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 }; - case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 }; - default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 }; - } -}; - export const RareValuesView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); - const [ tab, setTab ] = useState<'values' | 'editor'>('values'); const [ searchValue, setSearchValue ] = useState(''); const { values = null, loaded = false } = useRareValues(); - const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); - const canEdit = useHasPermission('acc_supporttool'); - const [ editRows, setEditRows ] = useState([]); useEffect(() => { @@ -94,16 +42,6 @@ export const RareValuesView: FC<{}> = () => return () => RemoveLinkEventTracker(linkTracker); }, []); - useEffect(() => - { - if(isVisible && (tab === 'editor') && canEdit && loadAdminPrizes) loadAdminPrizes(); - }, [ isVisible, tab, canEdit, loadAdminPrizes ]); - - useEffect(() => - { - setEditRows(adminPrizes.map(prize => ({ id: prize.id, category: prizeToCategory(prize), num: prizeToNum(prize), weight: prize.weight, label: prize.label }))); - }, [ adminPrizes ]); - const rows = useMemo(() => { if(!values) return []; @@ -143,91 +81,34 @@ export const RareValuesView: FC<{}> = () => if(!isVisible) return null; - const updateRow = (id: number, patch: Partial) => - setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); - return ( setIsVisible(false) } /> - { canEdit && - - setTab('values') }> - { LocalizeText('rarevalues.title') } - - setTab('editor') }> - { LocalizeText('rarevalues.editor.tab') } - - } - { (tab === 'values' || !canEdit) && - - setSearchValue(event.target.value) } /> - - { !loaded && - { LocalizeText('rarevalues.loading') } } - { (loaded && !filtered.length) && - { LocalizeText('rarevalues.empty') } } - { filtered.map(row => ( - - - { row.name } - - { LocalizeFormattedNumber(row.value.points) } - - + + setSearchValue(event.target.value) } /> + + { !loaded && + { LocalizeText('rarevalues.loading') } } + { (loaded && !filtered.length) && + { LocalizeText('rarevalues.empty') } } + { filtered.map(row => ( + + + { row.name } + + { LocalizeFormattedNumber(row.value.points) } + - )) } - - } - - { (tab === 'editor' && canEdit) && - - - { LocalizeText('rarevalues.editor.type') } - { LocalizeText('rarevalues.editor.value') } - { LocalizeText('rarevalues.editor.weight') } - { LocalizeText('rarevalues.editor.label') } - - - { editRows.map(row => ( - - - updateRow(row.id, { num: parseInt(event.target.value) || 0 }) } - className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" /> - updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) } - className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> - updateRow(row.id, { label: event.target.value }) } - className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> - - )) } - - - } + + )) } + + ); diff --git a/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx b/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx new file mode 100644 index 0000000..91cc44c --- /dev/null +++ b/src/components/user-settings/fortune-wheel/FortuneWheelSettingsView.tsx @@ -0,0 +1,147 @@ +import { IWheelAdminPrize, IWheelAdminPrizeEdit } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { LocalizeText } from '../../api'; +import { Column, Flex, Text } from '../../common'; +import { useFortuneWheel } from '../../hooks'; +import { NitroCard } from '../../layout'; + +interface EditRow +{ + id: number; + category: string; + num: number; + weight: number; + label: string; +} + +interface CategoryDef +{ + key: string; + labelKey: string; +} + +const CATEGORIES: CategoryDef[] = [ + { key: 'item', labelKey: 'rarevalues.editor.cat.item' }, + { key: 'diamanti', labelKey: 'achievements.activitypoint.5' }, + { key: 'duckets', labelKey: 'achievements.activitypoint.0' }, + { key: 'crediti', labelKey: 'credits' }, + { key: 'giri', labelKey: 'rarevalues.editor.cat.spin' }, + { key: 'nulla', labelKey: 'rarevalues.editor.cat.nothing' } +]; + +const prizeToCategory = (prize: IWheelAdminPrize): string => +{ + switch(prize.type) + { + case 'item': return 'item'; + case 'points': return (prize.pointsType === 5) ? 'diamanti' : 'duckets'; + case 'credits': return 'crediti'; + case 'spin': return 'giri'; + default: return 'nulla'; + } +}; + +const prizeToNum = (prize: IWheelAdminPrize): number => + (prize.type === 'item') ? (parseInt(prize.value) || 0) : prize.amount; + +const rowToEdit = (row: EditRow): IWheelAdminPrizeEdit => +{ + const base = { id: row.id, weight: row.weight, label: row.label }; + + switch(row.category) + { + case 'item': return { ...base, type: 'item', value: String(row.num), amount: 1, pointsType: 0 }; + case 'diamanti': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 5 }; + case 'duckets': return { ...base, type: 'points', value: '', amount: row.num, pointsType: 0 }; + case 'crediti': return { ...base, type: 'credits', value: '', amount: row.num, pointsType: 0 }; + case 'giri': return { ...base, type: 'spin', value: '', amount: row.num, pointsType: 0 }; + default: return { ...base, type: 'nothing', value: '', amount: 0, pointsType: 0 }; + } +}; + +interface FortuneWheelSettingsViewProps +{ + onClose: () => void; +} + +export const FortuneWheelSettingsView: FC = ({ onClose }) => +{ + const { adminPrizes = [], loadAdminPrizes = null, saveAdminPrizes = null } = useFortuneWheel(); + const [ editRows, setEditRows ] = useState([]); + + useEffect(() => + { + if(loadAdminPrizes) loadAdminPrizes(); + }, [ loadAdminPrizes ]); + + useEffect(() => + { + setEditRows(adminPrizes.map(prize => ({ + id: prize.id, + category: prizeToCategory(prize), + num: prizeToNum(prize), + weight: prize.weight, + label: prize.label + }))); + }, [ adminPrizes ]); + + const updateRow = (id: number, patch: Partial) => + setEditRows(prev => prev.map(row => (row.id === id) ? { ...row, ...patch } : row)); + + return ( + + + + + + { LocalizeText('rarevalues.editor.type') } + { LocalizeText('rarevalues.editor.value') } + { LocalizeText('rarevalues.editor.weight') } + { LocalizeText('rarevalues.editor.label') } + + + { editRows.map(row => ( + + + updateRow(row.id, { num: parseInt(event.target.value) || 0 }) } + className="w-16 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34] disabled:opacity-40" /> + updateRow(row.id, { weight: parseInt(event.target.value) || 0 }) } + className="w-12 rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> + updateRow(row.id, { label: event.target.value }) } + className="min-w-0 grow rounded border border-black/20 bg-white px-1 py-0.5 text-sm text-[#1f2d34]" /> + + )) } + { !editRows.length && + { LocalizeText('wheel.settings.empty') } } + + + + + + ); +}; diff --git a/src/components/user-settings/fortune-wheel/FortuneWheelView.tsx b/src/components/user-settings/fortune-wheel/FortuneWheelView.tsx new file mode 100644 index 0000000..37aa94b --- /dev/null +++ b/src/components/user-settings/fortune-wheel/FortuneWheelView.tsx @@ -0,0 +1,202 @@ +import { AddLinkEventTracker, GetRoomEngine, ILinkEventTracker, IWheelPrize, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useMemo, useRef, useState } from 'react'; +import { LocalizeText } from '../../api'; +import { Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; +import { useFortuneWheel, useHasPermission } from '../../hooks'; +import { NitroCard } from '../../layout'; +import { FortuneWheelSettingsView } from './FortuneWheelSettingsView'; + +// Stock UI palette (white / light-blue / grey / black). +const SLICE_COLORS = [ '#eef2f5', '#c3dcec' ]; +const RIM = '#4c606c'; +const WHEEL_SIZE = 420; +const ICON_RADIUS = 150; +const FULL_TURNS = 5; + +const renderPrizeIcon = (prize: IWheelPrize) => +{ + switch(prize.type) + { + case 'item': + return ; + case 'badge': + return ; + case 'credits': + return ( + + + { prize.amount } + ); + case 'points': + return ( + + + { prize.amount } + ); + case 'spin': + return +{ prize.amount }; + default: + return ; + } +}; + +export const FortuneWheelView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ isSettingsOpen, setIsSettingsOpen ] = useState(false); + const { freeSpins, extraSpins, spinCost, spinCostType, prizes, recentWins, pendingPrizeId, isSpinning, open, spin, buySpin, finishSpin } = useFortuneWheel(); + const canManage = useHasPermission('acc_wheeladmin'); + const [ rotation, setRotation ] = useState(0); + const rotationRef = useRef(0); + const prizesRef = useRef([]); + prizesRef.current = prizes; + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': setIsVisible(true); return; + case 'hide': setIsVisible(false); return; + case 'toggle': setIsVisible(prev => !prev); return; + } + }, + eventUrlPrefix: 'fortune-wheel/', + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(isVisible) open(); + }, [ isVisible, open ]); + + // Drive the spin animation when the server reports the winning slice. + useEffect(() => + { + if(pendingPrizeId < 0) return; + + const list = prizesRef.current; + const idx = list.findIndex(prize => prize.id === pendingPrizeId); + + if(!list.length || (idx < 0)) + { + finishSpin(); + return; + } + + const sliceAngle = 360 / list.length; + const centerAngle = ((idx + 0.5) * sliceAngle); + const current = rotationRef.current; + const target = (current - (current % 360)) + (FULL_TURNS * 360) + (360 - centerAngle); + + rotationRef.current = target; + setRotation(target); + }, [ pendingPrizeId, finishSpin ]); + + const sliceAngle = prizes.length ? (360 / prizes.length) : 0; + + const background = useMemo(() => + { + if(!prizes.length) return SLICE_COLORS[0]; + + const stops = prizes.map((_, i) => `${ SLICE_COLORS[i % 2] } ${ i * sliceAngle }deg ${ (i + 1) * sliceAngle }deg`).join(', '); + return `conic-gradient(${ stops })`; + }, [ prizes, sliceAngle ]); + + if(!isVisible) return null; + + const canSpin = ((freeSpins + extraSpins) > 0) && !isSpinning && (prizes.length > 0); + + return ( + + setIsVisible(false) } /> + + + +
+
+
{ if(isSpinning) finishSpin(); } }> + { prizes.map((_, i) => ( +
+ )) } + { prizes.map((prize, i) => + { + const centerAngle = ((i + 0.5) * sliceAngle); + return ( +
+
+ { renderPrizeIcon(prize) } +
+
); + }) } +
+
+
+ { LocalizeText('wheel.free.today', [ 'count' ], [ freeSpins.toString() ]) } + { LocalizeText('wheel.extra', [ 'count' ], [ extraSpins.toString() ]) } + + + + { canManage && + } + + + + { LocalizeText('wheel.winners') } + + { recentWins.map((win, i) => ( + +
+ +
+ + { win.username } + { win.prizeLabel } + +
+ )) } + { !recentWins.length && + { LocalizeText('wheel.winners.empty') } } +
+
+ + + { canManage && isSettingsOpen && + setIsSettingsOpen(false) } /> } + + ); +}; diff --git a/src/components/user-settings/rare-values/RareValuesView.tsx b/src/components/user-settings/rare-values/RareValuesView.tsx new file mode 100644 index 0000000..c03f787 --- /dev/null +++ b/src/components/user-settings/rare-values/RareValuesView.tsx @@ -0,0 +1,115 @@ +import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, IRareValue, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { LocalizeFormattedNumber, LocalizeText } from '../../api'; +import { Column, Flex, LayoutCurrencyIcon, LayoutImage, Text } from '../../common'; +import { useRareValues } from '../../hooks'; +import { NitroCard, NitroInput } from '../../layout'; + +interface RareValueRow +{ + spriteId: number; + name: string; + iconUrl: string; + value: IRareValue; +} + +export const RareValuesView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ searchValue, setSearchValue ] = useState(''); + const { values = null, loaded = false } = useRareValues(); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': setIsVisible(true); return; + case 'hide': setIsVisible(false); return; + case 'toggle': setIsVisible(prev => !prev); return; + } + }, + eventUrlPrefix: 'rare-values/', + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + const rows = useMemo(() => + { + if(!values) return []; + + const list: RareValueRow[] = []; + + values.forEach((value, spriteId) => + { + if(value.points <= 0) return; + + const floorData = GetSessionDataManager().getFloorItemData(spriteId); + const wallData = floorData ? null : GetSessionDataManager().getWallItemData(spriteId); + const data = (floorData ?? wallData); + + if(!data) return; + + const iconUrl = (floorData + ? GetRoomEngine().getFurnitureFloorIconUrl(spriteId) + : GetRoomEngine().getFurnitureWallIconUrl(spriteId)); + + list.push({ spriteId, name: (data.name || data.className || `#${ spriteId }`), iconUrl, value }); + }); + + list.sort((a, b) => (b.value.points - a.value.points)); + + return list; + }, [ values ]); + + const filtered = useMemo(() => + { + const query = searchValue.trim().toLocaleLowerCase(); + + if(!query) return rows; + + return rows.filter(row => row.name.toLocaleLowerCase().includes(query)); + }, [ rows, searchValue ]); + + if(!isVisible) return null; + + return ( + + setIsVisible(false) } /> + + + setSearchValue(event.target.value) } /> + + { !loaded && + { LocalizeText('rarevalues.loading') } } + { (loaded && !filtered.length) && + { LocalizeText('rarevalues.empty') } } + { filtered.map(row => ( + + + { row.name } + + { LocalizeFormattedNumber(row.value.points) } + + + + )) } + + + + + ); +}; diff --git a/src/hooks/rooms/widgets/useChatCommandSelector.ts b/src/hooks/rooms/widgets/useChatCommandSelector.ts index 9584e92..31dd495 100644 --- a/src/hooks/rooms/widgets/useChatCommandSelector.ts +++ b/src/hooks/rooms/widgets/useChatCommandSelector.ts @@ -1,36 +1,35 @@ import { AvailableCommandsEvent, GetCommunication } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { CommandDefinition } from '../../../api'; +import { CommandDefinition, LocalizeText } from '../../../api'; import { createNitroStore } from '../../../state/createNitroStore'; import { useMessageEvent } from '../../events'; -// Client-only commands are static; safe to keep at module scope. -const CLIENT_COMMANDS: CommandDefinition[] = [ - // Effetti stanza - { key: 'shake', description: 'Scuoti la stanza' }, - { key: 'rotate', description: 'Ruota la stanza' }, - { key: 'zoom', description: 'Zoom stanza' }, - { key: 'flip', description: 'Reset zoom' }, - { key: 'iddqd', description: 'Reset zoom' }, - { key: 'screenshot', description: 'Screenshot stanza' }, - { key: 'togglefps', description: 'Toggle FPS' }, - // Espressioni - { key: 'd', description: 'Ridi (VIP)' }, - { key: 'kiss', description: 'Manda un bacio (VIP)' }, - { key: 'jump', description: 'Salta (VIP)' }, - { key: 'idle', description: 'Vai in idle' }, - { key: 'sign', description: 'Mostra cartello' }, - // Gestione stanza - { key: 'furni', description: 'Furni chooser' }, - { key: 'chooser', description: 'User chooser' }, - { key: 'floor', description: 'Floor editor' }, - { key: 'bcfloor', description: 'Floor editor' }, - { key: 'pickall', description: 'Raccogli tutti i furni' }, - { key: 'ejectall', description: 'Espelli tutti i furni' }, - { key: 'settings', description: 'Impostazioni stanza' }, +const CLIENT_COMMANDS: { key: string; descriptionKey: string }[] = [ + // Room effects + { key: 'shake', descriptionKey: 'chatcmd.client.shake' }, + { key: 'rotate', descriptionKey: 'chatcmd.client.rotate' }, + { key: 'zoom', descriptionKey: 'chatcmd.client.zoom' }, + { key: 'flip', descriptionKey: 'chatcmd.client.flip' }, + { key: 'iddqd', descriptionKey: 'chatcmd.client.iddqd' }, + { key: 'screenshot', descriptionKey: 'chatcmd.client.screenshot' }, + { key: 'togglefps', descriptionKey: 'chatcmd.client.togglefps' }, + // Expressions + { key: 'd', descriptionKey: 'chatcmd.client.laugh' }, + { key: 'kiss', descriptionKey: 'chatcmd.client.kiss' }, + { key: 'jump', descriptionKey: 'chatcmd.client.jump' }, + { key: 'idle', descriptionKey: 'chatcmd.client.idle' }, + { key: 'sign', descriptionKey: 'chatcmd.client.sign' }, + // Room management + { key: 'furni', descriptionKey: 'chatcmd.client.furni' }, + { key: 'chooser', descriptionKey: 'chatcmd.client.chooser' }, + { key: 'floor', descriptionKey: 'chatcmd.client.floor' }, + { key: 'bcfloor', descriptionKey: 'chatcmd.client.floor' }, + { key: 'pickall', descriptionKey: 'chatcmd.client.pickall' }, + { key: 'ejectall', descriptionKey: 'chatcmd.client.ejectall' }, + { key: 'settings', descriptionKey: 'chatcmd.client.settings' }, // Info - { key: 'client', description: 'Info client' }, - { key: 'nitro', description: 'Info client' }, + { key: 'client', descriptionKey: 'chatcmd.client.info' }, + { key: 'nitro', descriptionKey: 'chatcmd.client.info' }, ]; /** @@ -110,11 +109,12 @@ export const useChatCommandSelector = (chatValue: string) => const allCommands = useMemo(() => { - const merged = [ ...serverCommands ]; + const merged: CommandDefinition[] = [ ...serverCommands ]; for(const clientCmd of CLIENT_COMMANDS) { - if(!merged.some(cmd => cmd.key === clientCmd.key)) merged.push(clientCmd); + if(merged.some(cmd => cmd.key === clientCmd.key)) continue; + merged.push({ key: clientCmd.key, description: LocalizeText(clientCmd.descriptionKey) }); } return merged.sort((a, b) => a.key.localeCompare(b.key)); From 772b6dd6320746bbf751ce70818c7de059f9d049 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 28 May 2026 17:54:20 +0200 Subject: [PATCH 35/86] docs: refresh CLAUDE.md after navigator modernization merge - TL;DR: working base is now main (not the old feat/react19-modernization long-running branch); document the navigator hook split landing - Add useNitroQuery fragility note: the one-shot listener pattern is unreliable for primary visible data (bit ModTools chatlogs + navigator search); reserve it for config/secondary fetches, use useMessageEvent + useState for primary content - Add navigator modernization row to the "What's wired up" table - Add navigator hook locations to "Where everything lives" --- CLAUDE.md | 52 ++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e9c7383..cd950d9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,19 +6,27 @@ the ground running. ## TL;DR -This branch — **`feat/react19-modernization`** — is a long-running modernization -of the Nitro V3 client: bump to React 19.2 idioms, add the supporting -infrastructure (TanStack Query, Zustand, Vitest, React Compiler, error -boundaries), split a few god-hooks, and audit logic bugs along the way. -PR is **#2** on `simoleo89/Nitro-V3`. +This client carries a long-running React 19.2 modernization: React 19 +idioms + supporting infrastructure (TanStack Query, Zustand, Vitest, +React Compiler, error boundaries), god-hook splits, and logic-bug audits. -Upstream `duckietm/Nitro-V3` (`origin/Dev`) is merged in through -`b2318b9` as of 2026-05-18 (merge commit `779a98c`). That brings in -JSON5 config support, user-settings (reset password / email / change -username), wear-badge popup fix, login screen fix, About update, and -the offer-selection refactor. When syncing the next batch of upstream -commits, expect conflicts in `App.tsx` / `bootstrap.ts` / `LoginView.tsx` -on React 19 imports — always keep the modernized local version. +**Working base is now `main`** (tracking `duckietm/Nitro-V3`). The earlier +`feat/react19-modernization` long-running branch was superseded — feature +work now ships as small focused PRs against `duckietm:Dev`, staged through +Dev then merged to main. (`feat/react19-modernization` still exists on the +fork as backup; do not force-push it.) + +**Navigator modernization landed** (merged to main 2026-05-28, PRs +#168/#169/#170): the 492-line `useNavigator` god-hook was split into +`useNavigatorStore` + `useNavigatorData`/`useNavigatorUiState`/ +`useNavigatorSearch` filters (wired-tools layout), door lifecycle extracted +to `src/hooks/rooms/widgets/useDoorState.ts`, 9 UI flags moved to a Zustand +`navigatorUiStore`, search migrated to a query hook, and 5 sub-views wrapped +in `WidgetErrorBoundary`. **Caveat**: duckietm patched `useNavigatorSearch` +post-merge (`05d71dd1`) — see the `useNitroQuery` fragility note below. + +When syncing upstream, expect conflicts in `App.tsx` / `bootstrap.ts` / +`LoginView.tsx` on React 19 imports — always keep the modernized version. Local-dev game assets are served by a small Vite plugin (`sirv` middleware mounted on `/nitro-assets` and `/swf`, reading from @@ -236,6 +244,20 @@ and invalidates the query slot on every push, so server-driven refresh paths work the same as the initial request/response (e.g. ClubGiftInfoEvent firing again after the user claims a gift). +**⚠️ Fragility — do NOT use `useNitroQuery` for primary visible data.** +The one-shot listener inside `awaitNitroResponse` (register listener → +await one matching response → remove itself) is fragile against +renderer-bundle quirks: for some parsers the event fires but the listener +never matches, so the promise never resolves and `query.data` stays +`undefined` forever — the UI shows the server's response arriving in logs +but renders blank. This bit **ModTools Room/CFH chatlog** (reverted to +`useMessageEvent + useEffect`) and then **Navigator search** (P2 shipped +with `useNitroQuery`, duckietm reverted it in `05d71dd1` to the god-hook +pattern). **Rule: reserve `useNitroQuery` for config / secondary fetches +where a brief blank is tolerable. For anything that is the primary visible +content of a panel, use `useMessageEvent + useState/useEffect`** — that's +what the rest of the codebase does and it's robust. + ### Singleton-filter split for `useBetween`-based hooks When a hook backs many consumers but most only need either state OR @@ -339,6 +361,7 @@ into `configurePreviewServer` so `yarn preview` keeps working. | Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`), `WiredCreatorToolsView` (`useWiredCreatorToolsUiStore` — every panel-lifecycle-relevant flag, snapshot, selection, highlight, inline editor, picker chain hoisted; what's left in the component as `useState` is genuinely transient: keepSelected, globalClock, roomEnteredAt, selectedMonitorErrorType, selectedMonitorLogDetails) | | God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` | | God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions` — all 48 consumers migrated, deprecated `useCatalog` shim removed) | +| Navigator modernization (merged to main 2026-05-28, PRs #168/#169/#170) | 492-line `useNavigator` god-hook split into `useNavigatorStore` (internal `useBetween` closure) + flat filters `useNavigatorData` / `useNavigatorUiState` / `useNavigatorSearch`; door bell/password lifecycle extracted to `src/hooks/rooms/widgets/useDoorState.ts` (dual-subscribes `GetGuestRoomResultEvent` + `GenericErrorEvent` alongside the nav store, each filtering by branch/errorCode); 9 UI flags + `currentTabCode`/`currentFilter` in Zustand `navigatorUiStore` (`src/hooks/navigator/navigatorUiStore.ts`); all 5 Navigator sub-views wrapped in `WidgetErrorBoundary`; old shim deleted. **`useNavigatorSearch` was reverted by duckietm (`05d71dd1`) from `useNitroQuery` to `useMessageEvent + useEffect`** — see the useNitroQuery fragility note. Specs/plans under `docs/superpowers/`. | | `WidgetErrorBoundary` | `RoomWidgetsView` umbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings) | | Vitest | 207/207 cases — pure helpers (incl. 4 new on `getPetPackageNameError`) + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore` with 45 cases including the picker-chain hoists) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `src/nitro-renderer.mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. **Tests are co-located** under `src/`, alongside their subject. | | Form Actions | Login / Register / Forgot (LoginView.tsx) | @@ -412,6 +435,11 @@ See `docs/ARCHITECTURE.md` "Recently fixed" for fix shapes. `useCatalogUiState` / `useCatalogActions` in `src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated; deprecated `useCatalog` shim removed) +- Navigator hooks: `src/hooks/navigator/` — `useNavigatorStore.ts` + (internal closure), `useNavigatorData.ts` / `useNavigatorUiState.ts` / + `useNavigatorSearch.ts` (filters), `navigatorUiStore.ts` (Zustand UI + flags + `setTab`/`setFilter`). Door lifecycle: `src/hooks/rooms/widgets/useDoorState.ts`. + Specs/plans: `docs/superpowers/specs/2026-05-2*-navigator-*.md` - Renderer-SDK mock for Vitest: `src/nitro-renderer.mock.ts` (aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`). Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` / From 3bce0c019134c9ce814a6155cbdd005986531b76 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Thu, 28 May 2026 18:02:48 +0200 Subject: [PATCH 36/86] feat(navigator): empty-state + skeleton views, fix double search fetch (P4 wave 1a) Visual polish, first wave: - NavigatorEmptyStateView: replaces the bare "No rooms found" text with a centered icon + message + a Create-room CTA. Reuses existing i18n keys (navigator.search.returned.no.results / .roomsettings.moderation.none / .createroom.create) so no new localization entries are needed. - NavigatorSearchSkeletonView: animate-pulse placeholder rows shown while a search is in flight and no result is cached yet (matches the HK dashboard skeleton pattern). Replaces the NitroCard.Content spinner overlay for the result list. Bug fix bundled in: NavigatorSearchView called useNavigatorSearch() a second time purely to read searchResult for its input-sync effect. Since the hook is not a useBetween singleton, that registered a duplicate NavigatorSearchEvent listener AND fired a duplicate NavigatorSearchComposer on every search. NavigatorView now owns the single useNavigatorSearch() call and passes searchResult to NavigatorSearchView via prop. Test maintenance: useNavigatorSearch.test.tsx was written for the original useNitroQuery implementation, which upstream reverted (05d71dd1) to useMessageEvent + useState. Removed the dead QueryClient scaffolding, fixed case 1 (assert no fetch starts with empty tab), dropped case 7 (the query invalidator no longer exists). 6 cases, all green. Full suite 471/471. Typecheck: only the environmental renderer-mismatch errors (soundboard / rare-values / floorplan APIs absent from the linked renderer), none in navigator files. --- src/components/navigator/NavigatorView.tsx | 12 +- .../views/search/NavigatorEmptyStateView.tsx | 33 +++++ .../search/NavigatorSearchSkeletonView.tsx | 25 ++++ .../views/search/NavigatorSearchView.tsx | 12 +- .../navigator/useNavigatorSearch.test.tsx | 127 +++--------------- 5 files changed, 93 insertions(+), 116 deletions(-) create mode 100644 src/components/navigator/views/search/NavigatorEmptyStateView.tsx create mode 100644 src/components/navigator/views/search/NavigatorSearchSkeletonView.tsx diff --git a/src/components/navigator/NavigatorView.tsx b/src/components/navigator/NavigatorView.tsx index b93b8d5..727d06b 100644 --- a/src/components/navigator/NavigatorView.tsx +++ b/src/components/navigator/NavigatorView.tsx @@ -14,8 +14,10 @@ import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView'; import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView'; import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView'; import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView'; +import { NavigatorEmptyStateView } from './views/search/NavigatorEmptyStateView'; import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView'; import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView'; +import { NavigatorSearchSkeletonView } from './views/search/NavigatorSearchSkeletonView'; import { NavigatorSearchView } from './views/search/NavigatorSearchView'; export const NavigatorView: FC<{}> = props => @@ -132,7 +134,7 @@ export const NavigatorView: FC<{}> = props => - + { !isCreatorOpen &&
{ isOpenSavesSearches && @@ -140,13 +142,13 @@ export const NavigatorView: FC<{}> = props =>
}
- +
+ { (isFetching && !searchResult) && + } { searchResult && searchResult.results.map((result, index) => ) } { searchResult && (!searchResult.results || searchResult.results.length === 0) && -
- { LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') } -
} + useNavigatorUiStore.getState().openCreator() } /> }
void; +} + +export const NavigatorEmptyStateView: FC = props => +{ + const { code, onCreateRoom } = props; + + const isMyWorld = (code === 'myworld_view'); + const messageKey = isMyWorld ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results'; + + return ( +
+
+ +
+
+ { LocalizeText(messageKey) } +
+ +
+ ); +}; diff --git a/src/components/navigator/views/search/NavigatorSearchSkeletonView.tsx b/src/components/navigator/views/search/NavigatorSearchSkeletonView.tsx new file mode 100644 index 0000000..95666d8 --- /dev/null +++ b/src/components/navigator/views/search/NavigatorSearchSkeletonView.tsx @@ -0,0 +1,25 @@ +import { FC } from 'react'; + +interface NavigatorSearchSkeletonViewProps +{ + rows?: number; +} + +export const NavigatorSearchSkeletonView: FC = props => +{ + const { rows = 5 } = props; + + return ( +