Merge pull request #137 from simoleo89/feat/react19-modernization

feat(client): React 19 modernization + permission-driven UI gating
This commit is contained in:
DuckieTM
2026-05-20 10:46:48 +02:00
committed by GitHub
305 changed files with 15805 additions and 6626 deletions
+96
View File
@@ -0,0 +1,96 @@
name: CI
on:
push:
branches:
- main
- 'feat/**'
pull_request:
# Opt into the Node.js 24 runtime for the JavaScript actions
# (actions/checkout, actions/setup-node, …). Node 20 will be removed
# from GitHub-hosted runners in September 2026; this env var asks the
# runner to use Node 24 today so the workflow logs stop warning about
# it on every run.
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true'
jobs:
check:
name: Type check + tests
runs-on: ubuntu-latest
steps:
# The build/dev/typecheck setup expects the Nitro renderer SDK to
# live as a sibling of this repo (see CLAUDE.md → Setup walkthrough).
# Mirror that here by checking the client into <workspace>/Nitro-V3
# and the renderer into <workspace>/Nitro_Render_V3.
- name: Checkout Nitro-V3
uses: actions/checkout@v4
with:
path: Nitro-V3
# The client tracks renderer changes that are pushed to the
# `feat/react19-event-bus` branch of `simoleo89/Nitro_Render_V3`
# (allowUnderpass + sendBackgroundMessage + Window NitroConfig
# alignment, etc.). `duckietm/Nitro_Render_V3:main` doesn't yet
# have those, so tsgo would fail right away if we checked that
# out instead.
- name: Checkout Nitro_Render_V3 (sibling)
uses: actions/checkout@v4
with:
repository: simoleo89/Nitro_Render_V3
ref: feat/react19-event-bus
path: Nitro_Render_V3
- name: Setup Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
cache: yarn
cache-dependency-path: |
Nitro-V3/yarn.lock
Nitro_Render_V3/yarn.lock
- name: Install renderer SDK deps
working-directory: Nitro_Render_V3
run: yarn install --frozen-lockfile
- name: Install client deps
working-directory: Nitro-V3
run: yarn install --frozen-lockfile
# The renderer SDK is consumed via a filesystem symlink in
# node_modules/@nitrots/nitro-renderer; create it AFTER yarn
# install (otherwise yarn would clean it up since the package
# isn't declared in package.json). tsgo (TS 7 native preview)
# then resolves the tsconfig `include` entry pointing at the
# renderer's `src/**/*.ts`.
#
# Use an absolute path so the link target is unambiguous
# regardless of the cwd that reads it. A relative target like
# `../../../Nitro_Render_V3` resolves to
# `Nitro-V3/Nitro_Render_V3` (one too few `..`), which doesn't
# exist and makes tsgo report TS2307 across the entire src/.
- name: Symlink renderer into client node_modules
run: |
mkdir -p Nitro-V3/node_modules/@nitrots
ln -sfn "${{ github.workspace }}/Nitro_Render_V3" Nitro-V3/node_modules/@nitrots/nitro-renderer
ls -la Nitro-V3/node_modules/@nitrots/
ls Nitro-V3/node_modules/@nitrots/nitro-renderer/packages/api/src/ | head -5
- name: Type check (tsgo)
working-directory: Nitro-V3
run: yarn typecheck
# Hook-order lint gate — the full yarn eslint emits ~900 pre-existing
# baseline errors (brace style, indentation), so we use a focused
# config that asserts only react-hooks/rules-of-hooks. Catches the
# "hook below early-return" pattern that produced two production
# crashes this session (CatalogPurchaseWidgetView, CatalogItemGridWidgetView).
- name: ESLint (hook-order gate)
working-directory: Nitro-V3
run: yarn lint:hooks
- name: Vitest
working-directory: Nitro-V3
run: yarn test --run
+6
View File
@@ -38,3 +38,9 @@ Thumbs.db
/public/configuration/client-mode.json
/public/configuration/adsense.json
/public/configuration/hotlooks.json
# Game assets are served by an external server (emulator/CMS), not by Vite.
# Never recreate these as symlinks inside public/ — chokidar follows them and
# the dev server takes minutes to start with 100k+ files under public/.
/public/nitro-assets
/public/swf
+121
View File
@@ -1,5 +1,126 @@
# Changelog
## React 19 Modernization Phase 2 (2026-05-12)
Long-running work on the `feat/react19-modernization` branch — see
[`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) for the design rationale.
Companion changes shipped on `feat/react19-event-bus` in
[`Nitro_Render_V3`](../Nitro_Render_V3) — see that repo's CLAUDE.md
for the renderer-side notes.
### Pattern #1: `useNitroEventState` + companions
- New `useNitroEventReducer` / `useMessageEventReducer` for the case
where multiple event types collapse into one owned state slice.
- New `useExternalSnapshot` — typed wrapper of
`useSyncExternalStore` pairing the renderer's
`EventDispatcher.subscribe()` with `getXxxSnapshot()` getters.
- Pilot adoption: `useAvatarInfoWidget` now owns the figure / badges /
group merge (three event listeners moved out of
`InfoStandWidgetUserView`, three `CloneObject` calls dropped).
Reducers extracted to `src/hooks/rooms/widgets/avatarInfo.reducers.ts`
with 14 Vitest cases.
- `useInventoryFurni` refactored to call three pure reducers
(`useInventoryFurni.reducers.ts`) instead of inlining ~250 LOC of
merge logic in the event handlers. Module-level
`furniMsgFragments` becomes a `useRef` — eliminates a latent bug
where two simultaneous client instances would have trampled each
other's fragment buffers. Empty `FurniturePostItPlacedEvent` listener
dropped.
### Pattern #2: `useNitroQuery` adoption
- New `useNitroEventInvalidator(eventType, queryKey, accept?)` companion
in `src/api/nitro-query/` — invalidates a query slot every time the
renderer pushes the matching parser event. Required when the server
refreshes data outside the request cycle (e.g. ClubGiftInfoEvent
after a gift claim).
- Seven catalog fetches lifted out of `useCatalog` into dedicated
TanStack queries:
- `useGiftConfiguration` (GiftWrappingConfigurationEvent)
- `useUserGroups` — consolidates 5 sites that each dispatched
`CatalogGroupsComposer` independently
- `useClubOffers(windowId)` — per-windowId, with `accept` filter
- `useSellablePetPalette(breed)` — per-breed, with `accept` filter
- `useMarketplaceConfiguration` — lifted out of a self-fetch in
`MarketplacePostOfferView`
- `useClubGifts` — paired with `useNitroEventInvalidator` for the
server-push-after-SelectClubGift case
- `ICatalogOptions` (the "catalogOptions" bag that the various views
stuffed their fetched data into) is now **empty and deleted**.
### Pattern #4: god-hook splits
Five new splits in this round, two patterns. The doorbell-style
(state + actions + shim, no shared singleton) for hooks whose actions
are pure-dispatch:
- **chat-input** (334 LOC → 3 files) — `useChatInputState` owns the
5 state slices + 3 event listeners + 3 lifecycle effects;
`useChatInputActions` owns `sendChat` with the full slash-command
repertoire and the outgoing-translation pipeline. Single consumer
(`ChatInputView`) keeps the original tuple via the shim.
The `useBetween` singleton-filter style for hooks where actions
mutate shared state:
- **wired-tools** (618 LOC) — 20 consumers; `useWiredToolsStore`
internal singleton, public `useWiredToolsState` /
`useWiredToolsActions` filter views, `useWiredTools` shim.
- **translation** (600 LOC) — 6 consumers; `useTranslationStore`
inline + filter views.
- **notification** (493 LOC) — ~44 consumers, most of which use a
single action (`simpleAlert` or `showConfirm`); the read-only state
slice exposes the three queue arrays for the renderer view layer.
- **friends** (258 LOC) — 16 consumers; state slice covers the friend
list / settings / derived online-offline split, actions slice covers
`requestFriend` / `requestResponse` / `followFriend` /
`updateRelationship`.
Documented skip-motivated splits: `useChatWidget`,
`useChatCommandSelector`, `useFurniturePresentWidget`,
`useAvatarInfoWidget`, `useNavigator`, `useMessenger`,
`usePetPackageWidget`, `useWordQuizWidget`. Reasons logged in commit
messages.
### Typecheck / Pixi v8 / Arcturus alignment
- Repository-wide `tsgo` (TS 7 preview) error count: **134 → 0** client,
**24 → 0** renderer. Notable clusters: framer-motion `Variants`
typing on Toolbar + FriendsBar (-33), `useFurniChooserState`
retyped as `IRoomObject` + dead `getUserData` guard dropped (-10),
React 19 `useRef<T>()``useRef<T>(null)` sweep on 15 sites (-15),
`IGetImageListener` single-arg signature migration on 3 sites,
`ColorVariantType` extended with the 5 `outline-*` bootstrap
variants.
- Renderer-side aligned with Pixi v8 (Filter[] narrowing,
WebGLRenderer narrowing, ImageLike cast) and TS 5.7+ ArrayBuffer
drift (BinaryReader / BinaryWriter / WsSessionCrypto / NitroBundle).
- Cross-repo additions on `Nitro_Render_V3`:
`RoomEnterComposer` now accepts optional `spawnX`/`spawnY` matching
Arcturus' `RequestRoomLoadEvent` optional tail; `RoomSettingsData`
surfaces the `allowUnderpass` field that Arcturus already emits.
Dead `sendWhisperGroupMessage` / `ChatWhisperGroupComposer`
reference removed.
### Vitest coverage
Bumped from 65 → 113 cases across 8 test files. New coverage:
- `dedupeBadges.test.ts` (6) — slot-preserving badge dedup.
- `catalog-favorites.helpers.test.ts` (16) — v2→v3 localStorage
migration + per-catalog-type storage-key routing.
- `avatar-info-reducers.test.ts` (14) — three reducer bail-out
branches + apply paths.
- `friendly-time.test.ts` (12) — `FriendlyTime` with a deterministic
`LocalizeText` mock.
### Logic bug fixes (in scope)
- `useInventoryFurni`'s module-level `furniMsgFragments` buffer
scoped to `useRef`.
- `RoomChatHandler.dispatchEvent(RoomSessionChatEvent)` arg order
fix in renderer — `chatColours` and `links` slots were swapped.
- `PetBreedingMessageParser.bytesAvailable < 12` was a boolean-vs-number
bug; replaced with the standard guard pattern.
- `useOnClickChat` was passing an extra 8th arg to `showConfirm`
(signature only takes 7).
- `UserContainerView` was passing `userProfile.friendsCount` (number)
to a `LocalizeText` placeholder array (expects string).
## Badge System Rework (2026-04-04)
### Bug Fixes
+422
View File
@@ -0,0 +1,422 @@
# Claude Code — project memory for Nitro-V3
This file is read automatically by Claude Code at session start. It captures
the conventions and current state of this branch so a new session can hit
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`.
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.
Local-dev game assets are served by a small Vite plugin (`sirv` middleware
mounted on `/nitro-assets` and `/swf`, reading from
`E:\Users\simol\Desktop\DEV\Nitro-Files`) — NOT by symlinking inside
`public/`. The symlink path triggers chokidar on ~177k files and the dev
server hangs for minutes on Windows. See `vite.config.mjs` and the
`.gitignore` note.
Detailed status, decisions, and next steps live in **`docs/ARCHITECTURE.md`** —
read that before starting anything non-trivial.
## Commands
| Goal | Command |
|---|---|
| Dev server | `yarn start` |
| Production build | `yarn build` |
| Serve production build | `yarn preview` (defaults to http://localhost:4173) |
| Lint | `yarn eslint` |
| Type-check (TS 7 native, fast) | `yarn typecheck` |
| Test (Vitest, once) | `yarn test` |
| Test (watch) | `yarn test:watch` |
## Setup walkthrough
1. **Clone the renderer SDK as a sibling of this repo.**
`vite.config.mjs` resolves the `@nitrots/*` aliases against
`../Nitro_Render_V3` (preferred) or `../renderer` (legacy). If neither
exists, the dev server and build now fail fast with a message
pointing here.
```sh
cd .. # parent of Nitro-V3
git clone <renderer-repo> Nitro_Render_V3
cd Nitro_Render_V3 && yarn install
```
2. **Install client deps.**
```sh
cd ../Nitro-V3
yarn install
```
3. **Materialize the runtime configuration.** `public/configuration/`
ships `.example` files. Copy the ones you need without the `.example`
suffix and point them at your game server (websocket URL, asset
base URL, UI texts, etc.). The dev server doesn't fail if these are
missing but the client renders a blank/error screen at runtime.
4. **Run.**
- Dev: `yarn start` (Vite, HMR, includes the renderer source).
- Production preview: `yarn build && yarn preview`.
The renderer SDK (`@nitrots/nitro-renderer`) is consumed via a filesystem
link to a sibling working tree — `../Nitro_Render_V3` (preferred) or
`../renderer` (legacy). Without it, `yarn typecheck` reports TS2307 across
the codebase — that's expected on a sandbox without the renderer, not a
regression.
## Stack snapshot
- React 19.2.5, `react-dom` 19.2.5, `@types/react` 19.2.x.
- TypeScript: TS 6 for build, **TS 7 native preview** (`@typescript/native-preview`,
invoked via `tsgo`) for the `typecheck` script.
- Vite 8 + `@vitejs/plugin-react` 6 + `babel-plugin-react-compiler` 1.0.
- ESLint 10 + `typescript-eslint` 8 + `eslint-plugin-react-hooks@7` +
`eslint-plugin-react-compiler`.
- TanStack Query 5 (`@tanstack/react-query` + devtools).
- Zustand 5.
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`.
- `react-error-boundary` 6.
## Layout convention (DO NOT CHANGE)
Established by the team and recorded in `docs/ARCHITECTURE.md` proposal #3
(rejected the `src/features/` alternative). Stay on this layout — every PR
that violates it will need to be reworked.
```
src/components/<area>/<feature>/ → views (.tsx only)
e.g. src/components/room/widgets/doorbell/DoorbellWidgetView.tsx
src/hooks/<area>/<feature?>/ → hooks, FLAT files, no per-feature subfolder
e.g. src/hooks/rooms/widgets/useDoorbellState.ts
src/hooks/rooms/widgets/useDoorbellActions.ts
src/hooks/rooms/widgets/useDoorbellWidget.ts (deprecated shim)
src/api/ → cross-cutting helpers (LocalizeText, composers, formatters)
src/common/ → reusable UI primitives + error boundary
src/state/ → Zustand stores (cross-feature only)
src/**/*.test.{ts,tsx} → Vitest suites co-located next to their subject (e.g. `Foo.ts` + `Foo.test.ts`)
src/nitro-renderer.mock.ts → hand-written renderer-SDK stub for tests (aliased over `@nitrots/nitro-renderer`)
src/test-setup.ts → Vitest setupFiles entry (jest-dom matchers, etc.)
```
When splitting a god-hook the convention is **3 files, all flat in the
hooks barrel directory**:
- `use<Feature>State.ts` — state + event subscriptions + derived values
- `use<Feature>Actions.ts` — pure imperative actions (no state writes)
- `use<Feature>Widget.ts` — deprecated wrapper that composes the two and
preserves the old return shape so existing consumers don't break
See `useDoorbellState`/`useDoorbellActions`/`useDoorbellWidget` as the
canonical pattern.
## Patterns to use
### `useSessionSnapshots` (renderer snapshot pattern, React-side — OPT-IN)
For state that lives on a renderer Manager and is invalidated through
`NitroEventType.*_UPDATED`, the file
`src/hooks/session/useSessionSnapshots.ts` exposes eight consumer hooks
backed by `useSyncExternalStore`:
```ts
const userData = useUserDataSnapshot(); // SessionData
const room = useActiveRoomSessionSnapshot(); // RoomSession
const ignored = useIgnoredUsersSnapshot(); // ReadonlyArray<string>
const isIgn = useIsUserIgnored(name); // boolean, memoized
const badges = useGroupBadgesSnapshot(); // ReadonlyMap<number,string>
const badge = useGroupBadge(groupId); // string, memoized
const vols = useVolumesSnapshot(); // sound volumes
const users = useRoomUserListSnapshot(); // ReadonlyArray<IRoomUserData>
```
Each hook has defensive `typeof method === 'function'` guards against
a stale renderer bundle and degrades to a frozen default snapshot if
the renderer doesn't expose the matching getter (kept module-level so
React's bailout still works on the degraded path).
**Adoption status: three pilot consumers shipped (commit `d28819d`,
2026-05-19).** `useSessionInfo` reads userFigure / respectsLeft /
respectsPetLeft from `useUserDataSnapshot`; `useChatWidget.ownUserId`
reads from the snapshot directly; `AvatarInfoWidgetAvatarView` flips
its Ignore/Unignore menu via `useIsUserIgnored`.
The original rollback (`e142efd`) was caused by a hard structural
constraint, NOT a stale renderer or React Compiler quirk: **snapshot
hooks (`useSyncExternalStore`-based) must NOT be called inside a
`useBetween(stateFn)` scope.** `use-between` 1.x swaps
`ReactCurrentDispatcher.current` with its own proxy
(`ownDispatcher` at
`node_modules/use-between/release/index.esm.js:54-169`) that
re-implements only useState / useReducer / useEffect /
useLayoutEffect / useCallback / useMemo / useRef /
useImperativeHandle. `useSyncExternalStore` isn't on the list, so
React resolves `dispatcher.useSyncExternalStore` to `undefined` and
crashes on first paint — that's the original "(intermediate value)()
is undefined" at `ToolbarView.tsx:46`. Chrome reports the same as
`dispatcher.useSyncExternalStore is not a function`.
**Fix pattern, applied to `useSessionInfo`:** call the snapshot hook
in the OUTER exported wrapper, after `useBetween`, so it runs in the
real React dispatcher's scope. The inner state function (the one
`useBetween` actually proxies) keeps only useState /
useMessageEvent / plain actions.
```ts
const useSessionInfoState = () => {
// ONLY use-between-safe hooks here.
const [chatStyleId, setChatStyleId] = useState(0);
// … useMessageEvent, actions …
return { chatStyleId, /* actions */ };
};
export const useSessionInfo = () => {
const shared = useBetween(useSessionInfoState);
const userData = useUserDataSnapshot(); // outside useBetween → ok
return { ...shared, userFigure: userData.figure, /* etc */ };
};
```
Regression guard: `src/hooks/session/useSessionSnapshots.test.tsx`
asserts the negative case (snapshot inside useBetween crashes via
ErrorBoundary) and the positive case (outside works). A CI gate
(`yarn lint:hooks` →
`react-hooks/rules-of-hooks: error`) blocks any future commit that
reintroduces hook-order issues.
### `useNitroEventState` / `useMessageEventState`
For "derived state from a single event" replace the two-step
`useState + useNitroEvent(e => setState(...))` with a single call:
```ts
const foo = useNitroEventState(SomeEvent, e => e.payload, initial);
const data = useMessageEventState(SomeParser, e => e.getParser()?.field ?? null, null);
```
The selector is held in a `useLayoutEffect`-refreshed ref so the listener
stays registered across renders. Both hooks are exported from
`src/hooks/events`.
### `useNitroQuery`
For composer/parser request-response pairs:
```ts
const { data } = useNitroQuery<SomeParser, SomeData>({
key: ['nitro', 'domain', 'request', ...args],
request: () => new SomeComposer(args),
parser: SomeParser,
select: e => e.getParser()?.data,
accept: e => e.getParser()?.correlationKey === args, // optional, for shared event bus
staleTime: 60_000,
});
```
Already wired up; `QueryClientProvider` is mounted in `src/index.tsx`.
Companion `useNitroEventInvalidator(eventType, queryKey, accept?)` —
import from `src/api/nitro-query`. Subscribes to the renderer event
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).
### Singleton-filter split for `useBetween`-based hooks
When a hook backs many consumers but most only need either state OR
actions (not both), split it without breaking the shared-singleton
guarantee:
```ts
// internal: state + actions in one closure
const useFooStore = () => {
const [ data, setData ] = useState(...);
// listeners, effects, actions ...
return { data, doThing };
};
// public: read-only filter
export const useFooState = () => {
const { data } = useBetween(useFooStore);
return { data };
};
// public: imperative filter
export const useFooActions = () => {
const { doThing } = useBetween(useFooStore);
return { doThing };
};
// deprecated shim — keeps the historical return shape
export const useFoo = () => useBetween(useFooStore);
```
`useBetween` ensures all three entry points hit the same store
instance, so listeners/effects register once. Used by `useWiredTools`,
`useTranslation`, `useNotification`, `useFriends`.
### Zustand stores
For cross-feature UI state (avoid module-level `let`):
```ts
import { createNitroStore } from '@/state/createNitroStore';
export const useFooStore = createNitroStore<FooState>()((set) => ({
...
}));
```
Components subscribe to slices, not the whole store:
```ts
const value = useFooStore(s => s.value);
```
Adoptions: `src/components/navigator/views/navigatorRoomCreatorStore.ts` (create-room lockout) and `src/components/wired-tools/wiredCreatorToolsUiStore.ts` (UI-only flags for the WiredCreatorTools panel — tab nav, modal/popover open, monitor + variable-manage filters).
### `WidgetErrorBoundary`
Wrap any in-room widget tree so a crash degrades gracefully (logs to
NitroLogger, falls back to `null`). Already applied at `RoomWidgetsView`
as an umbrella; per-widget wrapping is a follow-up.
```tsx
<WidgetErrorBoundary name="ChatWidget">
<ChatWidgetView />
</WidgetErrorBoundary>
```
### Form Actions
Login / Register / Forgot in `src/components/login/LoginView.tsx` use
`useActionState` + `useFormStatus`. The legacy non-Action versions in
`src/components/login/components/{Register,Forgot}Dialog.tsx` and
`shared.ts` have been **removed** (dead code).
### Configuration pre-init in bootstrap
`src/bootstrap.ts` calls `await GetConfiguration().init()` **before**
importing `./index`. Otherwise the first paint dumps a flood of
"Missing configuration key" warnings while components synchronously
read `asset.url`, `login.endpoint`, … against an empty store before
`prepare()`'s deferred init lands.
### Asset serving in dev
Game assets (`bundled/`, `c_images/`, `gamedata/`, `swf/...`) are NOT
copied or symlinked under `public/`. They're served by a custom Vite
plugin (`nitroAssetsServer` in `vite.config.mjs`) that mounts `sirv`
on `/nitro-assets` and `/swf`, reading from
`E:\Users\simol\Desktop\DEV\Nitro-Files\`. sirv is a connect-style
middleware that bypasses chokidar entirely, so the ~177k asset files
never enter the watch graph. The plugin also wires the same handler
into `configurePreviewServer` so `yarn preview` keeps working.
## What's wired up and what isn't
| Adopted | Pilot sites |
|---|---|
| Renderer snapshot consumer hooks (`useSessionSnapshots`) | `useSessionInfo` (userFigure / userRespectRemaining / petRespectRemaining via `useUserDataSnapshot` in the outer wrapper, outside useBetween), `useChatWidget.ownUserId` (via `useUserDataSnapshot`), `AvatarInfoWidgetAvatarView` Ignore/Unignore (via `useIsUserIgnored`), `ModToolsView` selected-user presence dot (via `useRoomUserListSnapshot` — green when still in the active room, gray when they've left). The 8 hooks (userData / activeRoomSession / ignoredUsers / groupBadges / soundVolumes / roomUserList / isUserIgnored / groupBadge) keep their typeof-guard defensive fallbacks for stale-renderer paths. |
| Reactive event-driven local state (companion to snapshots — when there is no manager-snapshot to read from yet) | `AvatarInfoWidgetAvatarView` Give/Remove Rights — local `controllerLevel` initialized from `avatarInfo.targetRoomControllerLevel`, kept reactive via `useMessageEvent<FlatControllerAddedEvent>` / `FlatControllerRemovedEvent` filtered by `parser.data.userId === avatarInfo.webID`, plus optimistic bump on click so the moderate submenu flips immediately. Same shape as `useIsUserIgnored` but the source is the renderer event bus, not a snapshot getter — use this when adding a manager-side snapshot for the same data isn't justified. |
| `useNitroEventState` + companions (Reducer, ExternalSnapshot) | `OfferView`, `useAvatarInfoWidget` (figure/badges/group reducer), `useInventoryFurni` (pure reducers + fragments useRef) |
| `useNitroQuery` + `useNitroEventInvalidator` | `OfferView`, `CatalogLayoutRoomAdsView`, `ModToolsChatlogView`, `CfhChatlogView`, `useGiftConfiguration`, `useUserGroups`, `useClubOffers(windowId)`, `useSellablePetPalette(breed)`, `useMarketplaceConfiguration`, `useClubGifts` (with invalidator) |
| 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) |
| `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) |
| Upstream `origin/Dev` absorbed (merge `779a98c`) | Through `b2318b9` (2026-05-18): JSON5, user-settings reset password/email/username, wear-badge popup fix, login screen fix, About, offer-selection refactor |
| Not yet | Notes |
|---|---|
| Split `useChatWidget` / `useAvatarInfoWidget` (data/actions) | Both state-driven via events with no clean imperative actions to extract — split still skip-motivated, but `useAvatarInfoWidget` got a typed `__nitroAvatarClickControl` accessor + module-scope DEBOUNCE const in 2026-05-18 (commit `05ff7df`). `useChatWidget.ownUserId` reactive migration re-applied 2026-05-19 in `d28819d` via `useUserDataSnapshot` (direct hook call — useChatWidget isn't wrapped in useBetween so the snapshot-outside-useBetween constraint doesn't apply). |
| Split `usePetPackageWidget` / `useWordQuizWidget` / `useChatCommandSelector` (data/actions) | Data/actions split remains a bad fit, but all three got real modernization in 2026-05-18 instead: usePetPackageWidget → useReducer + extracted `getPetPackageNameError` pure helper + 4 tests; useWordQuizWidget → fixed stale-closure bug in `setUserAnswers` updater + `useRef` for the timeout handle; useChatCommandSelector → module-level `let` cache replaced with a Zustand store. |
| Migrate more consumers to renderer snapshot hooks | **Unblocked.** Three pilot consumers shipped 2026-05-19 (`d28819d`), pattern documented above. Next candidates: any code reading from `GetSessionDataManager().userId / userName / clubLevel / securityLevel`, `GetRoomSessionManager().getActiveSession()`, or `GetSoundManager().<volume>` synchronously — those don't re-render today when the value changes. Rule: snapshot read MUST be outside any `useBetween` scope (CI gate `yarn lint:hooks` catches violations; regression test at `src/hooks/session/useSessionSnapshots.test.tsx`). |
| Widen the component / hook test coverage | Mock layer is in place (`src/nitro-renderer.mock.ts`) and 3+ hook/component pilots pass. Good follow-up targets: `LoginView` Form Actions happy/error paths, `OfferView` with `useNitroQuery`. (Acceptable only as a side-effect of a real change — coverage growth on its own is deprioritized per session feedback.) |
## Known open logic bugs
None on this branch. The two previously-open races are closed:
- `MainView` CREATED/ENDED race → fixed in `9d10e52` via a session-aware
reducer pattern.
- `LayoutFurniImageView` / `LayoutAvatarImageView` async fetch race →
fixed in `97c9717` via `requestIdRef` guard on the async callback.
See `docs/ARCHITECTURE.md` "Recently fixed" for fix shapes.
## House rules
- **Commit author**: `simoleo89 <simoleo89@users.noreply.github.com>`.
When committing, pass these via per-command overrides
(`git -c user.name=simoleo89 -c user.email=...`) — do NOT modify the
global git config.
- **No `claude/...` branch names** — auto-generated names should be
renamed before pushing. Prefer `feat/<description>`.
- **Never merge a branch that violates the layout convention** above.
The `feat/react19-hooks-adapter` branch (deleted) put hooks under
`src/components/...`; that's wrong and a recurring temptation.
- **Skip-motivated god-hook splits are fine** — when a hook's actions
mutate internal state, document the reason in the commit message and
move on rather than forcing a bad split.
- **`yarn test` must stay green** on every commit. Currently 193/193.
The GitHub Actions workflow at `.github/workflows/ci.yml` runs
`yarn typecheck` + `yarn test --run` on every push to `main` /
`feat/**` and on every PR — both must pass.
- **Lint baseline**: don't regress. Some pre-existing errors (`FC<{}>`,
`IMessageEvent | undefined` redundant union in the local sandbox where
the renderer SDK isn't installed) are out of scope here.
## Where everything lives
- Architecture doc: `docs/ARCHITECTURE.md`
- Test runner config: `vitest.config.mts` (separate from `vite.config.mjs`)
- Test setup: `src/test-setup.ts`
- Test convention: co-located under `src/` next to the subject (`src/<path>/Foo.ts``src/<path>/Foo.test.ts`). No separate `tests/` tree.
- React Query adapter: `src/api/nitro-query/createNitroQuery.ts`
- Zustand factory: `src/state/createNitroStore.ts`
- Error boundary: `src/common/error-boundary/WidgetErrorBoundary.tsx`
- Event hooks (`useNitroEvent`, `useMessageEvent`, `useNitroEventState`,
`useMessageEventState`): `src/hooks/events/`
- Wired-tools split (types/constants/helpers + 3 tab views):
`src/components/wired-tools/`
- User account settings (cherry-picked from upstream PR #126):
`src/components/user-settings/UserAccountSettingsView.tsx`
- Access-token persistence helper (used by login + remember + rotate):
`src/api/auth/accessToken.ts` (`persistAccessTokenFromPayload`)
- Asset middleware: `nitroAssetsServer()` in `vite.config.mjs`
- Configuration pre-init: `src/bootstrap.ts` (`await GetConfiguration().init()`
before `import('./index')`)
- Catalog pure helpers: `src/hooks/catalog/useCatalog.helpers.ts`
(`buildCatalogNodeTree`, `findNodeById` / `findNodeByName`,
`getNodesByOfferIdFromMap`, `getOfferProductKeys`,
`normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`)
- Catalog three-way filter split: `useCatalogData` /
`useCatalogUiState` / `useCatalogActions` in
`src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated;
deprecated `useCatalog` shim removed)
- 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` /
`clearMockEventDispatcher` helpers used by hook tests, the
`RoomSessionDoorbellEvent` stub, and a long list of placeholder
classes/enums kept around just so the `src/api/*` barrel cascade
imports without throwing. **Grow this file when a new test needs a
symbol; prefer real deterministic stubs over `vi.fn()`.**
+859
View File
@@ -0,0 +1,859 @@
# Architecture & Refactor Plan
> Status: **living document**, last updated 2026-05-10.
> This file describes the structural direction the codebase is moving in.
> Read it before starting a non-trivial refactor — half the value comes from
> staying consistent, not from each individual change.
## Table of contents
1. [Where the project stands today](#where-the-project-stands-today)
2. [Five structural improvements](#five-structural-improvements)
1. [Event subscriptions as derived state](#1-event-subscriptions-as-derived-state)
2. [Server requests as queries](#2-server-requests-as-queries)
3. [Feature folders](#3-feature-folders)
4. [Splitting god-hooks](#4-splitting-god-hooks)
5. [Unified UI store](#5-unified-ui-store)
3. [Bonus: error boundaries](#bonus-error-boundaries)
4. [What's already in place](#whats-already-in-place)
5. [How to pick the next refactor PR](#how-to-pick-the-next-refactor-pr)
---
## Where the project stands today
The codebase is a React 19.2 client for the Nitro renderer (Habbo-style hotel
client). Most of the architectural pressure comes from the renderer's
**event-bus + composer/parser** model: the UI talks to the server by sending
composers and listening to incoming message events. Almost every piece of
state in this app is "the latest value seen on a given event".
That model creates two kinds of friction with modern React:
1. **`useEffect` everywhere** — `react-hooks/set-state-in-effect` reports
~328 violations across ~280 files. Most are legitimate event-driven
updates, but the pattern hides the intent (it reads as "imperative
setState on mount/effect" rather than "subscribe to a stream").
2. **God-hooks**`useCatalog` (~1100 lines), `useChat`, `useWiredTools`,
`useInventoryFurni` all bundle data fetching, UI state, side effects,
and computed values into a single export. Components import the whole
thing for one field; the React Compiler skips memoization.
Two big files (`WiredCreatorToolsView.tsx` 4493→3901 lines,
`LoginView.tsx` 1700) further compound the problem: the Compiler logs
"Compilation Skipped: Existing memoization could not be preserved", which
means manual `useMemo`/`useCallback` are not even helping.
The improvements below are ordered so that each one makes the next one
easier.
---
## Five structural improvements
### 1. Event subscriptions as derived state
**Problem.** Pattern repeated hundreds of times:
```ts
const [foo, setFoo] = useState(initial);
useNitroEvent(SomeEvent, e => setFoo(e.payload));
```
or with the message channel:
```ts
const [data, setData] = useState(null);
useMessageEvent(SomeParser, e => {
const parser = e.getParser();
if (!parser) return;
setData(parser.field);
});
```
The shape of the code obscures the intent ("`foo` IS the latest event payload")
and makes the lint think we're doing imperative setState in an effect.
**Solution.** Two thin hooks (`src/hooks/events/useNitroEventState.ts`
and `useMessageEventState.ts`):
```ts
const foo = useNitroEventState(SomeEvent, e => e.payload, initial);
const data = useMessageEventState(SomeParser, e => e.getParser()?.field ?? null, null);
```
Internally the selector closure is held in a ref refreshed in commit phase
(`useLayoutEffect`), so a new selector identity per render does not force
re-subscription. The listener is registered once.
**Status.** Implemented + adopted in `OfferView.tsx`, `useAvatarInfoWidget`
(figure/badges/group merge), and `useInventoryFurni` (extracted pure
reducers consumed by `useMessageEvent` setters).
**Adoption.** Organic: when a contributor sees a clean
"derive-from-single-event" case, they convert it. **Do not sweep-replace.**
The majority of existing subscriptions have side effects, multi-state
updates, conditional filters, or state-machine semantics that lose
information when forced into a single selector.
**Companions** (all implemented in `src/hooks/events/`):
- `useNitroEventReducer<S, T>(types, reducer, initial)` — multiple event
types collapsing into one owned state slice (analogous to
`useReducer` but driven by renderer events).
- `useMessageEventReducer<S, T>(eventTypes, reducer, initial)` — same
shape on the server message channel; accepts a single type or an
array of types that all feed the same reducer.
- `useExternalSnapshot<T>(subscribe, getSnapshot)`
`useSyncExternalStore` wrapper pairing the renderer's
`EventDispatcher.subscribe()` with the `getXxxSnapshot()` getters
added in renderer 2.1.0. Use this for readonly views over manager
state. Eight pre-built consumers live in
`src/hooks/session/useSessionSnapshots.ts` (userData / activeRoomSession
/ ignoredUsers / groupBadges / soundVolumes / roomUserList + scalar
derivations `useIsUserIgnored`, `useGroupBadge`), each with defensive
`typeof` guards against a stale renderer bundle.
**Hard constraint — snapshot hooks must run outside `useBetween`.**
`use-between` 1.x swaps the React dispatcher with its own proxy
(`ownDispatcher` at
`node_modules/use-between/release/index.esm.js:54-169`) that
reimplements only useState / useReducer / useEffect /
useLayoutEffect / useCallback / useMemo / useRef /
useImperativeHandle. `useSyncExternalStore` is not on the list, so
calling a snapshot hook inside `useBetween(stateFn)` invokes
`undefined(...)` and crashes the first render with
"(intermediate value)() is undefined" (Firefox) /
"dispatcher.useSyncExternalStore is not a function" (Chrome). This
is what blocked the original 2026-05-18 migration of
`useSessionInfo` — the rollback (`e142efd`) was correct as a stop
the bleed, but neither the vite alias (`790ad2b`) nor the
defensive renderer-method guards (`c35a2d4`) could address it
because both were downstream of the dispatcher proxy.
**Fix landed 2026-05-19 (`d28819d`).** Three pilot consumers shipped:
`useSessionInfo` (snapshot read in the outer wrapper, after
`useBetween`); `useChatWidget.ownUserId` (direct hook call —
`useChatWidget` is not wrapped in `useBetween`);
`AvatarInfoWidgetAvatarView` Ignore/Unignore (direct hook call in a
component body via `useIsUserIgnored`). Pattern documented in
`CLAUDE.md` under "Patterns to use →
`useSessionSnapshots`". Regression guard:
`src/hooks/session/useSessionSnapshots.test.tsx` (negative case via
`ErrorBoundary` + positive case). CI gate:
`yarn lint:hooks` (`eslint.hooks.config.mjs`
`react-hooks/rules-of-hooks: error`) wired into
`.github/workflows/ci.yml`.
For state owned outside the listener (the `useState` + `setState(prev =>
applyX(prev, event))` pattern), keep using `useNitroEvent` /
`useMessageEvent` and extract the reducer as a pure function for
testability. See `src/hooks/inventory/useInventoryFurni.reducers.ts` and
`src/hooks/rooms/widgets/avatarInfo.reducers.ts` for the convention.
---
### 2. Server requests as queries
**Problem.** A request/response pair against the server today looks like:
```ts
useEffect(() => {
SendMessageComposer(new GetXComposer());
}, []);
useMessageEvent(YParser, e => {
setData(e.getParser().data);
});
```
There is no caching, no deduplication, no retry, no loading or error state,
no devtools. Every consumer rolls its own. The same request fires
multiple times if multiple components mount it.
**Solution.** Wrap composer/parser pairs in a TanStack Query adapter
(`@tanstack/react-query` is in the same family as `@tanstack/react-virtual`
which is already a dependency):
```ts
const { data, isLoading } = useNitroQuery({
request: () => new GetXComposer(),
parser: YParser,
select: e => e.getParser().data,
});
```
**Status.** Adapter prototype written (`src/api/nitro-query/createNitroQuery.ts`).
Not wired up because `@tanstack/react-query` is **not yet installed**
deliberately left as a `yarn add` step the team can approve.
**To enable.**
```sh
yarn add @tanstack/react-query @tanstack/react-query-devtools
```
Then mount the provider in `src/index.tsx`:
```tsx
<QueryClientProvider client={queryClient}>
<App />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
```
**Migration order suggested.**
1. Read-only catalog data (`useCatalog` page fetches) — biggest win, lowest
risk because it's mostly read.
2. Inventory tabs.
3. Navigator search results.
4. Marketplace listings.
Push messages (events the server emits without the client asking) keep
using `useMessageEventState` — they're not requests.
---
### 3. Feature folders ~~(adopted)~~ — **rejected, keep the current layout**
> **Update:** an earlier version of this document proposed a
> `src/features/<feature>/` layout (vertical slices). The pilot on the
> doorbell widget showed that the existing `src/components/<area>/` +
> `src/hooks/<area>/` split is the convention the team wants to keep.
> The pilot has been rolled back; this section is left as a record of
> the decision.
**Current convention** (the one to follow):
- **Views** live under `src/components/<area>/<feature>/*.tsx`
(e.g. `src/components/room/widgets/doorbell/DoorbellWidgetView.tsx`).
- **Hooks** live under `src/hooks/<area>/<feature?>/*.ts`
(e.g. `src/hooks/rooms/widgets/useDoorbellState.ts`). Multiple hooks
for the same widget go in the same folder as siblings, not in a
per-widget subfolder.
- **Pure helpers / constants / types** that are specific to one view
go in sibling files next to the view (see
`src/components/wired-tools/WiredCreatorTools.{types,constants,helpers}.ts`
for the established pattern).
- **Cross-cutting** utilities continue to live under `src/api/` and
`src/common/`.
Discoverability is acceptable as long as the **naming** is consistent —
`useDoorbellState` / `useDoorbellActions` / `DoorbellWidgetView` are
greppable in seconds even though they live in three separate directory
trees.
---
### 4. Splitting god-hooks
**Problem.** `useCatalog.ts` is ~1100 lines. It owns:
- Server fetch lifecycle (request/parser pairs)
- UI state (selected page, current product, filters)
- Side effects (purchases, gift composer dispatch)
- Computed values (pricing display, page tree)
- Cross-cutting helpers (currency lookup, club level checks)
Every component that imports `useCatalog()` for one field re-runs the
whole thing. The Compiler can't memoize it (too large). Tests can't be
written against a single concern.
**Solution.** Split by responsibility, not by entity:
```ts
useCatalogData() // server data, returns { pages, currentPage, isLoading }
useCatalogUiState() // ui state, returns { selectedNode, setSelectedNode, filters, ... }
useCatalogActions() // imperative actions, returns { purchase, gift, openOffer }
```
Inside, `useCatalogData` uses `useNitroQuery` (#2). `useCatalogUiState` uses
a Zustand slice (#5). `useCatalogActions` is a stateless export — just
functions that compose composers.
**Status.** Pilot done on `useDoorbellWidget`:
- `src/hooks/rooms/widgets/useDoorbellState.ts` — the users list,
derived from three events using a `useNitroEventReducer`-like pattern.
- `src/hooks/rooms/widgets/useDoorbellActions.ts``answer(name, flag)`.
- `src/hooks/rooms/widgets/useDoorbellWidget.ts` kept as a deprecated
shim that composes the two so existing consumers don't break.
It's a small hook so the split looks almost theatrical, but the shape is
the same one we want to apply to `useCatalog`.
**Migration order suggested.** Largest pain first, moving down:
1. `useCatalog` (~1100 LOC) — but only after #2 is enabled (server fetches
collapse to a few `useNitroQuery` calls, removing 60% of the file).
2. `useChatInputWidget` (~500 LOC)
3. `useWiredTools` (~600 LOC)
4. `useInventoryFurni` (~300 LOC)
---
### 5. Unified UI store
**Problem.** Cross-feature UI state lives in:
- React Context (e.g. `UiSettingsContext`)
- Custom hooks with module-level singletons (`useNavigator`'s implicit cache)
- `let foo = ...` module-level mutable variables — flagged by the React
Compiler as "Writing to a variable defined outside a component or hook is
not allowed" (currently 5+ violations)
- `localStorage` reads in effects
There is no single source of truth, no devtools, no time-travel.
**Solution.** Adopt **Zustand** for cross-feature UI state. Each feature
owns one slice:
```ts
// src/state/wired-tools.ts (or src/components/wired-tools/wiredToolsStore.ts)
export const useWiredToolsStore = create<WiredToolsState>()((set) => ({
activeTab: 'monitor',
setActiveTab: (tab) => set({ activeTab: tab }),
// ...
}));
```
Components subscribe to **specific keys** (Zustand re-renders only the
subscribers whose selected slice changed):
```ts
const activeTab = useWiredToolsStore(s => s.activeTab);
```
This eliminates the `let isCreatingRoom = false` module-level pattern and
makes the state ispezionable in dev tools.
**Status.** Skeleton written (`src/state/createNitroStore.ts`), not yet
adopted — `zustand` is not yet installed. Same reason as #2: deliberately
a follow-up `yarn add` step.
**To enable.**
```sh
yarn add zustand
```
Then convert the smallest singleton first (suggestion: the
`isCreatingRoom`/`createRoomTimeout` pair in
`NavigatorRoomCreatorView.tsx` — it's a clean 5-line conversion).
**Do not** wholesale-replace Context. Some Contexts (theming, i18n) are
fine as-is. Zustand is for *application* state, not *configuration* state.
---
## Bonus: error boundaries
`react-error-boundary` is already a dependency. A widget crashing in a
room (e.g. malformed pet data in `InfoStandWidgetFurniView`) currently
takes down the whole UI.
**Solution.** Wrap each widget root in `<ErrorBoundary fallback={null} onError={NitroLogger.error}>`.
Implementation lives at `src/common/error-boundary/WidgetErrorBoundary.tsx`.
**Status.** Implemented + applied to `RoomWidgetsView` as the umbrella for
all in-room widgets, **plus** a per-widget pass that wraps each of the 13
direct children of `RoomWidgetsView` and each of the 20 sub-widgets in
`FurnitureWidgetsView`. A crash in any single widget now silently logs
through `NitroLogger` and renders `null` for that widget only — its
siblings keep rendering. Each boundary carries a `name` prop matching
the widget so the log line identifies the culprit.
---
## What's already in place
The current branch (**`feat/react19-modernization`**, PR #2) has applied:
### Toolchain
- React 19.2 / `react-dom` 19.2 / `@types/react` 19.2.
- TS 6 for build + **TS 7 native preview** (`tsgo`) for `yarn typecheck`.
- ESLint 10 + `typescript-eslint` 8 + `eslint-plugin-react-hooks@7` +
`eslint-plugin-react-compiler`.
- Vite 8 + React Compiler 1.0 (`babel-plugin-react-compiler`).
- `<StrictMode>` mounted; `App.tsx` made idempotent for the double-mount.
### React 19 idioms
- **`forwardRef``ref` prop** on 7 layout/component files (11 call sites).
- **`<Ctx.Provider>``<Ctx>`** on 6 contexts.
- **Native `<script>`** in `TurnstileWidget`, `ExternalPluginLoader`,
`GoogleAdsView`.
- **Form Actions** (`useActionState` + `useFormStatus`) for the inline
Login/Register/Forgot dialogs in `LoginView.tsx`. Legacy non-Action
versions in `components/login/components/` removed as dead code.
- **`useEffectEvent`** in `App.tsx`, `FurniEditorSearchView`,
`NotificationBadgeReceivedBubbleView`,
`NavigatorRoomSettingsRightsTabView`, `UiSettingsContext`,
`TurnstileWidget` — clears all remaining `exhaustive-deps` warnings.
- Targeted `set-state-in-effect` fixes: `CatalogHeaderView` (pure derive),
`NavigatorRoomCreatorView` (lazy state init), `LoginView`
(track-previous-prop reset), `ChooserWidgetView` (callback in
`useEffectEvent`).
### Patterns + adoption (proposals #1, #2, #4, #5)
- **`useNitroEventState` / `useMessageEventState` + companions** (proposal #1)
— adapters in `src/hooks/events/`. Selectors are held in a
`useLayoutEffect`-refreshed ref (Dan Abramov's use-event-callback
pattern) so the listener stays mounted across renders.
Companions for the multi-event → single state-slice case:
`useNitroEventReducer`, `useMessageEventReducer`, plus
`useExternalSnapshot` (a typed wrapper of `useSyncExternalStore` for the
renderer's `EventDispatcher.subscribe()` + `getXxxSnapshot()` getters
added in `Nitro_Render_V3` 2.1.0).
Pilots: `OfferView` (single-event), `useAvatarInfoWidget` (3 listeners
for figure/badges/group merged via pure reducers — moved out of
`InfoStandWidgetUserView`, killing 3 `CloneObject` calls), and
`useInventoryFurni` (4 message listeners + fragment buffer refactored
to pure reducers; the module-level `furniMsgFragments` is now a
`useRef` and the dead `FurniturePostItPlacedEvent` handler dropped).
- **`useNitroQuery`** (proposal #2) — **enabled**. `@tanstack/react-query` +
devtools installed; `QueryClientProvider` mounted in `src/index.tsx`.
Adapter at `src/api/nitro-query/createNitroQuery.ts` with `select`,
`accept` (correlation-key filter), `timeoutMs`, `staleTime`, plus a
lower-level `awaitNitroResponse()` for imperative use. Companion at
`src/api/nitro-query/useNitroEventInvalidator.ts` invalidates a slot
whenever the server pushes the matching event unprompted — required
for queries whose data the server refreshes outside the request cycle
(e.g. ClubGiftInfoEvent after a gift claim). Pilots / sites:
- `OfferView` (targeted offer)
- `CatalogLayoutRoomAdsView` (room-ad list)
- `ModToolsChatlogView` / `CfhChatlogView` (correlated by roomId / ticketId)
- `useGiftConfiguration` — replaces the GiftWrappingConfigurationEvent
listener + eager composer dispatch that lived in `useCatalog`
- `useUserGroups` — consolidates 5 sites that each fired
CatalogGroupsComposer independently (2 wired views + 2 catalog
group widgets + useCatalog itself); now one query, dedup'd
- `useClubOffers(windowId)` — per-windowId query for the VIP / Builders
Club purchase pages, with accept() correlation filter
- `useSellablePetPalette(breed)` — per-breed pet palette, accept()
filter on parser.productCode
- `useMarketplaceConfiguration` — lifts a self-fetch out of
MarketplacePostOfferView
- `useClubGifts` — paired with `useNitroEventInvalidator` for the
server-push-after-SelectClubGift case
- **`ICatalogOptions` deleted** — useCatalog used to expose a
`catalogOptions` bag where multiple components stuffed unrelated
fetched data (groups, clubOffers, clubOffersByWindowId, petPalettes,
marketplaceConfiguration, clubGifts, giftConfiguration). Every field
is now its own TanStack query at the consumer site; the bag and the
interface are gone.
- **Layout / feature folders** (proposal #3) — **rejected**. The existing
`src/components/<area>/<feature>/` (views) +
`src/hooks/<area>/<feature?>/` (flat hook files) is the layout that
stays. See section 3 above for the full rule.
- **God-hook split** (proposal #4) — applied to:
- **doorbell**: `useDoorbellState` + `useDoorbellActions` + shim.
- **poll**: `usePollSubscriptions` (mounted once in `RoomWidgetsView`)
+ `usePollActions` + shim. `useWordQuizWidget` was migrated to
import `usePollActions` directly so it doesn't pull subscriptions.
- **furni chooser**: `useFurniChooserState` + `useFurniChooserActions`
+ shim. Helper `buildWallItem`/`buildFloorItem` dedupes ~50 lines
of inline `RoomObjectItem` construction (typed via `IRoomObject`;
the dead `sessionDataManager.getUserData` fallback dropped — the
method never existed).
- **user chooser**: `useUserChooserState` + `useUserChooserActions`
+ shim. Helper `buildUserItem`. Adds `?.` guards on
`roomSession?.userDataManager?` to avoid the room-transition NPE
pattern.
- **friend request**: `useFriendRequestState` (3 useState + 2 event
bridges + 1 derive effect) + `useFriendRequestActions` (thin
adapter on the friends store) + shim. Exports `ActiveFriendRequest`
type.
- **chat input**: `useChatInputState` (5 state slices + 3 event
listeners + 3 lifecycle effects: flood countdown, idle auto-clear,
typing-indicator sync) + `useChatInputActions` (`sendChat` with
the full slash-command repertoire and the outgoing-translation
pipeline) + shim. Single consumer (`ChatInputView`) keeps the
original tuple.
- **wired tools**: `useWiredToolsStore` (internal singleton — state,
listeners, effects, 13 actions in one closure) + `useWiredToolsState`
/ `useWiredToolsActions` (read-only and imperative `useBetween`
filters over the same singleton) + `useWiredTools` shim. Used by
~20 consumers; the singleton sharing keeps a single source of
truth while letting consumers import only the slice they touch.
- **translation**: `useTranslationStore` (internal singleton) +
`useTranslationState` / `useTranslationActions` (filtered
`useBetween` views) + `useTranslation` shim. Same pattern as
Wired tools — six consumers split across read-only views
(settings panel, bootstrap) and dispatch sites (messenger, chat
input).
- **notification**: `useNotificationStore` (internal singleton) +
`useNotificationState` (queue arrays for the renderer view) +
`useNotificationActions` (8 entry points: simpleAlert,
showNitroAlert, showTradeAlert, showConfirm, showSingleBubble,
closeAlert, closeBubbleAlert, closeConfirm) + shim. The ~30
message-event listeners and 5 state slices stay in the singleton.
Used by ~44 consumers, most of which only need one action.
- **friends**: `useFriendsStore` (internal singleton) +
`useFriendsState` (friends arrays, settings, derived
online/offline split, lookup helpers) + `useFriendsActions`
(requestFriend, requestResponse, followFriend, updateRelationship)
+ shim. 16 consumers.
- **Zustand** (proposal #5) — **enabled**. `zustand` installed; factory at
`src/state/createNitroStore.ts`. First adoption: the `let isCreatingRoom`
/ `createRoomTimeout` module-level pair in `NavigatorRoomCreatorView`
replaced by `useRoomCreatorStore` (timer lives in the store closure,
survives StrictMode double-mount).
### `WiredCreatorToolsView` decomposition
- Top-level constants/types/helpers extracted to sibling files
(`WiredCreatorTools.{types,constants,helpers}.ts`).
- All four tab JSX bodies extracted into sibling components:
- `WiredMonitorTabView`
- `WiredInspectionTabView`
- `WiredVariablesTabView`
- `WiredToolsSettingsTabView` (already separate from before this PR)
- The three Monitor-tab overlay popups guarded by `{ false && ... }`
were dead duplicates of the live overlays mounted at the root level —
dropped.
- Main view: **4493 → 3544 lines** (21%).
### `useCatalog` decomposition (in progress)
The 1100-line god-hook owns the catalog page tree, current page,
offer selection, and a long tail of secondary fetches. Decomposition
strategy from ARCHITECTURE.md proposal #4 step 1: lift the
session-stable read-only fetches to TanStack queries first, then
split the remaining state ownership into `useCatalogData` /
`useCatalogUiState` / `useCatalogActions`.
Status after this round of work:
| Fetch | Migrated to |
|---|---|
| GiftWrappingConfiguration | `useGiftConfiguration()` |
| GuildMemberships | `useUserGroups()` |
| HabboClubOffers (per windowId) | `useClubOffers(windowId)` |
| SellablePetPalettes (per breed) | `useSellablePetPalette(breed)` |
| MarketplaceConfiguration | `useMarketplaceConfiguration()` |
| ClubGiftInfo | `useClubGifts()` (with `useNitroEventInvalidator`) |
| CatalogPagesList / CatalogPage | **deferred** — core state slice (rootNode / offersToNodes / currentPage), needs its own split-out store |
| BuildersClubFurniCount / SubscriptionStatus | **deferred** — read by the internal `getBuilderFurniPlaceableStatus` logic, moves with the data/actions split |
**Helper extraction + filter split both landed.** The 1100-line hook
now has its dependency-free logic in
`src/hooks/catalog/useCatalog.helpers.ts` and exposes three public
filters built on top of the same `useBetween` singleton:
- `useCatalogData()` — server-driven read-only slice (`rootNode`,
`offersToNodes`, `currentPage`, `currentOffer`, `frontPageItems`,
`searchResult`, `roomPreviewer`, `isBusy`,
`catalogLocalizationVersion`, Builders Club counters + timers).
- `useCatalogUiState()` — UI ephemeral state + writers
(`isVisible`, `pageId`, `previousPageId`, `currentType`,
`activeNodes`, `navigationHidden`, `purchaseOptions`,
`catalogPlaceMultipleObjects`, plus all the `set*` writers,
including the ones that mutate the data slice on page / offer /
search-result selection).
- `useCatalogActions()` — imperative operations
(`openCatalogByType`, `toggleCatalogByType`, `activateNode`,
`openPageBy{Id,Name,OfferId}`, `requestOfferToMover`,
`selectCatalogOffer`, `getNodeBy{Id,Name}`,
`getBuilderFurniPlaceableStatus`).
The internal store is named `useCatalogStore` and is **not exported**;
the three public entry points (`useCatalogData` / `useCatalogUiState`
/ `useCatalogActions`) all funnel into the same `useBetween`
instance, so listeners + state register once. All 48 historical
consumers have been migrated to the targeted filters; the deprecated
`useCatalog` shim has been removed.
Pure helpers in `useCatalog.helpers.ts`:
- `normalizeCatalogType(type?)` — coerce the optional catalog type
back to `NORMAL` / `BUILDER`.
- `getOfferProductKeys(offer)` — canonical lookup keys for the
resolved-offer cache.
- `findNodeById` / `findNodeByName` — DFS over the catalog tree,
root excluded.
- `getNodesByOfferIdFromMap(offerId, map, onlyVisible)` — used to be
the closed-over `getNodesByOfferId`; the `onlyVisible` fallback to
the full bucket is preserved.
- `buildCatalogNodeTree(NodeData)` — pulled out of the
`CatalogPagesListEvent` reducer; returns the tree + the offerId
index map in one pass.
- `resolveBuilderFurniPlaceableStatus(input)` — the placement
decision tree as a pure function; the hook keeps the `GetRoomEngine`
/ `GetSessionDataManager` reads (to count non-self, non-moderator
visitors) and passes the resulting `visitorCount` into the helper.
`useCatalog.ts` now imports these instead of defining them inline
(net **75 LOC**). Co-located test file `src/hooks/catalog/useCatalog.helpers.test.ts` covers
all six helpers with 34 cases (tree depth + offerId mapping,
node lookups including root exclusion, the limit-reached / guild-admin
fallback / visitors-in-room paths of the placement helper, and the
empty-map / partial-bucket branches of the offer lookup).
### Tests
- Vitest 3 + jsdom + `@testing-library/react` + `@testing-library/jest-dom`
configured. Separate `vitest.config.mts` so the runner doesn't drag in
the renderer SDK aliases from `vite.config.mjs`.
- **178 cases passing** across 13 test files, **co-located under `src/`** next to each subject (no separate `tests/` tree). Pure-module suites:
- `WiredCreatorTools.helpers.test.ts` (18) — formatters + snapshot
factory.
- `navigatorRoomCreatorStore.test.ts` (4) — Zustand store invariants
with fake timers.
- `api-utils.test.ts` (27) — `ConvertSeconds`, `LocalizeShortNumber`,
`CloneObject`, `GetWiredTimeLocale`, `WiredDateToString`,
`PrefixUtils`.
- `api-utils-extra.test.ts` (16) — `ColorUtils`, `FixedSizeStack`,
`LocalizeFormattedNumber`.
- `friendly-time.test.ts` (12) — `FriendlyTime` with a deterministic
`LocalizeText` mock (cuts the transitive renderer-SDK import).
- `dedupeBadges.test.ts` (6) — slot-preserving badge dedup
(covers the helper used by the InfoStand pilot).
- `catalog-favorites.helpers.test.ts` (16) — localStorage parse +
v2→v3 migration + per-catalog-type storage-key routing.
- `avatar-info-reducers.test.ts` (14) — InfoStand reducer pilot:
bail-out branches (state-not-AvatarInfoUser, mismatched
user/roomIndex, equal-after-dedup) + the figure / favorite-group
apply paths.
- `useCatalog.helpers.test.ts` (34) — catalog pure helpers
extracted out of the god-hook: `normalizeCatalogType`,
`getOfferProductKeys`, `findNodeById` / `findNodeByName` (with
the root-exclusion guard), `getNodesByOfferIdFromMap` (with
the partial-visible fallback), `buildCatalogNodeTree` (tree
depth + offerId index), and the full decision tree of
`resolveBuilderFurniPlaceableStatus`.
- `useCatalog.filters.test.tsx` (4) — contract tests for the
three-way singleton-filter split. Stubs `use-between` so the
filters share one fake store, asserts each filter exposes
exactly the keys it owns (no leak across slices), and pins
down `===` identity of callbacks between the shim and each
slice so the migration of the 48 consumers stays safe.
Component-/hook-level suites (on the new renderer-SDK mock):
- `WidgetErrorBoundary.test.tsx` (4) — happy path + caught render
error logged via `NitroLogger.error` + custom fallback +
`unknown` default name.
- `useDoorbellState.test.tsx` (7) — initial empty state, append on
`DOORBELL`, dedup duplicates, remove on `RSDE_ACCEPTED` /
`RSDE_REJECTED`, ignore stale events, unsubscribe on unmount.
- **Renderer-SDK mock at `src/nitro-renderer.mock.ts`** —
`vitest.config.mts` aliases `@nitrots/nitro-renderer` over this file
so jsdom-hosted tests never load Pixi or the message
parser/composer registry. The mock exports:
- Explicit, behavioral stubs for the symbols tests actually
exercise: `NitroLogger`, `GetEventDispatcher`,
`mockEventDispatcher` / `clearMockEventDispatcher` helpers, the
`RoomSessionDoorbellEvent` class (signature mirrors the real
`(type, session, userName)` so `tsgo` stays happy).
- String-keyed `Proxy` enums for every `*EventType` /
`*FigurePartType` / `RoomObjectCategory` etc. — each access
returns a stable unique string so dispatch + listener agree.
- Lightweight `class StubClass {}` placeholders for the ~30 Pixi
and gameplay classes the `src/api/*` barrel touches at import
time (`NitroAlphaFilter`, `NitroContainer`, `EventDispatcher`,
etc.). Keeps the cascade from throwing without simulating
behavior tests don't care about.
- Singleton getters (`GetAssetManager`, `GetCommunication`,
`GetSessionDataManager`, …) returning a chainable proxy so
`GetX().y.z` evaluates to a no-op proxy instead of crashing.
- **Pure-module convention** (still applies for non-component tests):
import from concrete file paths so jsdom doesn't transitively load
the renderer SDK; use `import type { … }` for type-only renderer
imports.
- `yarn test` + `yarn test:watch` scripts.
### Logic bug fixes
- Doorbell close button didn't close while users were pending
(`useEffect(() => setIsVisible(!!users.length))` overrode the close).
- Doorbell `answer()` removed users locally before the server confirmed
via `RSDE_ACCEPTED`/`RSDE_REJECTED`, desyncing on network drop.
- `RoomToolsWidgetView` wiped `nitro.room.history` from localStorage on
every `beforeunload` (every tab close).
- `AvatarInfoPetTrainingPanelView` crashed if `roomSession` was null at
parser time.
- `useInventoryFurni` had a module-level `furniMsgFragments` buffer that
would have collided between two simultaneous client instances (now
scoped to a `useRef` inside the singleton hook).
### Dead code removed
- `src/components/login/components/RegisterDialog.tsx`.
- `src/components/login/components/ForgotDialog.tsx`.
- `src/components/login/components/shared.ts` (consumed only by the two
legacy dialogs).
- `useInventoryFurni`'s empty `FurniturePostItPlacedEvent` handler.
- `IRoomSession.sendWhisperGroupMessage` + impl in the renderer (the
`ChatWhisperGroupComposer` it referenced never existed; no client
call site).
### Typecheck baseline
- Repository-wide `tsgo` (TS 7 preview) errors driven down to **0**
client-side and **0** renderer-side via a series of small targeted
sweeps:
- Framer-motion `Variants` typing on `ToolbarView` + `FriendsBarView`
(33).
- `createNitroQuery` import path / generics / Pick subset
(3 + 1 propagation).
- `useFurniChooserState` typed as `IRoomObject` + dead getUserData
branch dropped (10).
- `ColorVariantType` extended with the 5 `outline-*` bootstrap
variants used by the group-forum thread view (4).
- React 19 `JSX` import in `WiredNeighborhoodSelectorView` (1).
- `showConfirm` extra-arg drop in `useOnClickChat` (1).
- `UserContainerView` `friendsCount.toString()` (1).
- Renderer-side cluster cleared in a single pass: TS 5.7+ `ArrayBuffer`
drift, Pixi v8 `Filter[]` / `WebGLRenderer` narrows, missing
`IGraphicAsset` import, empty-tuple `IMessageComposer<[]>`,
`PetBreedingMessageParser.bytesAvailable` boolean-vs-number bug,
`RoomEnterComposer` extended with optional spawnX/spawnY to match the
Arcturus server (which already reads both ints when present).
### Bonus
- **`WidgetErrorBoundary`** (`src/common/error-boundary/`) — wraps the
`RoomWidgetsView` umbrella. A widget crash now degrades gracefully
(logged to `NitroLogger.error`) instead of unmounting the room.
- **`CLAUDE.md`** at the repo root — onboarding file Claude Code reads at
session start. Captures the layout convention, the patterns to use,
what's wired up, what isn't, and the open logic bugs.
### Boot-time orchestration (`src/bootstrap.ts`)
- Mobile viewport meta tag inserted before anything else.
- `await loadClientMode()` — fetches `client-mode.json` into
`window.__nitroClientMode` so `getClientMode()` can pick up
`secureAssetsEnabled` / `secureApiEnabled` / `apiBaseUrl` for the
fetch interceptor.
- `installSecureFetch()` (no-op when both `secureAssetsEnabled` and
`secureApiEnabled` are off, which is the dev default).
- Populate `window.NitroConfig` with `config.urls`, `sso.ticket`,
forward parameters.
- **`await GetConfiguration().init()`** — eager configuration load
before React mounts. Eliminates the "Missing configuration key:
asset.url / login.endpoint / login.turnstile.* / …" warning flood
that happens when components synchronously read keys on the first
paint while `prepare()`'s deferred init is still in flight.
- `import('./index')` — dynamic, so we keep top-level await for the
steps above.
### Dev asset serving (`vite.config.mjs`)
- Game asset directories (`bundled/`, `c_images/`, `gamedata/`, `swf/`)
live OUTSIDE the repo. The historical "symlink them into `public/`
so Vite serves them via `publicDir`" trick is a trap on Windows:
chokidar tries to install a watcher on every file under `public/`
and the dev server hangs for minutes on ~177k assets.
- The current setup installs a tiny Vite plugin (`nitroAssetsServer`)
that mounts `sirv` on `/nitro-assets` and `/swf`, reading from
`../Nitro-Files/{nitro-assets,swf}`. `sirv` is connect-style
middleware; it bypasses chokidar entirely.
- The same plugin wires the same handler into
`configurePreviewServer` so `yarn preview` keeps working with the
production build.
- `.gitignore` has explicit entries for `/public/nitro-assets` and
`/public/swf` plus a comment explaining why those paths must not be
recreated as symlinks.
### Upstream feature catch-up
- `duckietm/Nitro-V3` PR #126 is cherry-picked: adds
`src/components/user-settings/UserAccountSettingsView.tsx`
(reset password / email / change username flows under the user
settings overlay) and a wear-badge popup fix in
`NotificationBadgeReceivedBubbleView` that gates the button on the
`canShowWearButton` derived predicate. The cherry-pick required
reconciling the LoginView fork to the Form Actions migration
(`useActionState` + `useFormStatus`) and restoring the
`useEffectEvent`-wrapped subscription pattern used elsewhere in
this branch.
---
## How to pick the next refactor PR
Foundations are **done**: React Query enabled with 4 pilot migrations,
Zustand enabled with 1 store, Vitest with 77 cases, error boundary on
the room widgets umbrella, `usePollSubscriptions` already hoisted to
`RoomWidgetsView`, `WiredCreatorToolsView` fully split per tab.
Remaining order of value/risk for the next contributor:
1. **Migrate `useCatalog`'s read-only fetches to `useNitroQuery`.**
Biggest expected payoff (cache + dedup + loading state for free).
The hook is ~1100 lines; start with the page-tree fetch and the
handful of fire-and-forget request/response pairs (gift wrapping
config, builders-club furni count, sellable pet palettes). The
imperative purchase / gift flows stay where they are. Add a
Vitest case per migration.
2. **Split `useCatalog` along the doorbell/poll lines**
(`useCatalogData` / `useCatalogUiState` / `useCatalogActions`,
siblings under `src/hooks/catalog/`). Only after step 1 — React
Query removes ~60% of the file's responsibility, Zustand can absorb
the UI state slice.
3. **Hoist `WiredCreatorToolsView`'s shared state to a Zustand slice.**
The 4-tab split is done but the parent still passes ~25 props to
each tab. A slice at `src/components/wired-tools/wiredToolsStore.ts`
would make each tab subscribe to the keys it needs.
4. **Widen the component/hook Vitest coverage.** The renderer-SDK
mock layer is in place (`src/nitro-renderer.mock.ts`) and the
first two pilots — `WidgetErrorBoundary` and `useDoorbellState`
pass. Good follow-up targets: other `*State` hooks built on event
reducers (`useFurniChooserState`, `useUserChooserState`,
`useFriendRequestState`, `useChatInputState`), the `useNitroQuery`
adapter (timeout + cleanup + accept-filter behavior), and the
`LoginView` Form Actions happy/error paths. Each new test will
likely need to add 1-3 named exports to the renderer mock.
Skipped intentionally and documented in commit messages:
- `usePetPackageWidget` and `useWordQuizWidget` god-hook splits — their
"actions" mutate internal state, so a clean data/actions split would
need either action arguments or a shared store first.
- `useChatInputWidget` / `useChatWidget` / `useAvatarInfoWidget`
large state machines, need per-file design before a mechanical split.
Anything else (the `LoginView` dialog split, the
`react-compiler/react-compiler` warnings on the remaining big files,
the `set-state-in-effect` sweep) is a downstream consequence of the
above — easier and safer once the foundations are in place.
---
## Known logic bugs (independent of structural refactor)
These are runtime bugs spotted while doing the structural work. They are
**not** fixed by the patterns above — they need their own PRs with manual
QA. Listing them here because there is currently no GitHub Issues board on
this repo.
### Open
_(none — both previously-listed bugs have landed; see "Recently fixed"
below.)_
### Recently fixed (in this branch)
- **`LayoutFurniImageView` / `LayoutAvatarImageView` async fetch race
fixed.** Both effects kicked off async image work
(`TextureUtils.generateImage` / SDK `resetFigure` callback) and wrote
the result via `setImageElement` / `setAvatarUrl` guarded only by an
`isMounted` / `isDisposed` ref. If props changed twice in quick
succession the older fetch could resolve last and overwrite the
newer image. Both now capture a `requestIdRef` bumped at the start
of the effect; the async callback bails when its captured id no
longer matches the latest one. (React Query keyed on the props
tuple would also work, but neither call goes through a composer /
parser pair so the request-id ref is the lighter fix.)
- **`MainView` CREATED/ENDED race fixed.** Two independent
`useNitroEvent` listeners on `RoomSessionEvent.CREATED` /
`RoomSessionEvent.ENDED` could land out of order under flaky
reconnects, leaving `landingViewVisible` contradicting the actual
session state. Replaced with a single `useNitroEventReducer` that
carries the active session's `roomId`: a CREATED bumps the tracked
id and closes the landing view; an ENDED is honored only if its
`event.session.roomId` matches the tracked id (or no session is
active), otherwise it's a stale ENDED for a previous session and
gets ignored.
- **Doorbell close button didn't close** while users were pending
(`useEffect(() => setIsVisible(!!users.length))` overrode the close).
Fixed by `src/components/room/widgets/doorbell/DoorbellWidgetView.tsx`
(separate `dismissed` state, visibility computed in render).
- **Doorbell optimistic remove without rollback** — the original
`answer()` removed the user from the local list before the server
confirmed via `RSDE_ACCEPTED`/`RSDE_REJECTED`, leaving client and
server desynced if the network dropped. Fixed by removing the local
`removeUser` call: the server-driven events now own the list. Note:
a "pending" indicator (so users see their answer is in flight) is
desirable — separate small PR.
- **`localStorage` room history wiped on every tab close**
(`RoomToolsWidgetView.tsx`, `useEffect` on `beforeunload` removing
`nitro.room.history`). Fixed by removing the `beforeunload` handler;
history now persists across sessions, which is the only sensible
meaning of `localStorage`. If "session-only" was the intent, the right
primitive is `sessionStorage` — file an issue if that's actually
desired.
- **`AvatarInfoPetTrainingPanelView` null-pointer** —
`roomSession.userDataManager.getPetData(parser.petId)` could throw if
`roomSession` was null at the moment the event arrived (between rooms).
Fixed with `?.` chain.
- **`useAvatarEditor` `set.paletteID` null-pointer** —
`buildCategory` read `set.paletteID` on the line above its
`if(!set || !palette) return null` guard. For categories where
`getSetType()` legitimately returns null (PETS / MISC without
server-side figure data), this threw and the avatar editor crashed
on open, escalating to `WidgetErrorBoundary`. Split the guard so
`set` is checked before its property access.
+895
View File
@@ -0,0 +1,895 @@
# React 19 modernization branches — complete changelog
End-to-end documentation of every modification made since the two modernization branches were opened:
- **Nitro-V3** (React client) — branch `feat/react19-modernization`, **114 commits** since baseline `ae17619`
- **Nitro_Render_V3** (renderer library) — branch `feat/react19-event-bus`, **22 commits** since baseline `98b03aa`
Plus the in-session upstream sync of the third codebase touched on 2026-05-18:
- **Arcturus-Morningstar-Extended** (Java emulator) — FF pull `e6093f9``efb4997` (v4.1.16)
Working directory: `E:\Users\simol\Desktop\DEV`. *(NitroV3-Housekeeping was not touched during the lifetime of these branches.)*
---
## Table of contents
1. [Overview](#1-overview)
2. [Nitro-V3 client — branch story](#2-nitro-v3-client--branch-story)
- [Phase 1: React 19 baseline adoption](#phase-1-react-19-baseline-adoption)
- [Phase 2: Infrastructure pillars (Query, Zustand, Vitest, mocks, Form Actions)](#phase-2-infrastructure-pillars-query-zustand-vitest-mocks-form-actions)
- [Phase 3: Hook taxonomy and god-hook splits](#phase-3-hook-taxonomy-and-god-hook-splits)
- [Phase 4: WiredCreatorTools extraction and Zustand hoists](#phase-4-wiredcreatortools-extraction-and-zustand-hoists)
- [Phase 5: Typecheck cleanup (Pixi v8, TS 6, framer-motion)](#phase-5-typecheck-cleanup-pixi-v8-ts-6-framer-motion)
- [Phase 6: Error boundaries and logic-bug fixes](#phase-6-error-boundaries-and-logic-bug-fixes)
- [Phase 7: Test infrastructure evolution](#phase-7-test-infrastructure-evolution)
- [Phase 8: CI pipeline](#phase-8-ci-pipeline)
- [Phase 9: Upstream cherry-picks (PR #126) and drive-by bugs](#phase-9-upstream-cherry-picks-pr-126-and-drive-by-bugs)
- [Phase 10: Toolbar spam-toggle fix (PR #130 upstream)](#phase-10-toolbar-spam-toggle-fix-pr-130-upstream)
- [Phase 11: Full upstream sync (origin/Dev b2318b9)](#phase-11-full-upstream-sync-origindev-b2318b9)
3. [Nitro_Render_V3 renderer — branch story](#3-nitro_render_v3-renderer--branch-story)
- [Phase 1: v2.1.0 React-friendly API additions](#phase-1-v210-react-friendly-api-additions)
- [Phase 2: TypeScript 6 + tsgo migration](#phase-2-typescript-6--tsgo-migration)
- [Phase 3: API interface alignments (IRoomSession)](#phase-3-api-interface-alignments-iroomsession)
- [Phase 4: TS 5.7+ ArrayBuffer drift fixes](#phase-4-ts-57-arraybuffer-drift-fixes)
- [Phase 5: Pixi v8 alignment](#phase-5-pixi-v8-alignment)
- [Phase 6: Composer/parser alignment with Arcturus](#phase-6-composerparser-alignment-with-arcturus)
- [Phase 7: Dead code removal and small fixes](#phase-7-dead-code-removal-and-small-fixes)
- [Phase 8: Upstream sync (origin/main)](#phase-8-upstream-sync-originmain)
- [Phase 9: Snapshot pattern extensions](#phase-9-snapshot-pattern-extensions)
4. [Arcturus emulator — upstream pull](#4-arcturus-emulator--upstream-pull)
5. [Documentation evolution (CLAUDE.md / ARCHITECTURE.md)](#5-documentation-evolution-claudemd--architecturemd)
6. [Full commit index](#6-full-commit-index)
7. [Final state matrix](#7-final-state-matrix)
---
## 1. Overview
### Branches and their goals
**`feat/react19-modernization`** (Nitro-V3 client) was opened to bring the React client up to React 19 idioms and the supporting infrastructure that React 19 unlocks: TanStack Query for server state, Zustand for cross-component UI state, Vitest for unit testing, React Compiler for automatic memoization, and `react-error-boundary` for graceful degradation. Along the way it absorbed god-hook decompositions, file extractions on oversized components, Pixi v8 alignment, two upstream cherry-picks (duckietm PR #126), an open-upstream PR (#130 — toolbar spam-toggle fix), and finally a full sync of `origin/Dev` through `b2318b9`.
**`feat/react19-event-bus`** (Nitro_Render_V3 renderer) was opened to add React-friendly primitives to the renderer library so the client could consume it through `useSyncExternalStore`, `use()`, and TanStack Query without re-architecting the event bus. It then absorbed TypeScript 6 + tsgo migration, TS 5.7+ ArrayBuffer drift fixes, Pixi v8 type alignment, composer/parser alignment with Arcturus (`RoomEnterComposer`, `RoomSettingsData.allowUnderpass`, etc.), dead-code removal, and finally — in the 2026-05-18 session — four new snapshot-pattern extensions covering ignored users, group badges, the room user list, and sound volumes.
### Current state
| Branch | HEAD | Commits since baseline | Typecheck | Vitest |
|---|---|---|---|---|
| Nitro-V3 / `feat/react19-modernization` | `02a396d` | 114 (baseline `ae17619`) | clean | **203/203** |
| Nitro_Render_V3 / `feat/react19-event-bus` | `28c552f` | 22 (baseline `98b03aa`) | clean | **127/127** |
| Arcturus / `main` | `efb4997` (v4.1.16) | tracks `origin/main` with no local divergence | n/a | n/a |
### Key architectural decisions taken
1. **Stay on the classic `src/components/` + `src/hooks/` layout** — an early experiment with `src/features/<feature>/` was reverted (commit `0755285`); the team decided the in-place layout is the convention. Every PR that violates it gets reworked.
2. **God-hook split into 3 files, flat in the hooks barrel directory**`use<Feature>State`, `use<Feature>Actions`, `use<Feature>Widget` (deprecated wrapper). No per-feature subfolders for hooks.
3. **`useBetween`-based singleton-filter pattern** — for hooks shared by many consumers but where most consumers only need state OR actions, not both: one internal `useBetweenStore`, then a `useState` filter, `useActions` filter, and a deprecated `useFoo` shim.
4. **Renderer-SDK mock for Vitest**`src/nitro-renderer.mock.ts` aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`. Without it, importing any `src/api/*` file in a test crashes jsdom because the real renderer eagerly loads Pixi v8 at module-import time.
5. **Tests co-located next to subjects**`src/path/Foo.ts``src/path/Foo.test.ts`. No parallel `tests/` tree.
6. **Snapshot pattern on the renderer** — referentially-stable, lazy-frozen, invalidated-on-mutation. Now extended to 6 state holders (Session, RoomSession, IgnoredUsers, GroupInfo, RoomUserList, SoundVolumes).
7. **Composer/parser optional trailing fields use a flat early-return chain**, never nested `if(bytesAvailable)` guards. The pattern is now documented in the renderer's CLAUDE.md.
### Commit author + signing convention
All commits authored as `simoleo89 <simoleo89@users.noreply.github.com>` (client) or `simoleo89 <simoleo89@gmail.com>` (renderer), passed via per-command `git -c user.name=… -c user.email=…` overrides — the global git config is never modified. Co-authored-by trailers are explicitly forbidden by a feedback memory entry.
---
## 2. Nitro-V3 client — branch story
### Phase 1: React 19 baseline adoption
The runtime was already on `react@19.2.5` but no React 19 APIs were in use. Phase 1 brought the codebase forward to idiomatic React 19.
#### `a1bee1d` — Initial React 19 modernization sweep
- **forwardRef → ref-as-prop** in 7 layout/component files (NitroInput, Button, ItemCountBadge, Card variants, InfiniteGridItem, ToolbarItemView, AvatarEditorIcon).
- **`<Context.Provider>``<Context>`** in 6 contexts (CatalogAdmin, FloorplanEditor, UiSettings, GridContext, NitroCardContext, NitroCardAccordionContext).
- **Native `<script>` hoisting** for Turnstile, ExternalPluginLoader, GoogleAdsView. React 19 dedupes by `src` and removes manual `document.head.appendChild` + module-level promise caches.
- **React Compiler enabled** at build time via `babel-plugin-react-compiler` in `vite.config.mjs` (target `'19'`), plus `eslint-plugin-react-compiler` in lint mode.
- **Global `<ErrorBoundary>` + `<Suspense>`** in `src/index.tsx` using `react-error-boundary`, with `LoadingView` as fallback.
- **`use(promise)` pilot** in `BackgroundsView` demonstrating Suspense-driven config loading.
- **ESLint react settings** bumped 18.3.1 → 19.2; legacy `@typescript-eslint/ban-types` replaced with `no-restricted-types` (the old rule was removed in `@typescript-eslint` v8).
Form Actions phase (login/register) deferred to its own commit because `LoginView.tsx` was 1623 lines with lockout + Turnstile + heartbeat interleaving.
#### `1b1e0c1` — React 19 Phase 3: login/forgot/register forms migrated to Form Actions
- **Login form** — `handleLoginSubmit → loginAction(prevState, FormData)` wrapped in `useActionState`. Submit button extracted as `<LoginSubmitButton/>` reading pending state via `useFormStatus`. Reads username/password/remember from FormData; remember checkbox carries `name="remember"`.
- **Forgot password form** — `forgotAction` wrapped in `useActionState`; awaits parent `onSubmit` so pending stays true through the parent fetch. `ForgotSubmitButton` uses `useFormStatus`.
- **Register credentials step** — `credentialsAction` with `useActionState`; the step transition (`setStep('avatar')`) happens inside the action after `pingServer` + `onCheckEmail`.
- **Register avatar step** — `avatarAction` validates username, pings server, checks availability, then awaits `onSubmit`. Button label uses `isAvatarPending` to show "Creating…" without prop drilling `submitting`.
- **`DialogSharedProps.onSubmit`** signatures updated to return `Promise<void>` so dialog actions can `await` the parent's fetch.
- **`lockState` memo** replaced with a direct `readLock()` call in render — any re-render (triggered by the action's pending toggle) recomputes it.
- Unused `FormEvent` import dropped; unused `checking` state in RegisterDialog dropped.
#### Other Phase 1 commits
- `535fa71` — ESLint `--fix` auto-fix for brace-style/indent/semi/no-trailing-spaces (mechanical hygiene before adopting new lint rules).
- `25d51af` — Enabled `<StrictMode>` + made `App.tsx` renderer init idempotent (StrictMode double-invokes effects in dev — the renderer init had to become safe to run twice).
- `13dc483` — Bumped ecosystem dependencies (minor/patch).
- `5697d16` — Fixed rules-of-hooks violation in `InfiniteGrid`.
- `6c9f414` — Applied `useEffectEvent` (React 19.2) to TurnstileWidget callbacks (stops stale-closure issues without bloating effect deps).
- `f18c917` — Added `@typescript/native-preview` (tsgo, the TS 7 preview compiler) as a fast `yarn typecheck` script alongside TS 6.
- `d382635` — Phase A: cleared all `react-hooks/exhaustive-deps` warnings via `useEffectEvent` or hoisting.
- `39eb2c6` — Phase C: cleared 4 set-state-in-effect violations on safe candidates.
### Phase 2: Infrastructure pillars (Query, Zustand, Vitest, mocks, Form Actions)
The next chunk laid down the long-term infrastructure that the rest of the modernization rests on.
#### `48d62c5` — Architecture refactor: docs + 5 pilot implementations + error boundary
Introduced `docs/ARCHITECTURE.md` (~370 lines) — a living document describing where the project stood, five proposed structural improvements (feature-folder migration, TanStack Query, Zustand, god-hook splits, Vitest), and the recommended order for the next refactor PRs. Concrete pilots delivered alongside:
- **Doorbell feature folder** — `src/features/doorbell/` with `views/`, `hooks/useDoorbellState.ts`, `hooks/useDoorbellActions.ts`. The split (data vs actions) became the canonical pattern. The folder structure itself was later reverted (commit `0755285`); the data/actions split survived.
#### `0755285` — Reverted feature-folder migration; kept classic `src/components/` + `src/hooks/`
Team feedback on the experiment was that `src/features/<feature>/` introduced cross-cutting friction without obvious wins. Reverted the folder migration but kept the split convention. From this point onward, every god-hook split lives in `src/hooks/<area>/use<Feature>State.ts` + `use<Feature>Actions.ts` + `use<Feature>Widget.ts` (deprecated shim).
#### `34b1b56` — Enable TanStack Query (proposal #2) + first real-data pilot on OfferView
- `yarn add @tanstack/react-query@5 @tanstack/react-query-devtools@5` (^5 matches React 19 peer).
- `src/index.tsx` mounts `QueryClientProvider` above `ErrorBoundary + Suspense`. Default config: `staleTime=30s`, `retry=1`, `refetchOnWindowFocus=false` (chat client, not a data dashboard).
- New adapter at `src/api/nitro-query/createNitroQuery.ts`. Exposes `useNitroQuery({ key, request, parser, select, timeoutMs })` (wraps TanStack's `useQuery`) and `awaitNitroResponse(...)` (lower-level helper). The internal promise registers the parser, dispatches the composer, resolves with `select(event)` on first matching parser, rejects after `timeoutMs` (default 15s), and always cleans up.
- **First pilot** — `OfferView` migrated from the previous `useMessageEventState + manual useEffect-send` pattern.
#### `fd1835c` — Enable Zustand (proposal #5) + convert `isCreatingRoom` singleton
- `yarn add zustand` (^5).
- `src/state/createNitroStore.ts` exports a re-export of zustand's `create` under the project-local name `createNitroStore`. Comments document the convention (one store per domain, subscribe to slices not the whole store).
- **First migration target** — `src/components/navigator/views/navigatorRoomCreatorStore.ts`. A Zustand store with `isCreating: boolean` and `beginCreate()` (latches the flag, dispatches an auto-reset `setTimeout` after 5s, replaces any in-flight timer on re-entry). The component drops two module-level `let` variables that React Compiler was flagging.
#### `6793de2` — Set up Vitest + 22 smoke tests on pure modules (proposal #6)
- `yarn add -D vitest@3 jsdom @testing-library/dom @testing-library/react @testing-library/jest-dom`. Vitest pinned to 3 — yarn 1's peer resolution breaks on vitest@4's peer link to vite.
- `vitest.config.mts` (separate from `vite.config.mjs` because the test runner shouldn't pull in the renderer SDK aliases).
- `tests/setup.ts` imports `@testing-library/jest-dom/vitest` for custom matchers.
- `tests/WiredCreatorTools.helpers.test.ts` (18 cases) covers the pure helpers extracted earlier — `createEmptyMonitorSnapshot`, `formatMonitorLatestOccurrence` (5 time-bucket branches), etc.
#### `22a44d1` — Added `useNitroEventState` / `useMessageEventState` hooks (proposal #1)
The "derived state from a single event" pattern. Replaces the two-step `useState + useNitroEvent(e => setState(...))` with a single call:
```ts
const foo = useNitroEventState(SomeEvent, e => e.payload, initial);
const data = useMessageEventState(SomeParser, e => e.getParser()?.field ?? null, null);
```
The selector is held in a `useLayoutEffect`-refreshed ref so the listener stays registered across renders.
#### `bb1238a` — Added `useExternalSnapshot` + `useNitroEventReducer` + `useMessageEventReducer` companion hooks
Pattern #1 family extended:
- `useExternalSnapshot` for subscribing to renderer-side snapshot getters via `useSyncExternalStore`.
- `useNitroEventReducer` / `useMessageEventReducer` for stateful event accumulation (reducer-style).
#### Other Phase 2 commits
- `9d2e4a7` — Expanded Vitest coverage on the pure helpers in `src/api/{utils,wired}`.
- `388fb8e` — Migrated `CatalogLayoutRoomAdsView`'s room-ad fetch to `useNitroQuery`.
- `bf84a0c``useNitroQuery` extended with an `accept()` predicate; two mod-tools chatlog views migrated.
- `bb28d25` — Vitest +16 cases on ColorUtils, FixedSizeStack, LocalizeFormattedNumber.
- `dbafc97` — Dropped unused login dialogs (dead code) + Vitest coverage on FriendlyTime.
- `f75762a` — Added `CLAUDE.md` + refreshed `docs/ARCHITECTURE.md`.
- `559d860` — Pilot: moved InfoStand event listeners to `useAvatarInfoWidget` owner.
- `8b7bedf` — Pilot: extracted `useInventoryFurni` reducers to a pure module.
- `b1729d8` — Vitest: covered `dedupeBadges` with 6 cases.
- `f1af6fb` — Docs: ARCHITECTURE pattern #1 — companions implemented, pilots adopted.
- `8e4544c` — Migrated `catalog/giftConfiguration` to `useNitroQuery`.
### Phase 3: Hook taxonomy and god-hook splits
The god-hook decomposition pattern was applied across many of the project's central hooks.
**Three-file flat-layout splits** (state + actions + deprecated shim, all flat in the hooks barrel directory):
- `0ae371e``useFurniChooserWidget` split.
- `85fc827``useUserChooserWidget` split.
- `f3442f8``useFriendRequestWidget` split.
- `7218285``usePollWidget` split into subscriptions + actions (proposal #4).
- `419de09` — Hoisted `usePollSubscriptions` to `RoomWidgetsView`; dropped the side effect from `usePollWidget`.
- `a4c9dd8``useChatInputWidget` split.
**`useBetween`-based singleton-filter splits** for hooks shared by many consumers:
- `e1f5df6``useWiredTools` split into state + actions via `useBetween` singleton.
- `eeb9cc6``useTranslation` split via `useBetween` singleton.
- `5344eaf``useNotification` split via `useBetween` singleton.
- `9f3cd9b``useFriends` split via `useBetween` singleton.
#### Catalog three-way split (the most extensive decomposition)
- `fd3ef78` — Extracted pure helpers from `useCatalog` (`buildCatalogNodeTree`, `findNodeById`/`findNodeByName`, `getNodesByOfferIdFromMap`, `getOfferProductKeys`, `normalizeCatalogType`, `resolveBuilderFurniPlaceableStatus`) into `src/hooks/catalog/useCatalog.helpers.ts`. **+34 Vitest cases** on the pure helpers.
- `59d6c4c` — Three-way singleton-filter split: `useCatalogData` / `useCatalogUiState` / `useCatalogActions`. First 3 consumer migrations.
- `0f9fa12` — Migrated remaining 36 `useCatalog()` consumers to the three filters. Deprecated `useCatalog` shim removed. Every consumer now subscribes only to the slice it actually reads, which restores React Compiler memoization and stops catalog-wide re-renders on unrelated key changes.
#### useNitroQuery adoption widening
- `2d9785e``useUserGroups`: consolidated 4 dedup'd `CatalogGroupsComposer` call sites.
- `2a5b9a4``useClubOffers`: per-`windowId` TanStack query for HC offer pages.
- `3947781``useSellablePetPalette(breed)`: per-breed TanStack query for pet picker.
- `9a807bf``useMarketplaceConfiguration`: lifted the marketplace config self-fetch.
- `7b06229``useClubGifts` + `useNitroEventInvalidator`: closed the catalogOptions bag (composer/parser request-response with server-driven invalidation).
- `8b79233` — Extracted `useCatalogFavorites` pure helpers + 16 Vitest cases.
### Phase 4: WiredCreatorTools extraction and Zustand hoists
The `WiredCreatorTools` panel was the largest single component in the codebase. Phase 4 took it apart progressively.
#### File extractions
- `5d8717d` — Split `WiredCreatorToolsView`: extracted types/constants/helpers into 3 sibling files (`WiredCreatorTools.types.ts`, `WiredCreatorTools.constants.ts`, `WiredCreatorTools.helpers.ts`).
- `23fc302` — Extracted Variables tab JSX into `WiredVariablesTabView` component.
- `d7d9a7e` — Extracted Inspection tab JSX into `WiredInspectionTabView` component.
- `bb09a56` — Extracted Monitor tab JSX into `WiredMonitorTabView` + dropped dead overlays.
#### Progressive Zustand hoists (each its own commit for revertability)
- `c16ac1d`**UI flags hoisted** to `useWiredCreatorToolsUiStore`: 14 pure UI flags (`isVisible`, `activeTab`, `inspectionType`, `variablesType`, modal/popover opens, monitor and variable-manage filters/sort/page). Setters accept value-or-updater. `WiredInspectionTabView` and `WiredVariablesTabView` drop 6 props.
- `eb8d879` — Docs follow-up (CLAUDE: store adoption + test count bump).
- `82bccd4`**monitorSnapshot hoisted** + polling reset. Server-pushed stats now survive panel close/reopen.
- `7758af7` — Docs: vitest count bump after monitorSnapshot cases.
- `8182e06`**Selection hoisted** (`selectedFurni`, `selectedFurniLiveState`, `selectedUser`, `selectedUserLiveState`, `selectedUserActionVersion`). Listeners (`useObjectSelectedEvent`, per-kind `useMessageEvent` handlers) stay in the component (need React lifecycle) and call store actions. Live-state setters keep the `Updater<T>` shape so the ~10 `previousValue => ...` call sites stay verbatim.
- `50fd908` — Docs: vitest count bump.
- `0fc32a1`**Variable-highlight hoisted** (`isVariableHighlightActive` toggle + `variableHighlightOverlays` screen-coords array). `WiredVariablesTabView` drops two more props. The two screen-coords recompute effects stay in the component (need React lifecycle for `WiredSelectionVisualizer` install/teardown). `variableHighlightObjectsRef` stays as `useRef` (refs don't belong in Zustand).
- `c1aafff` — Docs: vitest count bump.
- `181ca09`**Inline editor hoisted** (`editingVariable` / `editingValue` / `editingManagedHolderVariableId` / `editingManagedHolderValue`). `WiredInspectionTabView` drops three more props. `shouldPauseVariableSnapshotRefresh` still reads from the same store-backed values.
- `438b47d` — Docs: vitest count bump to 193/193.
#### Final picker hoists (2026-05-18 session — three commits closing the roadmap)
- `ba77806`**Variable-key records hoisted** (`selectedInspectionVariableKeys`, `selectedVariableKeys`). Setter shape `Updater<Record<...Type, string>>` because all writers used `prev => ({ ...prev, [type]: key })`. Empty defaults — the existing definition-sync effect at `WiredCreatorToolsView.tsx:1543-1574` populates them on first render. **+4 test cases**.
- `8894fcc`**Inspection give pickers hoisted** (`inspectionGiveVariableItemId`, `inspectionGiveValue`). Plain typed setters, sentinel pair `0`/`'0'`. **+2 test cases**.
- `1c2d8da`**Managed-holder give picker chain hoisted** (`selectedManagedVariableEntry`, `selectedManagedHolderVariableId`, `managedGiveVariableItemId`, `managedGiveValue`). Cascade reset effects at 2265-2307 stay in the component. **+4 test cases**.
**Roadmap result:** every `useState` left in `WiredCreatorToolsView.tsx` after `1c2d8da` is genuinely transient (`keepSelected`, `globalClock`, `roomEnteredAt`, `selectedMonitorErrorType`, `selectedMonitorLogDetails`) — none would benefit from store-backed persistence.
### Phase 5: Typecheck cleanup (Pixi v8, TS 6, framer-motion)
A separate sweep aimed at getting `yarn typecheck` to 0 errors (initial state was ~50+ errors carried over from prior version bumps).
- `b5eeb68` — Typed `framer-motion variants` as `Variants` — killed 33 tsgo errors.
- `96b61ff` — Fixed 4 typecheck errors in `createNitroQuery`.
- `feba672` — Sweep: union expansions + React 19 JSX + extra arg.
- `1083b2e` — Typed `useFurniChooserState` builders + dropped dead `getUserData` guard.
- `a39aa37` — React 19: `useRef<T>() → useRef<T>(null)` across 15 sites (React 19 made this required at the type level).
- `f57266a` — Updated 3 `IGetImageListener.imageReady` call sites to Pixi v8 single-arg signature.
- `a8065f6` — Added optional `clone()` to `IPurchasableOffer`.
- `71a1586` — Stripped dead server-sync from `UiSettingsContext` + re-exported `ui-settings`.
- `0192952` — Sweep: 11 fixes across 9 files.
- `2a9a5dd` — Added `react-colorful` dep for `InterfaceColorTabView`.
- `f09bb7e` — Pixi v8 alignment in 2 room-widget helpers.
- `0c43377` — Dropped dead `await success` on fire-and-forget catalog-admin actions.
- `68de96c` — Last-mile typecheck sweep: 3 small bugs.
### Phase 6: Error boundaries and logic-bug fixes
#### `WidgetErrorBoundary` framework
`src/common/error-boundary/WidgetErrorBoundary.tsx` wraps any in-room widget tree so a crash degrades gracefully (logs to NitroLogger, falls back to `null`). Applied at `RoomWidgetsView` as an umbrella initially:
- `ab93113` — Wrapped each room + furniture widget (13 room widgets + 20 furniture widgets) in its own `WidgetErrorBoundary` so a crash in one widget no longer takes down its siblings.
#### Logic-bug fixes documented during the modernization
- `81656e7` — Fixed two logic bugs found while refactoring + documented the open ones in `docs/ARCHITECTURE.md`.
- `9d10e52`**Fix(MainView):** collapsed CREATED/ENDED listeners into a session-aware reducer. The previous two-effect pattern had a race: a `RoomSessionEvent.ENDED` for a stale session could clear the current session's state if it arrived after `CREATED`. The reducer now compares session tokens.
- `97c9717`**Fix(layout-image):** guarded async image fetch with a `requestIdRef`. Resolved a race where props changed twice in quick succession could land the second fetch's result first, then the first's, overwriting valid data with stale.
- `b01f09c``fix`: null-check the set type before reading `.paletteID` in avatar editor.
### Phase 7: Test infrastructure evolution
- `6793de2` — Vitest setup + 22 initial smoke tests (covered in Phase 2).
- `bb28d25` — +16 cases on ColorUtils / FixedSizeStack / LocalizeFormattedNumber.
- `dbafc97` — Vitest coverage on FriendlyTime; dropped dead login dialogs.
- `b1729d8``dedupeBadges` with 6 cases.
- `3c732f1``avatarInfo` reducers with 14 cases.
- `c401839`**Renderer-SDK mock layer** at `tests/mocks/renderer-mock.ts` (later flattened to `src/nitro-renderer.mock.ts`). Stubs:
- Explicit behavioral stubs for symbols tests actually exercise (`NitroLogger`, `GetEventDispatcher`, `mockEventDispatcher` helpers, `RoomSessionDoorbellEvent`).
- String-keyed Proxy enums for `NitroEventType`, `RoomObjectCategory`, `AvatarFigurePartType`.
- Lightweight `class StubClass {}` placeholders for ~30 Pixi and gameplay classes the `src/api/*` barrel touches.
- Singleton getters returning chainable Proxies.
- First 2 component-/hook-level pilots: `WidgetErrorBoundary` (4 tests) + `useDoorbellState` (7 tests).
- `fd3ef78` — Catalog pure-helper extraction + 34 Vitest cases.
- `8b79233``useCatalogFavorites` pure helpers + 16 Vitest cases.
- `8b4308a`**Tests co-located** under `src/` — every `*.test.ts(x)` moved next to its subject.
- `803de20` — Flattened renderer mock to `src/nitro-renderer.mock.ts` (dropped `__mocks__/`).
### Phase 8: CI pipeline
- `8844cc1``ci`: ran typecheck + Vitest on every push to `main` / `feat/**` and on every PR. Workflow at `.github/workflows/ci.yml`.
- `53fc5f0``ci`: created renderer symlink AFTER `yarn install`, not before (yarn install would otherwise nuke the symlink).
- `5d7a20a``ci`: used absolute symlink target + checked out `feat/react19-event-bus` on the renderer fork.
- `cb7502f``ci`: opted JavaScript actions into Node.js 24.
### Phase 9: Upstream cherry-picks (PR #126) and drive-by bugs
Mid-modernization, two upstream commits from duckietm/Nitro-V3 PR #126 were cherry-picked into the branch so the modernization branch would carry features still pending upstream merge:
- `52b0c90` — Merge commit from PR #126 (merge of upstream into the local branch — at this stage upstream had not yet reached `b2318b9`).
- `53b0c90` `53f41cd` `2053c8e` — Fix wear badge popup + `UserAccountSettingsView` (reset password / email / username under user settings).
- `3a7c9ba` — Same wear-badge popup fix (rebased version).
- `9ef6983` — Post-cherry-pick: restored `useEffectEvent` wrapper + fixed configuration import (the cherry-pick's mechanical drift broke a few wires).
- `622d73c` — Docs: reflected PR #126 cherry-pick + boot/asset infrastructure.
**Boot/asset infrastructure** went in here too:
- `45620ca``vite`: actually split the renderer into its own chunk.
- `cd8951e``dev`: served game assets via `sirv` plugin and pre-init configuration. The chokidar-on-177k-files problem on Windows: serving game assets through `sirv` middleware mounted on `/nitro-assets` and `/swf` reading from `E:\Users\simol\Desktop\DEV\Nitro-Files\` bypasses chokidar entirely. The plugin also wires the same handler into `configurePreviewServer`. Same commit introduced `await GetConfiguration().init()` in `src/bootstrap.ts` before importing `./index` — otherwise the first paint flooded with "Missing configuration key" warnings while components synchronously read keys against an empty store.
- `35b8493``vite`: failed fast with a setup hint when the renderer SDK is missing.
- `8e0bcce` — Added `yarn preview` script for serving the production build.
#### Other Phase 9 commits
- `7cf01b0` — Docs: refresh ARCHITECTURE + CLAUDE.
- `cc225bd` — Docs: comprehensive refresh after React 19 modernization round.
### Phase 10: Toolbar spam-toggle fix (PR #130 upstream)
`4ab38d3`**toolbar: always-mount nav rows + drive show/hide via framer variants**. Replaced the outer `AnimatePresence` wrapper around the four toolbar rows (desktop backplate, left-nav, right-nav, mobile-nav) with always-mounted `motion.div` elements driven by an `isVisible`-derived variant string (`'visible'` or `'hidden'`).
**The bug it fixed:** rapid clicks on the show/hide chevron previously left motion children in inconsistent intermediate states (stuck opacity 0, phantom scale 0.8) because `AnimatePresence + Fragment + multiple keyed children` breaks when enter/exit cycles overlap. With variants, framer-motion's spring solver picks up from the current animated value on each retarget, so spam-clicking just settles smoothly toward whichever target is current.
Refactor details:
- `containerVariants` dropped its `'exit'` state (now lives in `'hidden'`).
- `itemVariants` dropped `'exit'` as well.
- New `shellVariants` for the backplate.
- `pointer-events` animated per-variant (`'auto'` visible / `'none'` hidden) instead of pinned via a Tailwind class, so hidden rows don't intercept clicks.
- Wrapper variants computed inside the component because `leftNavVariants.hidden` depends on `isInRoom` (nav slides in from the side in-room, from the bottom otherwise).
- Variant inheritance: outer wrapper drives `'visible'`/`'hidden'`; inner container and items inherit via framer's variant propagation, so stagger runs in both directions without needing `AnimatePresence`.
- Inner `AnimatePresence` around the Me popover stays (it has a single child, no spam-toggle risk).
The same fix opened upstream as **PR #130** on `duckietm/Nitro-V3` (branch `simoleo89:fix/toolbar-spam-show-hide`).
### Phase 11: Full upstream sync (origin/Dev b2318b9)
After Phase 10, the local branch was 98 commits ahead of `origin/Dev`. Upstream had 10 commits the branch needed to absorb. Done in the 2026-05-18 session.
- Tagged rollback: `pre-upstream-merge-20260518` at `4ab38d3`.
- `git merge --no-ff origin/Dev` produced 6 conflicts in `package.json`, `src/App.tsx`, `src/bootstrap.ts`, `src/components/login/LoginView.tsx`, `src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx`, `vite.config.mjs`, `yarn.lock`.
- Resolution: kept modernized structure on the conflict files, surgically applied upstream intent where it added value (`json5` dep, `JSON5.parse` fallback in `bootstrap.ts`, `base` config in `vite.config.mjs`). The `persistAccessTokenFromPayload(payload)` upstream fix in `LoginView.tsx` was already present in the modernized Form Actions SSO branch — no work needed.
- Two user-settings views auto-merged because the local branch had already cherry-picked the same commit (`cdf8d92` upstream = `2053c8e` local byte-for-byte).
- Five files came in upstream-only with no local divergence.
- `yarn.lock` regenerated via `git checkout --ours yarn.lock` then `yarn install`.
Verification: typecheck clean, Vitest 193/193 (preserved baseline), `yarn build` green.
Commit `779a98c` — merge commit. Followed by `3b35fa9` — CLAUDE.md refresh (TL;DR + wired-up table updated to reflect post-merge state with note about expected future conflict surface).
### Phase 12: Snapshot pattern consumer-side wiring + first migrations
After Phase 11 closed the WiredCreatorTools roadmap, the renderer side had six snapshot getters (Session, RoomSession, IgnoredUsers, GroupBadges, RoomUserList, SoundVolumes) but nothing on the client consumed them — `useExternalSnapshot` existed as a `useSyncExternalStore` wrapper, no widget was wired up to a snapshot.
#### `b2a86da` — React-side consumer hooks for the renderer snapshot pattern
New file [`src/hooks/session/useSessionSnapshots.ts`](../src/hooks/session/useSessionSnapshots.ts) exposes eight thin hooks, each a `useExternalSnapshot` wrapper around the matching subscribe + getter pair:
| Hook | Returns |
|---|---|
| `useUserDataSnapshot()` | `Readonly<IUserDataSnapshot>` |
| `useActiveRoomSessionSnapshot()` | `Readonly<IRoomSessionSnapshot> \| null` |
| `useIgnoredUsersSnapshot()` | `ReadonlyArray<string>` |
| `useIsUserIgnored(name)` | `boolean` (memoized) |
| `useGroupBadgesSnapshot()` | `ReadonlyMap<number, string>` |
| `useGroupBadge(groupId)` | `string` (memoized) |
| `useVolumesSnapshot()` | `Readonly<ISoundVolumesSnapshot>` |
| `useRoomUserListSnapshot()` | `ReadonlyArray<IRoomUserData>` |
Two design subtleties documented inline:
- `useRoomUserListSnapshot` subscribes to BOTH `ROOM_USER_LIST_UPDATED` (for join/leave/update inside the active session) AND `ROOM_SESSION_UPDATED` (because the underlying `userDataManager` reference flips when the active room changes). A module-level frozen `EMPTY_USER_LIST` is the fallback when no session is active, keeping reference stability across reads in the no-room state.
- `useIsUserIgnored` / `useGroupBadge` memoize the scalar derivation so a re-render only fires when the underlying snapshot reference flips, not on unrelated `useExternalSnapshot` wake-ups.
#### `71a0eee` — Migrate useSessionInfo to useUserDataSnapshot
First consumer migration. The old `useSessionInfo` carried three `useState` mirrors of session data (`userFigure` / `userRespectRemaining` / `petRespectRemaining`) driven by `useMessageEvent<UserInfoEvent>` + `useMessageEvent<FigureUpdateEvent>` + manual `setUser…` after `giveRespect`. Replaced with a single `useUserDataSnapshot()` read. `SessionDataManager` already invalidates its snapshot on every state change that mattered to the old hook (UserInfoEvent handler, FigureUpdateEvent listener, `giveRespect` / `givePetRespect`) — so the snapshot is a strict superset of the manual mirror.
Net result: 3 useState declarations + 2 useMessageEvent subscriptions removed; `respectUser` / `respectPet` become trivial pass-throughs because the manager's invalidate dispatches the event for us. `chatStyleId` stays on `useState` (driven by `UserSettingsEvent`, not in the snapshot). The deprecated `userInfo: UserInfoDataParser` field is dropped from the return shape — no in-tree consumer reads it.
#### `36addbe` — Reactive Ignore/Unignore menu entry via useIsUserIgnored
The Ignore ↔ Unignore context-menu entry in `AvatarInfoWidgetAvatarView` was driven by `avatarInfo.isIgnored` — a boolean captured by `AvatarInfoUtilities` once, at the time the avatar was clicked. If the user got ignored / unignored *while the popup was already open* (e.g. via the friends panel, or because a server push flipped the state), the menu kept showing the stale option and clicking it would no-op (or worse, double-ignore).
Switched the menu items to `useIsUserIgnored(avatarInfo.name)` — the reactive hook backed by `IgnoredUsersManager.getIgnoredUsersSnapshot()` + `NitroEventType.IGNORED_USERS_UPDATED`. The menu now flips automatically the moment the ignore list changes, without re-opening.
#### `02a396d` — docs(CLAUDE.md): refresh stale sections
Aligning `Nitro-V3/CLAUDE.md` with the current branch state:
- Adopted table: new row for the snapshot consumer hooks (pilots on `useSessionInfo` + `AvatarInfoWidgetAvatarView`); Vitest count bumped 193 → 203; Zustand row expanded to note the WiredCreatorTools panel-lifecycle hoist roadmap is fully closed.
- Not yet table: dropped the obsolete "hoist Wired Creator Tools derived state" row; added a new row for migrating remaining session-data mirrors.
- New "Patterns to use" entry at the top documenting the 8-hook `useSessionSnapshots` menu.
- "Known open logic bugs" replaced with a "no open bugs" entry (both previously-open races are closed in `9d10e52` and `97c9717`).
---
## 3. Nitro_Render_V3 renderer — branch story
### Phase 1: v2.1.0 React-friendly API additions
`87cf478`**feat(events,session): add React-friendly subscribe APIs and snapshot getters**. The single foundational commit that gave the branch its name. Backwards-compatible additions needed to consume the renderer from React 19 hooks (`useSyncExternalStore`, `use()`, TanStack Query):
- `EventDispatcher.subscribe(type, cb): () => void` — unsubscriber-returning wrapper matching the `useSyncExternalStore` subscribe signature. Legacy `addEventListener` / `removeEventListener` still work.
- `CommunicationManager.subscribeMessage(eventCtor, handler): () => void` — packet-stream equivalent.
- `SessionDataManager.getUserDataSnapshot(): Readonly<IUserDataSnapshot>` — referentially-stable read-only view invalidated through the new `SESSION_DATA_UPDATED` event.
- `RoomSessionManager.getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> | null` — same pattern, invalidated through `ROOM_SESSION_UPDATED`.
The interface contracts:
- `packages/api/src/nitro/session/IUserDataSnapshot.ts`
- `packages/api/src/nitro/session/IRoomSessionSnapshot.ts`
Bumped renderer to **2.1.0**.
### Phase 2: TypeScript 6 + tsgo migration
- `c7a5aea` — Bumped TypeScript `5.8 → 6.0`. Added `@typescript/native-preview` (tsgo, the TS 7 preview compiler) as `yarn compile:fast` (~7× faster: 2.5s vs 17.6s). Tsconfig cleanup ahead of TypeScript 7 deprecations:
- Removed `baseUrl` (unused).
- Removed `downlevelIteration` (target ES2022 makes it a no-op).
- `moduleResolution`: `"Node"``"bundler"`.
- Compile errors: 28 → 29 (TS 6's tightened lib types flagged two pre-existing `crypto` calls).
- `ddb7222` — Bumped TypeScript pins to `^6.0.3` across all 12 workspaces + thumbmarkjs 1.9. Added `CLAUDE.md` to the renderer.
- `e82d3e0``chore(types)`: augmented `ImportMeta` with `glob` signature.
### Phase 3: API interface alignments (IRoomSession)
`afb5f33`**fix(api): IRoomSession.password + sendBackgroundMessage + optional chatColour**. The `IRoomSession` interface was missing three things that always existed on the `RoomSession` implementation class:
- `password: string` — the room session's join password (used by the reconnect flow in `RoomSessionManager`).
- `sendBackgroundMessage(backgroundImage, backgroundStand, backgroundOverlay, backgroundCard?)` — sends the profile-background composer (used by the React client's `BackgroundsView`).
- `sendChatMessage` / `sendShoutMessage` `chatColour` parameter relaxed to optional. The implementation already accepted `undefined`; every historical call site in the React client passes only 2 args.
Net renderer typecheck: 26 → 23. Dropped 7 errors on the consumer side after the workspace link picked up the change.
### Phase 4: TS 5.7+ ArrayBuffer drift fixes
`c37171a`**TS 5.7+ ArrayBuffer drift: cast where ArrayBufferLike leaked**. The renderer never uses `SharedArrayBuffer`, so the type-level narrowings are safe. Sites cast:
- `BinaryReader` / `BinaryWriter``getBuffer()` / `toArrayBuffer()`.
- `WsSessionCrypto.randomNonce()`.
- `ArrayBufferToBase64`.
### Phase 5: Pixi v8 alignment
`5ea3201`**Align with Pixi v8: Filter[] union, WebGLRenderer narrow, ImageLike**. Four sites where Pixi v8's stricter typing tripped tsgo:
- `AvatarImage`: `container.filters` is `readonly Filter[] | null` in v8. Old fallback branch `else container.filters = [container.filters, …]` tried to treat a readonly array as a single Filter. Collapsed to the array-spread path which covers both undefined and non-empty cases.
- `FurnitureBadgeDisplayVisualization.updateSprite()` had a 4-arg override `(sprite, asset, scale, layerId)` of the parent's 2-arg `(scale, layerId)` signature. Refactored to fetch the sprite via `this.getSprite(layerId)` inside the override body.
- `ExtendedSprite`: `renderer.gl` / `glRenderTarget.resolveTargetFramebuffer` exist only on `WebGLRenderer` / `GlRenderTarget`. The runtime check `renderer.type === RendererType.WEBGL` guarantees this; cast at the boundary.
- `TextureUtils.generateImage`: Pixi v8's `Extractor.image()` returns the union `ImageLike` (`HTMLCanvasElement | HTMLImageElement`); the public signature promises `HTMLImageElement`. Cast at return.
### Phase 6: Composer/parser alignment with Arcturus
- `b42f989`**RoomEnterComposer: optional `spawnX`/`spawnY` for reconnect**. Arcturus' `RequestRoomLoadEvent` reads the two extra ints only when the inbound packet has 8+ bytes remaining, so the renderer can send 2-arg or 4-arg payloads against the same header. The client already called the 4-arg variant in two places inside `RoomSession`/`RoomSessionManager` — the composer signature was lagging behind.
- `0fc38a1` — Fixed self-referential `ConstructorParameters` in two Wired composers (`WiredRoomSettingsRequestComposer`, `WiredUserVariablesRequestComposer` — empty-tuple composers needed explicit `getMessageArray(): []` annotation).
- `999b818` — Fixed `PetBreedingMessageParser.bytesAvailable < 12` (bytesAvailable is a boolean, not a byte count — the old code compared it against `12` which TS 6 caught).
- `ef6c661`**Renderer: surface `allowUnderpass` on RoomSettingsData + composer**. Arcturus' `RoomSettingsComposer` appends an extra int at the end of the payload — `room.isAllowUnderpass() ? 1 : 0` — and `RoomSettingsSaveEvent` optionally reads back a boolean at the end (`if(bytesAvailable > 0)`). The renderer side never modeled this trailing field. Added the field + parser guard + optional trailing composer arg. Net client tsgo error count: 3 → 0 on the NavigatorRoomSettings cluster.
- `22d4e5b` — SocketConnection parser cast + RoomChatHandler arg-order fix.
- `f7a5897` — Renderer: aligned `NitroConfig` Window declaration with the client + fixed glob `.default` access.
### Phase 7: Dead code removal and small fixes
- `08d1efa`**Drop dead `sendWhisperGroupMessage`**. `IRoomSession.sendWhisperGroupMessage(userId)` referenced a `ChatWhisperGroupComposer` that never existed in the codebase and had zero call sites in the React client. Both the interface declaration and the broken impl removed. The real whisper path is `RoomUnitChatWhisperComposer(recipientName, message, styleId)` — unchanged.
- `5f5ba2f` — Docs: documented recent `feat/react19-event-bus` additions in CLAUDE.md.
### Phase 8: Upstream sync (origin/main)
Done in the 2026-05-18 session. Zero file intersection between the 15 local commits and the 1 non-merge upstream commit (`b6a26fb` — small landscape-offset fix in `RoomPlane.ts`).
- Tagged rollback: `pre-upstream-merge-20260518` at `5f5ba2f`.
- `git merge --no-ff origin/main` auto-completed with no conflict prompts.
- Commit `820f791`.
- Verification: `yarn compile:fast` clean, Vitest 104/104.
### Phase 9: Snapshot pattern extensions
Five commits in the 2026-05-18 session extending the v2.1.0 snapshot pattern to four new state holders.
#### `98662e7` — BinaryReader / BinaryWriter round-trip Vitest coverage (23 cases)
Added comprehensive round-trip tests under `packages/utils/src/__tests__/BinaryReader.test.ts`:
- byte / short / int round-trips, including signed-edge values (int8 -1 from 0xFF, int16 / int32 boundaries)
- big-endian wire-order assertions on `writeShort` / `writeInt` (matches Arcturus's `DataInputStream`)
- string round-trip with length prefix + bare (`includeLength=false`) + UTF-8 multibyte byte count + empty-string edge
- `writeBytes` for both `number[]` and `ArrayBuffer` payloads
- `readBytes` slice returns an independent reader whose position is decoupled from the outer reader
- `remaining()` decrements correctly across mixed-size reads
- `readFloat` / `readDouble` decode IEEE-754 big-endian values (the writer has no float/double counterparts — buffer built via `DataView` for these cases)
- writer `position` getter + explicit setter (caller-managed reposition)
- two independent writers concatenate cleanly into a single reader
**Note:** retrospectively deprioritized — mid-session the user redirected with "anche se renderer devi ragione la ui, niente _tests_". Pure Vitest coverage growth on the renderer is no longer considered modernization progress; UI-affecting changes (rendering, API surface, perf) are preferred. The test commit was kept (no regression, useful safety net for any future BinaryReader changes) but flagged as not the kind of work to repeat by default. A feedback memory entry (`feedback_renderer_ui_over_tests.md`) captures this preference.
#### `a599e0c` — feat(session): snapshot getters for IgnoredUsersManager + GroupInformationManager
**`IgnoredUsersManager.getIgnoredUsersSnapshot(): ReadonlyArray<string>`** — wrapped the existing `_ignoredUsers: string[]` with a snapshot getter. Invalidation hooked into:
- `onIgnoredUsersEvent` (initial server-side list fetch).
- `addUserToIgnoreList` (ignore action result code 1 and 2).
- `removeUserFromIgnoreList` (unignore action result code 3).
- After the special `_ignoredUsers.shift()` operation that runs alongside `addUserToIgnoreList` for result code 2 (queue truncation) — added explicit `invalidateIgnoredUsersSnapshot()` after the shift so the dispatched event fires only once the truncation is complete.
**`GroupInformationManager.getGroupBadgesSnapshot(): ReadonlyMap<number, string>`** — wrapped `_groupBadges: Map<number, string>` with a snapshot getter. The `onGroupBadgesEvent` handler now compares each incoming badge against the cached value and only flips a `didChange` flag if an entry is new or actually changed. Invalidation fires only when `didChange === true`.
New `NitroEventType` members: `IGNORED_USERS_UPDATED`, `GROUP_BADGES_UPDATED`.
#### `761d8ff` — feat(session): snapshot getter for UserDataManager room user list
**`UserDataManager.getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData>`** — the biggest snapshot in terms of invalidation surface. 11 mutation paths all wired:
`updateUserData`, `removeUserData`, `updateFigure`, `updateName`, `updateMotto`, `updateNickIcon`, `updateCustomization`, `updateBackground`, `updateAchievementScore`, `updatePetLevel`, `updatePetBreedingStatus`.
Design decision — **no deep-clone**: the inner `IRoomUserData` objects keep the existing in-place mutation semantics. Deep-cloning a snapshot of 30+ avatars on every server-pushed status event would defeat the snapshot's purpose. TSDoc on the interface explicitly documents that consumers should treat each entry as a *snapshot-at-time-of-read* and not retain references across invalidations.
Drive-by cleanup: `updatePetLevel` previously used an inline conditional; rewritten to use the explicit `if(!userData) return;` guard pattern shared by the surrounding methods.
New `NitroEventType` member: `ROOM_USER_LIST_UPDATED`.
#### `892d16b` — feat(sound): snapshot getter + volume-update event on SoundManager + bug fix
**`SoundManager.getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot>`** — new `ISoundVolumesSnapshot { system, furni, trax }` interface. New `systemVolume` / `furniVolume` getters for parity with the pre-existing `traxVolume`.
**Drive-by bug fix — volume diff comparison was always wrong.** The previous `onEvent(SETTINGS_UPDATED)` handler compared `castedEvent.volumeFurni` (percent, e.g. `75`) against `this._volumeFurni` (the already-divided fraction, e.g. `0.75`). The check almost never reported "unchanged" for any real settings push. Both `updateFurniSamplesVolume` and `_musicController.updateVolume` were being called on every settings push regardless of whether the volume actually changed.
Fix: divide first into local variables, compare divided values against the stored fractions, then write. Also tracks `volumeSystemUpdated` for the new snapshot's invalidation event.
New `NitroEventType` member: `SOUND_VOLUMES_UPDATED`.
#### `d740f83` — refactor(parsers): flatten nested bytesAvailable guards
Two parsers had nested `if(wrapper.bytesAvailable)` chains making each new optional trailing block sit one extra indent deeper than the previous:
- **`UserProfileParser`** — 4 optional trailing tiers (background/stand/overlay 3 ints, cardBackgroundId 1 int, nickIcon 1 string, prefix decoration set 6 strings). Previously 4 levels of nested `if` with an inline ternary mid-block for cardBackgroundId. Refactored to a flat early-return chain.
- **`GetGuestRoomResultMessageParser`** — 2 optional trailing tiers (hotelTimeZoneId + hotelCurrentTimeMs 2 strings, roomItemLimit 1 int). Previously 2 levels of nested `if`. Refactored to flat early-return.
Both files now follow the canonical pattern:
```ts
if(!wrapper.bytesAvailable) return true;
// block N reads
if(!wrapper.bytesAvailable) return true;
// block N+1 reads
```
Each block documented inline so the contract is obvious without cross-referencing Arcturus. Adding tier N+1 is now purely additive — no re-indentation of existing blocks.
An audit across all 29 parsers using `bytesAvailable` found exactly these two files with nested-guard chains. All other parsers either use a single optional trailing field or already used the flat pattern (`RoomUnitInfoParser` was the reference).
#### `28c552f` — docs(CLAUDE.md): document new snapshot getters + flat bytesAvailable pattern
Replaced the two-getter SessionData / RoomSession snapshot description in `Nitro_Render_V3/CLAUDE.md` with a six-row table covering every snapshot currently exposed:
| Manager | Getter | Invalidation event |
|---|---|---|
| `SessionDataManager` | `getUserDataSnapshot(): Readonly<IUserDataSnapshot>` | `SESSION_DATA_UPDATED` |
| `RoomSessionManager` | `getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> \| null` | `ROOM_SESSION_UPDATED` |
| `IgnoredUsersManager` | `getIgnoredUsersSnapshot(): ReadonlyArray<string>` | `IGNORED_USERS_UPDATED` |
| `GroupInformationManager` | `getGroupBadgesSnapshot(): ReadonlyMap<number, string>` | `GROUP_BADGES_UPDATED` |
| `UserDataManager` | `getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData>` | `ROOM_USER_LIST_UPDATED` |
| `SoundManager` | `getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot>` | `SOUND_VOLUMES_UPDATED` |
Plus a 3-step checklist for adding new ones and a dedicated section on the flat `bytesAvailable` early-return pattern as the canonical shape for optional-trailing-field parsers.
---
## 4. Arcturus emulator — upstream pull
In the same session, the Java emulator was brought from `e6093f9` (v4.1.14) to `efb4997` (v4.1.16) via a fast-forward pull from `origin/main` (duckietm). Zero local divergence existed; the FF pull absorbed 8 upstream commits:
- Version bumps to 4.1.15 and 4.1.16.
- Database migrations 000-019 reorganized under `Database Updates/Own_Database_RunFirst/`, with new files `010_Wired_Update.sql`, `018_Last_Username_Change.sql`, and a renamed `019_custom_nick_login_tokens_wired_message.sql`.
- New auth-related Java classes: `AccountChangeEndpoints`, `AccountCheckEndpoints`, `AuthHttpUtil`, `CorsOriginGate`, `RegistrationSupport`, `SessionEndpoints`, `StaticContentEndpoints`.
- `ChangeNameCommand.java` removed (replaced by API endpoints).
- Updates to `AboutCommand`, `CommandHandler`, `GuildManager`, multiple guild event handlers, `WebSocketChannelInitializer`.
- Default DB file renamed `FullDB.sql``FullDatabase.sql`.
Local working-tree modifications (customized `config.ini.example` shorter inline comments; untracked `Habbo-4.1.15-jar-with-dependencies.jar` and `emulator.cmd`) survived the pull intact. No push performed (local tracks `origin/main` directly).
Rollback tag: `pre-upstream-pull-20260518` at `e6093f9`.
---
## 5. Documentation evolution (CLAUDE.md / ARCHITECTURE.md)
Both branches maintained substantial in-tree documentation throughout their lifetime.
### Nitro-V3
**`docs/ARCHITECTURE.md`** (introduced in `48d62c5`) — Living long-form document describing where the project stands, the five structural proposals, and the next-PR recommended order. Updated across multiple commits as proposals landed:
- `0755285` — recorded the feature-folder reversion.
- `7218285` — proposal #4 landed (poll-widget split).
- `f1af6fb` — pattern #1 companions implemented, pilots adopted.
- `7cf01b0`, `cc225bd`, `622d73c` — comprehensive refresh sweeps.
**`Nitro-V3/CLAUDE.md`** (added in `f75762a`) — Project context summarized for Claude Code sessions. Refreshed across the modernization:
- `eb8d879`, `7758af7`, `50fd908`, `c1aafff`, `438b47d` — vitest count bumps after each hoist.
- `3b35fa9` — post-upstream-merge refresh.
### Nitro_Render_V3
**`Nitro_Render_V3/CLAUDE.md`** (added in `ddb7222`) — Renderer context for Claude Code sessions. Refreshed:
- `5f5ba2f` — documented feat/react19-event-bus additions.
- `28c552f` — documented new snapshot getters + flat bytesAvailable pattern.
---
## 6. Full commit index
### Nitro-V3 — `feat/react19-modernization` (109 commits, baseline `ae17619`)
#### Phase 1: React 19 baseline
| SHA | Subject |
|---|---|
| `cdf8d92` | 🆕 Added Reset password / Email and change username in user settings (upstream) |
| `a1bee1d` | React 19 modernization: forwardRef removal, Compiler, ErrorBoundary, Suspense, native <script> |
| `1b1e0c1` | React 19 Phase 3: login/forgot/register forms → useActionState + useFormStatus |
| `535fa71` | ESLint --fix: auto-fix brace-style, indent, semi, no-trailing-spaces |
| `25d51af` | Enable `<StrictMode>` + make App.tsx renderer init idempotent |
| `13dc483` | Bump ecosystem dependencies (minor/patch) |
| `5697d16` | Fix rules-of-hooks violation in InfiniteGrid |
| `6c9f414` | Apply useEffectEvent (React 19.2) to TurnstileWidget callbacks |
| `f18c917` | Add TypeScript 7 (tsgo) as fast type-checker alongside TS 6 |
| `d382635` | Phase A: clear all react-hooks/exhaustive-deps warnings via useEffectEvent or hoisting |
| `39eb2c6` | Phase C (targeted): clear 4 set-state-in-effect violations on safe candidates |
#### Phase 2: Infrastructure pillars
| SHA | Subject |
|---|---|
| `5d8717d` | Split WiredCreatorToolsView: extract types/constants/helpers into 3 sibling files |
| `22a44d1` | Add useNitroEventState / useMessageEventState hooks (proposal #1) |
| `48d62c5` | Architecture refactor: docs + 5 pilot implementations + error boundary |
| `81656e7` | Fix two logic bugs found while refactoring + document the open ones |
| `0755285` | Revert feature-folder migration; keep classic src/components + src/hooks layout |
| `34b1b56` | Enable React Query (proposal #2) + first real-data pilot on OfferView |
| `fd1835c` | Enable Zustand (proposal #5) + convert isCreatingRoom singleton |
| `6793de2` | Set up Vitest + 22 smoke tests on pure modules (proposal #6) |
| `7218285` | Split usePollWidget into subscriptions + actions (proposal #4) + doc update |
| `419de09` | Hoist usePollSubscriptions to RoomWidgetsView; drop the side effect from usePollWidget |
| `9d2e4a7` | Expand Vitest coverage on the pure helpers in src/api/{utils,wired} |
| `388fb8e` | Migrate CatalogLayoutRoomAdsView's room-ad fetch to useNitroQuery |
| `bf84a0c` | useNitroQuery: add accept() predicate; migrate two mod-tools chatlog views |
| `bb28d25` | Vitest: +16 cases on ColorUtils, FixedSizeStack, LocalizeFormattedNumber |
| `dbafc97` | Drop unused login dialogs (dead code) + Vitest coverage on FriendlyTime |
| `f75762a` | Add CLAUDE.md + refresh docs/ARCHITECTURE.md to current state |
| `bb1238a` | Add useExternalSnapshot + useNitroEventReducer + useMessageEventReducer hooks |
#### Phase 3: God-hook splits + tab extractions
| SHA | Subject |
|---|---|
| `559d860` | Pilot: move InfoStand event listeners to useAvatarInfoWidget owner |
| `8b7bedf` | Pilot: extract useInventoryFurni reducers to a pure module |
| `b1729d8` | Vitest: cover dedupeBadges with 6 cases |
| `f1af6fb` | docs: ARCHITECTURE pattern #1 — companions implemented, pilots adopted |
| `8e4544c` | Migrate catalog giftConfiguration to useNitroQuery |
| `23fc302` | Extract Variables tab JSX into WiredVariablesTabView component |
| `d7d9a7e` | Extract Inspection tab JSX into WiredInspectionTabView component |
| `bb09a56` | Extract Monitor tab JSX into WiredMonitorTabView + drop dead overlays |
| `0ae371e` | Split useFurniChooserWidget into state + actions (flat hooks layout) |
| `85fc827` | Split useUserChooserWidget into state + actions (flat hooks layout) |
| `f3442f8` | Split useFriendRequestWidget into state + actions (flat hooks layout) |
| `a4c9dd8` | Split useChatInputWidget into state + actions (flat hooks layout) |
| `e1f5df6` | Split useWiredTools into state + actions via useBetween singleton |
| `eeb9cc6` | Split useTranslation into state + actions via useBetween singleton |
| `5344eaf` | Split useNotification into state + actions via useBetween singleton |
| `9f3cd9b` | Split useFriends into state + actions via useBetween singleton |
#### Phase 4: useNitroQuery widening + catalog split
| SHA | Subject |
|---|---|
| `2d9785e` | useUserGroups: consolidate 4 dedup'd CatalogGroupsComposer call sites |
| `2a5b9a4` | useClubOffers: per-windowId TanStack query for HC offer pages |
| `3947781` | useSellablePetPalette(breed): per-breed TanStack query for pet picker |
| `9a807bf` | useMarketplaceConfiguration: lift the marketplace config self-fetch |
| `7b06229` | useClubGifts + useNitroEventInvalidator: close the catalogOptions bag |
| `8b79233` | Extract useCatalogFavorites pure helpers + 16 Vitest cases |
| `fd3ef78` | catalog: extract pure helpers + 34 cases, consume them from useCatalog |
| `59d6c4c` | catalog: three-way singleton-filter split + first 3 consumer migrations |
| `0f9fa12` | catalog: migrate remaining 36 useCatalog() consumers to the three filters |
#### Phase 5: Typecheck cleanup
| SHA | Subject |
|---|---|
| `b5eeb68` | Type framer-motion variants as Variants — kill 33 tsgo errors |
| `96b61ff` | Fix 4 typecheck errors in createNitroQuery |
| `feba672` | Sweep small typecheck nits: union expansions + React 19 JSX + extra arg |
| `1083b2e` | Type useFurniChooserState builders + drop dead getUserData guard |
| `a39aa37` | React 19: useRef<T>() -> useRef<T>(null) across 15 sites |
| `f57266a` | Update 3 IGetImageListener.imageReady call sites to v8 single-arg signature |
| `a8065f6` | Add optional clone() to IPurchasableOffer |
| `71a1586` | Strip dead server-sync from UiSettingsContext + re-export ui-settings |
| `0192952` | Sweep targeted typecheck errors: 11 fixes across 9 files |
| `2a9a5dd` | Add react-colorful dep for InterfaceColorTabView |
| `f09bb7e` | Pixi v8 alignment in 2 room-widget helpers |
| `0c43377` | Drop dead 'await success' on fire-and-forget catalog-admin actions |
| `68de96c` | Last-mile typecheck sweep: 3 small bugs |
#### Phase 6: Logic-bug fixes + WidgetErrorBoundary
| SHA | Subject |
|---|---|
| `9d10e52` | fix(MainView): collapse CREATED/ENDED listeners into a session-aware reducer |
| `97c9717` | fix(layout-image): guard async image fetch with a request-id ref |
| `ab93113` | widgets: wrap each room + furniture widget in its own WidgetErrorBoundary |
| `b01f09c` | fix: null-check the set type before reading .paletteID in avatar editor |
#### Phase 7: Test infrastructure evolution
| SHA | Subject |
|---|---|
| `c401839` | tests: add renderer-SDK mock layer + first 2 component-/hook-level pilots |
| `3c732f1` | Vitest +14 cases on avatarInfo reducers |
| `8b4308a` | tests: co-locate every Vitest suite next to its subject under src/ |
| `803de20` | tests: flatten renderer mock to src/nitro-renderer.mock.ts (drop __mocks__/) |
#### Phase 8: CI
| SHA | Subject |
|---|---|
| `8844cc1` | ci: run typecheck + Vitest on every push to main/feat/** and on every PR |
| `53fc5f0` | ci: create renderer symlink after yarn install, not before |
| `5d7a20a` | ci: use absolute symlink target + check out feat/react19-event-bus on the renderer fork |
| `cb7502f` | ci: opt the JavaScript actions into Node.js 24 |
#### Phase 9: PR #126 cherry-pick + asset infrastructure
| SHA | Subject |
|---|---|
| `35b8493` | vite: fail fast with a setup hint when the renderer SDK is missing |
| `53f41cd` | 🆙 Fix wear badge in popup |
| `52b0c90` | Merge pull request #126 from duckietm/Dev |
| `45620ca` | vite: actually split the renderer into its own chunk |
| `cd8951e` | dev: serve game assets via sirv plugin and pre-init configuration |
| `2053c8e` | 🆕 Added Reset password / Email and change username in user settings |
| `3a7c9ba` | 🆙 Fix wear badge in popup |
| `9ef6983` | post cherry-pick: restore useEffectEvent wrapper + fix configuration import |
| `622d73c` | docs: reflect PR #126 cherry-pick + boot/asset infrastructure |
| `8e0bcce` | Add yarn preview script for serving the production build |
| `7cf01b0` | docs: refresh ARCHITECTURE + CLAUDE with this session's work |
| `cc225bd` | docs: comprehensive refresh after the React 19 modernization round |
#### Phase 10: WiredCreatorTools Zustand hoists + Toolbar fix
| SHA | Subject |
|---|---|
| `c16ac1d` | wired-tools: hoist UI-only state flags to Zustand store |
| `eb8d879` | docs(claude): record wiredCreatorToolsUiStore adoption + new test count |
| `82bccd4` | wired-tools: hoist monitorSnapshot + polling reset to the Zustand store |
| `7758af7` | docs(claude): bump vitest count to 181/181 after monitorSnapshot cases |
| `8182e06` | wired-tools: hoist inspection selection (+ live state + action version) to the store |
| `50fd908` | docs(claude): bump vitest count to 187/187 after selection-hoist cases |
| `0fc32a1` | wired-tools: hoist variable-highlight toggle + overlays to the store |
| `c1aafff` | docs(claude): bump vitest count to 190/190 after highlight-hoist cases |
| `181ca09` | wired-tools: hoist inline editor state (variables + managed holder) to the store |
| `438b47d` | docs(claude): bump vitest count to 193/193 after editor-hoist cases |
| `4ab38d3` | toolbar: always-mount nav rows + drive show/hide via framer variants |
#### Phase 11: Upstream sync + final picker hoists (2026-05-18 session)
| SHA | Subject |
|---|---|
| `e209146` | 🆙 Update About screen (needs a emu change as well) (upstream) |
| `b2318b9` | 🆕 Added support for JSON5 (upstream) |
| `779a98c` | merge: sync upstream duckietm/Dev (b2318b9) into feat/react19-modernization |
| `3b35fa9` | docs(CLAUDE.md): refresh upstream-sync status after merging origin/Dev b2318b9 |
| `ba77806` | wired-tools(store): hoist variable-key records (selectedInspectionVariableKeys, selectedVariableKeys) |
| `8894fcc` | wired-tools(store): hoist inspection give pickers (inspectionGiveVariableItemId, inspectionGiveValue) |
| `1c2d8da` | wired-tools(store): hoist managed-holder give picker chain |
#### Phase 12: Snapshot consumer-side wiring + first migrations
| SHA | Subject |
|---|---|
| `e7e8bcc` | docs: full changelog for feat/react19-modernization + feat/react19-event-bus |
| `b2a86da` | feat(hooks/session): React-side consumer hooks for the renderer snapshot pattern |
| `71a0eee` | refactor(hooks/session): migrate useSessionInfo to useUserDataSnapshot |
| `36addbe` | fix(avatar-info): reactive Ignore/Unignore menu entry via useIsUserIgnored |
| `02a396d` | docs(CLAUDE.md): refresh stale sections — snapshot consumer hooks + closed bugs |
### Nitro_Render_V3 — `feat/react19-event-bus` (22 commits, baseline `98b03aa`)
| SHA | Subject |
|---|---|
| `87cf478` | feat(events,session): add React-friendly subscribe APIs and snapshot getters |
| `c7a5aea` | chore(ts): bump TypeScript 5.8 → 6.0 and add tsgo for fast type-checking |
| `ddb7222` | chore: bump TypeScript pins to ^6.0.3 across all 12 workspaces + thumbmarkjs 1.9 + add CLAUDE.md |
| `e82d3e0` | chore(types): augment ImportMeta with glob signature |
| `afb5f33` | fix(api): IRoomSession.password + sendBackgroundMessage + optional chatColour |
| `c37171a` | TS 5.7+ ArrayBuffer drift: cast where ArrayBufferLike leaked |
| `08d1efa` | Drop dead sendWhisperGroupMessage — composer never existed |
| `0fc38a1` | Fix self-referential ConstructorParameters in two Wired composers |
| `999b818` | Fix PetBreedingMessageParser bytesAvailable check |
| `b42f989` | RoomEnterComposer: optional spawnX/spawnY for reconnect |
| `5ea3201` | Align with Pixi v8: Filter[] union, WebGLRenderer narrow, ImageLike |
| `22d4e5b` | SocketConnection parser cast + RoomChatHandler arg-order fix |
| `f7a5897` | Renderer: align NitroConfig Window decl with client + fix glob .default access |
| `ef6c661` | Renderer: surface allowUnderpass on RoomSettingsData + composer |
| `5f5ba2f` | docs(claude): document recent feat/react19-event-bus additions |
| `b6a26fb` | 🆙 Small fix landscape's where a bit offset (upstream) |
| `e3078f0` | Merge pull request #69 from duckietm/Dev (upstream) |
| `820f791` | Merge remote-tracking branch 'origin/main' into feat/react19-event-bus |
| `98662e7` | test(utils): add BinaryReader / BinaryWriter round-trip coverage (23 cases) |
| `a599e0c` | feat(session): snapshot getters for IgnoredUsersManager + GroupInformationManager |
| `761d8ff` | feat(session): snapshot getter for UserDataManager room user list |
| `892d16b` | feat(sound): snapshot getter + volume-update event on SoundManager |
| `d740f83` | refactor(parsers): flatten nested bytesAvailable guards on UserProfile + GetGuestRoomResult |
| `28c552f` | docs(CLAUDE.md): document new snapshot getters + flat bytesAvailable pattern |
---
## 7. Final state matrix
### Repository state
| Repo | Branch | HEAD | Tracking | Push status |
|---|---|---|---|---|
| Nitro-V3 | `feat/react19-modernization` | `02a396d` | `simoleo/feat/react19-modernization` | up-to-date |
| Nitro_Render_V3 | `feat/react19-event-bus` | `28c552f` | `fork/feat/react19-event-bus` | up-to-date |
| Arcturus-Morningstar-Extended | `main` | `efb4997` (v4.1.16) | `origin/main` | up-to-date (no fork divergence) |
| NitroV3-Housekeeping | *(not touched)* | — | — | — |
### Verification gates
| Gate | Client | Renderer |
|---|---|---|
| Typecheck (`yarn typecheck` / `yarn compile:fast` — tsgo) | clean (0 errors) | clean (0 errors) |
| Vitest (`yarn test --run`) | **203/203** | **127/127** |
| Production build (`yarn build`) | green | n/a (library) |
### Test totals — evolution
The client started with 0 Vitest cases when the branch was opened. The renderer started with 0 too.
| Milestone | Client | Renderer |
|---|---|---|
| Branch open | 0 | 0 |
| After Vitest setup (`6793de2`) | 22 | — |
| After +16 ColorUtils etc (`bb28d25`) | 38 | — |
| After FriendlyTime (`dbafc97`) | ~50 | — |
| After dedupeBadges (`b1729d8`) | 56 | — |
| After avatarInfo reducers (`3c732f1`) | 70 | — |
| After catalog helpers (`fd3ef78`) | 104 | — |
| After useCatalogFavorites (`8b79233`) | 120 | — |
| After mock layer + first hook test (`c401839`) | ~133 | — |
| After all hoists pre-upstream-sync (`438b47d`) | 193 | — |
| After Phase 11 picker hoists (`1c2d8da`) | **203** | — |
| Renderer Vitest baseline (utility suites) | — | 104 |
| After BinaryReader tests (`98662e7`) | — | **127** |
### Public-API additions on the renderer (consumed by the React client)
| Surface | Commit | Type |
|---|---|---|
| `EventDispatcher.subscribe(type, cb): () => void` | `87cf478` | new |
| `CommunicationManager.subscribeMessage(eventCtor, handler): () => void` | `87cf478` | new |
| `SessionDataManager.getUserDataSnapshot()` + `IUserDataSnapshot` + `SESSION_DATA_UPDATED` | `87cf478` | new |
| `RoomSessionManager.getActiveRoomSessionSnapshot()` + `IRoomSessionSnapshot` + `ROOM_SESSION_UPDATED` | `87cf478` | new |
| `IRoomSession.password` (interface caught up to impl) | `afb5f33` | interface fix |
| `IRoomSession.sendBackgroundMessage(image, stand, overlay, card?)` (interface caught up to impl) | `afb5f33` | interface fix |
| `IRoomSession.sendChatMessage` / `sendShoutMessage``chatColour` optional | `afb5f33` | signature relax |
| `RoomEnterComposer(roomId, password?, spawnX?, spawnY?)` — 4-arg variant | `b42f989` | extension |
| `RoomSettingsData.allowUnderpass` + parser + composer arg | `ef6c661` | extension |
| `IgnoredUsersManager.getIgnoredUsersSnapshot()` + `IGNORED_USERS_UPDATED` | `a599e0c` | new |
| `GroupInformationManager.getGroupBadgesSnapshot()` + `GROUP_BADGES_UPDATED` | `a599e0c` | new |
| `UserDataManager.getRoomUserListSnapshot()` + `ROOM_USER_LIST_UPDATED` | `761d8ff` | new |
| `SoundManager.getVolumesSnapshot()` + `systemVolume`/`furniVolume` getters + `ISoundVolumesSnapshot` + `SOUND_VOLUMES_UPDATED` | `892d16b` | new |
### Bugs fixed during the modernization
| Bug | Commit | Repo | Severity |
|---|---|---|---|
| MainView CREATED/ENDED race (no session token guard) | `9d10e52` | client | medium — could clear current session state |
| LayoutImage async fetch race when props change twice quickly | `97c9717` | client | medium — stale image overwrites valid one |
| InfiniteGrid rules-of-hooks violation | `5697d16` | client | low — lint, no functional bug |
| Avatar editor `.paletteID` null-deref | `b01f09c` | client | low |
| Toolbar spam-toggle leaving children at opacity 0 / scale 0.8 | `4ab38d3` | client | medium — visible UI bug, also opened upstream as PR #130 |
| Two logic bugs found during refactor (documented) | `81656e7` | client | lowmedium |
| `PetBreedingMessageParser.bytesAvailable < 12` comparing boolean against number | `999b818` | renderer | high — incorrect parse on common path |
| `ChatWhisperGroupComposer` never existed but was declared on the interface | `08d1efa` | renderer | low — dead code |
| `SoundManager` volume diff comparison (percent vs fraction) | `892d16b` | renderer | medium — every settings push fired updateFurniSamplesVolume + musicController.updateVolume regardless of whether the volume changed |
| Two Wired composers had self-referential `ConstructorParameters` | `0fc38a1` | renderer | low — typecheck only |
### Rollback safety tags (local-only, session-private)
| Repo | Tag | Points at |
|---|---|---|
| Nitro-V3 | `pre-upstream-merge-20260518` | `4ab38d3` |
| Nitro_Render_V3 | `pre-upstream-merge-20260518` | `5f5ba2f` |
| Arcturus-Morningstar-Extended | `pre-upstream-pull-20260518` | `e6093f9` |
+11 -13
View File
@@ -1,6 +1,7 @@
import typescriptEslintPlugin from "@typescript-eslint/eslint-plugin";
import typescriptEslintParser from "@typescript-eslint/parser";
import reactPlugin from "eslint-plugin-react";
import reactCompilerPlugin from "eslint-plugin-react-compiler";
import reactHooksPlugin from "eslint-plugin-react-hooks";
import path from "path";
import { fileURLToPath } from "url";
@@ -14,6 +15,7 @@ export default [
plugins: {
react: reactPlugin,
"react-hooks": reactHooksPlugin,
"react-compiler": reactCompilerPlugin,
"@typescript-eslint": typescriptEslintPlugin,
},
languageOptions: {
@@ -110,28 +112,24 @@ export default [
'@typescript-eslint/no-unused-vars': [
'off'
],
'@typescript-eslint/ban-types': [
'@typescript-eslint/no-restricted-types': [
'error',
{
'types':
{
'String': true,
'Boolean': true,
'Number': true,
'Symbol': true,
'{}': false,
'Object': false,
'object': false,
'Function': false
},
'extendDefaults': true
'String': { message: 'Use string instead', fixWith: 'string' },
'Boolean': { message: 'Use boolean instead', fixWith: 'boolean' },
'Number': { message: 'Use number instead', fixWith: 'number' },
'Symbol': { message: 'Use symbol instead', fixWith: 'symbol' }
}
}
],
'react/react-in-jsx-scope': 'off'
'react/react-in-jsx-scope': 'off',
'react-compiler/react-compiler': 'warn'
},
settings: {
react: {
version: "18.3.1",
version: "19.2",
},
},
},
+43
View File
@@ -0,0 +1,43 @@
// Minimal ESLint config focused on the Rules of Hooks.
//
// The full eslint.config.mjs runs the project's full lint baseline,
// which currently emits ~900 pre-existing errors (brace style,
// indentation, recommended TS rules) — those are tracked separately
// and would drown a CI signal. This config strips down to just the
// rule we care about as a gate: react-hooks/rules-of-hooks.
//
// Wired up as `yarn lint:hooks` (see package.json) and called from
// .github/workflows/ci.yml so a hook-order violation breaks the
// build the same way a typecheck or test failure would.
import typescriptEslintParser from '@typescript-eslint/parser';
import reactHooksPlugin from 'eslint-plugin-react-hooks';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default [
{
files: ['**/*.jsx', '**/*.js', '**/*.tsx', '**/*.ts'],
plugins: {
'react-hooks': reactHooksPlugin
},
languageOptions: {
parser: typescriptEslintParser,
ecmaVersion: 'latest',
parserOptions: {
sourceType: 'module',
project: './tsconfig.json',
tsconfigRootDir: __dirname,
ecmaFeatures: {
jsx: true
}
}
},
rules: {
'react-hooks/rules-of-hooks': 'error'
}
}
];
+12 -1
View File
@@ -1 +1,12 @@
<div id="root"></div><script type="module" src="/src/bootstrap.ts"></script>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Nitro</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/bootstrap.ts"></script>
</body>
</html>
+27 -8
View File
@@ -10,44 +10,63 @@
"start": "vite --host",
"build": "vite build && node scripts/minify-dist.mjs",
"build:prod": "npx browserslist@latest --update-db && yarn build",
"eslint": "eslint ./src"
"preview": "vite preview --host",
"eslint": "eslint ./src",
"lint:hooks": "eslint --config eslint.hooks.config.mjs ./src",
"typecheck": "tsgo --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@babel/runtime": "^7.29.2",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slider": "^1.2.4",
"@tanstack/react-virtual": "3.13.24",
"dompurify": "^3.4.1",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-slider": "^1.3.6",
"@tanstack/react-query": "5",
"@tanstack/react-query-devtools": "5",
"@tanstack/react-virtual": "^3.13.24",
"dompurify": "^3.4.2",
"emoji-mart": "^5.6.0",
"emoji-toolkit": "10.0.0",
"framer-motion": "^12.38.0",
"json5": "^2.2.3",
"react": "^19.2.5",
"react-colorful": "^5.7.0",
"react-dom": "^19.2.5",
"react-icons": "^5.5.0",
"react-error-boundary": "^6.1.1",
"react-icons": "^5.6.0",
"react-player": "^2.16.0",
"use-between": "^1.4.0"
"use-between": "^1.4.0",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/forms": "^0.5.11",
"@tailwindcss/postcss": "^4.2.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/parser": "^8.59.1",
"@typescript/native-preview": "^7.0.0-dev.20260509.2",
"@vitejs/plugin-react": "^6.0.1",
"babel-plugin-react-compiler": "^1.0.0",
"eslint": "^10.2.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"eslint-plugin-react-hooks": "^7.1.1",
"jsdom": "^29.1.1",
"postcss": "^8.5.12",
"postcss-nested": "^7.0.2",
"sass": "^1.99.0",
"sirv": "^3.0.2",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
"typescript-eslint": "^8.59.1",
"vite": "^8.0.10"
"vite": "^8.0.10",
"vitest": "^3"
}
}
+204 -34
View File
@@ -1,5 +1,5 @@
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react';
import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api';
import { Base } from './common';
import { LoadingView } from './components/loading/LoadingView';
@@ -36,7 +36,8 @@ const preloadUrl = async (url: string): Promise<void> =>
const response = await fetch(url, { cache: 'force-cache' });
await response.arrayBuffer();
}
catch {}
catch
{}
};
const preloadImage = (url: string): void =>
@@ -49,7 +50,8 @@ const preloadImage = (url: string): void =>
image.decoding = 'async';
image.src = url;
}
catch {}
catch
{}
};
const asStringArray = (value: unknown): string[] =>
@@ -72,18 +74,65 @@ export const App: FC<{}> = props =>
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
const warmupPromiseRef = useRef<Promise<void>>(null);
const rendererPromiseRef = useRef<Promise<any>>(null);
const gameInitPromiseRef = useRef<Promise<void> | null>(null);
const bootstrapDoneRef = useRef(false);
const lastPrepareTriggerRef = useRef<number | null>(null);
const tickersStartedRef = useRef(false);
const heartbeatIntervalRef = useRef<number>(null);
const rememberRotateIntervalRef = useRef<number>(null);
const isReadyRef = useRef(false);
const reconnectInProgressRef = useRef(false);
const clearStoredCredentials = useCallback(() =>
{
ClearRememberLogin();
try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {}
try { GetConfiguration().setValue('sso.ticket', ''); } catch {}
// Drop `?sso=` from the URL too — otherwise the next reload re-applies
// the same already-consumed ticket via bootstrap.ts and we loop right
// back into "Session expired" without ever showing the login form.
try
{
const url = new URL(window.location.href);
if(url.searchParams.has('sso'))
{
url.searchParams.delete('sso');
window.history.replaceState({}, '', url.toString());
}
}
catch {}
}, []);
const fallbackToLogin = useCallback(() =>
{
// Using console.warn (not NitroLogger.log) on purpose: NitroLogger
// is gated on LOG_DEBUG, which only flips to true once startWarmup's
// GetConfiguration().init() completes. Auth-failure paths fire before
// that, so NitroLogger swallows their messages silently.
console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared');
// Wipe whatever credential the server just rejected so the form is
// pristine and the next attempt isn't sabotaged by the same stale data.
clearStoredCredentials();
setHomeUrl('');
setErrorMessage('');
setIsReady(false);
setShowLogin(true);
setIsEnteringHotel(false);
}, [ clearStoredCredentials ]);
const showSessionExpired = useCallback(() =>
{
console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)');
clearStoredCredentials();
const baseUrl = window.location.origin + '/';
setHomeUrl(baseUrl);
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
}, []);
}, [ clearStoredCredentials ]);
const applySsoTicket = useCallback((ssoTicket: string) =>
{
@@ -105,10 +154,20 @@ export const App: FC<{}> = props =>
{
const remembered = GetRememberLogin();
if(!remembered) return '';
if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket;
console.warn('[App] tryRememberLogin start', {
hasRemembered: !!remembered,
hasToken: !!remembered?.token?.length,
hasStoredSso: !!remembered?.ssoTicket?.length
});
let allowSsoFallback = true;
if(!remembered?.token?.length)
{
// No remember token means we'd be reusing a one-shot ssoTicket that
// the server already consumed. Force the login screen instead.
if(remembered) ClearRememberLogin();
console.warn('[App] tryRememberLogin → no token, returning empty');
return '';
}
try
{
@@ -126,30 +185,38 @@ export const App: FC<{}> = props =>
});
let payload: Record<string, unknown> = {};
try { payload = await response.json(); }
catch {}
try
{
payload = await response.json();
}
catch
{}
const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : '');
console.warn('[App] tryRememberLogin → remember endpoint replied', {
status: response.status,
ok: response.ok,
gotSsoTicket: !!ssoTicket
});
if(response.ok && ssoTicket)
{
persistAccessTokenFromPayload(payload);
StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : remembered.username, ssoTicket);
return ssoTicket;
}
if(response.status === 400 || response.status === 401 || response.status === 403)
{
allowSsoFallback = false;
ClearRememberLogin();
}
}
catch(error)
{
NitroLogger.error('[LoginScreen] Remember login failed', error);
console.warn('[App] tryRememberLogin → fetch threw', error);
}
if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket;
// Any failure (rejected token, bad payload, network error) — drop the
// stored credentials. Never fall back to the cached ssoTicket: it's
// one-shot and reusing it leads straight to "Session expired".
ClearRememberLogin();
console.warn('[App] tryRememberLogin → cleared remember, returning empty');
return '';
}, []);
@@ -176,8 +243,12 @@ export const App: FC<{}> = props =>
});
let payload: Record<string, unknown> = {};
try { payload = await response.json(); }
catch {}
try
{
payload = await response.json();
}
catch
{}
if(response.ok)
{
@@ -194,8 +265,51 @@ export const App: FC<{}> = props =>
}
}, []);
// Listen for socket closed events (code 1000 "Bye" - server rejected SSO)
useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired);
// Mirror isReady into a ref so the socket handlers below can read the
// freshest value without needing to re-subscribe on every state change.
useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]);
// Track whether a reconnect cycle is active. The renderer dispatches
// SOCKET_RECONNECTING when it starts retrying after an abnormal close
// (code != 1000/1001). On exhausted retries it fires SOCKET_RECONNECT_FAILED
// *and* a final SOCKET_CLOSED — we keep the flag set through that pair
// so ReconnectView's own overlay owns the UX and we don't double-render.
useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; });
useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; });
useNitroEvent(NitroEventType.SOCKET_CLOSED, () =>
{
// Three distinct close scenarios converge here:
//
// 1. !isReady — initial handshake just failed (server replied
// with "Bye" / code 1000 to a bad SSO ticket). The user never
// had a session. Surface the login form instead of the
// misleading "Session expired" diagnostic.
//
// 2. isReady && reconnect in progress — ReconnectView already
// owns the UX (its overlay shows attempts and the "Session
// expired" message on RECONNECT_FAILED). Stay out of its way.
//
// 3. isReady && no reconnect — instant server kick mid-game
// (code 1000 from the server side). No reconnect path will
// run. Show the legacy session-expired diagnostic so the
// user knows to reload.
console.warn('[App] SOCKET_CLOSED fired', {
isReady: isReadyRef.current,
reconnectInProgress: reconnectInProgressRef.current
});
if(!isReadyRef.current)
{
console.warn('[App] Socket closed before authentication completed — falling back to login');
fallbackToLogin();
return;
}
if(reconnectInProgressRef.current) return;
showSessionExpired();
});
useMessageEvent<LoadGameUrlEvent>(LoadGameUrlEvent, event =>
{
@@ -306,10 +420,20 @@ export const App: FC<{}> = props =>
};
}, []);
const onSessionExpired = useEffectEvent(() => showSessionExpired());
const onInitFailure = useEffectEvent(() => fallbackToLogin());
useEffect(() =>
{
const prepare = async (width: number, height: number) =>
{
console.warn('[App] prepare() start', {
hasNitroConfig: !!window.NitroConfig,
ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'],
hasRememberLocal: !!GetRememberLogin(),
urlSso: new URLSearchParams(window.location.search).get('sso')
});
try
{
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
@@ -322,12 +446,25 @@ export const App: FC<{}> = props =>
// Configuration is loaded lazily — fetch it up-front so the login
// screen toggle and Turnstile keys are available before we decide.
let configInitError: unknown = null;
try { await GetConfiguration().init(); }
catch(e) { configInitError = e; }
try
{
await GetConfiguration().init();
}
catch(e)
{
configInitError = e;
}
const rawLoginEnabled = GetConfiguration().getValue<unknown>('login.screen.enabled', false);
const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1;
console.warn('[App] no SSO path — login gate', {
configInitError: configInitError ? String((configInitError as Error)?.message ?? configInitError) : null,
rawLoginEnabled,
rawLoginEnabledType: typeof rawLoginEnabled,
loginScreenEnabled
});
if(configInitError)
{
NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError);
@@ -363,7 +500,7 @@ export const App: FC<{}> = props =>
return;
}
showSessionExpired();
onSessionExpired();
return;
}
}
@@ -371,14 +508,26 @@ export const App: FC<{}> = props =>
const renderer = await startRenderer(width, height);
await startWarmup(width, height);
await GetSessionDataManager().init();
await GetRoomSessionManager().init();
await GetRoomEngine().init();
await GetCommunication().init();
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'authentication', 'authok', []);
if(!gameInitPromiseRef.current)
{
gameInitPromiseRef.current = (async () =>
{
await GetSessionDataManager().init();
await GetRoomSessionManager().init();
await GetRoomEngine().init();
await GetCommunication().init();
})();
}
HabboWebTools.sendHeartBeat();
await gameInitPromiseRef.current;
if(!bootstrapDoneRef.current)
{
bootstrapDoneRef.current = true;
if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'authentication', 'authok', []);
HabboWebTools.sendHeartBeat();
}
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
@@ -402,12 +551,27 @@ export const App: FC<{}> = props =>
}
catch(err)
{
NitroLogger.error(err);
setIsEnteringHotel(false);
showSessionExpired();
NitroLogger.error('[App] Initialization failed — falling back to login', err);
// Anything thrown out of the post-auth chain (renderer init,
// asset download, GetCommunication().init()) is an init/connect
// failure, not session expiration. The credential we used is
// suspect — drop it and present the login form so the user
// can retry instead of getting stuck on a stale "Session expired".
onInitFailure();
}
};
// React Strict Mode in dev runs every effect twice (mount → cleanup → mount).
// `prepare()` is full of one-shot side effects (renderer init, websocket
// connect, NitroConfig mutation) — calling it twice with the same trigger
// value causes the second pass to race with the first and clobber state
// (e.g. the second pass falls through to onSessionExpired while the first
// had just set showLogin=true). Guard by trigger value: skip duplicate
// runs at the same trigger, but still re-run when handleAuthenticated
// bumps prepareTrigger after a successful login.
if(lastPrepareTriggerRef.current === prepareTrigger) return;
lastPrepareTriggerRef.current = prepareTrigger;
const { width, height } = getViewportDimensions();
prepare(width, height);
@@ -425,7 +589,13 @@ export const App: FC<{}> = props =>
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
{ isReady && <MainView /> }
<ReconnectView />
{ /* Reconnect overlay must NOT render before we've actually entered
the hotel — otherwise the renderer's auto-retry on an initial
handshake failure (e.g. emulator unreachable) would cover the
login form with "Reconnecting..." → "Session expired" and the
user wouldn't be able to interact with the form we just put up
via fallbackToLogin. */ }
{ isReady && <ReconnectView /> }
<Base id="draggable-windows-container" />
</Base>
);
+14 -4
View File
@@ -17,13 +17,20 @@ export const setAccessToken = (token: string | null | undefined, expiresAt?: num
window.localStorage.removeItem(EXPIRES_KEY);
}
}
catch {}
catch
{}
};
export const getAccessToken = (): string =>
{
try { return window.localStorage.getItem(STORAGE_KEY) ?? ''; }
catch { return ''; }
try
{
return window.localStorage.getItem(STORAGE_KEY) ?? '';
}
catch
{
return '';
}
};
export const getAccessTokenExpiresAt = (): number =>
@@ -35,7 +42,10 @@ export const getAccessTokenExpiresAt = (): number =>
const value = parseInt(raw, 10);
return Number.isFinite(value) ? value : 0;
}
catch { return 0; }
catch
{
return 0;
}
};
export const clearAccessToken = (): void =>
@@ -87,7 +87,7 @@ export class AvatarEditorThumbnailsHelper
AvatarFigurePartType.PET,
'ptl',
'ptr',
AvatarFigurePartType.MISC,
AvatarFigurePartType.MISC,
'mcl',
'mcr',
];
+39
View File
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { dedupeBadges } from './dedupeBadges';
describe('dedupeBadges', () =>
{
it('returns an empty array for an empty input', () =>
{
expect(dedupeBadges([])).toEqual([]);
});
it('preserves unique badges in slot order', () =>
{
expect(dedupeBadges([ 'a', 'b', 'c' ])).toEqual([ 'a', 'b', 'c' ]);
});
it('replaces duplicate slots with empty strings to preserve slot indices', () =>
{
expect(dedupeBadges([ 'a', 'b', 'a', 'c' ])).toEqual([ 'a', 'b', '', 'c' ]);
});
it('normalizes falsy entries (null, undefined, "") to empty string', () =>
{
// server sometimes returns null/undefined for unused slots
const input = [ 'a', null as unknown as string, '', undefined as unknown as string, 'b' ];
expect(dedupeBadges(input)).toEqual([ 'a', '', '', '', 'b' ]);
});
it('only keeps the FIRST occurrence of each unique code', () =>
{
expect(dedupeBadges([ 'a', 'a', 'a' ])).toEqual([ 'a', '', '' ]);
});
it('is order-sensitive: identical multisets but different orderings yield different outputs', () =>
{
expect(dedupeBadges([ 'a', 'b', 'a' ])).toEqual([ 'a', 'b', '' ]);
expect(dedupeBadges([ 'b', 'a', 'a' ])).toEqual([ 'b', 'a', '' ]);
});
});
+21
View File
@@ -0,0 +1,21 @@
/**
* Strips duplicate badge codes from a server-supplied badge array,
* preserving slot indices: a duplicate is replaced by an empty string
* rather than shifted out, so badge[i] still corresponds to slot i.
*
* Empty / falsy entries are normalized to '' (some servers emit null
* inside the array for unused slots).
*/
export const dedupeBadges = (badges: ReadonlyArray<string>): string[] =>
{
const seen = new Set<string>();
return badges.map(code =>
{
if(!code || seen.has(code)) return '';
seen.add(code);
return code;
});
};
+1
View File
@@ -3,5 +3,6 @@ export * from './AvatarEditorColorSorter';
export * from './AvatarEditorPartSorter';
export * from './AvatarEditorThumbnailsHelper';
export * from './BuildPurchasableClothingFigure';
export * from './dedupeBadges';
export * from './IAvatarEditorCategory';
export * from './IAvatarEditorCategoryPartItem';
+26 -7
View File
@@ -31,8 +31,14 @@ export interface CustomBadgeError
const interpolate = (value: string): string =>
{
try { return GetConfiguration().interpolate(value); }
catch { return value; }
try
{
return GetConfiguration().interpolate(value);
}
catch
{
return value;
}
};
const getConfigUrl = (key: string, fallback: string): string =>
@@ -61,8 +67,14 @@ const parseJson = async <T>(response: Response): Promise<T> =>
{
const text = await response.text();
if(!text) return {} as T;
try { return JSON.parse(text) as T; }
catch { throw new Error('Invalid response from server.'); }
try
{
return JSON.parse(text) as T;
}
catch
{
throw new Error('Invalid response from server.');
}
};
const throwOnError = async (response: Response): Promise<void> =>
@@ -129,8 +141,14 @@ const injectTextsIntoLocalization = (texts: Record<string, string> | null | unde
{
if(!texts) return;
let manager: ReturnType<typeof GetLocalizationManager> | null = null;
try { manager = GetLocalizationManager(); }
catch { return; }
try
{
manager = GetLocalizationManager();
}
catch
{
return;
}
if(!manager || typeof manager.setValue !== 'function') return;
for(const key of Object.keys(texts))
{
@@ -152,7 +170,8 @@ export const ensureCustomBadgeTexts = (): Promise<void> =>
const payload = await parseJson<{ texts: Record<string, string> }>(response);
injectTextsIntoLocalization(payload.texts);
}
catch {}
catch
{}
})();
return customBadgeTextsLoadPromise;
};
+1 -1
View File
@@ -15,7 +15,7 @@ export class FurnitureOffer implements IPurchasableOffer
constructor(furniData: IFurnitureData)
{
this._furniData = furniData;
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData) as IProduct);
this._product = (new Product(this._furniData.type, this._furniData.id, this._furniData.customParams, 1, GetProductDataForLocalization(this._furniData.className), this._furniData));
}
public activate(): void
-14
View File
@@ -1,14 +0,0 @@
import { ClubGiftInfoParser, ClubOfferData, HabboGroupEntryData, MarketplaceConfigurationMessageParser } from '@nitrots/nitro-renderer';
import { CatalogPetPalette } from './CatalogPetPalette';
import { GiftWrappingConfiguration } from './GiftWrappingConfiguration';
export interface ICatalogOptions
{
groups?: HabboGroupEntryData[];
petPalettes?: CatalogPetPalette[];
clubOffers?: ClubOfferData[];
clubOffersByWindowId?: Record<number, ClubOfferData[]>;
clubGifts?: ClubGiftInfoParser;
giftConfiguration?: GiftWrappingConfiguration;
marketplaceConfiguration?: MarketplaceConfigurationMessageParser;
}
+1
View File
@@ -24,4 +24,5 @@ export interface IPurchasableOffer
products: IProduct[];
itemIds: string;
haveOffer: boolean;
clone?(): IPurchasableOffer;
}
-1
View File
@@ -10,7 +10,6 @@ export * from './FurnitureOffer';
export * from './GetImageIconUrlForProduct';
export * from './GiftWrappingConfiguration';
export * from './ICatalogNode';
export * from './ICatalogOptions';
export * from './ICatalogPage';
export * from './IMarketplaceSearchOptions';
export * from './IPageLocalization';
+1 -1
View File
@@ -9,5 +9,5 @@ export const GetGroupChatData = (extraData: string) =>
const figure = splitData[1];
const userId = parseInt(splitData[2]);
return ({ username: username, figure: figure, userId: userId } as IGroupChatData);
return ({ username: username, figure: figure, userId: userId });
};
+1
View File
@@ -25,6 +25,7 @@ export * from './purse';
export * from './room';
export * from './room/events';
export * from './room/widgets';
export * from './ui-settings';
export * from './user';
export * from './utils';
export * from './wired';
+127
View File
@@ -0,0 +1,127 @@
import { GetCommunication, IMessageComposer, IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
import { QueryKey, useQuery, UseQueryOptions, UseQueryResult } from '@tanstack/react-query';
import { SendMessageComposer } from '../nitro/SendMessageComposer';
export interface NitroQueryConfig<TParser extends IMessageEvent, TData>
{
/**
* Stable key for caching/deduping. Convention:
* `['nitro', '<domain>', '<request>', ...args]`.
*/
key: QueryKey;
/**
* Factory for the request composer. Called once per query execution.
* `null` skips sending (useful when the server pushes the event
* unprompted — you only want subscription, not a request).
*/
request: (() => IMessageComposer<unknown[]>) | null;
/**
* The parser class to listen for as the response.
*/
parser: typeof MessageEvent;
/**
* Maps the parser event to the data the component cares about.
*/
select?: (event: TParser) => TData;
/**
* Optional predicate to ignore parser events that don't match this
* query (typically used as a correlation-key filter on a globally
* shared event stream — e.g. `e => e.getParser()?.roomId === roomId`).
* When the predicate returns false, the listener stays registered
* and keeps waiting; the timeout still applies.
*/
accept?: (event: TParser) => boolean;
/**
* Max time to wait for the response before rejecting (default 15s).
*/
timeoutMs?: number;
/**
* Forwarded to TanStack Query.
*/
enabled?: boolean;
staleTime?: number;
refetchOnMount?: boolean | 'always';
}
/**
* Wraps a Nitro composer/parser request-response pair as a TanStack Query
* `useQuery` call. The returned object is the standard TanStack result —
* `{ data, isLoading, isError, error, refetch, ... }`.
*
* Behavior:
* - On the first subscribe, registers the parser, sends the composer,
* resolves the Promise with the selected payload when the parser fires.
* - Default `staleTime` is the QueryClient default (30s).
* - Subsequent mounts within `staleTime` get the cached value immediately;
* the request is NOT re-sent.
* - Identical concurrent calls (same `key`) are deduped.
*/
export const useNitroQuery = <TParser extends IMessageEvent, TData = TParser>(
config: NitroQueryConfig<TParser, TData>
): UseQueryResult<TData> =>
{
const { key, request, parser, select, accept, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config;
const options: UseQueryOptions<TData, Error, TData> = {
queryKey: key,
queryFn: () => awaitNitroResponse<TParser, TData>({ request, parser, select, accept, timeoutMs }),
enabled,
staleTime,
refetchOnMount
};
return useQuery(options);
};
/**
* Lower-level helper: send a composer (if any) and resolve with the next
* matching parser event. Exposed so `queryClient.fetchQuery({...})` callers
* can use the same plumbing imperatively.
*/
export const awaitNitroResponse = <TParser extends IMessageEvent, TData>(
config: Pick<NitroQueryConfig<TParser, TData>, 'request' | 'parser' | 'select' | 'accept' | 'timeoutMs'>
): Promise<TData> =>
new Promise<TData>((resolve, reject) =>
{
const { request, parser: ParserCtor, select, accept, timeoutMs = 15_000 } = config;
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let listener: IMessageEvent | undefined = undefined;
const cleanup = () =>
{
if(timeoutHandle !== null) clearTimeout(timeoutHandle);
if(listener) GetCommunication().removeMessageEvent(listener);
};
listener = new (ParserCtor as any)((event: TParser) =>
{
if(settled) return;
if(accept && !accept(event)) return;
settled = true;
cleanup();
try
{
resolve(select ? select(event) : (event as unknown as TData));
}
catch(err)
{
reject(err instanceof Error ? err : new Error(String(err)));
}
});
GetCommunication().registerMessageEvent(listener);
timeoutHandle = setTimeout(() =>
{
if(settled) return;
settled = true;
cleanup();
reject(new Error(`NitroQuery timed out after ${ timeoutMs }ms`));
}, timeoutMs);
if(request) SendMessageComposer(request());
});
+2
View File
@@ -0,0 +1,2 @@
export * from './createNitroQuery';
export * from './useNitroEventInvalidator';
@@ -0,0 +1,48 @@
import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer';
import { QueryKey, useQueryClient } from '@tanstack/react-query';
import { useMessageEvent } from '../../hooks/events/useMessageEvent';
/**
* Invalidate a TanStack query slot every time the renderer pushes the
* matching parser event. Companion to useNitroQuery for the case where
* the server can push fresh data unprompted (e.g. ClubGiftInfoEvent
* fires both as the response to GetClubGiftInfo and again after the
* user claims a gift via SelectClubGiftComposer).
*
* Usage:
*
* const { data: clubGifts } = useNitroQuery({
* key: ['nitro', 'catalog', 'clubGifts'],
* request: () => new GetClubGiftInfo(),
* parser: ClubGiftInfoEvent,
* select: e => e.getParser(),
* });
*
* // re-fetch on every server push:
* useNitroEventInvalidator(ClubGiftInfoEvent, ['nitro', 'catalog', 'clubGifts']);
*
* Optional `accept` predicate filters out events that don't belong to
* this query slot — useful when the same parser is multiplexed across
* multiple correlated queries (mirrors useNitroQuery.accept).
*
* Implementation: the renderer push triggers `queryClient.invalidateQueries`,
* which marks the slot stale; the next subscriber render triggers a
* fresh fetch via useNitroQuery's queryFn. If nobody is currently
* subscribed, the invalidation is a no-op (TanStack drops stale entries
* with no active observers per its garbage-collection policy).
*/
export const useNitroEventInvalidator = <T extends IMessageEvent>(
eventType: typeof MessageEvent,
queryKey: QueryKey,
accept?: (event: T) => boolean
) =>
{
const queryClient = useQueryClient();
useMessageEvent<T>(eventType, event =>
{
if(accept && !accept(event)) return;
queryClient.invalidateQueries({ queryKey });
});
};
+1 -1
View File
@@ -20,7 +20,7 @@ export class AvatarInfoUser implements IAvatarInfo
public prefixFont: string = '';
public displayOrder: string = 'icon-prefix-name';
public achievementScore: number = 0;
public backgroundId: number = 0;
public backgroundId: number = 0;
public standId: number = 0;
public overlayId: number = 0;
public cardBackgroundId: number = 0;
+1 -1
View File
@@ -190,7 +190,7 @@ export class AvatarInfoUtilities
userInfo.prefixEffect = userData.prefixEffect;
userInfo.prefixFont = userData.prefixFont;
userInfo.displayOrder = userData.displayOrder;
userInfo.backgroundId = userData.background;
userInfo.backgroundId = userData.background;
userInfo.standId = userData.stand;
userInfo.overlayId = userData.overlay;
userInfo.cardBackgroundId = userData.cardBackground ?? 0;
@@ -9,9 +9,11 @@ export class chooserSelectionVisualizer
{
if (this.animationFrameId !== null) return;
const animate = (time: number) => {
const animate = (time: number) =>
{
const elapsed = time / 1000; // Convert to seconds
this.activeFilters.forEach(filter => {
this.activeFilters.forEach(filter =>
{
filter.time = elapsed; // Update time uniform
});
this.animationFrameId = requestAnimationFrame(animate);
@@ -22,7 +24,8 @@ export class chooserSelectionVisualizer
private static stopAnimation(): void
{
if (this.animationFrameId !== null) {
if (this.animationFrameId !== null)
{
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
@@ -45,7 +48,7 @@ export class chooserSelectionVisualizer
for (const sprite of visualization.sprites)
{
if (sprite.blendMode === 1) continue;
if (sprite.blendMode === 'add') continue;
const existing = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
sprite.filters = [...existing, filter];
}
@@ -69,7 +72,8 @@ export class chooserSelectionVisualizer
sprite.filters = (sprite.filters || []).filter(f => !(f instanceof ChooserSelectionFilter));
}
if (this.activeFilters.size === 0) {
if (this.activeFilters.size === 0)
{
this.stopAnimation();
}
}
+2 -2
View File
@@ -2,7 +2,7 @@ import { AvatarFigurePartType, GetAvatarRenderManager, IAvatarFigureContainer }
export class MannequinUtilities
{
public static MANNEQUIN_FIGURE = [ 'hd', 99999, [ 99998 ] ];
public static MANNEQUIN_FIGURE: [ string, number, number[] ] = [ 'hd', 99999, [ 99998 ] ];
public static MANNEQUIN_CLOTHING_PART_TYPES = [
AvatarFigurePartType.CHEST_ACCESSORY,
AvatarFigurePartType.COAT_CHEST,
@@ -33,6 +33,6 @@ export class MannequinUtilities
figureContainer.removePart(part);
}
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0] as string), (this.MANNEQUIN_FIGURE[1] as number), (this.MANNEQUIN_FIGURE[2] as number[]));
figureContainer.updatePart((this.MANNEQUIN_FIGURE[0]), (this.MANNEQUIN_FIGURE[1]), (this.MANNEQUIN_FIGURE[2]));
};
}
+27 -67
View File
@@ -1,7 +1,14 @@
import { GetCommunication, UiSettingsDataEvent, UiSettingsLoadComposer, UiSettingsSaveComposer } from '@nitrots/nitro-renderer';
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { createContext, FC, PropsWithChildren, useCallback, useContext, useEffect, useState } from 'react';
import { DEFAULT_UI_SETTINGS, IUiSettings } from './IUiSettings';
/**
* UI settings currently persist to localStorage only. The cross-device
* server-side sync (UiSettingsLoadComposer / UiSettingsSaveComposer /
* UiSettingsDataEvent) is a planned addition that requires both the
* renderer composer classes and the Arcturus packet handlers — none of
* which exist yet. Until those land, settings stay per-browser.
*/
const STORAGE_KEY = 'nitro.ui.settings';
interface IUiSettingsContext
@@ -18,8 +25,10 @@ interface IUiSettingsContext
const UiSettingsContext = createContext<IUiSettingsContext>({
settings: DEFAULT_UI_SETTINGS,
isCustomActive: false,
updateSettings: () => {},
resetSettings: () => {},
updateSettings: () =>
{},
resetSettings: () =>
{},
getHeaderStyle: () => ({}),
getTabsStyle: () => ({}),
getAccentColor: () => DEFAULT_UI_SETTINGS.headerColor
@@ -42,7 +51,8 @@ const loadSettings = (): IUiSettings =>
const stored = localStorage.getItem(STORAGE_KEY);
if(stored) return { ...DEFAULT_UI_SETTINGS, ...JSON.parse(stored) };
}
catch(e) {}
catch(e)
{}
return { ...DEFAULT_UI_SETTINGS };
};
@@ -53,61 +63,20 @@ const saveSettings = (settings: IUiSettings): void =>
{
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
catch(e) {}
catch(e)
{}
};
const sendComposer = (composer: any): void =>
{
try
{
GetCommunication()?.connection?.send(composer);
}
catch(e) {}
};
const ALL_CSS_VARS = [
'--ui-accent-color', '--ui-accent-dark',
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
'--ui-btn-primary-bg', '--ui-btn-primary-border',
'--ui-dark-bg', '--ui-dark-border'
];
export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
{
const [ settings, setSettings ] = useState<IUiSettings>(loadSettings);
const serverSaveTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
// Carica dal server al mount e ascolta risposta
useEffect(() =>
{
sendComposer(new UiSettingsLoadComposer());
const connection = GetCommunication()?.connection;
if(!connection) return;
const handler = (event: any) =>
{
try
{
const parser = event.getParser();
const json = parser?.settingsJson;
if(json && json !== '{}')
{
const serverSettings = { ...DEFAULT_UI_SETTINGS, ...JSON.parse(json) };
setSettings(serverSettings);
saveSettings(serverSettings);
}
}
catch(e) {}
};
connection.addMessageEvent(new UiSettingsDataEvent(handler));
}, []);
const syncToServer = useCallback((settingsToSave: IUiSettings) =>
{
if(serverSaveTimerRef.current) clearTimeout(serverSaveTimerRef.current);
serverSaveTimerRef.current = setTimeout(() =>
{
sendComposer(new UiSettingsSaveComposer(JSON.stringify(settingsToSave)));
}, 1000);
}, []);
const updateSettings = useCallback((partial: Partial<IUiSettings>) =>
{
@@ -115,18 +84,16 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
{
const updated = { ...prev, ...partial };
saveSettings(updated);
syncToServer(updated);
return updated;
});
}, [ syncToServer ]);
}, []);
const resetSettings = useCallback(() =>
{
setSettings({ ...DEFAULT_UI_SETTINGS });
saveSettings(DEFAULT_UI_SETTINGS);
syncToServer(DEFAULT_UI_SETTINGS);
}, [ syncToServer ]);
}, []);
const getHeaderStyle = useCallback((): React.CSSProperties =>
{
@@ -183,13 +150,6 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
const isCustomActive = settings.colorMode !== 'default';
const ALL_CSS_VARS = [
'--ui-accent-color', '--ui-accent-dark',
'--ui-ctx-bg', '--ui-ctx-header-bg', '--ui-ctx-item-bg1', '--ui-ctx-item-bg2',
'--ui-btn-primary-bg', '--ui-btn-primary-border',
'--ui-dark-bg', '--ui-dark-border'
];
useEffect(() =>
{
const root = document.documentElement;
@@ -215,9 +175,9 @@ export const UiSettingsProvider: FC<PropsWithChildren> = ({ children }) =>
}, [ settings ]);
return (
<UiSettingsContext.Provider value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
<UiSettingsContext value={ { settings, isCustomActive, updateSettings, resetSettings, getHeaderStyle, getTabsStyle, getAccentColor } }>
{ children }
</UiSettingsContext.Provider>
</UiSettingsContext>
);
};
+2 -2
View File
@@ -13,7 +13,7 @@ export class ProductImageUtility
imageUrl = GetRoomEngine().getFurnitureFloorIconUrl(furniClassId);
break;
case FurnitureType.WALL:
const productCategory = this.getProductCategory(CatalogPageMessageProductData.I, furniClassId);
const productCategory = this.getProductCategory(productType, furniClassId);
if(productCategory === 1)
{
@@ -32,7 +32,7 @@ export class ProductImageUtility
}
}
break;
case FurnitureType.EFFECT:
case FurnitureType.EFFECT:
// fx_icon_furniClassId_png
break;
}
+8 -3
View File
@@ -53,8 +53,12 @@ export const SetRememberLogin = (data: RememberLoginData): void =>
{
if(!data?.token?.length && !data?.ssoTicket?.length) return;
try { window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data)); }
catch {}
try
{
window.localStorage.setItem(REMEMBER_LOGIN_KEY, JSON.stringify(data));
}
catch
{}
};
export const ClearRememberLogin = (): void =>
@@ -64,7 +68,8 @@ export const ClearRememberLogin = (): void =>
window.localStorage.removeItem(REMEMBER_LOGIN_KEY);
window.localStorage.removeItem(LEGACY_REMEMBER_LOGIN_KEY);
}
catch {}
catch
{}
};
export const StoreRememberLoginFromPayload = (payload: Record<string, unknown>, username?: string, ssoTicket?: string): void =>
+165
View File
@@ -0,0 +1,165 @@
import { describe, expect, it } from 'vitest';
import { ColorUtils } from './ColorUtils';
import { FixedSizeStack } from './FixedSizeStack';
import { LocalizeFormattedNumber } from './LocalizeFormattedNumber';
describe('LocalizeFormattedNumber', () =>
{
it('returns "0" for zero / NaN / null / undefined', () =>
{
expect(LocalizeFormattedNumber(0)).toBe('0');
expect(LocalizeFormattedNumber(NaN)).toBe('0');
expect(LocalizeFormattedNumber(null)).toBe('0');
expect(LocalizeFormattedNumber(undefined as unknown as number)).toBe('0');
});
it('keeps numbers under 1000 unchanged', () =>
{
expect(LocalizeFormattedNumber(42)).toBe('42');
expect(LocalizeFormattedNumber(999)).toBe('999');
});
it('inserts a thin space every 3 digits for >=1000', () =>
{
expect(LocalizeFormattedNumber(1000)).toBe('1 000');
expect(LocalizeFormattedNumber(1_234_567)).toBe('1 234 567');
expect(LocalizeFormattedNumber(10_000_000)).toBe('10 000 000');
});
});
describe('ColorUtils', () =>
{
describe('makeColorHex', () =>
{
it('prepends "#" to the given color string', () =>
{
expect(ColorUtils.makeColorHex('ff0000')).toBe('#ff0000');
expect(ColorUtils.makeColorHex('abc')).toBe('#abc');
});
});
describe('makeColorNumberHex', () =>
{
it('pads to 6 hex chars and prepends "#"', () =>
{
expect(ColorUtils.makeColorNumberHex(0xff0000)).toBe('#ff0000');
expect(ColorUtils.makeColorNumberHex(0x00ff00)).toBe('#00ff00');
expect(ColorUtils.makeColorNumberHex(0)).toBe('#000000');
});
it('pads short hex values with leading zeros', () =>
{
expect(ColorUtils.makeColorNumberHex(0xff)).toBe('#0000ff');
expect(ColorUtils.makeColorNumberHex(1)).toBe('#000001');
});
});
describe('convertFromHex', () =>
{
it('parses a "#"-prefixed hex string to a number', () =>
{
expect(ColorUtils.convertFromHex('#ff0000')).toBe(0xff0000);
expect(ColorUtils.convertFromHex('#000000')).toBe(0);
expect(ColorUtils.convertFromHex('#ffffff')).toBe(0xffffff);
});
it('also handles strings without the leading "#"', () =>
{
expect(ColorUtils.convertFromHex('00ff00')).toBe(0x00ff00);
});
});
describe('int_to_8BitVals / eight_bitVals_to_int', () =>
{
it('roundtrips: int -> [a,r,g,b] -> int', () =>
{
const original = 0x12345678;
const [ a, b, c, d ] = ColorUtils.int_to_8BitVals(original);
expect(a).toBe(0x12);
expect(b).toBe(0x34);
expect(c).toBe(0x56);
expect(d).toBe(0x78);
expect(ColorUtils.eight_bitVals_to_int(a, b, c, d)).toBe(original);
});
it('roundtrips zero', () =>
{
const parts = ColorUtils.int_to_8BitVals(0);
expect(parts).toEqual([ 0, 0, 0, 0 ]);
expect(ColorUtils.eight_bitVals_to_int(0, 0, 0, 0)).toBe(0);
});
});
describe('int2rgb', () =>
{
it('produces rgba(r,g,b,1) for an RGB integer', () =>
{
expect(ColorUtils.int2rgb(0xff0000)).toBe('rgba(255,0,0,1)');
expect(ColorUtils.int2rgb(0x00ff00)).toBe('rgba(0,255,0,1)');
expect(ColorUtils.int2rgb(0x0000ff)).toBe('rgba(0,0,255,1)');
});
it('returns black for 0', () =>
{
expect(ColorUtils.int2rgb(0)).toBe('rgba(0,0,0,1)');
});
});
});
describe('FixedSizeStack', () =>
{
it('grows up to maxSize then overwrites the oldest entry', () =>
{
const stack = new FixedSizeStack(3);
stack.addValue(10);
stack.addValue(20);
stack.addValue(30);
expect(stack.getMax()).toBe(30);
expect(stack.getMin()).toBe(10);
// Capacity hit — 40 overwrites 10
stack.addValue(40);
expect(stack.getMin()).toBe(20);
expect(stack.getMax()).toBe(40);
// 50 overwrites 20
stack.addValue(50);
expect(stack.getMin()).toBe(30);
expect(stack.getMax()).toBe(50);
});
it('reset clears all values', () =>
{
const stack = new FixedSizeStack(2);
stack.addValue(100);
stack.addValue(200);
expect(stack.getMax()).toBe(200);
stack.reset();
stack.addValue(7);
expect(stack.getMax()).toBe(7);
expect(stack.getMin()).toBe(7);
});
it('getMax with maxSize > inserted entries returns the inserted value', () =>
{
// FixedSizeStack iterates the whole maxSize window but the
// unfilled slots are `undefined` which fail `> currentMax`, so
// the inserted value wins.
const stack = new FixedSizeStack(5);
stack.addValue(42);
expect(stack.getMax()).toBe(42);
});
it('getMax on an empty stack returns Number.MIN_VALUE', () =>
{
const stack = new FixedSizeStack(3);
expect(stack.getMax()).toBe(Number.MIN_VALUE);
});
});
+194
View File
@@ -0,0 +1,194 @@
import { describe, expect, it } from 'vitest';
import { CloneObject } from './CloneObject';
import { ConvertSeconds } from './ConvertSeconds';
import { LocalizeShortNumber } from './LocalizeShortNumber';
import { GetWiredTimeLocale } from '../wired/GetWiredTimeLocale';
import { WiredDateToString } from '../wired/WiredDateToString';
import { getPrefixFontStyle, parsePrefixColors, PRESET_PREFIX_FONTS } from './PrefixUtils';
describe('ConvertSeconds', () =>
{
it('formats zero seconds as the dd:hh:mm:ss zero string', () =>
{
expect(ConvertSeconds(0)).toBe('00:00:00:00');
});
it('formats one minute correctly', () =>
{
expect(ConvertSeconds(60)).toBe('00:00:01:00');
});
it('formats one hour correctly', () =>
{
expect(ConvertSeconds(3600)).toBe('00:01:00:00');
});
it('formats one day correctly', () =>
{
expect(ConvertSeconds(86400)).toBe('01:00:00:00');
});
it('formats a mixed value (1d 2h 3m 4s)', () =>
{
expect(ConvertSeconds(86400 + 2 * 3600 + 3 * 60 + 4)).toBe('01:02:03:04');
});
it('pads single-digit components with a leading zero', () =>
{
expect(ConvertSeconds(9)).toBe('00:00:00:09');
});
});
describe('LocalizeShortNumber', () =>
{
it('returns "0" for zero, null, undefined, and NaN', () =>
{
expect(LocalizeShortNumber(0)).toBe('0');
expect(LocalizeShortNumber(NaN)).toBe('0');
expect(LocalizeShortNumber(null)).toBe('0');
expect(LocalizeShortNumber(undefined as unknown as number)).toBe('0');
});
it('keeps numbers safely under 1000 unchanged (returns as-is)', () =>
{
expect(LocalizeShortNumber(42)).toBe('42');
// Anything that rounds to >= 1.0K (i.e. >= 950) crosses into the K bucket
expect(LocalizeShortNumber(949)).toBe('949');
});
it('rounds 950..999 up into the K bucket (documented quirk)', () =>
{
expect(LocalizeShortNumber(950)).toBe('1K');
expect(LocalizeShortNumber(999)).toBe('1K');
});
it('uses K for thousands', () =>
{
expect(LocalizeShortNumber(1500)).toBe('1.5K');
expect(LocalizeShortNumber(12_345)).toBe('12.3K');
});
it('uses M for millions', () =>
{
expect(LocalizeShortNumber(2_500_000)).toBe('2.5M');
});
it('uses B for billions', () =>
{
expect(LocalizeShortNumber(3_700_000_000)).toBe('3.7B');
});
it('preserves the sign for negative values', () =>
{
expect(LocalizeShortNumber(-1500)).toBe('-1.5K');
expect(LocalizeShortNumber(-2_500_000)).toBe('-2.5M');
});
});
describe('CloneObject', () =>
{
it('returns primitives unchanged', () =>
{
expect(CloneObject(42)).toBe(42);
expect(CloneObject('hello')).toBe('hello');
expect(CloneObject(null)).toBe(null);
expect(CloneObject(undefined)).toBe(undefined);
});
it('returns a new object instance for object inputs', () =>
{
const original = { a: 1, b: 'two' };
const copy = CloneObject(original);
expect(copy).not.toBe(original);
expect(copy).toEqual(original);
});
it('preserves enumerable own keys', () =>
{
const original = { x: 1, y: 2, z: 3 };
const copy = CloneObject(original);
expect(copy.x).toBe(1);
expect(copy.y).toBe(2);
expect(copy.z).toBe(3);
});
});
describe('GetWiredTimeLocale', () =>
{
// The renderer encodes time as `value = seconds * 2` so even values
// are whole seconds, odd values are half-seconds.
it('returns "0" for value 0', () =>
{
expect(GetWiredTimeLocale(0)).toBe('0');
});
it('returns whole seconds for even values', () =>
{
expect(GetWiredTimeLocale(2)).toBe('1');
expect(GetWiredTimeLocale(10)).toBe('5');
expect(GetWiredTimeLocale(60)).toBe('30');
});
it('returns half-second formatting for odd values', () =>
{
expect(GetWiredTimeLocale(1)).toBe('0.5');
expect(GetWiredTimeLocale(3)).toBe('1.5');
expect(GetWiredTimeLocale(11)).toBe('5.5');
});
});
describe('WiredDateToString', () =>
{
it('zero-pads single-digit month / day / hour / minute', () =>
{
const d = new Date(2024, 0, 5, 7, 9); // Jan 5, 2024, 07:09
expect(WiredDateToString(d)).toBe('2024/01/05 07:09');
});
it('formats two-digit values without extra padding', () =>
{
const d = new Date(2024, 11, 31, 23, 59); // Dec 31, 2024, 23:59
expect(WiredDateToString(d)).toBe('2024/12/31 23:59');
});
});
describe('PrefixUtils.parsePrefixColors', () =>
{
it('returns an empty array when text or colors are empty', () =>
{
expect(parsePrefixColors('', '#fff')).toEqual([]);
expect(parsePrefixColors('abc', '')).toEqual([]);
});
it('maps each text character to the nth color', () =>
{
expect(parsePrefixColors('ab', '#f00,#0f0')).toEqual([ '#f00', '#0f0' ]);
});
it('reuses the last color when the text is longer than the color list', () =>
{
expect(parsePrefixColors('abcd', '#f00,#0f0')).toEqual([ '#f00', '#0f0', '#0f0', '#0f0' ]);
});
});
describe('PrefixUtils.getPrefixFontStyle', () =>
{
it('returns an empty object for the default (empty) font id', () =>
{
expect(getPrefixFontStyle('')).toEqual({});
});
it('returns a fontFamily for a known preset', () =>
{
const out = getPrefixFontStyle('pixel');
expect(out.fontFamily).toBe(PRESET_PREFIX_FONTS.find(p => p.id === 'pixel')?.family);
});
it('returns an empty object for an unknown font id', () =>
{
expect(getPrefixFontStyle('does-not-exist')).toEqual({});
});
});
+100
View File
@@ -0,0 +1,100 @@
import { describe, expect, it, vi } from 'vitest';
/**
* Mock LocalizeText (which transitively imports @nitrots/nitro-renderer)
* with a deterministic stub. The stub returns `key|amount` so each test
* can assert both the bucket FriendlyTime chose AND the value it computed.
*/
vi.mock('./LocalizeText', () => ({
LocalizeText: (key: string, _params?: string[], replacements?: string[]) =>
`${ key }|${ replacements?.[0] ?? '' }`
}));
import { FriendlyTime } from './FriendlyTime';
const MINUTE = 60;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
const MONTH = 30 * DAY;
const YEAR = 365 * DAY;
describe('FriendlyTime.format', () =>
{
it('uses the seconds bucket for small values', () =>
{
expect(FriendlyTime.format(5)).toBe('friendlytime.seconds|5');
expect(FriendlyTime.format(0)).toBe('friendlytime.seconds|0');
});
it('uses the minutes bucket once we cross 3 * 60s (default threshold)', () =>
{
expect(FriendlyTime.format(4 * MINUTE)).toBe('friendlytime.minutes|4');
expect(FriendlyTime.format(10 * MINUTE)).toBe('friendlytime.minutes|10');
});
it('uses the hours bucket above 3 * HOUR', () =>
{
expect(FriendlyTime.format(4 * HOUR)).toBe('friendlytime.hours|4');
});
it('uses the days bucket above 3 * DAY', () =>
{
expect(FriendlyTime.format(5 * DAY)).toBe('friendlytime.days|5');
});
it('uses the months bucket above 3 * MONTH', () =>
{
expect(FriendlyTime.format(4 * MONTH)).toBe('friendlytime.months|4');
});
it('uses the years bucket above 3 * YEAR', () =>
{
expect(FriendlyTime.format(4 * YEAR)).toBe('friendlytime.years|4');
});
it('rounds half-hours correctly inside the hours bucket', () =>
{
// 4.5 hours -> rounds to 5
expect(FriendlyTime.format((4 * HOUR) + (30 * MINUTE))).toBe('friendlytime.hours|5');
});
it('threshold=1 lets the larger bucket win sooner', () =>
{
// With default threshold=3, 90s would stay in "seconds"; with threshold=1
// it crosses into "minutes" (90s > 1*60s).
expect(FriendlyTime.format(90, '', 1)).toBe('friendlytime.minutes|2');
});
it('key suffix is appended to the bucket key', () =>
{
// Useful for plurals / variants ('s' for singular fallback, etc.)
expect(FriendlyTime.format(5, '.foo')).toBe('friendlytime.seconds.foo|5');
expect(FriendlyTime.format(4 * HOUR, '.foo')).toBe('friendlytime.hours.foo|4');
});
});
describe('FriendlyTime.shortFormat', () =>
{
it('uses the .short variant of each bucket', () =>
{
expect(FriendlyTime.shortFormat(5)).toBe('friendlytime.seconds.short|5');
expect(FriendlyTime.shortFormat(4 * MINUTE)).toBe('friendlytime.minutes.short|4');
expect(FriendlyTime.shortFormat(4 * HOUR)).toBe('friendlytime.hours.short|4');
expect(FriendlyTime.shortFormat(5 * DAY)).toBe('friendlytime.days.short|5');
expect(FriendlyTime.shortFormat(4 * MONTH)).toBe('friendlytime.months.short|4');
expect(FriendlyTime.shortFormat(4 * YEAR)).toBe('friendlytime.years.short|4');
});
it('respects the optional key suffix and threshold', () =>
{
expect(FriendlyTime.shortFormat(2 * MINUTE, '.bar', 1)).toBe('friendlytime.minutes.short.bar|2');
});
});
describe('FriendlyTime.getLocalization', () =>
{
it('formats an arbitrary key and amount with the (amount, AMOUNT) replacements', () =>
{
expect(FriendlyTime.getLocalization('whatever', 42)).toBe('whatever|42');
});
});
+4 -1
View File
@@ -1,4 +1,7 @@
let _youtubeEnabled = false;
export const getYoutubeRoomEnabled = () => _youtubeEnabled;
export const setYoutubeRoomEnabled = (enabled: boolean) => { _youtubeEnabled = enabled; };
export const setYoutubeRoomEnabled = (enabled: boolean) =>
{
_youtubeEnabled = enabled;
};
@@ -1,4 +1,4 @@
const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }) as Record<string, string>;
const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' });
export const NICK_ICON_URLS: Record<string, string> = Object.entries(rawNickIcons).reduce((accumulator, [ path, url ]) =>
{
+27 -7
View File
@@ -1,3 +1,4 @@
import { GetConfiguration } from '@nitrots/nitro-renderer';
import JSON5 from 'json5';
import { configFileUrl, getClientMode, installSecureFetch } from './secure-assets';
@@ -32,7 +33,6 @@ const ensureMobileViewport = () =>
};
ensureMobileViewport();
installSecureFetch();
const setBootDebug = (message: string) =>
{
@@ -43,11 +43,10 @@ const setBootDebug = (message: string) =>
if(secureNode) secureNode.textContent = `${ secureNode.textContent }\n${ message }`;
}
catch {}
catch
{}
};
setBootDebug('boot: secure fetch installed');
const deployBaseUrl = (): string =>
{
try
@@ -55,14 +54,16 @@ const deployBaseUrl = (): string =>
const loaderBase = (window as any).__nitroLoaderBase;
if(typeof loaderBase === 'string' && loaderBase.length) return new URL('..', loaderBase).toString();
}
catch {}
catch
{}
try
{
const moduleUrl = (import.meta as any).url;
if(typeof moduleUrl === 'string' && moduleUrl.length) return new URL('..', new URL('.', moduleUrl)).toString();
}
catch {}
catch
{}
try
{
@@ -73,7 +74,8 @@ const deployBaseUrl = (): string =>
return trimmed ? `${ window.location.origin }/${ trimmed }/` : `${ window.location.origin }/`;
}
}
catch {}
catch
{}
return `${ window.location.origin }/`;
};
@@ -123,6 +125,9 @@ const loadClientMode = async () =>
await loadClientMode();
installSecureFetch();
setBootDebug('boot: secure fetch installed');
const search = new URLSearchParams(window.location.search);
const clientMode = getClientMode();
@@ -141,6 +146,21 @@ const clientMode = getClientMode();
setBootDebug('boot: NitroConfig assigned');
// Load renderer-config.json + ui-config.json BEFORE rendering React. Otherwise
// the first paint triggers a flood of "Missing configuration key" warnings for
// every key components read synchronously (asset.url, login.endpoint, …) until
// prepare()'s deferred init() finally lands. Doing it here makes the config
// already populated by the time index.tsx mounts <App/>.
try
{
await GetConfiguration().init();
setBootDebug('boot: configuration init done');
}
catch(error)
{
setBootDebug(`boot: configuration init failed ${ error?.message || error }`);
}
import('./index')
.then(() => setBootDebug('boot: app bundle imported'))
.catch(error =>
+3 -3
View File
@@ -44,9 +44,9 @@ export const Button: FC<ButtonProps> = props =>
if(variant == 'dark')
newClassNames.push('text-white bg-dark [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#18181bfb] hover:border-[#161619fb]');
if(variant == 'gray')
newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]');
if(variant == 'gray')
newClassNames.push('text-white bg-[#1e7295] border-[#1e7295] [box-shadow:inset_0_2px_#ffffff26,inset_0_-2px_#0000001a,0_1px_#0000001a] hover:text-white hover:bg-[#1a617f] hover:border-[#185b77]');
}
+1 -1
View File
@@ -19,4 +19,4 @@ export const ButtonGroup: FC<ButtonGroupProps> = props =>
}, [ classNames ]);
return <Base classNames={ getClassNames } { ...rest } />;
}
};
+3 -3
View File
@@ -1,4 +1,4 @@
import { createContext, FC, ProviderProps, useContext } from 'react';
import { createContext, FC, ReactNode, useContext } from 'react';
export interface IGridContext
{
@@ -9,9 +9,9 @@ const GridContext = createContext<IGridContext>({
isCssGrid: false
});
export const GridContextProvider: FC<ProviderProps<IGridContext>> = props =>
export const GridContextProvider: FC<{ value: IGridContext; children?: ReactNode }> = props =>
{
return <GridContext.Provider value={ props.value }>{ props.children }</GridContext.Provider>;
return <GridContext value={ props.value }>{ props.children }</GridContext>;
};
export const useGridContext = () => useContext(GridContext);
+1 -1
View File
@@ -1,4 +1,4 @@
import { FC, PropsWithChildren, useEffect, useRef, useState } from 'react';
import { FC, JSX, PropsWithChildren, useEffect, useRef, useState } from 'react';
export const ReactPopover: FC<PropsWithChildren<{
content: JSX.Element;
+1 -1
View File
@@ -145,4 +145,4 @@ export const Slider: FC<SliderProps> = props =>
) }
</Flex>
);
}
};
+16 -13
View File
@@ -20,7 +20,8 @@ export interface TextProps extends BaseProps<HTMLDivElement> {
textBreak?: boolean;
}
export const Text: FC<TextProps> = props => {
export const Text: FC<TextProps> = props =>
{
const {
variant = 'black',
fontWeight = null,
@@ -40,20 +41,22 @@ export const Text: FC<TextProps> = props => {
...rest
} = props;
const getClassNames = useMemo(() => {
const getClassNames = useMemo(() =>
{
const newClassNames: string[] = [truncate ? 'block' : 'inline'];
if (variant) {
if (variant === 'primary') newClassNames.push('text-[#1e7295]');
if (variant == 'secondary') newClassNames.push('text-[#185d79]');
if (variant === 'black') newClassNames.push('text-[#000000]');
if (variant == 'dark') newClassNames.push('text-[#18181b]');
if (variant === 'gray') newClassNames.push('text-[#6b7280]');
if (variant === 'white') newClassNames.push('text-[#ffffff]');
if (variant == 'success') newClassNames.push('text-[#00800b]');
if (variant == 'danger') newClassNames.push('text-[#a81a12]');
if (variant == 'warning') newClassNames.push('text-[#ffc107]');
}
if (variant)
{
if (variant === 'primary') newClassNames.push('text-[#1e7295]');
if (variant == 'secondary') newClassNames.push('text-[#185d79]');
if (variant === 'black') newClassNames.push('text-[#000000]');
if (variant == 'dark') newClassNames.push('text-[#18181b]');
if (variant === 'gray') newClassNames.push('text-[#6b7280]');
if (variant === 'white') newClassNames.push('text-[#ffffff]');
if (variant == 'success') newClassNames.push('text-[#00800b]');
if (variant == 'danger') newClassNames.push('text-[#a81a12]');
if (variant == 'warning') newClassNames.push('text-[#ffc107]');
}
if (bold) newClassNames.push('font-bold');
if (fontWeight) newClassNames.push('font-' + fontWeight);
+3 -3
View File
@@ -1,4 +1,4 @@
import { createContext, FC, ProviderProps, useContext } from 'react';
import { createContext, FC, ReactNode, useContext } from 'react';
interface INitroCardContext
{
@@ -9,9 +9,9 @@ const NitroCardContext = createContext<INitroCardContext>({
theme: null
});
export const NitroCardContextProvider: FC<ProviderProps<INitroCardContext>> = props =>
export const NitroCardContextProvider: FC<{ value: INitroCardContext; children?: ReactNode }> = props =>
{
return <NitroCardContext.Provider value={ props.value }>{ props.children }</NitroCardContext.Provider>;
return <NitroCardContext value={ props.value }>{ props.children }</NitroCardContext>;
};
export const useNitroCardContext = () => useContext(NitroCardContext);
+1 -1
View File
@@ -12,7 +12,7 @@ export interface NitroCardViewProps extends DraggableWindowProps, ColumnProps
export const NitroCardView: FC<NitroCardViewProps> = props =>
{
const { theme = 'primary', uniqueKey = null, handleSelector = '.drag-handler', windowPosition = DraggableWindowPosition.CENTER, disableDrag = false, overflow = 'hidden', position = 'relative', gap = 0, classNames = [], isResizable = true, ...rest } = props;
const elementRef = useRef<HTMLDivElement>();
const elementRef = useRef<HTMLDivElement>(null);
const getClassNames = useMemo(() =>
{
@@ -1,4 +1,4 @@
import { createContext, Dispatch, FC, ProviderProps, SetStateAction, useContext } from 'react';
import { createContext, Dispatch, FC, ReactNode, SetStateAction, useContext } from 'react';
export interface INitroCardAccordionContext
{
@@ -13,9 +13,9 @@ const NitroCardAccordionContext = createContext<INitroCardAccordionContext>({
closeAll: null
});
export const NitroCardAccordionContextProvider: FC<ProviderProps<INitroCardAccordionContext>> = props =>
export const NitroCardAccordionContextProvider: FC<{ value: INitroCardAccordionContext; children?: ReactNode }> = props =>
{
return <NitroCardAccordionContext.Provider { ...props } />;
return <NitroCardAccordionContext { ...props } />;
};
export const useNitroCardAccordionContext = () => useContext(NitroCardAccordionContext);
+55 -28
View File
@@ -21,7 +21,8 @@ export interface DraggableWindowProps {
children?: ReactNode;
}
export const DraggableWindow: FC<DraggableWindowProps> = props => {
export const DraggableWindow: FC<DraggableWindowProps> = props =>
{
const { uniqueKey = null, handleSelector = '.drag-handler', windowPosition = DraggableWindowPosition.CENTER, disableDrag = false, dragStyle = {}, children = null, offsetLeft = 0, offsetTop = 0 } = props;
const [delta, setDelta] = useState<{ x: number, y: number }>({ x: 0, y: 0 });
const [offset, setOffset] = useState<{ x: number, y: number }>({ x: 0, y: 0 });
@@ -32,47 +33,58 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
const elementRef = useRef<HTMLDivElement>();
const bringToTop = useCallback(() => {
let zIndex = 400;
for (const existingWindow of CURRENT_WINDOWS) {
for (const existingWindow of CURRENT_WINDOWS)
{
zIndex += 1;
existingWindow.style.zIndex = zIndex.toString();
}
}, []);
const moveCurrentWindow = useCallback(() => {
const moveCurrentWindow = useCallback(() =>
{
const index = CURRENT_WINDOWS.indexOf(elementRef.current);
if (index === -1) {
if (index === -1)
{
CURRENT_WINDOWS.push(elementRef.current);
} else if (index === (CURRENT_WINDOWS.length - 1)) return;
else if (index >= 0) {
}
else if (index === (CURRENT_WINDOWS.length - 1)) return;
else if (index >= 0)
{
CURRENT_WINDOWS.splice(index, 1);
CURRENT_WINDOWS.push(elementRef.current);
}
bringToTop();
}, [bringToTop]);
const onMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) => {
const onMouseDown = useCallback((event: ReactMouseEvent<HTMLDivElement>) =>
{
moveCurrentWindow();
}, [moveCurrentWindow]);
const onTouchStart = useCallback((event: ReactTouchEvent<HTMLDivElement>) => {
const onTouchStart = useCallback((event: ReactTouchEvent<HTMLDivElement>) =>
{
moveCurrentWindow();
}, [moveCurrentWindow]);
const startDragging = useCallback((startX: number, startY: number) => {
const startDragging = useCallback((startX: number, startY: number) =>
{
setStart({ x: startX, y: startY });
setIsDragging(true);
}, []);
const onDragMouseDown = useCallback((event: MouseEvent) => {
const onDragMouseDown = useCallback((event: MouseEvent) =>
{
startDragging(event.clientX, event.clientY);
}, [startDragging]);
const onTouchDown = useCallback((event: TouchEvent) => {
const onTouchDown = useCallback((event: TouchEvent) =>
{
const touch = event.touches[0];
startDragging(touch.clientX, touch.clientY);
}, [startDragging]);
const clampPosition = useCallback((newX: number, newY: number) => {
const clampPosition = useCallback((newX: number, newY: number) =>
{
if (!elementRef.current) return { x: newX, y: newY };
const windowWidth = elementRef.current.offsetWidth;
@@ -87,7 +99,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
return { x: clampedX, y: clampedY };
}, []);
const onDragMouseMove = useCallback((event: MouseEvent) => {
const onDragMouseMove = useCallback((event: MouseEvent) =>
{
if (!elementRef.current || !isDragging) return;
const newDeltaX = event.clientX - start.x;
@@ -99,7 +112,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
setDelta({ x: clampedPos.x - offset.x, y: clampedPos.y - offset.y });
}, [start, offset, clampPosition, isDragging]);
const onDragTouchMove = useCallback((event: TouchEvent) => {
const onDragTouchMove = useCallback((event: TouchEvent) =>
{
if (!elementRef.current || !isDragging) return;
const touch = event.touches[0];
@@ -112,7 +126,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
setDelta({ x: clampedPos.x - offset.x, y: clampedPos.y - offset.y });
}, [start, offset, clampPosition, isDragging]);
const completeDrag = useCallback(() => {
const completeDrag = useCallback(() =>
{
if (!elementRef.current || !dragHandler || !isDragging) return;
const finalOffsetX = offset.x + delta.x;
@@ -123,29 +138,34 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
setOffset({ x: clampedPos.x, y: clampedPos.y });
setIsDragging(false);
if (uniqueKey !== null) {
const newStorage = { ...GetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`) } as WindowSaveOptions;
if (uniqueKey !== null)
{
const newStorage = { ...GetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`) };
newStorage.offset = { x: clampedPos.x, y: clampedPos.y };
SetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`, newStorage);
}
}, [dragHandler, delta, offset, uniqueKey, clampPosition, isDragging]);
const onDragMouseUp = useCallback((event: MouseEvent) => {
const onDragMouseUp = useCallback((event: MouseEvent) =>
{
completeDrag();
}, [completeDrag]);
const onDragTouchUp = useCallback((event: TouchEvent) => {
const onDragTouchUp = useCallback((event: TouchEvent) =>
{
completeDrag();
}, [completeDrag]);
useLayoutEffect(() => {
useLayoutEffect(() =>
{
const element = elementRef.current as HTMLElement;
if (!element) return;
CURRENT_WINDOWS.push(element);
bringToTop();
if (!disableDrag) {
if (!disableDrag)
{
const handle = element.querySelector(handleSelector);
if (handle) setDragHandler(handle as HTMLElement);
}
@@ -155,7 +175,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
let offsetX = 0;
let offsetY = 0;
switch (windowPosition) {
switch (windowPosition)
{
case DraggableWindowPosition.TOP_CENTER:
offsetY = 50 + offsetTop;
offsetX = (window.innerWidth - windowWidth) / 2 + offsetLeft;
@@ -175,25 +196,29 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
setDelta({ x: 0, y: 0 });
setIsPositioned(true);
return () => {
return () =>
{
const index = CURRENT_WINDOWS.indexOf(element);
if (index >= 0) CURRENT_WINDOWS.splice(index, 1);
};
}, [handleSelector, windowPosition, uniqueKey, disableDrag, offsetLeft, offsetTop, bringToTop]);
useEffect(() => {
useEffect(() =>
{
if (!dragHandler) return;
dragHandler.addEventListener(MouseEventType.MOUSE_DOWN, onDragMouseDown);
dragHandler.addEventListener(TouchEventType.TOUCH_START, onTouchDown);
return () => {
return () =>
{
dragHandler.removeEventListener(MouseEventType.MOUSE_DOWN, onDragMouseDown);
dragHandler.removeEventListener(TouchEventType.TOUCH_START, onTouchDown);
};
}, [dragHandler, onDragMouseDown, onTouchDown]);
useEffect(() => {
useEffect(() =>
{
if (!isDragging) return;
document.addEventListener(MouseEventType.MOUSE_UP, onDragMouseUp);
@@ -201,7 +226,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
document.addEventListener(MouseEventType.MOUSE_MOVE, onDragMouseMove);
document.addEventListener(TouchEventType.TOUCH_MOVE, onDragTouchMove);
return () => {
return () =>
{
document.removeEventListener(MouseEventType.MOUSE_UP, onDragMouseUp);
document.removeEventListener(TouchEventType.TOUCH_END, onDragTouchUp);
document.removeEventListener(MouseEventType.MOUSE_MOVE, onDragMouseMove);
@@ -209,7 +235,8 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
};
}, [isDragging, onDragMouseUp, onDragMouseMove, onDragTouchUp, onDragTouchMove]);
useEffect(() => {
useEffect(() =>
{
if (!uniqueKey) return;
const localStorage = GetLocalStorage<WindowSaveOptions>(`nitro.windows.${uniqueKey}`);
@@ -0,0 +1,96 @@
/* @vitest-environment jsdom */
import { NitroLogger } from '@nitrots/nitro-renderer';
import { cleanup, render, screen } from '@testing-library/react';
import { FC } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { WidgetErrorBoundary } from './WidgetErrorBoundary';
// `import { NitroLogger } from '@nitrots/nitro-renderer'` resolves to
// `src/nitro-renderer.mock.ts` via the alias in vitest.config.mts.
// The SUT imports the same path, so both reach the same vi.fn instance.
describe('WidgetErrorBoundary', () =>
{
beforeEach(() =>
{
vi.clearAllMocks();
// react-error-boundary lets React's "uncaught error" log through
// by default — silence it so jsdom doesn't dump a stack trace
// every time we deliberately throw below.
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() =>
{
cleanup();
vi.restoreAllMocks();
});
it('renders its children when nothing throws', () =>
{
render(
<WidgetErrorBoundary name="HappyPath">
<span data-testid="child">visible</span>
</WidgetErrorBoundary>
);
expect(screen.getByTestId('child')).toHaveTextContent('visible');
});
it('swallows a render-time error to a silent fallback and logs it', () =>
{
const Boom: FC = () =>
{
throw new Error('kaboom');
};
const { container } = render(
<WidgetErrorBoundary name="Boom">
<Boom />
</WidgetErrorBoundary>
);
// Default fallback is `() => null` → boundary subtree is empty.
expect(container).toBeEmptyDOMElement();
expect(NitroLogger.error).toHaveBeenCalledTimes(1);
const [ message, err ] = (NitroLogger.error as ReturnType<typeof vi.fn>).mock.calls[0];
expect(message).toBe('[Widget:Boom] crashed');
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toBe('kaboom');
});
it('renders a custom fallback node when provided', () =>
{
const Boom: FC = () =>
{
throw new Error('explode');
};
render(
<WidgetErrorBoundary name="WithFallback" fallback={ <div data-testid="fb">offline</div> }>
<Boom />
</WidgetErrorBoundary>
);
expect(screen.getByTestId('fb')).toHaveTextContent('offline');
});
it('uses "unknown" as the widget name when the prop is omitted', () =>
{
const Boom: FC = () =>
{
throw new Error('anonymous');
};
render(
<WidgetErrorBoundary>
<Boom />
</WidgetErrorBoundary>
);
expect(NitroLogger.error).toHaveBeenCalledTimes(1);
expect((NitroLogger.error as ReturnType<typeof vi.fn>).mock.calls[0][0]).toBe('[Widget:unknown] crashed');
});
});
@@ -0,0 +1,28 @@
import { NitroLogger } from '@nitrots/nitro-renderer';
import { FC, ReactNode } from 'react';
import { ErrorBoundary, FallbackProps } from 'react-error-boundary';
interface WidgetErrorBoundaryProps
{
name?: string;
fallback?: ReactNode;
children: ReactNode;
}
const SilentFallback = (_props: FallbackProps) => null;
/**
* Wraps a (room) widget so a runtime error inside it degrades gracefully
* instead of unmounting the whole UI. Errors are logged to NitroLogger
* with the widget name.
*
* Bonus addition from docs/ARCHITECTURE.md.
*/
export const WidgetErrorBoundary: FC<WidgetErrorBoundaryProps> = ({ name = 'unknown', fallback, children }) =>
(
<ErrorBoundary
FallbackComponent={ fallback ? () => <>{ fallback }</> : SilentFallback }
onError={ (err) => NitroLogger.error(`[Widget:${ name }] crashed`, err) }>
{ children }
</ErrorBoundary>
);
+2 -1
View File
@@ -16,8 +16,9 @@ export * from './card';
export * from './card/accordion';
export * from './card/tabs';
export * from './draggable-window';
export * from './error-boundary/WidgetErrorBoundary';
export * from './layout';
export * from './layout/limited-edition';
export * from './types';
export * from "./Slider";
export * from './Slider';
export * from './utils';
+9 -2
View File
@@ -20,6 +20,12 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
const [ avatarUrl, setAvatarUrl ] = useState<string>(null);
const [ isReady, setIsReady ] = useState<boolean>(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(() =>
{
@@ -52,6 +58,7 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
{
if(!isReady) return;
const requestId = ++requestIdRef.current;
const figureKey = [ figure, gender, direction, headOnly ].join('-');
if(AVATAR_IMAGE_CACHE.has(figureKey))
@@ -62,7 +69,7 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
{
const resetFigure = (_figure: string) =>
{
if(isDisposed.current) return;
if(isDisposed.current || (requestIdRef.current !== requestId)) return;
const avatarImage = GetAvatarRenderManager().createAvatarImage(_figure, AvatarScaleType.LARGE, gender, { resetFigure: (figure: string) => resetFigure(figure), dispose: null, disposed: false });
@@ -74,7 +81,7 @@ export const LayoutAvatarImageView: FC<LayoutAvatarImageViewProps> = props =>
const imageUrl = avatarImage.processAsImageUrl(setType);
if(imageUrl && !isDisposed.current)
if(imageUrl && !isDisposed.current && (requestIdRef.current === requestId))
{
if(!avatarImage.isPlaceholder())
{
+15 -5
View File
@@ -17,21 +17,29 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
const { productType = 's', productClassId = -1, direction = 2, extraData = '', scale = 1, style = {}, ...rest } = props;
const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null);
const isMounted = useRef(true);
// Request id bumped by the effect on every prop change. The async
// generateImage / imageReady callbacks capture it and only write
// back if it still matches — prevents an older, slower fetch from
// overwriting a newer one when props change in quick succession.
const requestIdRef = useRef(0);
useEffect(() =>
{
isMounted.current = true;
return () => { isMounted.current = false; };
return () =>
{
isMounted.current = false;
};
}, []);
const updateImage = useCallback(async (texture: any) =>
const updateImage = useCallback(async (texture: any, requestId: number) =>
{
if(!texture) return;
const image = await TextureUtils.generateImage(texture);
if(image && isMounted.current) setImageElement(image);
if(image && isMounted.current && (requestIdRef.current === requestId)) setImageElement(image);
}, []);
const getStyle = useMemo(() =>
@@ -59,12 +67,14 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
useEffect(() =>
{
const requestId = ++requestIdRef.current;
setImageElement(null);
let imageResult: ImageResult = null;
const listener: IGetImageListener = {
imageReady: (result) => updateImage(result?.data),
imageReady: (result) => updateImage(result?.data, requestId),
imageFailed: null
};
@@ -78,7 +88,7 @@ export const LayoutFurniImageView: FC<LayoutFurniImageViewProps> = props =>
break;
}
if(imageResult?.data) updateImage(imageResult.data);
if(imageResult?.data) updateImage(imageResult.data, requestId);
}, [ productType, productClassId, direction, extraData, updateImage ]);
return <Base classNames={ [ 'furni-image' ] } style={ getStyle } { ...rest } />;
+7 -4
View File
@@ -9,11 +9,13 @@ interface LayoutMiniCameraViewProps {
onClose: () => void;
}
export const LayoutMiniCameraView: FC<LayoutMiniCameraViewProps> = props => {
export const LayoutMiniCameraView: FC<LayoutMiniCameraViewProps> = props =>
{
const { roomId = -1, textureReceiver = null, onClose = null } = props;
const elementRef = useRef<HTMLDivElement>();
const elementRef = useRef<HTMLDivElement>(null);
const getCameraBounds = () => {
const getCameraBounds = () =>
{
if (!elementRef || !elementRef.current) return null;
const frameBounds = elementRef.current.getBoundingClientRect();
@@ -26,7 +28,8 @@ export const LayoutMiniCameraView: FC<LayoutMiniCameraViewProps> = props => {
);
};
const takePicture = () => {
const takePicture = () =>
{
PlaySound(SoundNames.CAMERA_SHUTTER);
textureReceiver(GetRoomEngine().createTextureFromRoom(roomId, 1, getCameraBounds()));
};
+5 -3
View File
@@ -67,10 +67,12 @@ export const LayoutPetImageView: FC<LayoutPetImageViewProps> = props =>
if(petTypeId === 16) petHeadOnly = false;
const imageResult = GetRoomEngine().getRoomObjectPetImage(petTypeId, petPaletteId, petColor1, new Vector3d((direction * 45)), 64, {
imageReady: async (id, texture, image) =>
imageReady: async (result) =>
{
if(isDisposed.current) return;
const { image, data: texture } = result;
if(image)
{
setPetUrl(image.src);
@@ -85,9 +87,9 @@ export const LayoutPetImageView: FC<LayoutPetImageViewProps> = props =>
setHeight(texture.height);
}
},
imageFailed: (id) =>
imageFailed: () =>
{
// no-op
}
}, petHeadOnly, 0, petCustomParts, posture);
@@ -21,7 +21,10 @@ export const LayoutRoomObjectImageView: FC<LayoutRoomObjectImageViewProps> = pro
{
isMounted.current = true;
return () => { isMounted.current = false; };
return () =>
{
isMounted.current = false;
};
}, []);
const getStyle = useMemo(() =>
@@ -50,13 +53,16 @@ export const LayoutRoomObjectImageView: FC<LayoutRoomObjectImageViewProps> = pro
useEffect(() =>
{
const imageResult = GetRoomEngine().getRoomObjectImage(roomId, objectId, category, new Vector3d(direction * 45), 64, {
imageReady: async (id, texture, image) =>
imageReady: async (result) =>
{
const img = await TextureUtils.generateImage(texture);
const img = await TextureUtils.generateImage(result.data);
if(img && isMounted.current) setImageElement(img);
},
imageFailed: null
imageFailed: () =>
{
// no-op
}
});
if(!imageResult) return;
@@ -7,7 +7,7 @@ export const LayoutRoomPreviewerView: FC<{
}> = props =>
{
const { roomPreviewer = null, height = 0 } = props;
const elementRef = useRef<HTMLDivElement>();
const elementRef = useRef<HTMLDivElement>(null);
const onClick = (event: MouseEvent<HTMLDivElement>) =>
{
+1 -1
View File
@@ -1 +1 @@
export type ColorVariantType = 'primary' | 'success' | 'danger' | 'secondary' | 'link' | 'black' | 'white' | 'dark' | 'warning' | 'muted' | 'light' | 'gray';
export type ColorVariantType = 'primary' | 'success' | 'danger' | 'secondary' | 'link' | 'black' | 'white' | 'dark' | 'warning' | 'muted' | 'light' | 'gray' | 'outline-primary' | 'outline-secondary' | 'outline-success' | 'outline-danger' | 'outline-warning';
+27 -5
View File
@@ -1,7 +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 { useNitroEvent } from '../hooks';
import { useNitroEventReducer } from '../hooks';
import { AchievementsView } from './achievements/AchievementsView';
import { AvatarEditorView } from './avatar-editor';
import { BadgeCreatorView } from './badge-creator';
@@ -43,11 +43,33 @@ import { WiredCreatorToolsView } from './wired-tools/WiredCreatorToolsView';
export const MainView: FC<{}> = props =>
{
const [ isReady, setIsReady ] = useState(false);
const [ landingViewVisible, setLandingViewVisible ] = useState(true);
const [ localizationVersion, setLocalizationVersion ] = useState(0);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
// CREATED and ENDED can arrive out of order under flaky reconnects.
// Treating them as two independent setters left landingViewVisible
// contradicting the actual session state (stuck open in-room or
// stuck closed at the hotel view). The reducer carries the active
// session's roomId so a stale ENDED for a previous session is
// ignored — only an ENDED matching the tracked session (or when
// no session is active) is honored.
const { landingViewVisible } = useNitroEventReducer<{ sessionId: number | null; landingViewVisible: boolean }, RoomSessionEvent>(
[ RoomSessionEvent.CREATED, RoomSessionEvent.ENDED ],
(state, event) =>
{
if(event.type === RoomSessionEvent.CREATED)
{
return { sessionId: event.session.roomId, landingViewVisible: false };
}
if((state.sessionId !== null) && (event.session.roomId !== state.sessionId))
{
return state;
}
return { sessionId: null, landingViewVisible: event.openLandingView };
},
{ sessionId: null, landingViewVisible: true }
);
useEffect(() =>
{
@@ -130,7 +152,7 @@ export const MainView: FC<{}> = props =>
<AvatarEffectsView />
<AchievementsView />
<NavigatorView />
<NitrobubbleHiddenView />
<NitrobubbleHiddenView />
<InventoryView />
<CatalogView />
<FriendsView />
+48 -39
View File
@@ -9,10 +9,10 @@ interface AdsenseConfig {
fullWidthResponsive?: boolean;
}
const ADSENSE_SCRIPT_ID = 'google-adsense-script';
const parsePublisherIdFromAdsTxt = (text: string): string | null => {
for (const rawLine of text.split(/\r?\n/)) {
const parsePublisherIdFromAdsTxt = (text: string): string | null =>
{
for (const rawLine of text.split(/\r?\n/))
{
const line = rawLine.split('#')[0].trim();
if (!line) continue;
const parts = line.split(',').map(part => part.trim());
@@ -24,19 +24,8 @@ const parsePublisherIdFromAdsTxt = (text: string): string | null => {
return null;
};
const ensureAdsenseScript = (publisherId: string): void => {
if (typeof document === 'undefined') return;
if (document.getElementById(ADSENSE_SCRIPT_ID)) return;
const script = document.createElement('script');
script.id = ADSENSE_SCRIPT_ID;
script.async = true;
script.crossOrigin = 'anonymous';
script.src = `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }`;
document.head.appendChild(script);
};
export const GoogleAdsView: FC<{}> = () => {
export const GoogleAdsView: FC<{}> = () =>
{
const adsEnabled = GetConfigurationValue<boolean>('show.google.ads', false);
const [ isOpen, setIsOpen ] = useState(false);
const [ publisherId, setPublisherId ] = useState<string | null>(null);
@@ -46,7 +35,8 @@ export const GoogleAdsView: FC<{}> = () => {
const pushedRef = useRef(false);
const autoOpenedRef = useRef(false);
useEffect(() => {
useEffect(() =>
{
if (!adsEnabled) return;
const handler = () => setIsOpen(prev => !prev);
window.addEventListener('ads:toggle', handler);
@@ -56,7 +46,8 @@ export const GoogleAdsView: FC<{}> = () => {
// Auto-open once on initial mount (the login / landing stage).
// Subsequent toggles are driven by the "ads:toggle" window event
// (e.g. the Show Ad button in NitroSystemAlertView).
useEffect(() => {
useEffect(() =>
{
if (!adsEnabled) return;
if (autoOpenedRef.current) return;
autoOpenedRef.current = true;
@@ -64,11 +55,14 @@ export const GoogleAdsView: FC<{}> = () => {
return () => clearTimeout(t);
}, [ adsEnabled ]);
useEffect(() => {
useEffect(() =>
{
let cancelled = false;
(async () => {
try {
(async () =>
{
try
{
const [ adsTxtRes, configRes ] = await Promise.all([
fetch('/ads.txt', { cache: 'no-cache' }),
fetch(configFileUrl('adsense.json', true), { cache: 'no-cache' })
@@ -87,44 +81,54 @@ export const GoogleAdsView: FC<{}> = () => {
if (cancelled) return;
setPublisherId(pubId);
setConfig(cfg);
} catch (err) {
}
catch (err)
{
if (!cancelled) setLoadError((err as Error).message);
}
})();
return () => { cancelled = true; };
return () =>
{
cancelled = true;
};
}, []);
useEffect(() => {
if (!isOpen || !publisherId || !config) return;
ensureAdsenseScript(publisherId);
}, [ isOpen, publisherId, config ]);
useEffect(() => {
if (!isOpen) {
useEffect(() =>
{
if (!isOpen)
{
pushedRef.current = false;
return;
}
if (!insRef.current || pushedRef.current) return;
if (!publisherId || !config?.slot) return;
const tryPush = () => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tryPush = () =>
{
try
{
const w = window as any;
w.adsbygoogle = w.adsbygoogle || [];
w.adsbygoogle.push({});
pushedRef.current = true;
} catch {
}
catch
{
// AdSense script may not be ready yet; retry once
setTimeout(() => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setTimeout(() =>
{
try
{
const w = window as any;
w.adsbygoogle = w.adsbygoogle || [];
w.adsbygoogle.push({});
pushedRef.current = true;
} catch { /* give up */ }
}
catch
{ /* give up */ }
}, 500);
}
};
@@ -138,6 +142,11 @@ export const GoogleAdsView: FC<{}> = () => {
return (
<NitroCardView className="nitro-google-ads" uniqueKey="google-ads" theme="primary">
{ publisherId &&
<script
async
crossOrigin="anonymous"
src={ `https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-${ publisherId }` } /> }
<NitroCardHeaderView headerText="Sponsored" onCloseClick={ () => setIsOpen(false) } />
<NitroCardContentView>
<div className="flex items-center justify-center w-[300px] h-[250px] bg-white">
@@ -1,4 +1,4 @@
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, forwardRef } from 'react';
import { DetailedHTMLProps, HTMLAttributes, PropsWithChildren, Ref } from 'react';
import { classNames } from '../../layout';
import arrowLeftIcon from '../../assets/images/avatareditor/arrow-left-icon.png';
@@ -55,13 +55,14 @@ const ICON_MAP: Record<string, { normal: string; selected?: string }> = {
'wa': { normal: waIcon, selected: waSelectedIcon },
};
export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
type AvatarEditorIconProps = PropsWithChildren<{
icon: string;
selected?: boolean;
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>>((props, ref) =>
{
const { icon = null, selected = false, className = null, children, ...rest } = props;
ref?: Ref<HTMLDivElement>;
}> & DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
export const AvatarEditorIcon = ({ ref, icon = null, selected = false, className = null, children, ...rest }: AvatarEditorIconProps) =>
{
const iconEntry = icon ? ICON_MAP[icon] : null;
if(!iconEntry) return null;
@@ -77,6 +78,4 @@ export const AvatarEditorIcon = forwardRef<HTMLDivElement, PropsWithChildren<{
{ children }
</div>
);
});
AvatarEditorIcon.displayName = 'AvatarEditorIcon';
};
@@ -23,7 +23,10 @@ const findNearestColor = (hex: string, colors: IPartColor[]): IPartColor | null
const cb = color.rgb & 0xFF;
const dist = (r - cr) ** 2 + (g - cg) ** 2 + (b - cb) ** 2;
if(dist < minDist) { minDist = dist; nearest = color; }
if(dist < minDist)
{
minDist = dist; nearest = color;
}
}
return nearest;
@@ -40,7 +43,10 @@ export const AvatarEditorAdvancedColorView: FC<{
useEffect(() =>
{
return () => { if(debounceRef.current) clearTimeout(debounceRef.current); };
return () =>
{
if(debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
const selectedColor = useMemo(() =>
@@ -52,7 +58,7 @@ export const AvatarEditorAdvancedColorView: FC<{
const hexColor = useMemo(() =>
ColorUtils.makeColorNumberHex((selectedColor?.rgb ?? 0) & 0xFFFFFF),
[ selectedColor ]);
[ selectedColor ]);
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) =>
{
@@ -36,8 +36,8 @@ export const AvatarEffectsView: FC<{}> = () =>
switch(parts[1])
{
case 'show': setIsVisible(true); return;
case 'hide': setIsVisible(false); return;
case 'show': setIsVisible(true); return;
case 'hide': setIsVisible(false); return;
case 'toggle': setIsVisible(prev => !prev); return;
}
},
@@ -83,7 +83,10 @@ export const AvatarEffectsView: FC<{}> = () =>
}
})();
return () => { cancelled = true; };
return () =>
{
cancelled = true;
};
}, [ isVisible, effects.length, loadError ]);
const session = GetSessionDataManager();
+24 -15
View File
@@ -1,4 +1,4 @@
import { Dispatch, FC, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react';
import { Dispatch, FC, SetStateAction, use, useCallback, useMemo, useState } from 'react';
import { Base, Grid, Flex, NitroCardView, NitroCardHeaderView, NitroCardTabsView, NitroCardTabsItemView, NitroCardContentView, Text } from '../../common';
import { useRoom } from '../../hooks';
import { GetOptionalConfigurationValue } from '../../api';
@@ -27,6 +27,20 @@ type TabType = typeof TABS[number];
type RemoteData = Partial<Record<'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data' | 'borders.data', any[]>>;
let backgroundsDataPromise: Promise<RemoteData | null> | null = null;
const fetchBackgroundsData = (): Promise<RemoteData | null> =>
{
if(backgroundsDataPromise) return backgroundsDataPromise;
backgroundsDataPromise = fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => (json && typeof json === 'object') ? json as RemoteData : null)
.catch(() => null);
return backgroundsDataPromise;
};
export const BackgroundsView: FC<BackgroundsViewProps> = ({
setIsVisible,
selectedBackground,
@@ -39,27 +53,21 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
setSelectedCardBackground,
selectedBorder,
setSelectedBorder
}) => {
}) =>
{
const [activeTab, setActiveTab] = useState<TabType>('backgrounds');
const [remoteData, setRemoteData] = useState<RemoteData | null>(null);
const remoteData = use(fetchBackgroundsData());
const { roomSession } = useRoom();
useEffect(() => {
let cancelled = false;
fetch(configFileUrl('infostand_backgrounds.json'), { credentials: 'omit' })
.then(r => r.ok ? r.json() : null)
.then(json => { if(!cancelled && json && typeof json === 'object') setRemoteData(json as RemoteData); })
.catch(() => {});
return () => { cancelled = true; };
}, []);
const processData = useCallback((configData: any[], idField: string): ItemData[] => {
const processData = useCallback((configData: any[], idField: string): ItemData[] =>
{
if (!configData?.length) return [];
return configData.map(item => ({ id: typeof item === 'number' ? item : item[idField] }));
}, []);
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data' | 'borders.data'): any[] => {
const readData = useCallback((key: 'backgrounds.data' | 'stands.data' | 'overlays.data' | 'cards.data' | 'borders.data'): any[] =>
{
const fromRemote = remoteData?.[key];
if(Array.isArray(fromRemote)) return fromRemote;
return GetOptionalConfigurationValue<any[]>(key, []) || [];
@@ -73,7 +81,8 @@ export const BackgroundsView: FC<BackgroundsViewProps> = ({
borders: processData(readData('borders.data'), 'borderId')
}), [processData, readData]);
const handleSelection = useCallback((id: number) => {
const handleSelection = useCallback((id: number) =>
{
if (!roomSession) return;
const setters = { backgrounds: setSelectedBackground, stands: setSelectedStand, overlays: setSelectedOverlay, cards: setSelectedCardBackground, borders: setSelectedBorder };
@@ -12,7 +12,8 @@ const t = (key: string, fallback: string, params?: string[], replacements?: stri
const value = LocalizeText(key, params ?? null, replacements ?? null);
if(value && value !== key) return value;
}
catch {}
catch
{}
if(!params || !replacements) return fallback;
let out = fallback;
@@ -38,8 +39,8 @@ const PALETTE: number[] = [
const currencyName = (type: number): string =>
{
if(type === -1) return 'credits';
if(type === 0) return 'duckets';
if(type === 5) return 'diamonds';
if(type === 0) return 'duckets';
if(type === 5) return 'diamonds';
return `currency #${ type }`;
};
@@ -58,14 +59,14 @@ const floodFill = (grid: Uint32Array, w: number, h: number, startX: number, star
const stack: number[] = [ startIdx ];
while(stack.length)
{
const idx = stack.pop() as number;
const idx = stack.pop();
if(next[idx] !== target) continue;
next[idx] = replacement;
const x = idx % w;
const y = (idx - x) / w;
if(x > 0) stack.push(idx - 1);
if(x > 0) stack.push(idx - 1);
if(x < w - 1) stack.push(idx + 1);
if(y > 0) stack.push(idx - w);
if(y > 0) stack.push(idx - w);
if(y < h - 1) stack.push(idx + w);
}
return next;
@@ -119,7 +120,7 @@ const gridToPngBase64 = async (grid: Uint32Array): Promise<{ b64: string; bytes:
{
const argb = grid[i];
const o = i * 4;
image.data[o] = (argb >>> 16) & 0xff;
image.data[o] = (argb >>> 16) & 0xff;
image.data[o + 1] = (argb >>> 8) & 0xff;
image.data[o + 2] = argb & 0xff;
image.data[o + 3] = (argb >>> 24) & 0xff;
@@ -157,12 +158,18 @@ const loadGridFromUrl = (url: string): Promise<Uint32Array> =>
{
const o = i * 4;
const a = data[o + 3];
if(a === 0) { grid[i] = 0; continue; }
if(a === 0)
{
grid[i] = 0; continue;
}
grid[i] = ((a & 0xff) << 24) | ((data[o] & 0xff) << 16) | ((data[o + 1] & 0xff) << 8) | (data[o + 2] & 0xff);
}
resolve(grid);
}
catch(err) { reject(err); }
catch(err)
{
reject(err);
}
};
image.onerror = () => reject(new Error('Could not load badge image (CORS?).'));
image.src = url + (url.includes('?') ? '&' : '?') + 't=' + Date.now();
@@ -216,8 +223,8 @@ export const BadgeCreatorView: FC<{}> = () =>
if(parts.length < 2) return;
switch(parts[1])
{
case 'show': setIsVisible(true); return;
case 'hide': setIsVisible(false); return;
case 'show': setIsVisible(true); return;
case 'hide': setIsVisible(false); return;
case 'toggle': setIsVisible(v => !v); return;
case 'edit':
if(!parts[2]) return;
@@ -232,7 +239,13 @@ export const BadgeCreatorView: FC<{}> = () =>
return () => RemoveLinkEventTracker(tracker);
}, []);
useEffect(() => { if(isVisible) { refresh(); ensureCustomBadgeTexts(); } }, [ isVisible, refresh ]);
useEffect(() =>
{
if(isVisible)
{
refresh(); ensureCustomBadgeTexts();
}
}, [ isVisible, refresh ]);
const resetEditor = useCallback(() =>
{
@@ -316,9 +329,9 @@ export const BadgeCreatorView: FC<{}> = () =>
{
const v = grid[i];
const o = i * 4;
buffer[o] = (v >>> 16) & 0xff;
buffer[o + 1] = (v >>> 8) & 0xff;
buffer[o + 2] = v & 0xff;
buffer[o] = (v >>> 16) & 0xff;
buffer[o + 1] = (v >>> 8) & 0xff;
buffer[o + 2] = v & 0xff;
buffer[o + 3] = (v >>> 24) & 0xff;
}
ctx.putImageData(image, 0, 0);
@@ -365,7 +378,10 @@ export const BadgeCreatorView: FC<{}> = () =>
useEffect(() =>
{
const stopDrag = () => { isDraggingRef.current = false; };
const stopDrag = () =>
{
isDraggingRef.current = false;
};
window.addEventListener('mouseup', stopDrag);
return () => window.removeEventListener('mouseup', stopDrag);
}, []);
@@ -385,7 +401,10 @@ export const BadgeCreatorView: FC<{}> = () =>
const handleSave = useCallback(async () =>
{
if(submitting) return;
if(isEmpty) { setError(t('badgecreator.error.empty', 'Draw something first.')); return; }
if(isEmpty)
{
setError(t('badgecreator.error.empty', 'Draw something first.')); return;
}
if(!editingBadgeId && !canCreateMore)
{
setError(t('badgecreator.error.limit', 'You already have %max% custom badges.', [ 'max' ], [ String(maxBadges) ]));
@@ -506,7 +525,10 @@ export const BadgeCreatorView: FC<{}> = () =>
<button
key={ idx }
type="button"
onClick={ () => { setSelectedColor(color); setTool('paint'); } }
onClick={ () =>
{
setSelectedColor(color); setTool('paint');
} }
title={ isTransparent ? 'Transparent' : argbToCss(color) }
style={ {
width: 22,
@@ -19,7 +19,7 @@ export const CameraWidgetCaptureView: FC<CameraWidgetCaptureViewProps> = props =
const { onClose = null, onEdit = null, onDelete = null } = props;
const { cameraRoll = null, setCameraRoll = null, selectedPictureIndex = -1, setSelectedPictureIndex = null } = useCamera();
const { simpleAlert = null } = useNotification();
const elementRef = useRef<HTMLDivElement>();
const elementRef = useRef<HTMLDivElement>(null);
const selectedPicture = ((selectedPictureIndex > -1) ? cameraRoll[selectedPictureIndex] : null);
@@ -10,40 +10,46 @@ export interface CameraWidgetShowPhotoViewProps {
onClick?: () => void;
}
export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = props => {
export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = props =>
{
const { currentIndex = -1, currentPhotos = null, onClick = null } = props;
const [imageIndex, setImageIndex] = useState(0);
const currentImage = currentPhotos && currentPhotos.length ? currentPhotos[imageIndex] : null;
const next = () => {
setImageIndex(prevValue => {
const next = () =>
{
setImageIndex(prevValue =>
{
let newIndex = prevValue + 1;
if (newIndex >= currentPhotos.length) newIndex = 0;
return newIndex;
});
};
const previous = () => {
setImageIndex(prevValue => {
const previous = () =>
{
setImageIndex(prevValue =>
{
let newIndex = prevValue - 1;
if (newIndex < 0) newIndex = currentPhotos.length - 1;
return newIndex;
});
};
useEffect(() => {
useEffect(() =>
{
setImageIndex(currentIndex);
}, [currentIndex]);
if (!currentImage) return null;
const getUserData = (roomId: number, objectId: number, type: string): number | string =>
const getUserData = (roomId: number, objectId: number, type: string): number | string =>
{
const roomObject = GetRoomEngine().getRoomObject(roomId, objectId, RoomObjectCategory.WALL);
if (!roomObject) return;
return type == 'username' ? roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_NAME) : roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_OWNER_ID);
}
};
return (
<Grid style={{ display: 'flex', flexDirection: 'column' }}>
@@ -53,13 +59,13 @@ export const CameraWidgetShowPhotoView: FC<CameraWidgetShowPhotoViewProps> = pro
{currentImage.m && currentImage.m.length && <Text center>{currentImage.m}</Text>}
<div className="flex items-center center justify-between">
<Text>{currentImage.n || ''}</Text>
<Text onClick={() => GetUserProfile(Number(getUserData(currentImage.s, Number(currentImage.u), 'id')))}> { getUserData(currentImage.s, Number(currentImage.u), 'username') } </Text>
<Text className="cursor-pointer" onClick={() => GetUserProfile(currentImage.oi)}>{currentImage.o}</Text>
<Text>{new Date(currentImage.t * 1000).toLocaleDateString()}</Text>
<Text onClick={() => GetUserProfile(Number(getUserData(currentImage.s, Number(currentImage.u), 'id')))}> { getUserData(currentImage.s, Number(currentImage.u), 'username') } </Text>
<Text className="cursor-pointer" onClick={() => GetUserProfile(currentImage.oi)}>{currentImage.o}</Text>
<Text>{new Date(currentImage.t * 1000).toLocaleDateString()}</Text>
</div>
{currentPhotos.length > 1 && (
<Flex className="picture-preview-buttons">
<FaArrowLeft onClick={previous} />
<FaArrowLeft onClick={previous} />
<FaArrowRight className="cursor-pointer"onClick={next} />
</Flex>
)}
@@ -16,7 +16,8 @@ export interface CameraWidgetEditorViewProps {
const TABS: string[] = [ CameraEditorTabs.COLORMATRIX, CameraEditorTabs.COMPOSITE ];
export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props => {
export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
{
const { picture = null, availableEffects = null, myLevel = 1, onClose = null, onCancel = null, onCheckout = null } = props;
const [ currentTab, setCurrentTab ] = useState(TABS[0]);
const [ selectedEffectName, setSelectedEffectName ] = useState<string>(null);
@@ -35,37 +36,45 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
img.src = picture.imageUrl;
}, [ picture ]);
const getColorMatrixEffects = useMemo(() => {
const getColorMatrixEffects = useMemo(() =>
{
return availableEffects.filter(effect => effect.colorMatrix);
}, [ availableEffects ]);
const getCompositeEffects = useMemo(() => {
const getCompositeEffects = useMemo(() =>
{
return availableEffects.filter(effect => effect.texture);
}, [ availableEffects ]);
const getEffectList = useCallback(() => {
const getEffectList = useCallback(() =>
{
return currentTab === CameraEditorTabs.COLORMATRIX ? getColorMatrixEffects : getCompositeEffects;
}, [ currentTab, getColorMatrixEffects, getCompositeEffects ]);
const getSelectedEffectIndex = useCallback((name: string) => {
const getSelectedEffectIndex = useCallback((name: string) =>
{
if (!name || !name.length || !selectedEffects || !selectedEffects.length) return -1;
return selectedEffects.findIndex(effect => effect.effect.name === name);
}, [ selectedEffects ]);
const getCurrentEffectIndex = useMemo(() => {
const getCurrentEffectIndex = useMemo(() =>
{
return getSelectedEffectIndex(selectedEffectName);
}, [ selectedEffectName, getSelectedEffectIndex ]);
const getCurrentEffect = useMemo(() => {
const getCurrentEffect = useMemo(() =>
{
if (!selectedEffectName) return null;
return selectedEffects[getCurrentEffectIndex] || null;
}, [ selectedEffectName, getCurrentEffectIndex, selectedEffects ]);
const setSelectedEffectAlpha = useCallback((alpha: number) => {
const setSelectedEffectAlpha = useCallback((alpha: number) =>
{
const index = getCurrentEffectIndex;
if (index === -1) return;
setSelectedEffects(prevValue => {
setSelectedEffects(prevValue =>
{
const clone = [ ...prevValue ];
const currentEffect = clone[index];
clone[index] = new RoomCameraWidgetSelectedEffect(currentEffect.effect, alpha);
@@ -73,8 +82,10 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
});
}, [ getCurrentEffectIndex ]);
const processAction = useCallback((type: string, effectName: string = null) => {
switch (type) {
const processAction = useCallback((type: string, effectName: string = null) =>
{
switch (type)
{
case 'close':
onClose();
return;
@@ -102,7 +113,8 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
const existingIndex = getSelectedEffectIndex(effectName);
if (existingIndex === -1) return;
setSelectedEffects(prevValue => {
setSelectedEffects(prevValue =>
{
const clone = [ ...prevValue ];
clone.splice(existingIndex, 1);
return clone;
@@ -141,10 +153,12 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
}
}, [ availableEffects, selectedEffectName, currentPictureUrl, getSelectedEffectIndex, onCancel, onCheckout, onClose ]);
useEffect(() => {
useEffect(() =>
{
if(!stableTexture) return;
const processThumbnails = async () => {
const processThumbnails = async () =>
{
const renderedEffects = await Promise.all(
availableEffects.map(effect =>
GetRoomCameraWidgetManager().applyEffects(stableTexture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false)
@@ -155,24 +169,28 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
processThumbnails();
}, [ stableTexture, availableEffects ]);
useEffect(() => {
useEffect(() =>
{
if(!stableTexture) return;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
debounceTimerRef.current = setTimeout(() =>
{
const id = ++requestIdRef.current;
GetRoomCameraWidgetManager()
.applyEffects(stableTexture, selectedEffects, false)
.then(imageElement => {
.then(imageElement =>
{
if (id !== requestIdRef.current) return;
setCurrentPictureUrl(imageElement.src);
})
.catch(error => NitroLogger.error('Failed to apply effects to picture', error));
}, 50);
return () => {
return () =>
{
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [ stableTexture, selectedEffects ]);
@@ -19,22 +19,22 @@ export const CameraWidgetEffectListItemView: FC<CameraWidgetEffectListItemViewPr
const { effect = null, thumbnailUrl = null, isActive = false, isLocked = false, selectEffect = null, removeEffect = null } = props;
return (
<LayoutGridItem itemActive={ isActive } title={ LocalizeText(!isLocked ? (`camera.effect.name.${ effect.name }`) : `camera.effect.required.level ${ effect.minLevel }`) } onClick={ event => (!isActive && selectEffect()) }>
{ isActive &&
<LayoutGridItem itemActive={ isActive } title={ LocalizeText(!isLocked ? (`camera.effect.name.${ effect.name }`) : `camera.effect.required.level ${ effect.minLevel }`) } onClick={ event => (!isActive && selectEffect()) }>
{ isActive &&
<Button className="rounded-circle remove-effect" variant="danger" onClick={ removeEffect }>
<FaTimes className="fa-icon" />
</Button> }
{ !isLocked && (thumbnailUrl && thumbnailUrl.length > 0) &&
{ !isLocked && (thumbnailUrl && thumbnailUrl.length > 0) &&
<div className="effect-thumbnail-image border">
<img alt="" src={ thumbnailUrl } />
</div> }
{ isLocked &&
{ isLocked &&
<Text bold center>
<div>
<FaLock className="fa-icon" />
</div>
{ effect.minLevel }
</Text> }
</LayoutGridItem>
</LayoutGridItem>
);
};
@@ -25,8 +25,8 @@ export const CameraWidgetEffectListView: FC<CameraWidgetEffectListViewProps> = p
const isActive = (selectedEffects.findIndex(selectedEffect => (selectedEffect.effect.name === effect.name)) > -1);
// return <CameraWidgetEffectListItemView key={ index } effect={ effect } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } removeEffect={ () => processAction('remove_effect', effect.name) } selectEffect={ () => processAction('select_effect', effect.name) } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } />;
return <CameraWidgetEffectListItemView key={ index } effect={ effect } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } selectEffect={ () => processAction('select_effect', effect.name) } removeEffect={ () => processAction('remove_effect', effect.name) } />
return <CameraWidgetEffectListItemView key={ index } effect={ effect } thumbnailUrl={ ((thumbnailUrl && thumbnailUrl.thumbnailUrl) || null) } isActive={ isActive } isLocked={ (effect.minLevel > myLevel) } selectEffect={ () => processAction('select_effect', effect.name) } removeEffect={ () => processAction('remove_effect', effect.name) } />;
}) }
</Grid>
);
+3 -2
View File
@@ -1,7 +1,7 @@
import { GetSessionDataManager } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { CalendarItemState, ICalendarItem, LocalizeText } from '../../api';
import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useHasPermission } from '../../hooks';
import { CalendarItemView } from './CalendarItemView';
interface CalendarViewProps
@@ -23,6 +23,7 @@ export const CalendarView: FC<CalendarViewProps> = props =>
const { onClose = null, campaignName = null, currentDay = null, numDays = null, missedDays = null, openedDays = null, openPackage = null, receivedProducts = null } = props;
const [ selectedDay, setSelectedDay ] = useState(currentDay);
const [ index, setIndex ] = useState(Math.max(0, (selectedDay - 1)));
const isModerator = useHasPermission('acc_calendar_force');
const getDayState = (day: number) =>
{
@@ -109,7 +110,7 @@ export const CalendarView: FC<CalendarViewProps> = props =>
<Text>{ dayMessage(selectedDay) }</Text>
</div>
<div>
{ GetSessionDataManager().isModerator &&
{ isModerator &&
<Button variant="danger" onClick={ forceOpen }>Force open</Button> }
</div>
</div>
@@ -1,7 +1,7 @@
import { CatalogAdminCreateOfferComposer, CatalogAdminCreatePageComposer, CatalogAdminDeleteOfferComposer, CatalogAdminDeletePageComposer, CatalogAdminMoveOfferComposer, CatalogAdminMovePageComposer, CatalogAdminPublishComposer, CatalogAdminResultEvent, CatalogAdminSaveOfferComposer, CatalogAdminSavePageComposer } from '@nitrots/nitro-renderer';
import { createContext, FC, ReactNode, useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ICatalogNode, IPurchasableOffer, NotificationAlertType, SendMessageComposer } from '../../api';
import { useCatalog, useMessageEvent, useNotification } from '../../hooks';
import { useCatalogUiState, useMessageEvent, useNotification } from '../../hooks';
export interface IPageEditData
{
@@ -76,7 +76,7 @@ export const useCatalogAdmin = () => useContext(CatalogAdminContext);
export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children }) =>
{
const { currentType } = useCatalog();
const { currentType } = useCatalogUiState();
const [ adminMode, setAdminMode ] = useState(false);
const [ editingOffer, setEditingOffer ] = useState<IPurchasableOffer | null>(null);
const [ editingPageData, setEditingPageData ] = useState(false);
@@ -97,7 +97,10 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
{
if(e.key === 'Escape')
{
if(editingOffer) { setEditingOffer(null); e.preventDefault(); return; }
if(editingOffer)
{
setEditingOffer(null); e.preventDefault(); return;
}
if(editingPageData || editingRootPage || editingPageNode)
{
setEditingPageData(false);
@@ -280,7 +283,7 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
}, []);
return (
<CatalogAdminContext.Provider value={ {
<CatalogAdminContext value={ {
adminMode, setAdminMode,
editingOffer, setEditingOffer,
editingPageData, setEditingPageData,
@@ -293,6 +296,6 @@ export const CatalogAdminProvider: FC<{ children: ReactNode }> = ({ children })
publishCatalog
} }>
{ children }
</CatalogAdminContext.Provider>
</CatalogAdminContext>
);
};
+22 -9
View File
@@ -1,9 +1,9 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaPlus, FaTrash } from 'react-icons/fa';
import { CatalogType, GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../common';
import { useCatalog } from '../../hooks';
import { useCatalogActions, useCatalogData, useCatalogUiState, useHasPermission } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
@@ -16,15 +16,19 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
const CatalogClassicViewInner: FC<{}> = () =>
{
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog();
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
const setAdminMode = catalogAdmin?.setAdminMode ?? (() =>
{});
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
const publishCatalog = catalogAdmin?.publishCatalog ?? (() =>
{});
const loading = catalogAdmin?.loading ?? false;
const isMod = GetSessionDataManager().isModerator;
const isMod = useHasPermission('acc_catalogfurni');
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
: undefined;
@@ -148,13 +152,19 @@ const CatalogClassicViewInner: FC<{}> = () =>
{ adminMode &&
<div className="flex items-center gap-0.5 ml-1" onClick={ e => e.stopPropagation() }>
<FaEdit className="text-[8px] text-primary cursor-pointer hover:text-dark" title={ LocalizeText('catalog.admin.edit.title') }
onClick={ () => { catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } } />
onClick={ () =>
{
catalogAdmin.setEditingPageNode(child); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
} } />
<span className="cursor-pointer" title={ isHidden ? LocalizeText('catalog.admin.show') : LocalizeText('catalog.admin.hide') }
onClick={ () => catalogAdmin.togglePageVisible(child.pageId) }>
{ isHidden ? <FaEye className="text-[8px] text-success" /> : <FaEyeSlash className="text-[8px] text-muted" /> }
</span>
<FaTrash className="text-[8px] text-danger cursor-pointer hover:text-red-800" title={ LocalizeText('catalog.admin.delete.title') }
onClick={ () => { if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId); } } />
onClick={ () =>
{
if(confirm(LocalizeText('catalog.admin.delete.category.confirm', [ 'name' ], [ child.localization ]))) catalogAdmin.deletePage(child.pageId);
} } />
</div> }
</div>
</NitroCardTabsItemView>
@@ -180,7 +190,10 @@ const CatalogClassicViewInner: FC<{}> = () =>
</button>
<button
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[8px]" />
<span>{ LocalizeText('catalog.admin.root') }</span>
+14 -7
View File
@@ -1,9 +1,9 @@
import { AddLinkEventTracker, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaCog, FaEdit, FaEye, FaEyeSlash, FaHeart, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { CatalogType, LocalizeText } from '../../api';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common';
import { useCatalog, useCatalogFavorites } from '../../hooks';
import { useCatalogActions, useCatalogData, useCatalogFavorites, useCatalogUiState, useHasPermission } from '../../hooks';
import { CatalogAdminProvider, useCatalogAdmin } from './CatalogAdminContext';
import { CatalogAdminOfferEditView } from './views/admin/CatalogAdminOfferEditView';
import { CatalogAdminPageEditView } from './views/admin/CatalogAdminPageEditView';
@@ -18,17 +18,21 @@ import { MarketplacePostOfferView } from './views/page/layout/marketplace/Market
const CatalogModernViewInner: FC<{}> = () =>
{
const { isVisible = false, setIsVisible = null, rootNode = null, currentPage = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], searchResult = null, setSearchResult = null, openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null, currentType = CatalogType.NORMAL } = useCatalog();
const { rootNode = null, currentPage = null, searchResult = null } = useCatalogData();
const { isVisible = false, setIsVisible = null, navigationHidden = false, setNavigationHidden = null, activeNodes = [], setSearchResult = null, currentType = CatalogType.NORMAL } = useCatalogUiState();
const { openPageByName = null, openPageByOfferId = null, activateNode = null, openCatalogByType = null, toggleCatalogByType = null } = useCatalogActions();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const setAdminMode = catalogAdmin?.setAdminMode ?? (() => {});
const setAdminMode = catalogAdmin?.setAdminMode ?? (() =>
{});
const hasPendingChanges = catalogAdmin?.hasPendingChanges ?? false;
const publishCatalog = catalogAdmin?.publishCatalog ?? (() => {});
const publishCatalog = catalogAdmin?.publishCatalog ?? (() =>
{});
const loading = catalogAdmin?.loading ?? false;
const { favoriteOfferIds, favoritePageIds } = useCatalogFavorites();
const [ showFavorites, setShowFavorites ] = useState(false);
const isMod = GetSessionDataManager().isModerator;
const isMod = useHasPermission('acc_catalogfurni');
const totalFavs = favoriteOfferIds.length + favoritePageIds.length;
const buildersClubHeaderStyle = (currentType === CatalogType.BUILDER)
? { borderColor: '#d79d2e', borderBottomColor: '#000', background: 'linear-gradient(180deg, #d89f2d 0%, #c68515 100%)' }
@@ -170,7 +174,10 @@ const CatalogModernViewInner: FC<{}> = () =>
<button
className="flex items-center gap-1 text-[9px] text-primary hover:text-dark cursor-pointer transition-colors"
title={ LocalizeText('catalog.admin.edit.root') }
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true); } }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(true); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[8px]" />
<span className="whitespace-nowrap">{ LocalizeText('catalog.admin.root') }</span>
+2 -2
View File
@@ -1,12 +1,12 @@
import { FC } from 'react';
import { GetConfigurationValue } from '../../api';
import { useCatalog } from '../../hooks';
import { useCatalogData } from '../../hooks';
import { CatalogClassicView } from './CatalogClassicView';
import { CatalogModernView } from './CatalogModernView';
export const CatalogView: FC<{}> = () =>
{
const { catalogLocalizationVersion = 0 } = useCatalog();
const { catalogLocalizationVersion = 0 } = useCatalogData();
const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false);
if(useNewStyle) return (
@@ -2,12 +2,12 @@ import { FC, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogData } from '../../../../hooks';
import { IOfferEditData, useCatalogAdmin } from '../../CatalogAdminContext';
export const CatalogAdminOfferEditView: FC<{}> = () =>
{
const { currentPage = null } = useCatalog();
const { currentPage = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const editingOffer = catalogAdmin?.editingOffer ?? null;
const setEditingOffer = catalogAdmin?.setEditingOffer;
@@ -91,9 +91,10 @@ export const CatalogAdminOfferEditView: FC<{}> = () =>
orderNumber
};
const success = isNew ? await createOffer(data) : await saveOffer(data);
if(isNew) createOffer(data);
else saveOffer(data);
if(success && setEditingOffer) setEditingOffer(null);
if(setEditingOffer) setEditingOffer(null);
};
const handleDelete = () =>
@@ -1,7 +1,7 @@
import { FC, useEffect, useState } from 'react';
import { FaSave, FaSpinner, FaTimes, FaTrash } from 'react-icons/fa';
import { CatalogType, LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
import { IPageEditData, useCatalogAdmin } from '../../CatalogAdminContext';
const LAYOUT_OPTIONS = [
@@ -21,7 +21,8 @@ const MODE_OPTIONS = [
export const CatalogAdminPageEditView: FC<{}> = () =>
{
const { currentPage = null, activeNodes = [], rootNode = null, currentType = CatalogType.NORMAL } = useCatalog();
const { currentPage = null, rootNode = null } = useCatalogData();
const { activeNodes = [], currentType = CatalogType.NORMAL } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const editingPageData = catalogAdmin?.editingPageData ?? false;
const editingRootPage = catalogAdmin?.editingRootPage ?? false;
@@ -91,9 +92,9 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
parentId: parentNode ? parentNode.pageId : -1,
};
const success = await catalogAdmin.savePage(data);
catalogAdmin.savePage(data);
if(success) closeForm();
closeForm();
};
const handleDelete = async () =>
@@ -101,9 +102,9 @@ export const CatalogAdminPageEditView: FC<{}> = () =>
if(!catalogAdmin?.deletePage || isRoot) return;
if(!confirm(LocalizeText('catalog.admin.delete.page.confirm', [ 'name' ], [ targetNode.localization ]))) return;
const success = await catalogAdmin.deletePage(targetPageId);
catalogAdmin.deletePage(targetPageId);
if(success) closeForm();
closeForm();
};
return (
@@ -2,11 +2,12 @@ import { GetTickerTime } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { CatalogType, FriendlyTime, LocalizeText } from '../../../../api';
import buildersClubIcon from '../../../../assets/images/toolbar/icons/buildersclub.png';
import { useCatalog } from '../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../hooks';
export const CatalogBuildersClubStatusView: FC = () =>
{
const { currentType = CatalogType.NORMAL, furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalog();
const { furniCount = 0, furniLimit = 0, secondsLeft = 0, secondsLeftWithGrace = 0, updateTime = 0 } = useCatalogData();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const [ ticker, setTicker ] = useState(() => GetTickerTime());
useEffect(() =>
@@ -1,4 +1,4 @@
import { FC, useEffect, useState } from 'react';
import { FC } from 'react';
import { GetConfigurationValue } from '../../../../api';
export interface CatalogHeaderViewProps
@@ -9,12 +9,7 @@ export interface CatalogHeaderViewProps
export const CatalogHeaderView: FC<CatalogHeaderViewProps> = props =>
{
const { imageUrl = null } = props;
const [ displayImageUrl, setDisplayImageUrl ] = useState('');
useEffect(() =>
{
setDisplayImageUrl(imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder'));
}, [ imageUrl ]);
const displayImageUrl = imageUrl ?? GetConfigurationValue<string>('catalog.asset.image.url').replace('%name%', 'catalog_header_roombuilder');
return <div className="flex justify-center items-center w-full nitro-catalog-header">
<img src={ displayImageUrl } onError={ ({ currentTarget }) =>
@@ -1,7 +1,7 @@
import { FC, useMemo } from 'react';
import { FaHeart, FaStar, FaTimes } from 'react-icons/fa';
import { ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalogActions, useCatalogData, useCatalogFavorites } from '../../../../hooks';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
interface CatalogFavoritesViewProps
@@ -13,7 +13,8 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
{
const { onClose } = props;
const { favoriteOffers, favoritePageIds, toggleFavoritePage, toggleFavoriteOffer } = useCatalogFavorites();
const { offersToNodes, activateNode, openPageByOfferId, rootNode } = useCatalog();
const { offersToNodes, rootNode } = useCatalogData();
const { activateNode, openPageByOfferId } = useCatalogActions();
const favoritePages = useMemo(() =>
{
@@ -93,13 +94,19 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
<div
key={ page.pageId }
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
onClick={ () => { activateNode(page.node); onClose(); } }
onClick={ () =>
{
activateNode(page.node); onClose();
} }
>
<CatalogIconView icon={ page.iconId } />
<span className="text-[11px] flex-1 truncate font-medium">{ page.name }</span>
<FaTimes
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
onClick={ e => { e.stopPropagation(); toggleFavoritePage(page.pageId); } }
onClick={ e =>
{
e.stopPropagation(); toggleFavoritePage(page.pageId);
} }
/>
</div>
)) }
@@ -118,7 +125,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
<div
key={ fav.offerId }
className="group/fav flex items-center gap-2 px-1.5 py-1 bg-card-grid-item rounded border border-card-grid-item-border hover:bg-card-grid-item-active cursor-pointer transition-all duration-100"
onClick={ () => { openPageByOfferId(fav.offerId); onClose(); } }
onClick={ () =>
{
openPageByOfferId(fav.offerId); onClose();
} }
>
{ /* Furni icon */ }
<div className="w-7 h-7 flex items-center justify-center shrink-0 bg-white rounded border border-card-grid-item-border overflow-hidden">
@@ -132,7 +142,10 @@ export const CatalogFavoritesView: FC<CatalogFavoritesViewProps> = props =>
<span className="text-[11px] flex-1 truncate font-medium">{ fav.displayName }</span>
<FaTimes
className="text-[7px] text-muted opacity-0 group-hover/fav:opacity-100 hover:text-danger transition-all cursor-pointer"
onClick={ e => { e.stopPropagation(); toggleFavoriteOffer(fav.offerId); } }
onClick={ e =>
{
e.stopPropagation(); toggleFavoriteOffer(fav.offerId);
} }
/>
</div>
)) }
@@ -4,7 +4,7 @@ import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { ColorUtils, LocalizeText, MessengerFriend, ProductTypeEnum, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, FormGroup, LayoutCurrencyIcon, LayoutFurniImageView, LayoutGiftTagView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchasedEvent } from '../../../../events';
import { useCatalog, useFriends, useMessageEvent, useUiEvent } from '../../../../hooks';
import { useFriends, useGiftConfiguration, useMessageEvent, useUiEvent } from '../../../../hooks';
import { classNames } from '../../../../layout';
let isBuyingGift = false;
@@ -25,9 +25,8 @@ export const CatalogGiftView: FC<{}> = props =>
const [ maxBoxIndex, setMaxBoxIndex ] = useState<number>(0);
const [ maxRibbonIndex, setMaxRibbonIndex ] = useState<number>(0);
const [ receiverNotFound, setReceiverNotFound ] = useState<boolean>(false);
const { catalogOptions = null } = useCatalog();
const { friends } = useFriends();
const { giftConfiguration = null } = catalogOptions;
const { data: giftConfiguration = null } = useGiftConfiguration();
const [ boxTypes, setBoxTypes ] = useState<number[]>([]);
const [ suggestions, setSuggestions ] = useState([]);
const [ isAutocompleteVisible, setIsAutocompleteVisible ] = useState(true);
@@ -133,7 +132,10 @@ export const CatalogGiftView: FC<{}> = props =>
if(isBuyingGift) return;
isBuyingGift = true;
setTimeout(() => { isBuyingGift = false; }, 10000);
setTimeout(() =>
{
isBuyingGift = false;
}, 10000);
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
return;
@@ -1,11 +1,12 @@
import { FC } from 'react';
import { FaChevronRight, FaHome } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogActions, useCatalogUiState } from '../../../../hooks';
export const CatalogBreadcrumbView: FC<{}> = () =>
{
const { activeNodes = [], activateNode } = useCatalog();
const { activeNodes = [] } = useCatalogUiState();
const { activateNode } = useCatalogActions();
if(!activeNodes || activeNodes.length === 0)
{
@@ -1,7 +1,7 @@
import { FC, useCallback, useRef, useState } from 'react';
import { FaArrowsAlt, FaCaretDown, FaCaretUp, FaPlus, FaStar, FaTrash } from 'react-icons/fa';
import { CatalogType, ICatalogNode, LocalizeText } from '../../../../api';
import { useCatalog, useCatalogFavorites } from '../../../../hooks';
import { useCatalogActions, useCatalogFavorites, useCatalogUiState } from '../../../../hooks';
import { useCatalogAdmin } from '../../CatalogAdminContext';
import { CatalogIconView } from '../catalog-icon/CatalogIconView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
@@ -15,7 +15,8 @@ export interface CatalogNavigationItemViewProps
export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = props =>
{
const { node = null, child = false } = props;
const { activateNode = null, currentType = CatalogType.NORMAL } = useCatalog();
const { activateNode = null } = useCatalogActions();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const { isFavoritePage, toggleFavoritePage } = useCatalogFavorites();
@@ -126,7 +127,10 @@ export const CatalogNavigationItemView: FC<CatalogNavigationItemViewProps> = pro
{ !adminMode && node.pageId > 0 &&
<FaStar
className={ `text-[8px] transition-all duration-100 cursor-pointer shrink-0 ${ isFav ? 'text-warning opacity-100' : 'text-muted opacity-0 group-hover/nav:opacity-100 hover:text-warning' }` }
onClick={ e => { e.stopPropagation(); toggleFavoritePage(node.pageId); } }
onClick={ e =>
{
e.stopPropagation(); toggleFavoritePage(node.pageId);
} }
/> }
{ node.isBranch &&
<span className="text-[9px] text-muted shrink-0">
@@ -1,6 +1,6 @@
import { FC } from 'react';
import { ICatalogNode } from '../../../../api';
import { useCatalog } from '../../../../hooks';
import { useCatalogData } from '../../../../hooks';
import { CatalogNavigationItemView } from './CatalogNavigationItemView';
import { CatalogNavigationSetView } from './CatalogNavigationSetView';
@@ -12,7 +12,7 @@ export interface CatalogNavigationViewProps
export const CatalogNavigationView: FC<CatalogNavigationViewProps> = props =>
{
const { node = null } = props;
const { searchResult = null } = useCatalog();
const { searchResult = null } = useCatalogData();
return (
<div className="flex flex-col gap-px px-0.5 py-0.5">
@@ -3,7 +3,7 @@ import { FC, MouseEvent, useMemo, useState } from 'react';
import { FaHeart } from 'react-icons/fa';
import { CatalogType, IPurchasableOffer, Offer, ProductTypeEnum } from '../../../../../api';
import { LayoutAvatarImageView, LayoutGridItem, LayoutGridItemProps } from '../../../../../common';
import { useCatalog, useCatalogFavorites, useInventoryFurni } from '../../../../../hooks';
import { useCatalogActions, useCatalogFavorites, useCatalogUiState, useInventoryFurni } from '../../../../../hooks';
interface CatalogGridOfferViewProps extends LayoutGridItemProps
{
@@ -15,7 +15,8 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
{
const { offer = null, selectOffer = null, itemActive = false, ...rest } = props;
const [ isMouseDown, setMouseDown ] = useState(false);
const { requestOfferToMover = null, currentType = CatalogType.NORMAL } = useCatalog();
const { requestOfferToMover = null } = useCatalogActions();
const { currentType = CatalogType.NORMAL } = useCatalogUiState();
const { isVisible = false } = useInventoryFurni();
const { isFavoriteOffer, toggleFavoriteOffer } = useCatalogFavorites();
const isFav = offer ? isFavoriteOffer(offer.offerId) : false;
@@ -77,7 +78,10 @@ export const CatalogGridOfferView: FC<CatalogGridOfferViewProps> = props =>
<LayoutAvatarImageView direction={ 3 } figure={ offer.product.extraParam } headOnly={ true } /> }
<div
className={ `absolute top-0 right-0 z-10 p-0.5 cursor-pointer transition-opacity duration-100 ${ isFav ? 'opacity-100' : 'opacity-0 group-hover/tile:opacity-100' }` }
onClick={ e => { e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl); } }
onClick={ e =>
{
e.stopPropagation(); e.preventDefault(); toggleFavoriteOffer(offer.offerId, offer.localizationName, iconUrl);
} }
onMouseDown={ e => e.stopPropagation() }
>
<FaHeart className={ `text-[10px] drop-shadow transition-colors duration-100 ${ isFav ? 'text-danger' : 'text-muted hover:text-danger' }` } />
@@ -2,12 +2,13 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
export const CatalogSearchView: FC<{}> = () =>
{
const [ searchValue, setSearchValue ] = useState('');
const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
const { rootNode = null, searchResult = null } = useCatalogData();
const { currentType = null, setSearchResult = null, setCurrentPage = null } = useCatalogUiState();
const normalizeSearchText = (value: string) => (value || '')
.toLocaleLowerCase()
@@ -81,7 +82,7 @@ export const CatalogSearchView: FC<{}> = () =>
FilterCatalogNode(search, foundFurniLines, rootNode, nodes);
setSearchResult(new SearchResult(search, offers, nodes.filter(node => (node.isVisible))));
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1) as ICatalogPage));
setCurrentPage((new CatalogPage(-1, 'default_3x3', new PageLocalization([], []), offers, false, 1)));
}, 300);
return () => clearTimeout(timeout);
@@ -1,7 +1,7 @@
import { FC } from 'react';
import { LocalizeText, SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogBadgeSelectorWidgetView } from '../widgets/CatalogBadgeSelectorWidgetView';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -14,7 +14,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutBadgeDisplayView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<>
@@ -1,9 +1,9 @@
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { ClubOfferData, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
import { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogLayoutProps } from './CatalogLayout.types';
@@ -14,12 +14,12 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
{
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null, catalogOptions = null } = useCatalog();
const { currentPage = null } = useCatalogData();
const { getCurrencyAmount = null } = usePurse();
const isPurchasingRef = useRef(false);
const isAddonLayout = (currentPage?.layoutCode === 'builders_club_addons');
const windowId = (isAddonLayout ? BUILDERS_CLUB_ADDONS_WINDOW_ID : BUILDERS_CLUB_WINDOW_ID);
const offers = catalogOptions?.clubOffersByWindowId?.[windowId] || null;
const { data: offers = null } = useClubOffers(windowId);
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
@@ -120,11 +120,6 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
return currentPage.localization.getText(1) || currentPage.localization.getText(2) || currentPage.localization.getText(0) || '';
}, [ currentPage ]);
useEffect(() =>
{
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(windowId));
}, [ offers, windowId ]);
useEffect(() =>
{
if(!offers || !offers.length) return;
@@ -142,44 +137,45 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
{ currentPage?.localization?.getImage(0) &&
<CatalogHeaderView imageUrl={ currentPage.localization.getImage(0) } /> }
<Grid>
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
<Column gap={ 1 } overflow="auto">
{ offers && (offers.length > 0) && offers.map((offer, index) =>
{
const meta = getOfferMeta(offer);
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
<Column gap={ 1 } overflow="auto">
{ offers && (offers.length > 0) && offers.map((offer, index) =>
{
const meta = getOfferMeta(offer);
return (
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-2" column={ false } itemActive={ pendingOffer?.offerId === offer.offerId } justifyContent="between" onClick={ () => {
setPurchaseState(CatalogPurchaseState.NONE);
setPendingOffer(offer);
} }>
<Column gap={ 0 }>
<Text fontWeight="bold">{ getOfferName(offer) }</Text>
{ meta.length > 0 && <Text small>{ meta }</Text> }
</Column>
<div className="flex flex-col gap-1">
{ (offer.priceCredits > 0) &&
return (
<LayoutGridItem key={ index } alignItems="center" center={ false } className="p-2" column={ false } itemActive={ pendingOffer?.offerId === offer.offerId } justifyContent="between" onClick={ () =>
{
setPurchaseState(CatalogPurchaseState.NONE);
setPendingOffer(offer);
} }>
<Column gap={ 0 }>
<Text fontWeight="bold">{ getOfferName(offer) }</Text>
{ meta.length > 0 && <Text small>{ meta }</Text> }
</Column>
<div className="flex flex-col gap-1">
{ (offer.priceCredits > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceCredits }</Text>
<LayoutCurrencyIcon type={ -1 } />
</Flex> }
{ (offer.priceActivityPoints > 0) &&
{ (offer.priceActivityPoints > 0) &&
<Flex alignItems="center" gap={ 1 } justifyContent="end">
<Text>{ offer.priceActivityPoints }</Text>
<LayoutCurrencyIcon type={ offer.priceActivityPointsType } />
</Flex> }
</div>
</LayoutGridItem>
);
}) }
</div>
</LayoutGridItem>
);
}) }
</Column>
</Column>
</Column>
<Column gap={ 2 } overflow="hidden" size={ 5 }>
<Column center grow overflow="hidden">
{ currentPage?.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
{ pageDescription.length > 0 && <Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(pageDescription) } } overflow="auto" /> }
</Column>
{ pendingOffer &&
<Column gap={ 2 } overflow="hidden" size={ 5 }>
<Column center grow overflow="hidden">
{ currentPage?.localization.getImage(1) && <img alt="" src={ currentPage.localization.getImage(1) } /> }
{ pageDescription.length > 0 && <Text center dangerouslySetInnerHTML={ { __html: SanitizeHtml(pageDescription) } } overflow="auto" /> }
</Column>
{ pendingOffer &&
<Column fullWidth gap={ 1 }>
<Text fontWeight="bold">{ getOfferName(pendingOffer) }</Text>
{ getOfferMeta(pendingOffer).length > 0 && <Text>{ getOfferMeta(pendingOffer) }</Text> }
@@ -202,7 +198,7 @@ export const CatalogLayoutBuildersClubBuyView: FC<CatalogLayoutProps> = () =>
</Flex>
{ getPurchaseButton() }
</Column> }
</Column>
</Column>
</Grid>
</div>
);
@@ -3,7 +3,7 @@ import { FC, useMemo, useState } from 'react';
import { FaFillDrip } from 'react-icons/fa';
import { IPurchasableOffer, SanitizeHtml } from '../../../../../api';
import { AutoGrid, Button, Column, Grid, LayoutGridItem, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { CatalogGridOfferView } from '../common/CatalogGridOfferView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogLimitedItemWidgetView } from '../widgets/CatalogLimitedItemWidgetView';
@@ -22,7 +22,8 @@ export const CatalogLayoutColorGroupingView: FC<CatalogLayoutColorGroupViewProps
{
const { page = null } = props;
const [ colorableItems, setColorableItems ] = useState<Map<string, number[]>>(new Map<string, number[]>());
const { currentOffer = null, setCurrentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
const [ colorsShowing, setColorsShowing ] = useState<boolean>(false);
const sortByColorIndex = (a: IPurchasableOffer, b: IPurchasableOffer) =>
@@ -117,7 +117,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
if(!prefixText.length) return;
const newColors: Record<number, string> = {};
[ ...prefixText ].forEach((_, i) => { newColors[i] = customColorInput; });
[ ...prefixText ].forEach((_, i) =>
{
newColors[i] = customColorInput;
});
setLetterColors(newColors);
};
@@ -222,7 +225,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
<Picker
data={ data }
locale="it"
onEmojiSelect={ (emoji: { native: string }) => { setSelectedIcon(emoji.native); setShowIconPicker(false); } }
onEmojiSelect={ (emoji: { native: string }) =>
{
setSelectedIcon(emoji.native); setShowIconPicker(false);
} }
theme="dark"
previewPosition="none"
skinTonePosition="search"
@@ -268,7 +274,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
borderRight: '1px solid rgba(0,0,0,0.1)',
opacity: colorMode === 'single' ? 1 : 0.6
} }
onClick={ () => { setColorMode('single'); setSelectedLetterIndex(null); } }>
onClick={ () =>
{
setColorMode('single'); setSelectedLetterIndex(null);
} }>
{ LocalizeText('catalog.prefix.color.single') }
</button>
<button
@@ -277,7 +286,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
background: colorMode === 'perLetter' ? 'rgba(59,130,246,0.25)' : 'rgba(0,0,0,0.1)',
opacity: colorMode === 'perLetter' ? 1 : 0.6
} }
onClick={ () => { setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0); } }>
onClick={ () =>
{
setColorMode('perLetter'); if(prefixText.length > 0) setSelectedLetterIndex(0);
} }>
{ LocalizeText('catalog.prefix.color.per.letter') }
</button>
</div>
@@ -328,7 +340,10 @@ export const CatalogLayoutCustomPrefixView: FC<CatalogLayoutProps> = props =>
zIndex: isSelected ? 10 : 1,
boxShadow: isSelected ? '0 0 8px rgba(59,130,246,0.3)' : 'none'
} }
onClick={ () => { setSelectedLetterIndex(i); setCustomColorInput(charColor); } }>
onClick={ () =>
{
setSelectedLetterIndex(i); setCustomColorInput(charColor);
} }>
<span className="text-sm font-black" style={ { color: charColor } }>
{ char }
</span>
@@ -2,7 +2,7 @@ import { FC } from 'react';
import { FaEdit, FaPlus } from 'react-icons/fa';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
@@ -17,7 +17,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, currentPage = null } = useCatalog();
const { currentOffer = null, currentPage = null } = useCatalogData();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
@@ -28,7 +28,10 @@ export const CatalogLayoutDefaultView: FC<CatalogLayoutProps> = props =>
<div className="flex gap-2">
<button
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
@@ -1,7 +1,7 @@
import { FC } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogGuildBadgeWidgetView } from '../widgets/CatalogGuildBadgeWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -13,7 +13,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayouGuildCustomFurniView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
return (
<Grid>
@@ -1,8 +1,7 @@
import { CatalogGroupsComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { FC, useState } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState, useUserGroups } from '../../../../../hooks';
import { CatalogFirstProductSelectorWidgetView } from '../widgets/CatalogFirstProductSelectorWidgetView';
import { CatalogGuildSelectorWidgetView } from '../widgets/CatalogGuildSelectorWidgetView';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
@@ -13,13 +12,9 @@ export const CatalogLayouGuildForumView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ selectedGroupIndex, setSelectedGroupIndex ] = useState<number>(0);
const { currentOffer = null, setCurrentOffer = null, catalogOptions = null } = useCatalog();
const { groups = null } = catalogOptions;
useEffect(() =>
{
SendMessageComposer(new CatalogGroupsComposer());
}, [ page ]);
const { currentOffer = null } = useCatalogData();
const { setCurrentOffer = null } = useCatalogUiState();
const { data: groups = null } = useUserGroups();
return (
<>
@@ -1,8 +1,9 @@
import { GetRoomAdPurchaseInfoComposer, GetUserEventCatsMessageComposer, PurchaseRoomAdMessageComposer, RoomAdPurchaseInfoEvent, RoomEntryData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, SendMessageComposer } from '../../../../../api';
import { useNitroQuery } from '../../../../../api/nitro-query';
import { Button, Column, Text } from '../../../../../common';
import { useCatalog, useMessageEvent, useNavigator, useRoomPromote } from '../../../../../hooks';
import { useCatalogUiState, useNavigator, useRoomPromote } from '../../../../../hooks';
import { NitroInput } from '../../../../../layout';
import { CatalogLayoutProps } from './CatalogLayout.types';
@@ -14,13 +15,20 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
const [ eventName, setEventName ] = useState<string>('');
const [ eventDesc, setEventDesc ] = useState<string>('');
const [ roomId, setRoomId ] = useState<number>(-1);
const [ availableRooms, setAvailableRooms ] = useState<RoomEntryData[]>([]);
const [ extended, setExtended ] = useState<boolean>(false);
const [ categoryId, setCategoryId ] = useState<number>(1);
const { categories = null } = useNavigator();
const { setIsVisible = null } = useCatalog();
const { setIsVisible = null } = useCatalogUiState();
const { promoteInformation, isExtended, setIsExtended } = useRoomPromote();
const { data: availableRooms = [] } = useNitroQuery<RoomAdPurchaseInfoEvent, RoomEntryData[]>({
key: [ 'nitro', 'catalog', 'room-ad-purchase-info' ],
request: () => new GetRoomAdPurchaseInfoComposer(),
parser: RoomAdPurchaseInfoEvent,
select: e => e.getParser()?.rooms ?? [],
staleTime: 60_000
});
useEffect(() =>
{
if(isExtended)
@@ -62,18 +70,8 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
resetData();
};
useMessageEvent<RoomAdPurchaseInfoEvent>(RoomAdPurchaseInfoEvent, event =>
{
const parser = event.getParser();
if(!parser) return;
setAvailableRooms(parser.rooms);
});
useEffect(() =>
{
SendMessageComposer(new GetRoomAdPurchaseInfoComposer());
// TODO: someone needs to fix this for morningstar
SendMessageComposer(new GetUserEventCatsMessageComposer());
}, []);
@@ -2,7 +2,7 @@ import { GetOfficialSongIdMessageComposer, GetSoundManager, MusicPriorities, Off
import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, ProductTypeEnum, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { Button, Column, Grid, LayoutImage, Text } from '../../../../../common';
import { useCatalog, useMessageEvent } from '../../../../../hooks';
import { useCatalogData, useMessageEvent } from '../../../../../hooks';
import { CatalogHeaderView } from '../../catalog-header/CatalogHeaderView';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -18,7 +18,7 @@ export const CatalogLayoutSoundMachineView: FC<CatalogLayoutProps> = props =>
const { page = null } = props;
const [ songId, setSongId ] = useState(-1);
const [ officialSongId, setOfficialSongId ] = useState('');
const { currentOffer = null, currentPage = null } = useCatalog();
const { currentOffer = null, currentPage = null } = useCatalogData();
const previewSong = (previewSongId: number) => GetSoundManager().musicController?.playSong(previewSongId, MusicPriorities.PRIORITY_PURCHASE_PREVIEW, 15, 0, 0, 0);
@@ -1,7 +1,7 @@
import { FC, useEffect } from 'react';
import { SanitizeHtml } from '../../../../../api';
import { Column, Grid, Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData } from '../../../../../hooks';
import { CatalogPurchaseWidgetView } from '../widgets/CatalogPurchaseWidgetView';
import { CatalogSpacesWidgetView } from '../widgets/CatalogSpacesWidgetView';
import { CatalogTotalPriceWidget } from '../widgets/CatalogTotalPriceWidget';
@@ -11,7 +11,7 @@ import { CatalogLayoutProps } from './CatalogLayout.types';
export const CatalogLayoutSpacesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const { currentOffer = null, roomPreviewer = null } = useCatalog();
const { currentOffer = null, roomPreviewer = null } = useCatalogData();
useEffect(() =>
{
@@ -2,7 +2,7 @@ import { FC, useEffect, useState } from 'react';
import { FaEdit, FaPen, FaPlus, FaTrophy } from 'react-icons/fa';
import { LocalizeText, ProductTypeEnum, SanitizeHtml } from '../../../../../api';
import { Text } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogData, useCatalogUiState } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
import { CatalogAddOnBadgeWidgetView } from '../widgets/CatalogAddOnBadgeWidgetView';
import { CatalogItemGridWidgetView } from '../widgets/CatalogItemGridWidgetView';
@@ -15,7 +15,8 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
const [ trophyText, setTrophyText ] = useState<string>('');
const { currentOffer = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null } = useCatalogData();
const { setPurchaseOptions = null } = useCatalogUiState();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
@@ -42,7 +43,10 @@ export const CatalogLayoutTrophiesView: FC<CatalogLayoutProps> = props =>
<div className="flex gap-2">
<button
className="flex items-center gap-1 text-[10px] text-primary hover:text-dark transition-colors cursor-pointer"
onClick={ () => { catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true); } }
onClick={ () =>
{
catalogAdmin.setEditingPageNode(null); catalogAdmin.setEditingRootPage(false); catalogAdmin.setEditingPageData(true);
} }
>
<FaEdit className="text-[10px]" /> { LocalizeText('catalog.admin.edit.page') }
</button>
@@ -1,19 +1,20 @@
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { ClubOfferData, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CatalogPurchaseState, LocalizeText, SanitizeHtml, SendMessageComposer } from '../../../../../api';
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
import { useCatalog, usePurse, useUiEvent } from '../../../../../hooks';
import { useCatalogData, useClubOffers, usePurse, useUiEvent } from '../../../../../hooks';
import { CatalogLayoutProps } from './CatalogLayout.types';
const VIP_WINDOW_ID = 1;
export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
{
const [ pendingOffer, setPendingOffer ] = useState<ClubOfferData>(null);
const [ purchaseState, setPurchaseState ] = useState(CatalogPurchaseState.NONE);
const { currentPage = null, catalogOptions = null } = useCatalog();
const { currentPage = null } = useCatalogData();
const { purse = null, getCurrencyAmount = null } = usePurse();
const { clubOffers = null, clubOffersByWindowId = null } = (catalogOptions || {});
const offers = clubOffersByWindowId?.[1] || clubOffers;
const { data: offers = null } = useClubOffers(VIP_WINDOW_ID);
const isPurchasingRef = useRef<boolean>(false);
const onCatalogEvent = useCallback((event: CatalogEvent) =>
@@ -128,11 +129,6 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
}
}, [ pendingOffer, purchaseState, purchaseSubscription, getCurrencyAmount ]);
useEffect(() =>
{
if(!offers) SendMessageComposer(new GetClubOffersMessageComposer(1));
}, [ offers ]);
return (
<Grid>
<Column fullHeight justifyContent="between" overflow="hidden" size={ 7 }>
@@ -1,7 +1,7 @@
import { CreateLinkEvent, FrontPageItem } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect } from 'react';
import { Column, Grid } from '../../../../../../common';
import { useCatalog } from '../../../../../../hooks';
import { useCatalogData } from '../../../../../../hooks';
import { CatalogRedeemVoucherView } from '../../common/CatalogRedeemVoucherView';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView';
@@ -9,7 +9,7 @@ import { CatalogLayoutFrontPageItemView } from './CatalogLayoutFrontPageItemView
export const CatalogLayoutFrontpage4View: FC<CatalogLayoutProps> = props =>
{
const { page = null, hideNavigation = null } = props;
const { frontPageItems = [] } = useCatalog();
const { frontPageItems = [] } = useCatalogData();
const selectItem = useCallback((item: FrontPageItem) =>
{

Some files were not shown because too many files have changed in this diff Show More