diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b81da00 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,150 @@ +name: CI + +on: + push: + branches: + - main + - 'feat/**' + pull_request: + workflow_dispatch: + inputs: + renderer_repo: + description: 'Renderer repo (owner/name). Empty = auto from client branch.' + required: false + default: '' + renderer_ref: + description: 'Renderer git ref. Empty = auto from client branch.' + required: false + default: '' + +# 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 /Nitro-V3 + # and the renderer into /Nitro_Render_V3. + - name: Checkout Nitro-V3 + uses: actions/checkout@v4 + with: + path: Nitro-V3 + + # Pick the renderer ref dynamically based on the client context. + # Renderer repo is always upstream `duckietm/Nitro_Render_V3` — + # the two repos must stay wire-aligned (composer/parser + # signatures); pairing `main` with a stale branch is what + # produced the "Expected 14-15 arguments, but got 16" failure on + # the catalog edit composer. + # + # Mapping: + # client `main` → duckietm/Nitro_Render_V3 @ main + # client `feat/**` → duckietm/Nitro_Render_V3 @ Dev + # PR base `main` → duckietm/Nitro_Render_V3 @ main + # PR base `Dev` (upstream) → duckietm/Nitro_Render_V3 @ Dev + # PR base `feat/**` → duckietm/Nitro_Render_V3 @ Dev + # + # Override via workflow_dispatch inputs when you need an ad-hoc + # pairing. + - name: Resolve renderer ref + id: renderer + run: | + REPO="${{ github.event.inputs.renderer_repo }}" + REF="${{ github.event.inputs.renderer_ref }}" + + if [ -z "$REPO" ] || [ -z "$REF" ]; then + case "${GITHUB_EVENT_NAME}" in + pull_request) + CTX="${GITHUB_BASE_REF}" + ;; + *) + CTX="${GITHUB_REF_NAME}" + ;; + esac + + AUTO_REPO="duckietm/Nitro_Render_V3" + case "$CTX" in + main) + AUTO_REF="main" + ;; + *) + AUTO_REF="Dev" + ;; + esac + + [ -z "$REPO" ] && REPO="$AUTO_REPO" + [ -z "$REF" ] && REF="$AUTO_REF" + fi + + echo "repo=$REPO" >> "$GITHUB_OUTPUT" + echo "ref=$REF" >> "$GITHUB_OUTPUT" + echo "Resolved renderer pairing: $REPO @ $REF (client ctx: ${GITHUB_BASE_REF:-$GITHUB_REF_NAME}, event: ${GITHUB_EVENT_NAME})" + + - name: Checkout Nitro_Render_V3 (sibling) + uses: actions/checkout@v4 + with: + repository: ${{ steps.renderer.outputs.repo }} + ref: ${{ steps.renderer.outputs.ref }} + 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 diff --git a/.gitignore b/.gitignore index b2f21a6..e7bee96 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d3e6978..9b62d2d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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()` → `useRef(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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e9c7383 --- /dev/null +++ b/CLAUDE.md @@ -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 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/// → views (.tsx only) + e.g. src/components/room/widgets/doorbell/DoorbellWidgetView.tsx + +src/hooks/// → 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**: + +- `useState.ts` — state + event subscriptions + derived values +- `useActions.ts` — pure imperative actions (no state writes) +- `useWidget.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 +const isIgn = useIsUserIgnored(name); // boolean, memoized +const badges = useGroupBadgesSnapshot(); // ReadonlyMap +const badge = useGroupBadge(groupId); // string, memoized +const vols = useVolumesSnapshot(); // sound volumes +const users = useRoomUserListSnapshot(); // ReadonlyArray +``` + +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({ + 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()((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 + + + +``` + +### 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` / `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().` 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 `. + 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/`. +- **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//Foo.ts` ↔ `src//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()`.** diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..494cf2c --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -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(types, reducer, initial)` — multiple event + types collapsing into one owned state slice (analogous to + `useReducer` but driven by renderer events). +- `useMessageEventReducer(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(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 + + + + +``` + +**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//` layout (vertical slices). The pilot on the +> doorbell widget showed that the existing `src/components//` + +> `src/hooks//` 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///*.tsx` + (e.g. `src/components/room/widgets/doorbell/DoorbellWidgetView.tsx`). +- **Hooks** live under `src/hooks///*.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()((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 ``. +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`). +- `` mounted; `App.tsx` made idempotent for the double-mount. + +### React 19 idioms +- **`forwardRef` → `ref` prop** on 7 layout/component files (11 call sites). +- **`` → ``** on 6 contexts. +- **Native ` + + + + + + Nitro + + + + + +
+ + + diff --git a/package.json b/package.json index cc82251..85c6c78 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "nitro-react", - "version": "2.2", + "version": "3.5.0", "homepage": ".", "private": true, "scripts": { @@ -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" } } diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example index acf246e..cf24e72 100644 --- a/public/configuration/UITexts.example +++ b/public/configuration/UITexts.example @@ -1,116 +1,269 @@ { - "notification.badge.received": "Nuovo Distintivo!", - "wiredfurni.badgereceived.title": "Distintivo ricevuto!", - "wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!", - "friendlist.search": "Search friends", - "purse.seasonal.currency.101": "cash", - "widget.chooser.checkall": "Select furniture", - "widget.chooser.btn.pickall": "pick up selected items!", - "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", - "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", - "widget.settings.general": "General", - "widget.settings.general.title": "Adjust the default Nitro settings", - "widget.settings.volume": "Volume", - "widget.settings.interface": "Interface", - "widget.settings.interface.title": "Adjust the interface settings", - "widget.settings.interface.fps.automatic": "Set FPS to unlimited", - "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", - "widget.settings.interface.secondary": "Change the window header color", - "widget.settings.interface.reset": "Reset header color to default", - "widget.room.chat.hide_pets": "Hide pets", - "widget.room.chat.hide_avatars": "Hide avatars", - "widget.room.chat.hide_balloon": "Hide speech bubble", - "widget.room.chat.show_balloon": "Speech bubble", - "widget.room.chat.clear_history": "clear history", - "widget.room.youtube.shared": "YouTube is being shared", - "widget.room.youtube.open_video": "Open the video", - "wiredfurni.tooltip.select.tile": "Select tile", - "wiredfurni.tooltip.remove.tile": "Deselect tile", - "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", - "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", - "wiredfurni.params.furni_neighborhood.group.user": "Players", - "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", - "wiredfurni.params.selector_option.bot": "No bots", - "wiredfurni.params.selector_option.pet": "No pets", - "catalog.title": "Catalog", - "catalog.favorites": "Favorites", - "catalog.favorites.pages": "Pages", - "catalog.favorites.furni": "Furni", - "catalog.favorites.empty": "No favorites", - "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", - "catalog.admin": "Admin", - "catalog.admin.new": "New", - "catalog.admin.root": "Root", - "catalog.admin.new.root.category": "New root category", - "catalog.admin.edit.root": "Edit Root", - "catalog.admin.edit": "Edit:", - "catalog.admin.edit.page": "Edit Page", - "catalog.admin.hidden": "hidden", - "catalog.admin.edit.title": "Edit \"%name%\"", - "catalog.admin.show": "Show", - "catalog.admin.hide": "Hide", - "catalog.admin.delete": "Delete", - "catalog.admin.delete.title": "Delete \"%name%\"", - "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", - "catalog.admin.delete.page": "Delete page", - "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", - "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", - "catalog.admin.create": "Create", - "catalog.admin.save": "Save", - "catalog.admin.create.subpage": "Create sub-page", - "catalog.admin.order": "Order", - "catalog.admin.visible": "Visible", - "catalog.admin.enabled": "Enabled", - "catalog.admin.offer.new": "New Offer", - "catalog.admin.offer.edit": "Edit Offer", - "catalog.admin.offer.name": "Catalog Name", - "catalog.admin.offer.general": "General", - "catalog.admin.offer.quantity": "Quantity", - "catalog.admin.offer.prices": "Prices", - "catalog.admin.offer.credits": "Credits", - "catalog.admin.offer.points": "Points", - "catalog.admin.offer.points.type": "Points Type", - "catalog.admin.offer.options": "Options", - "catalog.admin.offer.club.only": "Club Only", - "catalog.admin.offer.extradata": "Extra Data (optional)....", - "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", - "catalog.trophies.title": "Trophies", - "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", - "catalog.trophies.inscription": "Trophy Inscription", - "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", - "catalog.pets.show.colors": "Show colors", - "catalog.pets.choose.color": "Choose color", - "catalog.pets.choose.breed": "Choose breed", - "catalog.pets.back.breeds": "? Breeds", - "catalog.prefix.text": "Text", - "catalog.prefix.text.placeholder": "Enter text...", - "catalog.prefix.icon": "Icon", - "catalog.prefix.icon.remove": "Remove icon", - "catalog.prefix.effect": "Effect", - "catalog.prefix.color": "Color", - "catalog.prefix.color.single": "?? Single", - "catalog.prefix.color.per.letter": "?? Per Letter", - "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", - "catalog.prefix.color.apply.all.title": "Apply current color to all letters", - "catalog.prefix.color.apply.all": "Apply to all", - "catalog.prefix.color.selected": "Selected letter:", - "catalog.prefix.price": "Price:", - "catalog.prefix.price.amount": "5 Credits", - "catalog.prefix.purchased": "? Purchased!", - "catalog.prefix.purchase": "Purchase", - "groupforum.list.tab.most_active": "Most active threads", - "groupforum.list.tab.my_forums": "My group forums", - "groupforum.list.no_forums": "There are no forums", - "groupforum.view.threads": "Number of threads", - "groupforum.thread.pin": "Pin thread", - "groupforum.thread.unpin": "Unpin thread", - "groupforum.thread.lock": "Lock thread", - "groupforum.thread.unlock": "Unlock thread", - "groupforum.thread.hide": "Hide thread", - "groupforum.thread.restore": "Restore thread", - "groupforum.thread.delete": "Delete thread + posts", - "groupforum.message.hide": "Hide message", - "group.forum.enable.caption": "Enable / Disable group forum", - "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", - "groupforum.view.no_threads": "There are currently no active threads" + "notification.badge.received": "Nuovo Distintivo!", + "wiredfurni.badgereceived.title": "Distintivo ricevuto!", + "wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!", + "friendlist.search": "Search friends", + "purse.seasonal.currency.101": "cash", + "widget.chooser.checkall": "Select furniture", + "widget.chooser.btn.pickall": "pick up selected items!", + "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", + "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", + "widget.settings.general": "General", + "widget.settings.general.title": "Adjust the default Nitro settings", + "widget.settings.volume": "Volume", + "widget.settings.interface": "Interface", + "widget.settings.interface.title": "Adjust the interface settings", + "widget.settings.interface.fps.automatic": "Set FPS to unlimited", + "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", + "widget.settings.interface.secondary": "Change the window header color", + "widget.settings.interface.reset": "Reset header color to default", + "widget.room.chat.hide_pets": "Hide pets", + "widget.room.chat.hide_avatars": "Hide avatars", + "widget.room.chat.hide_balloon": "Hide speech bubble", + "widget.room.chat.show_balloon": "Speech bubble", + "widget.room.chat.clear_history": "clear history", + "widget.room.youtube.shared": "YouTube is being shared", + "widget.room.youtube.open_video": "Open the video", + "wiredfurni.tooltip.select.tile": "Select tile", + "wiredfurni.tooltip.remove.tile": "Deselect tile", + "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", + "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", + "wiredfurni.params.furni_neighborhood.group.user": "Players", + "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", + "wiredfurni.params.selector_option.bot": "No bots", + "wiredfurni.params.selector_option.pet": "No pets", + "catalog.title": "Catalog", + "catalog.favorites": "Favorites", + "catalog.favorites.pages": "Pages", + "catalog.favorites.furni": "Furni", + "catalog.favorites.empty": "No favorites", + "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", + "catalog.admin": "Admin", + "catalog.admin.new": "New", + "catalog.admin.root": "Root", + "catalog.admin.new.root.category": "New root category", + "catalog.admin.edit.root": "Edit Root", + "catalog.admin.edit": "Edit:", + "catalog.admin.edit.page": "Edit Page", + "catalog.admin.hidden": "hidden", + "catalog.admin.edit.title": "Edit \"%name%\"", + "catalog.admin.show": "Show", + "catalog.admin.hide": "Hide", + "catalog.admin.delete": "Delete", + "catalog.admin.delete.title": "Delete \"%name%\"", + "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", + "catalog.admin.delete.page": "Delete page", + "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", + "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", + "catalog.admin.create": "Create", + "catalog.admin.save": "Save", + "catalog.admin.create.subpage": "Create sub-page", + "catalog.admin.order": "Order", + "catalog.admin.visible": "Visible", + "catalog.admin.enabled": "Enabled", + "catalog.admin.offer.new": "New Offer", + "catalog.admin.offer.edit": "Edit Offer", + "catalog.admin.offer.name": "Catalog Name", + "catalog.admin.offer.general": "General", + "catalog.admin.offer.quantity": "Quantity", + "catalog.admin.offer.prices": "Prices", + "catalog.admin.offer.credits": "Credits", + "catalog.admin.offer.points": "Points", + "catalog.admin.offer.points.type": "Points Type", + "catalog.admin.offer.options": "Options", + "catalog.admin.offer.club.only": "Club Only", + "catalog.admin.offer.extradata": "Extra Data (optional)....", + "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", + "catalog.trophies.title": "Trophies", + "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", + "catalog.trophies.inscription": "Trophy Inscription", + "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", + "catalog.pets.show.colors": "Show colors", + "catalog.pets.choose.color": "Choose color", + "catalog.pets.choose.breed": "Choose breed", + "catalog.pets.back.breeds": "? Breeds", + "catalog.prefix.text": "Text", + "catalog.prefix.text.placeholder": "Enter text...", + "catalog.prefix.icon": "Icon", + "catalog.prefix.icon.remove": "Remove icon", + "catalog.prefix.effect": "Effect", + "catalog.prefix.color": "Color", + "catalog.prefix.color.single": "?? Single", + "catalog.prefix.color.per.letter": "?? Per Letter", + "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", + "catalog.prefix.color.apply.all.title": "Apply current color to all letters", + "catalog.prefix.color.apply.all": "Apply to all", + "catalog.prefix.color.selected": "Selected letter:", + "catalog.prefix.price": "Price:", + "catalog.prefix.price.amount": "5 Credits", + "catalog.prefix.purchased": "? Purchased!", + "catalog.prefix.purchase": "Purchase", + "modtools.userinfo.title": "User Info: %username%", + "modtools.userinfo.userName": "Name", + "modtools.userinfo.cfhCount": "CFHs", + "modtools.userinfo.abusiveCfhCount": "Abusive CFHs", + "modtools.userinfo.cautionCount": "Cautions", + "modtools.userinfo.banCount": "Bans", + "modtools.userinfo.lastSanctionTime": "Last Sanction", + "modtools.userinfo.tradingLockCount": "Trade Locks", + "modtools.userinfo.tradingExpiryDate": "Lock Expires", + "modtools.userinfo.minutesSinceLastLogin": "Last Login", + "modtools.userinfo.lastPurchaseDate": "Last Purchase", + "modtools.userinfo.primaryEmailAddress": "Email", + "modtools.userinfo.identityRelatedBanCount": "Banned Accs", + "modtools.userinfo.registrationAgeInMinutes": "Registered", + "modtools.userinfo.userClassification": "Rank", + "modtools.window.title": "Mod Tools", + "modtools.window.tools.room": "Room Tool", + "modtools.window.tools.chatlog": "Chatlog Tool", + "modtools.window.tools.report": "Report Tool", + "modtools.window.select.user": "Select a user", + "modtools.window.no.room": "Enter a room first", + "modtools.window.user.in_room": "Still in this room", + "modtools.window.user.left_room": "No longer in this room", + "modtools.window.user.clear": "Clear selection", + "modtools.window.tickets.open": "%count% open ticket", + "modtools.window.tickets.open.many": "%count% open tickets", + "modtools.window.section.room": "Room", + "modtools.window.section.user": "User", + "modtools.window.section.reports": "Reports", + "modtools.window.user.open_info": "Open Info", + "modtools.userinfo.refresh": "Refresh user info", + "modtools.userinfo.presence.in_room": "In room", + "modtools.userinfo.presence.in_room.title": "In the room you are observing", + "modtools.userinfo.presence.online": "Online", + "modtools.userinfo.presence.online.title": "Online on the hotel", + "modtools.userinfo.presence.offline": "Offline", + "modtools.userinfo.presence.offline.title": "Offline at panel open", + "modtools.userinfo.section.account": "Account", + "modtools.userinfo.section.activity": "Activity", + "modtools.userinfo.section.sanctions": "Sanctions", + "modtools.userinfo.section.trading": "Trading", + "modtools.userinfo.button.room.chat": "Room Chat", + "modtools.userinfo.button.send.message": "Send Message", + "modtools.userinfo.button.room.visits": "Room Visits", + "modtools.userinfo.button.mod.action": "Mod Action", + "modtools.userinfo.stat.cfh": "CFH", + "modtools.userinfo.stat.cautions": "Cautions", + "modtools.userinfo.stat.bans": "Bans", + "modtools.userinfo.stat.trade.locks": "Trade locks", + "modtools.roominfo.title": "Room Info", + "modtools.roominfo.refresh": "Refresh room info", + "modtools.roominfo.loading": "Loading…", + "modtools.roominfo.owner.here": "Owner here", + "modtools.roominfo.owner.away": "Owner away", + "modtools.roominfo.owner.title.here": "The room owner is currently inside", + "modtools.roominfo.owner.title.away": "The room owner is NOT inside", + "modtools.roominfo.stat.users": "Users", + "modtools.roominfo.stat.owner": "Owner", + "modtools.roominfo.owner.open": "Open %username%'s info", + "modtools.roominfo.button.visit": "Visit Room", + "modtools.roominfo.button.chatlog": "Chatlog", + "modtools.roominfo.moderate.title": "Moderate room", + "modtools.roominfo.moderate.kick": "Kick everyone out", + "modtools.roominfo.moderate.doorbell": "Enable the doorbell", + "modtools.roominfo.moderate.rename": "Change room name", + "modtools.roominfo.moderate.message.placeholder": "Mandatory message to deliver with the action…", + "modtools.roominfo.moderate.send.caution": "Send Caution", + "modtools.roominfo.moderate.send.alert": "Send Alert", + "modtools.user.message.title": "Send Message", + "modtools.user.message.recipient": "Message to", + "modtools.user.message.label": "Message", + "modtools.user.message.placeholder": "Write something useful — the user will see it as a moderator message.", + "modtools.user.message.empty": "Empty", + "modtools.user.message.chars": "%count% chars", + "modtools.user.message.send": "Send Message", + "modtools.user.modaction.title": "Mod Action: %username%", + "modtools.user.modaction.sanctioning": "Sanctioning", + "modtools.user.modaction.step.topic": "1. CFH Topic", + "modtools.user.modaction.step.topic.placeholder": "Select a topic…", + "modtools.user.modaction.step.sanction": "2. Sanction", + "modtools.user.modaction.step.sanction.placeholder": "Select a sanction…", + "modtools.user.modaction.step.message": "3. Custom message", + "modtools.user.modaction.step.message.optional": "(optional — overrides default)", + "modtools.user.modaction.message.placeholder": "Leave empty to use the default topic message", + "modtools.user.modaction.preview": "Preview", + "modtools.user.modaction.button.default": "Default Sanction", + "modtools.user.modaction.button.apply": "Apply Sanction", + "modtools.user.modaction.error.no.topic": "You must select a CFH topic", + "modtools.user.modaction.error.no.action": "You must select a CFH topic and Sanction", + "modtools.user.modaction.error.no.permission": "You do not have permission to do this", + "modtools.user.modaction.error.no.message": "Please write a message to user", + "modtools.user.modaction.error.no.permission.alert": "You have insufficient permissions", + "modtools.user.visits.title": "User Visits", + "modtools.user.visits.recent": "Recent visited rooms", + "modtools.user.visits.entries.one": "%count% entry", + "modtools.user.visits.entries.many": "%count% entries", + "modtools.user.visits.empty": "No recent visits", + "modtools.user.visits.time": "Time", + "modtools.user.visits.room": "Room name", + "modtools.user.visits.action": "Action", + "modtools.user.visits.visit": "Visit", + "modtools.user.visits.visit.title": "Visit room", + "modtools.user.chatlog.title": "User Chatlog", + "modtools.user.chatlog.title.with": "User Chatlog: %username%", + "modtools.user.chatlog.loading": "Loading chatlog…", + "modtools.room.chatlog.title": "Room Chatlog", + "modtools.chatlog.column.time": "Time", + "modtools.chatlog.column.user": "User", + "modtools.chatlog.column.message": "Message", + "modtools.chatlog.empty": "No messages", + "modtools.chatlog.visit": "Visit", + "modtools.chatlog.tools": "Tools", + "modtools.tickets.title": "Tickets", + "modtools.tickets.tab.open": "Open", + "modtools.tickets.tab.mine": "Mine", + "modtools.tickets.tab.picked": "All picked", + "modtools.tickets.column.type": "Type", + "modtools.tickets.column.reported": "Reported", + "modtools.tickets.column.opened": "Opened", + "modtools.tickets.column.picker": "Picker", + "modtools.tickets.empty.open": "No open issues", + "modtools.tickets.empty.mine": "No issues picked by you", + "modtools.tickets.empty.picked": "No picked issues", + "modtools.tickets.action.pick": "Pick", + "modtools.tickets.action.handle": "Handle", + "modtools.tickets.action.release": "Release", + "modtools.tickets.issue.title": "Resolving issue #%issueId%", + "modtools.tickets.issue.label": "Issue #%issueId%", + "modtools.tickets.issue.details": "Details", + "modtools.tickets.issue.field.source": "Source", + "modtools.tickets.issue.field.category": "Category", + "modtools.tickets.issue.field.description": "Description", + "modtools.tickets.issue.field.caller": "Caller", + "modtools.tickets.issue.field.reported": "Reported", + "modtools.tickets.issue.chatlog.view": "View chatlog", + "modtools.tickets.issue.chatlog.close": "Close chatlog", + "modtools.tickets.issue.resolve.heading": "Resolve as", + "modtools.tickets.issue.resolve.resolved": "Resolved", + "modtools.tickets.issue.resolve.useless": "Useless", + "modtools.tickets.issue.resolve.abusive": "Abusive", + "modtools.tickets.issue.release": "Release back to queue", + "modtools.tickets.cfh.chatlog.title": "Issue #%issueId% Chatlog", + "groupforum.list.tab.most_active": "Most active threads", + "groupforum.list.tab.my_forums": "My group forums", + "groupforum.list.no_forums": "There are no forums", + "groupforum.view.threads": "Number of threads", + "groupforum.thread.pin": "Pin thread", + "groupforum.thread.unpin": "Unpin thread", + "groupforum.thread.lock": "Lock thread", + "groupforum.thread.unlock": "Unlock thread", + "groupforum.thread.hide": "Hide thread", + "groupforum.thread.restore": "Restore thread", + "groupforum.thread.delete": "Delete thread + posts", + "groupforum.message.hide": "Hide message", + "group.forum.enable.caption": "Enable / Disable group forum", + "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", + "groupforum.view.no_threads": "There are currently no active threads", + "loading.task.session": "Verifying session...", + "loading.task.renderer": "Initializing renderer...", + "loading.task.assets": "loading game assets...", + "loading.task.localization": "loading translations...", + "loading.task.avatar": "loading wardrobe...", + "loading.task.sounds": "loading sounds...", + "loading.task.startsession": "Starting session...", + "loading.task.userdata": "loading user data...", + "loading.task.rooms": "loading rooms...", + "loading.task.engine": "loading graphics engine...", + "catalog.gift_wrapping.gift_sent": "Done!" } diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js index 7483733..8a4a916 100644 --- a/public/configuration/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -57,7 +57,10 @@ const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // Match the React LoadingView background so the pre-React shell paints + // the same gradient — no light-blue login-skeleton flash before the + // loader takes over. + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/public/configuration/infostand_backgrounds.json b/public/configuration/infostand_backgrounds.json index 3608e73..e45ee65 100644 --- a/public/configuration/infostand_backgrounds.json +++ b/public/configuration/infostand_backgrounds.json @@ -708,5 +708,33 @@ { "backgroundId": 15 } + ], + "borders.data": [ + { "borderId": 0 }, + { "borderId": 1 }, + { "borderId": 2 }, + { "borderId": 3 }, + { "borderId": 4 }, + { "borderId": 5 }, + { "borderId": 6 }, + { "borderId": 7 }, + { "borderId": 8 }, + { "borderId": 9 }, + { "borderId": 10 }, + { "borderId": 11 }, + { "borderId": 12 }, + { "borderId": 13 }, + { "borderId": 14 }, + { "borderId": 15 }, + { "borderId": 16 }, + { "borderId": 17 }, + { "borderId": 18 }, + { "borderId": 19 }, + { "borderId": 20 }, + { "borderId": 21 }, + { "borderId": 22 }, + { "borderId": 23 }, + { "borderId": 24 }, + { "borderId": 25 } ] } diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index b8287ff..5745409 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -48,6 +48,9 @@ "timezone.settings": "Europe/Amsterdam", "youtube.publish.disabled": false, "user.badges.group.slot.enabled": true, + "loading.logo.url": "", + "loading.background": "", + "loading.progress.color": "linear-gradient(90deg,#4f8cff,#2563eb)", "login.screen.enabled": true, "login.endpoint": "${api.url}/api/auth/login", "login.register.endpoint": "${api.url}/api/auth/register", @@ -68,8 +71,11 @@ "badges.custom.update.endpoint": "${api.url}/api/badges/custom/%badgeId%", "badges.custom.delete.endpoint": "${api.url}/api/badges/custom/%badgeId%", "badges.custom.texts.endpoint": "${api.url}/api/badges/custom/texts", + "badges.custom.texts.endpoint": "${api.url}//api/auth/change-password", "badges.leaderboard.endpoint": "${api.url}/api/badges/leaderboard", - "emustats.endpoint": "${api.url}/api/emustats", + "account.change-password.endpoint": "${api.url}/api/auth/change-password", + "account.change-email.endpoint": "${api.url}/api/auth/change-email", + "account.change-username.endpoint": "${api.url}/api/auth/change-username", "login.turnstile.enabled": true, "login.turnstile.sitekey": "1x00000000000000000000AA", "avatar.mandatory.libraries": [ diff --git a/scripts/minify-dist.mjs b/scripts/minify-dist.mjs index c61ff54..bfb56ed 100644 --- a/scripts/minify-dist.mjs +++ b/scripts/minify-dist.mjs @@ -78,4 +78,4 @@ for(const [ source, file ] of publicLoaderAssets) } } -writeFileSync(join(dist, 'index.html'), `
`); +writeFileSync(join(dist, 'index.html'), `
`); diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 4b082fc..44976c6 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => { const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // Match the React LoadingView background so the pre-React shell paints + // the same gradient — no light-blue login-skeleton flash before the + // loader takes over. + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/src/App.tsx b/src/App.tsx index aab3dbc..55fce02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ 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 { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; +import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, SetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -36,7 +36,8 @@ const preloadUrl = async (url: string): Promise => 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[] => @@ -70,20 +72,99 @@ export const App: FC<{}> = props => const [ showLogin, setShowLogin ] = useState(false); const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin()); const [ prepareTrigger, setPrepareTrigger ] = useState(0); + const [ loadingProgress, setLoadingProgress ] = useState(0); + const [ loadingTask, setLoadingTask ] = useState(''); + const taskLabel = useCallback((key: string, fallback: string): string => + { + try + { + const locManager = GetLocalizationManager(); + if(locManager && typeof locManager.getValue === 'function') + { + const fromLoc = locManager.getValue(key, false); + + if(typeof fromLoc === 'string' && fromLoc.length && fromLoc !== key) return fromLoc; + } + } + catch + { } + + try + { + const fromConfig = GetConfiguration().getValue(key, ''); + if(typeof fromConfig === 'string' && fromConfig.length) return fromConfig; + } + catch + { } + + return fallback; + }, []); + const bumpProgress = useCallback((value: number, task?: string) => + { + setLoadingProgress(prev => (value > prev ? value : prev)); + if(task !== undefined) setLoadingTask(task); + }, []); const warmupPromiseRef = useRef>(null); const rendererPromiseRef = useRef>(null); + const gameInitPromiseRef = useRef | null>(null); + const bootstrapDoneRef = useRef(false); + const lastPrepareTriggerRef = useRef(null); const tickersStartedRef = useRef(false); const heartbeatIntervalRef = useRef(null); const rememberRotateIntervalRef = useRef(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 {} + try + { + const url = new URL(window.location.href); + + if(url.searchParams.has('sso')) + { + url.searchParams.delete('sso'); + window.history.replaceState({}, '', url.toString()); + } + } + catch {} + }, []); + 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 fallbackToLogin = useCallback(() => + { + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + + if(!loginScreenEnabled) + { + console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead'); + showSessionExpired(); + return; + } + console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared'); + clearStoredCredentials(); + setHomeUrl(''); + setErrorMessage(''); + setIsReady(false); + setShowLogin(true); + setIsEnteringHotel(false); + }, [ clearStoredCredentials, showSessionExpired ]); const applySsoTicket = useCallback((ssoTicket: string) => { @@ -105,10 +186,18 @@ 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) + { + if(remembered) ClearRememberLogin(); + console.warn('[App] tryRememberLogin → no token, returning empty'); + return ''; + } try { @@ -126,30 +215,35 @@ export const App: FC<{}> = props => }); let payload: Record = {}; - 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; + ClearRememberLogin(); + console.warn('[App] tryRememberLogin → cleared remember, returning empty'); return ''; }, []); @@ -176,8 +270,12 @@ export const App: FC<{}> = props => }); let payload: Record = {}; - try { payload = await response.json(); } - catch {} + try + { + payload = await response.json(); + } + catch + {} if(response.ok) { @@ -194,8 +292,28 @@ export const App: FC<{}> = props => } }, []); - // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) - useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired); + useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]); + useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; }); + useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; }); + + useNitroEvent(NitroEventType.SOCKET_CLOSED, () => + { + 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, event => { @@ -238,6 +356,7 @@ export const App: FC<{}> = props => warmupPromiseRef.current = (async () => { await GetConfiguration().init(); + bumpProgress(25, taskLabel('loader.waiting', 'Loading content...')); GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); @@ -274,18 +393,25 @@ export const App: FC<{}> = props => loginImageUrls.forEach(preloadImage); gamedataUrls.forEach(url => preloadUrl(url)); - await Promise.all( - [ - GetAssetManager().downloadAssets(assetUrls), - GetLocalizationManager().init(), - GetAvatarRenderManager().init(), - GetSoundManager().init() - ] - ); + const warmupTasks: { promise: Promise; label: string }[] = [ + { promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Loading game assets...') }, + { promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Loading translations...') }, + { promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Loading wardrobe...') }, + { promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Loading sounds...') } + ]; + let warmupDone = 0; + const warmupStart = 25; + const warmupSpan = 45; + await Promise.all(warmupTasks.map(t => t.promise.then(value => + { + warmupDone++; + bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label); + return value; + }))); })(); return warmupPromiseRef.current; - }, [ startRenderer ]); + }, [ startRenderer, bumpProgress, taskLabel ]); useEffect(() => { @@ -306,10 +432,25 @@ 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(), + hasUrlSso: !!new URLSearchParams(window.location.search).get('sso') + }); + + const bootLabel = taskLabel('loader', 'Booting...'); + setLoadingProgress(0); + setLoadingTask(bootLabel); + bumpProgress(5, bootLabel); + try { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); @@ -317,17 +458,49 @@ export const App: FC<{}> = props => let ssoTicket = window.NitroConfig['sso.ticket']; if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); + try + { + const urlParams = new URLSearchParams(window.location.search); + const tokenParam = urlParams.get('token'); + const tokenExpParam = urlParams.get('token_exp'); + if(tokenParam && !GetRememberLogin()) + { + const parsedExpiry = Number(tokenExpParam || 0); + const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0) + ? parsedExpiry + : Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60); + SetRememberLogin({ token: tokenParam, expiresAt }); + } + } + catch(e) + { + console.warn('[App] failed to persist remember token from URL', e); + } + + bumpProgress(10, taskLabel('loading.task.session', 'Verifying session...')); + if(!ssoTicket || ssoTicket === '') { - // 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('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,22 +536,40 @@ export const App: FC<{}> = props => return; } - showSessionExpired(); + onSessionExpired(); return; } } const renderer = await startRenderer(width, height); + bumpProgress(20, taskLabel('loading.task.renderer', 'Initializing renderer...')); await startWarmup(width, height); - await GetSessionDataManager().init(); - await GetRoomSessionManager().init(); - await GetRoomEngine().init(); - await GetCommunication().init(); + bumpProgress(70, taskLabel('loading.task.startsession', 'Starting session...')); - if(LegacyExternalInterface.available) LegacyExternalInterface.call('legacyTrack', 'authentication', 'authok', []); + if(!gameInitPromiseRef.current) + { + gameInitPromiseRef.current = (async () => + { + await GetSessionDataManager().init(); + bumpProgress(78, taskLabel('loading.task.userdata', 'Loading user data...')); + await GetRoomSessionManager().init(); + bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...')); + await GetRoomEngine().init(); + bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...')); + await GetCommunication().init(); + bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...')); + })(); + } - 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); @@ -396,18 +587,21 @@ export const App: FC<{}> = props => GetTicker().add(ticker => GetTexturePool().run()); } + bumpProgress(100, taskLabel('onboarding.button.ready', 'Ready!')); setIsReady(true); setShowLogin(false); setIsEnteringHotel(false); } catch(err) { - NitroLogger.error(err); - setIsEnteringHotel(false); - showSessionExpired(); + NitroLogger.error('[App] Initialization failed — falling back to login', err); + onInitFailure(); } }; + if(lastPrepareTriggerRef.current === prepareTrigger) return; + lastPrepareTriggerRef.current = prepareTrigger; + const { width, height } = getViewportDimensions(); prepare(width, height); @@ -417,15 +611,15 @@ export const App: FC<{}> = props => if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current); }; - }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]); + }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]); return ( { !isReady && !showLogin && - 0 } message={ errorMessage } homeUrl={ homeUrl } /> } + 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> } { !isReady && showLogin && } { isReady && } - + { isReady && } ); diff --git a/src/api/auth/accessToken.ts b/src/api/auth/accessToken.ts index 1d53575..94f7d51 100644 --- a/src/api/auth/accessToken.ts +++ b/src/api/auth/accessToken.ts @@ -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 => diff --git a/src/api/avatar/AvatarEditorThumbnailsHelper.ts b/src/api/avatar/AvatarEditorThumbnailsHelper.ts index 5176436..c90eb1b 100644 --- a/src/api/avatar/AvatarEditorThumbnailsHelper.ts +++ b/src/api/avatar/AvatarEditorThumbnailsHelper.ts @@ -87,7 +87,7 @@ export class AvatarEditorThumbnailsHelper AvatarFigurePartType.PET, 'ptl', 'ptr', - AvatarFigurePartType.MISC, + AvatarFigurePartType.MISC, 'mcl', 'mcr', ]; diff --git a/src/api/avatar/dedupeBadges.test.ts b/src/api/avatar/dedupeBadges.test.ts new file mode 100644 index 0000000..33067a1 --- /dev/null +++ b/src/api/avatar/dedupeBadges.test.ts @@ -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', '' ]); + }); +}); diff --git a/src/api/avatar/dedupeBadges.ts b/src/api/avatar/dedupeBadges.ts new file mode 100644 index 0000000..e3a8d14 --- /dev/null +++ b/src/api/avatar/dedupeBadges.ts @@ -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[] => +{ + const seen = new Set(); + + return badges.map(code => + { + if(!code || seen.has(code)) return ''; + + seen.add(code); + + return code; + }); +}; diff --git a/src/api/avatar/index.ts b/src/api/avatar/index.ts index 6049e7a..11f6376 100644 --- a/src/api/avatar/index.ts +++ b/src/api/avatar/index.ts @@ -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'; diff --git a/src/api/badges/CustomBadgeApi.ts b/src/api/badges/CustomBadgeApi.ts index 9e6eeca..1cf9d88 100644 --- a/src/api/badges/CustomBadgeApi.ts +++ b/src/api/badges/CustomBadgeApi.ts @@ -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 (response: Response): Promise => { 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 => @@ -129,8 +141,14 @@ const injectTextsIntoLocalization = (texts: Record | null | unde { if(!texts) return; let manager: ReturnType | 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 => const payload = await parseJson<{ texts: Record }>(response); injectTextsIntoLocalization(payload.texts); } - catch {} + catch + {} })(); return customBadgeTextsLoadPromise; }; diff --git a/src/api/catalog/CatalogNode.ts b/src/api/catalog/CatalogNode.ts index 5e7c2fc..44e5f6a 100644 --- a/src/api/catalog/CatalogNode.ts +++ b/src/api/catalog/CatalogNode.ts @@ -6,6 +6,7 @@ export class CatalogNode implements ICatalogNode private _depth: number = 0; private _localization: string = ''; private _pageId: number = -1; + private _parentId: number = -1; private _pageName: string = ''; private _iconId: number = 0; private _children: ICatalogNode[]; @@ -21,6 +22,7 @@ export class CatalogNode implements ICatalogNode this._parent = parent; this._localization = node.localization; this._pageId = node.pageId; + this._parentId = node.parentId; this._pageName = node.pageName; this._iconId = node.icon; this._children = []; @@ -82,6 +84,11 @@ export class CatalogNode implements ICatalogNode return this._pageId; } + public get parentId(): number + { + return this._parentId; + } + public get pageName(): string { return this._pageName; diff --git a/src/api/catalog/FurnitureOffer.ts b/src/api/catalog/FurnitureOffer.ts index acc1c14..e9b744c 100644 --- a/src/api/catalog/FurnitureOffer.ts +++ b/src/api/catalog/FurnitureOffer.ts @@ -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 diff --git a/src/api/catalog/ICatalogNode.ts b/src/api/catalog/ICatalogNode.ts index 6253a75..c86e3d0 100644 --- a/src/api/catalog/ICatalogNode.ts +++ b/src/api/catalog/ICatalogNode.ts @@ -10,6 +10,7 @@ export interface ICatalogNode readonly isLeaf: boolean; readonly localization: string; readonly pageId: number; + readonly parentId: number; readonly pageName: string; readonly iconId: number; readonly children: ICatalogNode[]; diff --git a/src/api/catalog/ICatalogOptions.ts b/src/api/catalog/ICatalogOptions.ts deleted file mode 100644 index e8676e5..0000000 --- a/src/api/catalog/ICatalogOptions.ts +++ /dev/null @@ -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; - clubGifts?: ClubGiftInfoParser; - giftConfiguration?: GiftWrappingConfiguration; - marketplaceConfiguration?: MarketplaceConfigurationMessageParser; -} diff --git a/src/api/catalog/IPurchasableOffer.ts b/src/api/catalog/IPurchasableOffer.ts index bea7781..e918c50 100644 --- a/src/api/catalog/IPurchasableOffer.ts +++ b/src/api/catalog/IPurchasableOffer.ts @@ -24,4 +24,5 @@ export interface IPurchasableOffer products: IProduct[]; itemIds: string; haveOffer: boolean; + clone?(): IPurchasableOffer; } diff --git a/src/api/catalog/index.ts b/src/api/catalog/index.ts index 6c5b9e2..027bd2a 100644 --- a/src/api/catalog/index.ts +++ b/src/api/catalog/index.ts @@ -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'; diff --git a/src/api/friends/GetGroupChatData.ts b/src/api/friends/GetGroupChatData.ts index d1a2c7b..fe7feb8 100644 --- a/src/api/friends/GetGroupChatData.ts +++ b/src/api/friends/GetGroupChatData.ts @@ -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 }); }; diff --git a/src/api/index.ts b/src/api/index.ts index f196e2f..47caa6e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -26,6 +26,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'; diff --git a/src/api/nitro-query/createNitroQuery.ts b/src/api/nitro-query/createNitroQuery.ts new file mode 100644 index 0000000..bb21b29 --- /dev/null +++ b/src/api/nitro-query/createNitroQuery.ts @@ -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 +{ + /** + * Stable key for caching/deduping. Convention: + * `['nitro', '', '', ...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) | 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 = ( + config: NitroQueryConfig +): UseQueryResult => +{ + const { key, request, parser, select, accept, timeoutMs = 15_000, enabled, staleTime, refetchOnMount } = config; + + const options: UseQueryOptions = { + queryKey: key, + queryFn: () => awaitNitroResponse({ 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 = ( + config: Pick, 'request' | 'parser' | 'select' | 'accept' | 'timeoutMs'> +): Promise => + new Promise((resolve, reject) => + { + const { request, parser: ParserCtor, select, accept, timeoutMs = 15_000 } = config; + + let settled = false; + let timeoutHandle: ReturnType | 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()); + }); diff --git a/src/api/nitro-query/index.ts b/src/api/nitro-query/index.ts new file mode 100644 index 0000000..3eda014 --- /dev/null +++ b/src/api/nitro-query/index.ts @@ -0,0 +1,2 @@ +export * from './createNitroQuery'; +export * from './useNitroEventInvalidator'; diff --git a/src/api/nitro-query/useNitroEventInvalidator.ts b/src/api/nitro-query/useNitroEventInvalidator.ts new file mode 100644 index 0000000..d1a160c --- /dev/null +++ b/src/api/nitro-query/useNitroEventInvalidator.ts @@ -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 = ( + eventType: typeof MessageEvent, + queryKey: QueryKey, + accept?: (event: T) => boolean +) => +{ + const queryClient = useQueryClient(); + + useMessageEvent(eventType, event => + { + if(accept && !accept(event)) return; + + queryClient.invalidateQueries({ queryKey }); + }); +}; diff --git a/src/api/room/widgets/AvatarInfoUser.ts b/src/api/room/widgets/AvatarInfoUser.ts index 2d3157e..4c75e00 100644 --- a/src/api/room/widgets/AvatarInfoUser.ts +++ b/src/api/room/widgets/AvatarInfoUser.ts @@ -20,10 +20,11 @@ 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; + public borderId: number = 0; public webID: number = 0; public xp: number = 0; public userType: number = -1; diff --git a/src/api/room/widgets/AvatarInfoUtilities.ts b/src/api/room/widgets/AvatarInfoUtilities.ts index ffd8b8a..1cc0620 100644 --- a/src/api/room/widgets/AvatarInfoUtilities.ts +++ b/src/api/room/widgets/AvatarInfoUtilities.ts @@ -190,10 +190,11 @@ 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; + userInfo.borderId = (userData as any).borderId ?? 0; userInfo.achievementScore = userData.activityPoints; userInfo.webID = userData.webID; userInfo.roomIndex = userData.roomIndex; diff --git a/src/api/room/widgets/ChooserSelectionVisualizer.ts b/src/api/room/widgets/ChooserSelectionVisualizer.ts index b9bd248..36bfba0 100644 --- a/src/api/room/widgets/ChooserSelectionVisualizer.ts +++ b/src/api/room/widgets/ChooserSelectionVisualizer.ts @@ -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(); } } diff --git a/src/api/room/widgets/MannequinUtilities.ts b/src/api/room/widgets/MannequinUtilities.ts index 74d45f9..0c15d43 100644 --- a/src/api/room/widgets/MannequinUtilities.ts +++ b/src/api/room/widgets/MannequinUtilities.ts @@ -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])); }; } diff --git a/src/api/ui-settings/UiSettingsContext.tsx b/src/api/ui-settings/UiSettingsContext.tsx index 99685ce..f9311cf 100644 --- a/src/api/ui-settings/UiSettingsContext.tsx +++ b/src/api/ui-settings/UiSettingsContext.tsx @@ -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({ 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 = ({ children }) => { const [ settings, setSettings ] = useState(loadSettings); - const serverSaveTimerRef = useRef>(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) => { @@ -115,18 +84,16 @@ export const UiSettingsProvider: FC = ({ 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 = ({ 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 = ({ children }) => }, [ settings ]); return ( - + { children } - + ); }; diff --git a/src/api/utils/ProductImageUtility.ts b/src/api/utils/ProductImageUtility.ts index 5443513..55c241e 100644 --- a/src/api/utils/ProductImageUtility.ts +++ b/src/api/utils/ProductImageUtility.ts @@ -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; } diff --git a/src/api/utils/RememberLogin.ts b/src/api/utils/RememberLogin.ts index 6609af8..9920614 100644 --- a/src/api/utils/RememberLogin.ts +++ b/src/api/utils/RememberLogin.ts @@ -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, username?: string, ssoTicket?: string): void => diff --git a/src/api/utils/api-utils-extra.test.ts b/src/api/utils/api-utils-extra.test.ts new file mode 100644 index 0000000..9fb5205 --- /dev/null +++ b/src/api/utils/api-utils-extra.test.ts @@ -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); + }); +}); diff --git a/src/api/utils/api-utils.test.ts b/src/api/utils/api-utils.test.ts new file mode 100644 index 0000000..d200be3 --- /dev/null +++ b/src/api/utils/api-utils.test.ts @@ -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({}); + }); +}); diff --git a/src/api/utils/friendly-time.test.ts b/src/api/utils/friendly-time.test.ts new file mode 100644 index 0000000..10e7e30 --- /dev/null +++ b/src/api/utils/friendly-time.test.ts @@ -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'); + }); +}); diff --git a/src/api/youtube/YouTubeRoomState.ts b/src/api/youtube/YouTubeRoomState.ts index 364bf19..951011b 100644 --- a/src/api/youtube/YouTubeRoomState.ts +++ b/src/api/youtube/YouTubeRoomState.ts @@ -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; +}; diff --git a/src/assets/images/backgrounds/borders/border_17.webp b/src/assets/images/backgrounds/borders/border_17.webp new file mode 100644 index 0000000..78f5a10 Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_17.webp differ diff --git a/src/assets/images/backgrounds/borders/border_18.webp b/src/assets/images/backgrounds/borders/border_18.webp new file mode 100644 index 0000000..caec392 Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_18.webp differ diff --git a/src/assets/images/backgrounds/borders/border_19.webp b/src/assets/images/backgrounds/borders/border_19.webp new file mode 100644 index 0000000..4092794 Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_19.webp differ diff --git a/src/assets/images/backgrounds/borders/border_20.webp b/src/assets/images/backgrounds/borders/border_20.webp new file mode 100644 index 0000000..8227088 Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_20.webp differ diff --git a/src/assets/images/backgrounds/borders/border_21.webp b/src/assets/images/backgrounds/borders/border_21.webp new file mode 100644 index 0000000..3b2fe8c Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_21.webp differ diff --git a/src/assets/images/backgrounds/borders/border_22.webp b/src/assets/images/backgrounds/borders/border_22.webp new file mode 100644 index 0000000..d09ce2b Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_22.webp differ diff --git a/src/assets/images/backgrounds/borders/border_23.webp b/src/assets/images/backgrounds/borders/border_23.webp new file mode 100644 index 0000000..40ea0a5 Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_23.webp differ diff --git a/src/assets/images/backgrounds/borders/border_24.webp b/src/assets/images/backgrounds/borders/border_24.webp new file mode 100644 index 0000000..6da2b04 Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_24.webp differ diff --git a/src/assets/images/backgrounds/borders/border_25.webp b/src/assets/images/backgrounds/borders/border_25.webp new file mode 100644 index 0000000..0826e68 Binary files /dev/null and b/src/assets/images/backgrounds/borders/border_25.webp differ diff --git a/src/assets/images/user_custom/nick_icons/index.ts b/src/assets/images/user_custom/nick_icons/index.ts index 5881930..dd7b6a1 100644 --- a/src/assets/images/user_custom/nick_icons/index.ts +++ b/src/assets/images/user_custom/nick_icons/index.ts @@ -1,4 +1,4 @@ -const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }) as Record; +const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }); export const NICK_ICON_URLS: Record = Object.entries(rawNickIcons).reduce((accumulator, [ path, url ]) => { diff --git a/src/bootstrap.ts b/src/bootstrap.ts index 7646c5d..82a8f70 100644 --- a/src/bootstrap.ts +++ b/src/bootstrap.ts @@ -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 . +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 => diff --git a/src/common/Button.tsx b/src/common/Button.tsx index 6b7d454..b7cd1b0 100644 --- a/src/common/Button.tsx +++ b/src/common/Button.tsx @@ -19,7 +19,7 @@ export const Button: FC = props => // fucked up method i know (i dont have a clue what im doing because im a ninja) - const newClassNames: string[] = [ 'pointer-events-auto inline-block font-normal leading-normal text-[#fff] text-center no-underline align-middle cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ]; + const newClassNames: string[] = [ 'pointer-events-auto font-normal leading-normal text-[#fff] text-center no-underline cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ]; if(variant) { @@ -44,9 +44,9 @@ export const Button: FC = 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]'); } @@ -67,5 +67,5 @@ export const Button: FC = props => return newClassNames; }, [ variant, size, active, disabled, classNames ]); - return ; + return ; }; diff --git a/src/common/ButtonGroup.tsx b/src/common/ButtonGroup.tsx index 033bb1f..1962332 100644 --- a/src/common/ButtonGroup.tsx +++ b/src/common/ButtonGroup.tsx @@ -19,4 +19,4 @@ export const ButtonGroup: FC = props => }, [ classNames ]); return ; -} +}; diff --git a/src/common/GridContext.tsx b/src/common/GridContext.tsx index 082d4be..1825131 100644 --- a/src/common/GridContext.tsx +++ b/src/common/GridContext.tsx @@ -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({ isCssGrid: false }); -export const GridContextProvider: FC> = props => +export const GridContextProvider: FC<{ value: IGridContext; children?: ReactNode }> = props => { - return { props.children }; + return { props.children }; }; export const useGridContext = () => useContext(GridContext); diff --git a/src/common/Popover.tsx b/src/common/Popover.tsx index 25267fa..46aeece 100644 --- a/src/common/Popover.tsx +++ b/src/common/Popover.tsx @@ -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 = props => ) } ); -} +}; diff --git a/src/common/Text.tsx b/src/common/Text.tsx index ffc0a85..e81cab3 100644 --- a/src/common/Text.tsx +++ b/src/common/Text.tsx @@ -20,7 +20,8 @@ export interface TextProps extends BaseProps { textBreak?: boolean; } -export const Text: FC = props => { +export const Text: FC = props => +{ const { variant = 'black', fontWeight = null, @@ -40,20 +41,22 @@ export const Text: FC = 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); diff --git a/src/common/card/NitroCardContext.tsx b/src/common/card/NitroCardContext.tsx index c296b2a..8be7b98 100644 --- a/src/common/card/NitroCardContext.tsx +++ b/src/common/card/NitroCardContext.tsx @@ -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({ theme: null }); -export const NitroCardContextProvider: FC> = props => +export const NitroCardContextProvider: FC<{ value: INitroCardContext; children?: ReactNode }> = props => { - return { props.children }; + return { props.children }; }; export const useNitroCardContext = () => useContext(NitroCardContext); diff --git a/src/common/card/NitroCardView.tsx b/src/common/card/NitroCardView.tsx index 2d028f2..0e7488f 100644 --- a/src/common/card/NitroCardView.tsx +++ b/src/common/card/NitroCardView.tsx @@ -12,7 +12,7 @@ export interface NitroCardViewProps extends DraggableWindowProps, ColumnProps export const NitroCardView: FC = 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(); + const elementRef = useRef(null); const getClassNames = useMemo(() => { diff --git a/src/common/card/accordion/NitroCardAccordionContext.tsx b/src/common/card/accordion/NitroCardAccordionContext.tsx index 5e65c30..d8c93b6 100644 --- a/src/common/card/accordion/NitroCardAccordionContext.tsx +++ b/src/common/card/accordion/NitroCardAccordionContext.tsx @@ -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({ closeAll: null }); -export const NitroCardAccordionContextProvider: FC> = props => +export const NitroCardAccordionContextProvider: FC<{ value: INitroCardAccordionContext; children?: ReactNode }> = props => { - return ; + return ; }; export const useNitroCardAccordionContext = () => useContext(NitroCardAccordionContext); diff --git a/src/common/draggable-window/DraggableWindow.tsx b/src/common/draggable-window/DraggableWindow.tsx index 1e498e0..20a17e9 100644 --- a/src/common/draggable-window/DraggableWindow.tsx +++ b/src/common/draggable-window/DraggableWindow.tsx @@ -21,7 +21,8 @@ export interface DraggableWindowProps { children?: ReactNode; } -export const DraggableWindow: FC = props => { +export const DraggableWindow: FC = 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 }); @@ -29,50 +30,61 @@ export const DraggableWindow: FC = props => { const [isDragging, setIsDragging] = useState(false); const [isPositioned, setIsPositioned] = useState(false); const [dragHandler, setDragHandler] = useState(null); - const elementRef = useRef(); + const elementRef = useRef(null); 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) => { + const onMouseDown = useCallback((event: ReactMouseEvent) => + { moveCurrentWindow(); }, [moveCurrentWindow]); - const onTouchStart = useCallback((event: ReactTouchEvent) => { + const onTouchStart = useCallback((event: ReactTouchEvent) => + { 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 = 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 = 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 = 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 = props => { setOffset({ x: clampedPos.x, y: clampedPos.y }); setIsDragging(false); - if (uniqueKey !== null) { - const newStorage = { ...GetLocalStorage(`nitro.windows.${uniqueKey}`) } as WindowSaveOptions; + if (uniqueKey !== null) + { + const newStorage = { ...GetLocalStorage(`nitro.windows.${uniqueKey}`) }; newStorage.offset = { x: clampedPos.x, y: clampedPos.y }; SetLocalStorage(`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 = 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 = 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 = 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 = props => { }; }, [isDragging, onDragMouseUp, onDragMouseMove, onDragTouchUp, onDragTouchMove]); - useEffect(() => { + useEffect(() => + { if (!uniqueKey) return; const localStorage = GetLocalStorage(`nitro.windows.${uniqueKey}`); diff --git a/src/common/error-boundary/WidgetErrorBoundary.test.tsx b/src/common/error-boundary/WidgetErrorBoundary.test.tsx new file mode 100644 index 0000000..03725a6 --- /dev/null +++ b/src/common/error-boundary/WidgetErrorBoundary.test.tsx @@ -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( + + visible + + ); + + 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( + + + + ); + + // Default fallback is `() => null` → boundary subtree is empty. + expect(container).toBeEmptyDOMElement(); + + expect(NitroLogger.error).toHaveBeenCalledTimes(1); + const [ message, err ] = (NitroLogger.error as ReturnType).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( + offline }> + + + ); + + 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( + + + + ); + + expect(NitroLogger.error).toHaveBeenCalledTimes(1); + expect((NitroLogger.error as ReturnType).mock.calls[0][0]).toBe('[Widget:unknown] crashed'); + }); +}); diff --git a/src/common/error-boundary/WidgetErrorBoundary.tsx b/src/common/error-boundary/WidgetErrorBoundary.tsx new file mode 100644 index 0000000..229e8af --- /dev/null +++ b/src/common/error-boundary/WidgetErrorBoundary.tsx @@ -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 = ({ name = 'unknown', fallback, children }) => + ( + <>{ fallback } : SilentFallback } + onError={ (err) => NitroLogger.error(`[Widget:${ name }] crashed`, err) }> + { children } + + ); diff --git a/src/common/index.ts b/src/common/index.ts index 6802959..cd28d24 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -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'; diff --git a/src/common/layout/LayoutAvatarImageView.tsx b/src/common/layout/LayoutAvatarImageView.tsx index 853d361..75233a8 100644 --- a/src/common/layout/LayoutAvatarImageView.tsx +++ b/src/common/layout/LayoutAvatarImageView.tsx @@ -20,6 +20,12 @@ export const LayoutAvatarImageView: FC = props => const [ avatarUrl, setAvatarUrl ] = useState(null); const [ isReady, setIsReady ] = useState(false); const isDisposed = useRef(false); + // Request id bumped on every prop change. The SDK can call + // resetFigure asynchronously when server-side figure data lands; + // if props change in quick succession the older callback could + // otherwise overwrite the newer image. The closure captures the + // id and bails when stale. + const requestIdRef = useRef(0); const getClassNames = useMemo(() => { @@ -52,6 +58,7 @@ export const LayoutAvatarImageView: FC = 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 = 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 = props => const imageUrl = avatarImage.processAsImageUrl(setType); - if(imageUrl && !isDisposed.current) + if(imageUrl && !isDisposed.current && (requestIdRef.current === requestId)) { if(!avatarImage.isPlaceholder()) { diff --git a/src/common/layout/LayoutFurniImageView.tsx b/src/common/layout/LayoutFurniImageView.tsx index f7fe9dc..c2ad62b 100644 --- a/src/common/layout/LayoutFurniImageView.tsx +++ b/src/common/layout/LayoutFurniImageView.tsx @@ -17,21 +17,29 @@ export const LayoutFurniImageView: FC = props => const { productType = 's', productClassId = -1, direction = 2, extraData = '', scale = 1, style = {}, ...rest } = props; const [ imageElement, setImageElement ] = useState(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 = 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 = props => break; } - if(imageResult?.data) updateImage(imageResult.data); + if(imageResult?.data) updateImage(imageResult.data, requestId); }, [ productType, productClassId, direction, extraData, updateImage ]); return ; diff --git a/src/common/layout/LayoutMiniCameraView.tsx b/src/common/layout/LayoutMiniCameraView.tsx index 4d1c2dc..10fbd3e 100644 --- a/src/common/layout/LayoutMiniCameraView.tsx +++ b/src/common/layout/LayoutMiniCameraView.tsx @@ -9,11 +9,13 @@ interface LayoutMiniCameraViewProps { onClose: () => void; } -export const LayoutMiniCameraView: FC = props => { +export const LayoutMiniCameraView: FC = props => +{ const { roomId = -1, textureReceiver = null, onClose = null } = props; - const elementRef = useRef(); + const elementRef = useRef(null); - const getCameraBounds = () => { + const getCameraBounds = () => + { if (!elementRef || !elementRef.current) return null; const frameBounds = elementRef.current.getBoundingClientRect(); @@ -26,7 +28,8 @@ export const LayoutMiniCameraView: FC = props => { ); }; - const takePicture = () => { + const takePicture = () => + { PlaySound(SoundNames.CAMERA_SHUTTER); textureReceiver(GetRoomEngine().createTextureFromRoom(roomId, 1, getCameraBounds())); }; diff --git a/src/common/layout/LayoutPetImageView.tsx b/src/common/layout/LayoutPetImageView.tsx index acf1a79..29686b8 100644 --- a/src/common/layout/LayoutPetImageView.tsx +++ b/src/common/layout/LayoutPetImageView.tsx @@ -67,10 +67,12 @@ export const LayoutPetImageView: FC = 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 = props => setHeight(texture.height); } }, - imageFailed: (id) => + imageFailed: () => { - + // no-op } }, petHeadOnly, 0, petCustomParts, posture); diff --git a/src/common/layout/LayoutRoomObjectImageView.tsx b/src/common/layout/LayoutRoomObjectImageView.tsx index aa9301c..2e0472f 100644 --- a/src/common/layout/LayoutRoomObjectImageView.tsx +++ b/src/common/layout/LayoutRoomObjectImageView.tsx @@ -21,7 +21,10 @@ export const LayoutRoomObjectImageView: FC = pro { isMounted.current = true; - return () => { isMounted.current = false; }; + return () => + { + isMounted.current = false; + }; }, []); const getStyle = useMemo(() => @@ -50,13 +53,16 @@ export const LayoutRoomObjectImageView: FC = 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; diff --git a/src/common/layout/LayoutRoomPreviewerView.tsx b/src/common/layout/LayoutRoomPreviewerView.tsx index d35294c..4355e40 100644 --- a/src/common/layout/LayoutRoomPreviewerView.tsx +++ b/src/common/layout/LayoutRoomPreviewerView.tsx @@ -7,7 +7,7 @@ export const LayoutRoomPreviewerView: FC<{ }> = props => { const { roomPreviewer = null, height = 0 } = props; - const elementRef = useRef(); + const elementRef = useRef(null); const onClick = (event: MouseEvent) => { diff --git a/src/common/types/ColorVariantType.ts b/src/common/types/ColorVariantType.ts index 945b64d..3f0047b 100644 --- a/src/common/types/ColorVariantType.ts +++ b/src/common/types/ColorVariantType.ts @@ -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'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index bbe9d49..5f6882f 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -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'; @@ -44,11 +44,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.CREATED, event => setLandingViewVisible(false)); - useNitroEvent(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(() => { @@ -132,7 +154,7 @@ export const MainView: FC<{}> = props => - + diff --git a/src/components/ads/GoogleAdsView.tsx b/src/components/ads/GoogleAdsView.tsx index 4b65295..4da4373 100644 --- a/src/components/ads/GoogleAdsView.tsx +++ b/src/components/ads/GoogleAdsView.tsx @@ -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('show.google.ads', false); const [ isOpen, setIsOpen ] = useState(false); const [ publisherId, setPublisherId ] = useState(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 ( + { publisherId && +