diff --git a/.gitignore b/.gitignore index 249e7db..90a9bfe 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,10 @@ Thumbs.db *.zip .env .claude/ + +# Local runtime config copies +/public/configuration/renderer-config.json +/public/configuration/ui-config.json +/public/configuration/client-mode.json +/public/configuration/adsense.json +/public/configuration/hotlooks.json diff --git a/README.md b/README.md index a733fba..bc91d90 100644 --- a/README.md +++ b/README.md @@ -20,12 +20,13 @@ - `yarn install` - `yarn link "@nitrots/nitro-renderer"` <== This will link the renderer in the project - Rename a few files - - Rename `public/renderer-config.json.example` to `public/renderer-config.json` - - Rename `public/ui-config.json.example` to `public/ui-config.json` -- Set your links - - Open `public/renderer-config.json` + - Copy `public/configuration/renderer-config.example` to `public/configuration/renderer-config.json` + - Copy `public/configuration/ui-config.example` to `public/configuration/ui-config.json` + - Copy `public/configuration/client-mode.example` to `public/configuration/client-mode.json` + - Set your links + - Open `public/configuration/renderer-config.json` - Update `socket.url, asset.url, image.library.url, & hof.furni.url` - - Open `public/ui-config.json` + - Open `public/configuration/ui-config.json` - Update `camera.url, thumbnails.url, url.prefix, habbopages.url` - `yarn build` <== the final step to build the DIST folder this is where your browser needs to point / or upload this to your /client if you do the compile on a other machine (preferd) - You can override any variable by passing it to `NitroConfig` in the index.html diff --git a/docs/local-development-setup.en.md b/docs/local-development-setup.en.md new file mode 100644 index 0000000..b873850 --- /dev/null +++ b/docs/local-development-setup.en.md @@ -0,0 +1,279 @@ +# Local Development Setup with `yarn start` + +This guide explains how to run Nitro locally with Vite, using: + +- local UI on `http://localhost:5173`; +- local API/emulator on `http://localhost:2096`; +- local WebSocket on `ws://localhost:2096`; +- remote plain assets and gamedata, so you do not need to copy the full `client/nitro` folder locally. + +## 1. Start the emulator + +Inside `Arcturus-Morningstar-Extended/Emulator`, start the emulator with WebSocket enabled. + +Recommended local `config.ini` values: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=* +ws.ip.header= + +crypto.ws.enabled=0 + +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +For local development, it is easier to disable: + +- `crypto.ws.enabled`; +- `nitro.secure.assets.enabled`; +- `nitro.secure.api.enabled`. + +This keeps debugging simple and avoids the secure runtime layer. + +## 2. `public/configuration/client-mode.json` + +File: + +```txt +Nitro-V3/public/configuration/client-mode.json +``` + +Recommended local config: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "http://localhost:2096", + "plainConfigBaseUrl": "http://localhost:5173/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Notes: + +- `secureAssetsEnabled=false` avoids `/nitro-sec/file`. +- `secureApiEnabled=false` avoids encrypted `/api/*` requests. +- `apiBaseUrl` must point to your local emulator. +- `plainGamedataBaseUrl` can stay remote if you do not have gamedata copied locally. + +If you want everything local, use: + +```json +"plainGamedataBaseUrl": "http://localhost:5173/client/nitro/gamedata/" +``` + +but the files must really exist under: + +```txt +Nitro-V3/public/client/nitro/gamedata/ +``` + +## 3. `public/configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/public/configuration/renderer-config.json +``` + +Minimum local values: + +```json +{ + "socket.url": "ws://localhost:2096", + "api.url": "http://localhost:2096", + "crypto.ws.enabled": false, + "gamedata.url": "https://hotel.example.com/client/nitro/gamedata", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "login.endpoint": "${api.url}/api/auth/login", + "login.register.endpoint": "${api.url}/api/auth/register", + "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", + "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.health.endpoint": "${api.url}/api/health", + "login.health.method": "GET", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.register.imaging.url": "${api.url}/api/avatar/imaging", + "login.news.url": "${api.url}/api/auth/news", + "badges.custom.list.endpoint": "${api.url}/api/badges/custom", + "badges.custom.create.endpoint": "${api.url}/api/badges/custom", + "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" +} +``` + +Important: + +- Do not use `https://localhost:2096/nitro-sec/file` locally if `secureAssetsEnabled=false`. +- Do not use `ws://192.168.x.x/:2096`; it is malformed. Use `ws://localhost:2096` or `ws://192.168.x.x:2096`. + +## 4. `public/configuration/ui-config.json` + +File: + +```txt +Nitro-V3/public/configuration/ui-config.json +``` + +For the login view, you can use remote plain images: + +```json +{ + "loginview": { + "images": { + "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", + "background.colour": "#6eadc8", + "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", + "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" + } + } +} +``` + +If you see `ERR_NAME_NOT_RESOLVED`, the configured domain does not exist or is not reachable. + +## 5. Database-backed news + +Login news should come from the database through the emulator. + +In renderer config use: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +The emulator reads from: + +```txt +ui_news +``` + +Reference SQL: + +```txt +Arcturus-Morningstar-Extended/Database Updates/013_UI_Client_News.sql +``` + +Main columns: + +- `title` +- `body` +- `image` +- `link_text` +- `link_url` +- `enabled` +- `sort_order` + +`public/configuration/news.json` can stay as a mock/fallback only, but it is not the correct production flow. + +## 6. Start Nitro + +Inside `Nitro-V3`: + +```bash +yarn start +``` + +Open: + +```txt +http://localhost:5173 +``` + +Recommendation: use `localhost`, not `192.168.x.x`, because cookies and API sessions are host-based and can otherwise cause `401 Unauthorized`. + +## 7. Common errors + +### `Unable to load renderer-config.json` + +Check: + +```txt +public/configuration/client-mode.json +``` + +It must contain: + +```json +"secureAssetsEnabled": false +``` + +### `Invalid JSON ... Unexpected token '<'` + +The client requested JSON, but Vite returned HTML. + +This happens when a URL points to a file that does not exist, for example: + +```txt +http://localhost:5173/client/nitro/gamedata/ExternalTexts.json +``` + +Fix: + +- use remote plain gamedata; +- or copy the gamedata files into `public/client/nitro/gamedata`. + +### WebSocket `1006` + +Check: + +```json +"socket.url": "ws://localhost:2096" +``` + +and emulator config: + +```ini +ws.enabled=true +ws.port=2096 +``` + +### Custom badges `401 Unauthorized` + +This is normal if you are not logged in or if you open Nitro from a different host. + +Use: + +```txt +http://localhost:5173 +``` + +and API: + +```txt +http://localhost:2096 +``` + +## 8. Difference from production + +Local `yarn start`: + +```html + +``` + +Production build: + +```html + +``` + +Do not mix the two flows. diff --git a/docs/local-development-setup.md b/docs/local-development-setup.md new file mode 100644 index 0000000..b788e2e --- /dev/null +++ b/docs/local-development-setup.md @@ -0,0 +1,279 @@ +# Setup locale con `yarn start` + +Questa guida serve per avviare Nitro in locale con Vite, usando: + +- UI locale su `http://localhost:5173`; +- API/emulatore locale su `http://localhost:2096`; +- WebSocket locale su `ws://localhost:2096`; +- asset e gamedata remoti plain, così non devi copiare tutta la cartella `client/nitro`. + +## 1. Avvia l'emulatore + +Nel repo `Arcturus-Morningstar-Extended/Emulator`, avvia l'emulatore con WebSocket attivo. + +Nel tuo `config.ini` locale usa valori tipo: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=* +ws.ip.header= + +crypto.ws.enabled=0 + +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +Per il locale è meglio tenere spenti: + +- `crypto.ws.enabled`; +- `nitro.secure.assets.enabled`; +- `nitro.secure.api.enabled`. + +Così puoi debuggare senza layer secure in mezzo. + +## 2. `public/configuration/client-mode.json` + +File: + +```txt +Nitro-V3/public/configuration/client-mode.json +``` + +Config locale consigliato: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "http://localhost:2096", + "plainConfigBaseUrl": "http://localhost:5173/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Note: + +- `secureAssetsEnabled=false` evita `/nitro-sec/file`. +- `secureApiEnabled=false` evita cifratura `/api/*`. +- `apiBaseUrl` deve puntare all'emulatore locale. +- `plainGamedataBaseUrl` può rimanere remoto se non hai gamedata copiato in locale. + +Se vuoi tutto locale, usa: + +```json +"plainGamedataBaseUrl": "http://localhost:5173/client/nitro/gamedata/" +``` + +ma devi avere davvero i file sotto: + +```txt +Nitro-V3/public/client/nitro/gamedata/ +``` + +## 3. `public/configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/public/configuration/renderer-config.json +``` + +Valori minimi locali: + +```json +{ + "socket.url": "ws://localhost:2096", + "api.url": "http://localhost:2096", + "crypto.ws.enabled": false, + "gamedata.url": "https://hotel.example.com/client/nitro/gamedata", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "login.endpoint": "${api.url}/api/auth/login", + "login.register.endpoint": "${api.url}/api/auth/register", + "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", + "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.health.endpoint": "${api.url}/api/health", + "login.health.method": "GET", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.register.imaging.url": "${api.url}/api/avatar/imaging", + "login.news.url": "${api.url}/api/auth/news", + "badges.custom.list.endpoint": "${api.url}/api/badges/custom", + "badges.custom.create.endpoint": "${api.url}/api/badges/custom", + "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" +} +``` + +Importante: + +- Non usare `https://localhost:2096/nitro-sec/file` in locale se `secureAssetsEnabled=false`. +- Non usare `ws://192.168.x.x/:2096`: è malformato. Usa `ws://localhost:2096` oppure `ws://192.168.x.x:2096`. + +## 4. `public/configuration/ui-config.json` + +File: + +```txt +Nitro-V3/public/configuration/ui-config.json +``` + +Per la login view puoi usare immagini remote plain: + +```json +{ + "loginview": { + "images": { + "background": "https://hotel.example.com/client/nitro/images/reception/background_gradient_apr25.png", + "background.colour": "#6eadc8", + "drape": "https://hotel.example.com/client/nitro/images/reception/drape.png", + "left": "https://hotel.example.com/client/nitro/images/reception/mute_reception_backdrop_left.png", + "right": "https://hotel.example.com/client/nitro/images/reception/background_right.png" + } + } +} +``` + +Se vedi `ERR_NAME_NOT_RESOLVED`, il dominio configurato non esiste o non è raggiungibile. + +## 5. News dal database + +Le news della login devono arrivare dal database tramite l'emulatore. + +Nel renderer config usa: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +L'emulatore legge dalla tabella: + +```txt +ui_news +``` + +SQL di riferimento: + +```txt +Arcturus-Morningstar-Extended/Database Updates/013_UI_Client_News.sql +``` + +Colonne principali: + +- `title` +- `body` +- `image` +- `link_text` +- `link_url` +- `enabled` +- `sort_order` + +`public/configuration/news.json` può rimanere solo come mock/fallback, ma non è il flow corretto. + +## 6. Avvio Nitro + +Nel repo `Nitro-V3`: + +```bash +yarn start +``` + +Apri: + +```txt +http://localhost:5173 +``` + +Consiglio: usa `localhost`, non `192.168.x.x`, perché cookie e sessioni API possono cambiare host e causare `401 Unauthorized`. + +## 7. Errori comuni + +### `Unable to load renderer-config.json` + +Controlla: + +```txt +public/configuration/client-mode.json +``` + +Deve avere: + +```json +"secureAssetsEnabled": false +``` + +### `Invalid JSON ... Unexpected token '<'` + +Vuol dire che il client ha chiesto un JSON, ma Vite ha risposto HTML. + +Succede quando un URL punta a un file che non esiste, per esempio: + +```txt +http://localhost:5173/client/nitro/gamedata/ExternalTexts.json +``` + +Soluzione: + +- usa gamedata remoto plain; +- oppure copia davvero i gamedata in `public/client/nitro/gamedata`. + +### WebSocket `1006` + +Controlla: + +```json +"socket.url": "ws://localhost:2096" +``` + +e nel config emulator: + +```ini +ws.enabled=true +ws.port=2096 +``` + +### Custom badges `401 Unauthorized` + +È normale se non sei loggato o se apri Nitro da un host diverso. + +Usa: + +```txt +http://localhost:5173 +``` + +e API: + +```txt +http://localhost:2096 +``` + +## 8. Differenza con produzione + +Locale con `yarn start`: + +```html + +``` + +Produzione buildata: + +```html + +``` + +Non mischiare i due flow. diff --git a/docs/secure-production-setup.en.md b/docs/secure-production-setup.en.md new file mode 100644 index 0000000..ab86bfd --- /dev/null +++ b/docs/secure-production-setup.en.md @@ -0,0 +1,365 @@ +# Secure Runtime Production Setup + +Quick setup guide for running Nitro with: + +- configuration and gamedata served through `/nitro-sec/file`; +- encrypted runtime `/api/*` calls; +- obfuscated production bundles loaded as `.dat`. + +Replace the example domains with your real domains: + +- `https://hotel.example.com` +- `https://nitro.example.com:2096` + +## 1. Build Nitro + +Inside the `Nitro-V3` repository: + +```bash +yarn build +``` + +Then publish the `dist` folder to your web server, for example: + +```txt +C:/inetpub/wwwroot/hotel/nitro +``` + +The deployed folder should contain at least: + +```txt +configuration/ +assets/ +asset-loader.js +index.html +src/ +``` + +## 2. `configuration/client-mode.json` + +File: + +```txt +Nitro-V3/dist/configuration/client-mode.json +``` + +Secure production configuration: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Meaning: + +- `distObfuscationEnabled: true` loads `app.js.dat` and `app.css.dat`. +- `secureAssetsEnabled: true` loads `renderer-config.json`, `ui-config.json`, and gamedata through `/nitro-sec/file`. +- `secureApiEnabled: true` automatically encrypts `/api/*` requests. +- `apiBaseUrl` must point to the emulator/API. +- `plainConfigBaseUrl` and `plainGamedataBaseUrl` are fallbacks when secure assets are disabled. + +## 3. `configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/renderer-config.json +``` + +Important values: + +```json +{ + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "crypto.ws.enabled": true +} +``` + +If you are not using WebSocket crypto yet, use: + +```json +"crypto.ws.enabled": false +``` + +## 4. `configuration/ui-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/ui-config.json +``` + +Static image and camera URLs can remain plain: + +```json +{ + "camera.url": "https://hotel.example.com/client/camera/", + "thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png" +} +``` + +Non-sensitive images can stay static. JSON configuration and gamedata should go through the secure endpoint. + +## 5. Emulator `config.ini` + +Inside `Arcturus-Morningstar-Extended`, edit the emulator config: + +```txt +Emulator/config.ini +``` + +Production example: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=https://hotel.example.com +ws.ip.header=CF-Connecting-IP + +crypto.ws.enabled=1 + +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +nitro.secure.gamedata.root=C:/inetpub/wwwroot/hotel/nitro/client/nitro/gamedata +nitro.secure.master_key=change-this-to-a-long-random-secret + +login.remember.enabled=true +login.remember.duration.days=30 +login.remember.jwt.secret=change-this-too-if-you-use-remember-me +``` + +Notes: + +- `nitro.secure.config.root` must point to the folder containing `renderer-config.json`, `ui-config.json`, and `client-mode.json`. +- `nitro.secure.gamedata.root` must point to the live gamedata folder. +- Files are read live from disk: if you update a JSON file, a new browser refresh reads the new version. +- `nitro.secure.master_key` must be secret and stable. Never put it in public files. + +## 6. Cloudflare + +If you use Cloudflare: + +1. Keep the proxy enabled for the website domain `hotel.example.com`. +2. Make sure Cloudflare supports/proxies the port used by `nitro.example.com:2096`. +3. Always use HTTPS/WSS in the browser: + +```json +"api.url": "https://nitro.example.com:2096", +"socket.url": "wss://nitro.example.com:2096" +``` + +If you get CORS errors, check: + +```ini +ws.whitelist=https://hotel.example.com +``` + +## 7. IIS / `.dat` MIME type + +If obfuscated `.dat` assets are enabled, IIS must serve them correctly. + +Add this MIME type: + +```txt +Extension: .dat +MIME type: application/octet-stream +``` + +Without it, the browser can receive 404 even when the file exists. + +## 8. Final checklist + +- `client-mode.json` has `secureAssetsEnabled=true`. +- `client-mode.json` has `secureApiEnabled=true`. +- `renderer-config.json` uses `/nitro-sec/file?kind=gamedata&file=`. +- `api.url` points to `https://nitro.example.com:2096`. +- `socket.url` points to `wss://nitro.example.com:2096`. +- `config.ini` has the correct `nitro.secure.config.root`. +- `config.ini` has the correct `nitro.secure.gamedata.root`. +- `config.ini` has a stable `nitro.secure.master_key`. +- IIS knows the `.dat` MIME type. +- Restart the emulator after changing `config.ini`. +- Refresh the browser after changing JSON files in `configuration` or `gamedata`. + +## 9. Temporarily disable secure mode + +For quick debugging, only change `client-mode.json`: + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Then hard refresh the browser. + +## 10. `configuration/bootstrap.js` + +File: + +```txt +Nitro-V3/dist/configuration/bootstrap.js +``` + +This is the first loader when you use the external secure mode. + +It does three things: + +1. opens an ECDH session with the emulator through `/nitro-sec/bootstrap`; +2. downloads encrypted `client-mode.json` through `/nitro-sec/file?kind=config`; +3. downloads encrypted `asset-loader.js` and imports it as a JavaScript module. + +### Value to check + +Inside `bootstrap.js` there is: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +It must point to your public emulator/API URL. + +In production: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +In local development: + +```js +const API_BASE = "http://localhost:2096"; +``` + +If `bootstrap.js` fails, it automatically falls back to the plain loader: + +```txt +configuration/asset-loader.js +``` + +So `asset-loader.js` must always exist inside the `configuration` folder. + +## 11. `configuration/asset-loader.js` + +File: + +```txt +Nitro-V3/dist/configuration/asset-loader.js +``` + +This loader loads the actual bundle: + +- if `distObfuscationEnabled=true` + - it loads `app.css.dat`; + - it loads `app.js.dat`; + - it decodes, decompresses, and imports the bundle from a blob. + +- if `distObfuscationEnabled=false` + - it loads `assets/app.css`; + - it loads `assets/app.js`. + +### Required files in production + +With obfuscation enabled, these files must exist: + +```txt +assets/app.css.dat +assets/app.js.dat +configuration/asset-loader.js +configuration/bootstrap.js +configuration/client-mode.json +``` + +With obfuscation disabled, these files must exist: + +```txt +assets/app.css +assets/app.js +configuration/asset-loader.js +configuration/client-mode.json +``` + +## 12. `index.html` + +`index.html` should stay minimal. + +Secure production example: + +```html +
+ +``` + +Vite development example: + +```html +
+ +``` + +Do not mix the two flows: + +- production build: use `configuration/bootstrap.js`; +- `yarn start` development: use `/src/bootstrap.ts`. + +## 13. Files inside `/configuration` + +The `configuration` folder should contain: + +```txt +asset-loader.js +bootstrap.js +client-mode.json +renderer-config.json +ui-config.json +adsense.json optional +hotlooks.json if register hot looks are enabled +UITexts.json if separate UI texts are enabled +``` + +Login news should not live in `news.json` in production. They come from the database through: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +The emulator reads from the `ui_news` table. + +With `secureAssetsEnabled=true`, client-loaded files go through: + +```txt +https://nitro.example.com:2096/nitro-sec/file?kind=config&file=... +``` + +The emulator reads them from: + +```ini +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +``` + +If you add new JSON/JS files inside `configuration` and want to protect them, they must be requested through the secure endpoint or loaded through `bootstrap.js`. diff --git a/docs/secure-production-setup.md b/docs/secure-production-setup.md new file mode 100644 index 0000000..eb76a6a --- /dev/null +++ b/docs/secure-production-setup.md @@ -0,0 +1,365 @@ +# Setup Secure Runtime in produzione + +Guida rapida per avviare Nitro con: + +- configurazioni e gamedata serviti da `/nitro-sec/file`; +- API `/api/*` cifrate dal wrapper runtime; +- bundle buildati offuscati come `.dat`. + +Negli esempi usa i tuoi domini reali al posto di: + +- `https://hotel.example.com` +- `https://nitro.example.com:2096` + +## 1. Build Nitro + +Nel repo `Nitro-V3`: + +```bash +yarn build +``` + +Poi pubblica la cartella `dist` nel web server del sito, ad esempio: + +```txt +C:/inetpub/wwwroot/hotel/nitro +``` + +La struttura pubblicata deve contenere almeno: + +```txt +configuration/ +assets/ +asset-loader.js +index.html +src/ +``` + +## 2. `configuration/client-mode.json` + +File: + +```txt +Nitro-V3/dist/configuration/client-mode.json +``` + +Configurazione produzione secure: + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Significato: + +- `distObfuscationEnabled: true` carica `app.js.dat` e `app.css.dat`. +- `secureAssetsEnabled: true` carica `renderer-config.json`, `ui-config.json` e gamedata da `/nitro-sec/file`. +- `secureApiEnabled: true` cifra automaticamente le chiamate `/api/*`. +- `apiBaseUrl` deve puntare all'emulatore/API. +- `plainConfigBaseUrl` e `plainGamedataBaseUrl` restano fallback quando spegni secure assets. + +## 3. `configuration/renderer-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/renderer-config.json +``` + +Valori importanti: + +```json +{ + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "crypto.ws.enabled": true +} +``` + +Se non usi ancora WebSocket crypto, metti: + +```json +"crypto.ws.enabled": false +``` + +## 4. `configuration/ui-config.json` + +File: + +```txt +Nitro-V3/dist/configuration/ui-config.json +``` + +Qui puoi lasciare immagini e camera su URL statici normali: + +```json +{ + "camera.url": "https://hotel.example.com/client/camera/", + "thumbnails.url": "https://hotel.example.com/client/camera/thumbnail/%thumbnail%.png" +} +``` + +Le immagini non sensibili possono rimanere statiche. I JSON/gamedata invece passano dal secure endpoint. + +## 5. `config.ini` dell'emulatore + +Nel repo `Arcturus-Morningstar-Extended`, file usato dall'emulatore: + +```txt +Emulator/config.ini +``` + +Esempio produzione: + +```ini +ws.enabled=true +ws.host=0.0.0.0 +ws.port=2096 +ws.whitelist=https://hotel.example.com +ws.ip.header=CF-Connecting-IP + +crypto.ws.enabled=1 + +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +nitro.secure.gamedata.root=C:/inetpub/wwwroot/hotel/nitro/client/nitro/gamedata +nitro.secure.master_key=change-this-to-a-long-random-secret + +login.remember.enabled=true +login.remember.duration.days=30 +login.remember.jwt.secret=change-this-too-if-you-use-remember-me +``` + +Note: + +- `nitro.secure.config.root` deve puntare alla cartella dove ci sono `renderer-config.json`, `ui-config.json`, `client-mode.json`. +- `nitro.secure.gamedata.root` deve puntare alla cartella live dei gamedata. +- I file vengono letti live da disco: se cambi un JSON, un nuovo refresh pagina legge la nuova versione. +- `nitro.secure.master_key` deve restare segreta e stabile. Non metterla nei file pubblici. + +## 6. Cloudflare + +Se usi Cloudflare: + +1. Lascia la nuvoletta attiva sul dominio web `hotel.example.com`. +2. Per `nitro.example.com:2096`, assicurati che Cloudflare supporti/proxy il traffico sulla porta usata. +3. Usa sempre HTTPS/WSS lato browser: + +```json +"api.url": "https://nitro.example.com:2096", +"socket.url": "wss://nitro.example.com:2096" +``` + +Se vedi errori CORS, controlla: + +```ini +ws.whitelist=https://hotel.example.com +``` + +## 7. IIS / MIME `.dat` + +Se usi gli asset offuscati `.dat`, IIS deve servirli. + +Aggiungi MIME type: + +```txt +Extension: .dat +MIME type: application/octet-stream +``` + +Senza questo, il browser può dare 404 anche se il file esiste davvero. + +## 8. Checklist finale + +- `client-mode.json` ha `secureAssetsEnabled=true`. +- `client-mode.json` ha `secureApiEnabled=true`. +- `renderer-config.json` usa `/nitro-sec/file?kind=gamedata&file=`. +- `api.url` punta a `https://nitro.example.com:2096`. +- `socket.url` punta a `wss://nitro.example.com:2096`. +- `config.ini` ha `nitro.secure.config.root` corretto. +- `config.ini` ha `nitro.secure.gamedata.root` corretto. +- `config.ini` ha `nitro.secure.master_key` stabile. +- IIS conosce il MIME `.dat`. +- Dopo modifiche a `config.ini`, riavvia l'emulatore. +- Dopo modifiche ai JSON in `configuration` o `gamedata`, basta refresh pagina. + +## 9. Spegnere temporaneamente secure + +Per debug rapido, cambia solo `client-mode.json`: + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +Poi fai hard refresh. + +## 10. `configuration/bootstrap.js` + +File: + +```txt +Nitro-V3/dist/configuration/bootstrap.js +``` + +Questo è il primo loader quando usi la modalità secure esterna. + +Fa tre cose: + +1. apre una sessione ECDH con l'emulatore tramite `/nitro-sec/bootstrap`; +2. scarica `client-mode.json` cifrato da `/nitro-sec/file?kind=config`; +3. scarica `asset-loader.js` cifrato e lo importa come modulo JavaScript. + +### Valore da controllare + +Dentro `bootstrap.js` esiste: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +Deve puntare all'emulatore/API pubblico. + +In produzione: + +```js +const API_BASE = "https://nitro.example.com:2096"; +``` + +In locale: + +```js +const API_BASE = "http://localhost:2096"; +``` + +Se `bootstrap.js` fallisce, prova automaticamente fallback plain su: + +```txt +configuration/asset-loader.js +``` + +Quindi `asset-loader.js` deve esistere sempre nella cartella `configuration`. + +## 11. `configuration/asset-loader.js` + +File: + +```txt +Nitro-V3/dist/configuration/asset-loader.js +``` + +Questo loader carica il bundle vero: + +- se `distObfuscationEnabled=true` + - carica `app.css.dat`; + - carica `app.js.dat`; + - decodifica, decomprime e importa il bundle da blob. + +- se `distObfuscationEnabled=false` + - carica `assets/app.css`; + - carica `assets/app.js`. + +### File richiesti in produzione + +Con offuscamento attivo devono esistere: + +```txt +assets/app.css.dat +assets/app.js.dat +configuration/asset-loader.js +configuration/bootstrap.js +configuration/client-mode.json +``` + +Con offuscamento spento devono esistere: + +```txt +assets/app.css +assets/app.js +configuration/asset-loader.js +configuration/client-mode.json +``` + +## 12. `index.html` + +Il file `index.html` deve rimanere minimale. + +Esempio secure: + +```html +
+ +``` + +Esempio dev Vite: + +```html +
+ +``` + +Non mischiare i due flow: + +- produzione buildata: usa `configuration/bootstrap.js`; +- sviluppo con `yarn start`: usa `/src/bootstrap.ts`. + +## 13. File dentro `/configuration` + +La cartella `configuration` deve contenere: + +```txt +asset-loader.js +bootstrap.js +client-mode.json +renderer-config.json +ui-config.json +adsense.json opzionale +hotlooks.json se usi register hot looks +UITexts.json se usi testi UI separati +``` + +Le news login non devono stare in `news.json` in produzione: arrivano dal database tramite: + +```json +"login.news.url": "${api.url}/api/auth/news" +``` + +L'emulatore legge dalla tabella `ui_news`. + +Con `secureAssetsEnabled=true`, i file letti dal client passano da: + +```txt +https://nitro.example.com:2096/nitro-sec/file?kind=config&file=... +``` + +Quindi l'emulatore li legge da: + +```ini +nitro.secure.config.root=C:/inetpub/wwwroot/hotel/nitro/configuration +``` + +Se aggiungi nuovi file JSON/JS in `configuration` e vuoi proteggerli, devono essere richiesti passando dal secure endpoint o caricati tramite `bootstrap.js`. diff --git a/docs/secure-runtime-modes.en.html b/docs/secure-runtime-modes.en.html new file mode 100644 index 0000000..491608d --- /dev/null +++ b/docs/secure-runtime-modes.en.html @@ -0,0 +1,236 @@ + + + + + + Nitro Secure Runtime Modes + + + +
+
+
+ Nitro V3 + Secure Runtime +
+

Runtime configuration guide

+

+ This page gives you a cleaner, readable overview of runtime toggles, example files and the values that belong in config files + rather than hardcoded inside src. +

+
+ +
+ + +
+
+

Overview

+
+
+

Dist Obfuscation

+

Chooses whether the client loads app.js/app.css or the obfuscated .dat versions.

+
+
+

Secure Assets

+

Controls whether renderer-config, ui-config and gamedata go through /nitro-sec/file.

+
+
+

Secure API

+

Enables or disables runtime encryption for /api/* requests.

+
+
+
+ +
+

Files to use

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FilePurposeNote
public/configuration/client-mode.exampleTemplate for runtime togglesCopy it into a real configuration/client-mode.json in deployment; that real file stays ignored by Git
public/configuration/renderer-config.exampleClean renderer config templateDoes not touch your local configuration/renderer-config.json
public/configuration/ui-config.exampleUI config reference templateUse it as the source of truth for UI URLs and widgets
Latest_Compiled_Version/config.ini.exampleBackend secure flagsDefines the emulator-side runtime settings
+
+
+ +
+

client-mode.example

+

This is the main runtime switchboard. You can enable or disable behavior without editing client source code.

+
{
+    "distObfuscationEnabled": true,
+    "secureAssetsEnabled": true,
+    "secureApiEnabled": true,
+    "apiBaseUrl": "https://nitro.example.com:2096",
+    "plainConfigBaseUrl": "https://hotel.example.com/configuration/",
+    "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
+}
+
+
+

Fields

+
    +
  • distObfuscationEnabled: use .dat or plain assets
  • +
  • secureAssetsEnabled: enables /nitro-sec/file
  • +
  • secureApiEnabled: encrypts /api/* requests
  • +
  • apiBaseUrl: emulator/API base URL
  • +
+
+
+

Recommendation

+

Always set apiBaseUrl explicitly so you do not rely on fallback logic.

+
+
+
+ +
+

renderer-config.example

+

Socket, API, asset and gamedata URLs should live here, not inside React components.

+
+
+

Main keys

+
    +
  • socket.url
  • +
  • api.url
  • +
  • asset.url
  • +
  • image.library.url
  • +
  • images.url
  • +
  • gamedata.url
  • +
+
+
+

Translations

+
    +
  • external.texts.translation.url
  • +
  • furnidata.translation.url
  • +
  • Uses %locale% and %timestamp%
  • +
+
+
+
+ +
+

ui-config.example

+

UI image and login view sources should come from config values here or from renderer config, never from hardcoded URLs in components.

+
+

Login view

+
    +
  • loginview.images.background
  • +
  • loginview.images.drape
  • +
  • loginview.images.left
  • +
  • loginview.images.right
  • +
  • loginview.widgets for promotional blocks
  • +
+
+
+ +
+

Runtime code involved

+
+
+

src/bootstrap.ts

+

Reads client-mode, builds NitroConfig['config.urls'] and prepares client bootstrap.

+
+
+

src/secure-assets.ts

+

Handles ECDH, decrypt/encrypt, plain fallback and secure API runtime behavior.

+
+
+

scripts/write-asset-loader.mjs

+

Generates public/configuration/asset-loader.js and decides between plain assets and .dat.

+
+
+

scripts/minify-dist.mjs

+

Generates .dat files while keeping plain files available for runtime switching.

+
+
+
+ +
+

Emulator

+
nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=C:/path/to/Nitro-V3/public
+nitro.secure.gamedata.root=C:/path/to/gamedata
+nitro.secure.master_key=change-me-to-a-long-random-secret
+
    +
  • nitro.secure.assets.enabled: enables /nitro-sec/bootstrap and /nitro-sec/file
  • +
  • nitro.secure.api.enabled: enables secure handling for /api/*
  • +
  • nitro.secure.config.root: path to live config files
  • +
  • nitro.secure.gamedata.root: path to live gamedata
  • +
  • nitro.secure.master_key: persistent server-side secret
  • +
+
+ +
+

Quick scenarios

+
+
+

Everything enabled

+

Secure assets, secure API and dist obfuscation all enabled.

+
+
+

Only .dat

+

Uses obfuscated assets but leaves config/API in plain mode.

+
+
+

Everything plain

+

Complete fallback mode for local testing or debugging.

+
+
+
+ +
+

Final checklist

+
+
You created real files from client-mode.example, renderer-config.example and ui-config.example
+
Public URLs live in config files, not in React components
+
Both plain files and .dat files are deployed
+
Your server exposes a proper MIME type for .dat
+
You set nitro.secure.master_key on the emulator side
+
+
+
+
+
+ + + + diff --git a/docs/secure-runtime-modes.en.md b/docs/secure-runtime-modes.en.md new file mode 100644 index 0000000..3bac47f --- /dev/null +++ b/docs/secure-runtime-modes.en.md @@ -0,0 +1,309 @@ +# Secure runtime modes + +This document summarizes all values you may need to configure for: + +- `dist` bundle obfuscation (`app.js` / `app.css` → `.dat`) +- secure runtime assets (`configuration/renderer-config.json`, `configuration/ui-config.json`, `gamedata`) +- secure runtime API (`/api/*`) +- plain fallbacks when you want to disable the secure layer without removing the code + +## 1. `Nitro-V3/public/configuration/client-mode.json` + +This file controls everything at runtime. + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +### Fields + +- `distObfuscationEnabled` + - `true`: `asset-loader.js` loads `app.css.dat` and `app.js.dat` + - `false`: it loads plain `assets/app.css` and `assets/app.js` + +- `secureAssetsEnabled` + - `true`: `bootstrap.ts` and `secure-assets.ts` use `/nitro-sec/file` + - `false`: `configuration/renderer-config.json`, `configuration/ui-config.json`, and gamedata are loaded in plain mode + +- `secureApiEnabled` + - `true`: the `fetch` wrapper encrypts `/api/*` requests + - `false`: `/api/*` requests stay plain + +- `apiBaseUrl` + - Nitro emulator / API base URL + - example: `https://nitro.example.com:2096` + - it is best to always set this explicitly, so you do not depend on the hardcoded fallback + +- `plainConfigBaseUrl` + - base URL for plain config files + - usually: `https://hotel.example.com/configuration/` + +- `plainGamedataBaseUrl` + - base URL for plain gamedata files + - usually: `https://hotel.example.com/client/nitro/gamedata/` + +## 2. `Nitro-V3/src/bootstrap.ts` + +`bootstrap.ts`: + +- installs the secure fetch wrapper +- reads `window.__nitroClientMode` +- builds `NitroConfig['config.urls']` + +### Current behavior + +- if `secureAssetsEnabled=true` + - it uses `secureUrl('config', 'renderer-config.json', true)` + - it uses `secureUrl('config', 'ui-config.json', true)` + +- if `secureAssetsEnabled=false` + - it uses plain files with cache busting (`?v=...`) + +### Important note + +The current fallback is: + +```ts +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; +``` + +So in production it is better to always set `apiBaseUrl` inside `configuration/client-mode.json`. + +## 3. `Nitro-V3/src/secure-assets.ts` + +This file contains the runtime logic for: + +- ECDH bootstrap +- asset decrypt/encrypt +- secure `/api/*` +- plain fallback when the toggles are disabled + +### In practice + +- it reads flags from `window.__nitroClientMode` +- if `secureAssetsEnabled=false` + - it automatically rewrites `/nitro-sec/file?...` into plain URLs +- if `secureApiEnabled=false` + - it does not encrypt `/api/*` + +Normally you should not need to touch it unless you want to change the secure protocol itself. + +## 4. `Nitro-V3/public/configuration/renderer-config.json` + +This file still defines the paths used by the renderer. + +### Things to check + +- `api.url` +- `socket.url` +- `gamedata.url` +- `external.texts.url` +- `external.texts.translation.url` +- `furnidata.url` +- `furnidata.translation.url` + +### With secure assets enabled + +You can use: + +```json +"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=" +``` + +and the equivalent secure URLs for the other gamedata resources. + +### With secure assets disabled + +You can use plain classic paths, for example: + +```json +"gamedata.url": "https://hotel.example.com/client/nitro/gamedata" +``` + +or you can keep the renderer config as-is and let `secure-assets.ts` handle the fallback conversion. + +## 5. `Nitro-V3/public/configuration/ui-config.json` + +There is no secure logic here, but it is one of the files loaded through `config.urls`. + +If `secureAssetsEnabled=true`, it is served from `/nitro-sec/file`. +If `secureAssetsEnabled=false`, it is loaded from the static file with `?v=...`. + +So you only need to maintain the content itself correctly. + +## 6. `Nitro-V3/scripts/write-asset-loader.mjs` + +This script generates `public/configuration/asset-loader.js`. + +### What it does now + +- renders the initial shell +- reads `configuration/client-mode.json` +- decides whether to load: + - `app.css.dat` / `app.js.dat` + - or `assets/app.css` / `assets/app.js` + +### Important + +If you modify this script, the updated loader is regenerated on the next: + +```bash +yarn build +``` + +because `package.json` already contains: + +```json +"prebuild": "node scripts/write-asset-loader.mjs" +``` + +## 7. `Nitro-V3/scripts/minify-dist.mjs` + +This script now: + +- generates the `.dat` files +- keeps the original `app.css` and `app.js` files too + +This is required, otherwise `distObfuscationEnabled=false` would not have a working fallback. + +## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example` + +The current backend flags are: + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root= +nitro.secure.gamedata.root= +nitro.secure.master_key=change-me-to-a-long-random-secret +``` + +### Meaning + +- `nitro.secure.assets.enabled` + - enables `/nitro-sec/bootstrap` and `/nitro-sec/file` + +- `nitro.secure.api.enabled` + - enables the secure layer for `/api/*` + +- `nitro.secure.config.root` + - folder used to read `configuration/renderer-config.json` and `configuration/ui-config.json` + +- `nitro.secure.gamedata.root` + - folder used to read live gamedata + +- `nitro.secure.master_key` + - persistent server-side secret + - especially important when running behind Cloudflare / multiple backend requests + +## 9. Example setups + +### Everything enabled + +`configuration/client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro +nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata +nitro.secure.master_key=a-long-random-secret +``` + +### `.dat` only, no secure assets/API + +`configuration/client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +### Everything plain + +`configuration/client-mode.json` + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +## 10. When rebuild is required + +### No rebuild required + +For changes to: + +- `configuration/client-mode.json` +- `configuration/renderer-config.json` +- `configuration/ui-config.json` +- live gamedata +- `config.ini` + +### Rebuild required + +For changes to: + +- `src/bootstrap.ts` +- `src/secure-assets.ts` +- `scripts/write-asset-loader.mjs` +- `scripts/minify-dist.mjs` + +## 11. Deployment note + +To make the toggles work properly: + +- always deploy both plain files and `.dat` files +- make sure IIS / your host serves the `.dat` MIME type +- if you disable secure mode on the client, disable it on the backend too for consistency + +## 12. Quick checklist + +- `configuration/client-mode.json` configured +- `apiBaseUrl` correct +- `nitro.secure.master_key` set +- `nitro.secure.config.root` correct +- `nitro.secure.gamedata.root` correct +- both `.dat` and plain files deployed +- `.dat` MIME type configured on the web server + + diff --git a/docs/secure-runtime-modes.html b/docs/secure-runtime-modes.html new file mode 100644 index 0000000..d54c635 --- /dev/null +++ b/docs/secure-runtime-modes.html @@ -0,0 +1,236 @@ + + + + + + Nitro Secure Runtime Modes + + + +
+
+
+ Nitro V3 + Secure Runtime +
+

Documentazione configurazione runtime

+

+ Questa pagina riassume in modo ordinato come configurare i toggle runtime, i file example e i parametri lato client / emulatore + senza sporcare i componenti in src. +

+
+ +
+ + +
+
+

Overview

+
+
+

Dist Obfuscation

+

Sceglie se caricare app.js/app.css oppure .dat.

+
+
+

Secure Assets

+

Controlla se renderer-config, ui-config e gamedata passano da /nitro-sec/file.

+
+
+

Secure API

+

Attiva o disattiva la cifratura runtime automatica su /api/*.

+
+
+
+ +
+

File da usare

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FileScopoNota
public/configuration/client-mode.exampleTemplate per i toggle runtimeDa copiare in configuration/client-mode.json nel deploy reale, che resta ignorato da Git
public/configuration/renderer-config.exampleTemplate sicuro del renderer configNon tocca il tuo configuration/renderer-config.json locale
public/configuration/ui-config.exampleTemplate UI configDa mantenere come riferimento pulito
Latest_Compiled_Version/config.ini.exampleFlag backend secureSpecifica la parte lato emulatore
+
+
+ +
+

client-mode.example

+

È il punto centrale per attivare o disattivare il comportamento runtime senza dover modificare il codice.

+
{
+    "distObfuscationEnabled": true,
+    "secureAssetsEnabled": true,
+    "secureApiEnabled": true,
+    "apiBaseUrl": "https://nitro.example.com:2096",
+    "plainConfigBaseUrl": "https://hotel.example.com/configuration/",
+    "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/"
+}
+
+
+

Campi

+
    +
  • distObfuscationEnabled: usa .dat oppure file plain
  • +
  • secureAssetsEnabled: attiva /nitro-sec/file
  • +
  • secureApiEnabled: cifra le richieste /api/*
  • +
  • apiBaseUrl: base URL emulatore/API
  • +
+
+
+

Suggerimento

+

Conviene impostare sempre apiBaseUrl in modo esplicito, così non dipendi da fallback impliciti del runtime.

+
+
+
+ +
+

renderer-config.example

+

Qui definisci URL di socket, API, asset library e gamedata. Tutti i link pubblici dovrebbero vivere qui, non nei componenti React.

+
+
+

Chiavi principali

+
    +
  • socket.url
  • +
  • api.url
  • +
  • asset.url
  • +
  • image.library.url
  • +
  • images.url
  • +
  • gamedata.url
  • +
+
+
+

Traduzioni

+
    +
  • external.texts.translation.url
  • +
  • furnidata.translation.url
  • +
  • Usano %locale% e %timestamp%
  • +
+
+
+
+ +
+

ui-config.example

+

Per la login view e altre immagini UI, la sorgente deve stare qui o in renderer config, non hardcoded nei componenti.

+
+

Login view

+
    +
  • loginview.images.background
  • +
  • loginview.images.drape
  • +
  • loginview.images.left
  • +
  • loginview.images.right
  • +
  • loginview.widgets per i blocchi promozionali
  • +
+
+
+ +
+

Codice runtime coinvolto

+
+
+

src/bootstrap.ts

+

Legge client-mode, costruisce NitroConfig['config.urls'] e prepara il bootstrap del client.

+
+
+

src/secure-assets.ts

+

Gestisce ECDH, decrypt/encrypt, fallback plain e secure API runtime.

+
+
+

scripts/write-asset-loader.mjs

+

Genera public/configuration/asset-loader.js e decide se usare file plain o .dat.

+
+
+

scripts/minify-dist.mjs

+

Genera i .dat ma mantiene anche i file plain per il toggle runtime.

+
+
+
+ +
+

Emulatore

+
nitro.secure.assets.enabled=true
+nitro.secure.api.enabled=true
+nitro.secure.config.root=C:/path/to/Nitro-V3/public
+nitro.secure.gamedata.root=C:/path/to/gamedata
+nitro.secure.master_key=change-me-to-a-long-random-secret
+
    +
  • nitro.secure.assets.enabled: abilita /nitro-sec/bootstrap e /nitro-sec/file
  • +
  • nitro.secure.api.enabled: abilita la cifratura su /api/*
  • +
  • nitro.secure.config.root: cartella dei config live
  • +
  • nitro.secure.gamedata.root: cartella del gamedata live
  • +
  • nitro.secure.master_key: chiave persistente server-side
  • +
+
+ +
+

Scenari rapidi

+
+
+

Tutto attivo

+

Secure assets, secure API e dist obfuscation tutti attivi.

+
+
+

Solo .dat

+

Usi i .dat, ma lasci config/API in plain.

+
+
+

Tutto plain

+

Modalità fallback completa per debug o test locali.

+
+
+
+ +
+

Checklist finale

+
+
Hai creato i file reali partendo da client-mode.example, renderer-config.example e ui-config.example
+
Gli URL pubblici stanno nei file config, non nei componenti React
+
Hai deployato sia i file plain sia i .dat
+
Il server espone correttamente il MIME type per .dat
+
Hai impostato nitro.secure.master_key lato emulatore
+
+
+
+
+
+ + + + diff --git a/docs/secure-runtime-modes.md b/docs/secure-runtime-modes.md new file mode 100644 index 0000000..4a9d311 --- /dev/null +++ b/docs/secure-runtime-modes.md @@ -0,0 +1,309 @@ +# Secure runtime modes + +Questa doc riassume tutti i dati da impostare per: + +- offuscamento bundle `dist` (`app.js` / `app.css` → `.dat`) +- secure assets runtime (`configuration/renderer-config.json`, `configuration/ui-config.json`, `gamedata`) +- secure API runtime (`/api/*`) +- fallback plain quando vuoi spegnere tutto senza togliere il codice + +## 1. `Nitro-V3/public/configuration/client-mode.json` + +Questo file controlla tutto a runtime. + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +### Campi + +- `distObfuscationEnabled` + - `true`: `asset-loader.js` carica `app.css.dat` e `app.js.dat` + - `false`: carica i file normali `assets/app.css` e `assets/app.js` + +- `secureAssetsEnabled` + - `true`: `bootstrap.ts` e `secure-assets.ts` usano `/nitro-sec/file` + - `false`: `configuration/renderer-config.json`, `configuration/ui-config.json` e gamedata vengono letti in plain + +- `secureApiEnabled` + - `true`: il wrapper `fetch` cifra le chiamate `/api/*` + - `false`: le chiamate `/api/*` restano normali + +- `apiBaseUrl` + - base URL dell’emulatore / API Nitro + - esempio: `https://nitro.example.com:2096` + - meglio valorizzarlo sempre, così non dipendi dal fallback hardcoded + +- `plainConfigBaseUrl` + - base URL dei file config plain + - normalmente: `https://hotel.example.com/configuration/` + +- `plainGamedataBaseUrl` + - base URL del gamedata plain + - normalmente: `https://hotel.example.com/client/nitro/gamedata/` + +## 2. `Nitro-V3/src/bootstrap.ts` + +`bootstrap.ts`: + +- installa il secure fetch wrapper +- legge `window.__nitroClientMode` +- costruisce `NitroConfig['config.urls']` + +### Comportamento attuale + +- se `secureAssetsEnabled=true` + - usa `secureUrl('config', 'renderer-config.json', true)` + - usa `secureUrl('config', 'ui-config.json', true)` + +- se `secureAssetsEnabled=false` + - usa i file plain con cache bust (`?v=...`) + +### Nota importante + +Il fallback attuale è: + +```ts +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || 'https://nitro.example.com:2096/'; +``` + +Quindi in produzione conviene sempre valorizzare `apiBaseUrl` dentro `configuration/client-mode.json`. + +## 3. `Nitro-V3/src/secure-assets.ts` + +Qui vive tutta la logica runtime: + +- bootstrap ECDH +- decrypt/encrypt assets +- secure `/api/*` +- fallback plain quando i toggle sono spenti + +### In pratica + +- legge i flag da `window.__nitroClientMode` +- se `secureAssetsEnabled=false` + - converte automaticamente `/nitro-sec/file?...` in URL plain +- se `secureApiEnabled=false` + - non cifra `/api/*` + +Normalmente non serve toccarlo, a meno che tu non voglia cambiare il protocollo secure. + +## 4. `Nitro-V3/public/configuration/renderer-config.json` + +Questo file continua a definire i path usati dal renderer. + +### Da controllare + +- `api.url` +- `socket.url` +- `gamedata.url` +- `external.texts.url` +- `external.texts.translation.url` +- `furnidata.url` +- `furnidata.translation.url` + +### Con secure assets attivo + +Puoi usare: + +```json +"gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=" +``` + +e gli altri URL secure equivalenti. + +### Con secure assets disattivo + +Conviene usare i path plain classici, per esempio: + +```json +"gamedata.url": "https://hotel.example.com/client/nitro/gamedata" +``` + +oppure lasciare il renderer configurato com’è e demandare il fallback a `secure-assets.ts`. + +## 5. `Nitro-V3/public/configuration/ui-config.json` + +Qui non c’è logica secure, ma è uno dei file caricati da `config.urls`. + +Se `secureAssetsEnabled=true`, arriva da `/nitro-sec/file`. +Se `secureAssetsEnabled=false`, arriva dal file statico con `?v=...`. + +Quindi basta mantenerlo corretto come contenuto, non serve altro. + +## 6. `Nitro-V3/scripts/write-asset-loader.mjs` + +Questo script genera `public/configuration/asset-loader.js`. + +### Cosa fa ora + +- mostra la shell iniziale +- legge `configuration/client-mode.json` +- decide se caricare: + - `app.css.dat` / `app.js.dat` + - oppure `assets/app.css` / `assets/app.js` + +### Importante + +Se modifichi questo script, il loader aggiornato viene rigenerato al prossimo: + +```bash +yarn build +``` + +perché in `package.json` c’è: + +```json +"prebuild": "node scripts/write-asset-loader.mjs" +``` + +## 7. `Nitro-V3/scripts/minify-dist.mjs` + +Adesso questo script: + +- genera i `.dat` +- lascia anche i file originali `app.css` e `app.js` + +Questa parte è fondamentale, altrimenti il toggle `distObfuscationEnabled=false` non avrebbe fallback. + +## 8. `Arcturus-Morningstar-Extended/Latest_Compiled_Version/config.ini.example` + +I flag backend attuali sono: + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root= +nitro.secure.gamedata.root= +nitro.secure.master_key=change-me-to-a-long-random-secret +``` + +### Significato + +- `nitro.secure.assets.enabled` + - abilita `/nitro-sec/bootstrap` e `/nitro-sec/file` + +- `nitro.secure.api.enabled` + - abilita il layer secure per `/api/*` + +- `nitro.secure.config.root` + - cartella dove leggere `configuration/renderer-config.json` e `configuration/ui-config.json` + +- `nitro.secure.gamedata.root` + - cartella dove leggere il gamedata live + +- `nitro.secure.master_key` + - segreto persistente lato server + - necessario soprattutto con Cloudflare / richieste multiple + +## 9. Esempi di configurazione + +### Tutto attivo + +`configuration/client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=true +nitro.secure.api.enabled=true +nitro.secure.config.root=C:/inetpub/wwwroot/paxxo/nitro +nitro.secure.gamedata.root=C:/inetpub/wwwroot/paxxo/nitro/client/nitro/gamedata +nitro.secure.master_key=una-chiave-lunga-random +``` + +### Solo `.dat`, senza secure assets/api + +`configuration/client-mode.json` + +```json +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +`config.ini` + +```ini +nitro.secure.assets.enabled=false +nitro.secure.api.enabled=false +``` + +### Tutto plain + +`configuration/client-mode.json` + +```json +{ + "distObfuscationEnabled": false, + "secureAssetsEnabled": false, + "secureApiEnabled": false, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} +``` + +## 10. Quando serve rebuild + +### Non serve rebuild + +Per cambiare: + +- `configuration/client-mode.json` +- `configuration/renderer-config.json` +- `configuration/ui-config.json` +- gamedata live +- `config.ini` + +### Serve rebuild + +Per cambiare: + +- `src/bootstrap.ts` +- `src/secure-assets.ts` +- `scripts/write-asset-loader.mjs` +- `scripts/minify-dist.mjs` + +## 11. Nota pratica deployment + +Per usare bene i toggle: + +- pubblica sempre sia i file plain sia i `.dat` +- assicurati che IIS/host serva il MIME type per `.dat` +- se spegni il secure mode nel client, spegnilo anche nel backend per coerenza + +## 12. Checklist veloce + +- `configuration/client-mode.json` configurato +- `apiBaseUrl` corretto +- `nitro.secure.master_key` valorizzata +- `nitro.secure.config.root` corretto +- `nitro.secure.gamedata.root` corretto +- `.dat` e file plain entrambi deployati +- MIME `.dat` presente sul web server + + diff --git a/index.html b/index.html index e735f16..1c4fa2e 100644 --- a/index.html +++ b/index.html @@ -1,35 +1 @@ - - - - Nitro - - - - - - - - - - - - - - - - - - -
- - - - +
diff --git a/localization/badge-texts-en.json b/localization/badge-texts-en.json deleted file mode 100644 index a8ab19a..0000000 --- a/localization/badge-texts-en.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "notification.badge.received": "New Badge!" -} diff --git a/localization/badge-texts-it.json b/localization/badge-texts-it.json deleted file mode 100644 index 10c1271..0000000 --- a/localization/badge-texts-it.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "notification.badge.received": "Nuovo Distintivo!" -} diff --git a/mockup/assets/README.md b/mockup/assets/README.md deleted file mode 100644 index f902c1b..0000000 --- a/mockup/assets/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Mockup Assets - -Questa cartella e' pronta per immagini o sprite dedicate ai mockup HTML. - -Uso previsto: -- copiare qui versioni statiche di asset che vuoi testare fuori dal progetto -- collegarle da `mockup/index.html` -- tenere separati i file di esperimento dagli asset reali di `src/assets` - -Percorso base: -- `Nitro-V3/mockup/index.html` -- `Nitro-V3/mockup/assets/` diff --git a/mockup/index.html b/mockup/index.html deleted file mode 100644 index 8a08573..0000000 --- a/mockup/index.html +++ /dev/null @@ -1,623 +0,0 @@ - - - - - - Nitro V3 Mockup Lab - - - -

Nitro V3 Mockup Lab

-

Mockup HTML standalone dei componenti principali attuali. La resa è pensata per darti una base visiva da modificare rapidamente fuori dal progetto reale.

- -
-
-
-

NitroCard

-

Base card attuale con header blu, tabs grigie e content chiaro.

-
-
-
-
-
-
Navigator
-
-
-
-
Hotel
-
Rooms
-
+
-
-
- Contenuto card attuale, usato come base da vari componenti. -
-
-
-
Source files: -src/common/card/NitroCardView.tsx -src/common/card/NitroCardHeaderView.tsx -src/common/card/NitroCardContentView.tsx -src/common/card/tabs/NitroCardTabsView.tsx -src/common/card/tabs/NitroCardTabsItemView.tsx -src/css/nitrocard/NitroCardView.css
-
-
- -
-
-

Purse

-

Layout attuale con currency, box HC, pulsanti laterali e seasonal sotto.

-
-
-
-
-
-
-
3601 ◉
-
5365 ◎
-
700 ◈
-
-
-
HC
-
78 g
-
-
-
-
-
-
-
- Stagionale - 99 999 -
-
-
-
Source files: -src/components/purse/PurseView.tsx -src/components/purse/views/CurrencyView.tsx -src/components/purse/views/SeasonalView.tsx -src/css/purse/PurseView.css
-
-
- -
-
-

Toolbar

-

Barra bassa attuale con area me, icone centrali e blocco friend/message.

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Source files: -src/components/toolbar/ToolbarView.tsx -src/components/toolbar/ToolbarItemView.tsx -src/components/toolbar/ToolbarMeView.tsx
-
-
- -
-
-

Navigator

-

Finestra navigator attuale con card base, search e footer actions.

-
-
-
- -
-
Source files: -src/components/navigator/NavigatorView.tsx -src/components/navigator/NavigatorView.scss -src/css/room/NavigatorRoomSettings.css
-
-
- -
-
-

Notifications

-

Bubble attuale con fondo scuro e inner shadow.

-
-
-
-
- Hai ricevuto una nuova notifica. Questo box rappresenta lo stato attuale delle bubble notifications. -
-
-
Source files: -src/components/notification-center/NotificationCenterView.tsx -src/css/notification/NotificationCenterView.css
-
-
- -
-
-

Friends

-

Barra amici e blocchi friend pill attuali.

-
-
-
-
-
-
-
-
-
-
-
Source files: -src/components/friends/FriendsView.tsx -src/css/friends/FriendsView.css
-
-
- -
-
-

HotelView

-

Mockup della scena hotel attuale con sfondo e hotspot.

-
-
-
-
-
-
-
-
-
-
-
Source files: -src/components/hotel-view/HotelView.tsx -src/css/hotelview/HotelView.css
-
-
-
- - diff --git a/package.json b/package.json index 6619dea..13915c1 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,9 @@ "homepage": ".", "private": true, "scripts": { - "start": "vite --base=/client/ --host", - "build": "vite --base=/client/ build", + "prebuild": "node scripts/write-asset-loader.mjs", + "start": "vite --host", + "build": "vite build && node scripts/minify-dist.mjs", "build:prod": "npx browserslist@latest --update-db && yarn build", "eslint": "eslint ./src" }, diff --git a/public/UITexts.example b/public/UITexts.example deleted file mode 100644 index 34719b4..0000000 --- a/public/UITexts.example +++ /dev/null @@ -1,174 +0,0 @@ -{ - "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", - "login.username": "Name of your Habbo", - "login.forgot_password": "Forgotten your password?", - - "nitro.login.firsttime.title": "First time here?", - "nitro.login.firsttime.text": "Don't have a Habbo yet?", - "nitro.login.firsttime.link": "You can create one here", - "nitro.login.card.title": "What's your Habbo called?", - - "nitro.login.server.offline.short": "The gameserver isn't running right now. Please try again in a moment.", - "nitro.login.server.offline.long": "The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment.", - "nitro.login.server.checking": "Checking…", - "nitro.login.server.retry": "Retry", - - "nitro.login.register.title": "Habbo Details", - "nitro.login.register.next": "Next", - "nitro.login.register.finish": "Finish", - "nitro.login.register.creating": "Creating…", - - "nitro.login.register.intro.credentials": "Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use.", - "nitro.login.register.intro.avatar": "Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name.", - "nitro.login.register.intro.room": "Last step — pick a starter room, or skip and create your own later.", - - "nitro.login.register.confirm.label": "Confirm password", - "nitro.login.register.username.placeholder": "HabboName", - - "nitro.login.register.hotlooks.count": "%count% looks available", - "nitro.login.register.hotlooks.none": "No hot looks loaded", - - "nitro.login.register.room.skip.title": "I'm okay — I'll create my own rooms", - "nitro.login.register.room.skip.description": "Skip for now and start with an empty hotel inventory.", - "nitro.login.register.room.loading": "Loading rooms…", - "nitro.login.register.room.error": "Could not load room options. You can still skip this step.", - - "nitro.login.register.success": "Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.", - - "nitro.login.forgot.title": "Reset password", - "nitro.login.forgot.email.label": "Email address", - "nitro.login.forgot.send": "Send email", - "nitro.login.forgot.success": "Email sent! If an account matches that address you'll find a reset link in your inbox shortly (check spam if it doesn't show up within a minute).", - - "nitro.login.error.missing_credentials": "Please enter both your Habbo name and password.", - "nitro.login.error.invalid_credentials": "Invalid Habbo name or password.", - "nitro.login.error.too_many_attempts": "Too many attempts. Try again in %seconds%s.", - "nitro.login.error.turnstile": "Please complete the security check.", - "nitro.login.error.server_offline": "The gameserver is not running. Please try again later.", - "nitro.login.error.login_unreachable": "Unable to reach the login service. Please try again.", - "nitro.login.error.register_failed": "Unable to create your account.", - "nitro.login.error.register_unreachable": "Unable to reach the registration service.", - "nitro.login.error.forgot_failed": "Unable to send a reset email right now.", - "nitro.login.error.forgot_unreachable": "Unable to reach the password reset service.", - "nitro.login.error.missing_fields": "Please fill in every field.", - "nitro.login.error.invalid_email": "Please enter a valid email address.", - "nitro.login.error.password_too_short": "Your password must be at least 8 characters.", - "nitro.login.error.password_mismatch": "Passwords do not match.", - "nitro.login.error.email_taken": "This email is already in use.", - "nitro.login.error.missing_username": "Please choose a Habbo name.", - "nitro.login.error.username_length": "Habbo name must be 3–16 characters.", - "nitro.login.error.username_taken": "This Habbo name is already taken.", - "nitro.login.error.missing_email": "Please enter your email address.", - "inventory.effects.activate": "Use selected effect" -} -} diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example new file mode 100644 index 0000000..acf246e --- /dev/null +++ b/public/configuration/UITexts.example @@ -0,0 +1,116 @@ +{ + "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" +} diff --git a/public/adsense.json b/public/configuration/adsense.example similarity index 100% rename from public/adsense.json rename to public/configuration/adsense.example diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js new file mode 100644 index 0000000..c1cfde3 --- /dev/null +++ b/public/configuration/asset-loader.js @@ -0,0 +1,187 @@ +(() => { + const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); + const MODE_DEFAULTS = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true + }; + + const isDebug = () => { + try { + const search = new URLSearchParams(location.search); + return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1"; + } catch { + return false; + } + }; + + const debug = (message) => { + try { + window.__nitroLoaderDebug = message; + const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : []; + log.push(message); + window.__nitroLoaderDebugLog = log.slice(-30); + if(!isDebug()) { + document.getElementById("nitro-loader-debug")?.remove(); + return; + } + let node = document.getElementById("nitro-loader-debug"); + if(!node) { + node = document.createElement("div"); + node.id = "nitro-loader-debug"; + node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap"; + document.body.appendChild(node); + } + node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\n"); + } catch {} + }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const renderShell = () => { + const root = document.getElementById("root"); + if(!root || root.firstChild) return; + root.innerHTML = '
'; + }; + + const decodeAsset = (bytes) => { + const output = new Uint8Array(bytes.length); + for(let index = 0; index < bytes.length; index++) { + output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255); + } + return output; + }; + + const gunzip = async (bytes) => { + if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }; + + const resolveAssetCandidates = (path) => { + const base = getBase(); + const normalized = path.replace(/^\.\//, ""); + const file = normalized.split("/").pop(); + const urls = [ + new URL("./src/assets/" + file, base), + new URL("./assets/" + file, base), + new URL("/src/assets/" + file, base.origin), + new URL("/assets/" + file, base.origin), + new URL("/client/src/assets/" + file, base.origin), + new URL("/client/assets/" + file, base.origin) + ]; + return [...new Map(urls.map(url => [url.href, url])).values()]; + }; + + const fetchBytes = async (path) => { + let error = null; + debug("loader: fetching " + path); + for(const candidate of resolveAssetCandidates(path)) { + try { + debug("loader: try " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + error = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + debug("loader: ok " + candidate.href); + return new Uint8Array(await response.arrayBuffer()); + } catch(caught) { + error = caught; + } + } + throw error || new Error("asset " + path + " not found"); + }; + + const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path))); + + const injectCssText = (bytes) => { + const node = document.createElement("style"); + node.textContent = new TextDecoder().decode(bytes); + document.head.appendChild(node); + debug("loader: css injected from dat"); + }; + + const loadPlainCss = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + await new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href.href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error("plain css failed")); + document.head.appendChild(link); + }); + debug("loader: css linked"); + }; + + const importBytes = async (bytes) => { + const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); + try { + debug("loader: importing app blob"); + await import(blobUrl); + debug("loader: app blob imported"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const importPlainJs = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + debug("loader: importing plain js"); + await import(href.href); + debug("loader: plain js imported"); + }; + + const readClientMode = async () => { + try { + if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") { + debug("loader: client-mode preset"); + return window.__nitroClientMode; + } + const url = withCacheBust(new URL("./client-mode.json", getBase())); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("client-mode " + response.status); + const payload = await response.json(); + const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) }; + window.__nitroClientMode = mode; + debug("loader: client-mode loaded"); + return mode; + } catch(error) { + window.__nitroClientMode = { ...MODE_DEFAULTS }; + debug("loader: client-mode fallback " + (error?.message || error)); + return window.__nitroClientMode; + } + }; + + (async () => { + debug("loader: start"); + renderShell(); + const mode = await readClientMode(); + if(mode.distObfuscationEnabled) { + const [cssBytes, jsBytes] = await Promise.all([ + loadDatAsset("./assets/app.css.dat"), + loadDatAsset("./assets/app.js.dat") + ]); + injectCssText(cssBytes); + await importBytes(jsBytes); + return; + } + await loadPlainCss("./assets/app.css"); + await importPlainJs("./assets/app.js"); + })().catch(error => { + console.error(error); + debug("loader: failed " + (error?.message || error)); + document.body.textContent = "Unable to load client."; + }); +})(); \ No newline at end of file diff --git a/public/configuration/bootstrap.js b/public/configuration/bootstrap.js new file mode 100644 index 0000000..f2a9d7e --- /dev/null +++ b/public/configuration/bootstrap.js @@ -0,0 +1,133 @@ +(() => { + const API_BASE = "https://nitro.slogga.it:2096"; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const bytesToBase64 = (buffer) => { + let binary = ""; + const bytes = new Uint8Array(buffer); + for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + }; + + const hexValue = (code) => { + if(code >= 48 && code <= 57) return code - 48; + if(code >= 65 && code <= 70) return code - 55; + if(code >= 97 && code <= 102) return code - 87; + return -1; + }; + + const hexToBytes = (hex) => { + const normalized = hex.trim(); + if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload."); + const bytes = new Uint8Array(normalized.length / 2); + for(let i = 0; i < bytes.length; i++) { + const high = hexValue(normalized.charCodeAt(i * 2)); + const low = hexValue(normalized.charCodeAt((i * 2) + 1)); + if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload."); + bytes[i] = (high << 4) | low; + } + return bytes; + }; + + const deriveAesKey = async (privateKey, serverKeyBase64) => { + const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0)); + const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []); + const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256); + const salt = new TextEncoder().encode("nitro-secure-assets-v1"); + const material = new Uint8Array(secret.byteLength + salt.length); + material.set(new Uint8Array(secret), 0); + material.set(salt, secret.byteLength); + const hash = await crypto.subtle.digest("SHA-256", material); + return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]); + }; + + const decryptPayload = async (key, response) => { + if(response.headers.get("X-Nitro-Sec") !== "1") return response.text(); + const bytes = hexToBytes(await response.text()); + if(bytes.length < 13) throw new Error("Encrypted response is too short."); + const iv = bytes.slice(0, 12); + const payload = bytes.slice(12); + const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload); + return new TextDecoder().decode(clear); + }; + + const importTextModule = async (sourceText) => { + const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" })); + try { + await import(blobUrl); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const loadPlainBootstrap = async () => { + const url = withCacheBust(new URL("./asset-loader.js", getBase())); + await import(url.href); + }; + + const loadSecureBootstrap = async () => { + if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap."); + + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); + const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey); + const publicKey = bytesToBase64(publicKeyBuffer); + const base = API_BASE.replace(/\/$/, ""); + const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: publicKey }) + }); + + if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status); + + const bootstrapPayload = await bootstrapResponse.json(); + if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) { + throw new Error("Secure bootstrap returned an invalid server key."); + } + + const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key); + + const fetchSecureConfig = async (file) => { + const url = new URL(base + "/nitro-sec/file"); + url.searchParams.set("kind", "config"); + url.searchParams.set("file", file); + url.searchParams.set("v", Date.now().toString(36)); + + const response = await fetch(url.toString(), { + headers: { "X-Nitro-Key": publicKey }, + cache: "no-store" + }); + + if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status); + + return decryptPayload(sessionKey, response); + }; + + const modeText = await fetchSecureConfig("client-mode.json"); + window.__nitroClientMode = JSON.parse(modeText); + + const loaderText = await fetchSecureConfig("asset-loader.js"); + await importTextModule(loaderText); + }; + + (async () => { + try { + await loadSecureBootstrap(); + } catch(error) { + console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); + await loadPlainBootstrap(); + } + })().catch(error => { + console.error(error); + document.body.textContent = "Unable to load client."; + }); +})(); \ No newline at end of file diff --git a/public/configuration/client-mode.example b/public/configuration/client-mode.example new file mode 100644 index 0000000..a6e11ed --- /dev/null +++ b/public/configuration/client-mode.example @@ -0,0 +1,8 @@ +{ + "distObfuscationEnabled": true, + "secureAssetsEnabled": true, + "secureApiEnabled": true, + "apiBaseUrl": "https://nitro.example.com:2096", + "plainConfigBaseUrl": "https://hotel.example.com/configuration/", + "plainGamedataBaseUrl": "https://hotel.example.com/client/nitro/gamedata/" +} diff --git a/public/hotlooks.json b/public/configuration/hotlooks.example similarity index 100% rename from public/hotlooks.json rename to public/configuration/hotlooks.example diff --git a/public/configuration/news.example b/public/configuration/news.example new file mode 100644 index 0000000..4003700 --- /dev/null +++ b/public/configuration/news.example @@ -0,0 +1,12 @@ +{ + "news": [ + { + "id": 1, + "title": "Welcome to Nitro", + "body": "This news entry is loaded from configuration/news.json.", + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "link": "", + "linkText": "Read more" + } + ] +} diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example new file mode 100644 index 0000000..8ee77b6 --- /dev/null +++ b/public/configuration/renderer-config.example @@ -0,0 +1,91 @@ +{ + "socket.url": "wss://nitro.example.com:2096", + "api.url": "https://nitro.example.com:2096", + "asset.url": "https://hotel.example.com/client/nitro/bundled", + "image.library.url": "https://hotel.example.com/client/c_images/", + "hof.furni.url": "https://hotel.example.com/client/c_images/dcr/hof_furni", + "images.url": "https://hotel.example.com/client/nitro/images", + "gamedata.url": "https://nitro.example.com:2096/nitro-sec/file?kind=gamedata&file=", + "sounds.url": "${asset.url}/sounds/%sample%.mp3", + "external.texts.url": [ + "${gamedata.url}/ExternalTexts.json", + "${gamedata.url}/UITexts.json" + ], + "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", + "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", + "avatar.asset.url": "${asset.url}/figure/%libname%.nitro", + "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", + "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", + "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", + "pet.asset.url": "${asset.url}/pets/%libname%.nitro", + "generic.asset.url": "${asset.url}/generic/%libname%.nitro", + "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", + "furni.rotation.bounce.steps": 20, + "furni.rotation.bounce.height": 0.0625, + "enable.avatar.arrow": false, + "system.log.debug": true, + "system.log.warn": true, + "system.log.error": true, + "system.log.events": false, + "system.log.packets": true, + "system.fps.animation": 24, + "system.fps.max": 60, + "system.pong.manually": true, + "system.pong.interval.ms": 20000, + "room.color.skip.transition": true, + "room.landscapes.enabled": true, + "room.zoom.enabled": true, + "login.screen.enabled": true, + "login.endpoint": "${api.url}/api/auth/login", + "login.register.endpoint": "${api.url}/api/auth/register", + "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", + "login.logout.endpoint": "${api.url}/api/auth/logout", + "login.remember.endpoint": "${api.url}/api/auth/remember", + "login.turnstile.enabled": false, + "login.turnstile.sitekey": "", + "avatar.mandatory.libraries": [ + "bd:1", + "li:0" + ], + "avatar.mandatory.effect.libraries": [ + "dance.1", + "dance.2", + "dance.3", + "dance.4" + ], + "avatar.default.figuredata": { + "palettes": [], + "setTypes": [] + }, + "avatar.default.actions": { + "actions": [] + }, + "pet.types": [], + "preload.assets.urls": [ + "${asset.url}/generic/avatar_additions.nitro", + "${asset.url}/generic/group_badge.nitro", + "${asset.url}/generic/floor_editor.nitro", + "${images.url}/loading_icon.png", + "${images.url}/clear_icon.png", + "${images.url}/big_arrow.png" + ], + "login.health.endpoint": "${api.url}/api/health", + "login.health.method": "GET", + "login.check-email.endpoint": "${api.url}/api/auth/check-email", + "login.check-username.endpoint": "${api.url}/api/auth/check-username", + "login.register.imaging.url": "${api.url}/api/avatar/imaging", + "crypto.ws.enabled": true, + "login.news.url": "${api.url}/api/auth/news", + "badges.custom.list.endpoint": "${api.url}/api/badges/custom", + "badges.custom.create.endpoint": "${api.url}/api/badges/custom", + "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" +} diff --git a/public/ui-config.example b/public/configuration/ui-config.example similarity index 90% rename from public/ui-config.example rename to public/configuration/ui-config.example index 7510024..5503c2c 100644 --- a/public/ui-config.example +++ b/public/configuration/ui-config.example @@ -37,145 +37,202 @@ "left": "${asset.url}/c_images/reception/ts.png", "right": "${asset.url}/c_images/reception/US_right.png", "right.repeat": "${asset.url}/c_images/reception/US_top_right.png" + }, + "widgets": { + "slot.1.widget": "promoarticle", + "slot.1.conf": {}, + "slot.2.widget": "widgetcontainer", + "slot.2.conf": { + "image": "${image.library.url}web_promo_small/spromo_Canal_Bundle.png", + "texts": "2021NitroPromo", + "btnLink": "" + }, + "slot.3.widget": "", + "slot.3.conf": {}, + "slot.4.widget": "", + "slot.4.conf": {}, + "slot.5.widget": "", + "slot.5.conf": {}, + "slot.6.widget": "", + "slot.6.conf": { + "campaign": "" + }, + "slot.7.widget": "", + "slot.7.conf": {} } }, - "navigator.room.models": [{ + "navigator.room.models": [ + { "clubLevel": 0, "tileSize": 104, "name": "a" - }, { + }, + { "clubLevel": 0, "tileSize": 94, "name": "b" - }, { + }, + { "clubLevel": 0, "tileSize": 36, "name": "c" - }, { + }, + { "clubLevel": 0, "tileSize": 84, "name": "d" - }, { + }, + { "clubLevel": 0, "tileSize": 80, "name": "e" - }, { + }, + { "clubLevel": 0, "tileSize": 80, "name": "f" - }, { + }, + { "clubLevel": 0, "tileSize": 416, "name": "i" - }, { + }, + { "clubLevel": 0, "tileSize": 320, "name": "j" - }, { + }, + { "clubLevel": 0, "tileSize": 448, "name": "k" - }, { + }, + { "clubLevel": 0, "tileSize": 352, "name": "l" - }, { + }, + { "clubLevel": 0, "tileSize": 384, "name": "m" - }, { + }, + { "clubLevel": 0, "tileSize": 372, "name": "n" - }, { + }, + { "clubLevel": 1, "tileSize": 80, "name": "g" - }, { + }, + { "clubLevel": 1, "tileSize": 74, "name": "h" - }, { + }, + { "clubLevel": 1, "tileSize": 416, "name": "o" - }, { + }, + { "clubLevel": 1, "tileSize": 352, "name": "p" - }, { + }, + { "clubLevel": 1, "tileSize": 304, "name": "q" - }, { + }, + { "clubLevel": 1, "tileSize": 336, "name": "r" - }, { + }, + { "clubLevel": 1, "tileSize": 748, "name": "u" - }, { + }, + { "clubLevel": 1, "tileSize": 438, "name": "v" - }, { + }, + { "clubLevel": 2, "tileSize": 540, "name": "t" - }, { + }, + { "clubLevel": 2, "tileSize": 512, "name": "w" - }, { + }, + { "clubLevel": 2, "tileSize": 396, "name": "x" - }, { + }, + { "clubLevel": 2, "tileSize": 440, "name": "y" - }, { + }, + { "clubLevel": 2, "tileSize": 456, "name": "z" - }, { + }, + { "clubLevel": 2, "tileSize": 208, "name": "0" - }, { + }, + { "clubLevel": 2, "tileSize": 1009, "name": "1" - }, { + }, + { "clubLevel": 2, "tileSize": 1044, "name": "2" - }, { + }, + { "clubLevel": 2, "tileSize": 183, "name": "3" - }, { + }, + { "clubLevel": 2, "tileSize": 254, "name": "4" - }, { + }, + { "clubLevel": 2, "tileSize": 1024, "name": "5" - }, { + }, + { "clubLevel": 2, "tileSize": 801, "name": "6" - }, { + }, + { "clubLevel": 2, "tileSize": 354, "name": "7" - }, { + }, + { "clubLevel": 2, "tileSize": 888, "name": "8" - }, { + }, + { "clubLevel": 2, "tileSize": 926, "name": "9" @@ -260,325 +317,379 @@ "catalog.headers": false, "chat.input.maxlength": 100, "chat.styles.disabled": [], - "chat.styles": [{ + "chat.styles": [ + { "styleId": 0, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 1, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 2, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 3, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 4, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 5, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 6, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 7, "minRank": 0, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 8, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 9, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 10, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 11, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 12, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 13, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 14, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 15, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 16, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 17, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 18, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 19, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 20, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 21, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 22, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 23, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 24, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 25, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 26, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 27, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 28, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 29, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 30, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 31, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 32, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 33, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 34, "minRank": 5, "isSystemStyle": true, "isHcOnly": false, "isAmbassadorOnly": false - }, { + }, + { "styleId": 35, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 36, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 37, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 38, "minRank": 0, "isSystemStyle": false, "isHcOnly": true, "isAmbassadorOnly": false - }, { + }, + { "styleId": 39, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 40, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 41, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 42, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 43, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 44, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 45, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 46, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 47, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 48, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 49, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 50, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 51, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 52, "minRank": 5, "isSystemStyle": false, "isHcOnly": false, "isAmbassadorOnly": true - }, { + }, + { "styleId": 53, "minRank": 5, "isSystemStyle": false, @@ -586,7 +697,8 @@ "isAmbassadorOnly": true } ], - "camera.available.effects": [{ + "camera.available.effects": [ + { "name": "dark_sepia", "colorMatrix": [ 0.4, @@ -612,7 +724,8 @@ ], "minLevel": 0, "enabled": true - }, { + }, + { "name": "increase_saturation", "colorMatrix": [ 2, @@ -638,7 +751,8 @@ ], "minLevel": 0, "enabled": true - }, { + }, + { "name": "increase_contrast", "colorMatrix": [ 1.5, @@ -664,13 +778,15 @@ ], "minLevel": 0, "enabled": true - }, { + }, + { "name": "shadow_multiply_02", "colorMatrix": [], "minLevel": 0, "blendMode": 2, "enabled": true - }, { + }, + { "name": "color_1", "colorMatrix": [ 0.393, @@ -696,7 +812,8 @@ ], "minLevel": 1, "enabled": true - }, { + }, + { "name": "hue_bright_sat", "colorMatrix": [ 1, @@ -722,25 +839,29 @@ ], "minLevel": 1, "enabled": true - }, { + }, + { "name": "hearts_hardlight_02", "colorMatrix": [], "minLevel": 1, "blendMode": 9, "enabled": true - }, { + }, + { "name": "texture_overlay", "colorMatrix": [], "minLevel": 1, "blendMode": 4, "enabled": true - }, { + }, + { "name": "pinky_nrm", "colorMatrix": [], "minLevel": 1, "blendMode": 0, "enabled": true - }, { + }, + { "name": "color_2", "colorMatrix": [ 0.333, @@ -766,7 +887,8 @@ ], "minLevel": 2, "enabled": true - }, { + }, + { "name": "night_vision", "colorMatrix": [ 0, @@ -792,37 +914,43 @@ ], "minLevel": 2, "enabled": true - }, { + }, + { "name": "stars_hardlight_02", "colorMatrix": [], "minLevel": 2, "blendMode": 9, "enabled": true - }, { + }, + { "name": "coffee_mpl", "colorMatrix": [], "minLevel": 2, "blendMode": 2, "enabled": true - }, { + }, + { "name": "security_hardlight", "colorMatrix": [], "minLevel": 3, "blendMode": 9, "enabled": true - }, { + }, + { "name": "bluemood_mpl", "colorMatrix": [], "minLevel": 3, "blendMode": 2, "enabled": true - }, { + }, + { "name": "rusty_mpl", "colorMatrix": [], "minLevel": 3, "blendMode": 2, "enabled": true - }, { + }, + { "name": "decr_conrast", "colorMatrix": [ 0.5, @@ -848,7 +976,8 @@ ], "minLevel": 4, "enabled": true - }, { + }, + { "name": "green_2", "colorMatrix": [ 0.5, @@ -874,13 +1003,15 @@ ], "minLevel": 4, "enabled": true - }, { + }, + { "name": "alien_hrd", "colorMatrix": [], "minLevel": 4, "blendMode": 9, "enabled": true - }, { + }, + { "name": "color_3", "colorMatrix": [ 0.609, @@ -906,7 +1037,8 @@ ], "minLevel": 5, "enabled": true - }, { + }, + { "name": "color_4", "colorMatrix": [ 0.8, @@ -932,13 +1064,15 @@ ], "minLevel": 5, "enabled": true - }, { + }, + { "name": "toxic_hrd", "colorMatrix": [], "minLevel": 5, "blendMode": 9, "enabled": true - }, { + }, + { "name": "hypersaturated", "colorMatrix": [ 2, @@ -964,7 +1098,8 @@ ], "minLevel": 6, "enabled": true - }, { + }, + { "name": "Yellow", "colorMatrix": [ 1, @@ -990,13 +1125,15 @@ ], "minLevel": 6, "enabled": true - }, { + }, + { "name": "misty_hrd", "colorMatrix": [], "minLevel": 6, "blendMode": 9, "enabled": true - }, { + }, + { "name": "x_ray", "colorMatrix": [ 0, @@ -1022,7 +1159,8 @@ ], "minLevel": 7, "enabled": true - }, { + }, + { "name": "decrease_saturation", "colorMatrix": [ 0.7, @@ -1048,55 +1186,64 @@ ], "minLevel": 7, "enabled": true - }, { + }, + { "name": "drops_mpl", "colorMatrix": [], "minLevel": 8, "blendMode": 2, "enabled": true - }, { + }, + { "name": "shiny_hrd", "colorMatrix": [], "minLevel": 9, "blendMode": 9, "enabled": true - }, { + }, + { "name": "glitter_hrd", "colorMatrix": [], "minLevel": 10, "blendMode": 9, "enabled": true - }, { + }, + { "name": "frame_gold", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "frame_gray_4", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "frame_black_2", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "frame_wood_2", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "finger_nrm", "colorMatrix": [], "minLevel": 10, "blendMode": 0, "enabled": true - }, { + }, + { "name": "color_5", "colorMatrix": [ 3.309, @@ -1122,7 +1269,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "black_white_negative", "colorMatrix": [ -0.5, @@ -1148,7 +1296,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "blue", "colorMatrix": [ 0.5, @@ -1174,7 +1323,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "red", "colorMatrix": [ 0.5, @@ -1200,7 +1350,8 @@ ], "minLevel": 10, "enabled": true - }, { + }, + { "name": "green", "colorMatrix": [ 0.5, @@ -1336,5 +1487,8 @@ "display": "BUBBLE", "image": "${image.library.url}/album1584/X1517.gif" } - } + }, + "backgrounds.data": [], + "stands.data": [], + "overlays.data": [] } diff --git a/public/renderer-config.example b/public/renderer-config.example deleted file mode 100644 index eccba80..0000000 --- a/public/renderer-config.example +++ /dev/null @@ -1,534 +0,0 @@ -{ - "socket.url": "ws://localhost:2096", - "crypto.ws.enabled": false, - "crypto.ws.signing.enabled": true, - "crypto.ws.signing.public_key": "### PASTE HERE THE PUBLIC KEY !!!! NOT THE PRIVATE !!!! ###", - "api.url": "http://localhost:2096", - "asset.url": "https://localhost/nitro/bundled", - "image.library.url": "https://localhost/c_images/", - "hof.furni.url": "https://localhost/c_images/dcr/hof_furni", - "images.url": "https://localhost/nitro/images", - "gamedata.url": "https://localhost/nitro/gamedata", - "sounds.url": "${asset.url}/sounds/%sample%.mp3", - "external.texts.url": [ - "${gamedata.url}/ExternalTexts.json", - "${gamedata.url}/UITexts.json" - ], - "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", - "furnidata.url": "${gamedata.url}/FurnitureData.json?v=2", - "productdata.url": "${gamedata.url}/ProductData.json?v=2", - "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?v=2", - "avatar.figuredata.url": "${gamedata.url}/FigureData.json?v=2", - "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?v=2", - "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?v=2", - "avatar.asset.url": "${asset.url}/figure/%libname%.nitro", - "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", - "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", - "furni.asset.icon.url": "${hof.furni.url}/icons/%libname%%param%_icon.png", - "pet.asset.url": "${asset.url}/pets/%libname%.nitro", - "generic.asset.url": "${asset.url}/generic/%libname%.nitro", - "badge.asset.url": "${image.library.url}album1584/%badgename%.gif", - "furni.rotation.bounce.steps": 20, - "furni.rotation.bounce.height": 0.0625, - "enable.avatar.arrow": false, - "system.log.debug": true, - "system.log.warn": true, - "system.log.error": true, - "system.log.events": false, - "system.log.packets": true, - "system.fps.animation": 24, - "system.fps.max": 60, - "system.pong.manually": true, - "system.pong.interval.ms": 20000, - "room.color.skip.transition": true, - "room.landscapes.enabled": true, - "room.zoom.enabled": true, - "login.screen.enabled": true, - "login.endpoint": "${api.url}/api/auth/login", - "login.register.endpoint": "${api.url}/api/auth/register", - "login.forgot.endpoint": "${api.url}/api/auth/forgot-password", - "login.logout.endpoint": "${api.url}/api/auth/logout", - "login.health.endpoint": "${api.url}/api/health", - "login.check-email.endpoint": "${api.url}/api/auth/check-email", - "login.check-username.endpoint": "${api.url}/api/auth/check-username", - "login.room_templates.endpoint": "${api.url}/api/auth/room-templates", - "login.remember.endpoint": "${api.url}/api/auth/remember", - "login.server_key.endpoint": "${api.url}/api/auth/server-key", - "login.news.endpoint": "${api.url}/api/auth/news", - "login.sso-token.endpoint": "${api.url}/api/auth/sso-token", - "login.refresh.endpoint": "${api.url}/api/auth/refresh", - "badges.custom.list.endpoint": "${api.url}/api/badges/custom", - "badges.custom.create.endpoint": "${api.url}/api/badges/custom", - "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", - "login.turnstile.enabled": true, - "login.turnstile.sitekey": "", - "avatar.mandatory.libraries": [ - "bd:1", - "li:0" - ], - "avatar.mandatory.effect.libraries": [ - "dance.1", - "dance.2", - "dance.3", - "dance.4" - ], - "avatar.default.figuredata": { - "palettes": [{ - "id": 1, - "colors": [{ - "id": 99999, - "index": 1001, - "club": 0, - "selectable": false, - "hexCode": "DDDDDD" - }, { - "id": 99998, - "index": 1001, - "club": 0, - "selectable": false, - "hexCode": "FAFAFA" - } - ] - }, { - "id": 3, - "colors": [{ - "id": 10001, - "index": 1001, - "club": 0, - "selectable": false, - "hexCode": "EEEEEE" - }, { - "id": 10002, - "index": 1002, - "club": 0, - "selectable": false, - "hexCode": "FA3831" - }, { - "id": 10003, - "index": 1003, - "club": 0, - "selectable": false, - "hexCode": "FD92A0" - }, { - "id": 10004, - "index": 1004, - "club": 0, - "selectable": false, - "hexCode": "2AC7D2" - }, { - "id": 10005, - "index": 1005, - "club": 0, - "selectable": false, - "hexCode": "35332C" - }, { - "id": 10006, - "index": 1006, - "club": 0, - "selectable": false, - "hexCode": "EFFF92" - }, { - "id": 10007, - "index": 1007, - "club": 0, - "selectable": false, - "hexCode": "C6FF98" - }, { - "id": 10008, - "index": 1008, - "club": 0, - "selectable": false, - "hexCode": "FF925A" - }, { - "id": 10009, - "index": 1009, - "club": 0, - "selectable": false, - "hexCode": "9D597E" - }, { - "id": 10010, - "index": 1010, - "club": 0, - "selectable": false, - "hexCode": "B6F3FF" - }, { - "id": 10011, - "index": 1011, - "club": 0, - "selectable": false, - "hexCode": "6DFF33" - }, { - "id": 10012, - "index": 1012, - "club": 0, - "selectable": false, - "hexCode": "3378C9" - }, { - "id": 10013, - "index": 1013, - "club": 0, - "selectable": false, - "hexCode": "FFB631" - }, { - "id": 10014, - "index": 1014, - "club": 0, - "selectable": false, - "hexCode": "DFA1E9" - }, { - "id": 10015, - "index": 1015, - "club": 0, - "selectable": false, - "hexCode": "F9FB32" - }, { - "id": 10016, - "index": 1016, - "club": 0, - "selectable": false, - "hexCode": "CAAF8F" - }, { - "id": 10017, - "index": 1017, - "club": 0, - "selectable": false, - "hexCode": "C5C6C5" - }, { - "id": 10018, - "index": 1018, - "club": 0, - "selectable": false, - "hexCode": "47623D" - }, { - "id": 10019, - "index": 1019, - "club": 0, - "selectable": false, - "hexCode": "8A8361" - }, { - "id": 10020, - "index": 1020, - "club": 0, - "selectable": false, - "hexCode": "FF8C33" - }, { - "id": 10021, - "index": 1021, - "club": 0, - "selectable": false, - "hexCode": "54C627" - }, { - "id": 10022, - "index": 1022, - "club": 0, - "selectable": false, - "hexCode": "1E6C99" - }, { - "id": 10023, - "index": 1023, - "club": 0, - "selectable": false, - "hexCode": "984F88" - }, { - "id": 10024, - "index": 1024, - "club": 0, - "selectable": false, - "hexCode": "77C8FF" - }, { - "id": 10025, - "index": 1025, - "club": 0, - "selectable": false, - "hexCode": "FFC08E" - }, { - "id": 10026, - "index": 1026, - "club": 0, - "selectable": false, - "hexCode": "3C4B87" - }, { - "id": 10027, - "index": 1027, - "club": 0, - "selectable": false, - "hexCode": "7C2C47" - }, { - "id": 10028, - "index": 1028, - "club": 0, - "selectable": false, - "hexCode": "D7FFE3" - }, { - "id": 10029, - "index": 1029, - "club": 0, - "selectable": false, - "hexCode": "8F3F1C" - }, { - "id": 10030, - "index": 1030, - "club": 0, - "selectable": false, - "hexCode": "FF6393" - }, { - "id": 10031, - "index": 1031, - "club": 0, - "selectable": false, - "hexCode": "1F9B79" - }, { - "id": 10032, - "index": 1032, - "club": 0, - "selectable": false, - "hexCode": "FDFF33" - } - ] - } - ], - "setTypes": [{ - "type": "hd", - "paletteId": 1, - "mandatory_f_0": true, - "mandatory_f_1": true, - "mandatory_m_0": true, - "mandatory_m_1": true, - "sets": [{ - "id": 99999, - "gender": "U", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [{ - "id": 1, - "type": "bd", - "colorable": true, - "index": 0, - "colorindex": 1 - }, { - "id": 1, - "type": "hd", - "colorable": true, - "index": 0, - "colorindex": 1 - }, { - "id": 1, - "type": "lh", - "colorable": true, - "index": 0, - "colorindex": 1 - }, { - "id": 1, - "type": "rh", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ] - } - ] - }, { - "type": "bds", - "paletteId": 1, - "mandatory_f_0": false, - "mandatory_f_1": false, - "mandatory_m_0": false, - "mandatory_m_1": false, - "sets": [{ - "id": 10001, - "gender": "U", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [{ - "id": 10001, - "type": "bds", - "colorable": true, - "index": 0, - "colorindex": 1 - }, { - "id": 10001, - "type": "lhs", - "colorable": true, - "index": 0, - "colorindex": 1 - }, { - "id": 10001, - "type": "rhs", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ], - "hiddenLayers": [{ - "partType": "bd" - }, { - "partType": "rh" - }, { - "partType": "lh" - } - ] - } - ] - }, { - "type": "ss", - "paletteId": 3, - "mandatory_f_0": false, - "mandatory_f_1": false, - "mandatory_m_0": false, - "mandatory_m_1": false, - "sets": [{ - "id": 10010, - "gender": "F", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [{ - "id": 10001, - "type": "ss", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ], - "hiddenLayers": [{ - "partType": "ch" - }, { - "partType": "lg" - }, { - "partType": "ca" - }, { - "partType": "wa" - }, { - "partType": "sh" - }, { - "partType": "ls" - }, { - "partType": "rs" - }, { - "partType": "lc" - }, { - "partType": "rc" - }, { - "partType": "cc" - }, { - "partType": "cp" - } - ] - }, { - "id": 10011, - "gender": "M", - "club": 0, - "colorable": true, - "selectable": false, - "preselectable": false, - "sellable": false, - "parts": [{ - "id": 10002, - "type": "ss", - "colorable": true, - "index": 0, - "colorindex": 1 - } - ], - "hiddenLayers": [{ - "partType": "ch" - }, { - "partType": "lg" - }, { - "partType": "ca" - }, { - "partType": "wa" - }, { - "partType": "sh" - }, { - "partType": "ls" - }, { - "partType": "rs" - }, { - "partType": "lc" - }, { - "partType": "rc" - }, { - "partType": "cc" - }, { - "partType": "cp" - } - ] - } - ] - } - ] - }, - "avatar.default.actions": { - "actions": [{ - "id": "Default", - "state": "std", - "precedence": 1000, - "main": true, - "isDefault": true, - "geometryType": "vertical", - "activePartSet": "figure", - "assetPartDefinition": "std" - } - ] - }, - "pet.types": [ - "dog", - "cat", - "croco", - "terrier", - "bear", - "pig", - "lion", - "rhino", - "spider", - "turtle", - "chicken", - "frog", - "dragon", - "monster", - "monkey", - "horse", - "monsterplant", - "bunnyeaster", - "bunnyevil", - "bunnydepressed", - "bunnylove", - "pigeongood", - "pigeonevil", - "demonmonkey", - "bearbaby", - "terrierbaby", - "gnome", - "gnome", - "kittenbaby", - "puppybaby", - "pigletbaby", - "haloompa", - "fools", - "pterosaur", - "velociraptor", - "cow", - "LeetPen", - "bbwibb", - "elephants" - ], - "preload.assets.urls": [ - "${asset.url}/generic/avatar_additions.nitro", - "${asset.url}/generic/group_badge.nitro", - "${asset.url}/generic/floor_editor.nitro", - "${images.url}/loading_icon.png", - "${images.url}/clear_icon.png", - "${images.url}/big_arrow.png" - ] -} diff --git a/public/text_translate/README.txt b/public/text_translate/README.txt new file mode 100644 index 0000000..4c24749 --- /dev/null +++ b/public/text_translate/README.txt @@ -0,0 +1,13 @@ +Place localized external text files here, for example: + +- `ExternalTexts_br.json` +- `ExternalTexts_com.json` +- `ExternalTexts_de.json` +- `ExternalTexts_es.json` +- `ExternalTexts_fi.json` +- `ExternalTexts_fr.json` +- `ExternalTexts_it.json` +- `ExternalTexts_nl.json` +- `ExternalTexts_tr.json` + +The client loads them from `/text_translate/` when selected in the Google Translate panel. diff --git a/scripts/asset-codec.mjs b/scripts/asset-codec.mjs new file mode 100644 index 0000000..9e82654 --- /dev/null +++ b/scripts/asset-codec.mjs @@ -0,0 +1,13 @@ +const KEY = new TextEncoder().encode('slogga-dist-assets-2026'); + +export const encodeBytes = bytes => +{ + const output = new Uint8Array(bytes.length); + + for(let index = 0; index < bytes.length; index++) + { + output[index] = bytes[index] ^ KEY[index % KEY.length] ^ ((index * 31) & 255); + } + + return output; +}; diff --git a/scripts/minify-dist.mjs b/scripts/minify-dist.mjs new file mode 100644 index 0000000..c61ff54 --- /dev/null +++ b/scripts/minify-dist.mjs @@ -0,0 +1,81 @@ +import { encodeBytes } from './asset-codec.mjs'; +import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { gzipSync } from 'zlib'; + +const dist = 'dist'; +const buildVersion = Date.now().toString(36); + +const walk = dir => +{ + const out = []; + + for(const entry of readdirSync(dir)) + { + const path = join(dir, entry); + const stat = statSync(path); + + if(stat.isDirectory()) out.push(...walk(path)); + else out.push(path); + } + + return out; +}; + +const minifyJson = path => +{ + try + { + writeFileSync(path, JSON.stringify(JSON.parse(readFileSync(path, 'utf8')))); + } + catch {} +}; + +const encryptFile = path => +{ + const bytes = gzipSync(readFileSync(path), { level: 9 }); + writeFileSync(path + '.dat', encodeBytes(bytes)); +}; + +if(!existsSync(dist)) throw new Error('dist folder not found'); + +for(const file of walk(dist)) +{ + if(file.endsWith('.json')) minifyJson(file); +} + +for(const file of walk(dist)) +{ + if(file.endsWith('.js') && !file.endsWith('asset-loader.js')) encryptFile(file); + if(file.endsWith('.css')) encryptFile(file); +} + +const assetMirrorDir = join(dist, 'src', 'assets'); +mkdirSync(assetMirrorDir, { recursive: true }); + +for(const file of [ 'app.css.dat', 'app.js.dat' ]) +{ + const source = join(dist, 'assets', file); + const target = join(assetMirrorDir, file); + + if(existsSync(source)) copyFileSync(source, target); +} + +const publicLoaderAssets = [ + [ 'src/assets/images/loading/loading.gif', 'loading.gif' ], + [ 'src/assets/images/notifications/nitro_v3.png', 'nitro_v3.png' ] +]; + +for(const [ source, file ] of publicLoaderAssets) +{ + const target = join(dist, 'assets', file); + const mirrorTarget = join(assetMirrorDir, file); + + if(existsSync(source)) + { + copyFileSync(source, target); + copyFileSync(source, mirrorTarget); + } +} + +writeFileSync(join(dist, 'index.html'), `
`); diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs new file mode 100644 index 0000000..59e403c --- /dev/null +++ b/scripts/write-asset-loader.mjs @@ -0,0 +1,345 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { dirname, resolve } from 'path'; + +const loader = `(() => { + const ASSET_KEY = new TextEncoder().encode("slogga-dist-assets-2026"); + const MODE_DEFAULTS = { + distObfuscationEnabled: true, + secureAssetsEnabled: true, + secureApiEnabled: true + }; + + const isDebug = () => { + try { + const search = new URLSearchParams(location.search); + return search.get("loaderDebug") === "1" || localStorage.getItem("nitro.loader.debug") === "1"; + } catch { + return false; + } + }; + + const debug = (message) => { + try { + window.__nitroLoaderDebug = message; + const log = Array.isArray(window.__nitroLoaderDebugLog) ? window.__nitroLoaderDebugLog : []; + log.push(message); + window.__nitroLoaderDebugLog = log.slice(-30); + if(!isDebug()) { + document.getElementById("nitro-loader-debug")?.remove(); + return; + } + let node = document.getElementById("nitro-loader-debug"); + if(!node) { + node = document.createElement("div"); + node.id = "nitro-loader-debug"; + node.style.cssText = "position:fixed;left:8px;top:8px;z-index:2147483647;padding:6px 8px;max-width:70vw;background:rgba(0,0,0,.85);color:#fff;font:12px monospace;white-space:pre-wrap"; + document.body.appendChild(node); + } + node.textContent = window.__nitroLoaderDebugLog.slice(-10).join("\\n"); + } catch {} + }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const renderShell = () => { + const root = document.getElementById("root"); + if(!root || root.firstChild) return; + root.innerHTML = '
'; + }; + + const decodeAsset = (bytes) => { + const output = new Uint8Array(bytes.length); + for(let index = 0; index < bytes.length; index++) { + output[index] = bytes[index] ^ ASSET_KEY[index % ASSET_KEY.length] ^ ((index * 31) & 255); + } + return output; + }; + + const gunzip = async (bytes) => { + if(!("DecompressionStream" in self)) throw new Error("gzip decompression unsupported"); + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); + }; + + const resolveAssetCandidates = (path) => { + const base = getBase(); + const normalized = path.replace(/^\\.\\//, ""); + const file = normalized.split("/").pop(); + const urls = [ + new URL("./src/assets/" + file, base), + new URL("./assets/" + file, base), + new URL("/src/assets/" + file, base.origin), + new URL("/assets/" + file, base.origin), + new URL("/client/src/assets/" + file, base.origin), + new URL("/client/assets/" + file, base.origin) + ]; + return [...new Map(urls.map(url => [url.href, url])).values()]; + }; + + const fetchBytes = async (path) => { + let error = null; + debug("loader: fetching " + path); + for(const candidate of resolveAssetCandidates(path)) { + try { + debug("loader: try " + candidate.href); + const response = await fetch(withCacheBust(candidate), { cache: "no-store" }); + if(!response.ok) { + error = new Error("asset " + candidate.pathname + " " + response.status); + continue; + } + debug("loader: ok " + candidate.href); + return new Uint8Array(await response.arrayBuffer()); + } catch(caught) { + error = caught; + } + } + throw error || new Error("asset " + path + " not found"); + }; + + const loadDatAsset = async (path) => gunzip(decodeAsset(await fetchBytes(path))); + + const injectCssText = (bytes) => { + const node = document.createElement("style"); + node.textContent = new TextDecoder().decode(bytes); + document.head.appendChild(node); + debug("loader: css injected from dat"); + }; + + const loadPlainCss = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + await new Promise((resolve, reject) => { + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = href.href; + link.onload = () => resolve(); + link.onerror = () => reject(new Error("plain css failed")); + document.head.appendChild(link); + }); + debug("loader: css linked"); + }; + + const importBytes = async (bytes) => { + const blobUrl = URL.createObjectURL(new Blob([bytes], { type: "text/javascript" })); + try { + debug("loader: importing app blob"); + await import(blobUrl); + debug("loader: app blob imported"); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const importPlainJs = async (path) => { + const href = resolveAssetCandidates(path)[0]; + href.searchParams.set("v", Date.now().toString(36)); + debug("loader: importing plain js"); + await import(href.href); + debug("loader: plain js imported"); + }; + + const readClientMode = async () => { + try { + if(window.__nitroClientMode && typeof window.__nitroClientMode === "object") { + debug("loader: client-mode preset"); + return window.__nitroClientMode; + } + const url = withCacheBust(new URL("./client-mode.json", getBase())); + const response = await fetch(url, { cache: "no-store" }); + if(!response.ok) throw new Error("client-mode " + response.status); + const payload = await response.json(); + const mode = { ...MODE_DEFAULTS, ...(payload && typeof payload === "object" ? payload : {}) }; + window.__nitroClientMode = mode; + debug("loader: client-mode loaded"); + return mode; + } catch(error) { + window.__nitroClientMode = { ...MODE_DEFAULTS }; + debug("loader: client-mode fallback " + (error?.message || error)); + return window.__nitroClientMode; + } + }; + + (async () => { + debug("loader: start"); + renderShell(); + const mode = await readClientMode(); + if(mode.distObfuscationEnabled) { + const [cssBytes, jsBytes] = await Promise.all([ + loadDatAsset("./assets/app.css.dat"), + loadDatAsset("./assets/app.js.dat") + ]); + injectCssText(cssBytes); + await importBytes(jsBytes); + return; + } + await loadPlainCss("./assets/app.css"); + await importPlainJs("./assets/app.js"); + })().catch(error => { + console.error(error); + debug("loader: failed " + (error?.message || error)); + document.body.textContent = "Unable to load client."; + }); +})();`; + +const clientModePath = resolve('public', 'configuration', 'client-mode.json'); +let bootstrapApiBase = ''; + +if(existsSync(clientModePath)) +{ + try + { + const clientMode = JSON.parse(readFileSync(clientModePath, 'utf8')); + + if(typeof clientMode.apiBaseUrl === 'string') bootstrapApiBase = clientMode.apiBaseUrl; + } + catch {} +} + +const bootstrap = `(() => { + const API_BASE = ${ JSON.stringify(bootstrapApiBase) }; + + const getBase = () => { + const source = document.currentScript?.src || location.href; + return new URL(".", source); + }; + + const withCacheBust = (url) => { + url.searchParams.set("v", Date.now().toString(36)); + return url; + }; + + const bytesToBase64 = (buffer) => { + let binary = ""; + const bytes = new Uint8Array(buffer); + for(let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); + }; + + const hexValue = (code) => { + if(code >= 48 && code <= 57) return code - 48; + if(code >= 65 && code <= 70) return code - 55; + if(code >= 97 && code <= 102) return code - 87; + return -1; + }; + + const hexToBytes = (hex) => { + const normalized = hex.trim(); + if((normalized.length % 2) !== 0) throw new Error("Invalid encrypted hex payload."); + const bytes = new Uint8Array(normalized.length / 2); + for(let i = 0; i < bytes.length; i++) { + const high = hexValue(normalized.charCodeAt(i * 2)); + const low = hexValue(normalized.charCodeAt((i * 2) + 1)); + if(high < 0 || low < 0) throw new Error("Invalid encrypted hex payload."); + bytes[i] = (high << 4) | low; + } + return bytes; + }; + + const deriveAesKey = async (privateKey, serverKeyBase64) => { + const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0)); + const serverKey = await crypto.subtle.importKey("spki", serverBytes, { name: "ECDH", namedCurve: "P-256" }, false, []); + const secret = await crypto.subtle.deriveBits({ name: "ECDH", public: serverKey }, privateKey, 256); + const salt = new TextEncoder().encode("nitro-secure-assets-v1"); + const material = new Uint8Array(secret.byteLength + salt.length); + material.set(new Uint8Array(secret), 0); + material.set(salt, secret.byteLength); + const hash = await crypto.subtle.digest("SHA-256", material); + return crypto.subtle.importKey("raw", hash, "AES-GCM", false, ["decrypt"]); + }; + + const decryptPayload = async (key, response) => { + if(response.headers.get("X-Nitro-Sec") !== "1") return response.text(); + const bytes = hexToBytes(await response.text()); + if(bytes.length < 13) throw new Error("Encrypted response is too short."); + const iv = bytes.slice(0, 12); + const payload = bytes.slice(12); + const clear = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, key, payload); + return new TextDecoder().decode(clear); + }; + + const importTextModule = async (sourceText) => { + const blobUrl = URL.createObjectURL(new Blob([sourceText], { type: "text/javascript" })); + try { + await import(blobUrl); + } finally { + URL.revokeObjectURL(blobUrl); + } + }; + + const loadPlainBootstrap = async () => { + const url = withCacheBust(new URL("./asset-loader.js", getBase())); + await import(url.href); + }; + + const loadSecureBootstrap = async () => { + if(!API_BASE) throw new Error("Missing apiBaseUrl for secure bootstrap."); + + const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]); + const publicKeyBuffer = await crypto.subtle.exportKey("spki", pair.publicKey); + const publicKey = bytesToBase64(publicKeyBuffer); + const base = API_BASE.replace(/\\/$/, ""); + const bootstrapResponse = await fetch(base + "/nitro-sec/bootstrap", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: publicKey }) + }); + + if(!bootstrapResponse.ok) throw new Error("Secure bootstrap failed: HTTP " + bootstrapResponse.status); + + const bootstrapPayload = await bootstrapResponse.json(); + if(!bootstrapPayload || typeof bootstrapPayload.key !== "string" || !bootstrapPayload.key.length) { + throw new Error("Secure bootstrap returned an invalid server key."); + } + + const sessionKey = await deriveAesKey(pair.privateKey, bootstrapPayload.key); + + const fetchSecureConfig = async (file) => { + const url = new URL(base + "/nitro-sec/file"); + url.searchParams.set("kind", "config"); + url.searchParams.set("file", file); + url.searchParams.set("v", Date.now().toString(36)); + + const response = await fetch(url.toString(), { + headers: { "X-Nitro-Key": publicKey }, + cache: "no-store" + }); + + if(!response.ok) throw new Error("Failed to load secure config " + file + ": HTTP " + response.status); + + return decryptPayload(sessionKey, response); + }; + + const modeText = await fetchSecureConfig("client-mode.json"); + window.__nitroClientMode = JSON.parse(modeText); + + const loaderText = await fetchSecureConfig("asset-loader.js"); + await importTextModule(loaderText); + }; + + (async () => { + try { + await loadSecureBootstrap(); + } catch(error) { + console.warn("[Nitro] Secure bootstrap fallback:", error?.message || error); + await loadPlainBootstrap(); + } + })().catch(error => { + console.error(error); + document.body.textContent = "Unable to load client."; + }); +})();`; + +const target = resolve('public', 'configuration', 'asset-loader.js'); +const bootstrapTarget = resolve('public', 'configuration', 'bootstrap.js'); + +mkdirSync(dirname(target), { recursive: true }); +writeFileSync(target, loader); +writeFileSync(bootstrapTarget, bootstrap); diff --git a/src/App.tsx b/src/App.tsx index b59c167..7722748 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, useState } from 'react'; -import { clearAccessToken, getAccessToken, getAccessTokenExpiresAt, GetUIVersion, persistAccessTokenFromPayload } from './api'; +import { FC, useCallback, useEffect, useRef, useState } from 'react'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -10,13 +10,54 @@ import { useMessageEvent, useNitroEvent } from './hooks'; NitroVersion.UI_VERSION = GetUIVersion(); +const preloadUrl = async (url: string): Promise => +{ + if(!url) return; + + try + { + const response = await fetch(url, { cache: 'force-cache' }); + await response.arrayBuffer(); + } + catch {} +}; + +const preloadImage = (url: string): void => +{ + if(!url) return; + + try + { + const image = new Image(); + image.decoding = 'async'; + image.src = url; + } + catch {} +}; + +const asStringArray = (value: unknown): string[] => +{ + if(Array.isArray(value)) return value.filter(item => typeof item === 'string'); + if(typeof value === 'string' && value.length) return [ value ]; + + return []; +}; + +const hasRememberLogin = (): boolean => !!GetRememberLogin(); + export const App: FC<{}> = props => { const [ isReady, setIsReady ] = useState(false); const [ errorMessage, setErrorMessage ] = useState(''); const [ homeUrl, setHomeUrl ] = useState(''); - const [ showLogin, setShowLogin ] = useState(false); + const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket'] && !hasRememberLogin()); + const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin()); const [ prepareTrigger, setPrepareTrigger ] = useState(0); + const warmupPromiseRef = useRef>(null); + const rendererPromiseRef = useRef>(null); + const tickersStartedRef = useRef(false); + const heartbeatIntervalRef = useRef(null); + const rememberRotateIntervalRef = useRef(null); const showSessionExpired = useCallback(() => { const baseUrl = window.location.origin + '/'; @@ -24,17 +65,117 @@ export const App: FC<{}> = props => setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.'); setIsReady(false); setShowLogin(false); + setIsEnteringHotel(false); + }, []); + + const applySsoTicket = useCallback((ssoTicket: string) => + { + if(!ssoTicket) return; + window.NitroConfig['sso.ticket'] = ssoTicket; + GetConfiguration().setValue('sso.ticket', ssoTicket); }, []); const handleAuthenticated = useCallback((ssoTicket: string) => { if(!ssoTicket) return; - window.NitroConfig['sso.ticket'] = ssoTicket; - setShowLogin(false); + applySsoTicket(ssoTicket); + setIsEnteringHotel(true); setErrorMessage(''); setPrepareTrigger(prev => prev + 1); + }, [ applySsoTicket ]); + + const tryRememberLogin = useCallback(async (): Promise => + { + const remembered = GetRememberLogin(); + + if(!remembered) return ''; + if(!remembered.token?.length && remembered.ssoTicket?.length) return remembered.ssoTicket; + + let allowSsoFallback = true; + + try + { + const rawEndpoint = GetConfiguration().getValue('login.remember.endpoint', '${api.url}/api/auth/remember'); + const endpoint = GetConfiguration().interpolate(rawEndpoint); + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroRememberLogin' + }, + body: JSON.stringify({ rememberToken: remembered.token }) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch {} + + const ssoTicket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : (typeof payload.sso === 'string' ? payload.sso : ''); + + if(response.ok && ssoTicket) + { + 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); + } + + if(allowSsoFallback && remembered.ssoTicket?.length) return remembered.ssoTicket; + + return ''; }, []); + const rotateRememberLogin = useCallback(async (): Promise => + { + const remembered = GetRememberLogin(); + + if(!remembered?.token?.length) return; + + try + { + const rawEndpoint = GetConfiguration().getValue('login.refresh.endpoint', '${api.url}/api/auth/refresh'); + const endpoint = GetConfiguration().interpolate(rawEndpoint); + const response = await fetch(endpoint, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'X-Requested-With': 'NitroRememberRotate' + }, + body: JSON.stringify({ rememberToken: remembered.token }) + }); + + let payload: Record = {}; + try { payload = await response.json(); } + catch {} + + if(response.ok) + { + StoreRememberLoginFromPayload(payload, remembered.username, remembered.ssoTicket); + return; + } + + if(response.status === 400 || response.status === 401 || response.status === 403) ClearRememberLogin(); + } + catch(error) + { + NitroLogger.error('[LoginScreen] Remember rotation failed', error); + } + }, []); + + // Listen for socket closed events (code 1000 "Bye" - server rejected SSO) useNitroEvent(NitroEventType.SOCKET_CLOSED, showSessionExpired); useMessageEvent(LoadGameUrlEvent, event => @@ -46,11 +187,89 @@ export const App: FC<{}> = props => LegacyExternalInterface.callGame('showGame', parser.url); }); + const startRenderer = useCallback((width: number, height: number) => + { + if(rendererPromiseRef.current) return rendererPromiseRef.current; + + const rawUseBackBuffer = window.NitroConfig?.['renderer.useBackBuffer']; + const useBackBuffer = (rawUseBackBuffer === undefined) + ? true + : ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true')); + + rendererPromiseRef.current = PrepareRenderer({ + width: Math.floor(width), + height: Math.floor(height), + resolution: window.devicePixelRatio, + autoDensity: true, + backgroundAlpha: 0, + preference: 'webgl', + eventMode: 'none', + failIfMajorPerformanceCaveat: false, + roundPixels: true, + useBackBuffer + }); + + return rendererPromiseRef.current; + }, []); + + const startWarmup = useCallback((width: number, height: number) => + { + if(warmupPromiseRef.current) return warmupPromiseRef.current; + + warmupPromiseRef.current = (async () => + { + await GetConfiguration().init(); + + GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); + NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); + NitroLogger.LOG_WARN = GetConfiguration().getValue('system.log.warn', false); + NitroLogger.LOG_ERROR = GetConfiguration().getValue('system.log.error', false); + NitroLogger.LOG_EVENTS = GetConfiguration().getValue('system.log.events', false); + NitroLogger.LOG_PACKETS = GetConfiguration().getValue('system.log.packets', false); + + startRenderer(width, height).catch(error => NitroLogger.error('[LoginScreen] Renderer warmup failed', error)); + + const interpolate = (value: string) => GetConfiguration().interpolate(value); + const assetUrls = asStringArray(GetConfiguration().getValue('preload.assets.urls')).map(interpolate); + const gamedataUrls = [ + ...asStringArray(GetConfiguration().getValue('external.texts.url')).map(interpolate), + ...[ + 'furnidata.url', + 'productdata.url', + 'avatar.actions.url', + 'avatar.figuredata.url', + 'avatar.figuremap.url', + 'avatar.effectmap.url' + ].map(key => interpolate(GetConfiguration().getValue(key, ''))).filter(Boolean) + ]; + const loginImages = ((GetConfiguration().getValue>('loginview', {})?.images) as Record) ?? {}; + const loginImageUrls = [ + loginImages.background, + loginImages.sun, + loginImages.drape, + loginImages.left, + loginImages['right.repeat'], + loginImages.right + ].filter(Boolean).map(interpolate); + + loginImageUrls.forEach(preloadImage); + gamedataUrls.forEach(url => preloadUrl(url)); + + await Promise.all( + [ + GetAssetManager().downloadAssets(assetUrls), + GetLocalizationManager().init(), + GetAvatarRenderManager().init(), + GetSoundManager().init() + ] + ); + })(); + + return warmupPromiseRef.current; + }, [ startRenderer ]); + useEffect(() => { - let heartbeatInterval: number = null; - let rememberRotateInterval: number = null; - const prepare = async (width: number, height: number) => { try @@ -58,168 +277,64 @@ export const App: FC<{}> = props => if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); let ssoTicket = window.NitroConfig['sso.ticket']; - let configInitError: unknown = null; + if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); 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; } + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + if(configInitError) { NitroLogger.error('[LoginScreen] Failed to load renderer-config.json — cannot resolve login.screen.enabled', configInitError); } - if(!configInitError) - { - let storedRemember: string | null = null; - try { storedRemember = window.localStorage.getItem('nitro.remember.token'); } - catch {} - - if(storedRemember) - { - const rememberUrlTemplate = GetConfiguration().getValue('login.remember.endpoint', '/api/auth/remember'); - const rememberUrl = GetConfiguration().interpolate(rememberUrlTemplate); - try - { - const response = await fetch(rememberUrl, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-Requested-With': 'NitroRememberMe' - }, - body: JSON.stringify({ rememberToken: storedRemember }) - }); - if(response.ok) - { - const payload = await response.json(); - const ticket = typeof payload.ssoTicket === 'string' ? payload.ssoTicket : ''; - if(ticket) - { - window.NitroConfig['sso.ticket'] = ticket; - ssoTicket = ticket; - try - { - if(typeof payload.rememberToken === 'string' && payload.rememberToken.length) - window.localStorage.setItem('nitro.remember.token', payload.rememberToken); - } - catch {} - persistAccessTokenFromPayload(payload); - } - } - else if(response.status === 401) - { - try { window.localStorage.removeItem('nitro.remember.token'); } catch {} - clearAccessToken(); - } - } - catch {} - } - } - } - - if(ssoTicket) - { - const expiresAt = getAccessTokenExpiresAt(); - const nowSec = Math.floor(Date.now() / 1000); - const accessNeedsRefresh = !getAccessToken() || (expiresAt > 0 && expiresAt - nowSec < 60); - - if(accessNeedsRefresh) - { - const ssoTokenUrlTemplate = GetConfiguration().getValue('login.sso-token.endpoint', '/api/auth/sso-token'); - const ssoTokenUrl = GetConfiguration().interpolate(ssoTokenUrlTemplate); - try - { - const response = await fetch(ssoTokenUrl, { - method: 'POST', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-Requested-With': 'NitroSsoExchange' - }, - body: JSON.stringify({ ssoTicket }) - }); - if(response.ok) - { - const payload = await response.json(); - persistAccessTokenFromPayload(payload); - } - } - catch {} - } - } - - if(!ssoTicket || ssoTicket === '') - { - const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); - const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; - if(loginScreenEnabled) { - try { await GetLocalizationManager().init(); } - catch(localizationErr) { NitroLogger.error('[LoginScreen] Localization init failed', localizationErr); } + const rememberedSsoTicket = await tryRememberLogin(); - setIsReady(false); - setShowLogin(true); - return; + if(rememberedSsoTicket) + { + ssoTicket = rememberedSsoTicket; + applySsoTicket(rememberedSsoTicket); + setShowLogin(false); + } + else + { + setIsReady(false); + setShowLogin(true); + startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error)); + return; + } } - - if(configInitError) + else { - setHomeUrl(window.location.origin + '/'); - setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); - setIsReady(false); - setShowLogin(false); + if(configInitError) + { + setHomeUrl(window.location.origin + '/'); + setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`); + setIsReady(false); + setShowLogin(false); + setIsEnteringHotel(false); + return; + } + + showSessionExpired(); return; } - - showSessionExpired(); - return; } - const rawUseBackBuffer = window.NitroConfig['renderer.useBackBuffer']; - const useBackBuffer = (rawUseBackBuffer === undefined) - ? true - : ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true')); - - const renderer = await PrepareRenderer({ - width: Math.floor(width), - height: Math.floor(height), - resolution: window.devicePixelRatio, - autoDensity: true, - backgroundAlpha: 0, - preference: 'webgl', - eventMode: 'none', - failIfMajorPerformanceCaveat: false, - roundPixels: true, - useBackBuffer - }); - - await GetConfiguration().init(); - - GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); - NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); - NitroLogger.LOG_WARN = GetConfiguration().getValue('system.log.warn', false); - NitroLogger.LOG_ERROR = GetConfiguration().getValue('system.log.error', false); - NitroLogger.LOG_EVENTS = GetConfiguration().getValue('system.log.events', false); - NitroLogger.LOG_PACKETS = GetConfiguration().getValue('system.log.packets', false); - - const assetUrls = GetConfiguration().getValue('preload.assets.urls').map(url => GetConfiguration().interpolate(url)) ?? []; - - await Promise.all( - [ - GetAssetManager().downloadAssets(assetUrls), - GetLocalizationManager().init(), - GetAvatarRenderManager().init(), - GetSoundManager().init(), - GetSessionDataManager().init(), - GetRoomSessionManager().init() - ] - ); + const renderer = await startRenderer(width, height); + await startWarmup(width, height); + await GetSessionDataManager().init(); + await GetRoomSessionManager().init(); await GetRoomEngine().init(); await GetCommunication().init(); @@ -227,53 +342,30 @@ export const App: FC<{}> = props => HabboWebTools.sendHeartBeat(); - heartbeatInterval = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000); + if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); + heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000); + + if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current); const rotateMinutes = Math.max(1, Number(GetConfiguration().getValue('login.remember.rotate.interval.minutes', 15)) || 15); - const refreshUrlTemplate = GetConfiguration().getValue('login.refresh.endpoint', '/api/auth/refresh'); - const refreshUrl = GetConfiguration().interpolate(refreshUrlTemplate); - const rotateRemember = async () => - { - let stored: string = null; - try { stored = window.localStorage.getItem('nitro.remember.token'); } - catch { return; } - if(!stored) return; - try - { - const resp = await fetch(refreshUrl, { - method: 'POST', - credentials: 'include', - headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-Requested-With': 'NitroRememberRotate' }, - body: JSON.stringify({ rememberToken: stored }) - }); - if(resp.ok) - { - const payload = await resp.json(); - if(typeof payload.rememberToken === 'string' && payload.rememberToken.length) - { - try { window.localStorage.setItem('nitro.remember.token', payload.rememberToken); } catch {} - } - persistAccessTokenFromPayload(payload); - } - else if(resp.status === 401) - { - try { window.localStorage.removeItem('nitro.remember.token'); } catch {} - clearAccessToken(); - } - } - catch {} - }; - rememberRotateInterval = window.setInterval(rotateRemember, rotateMinutes * 60 * 1000); + if(GetRememberLogin()?.token?.length) rememberRotateIntervalRef.current = window.setInterval(() => rotateRememberLogin(), rotateMinutes * 60 * 1000); - GetTicker().add(ticker => GetRoomEngine().update(ticker)); - GetTicker().add(ticker => renderer.render(GetStage())); - GetTicker().add(ticker => GetTexturePool().run()); + if(!tickersStartedRef.current) + { + tickersStartedRef.current = true; + GetTicker().add(ticker => GetRoomEngine().update(ticker)); + GetTicker().add(ticker => renderer.render(GetStage())); + GetTicker().add(ticker => GetTexturePool().run()); + } setIsReady(true); + setShowLogin(false); + setIsEnteringHotel(false); } catch(err) { NitroLogger.error(err); + setIsEnteringHotel(false); showSessionExpired(); } }; @@ -282,16 +374,16 @@ export const App: FC<{}> = props => return () => { - if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval); - if(rememberRotateInterval !== null) window.clearInterval(rememberRotateInterval); + if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); + if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current); }; - }, [ prepareTrigger ]); + }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]); return ( { !isReady && !showLogin && 0 } message={ errorMessage } homeUrl={ homeUrl } /> } - { !isReady && showLogin && } + { !isReady && showLogin && } { isReady && } diff --git a/src/api/catalog/Offer.ts b/src/api/catalog/Offer.ts index 3bfc2ce..96d5734 100644 --- a/src/api/catalog/Offer.ts +++ b/src/api/catalog/Offer.ts @@ -152,6 +152,10 @@ export class Offer implements IPurchasableOffer public get localizationName(): string { + const furnitureProduct = this.product; + + if(furnitureProduct?.furnitureData?.name?.length) return furnitureProduct.furnitureData.name; + const productData = GetProductDataForLocalization(this._localizationId); if(productData) return productData.name; @@ -161,6 +165,10 @@ export class Offer implements IPurchasableOffer public get localizationDescription(): string { + const furnitureProduct = this.product; + + if(furnitureProduct?.furnitureData?.description?.length) return furnitureProduct.furnitureData.description; + const productData = GetProductDataForLocalization(this._localizationId); if(productData) return productData.description; diff --git a/src/api/chat-history/IChatEntry.ts b/src/api/chat-history/IChatEntry.ts index 1bf7a52..b6be72d 100644 --- a/src/api/chat-history/IChatEntry.ts +++ b/src/api/chat-history/IChatEntry.ts @@ -11,6 +11,11 @@ export interface IChatEntry chatType?: number; imageUrl?: string; color?: string; + showTranslation?: boolean; + originalMessage?: string; + translatedMessage?: string; + detectedLanguage?: string; + targetLanguage?: string; roomId: number; timestamp: string; type: number; diff --git a/src/api/friends/MessengerThread.ts b/src/api/friends/MessengerThread.ts index 5309f36..a8cdb3c 100644 --- a/src/api/friends/MessengerThread.ts +++ b/src/api/friends/MessengerThread.ts @@ -48,6 +48,18 @@ export class MessengerThread return chat; } + public getChat(chatId: number): MessengerThreadChat + { + for(const group of this._groups) + { + const chat = group.chats.find(existingChat => (existingChat.id === chatId)); + + if(chat) return chat; + } + + return null; + } + private pruneChats(): void { let totalChats = this._groups.reduce((total, current) => (total + current.chats.length), 0); diff --git a/src/api/friends/MessengerThreadChat.ts b/src/api/friends/MessengerThreadChat.ts index 2927fec..5e37167 100644 --- a/src/api/friends/MessengerThreadChat.ts +++ b/src/api/friends/MessengerThreadChat.ts @@ -4,22 +4,49 @@ export class MessengerThreadChat public static ROOM_INVITE: number = 1; public static STATUS_NOTIFICATION: number = 2; public static SECURITY_NOTIFICATION: number = 3; + private static CHAT_ID: number = 0; + private _id: number; private _type: number; private _senderId: number; private _message: string; private _secondsSinceSent: number; private _extraData: string; private _date: Date; + private _showTranslation: boolean; + private _originalMessage: string; + private _translatedMessage: string; + private _detectedLanguage: string; + private _targetLanguage: string; constructor(senderId: number, message: string, secondsSinceSent: number = 0, extraData: string = null, type: number = 0) { + this._id = ++MessengerThreadChat.CHAT_ID; this._type = type; this._senderId = senderId; this._message = message; this._secondsSinceSent = secondsSinceSent; this._extraData = extraData; this._date = new Date(); + this._showTranslation = false; + this._originalMessage = message; + this._translatedMessage = ''; + this._detectedLanguage = ''; + this._targetLanguage = ''; + } + + public setTranslation(originalMessage: string, translatedMessage: string, detectedLanguage: string, targetLanguage: string): void + { + this._showTranslation = true; + this._originalMessage = originalMessage || this._message || ''; + this._translatedMessage = translatedMessage || this._originalMessage; + this._detectedLanguage = detectedLanguage || ''; + this._targetLanguage = targetLanguage || ''; + } + + public get id(): number + { + return this._id; } public get type(): number @@ -51,4 +78,29 @@ export class MessengerThreadChat { return this._date; } + + public get showTranslation(): boolean + { + return this._showTranslation; + } + + public get originalMessage(): string + { + return this._originalMessage; + } + + public get translatedMessage(): string + { + return this._translatedMessage; + } + + public get detectedLanguage(): string + { + return this._detectedLanguage; + } + + public get targetLanguage(): string + { + return this._targetLanguage; + } } diff --git a/src/api/inventory/GroupItem.ts b/src/api/inventory/GroupItem.ts index 8569321..9c14d6c 100644 --- a/src/api/inventory/GroupItem.ts +++ b/src/api/inventory/GroupItem.ts @@ -65,6 +65,12 @@ export class GroupItem this.setDescription(); } + public refreshLocalization(): void + { + this.setName(); + this.setDescription(); + } + public dispose(): void { diff --git a/src/api/inventory/INickIconItem.ts b/src/api/inventory/INickIconItem.ts new file mode 100644 index 0000000..ead1c34 --- /dev/null +++ b/src/api/inventory/INickIconItem.ts @@ -0,0 +1,10 @@ +export interface INickIconItem +{ + id: number; + iconKey: string; + displayName: string; + points: number; + pointsType: number; + owned: boolean; + active: boolean; +} diff --git a/src/api/inventory/IPrefixItem.ts b/src/api/inventory/IPrefixItem.ts index b65981f..c04d8bb 100644 --- a/src/api/inventory/IPrefixItem.ts +++ b/src/api/inventory/IPrefixItem.ts @@ -1,9 +1,15 @@ export interface IPrefixItem { id: number; + displayName?: string; text: string; color: string; icon: string; effect: string; + font?: string; active: boolean; + isCustom?: boolean; + points?: number; + pointsType?: number; + catalogPrefixId?: number; } diff --git a/src/api/inventory/index.ts b/src/api/inventory/index.ts index 4e6ca21..bde1a41 100644 --- a/src/api/inventory/index.ts +++ b/src/api/inventory/index.ts @@ -4,6 +4,7 @@ export * from './FurnitureUtilities'; export * from './GroupItem'; export * from './IBotItem'; export * from './IFurnitureItem'; +export * from './INickIconItem'; export * from './IPetItem'; export * from './IPrefixItem'; export * from './IUnseenItemTracker'; diff --git a/src/api/room/widgets/AvatarInfoUser.ts b/src/api/room/widgets/AvatarInfoUser.ts index ad728f3..2d3157e 100644 --- a/src/api/room/widgets/AvatarInfoUser.ts +++ b/src/api/room/widgets/AvatarInfoUser.ts @@ -12,6 +12,13 @@ export class AvatarInfoUser implements IAvatarInfo public name: string = ''; public motto: string = ''; + public nickIcon: string = ''; + public prefixText: string = ''; + public prefixColor: string = ''; + public prefixIcon: string = ''; + public prefixEffect: string = ''; + public prefixFont: string = ''; + public displayOrder: string = 'icon-prefix-name'; public achievementScore: number = 0; public backgroundId: number = 0; public standId: number = 0; diff --git a/src/api/room/widgets/AvatarInfoUtilities.ts b/src/api/room/widgets/AvatarInfoUtilities.ts index f9b29ac..a2d0b45 100644 --- a/src/api/room/widgets/AvatarInfoUtilities.ts +++ b/src/api/room/widgets/AvatarInfoUtilities.ts @@ -32,17 +32,16 @@ export class AvatarInfoUtilities else { let furniData: IFurnitureData = null; - - const typeId = roomObject.model.getValue(RoomObjectVariable.FURNITURE_TYPE_ID); + const className = roomObject.type; if(category === RoomObjectCategory.FLOOR) { - furniData = GetSessionDataManager().getFloorItemData(typeId); + furniData = GetSessionDataManager().getFloorItemDataByName(className); } else if(category === RoomObjectCategory.WALL) { - furniData = GetSessionDataManager().getWallItemData(typeId); + furniData = GetSessionDataManager().getWallItemDataByName(className); } if(!furniData) break; @@ -102,18 +101,17 @@ export class AvatarInfoUtilities } else { - const typeId = model.getValue(RoomObjectVariable.FURNITURE_TYPE_ID); - let furnitureData: IFurnitureData = null; + const className = roomObject.type; if(category === RoomObjectCategory.FLOOR) { - furnitureData = GetSessionDataManager().getFloorItemData(typeId); + furnitureData = GetSessionDataManager().getFloorItemDataByName(className); } else if(category === RoomObjectCategory.WALL) { - furnitureData = GetSessionDataManager().getWallItemData(typeId); + furnitureData = GetSessionDataManager().getWallItemDataByName(className); } if(furnitureData) @@ -183,6 +181,13 @@ export class AvatarInfoUtilities userInfo.isSpectatorMode = roomSession.isSpectator; userInfo.name = userData.name; userInfo.motto = userData.custom; + userInfo.nickIcon = userData.nickIcon; + userInfo.prefixText = userData.prefixText; + userInfo.prefixColor = userData.prefixColor; + userInfo.prefixIcon = userData.prefixIcon; + userInfo.prefixEffect = userData.prefixEffect; + userInfo.prefixFont = userData.prefixFont; + userInfo.displayOrder = userData.displayOrder; userInfo.backgroundId = userData.background; userInfo.standId = userData.stand; userInfo.overlayId = userData.overlay; diff --git a/src/api/room/widgets/ChatBubbleMessage.ts b/src/api/room/widgets/ChatBubbleMessage.ts index 3fc6719..2ff145d 100644 --- a/src/api/room/widgets/ChatBubbleMessage.ts +++ b/src/api/room/widgets/ChatBubbleMessage.ts @@ -11,6 +11,16 @@ export class ChatBubbleMessage public prefixColor: string = ''; public prefixIcon: string = ''; public prefixEffect: string = ''; + public prefixFont: string = ''; + public nickIcon: string = ''; + public displayOrder: string = 'icon-prefix-name'; + public originalText: string = ''; + public originalFormattedText: string = ''; + public translatedText: string = ''; + public translatedFormattedText: string = ''; + public showTranslation: boolean = false; + public translationDetectedLanguage: string = ''; + public translationTargetLanguage: string = ''; private _top: number = 0; private _left: number = 0; @@ -30,6 +40,8 @@ export class ChatBubbleMessage ) { this.id = ++ChatBubbleMessage.BUBBLE_COUNTER; + this.originalText = text; + this.originalFormattedText = formattedText; } public get top(): number diff --git a/src/api/utils/GetLocalStorage.ts b/src/api/utils/GetLocalStorage.ts index a4270cf..e82d44b 100644 --- a/src/api/utils/GetLocalStorage.ts +++ b/src/api/utils/GetLocalStorage.ts @@ -2,7 +2,7 @@ export const GetLocalStorage = (key: string) => { try { - JSON.parse(window.localStorage.getItem(key)) as T ?? null; + return JSON.parse(window.localStorage.getItem(key)) as T ?? null; } catch (e) { diff --git a/src/api/utils/LocalStorageKeys.ts b/src/api/utils/LocalStorageKeys.ts index dd74db5..847f3aa 100644 --- a/src/api/utils/LocalStorageKeys.ts +++ b/src/api/utils/LocalStorageKeys.ts @@ -3,4 +3,5 @@ export class LocalStorageKeys public static CATALOG_PLACE_MULTIPLE_OBJECTS: string = 'catalogPlaceMultipleObjects'; public static CATALOG_SKIP_PURCHASE_CONFIRMATION: string = 'catalogSkipPurchaseConfirmation'; public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; + public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings'; } diff --git a/src/api/utils/PrefixUtils.ts b/src/api/utils/PrefixUtils.ts index 5da5133..b57bc3f 100644 --- a/src/api/utils/PrefixUtils.ts +++ b/src/api/utils/PrefixUtils.ts @@ -1,11 +1,41 @@ -export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[] = [ - { id: '', label: 'None', icon: '—' }, - { id: 'glow', label: 'Glow', icon: '✨' }, - { id: 'shadow', label: 'Shadow', icon: '🌑' }, - { id: 'italic', label: 'Italic', icon: '𝑰' }, - { id: 'outline', label: 'Outline', icon: '🔲' }, - { id: 'pulse', label: 'Pulse', icon: '💫' }, - { id: 'bold-glow', label: 'Neon', icon: '💡' }, +export type PrefixFontTier = 'basic' | 'premium'; +export type PrefixFontOption = { + id: string; + label: string; + family: string; + tier: PrefixFontTier; +}; + +export const PRESET_PREFIX_FONTS: PrefixFontOption[] = [ + { id: '', label: 'Default', family: 'Ubuntu, sans-serif', tier: 'basic' }, + { id: 'pixel', label: 'Pixelify Sans', family: '"Pixelify Sans", cursive', tier: 'premium' }, + { id: 'cherry', label: 'Cherry Bomb One', family: '"Cherry Bomb One", cursive', tier: 'premium' }, + { id: 'vampiro', label: 'Vampiro One', family: '"Vampiro One", cursive', tier: 'premium' } +]; + +export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string; tier: 'basic' | 'premium' }[] = [ + { id: '', label: 'None', icon: '-', tier: 'basic' }, + { id: 'glow', label: 'Glow', icon: '*', tier: 'basic' }, + { id: 'shadow', label: 'Shadow', icon: 'S', tier: 'basic' }, + { id: 'italic', label: 'Italic', icon: 'I', tier: 'basic' }, + { id: 'outline', label: 'Outline', icon: 'O', tier: 'basic' }, + { id: 'underline', label: 'Underline', icon: 'U', tier: 'basic' }, + { id: 'pulse', label: 'Pulse', icon: 'P', tier: 'basic' }, + { id: 'bounce', label: 'Bounce', icon: 'B', tier: 'basic' }, + { id: 'wave', label: 'Wave', icon: 'W', tier: 'basic' }, + { id: 'shake', label: 'Shake', icon: '!', tier: 'basic' }, + { id: 'discord-neon', label: 'Discord Neon', icon: 'D', tier: 'premium' }, + { id: 'cartoon', label: 'Cartoon', icon: 'C', tier: 'premium' }, + { id: 'toon', label: 'Toon', icon: 'T', tier: 'premium' }, + { id: 'pop', label: 'Pop', icon: 'P+', tier: 'premium' }, + { id: 'bold-glow', label: 'Neon', icon: 'N', tier: 'premium' }, + { id: 'rainbow', label: 'Rainbow', icon: 'R', tier: 'premium' }, + { id: 'frost', label: 'Frost', icon: 'F', tier: 'premium' }, + { id: 'gold', label: 'Gold Shine', icon: 'G', tier: 'premium' }, + { id: 'glitch', label: 'Glitch', icon: 'X', tier: 'premium' }, + { id: 'fire', label: 'Fire', icon: 'H', tier: 'premium' }, + { id: 'matrix', label: 'Matrix', icon: 'M', tier: 'premium' }, + { id: 'sparkle', label: 'Sparkle', icon: '+', tier: 'premium' } ]; export const parsePrefixColors = (text: string, colorStr: string): string[] => @@ -16,6 +46,15 @@ export const parsePrefixColors = (text: string, colorStr: string): string[] => return [ ...text ].map((_, i) => colors[Math.min(i, colors.length - 1)]); }; +export const getPrefixFontStyle = (font: string): Record => +{ + const option = PRESET_PREFIX_FONTS.find(entry => entry.id === font); + + if(!option || !option.id.length) return {}; + + return { fontFamily: option.family }; +}; + export const getPrefixEffectStyle = (effect: string, color?: string): Record => { const baseColor = color || '#FFFFFF'; @@ -33,13 +72,95 @@ export const getPrefixEffectStyle = (effect: string, color?: string): Record +{ + try + { + const data = JSON.parse(window.localStorage.getItem(REMEMBER_LOGIN_KEY) || 'null') as RememberLoginData | null; + + if(!data?.token?.length && !data?.ssoTicket?.length) return null; + if(data.expiresAt && ((data.expiresAt * 1000) <= Date.now())) + { + ClearRememberLogin(); + return null; + } + + return data; + } + catch + { + try + { + const legacyToken = window.localStorage.getItem(LEGACY_REMEMBER_LOGIN_KEY) || ''; + + if(!legacyToken.length) return null; + + const data: RememberLoginData = { + token: legacyToken, + expiresAt: Math.floor(Date.now() / 1000) + DEFAULT_REMEMBER_SECONDS + }; + + SetRememberLogin(data); + + return data; + } + catch + { + return null; + } + } +}; + +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 {} +}; + +export const ClearRememberLogin = (): void => +{ + try + { + window.localStorage.removeItem(REMEMBER_LOGIN_KEY); + window.localStorage.removeItem(LEGACY_REMEMBER_LOGIN_KEY); + } + catch {} +}; + +export const StoreRememberLoginFromPayload = (payload: Record, username?: string, ssoTicket?: string): void => +{ + const token = typeof payload.rememberToken === 'string' ? payload.rememberToken : ''; + const rawExpiresAt = (payload.rememberExpiresAt ?? payload.expiresAt); + const parsedExpiresAt = typeof rawExpiresAt === 'number' ? rawExpiresAt : Number(rawExpiresAt || 0); + const expiresAt = (Number.isFinite(parsedExpiresAt) && parsedExpiresAt > 0) + ? parsedExpiresAt + : Math.floor(Date.now() / 1000) + DEFAULT_REMEMBER_SECONDS; + + if(!token.length && !ssoTicket?.length) return; + + SetRememberLogin({ token: token || undefined, ssoTicket: ssoTicket || undefined, expiresAt, username }); +}; diff --git a/src/api/utils/RoomChatFormatter.ts b/src/api/utils/RoomChatFormatter.ts index 1ce8a77..949de56 100644 --- a/src/api/utils/RoomChatFormatter.ts +++ b/src/api/utils/RoomChatFormatter.ts @@ -39,11 +39,53 @@ const encodeHTML = (str: string) => }); }; +const formatTag = (content: string, tag: string, replacement: (value: string) => string) => +{ + const pattern = new RegExp(`\\[${ tag }\\]([\\s\\S]*?)\\[\\/${ tag }\\]`, 'gi'); + let previous = ''; + let next = content; + let guard = 0; + + while((previous !== next) && (guard < 20)) + { + previous = next; + next = next.replace(pattern, (match, value) => replacement(value)); + guard++; + } + + return next; +}; + +const applyWiredTextMarkup = (content: string) => +{ + const colorStyles: Record = { + green: '#008000', + cyan: '#008b8b', + red: '#d60000', + blue: '#005dff', + purple: '#7d31b8' + }; + + let result = content; + + result = formatTag(result, 'b', value => `${ value }`); + result = formatTag(result, 'i', value => `${ value }`); + result = formatTag(result, 'u', value => `${ value }`); + + Object.entries(colorStyles).forEach(([ tag, color ]) => + { + result = formatTag(result, tag, value => `${ value }`); + }); + + return result; +}; + export const RoomChatFormatter = (content: string) => { let result = ''; content = encodeHTML(content); + content = applyWiredTextMarkup(content); //content = (joypixels.shortnameToUnicode(content) as string) if(content.startsWith('@') && content.indexOf('@', 1) > -1) @@ -73,5 +115,5 @@ export const RoomChatFormatter = (content: string) => result = content; } - return result; + return result.replace(/\r\n|\r|\n/g, '
'); }; diff --git a/src/api/utils/index.ts b/src/api/utils/index.ts index 1f22e7f..6e19efc 100644 --- a/src/api/utils/index.ts +++ b/src/api/utils/index.ts @@ -14,6 +14,7 @@ export * from './PlaySound'; export * from './PrefixUtils'; export * from './ProductImageUtility'; export * from './Randomizer'; +export * from './RememberLogin'; export * from './RoomChatFormatter'; export * from './SanitizeHtml'; export * from './SetLocalStorage'; diff --git a/src/api/wired/WiredSelectionVisualizer.ts b/src/api/wired/WiredSelectionVisualizer.ts index 06aeb57..dc0b7b3 100644 --- a/src/api/wired/WiredSelectionVisualizer.ts +++ b/src/api/wired/WiredSelectionVisualizer.ts @@ -10,6 +10,10 @@ export class WiredSelectionVisualizer lineColor: [ 0.45, 0.78, 1 ], color: [ 0.20, 0.52, 0.95 ] }); + private static _variableHighlightShader: WiredFilter = new WiredFilter({ + lineColor: [ 0.52, 0.92, 1 ], + color: [ 0.20, 0.70, 1 ] + }); public static show(furniId: number): void { @@ -73,12 +77,37 @@ export class WiredSelectionVisualizer if(roomId < 0) return; - const roomObjects = roomEngine.getRoomObjects(roomId, RoomObjectCategory.FLOOR); + const roomObjects = [ + ...roomEngine.getRoomObjects(roomId, RoomObjectCategory.FLOOR), + ...roomEngine.getRoomObjects(roomId, RoomObjectCategory.WALL), + ...roomEngine.getRoomObjects(roomId, RoomObjectCategory.UNIT) + ]; for(const roomObject of roomObjects) { WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._selectionShader); WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._secondarySelectionShader); + WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._variableHighlightShader); + } + } + + public static applyVariableHighlightToObjects(objects: Array<{ category: number; objectId: number; }>): void + { + for(const object of objects) + { + WiredSelectionVisualizer.applySelectionShader( + WiredSelectionVisualizer.getRoomObjectByCategory(object.objectId, object.category), + WiredSelectionVisualizer._variableHighlightShader); + } + } + + public static clearVariableHighlightFromObjects(objects: Array<{ category: number; objectId: number; }>): void + { + for(const object of objects) + { + WiredSelectionVisualizer.clearSelectionShader( + WiredSelectionVisualizer.getRoomObjectByCategory(object.objectId, object.category), + WiredSelectionVisualizer._variableHighlightShader); } } @@ -89,6 +118,13 @@ export class WiredSelectionVisualizer return roomEngine.getRoomObject(roomEngine.activeRoomId, objectId, RoomObjectCategory.FLOOR); } + private static getRoomObjectByCategory(objectId: number, category: number): IRoomObject + { + const roomEngine = GetRoomEngine(); + + return roomEngine.getRoomObject(roomEngine.activeRoomId, objectId, category); + } + private static applySelectionShader(roomObject: IRoomObject, filter: WiredFilter): void { if(!roomObject) return; diff --git a/src/assets/images/flag_icon/flag_icon_br.png b/src/assets/images/flag_icon/flag_icon_br.png new file mode 100644 index 0000000..9d62a46 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_br.png differ diff --git a/src/assets/images/flag_icon/flag_icon_de.png b/src/assets/images/flag_icon/flag_icon_de.png new file mode 100644 index 0000000..6090d2c Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_de.png differ diff --git a/src/assets/images/flag_icon/flag_icon_en.png b/src/assets/images/flag_icon/flag_icon_en.png new file mode 100644 index 0000000..5d1daa6 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_en.png differ diff --git a/src/assets/images/flag_icon/flag_icon_es.png b/src/assets/images/flag_icon/flag_icon_es.png new file mode 100644 index 0000000..623c8c4 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_es.png differ diff --git a/src/assets/images/flag_icon/flag_icon_fi.png b/src/assets/images/flag_icon/flag_icon_fi.png new file mode 100644 index 0000000..c547fb1 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_fi.png differ diff --git a/src/assets/images/flag_icon/flag_icon_fr.png b/src/assets/images/flag_icon/flag_icon_fr.png new file mode 100644 index 0000000..2f43877 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_fr.png differ diff --git a/src/assets/images/flag_icon/flag_icon_it.png b/src/assets/images/flag_icon/flag_icon_it.png new file mode 100644 index 0000000..0daffbd Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_it.png differ diff --git a/src/assets/images/flag_icon/flag_icon_nl.png b/src/assets/images/flag_icon/flag_icon_nl.png new file mode 100644 index 0000000..548c212 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_nl.png differ diff --git a/src/assets/images/flag_icon/flag_icon_selected.png b/src/assets/images/flag_icon/flag_icon_selected.png new file mode 100644 index 0000000..3aac067 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_selected.png differ diff --git a/src/assets/images/flag_icon/flag_icon_tr.png b/src/assets/images/flag_icon/flag_icon_tr.png new file mode 100644 index 0000000..9cd1c27 Binary files /dev/null and b/src/assets/images/flag_icon/flag_icon_tr.png differ diff --git a/src/assets/images/user_custom/nick_icons/1.gif b/src/assets/images/user_custom/nick_icons/1.gif new file mode 100644 index 0000000..76663d8 Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/1.gif differ diff --git a/src/assets/images/user_custom/nick_icons/2.gif b/src/assets/images/user_custom/nick_icons/2.gif new file mode 100644 index 0000000..1d5a924 Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/2.gif differ diff --git a/src/assets/images/user_custom/nick_icons/3.gif b/src/assets/images/user_custom/nick_icons/3.gif new file mode 100644 index 0000000..57a8bdf Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/3.gif differ diff --git a/src/assets/images/user_custom/nick_icons/4.gif b/src/assets/images/user_custom/nick_icons/4.gif new file mode 100644 index 0000000..bff0af1 Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/4.gif differ diff --git a/src/assets/images/user_custom/nick_icons/5.gif b/src/assets/images/user_custom/nick_icons/5.gif new file mode 100644 index 0000000..f5feefa Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/5.gif differ diff --git a/src/assets/images/user_custom/nick_icons/6.gif b/src/assets/images/user_custom/nick_icons/6.gif new file mode 100644 index 0000000..3a1d07d Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/6.gif differ diff --git a/src/assets/images/user_custom/nick_icons/index.ts b/src/assets/images/user_custom/nick_icons/index.ts new file mode 100644 index 0000000..5881930 --- /dev/null +++ b/src/assets/images/user_custom/nick_icons/index.ts @@ -0,0 +1,19 @@ +const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }) as Record; + +export const NICK_ICON_URLS: Record = Object.entries(rawNickIcons).reduce((accumulator, [ path, url ]) => +{ + const filename = path.split('/').pop() || ''; + const stem = filename.replace(/\.gif$/i, ''); + + if(stem) accumulator[stem] = url; + if(filename) accumulator[filename] = url; + + return accumulator; +}, {} as Record); + +export const GetNickIconUrl = (iconKey: string) => +{ + if(!iconKey) return ''; + + return (NICK_ICON_URLS[iconKey] || NICK_ICON_URLS[iconKey.toLowerCase()] || ''); +}; diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..7513077 --- /dev/null +++ b/src/bootstrap.ts @@ -0,0 +1,75 @@ +import { getClientMode, installSecureFetch, secureUrl } from './secure-assets'; + +installSecureFetch(); + +const setBootDebug = (message: string) => +{ + try + { + (window as any).__nitroBootDebug = message; + const secureNode = document.getElementById('nitro-secure-debug'); + + if(secureNode) secureNode.textContent = `${ secureNode.textContent }\n${ message }`; + } + catch {} +}; + +setBootDebug('boot: secure fetch installed'); + +const loadClientMode = async () => +{ + try + { + if((window as any).__nitroClientMode) return; + + const url = new URL('configuration/client-mode.json', `${ window.location.origin }/`); + url.searchParams.set('v', Date.now().toString(36)); + + const response = await fetch(url.toString()); + + if(!response.ok) throw new Error(`HTTP ${ response.status }`); + + (window as any).__nitroClientMode = await response.json(); + setBootDebug('boot: client-mode loaded'); + } + catch(error) + { + setBootDebug(`boot: client-mode fallback ${ error?.message || error }`); + } +}; + +await loadClientMode(); + +const search = new URLSearchParams(window.location.search); +const clientMode = getClientMode(); +const cacheBustUrl = (path: string): string => +{ + const url = new URL(path.replace(/^\/+/, ''), `${ window.location.origin }/`); + + url.searchParams.set('v', Date.now().toString(36)); + + return url.toString(); +}; + +(window as any).NitroSecureApiUrl = clientMode.apiBaseUrl || window.location.origin; +(window as any).NitroClientMode = clientMode; +(window as any).NitroConfig = { + 'config.urls': [ + clientMode.secureAssetsEnabled ? secureUrl('config', 'renderer-config.json', true) : cacheBustUrl('configuration/renderer-config.json'), + clientMode.secureAssetsEnabled ? secureUrl('config', 'ui-config.json', true) : cacheBustUrl('configuration/ui-config.json') + ], + 'sso.ticket': search.get('sso') || null, + 'forward.type': search.get('room') ? 2 : -1, + 'forward.id': search.get('room') || 0, + 'friend.id': search.get('friend') || 0 +}; + +setBootDebug('boot: NitroConfig assigned'); + +import('./index') + .then(() => setBootDebug('boot: app bundle imported')) + .catch(error => + { + setBootDebug(`boot: import failed ${ error?.message || error }`); + throw error; + }); diff --git a/src/common/UserIdentityView.tsx b/src/common/UserIdentityView.tsx new file mode 100644 index 0000000..fac5e9a --- /dev/null +++ b/src/common/UserIdentityView.tsx @@ -0,0 +1,102 @@ +import { FC, useMemo } from 'react'; +import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons'; +import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../api'; + +interface UserIdentityViewProps +{ + username: string; + nickIcon?: string; + prefixText?: string; + prefixColor?: string; + prefixIcon?: string; + prefixEffect?: string; + prefixFont?: string; + displayOrder?: string; + showColon?: boolean; + className?: string; + iconClassName?: string; + nameClassName?: string; + prefixClassName?: string; +} + +const sanitizeDisplayOrder = (displayOrder?: string) => +{ + const fallback = [ 'icon', 'prefix', 'name' ]; + + if(!displayOrder?.length) return fallback; + + const parts = displayOrder.toLowerCase().split('-'); + + if(parts.length !== 3) return fallback; + + const unique = new Set(parts); + + if(unique.size !== 3) return fallback; + + if(parts.some(part => !fallback.includes(part))) return fallback; + + return parts; +}; + +export const UserIdentityView: FC = ({ + username = '', + nickIcon = '', + prefixText = '', + prefixColor = '', + prefixIcon = '', + prefixEffect = '', + prefixFont = '', + displayOrder = 'icon-prefix-name', + showColon = false, + className = '', + iconClassName = 'inline-block w-auto h-auto align-[-1px]', + nameClassName = 'username font-bold', + prefixClassName = '' +}) => +{ + const nickIconUrl = GetNickIconUrl(nickIcon); + const prefixColors = useMemo(() => parsePrefixColors(prefixText, prefixColor), [ prefixText, prefixColor ]); + const hasMultiColor = (prefixColors.length > 1) && (new Set(prefixColors).size > 1); + const prefixStyle = getPrefixEffectStyle(prefixEffect, prefixColors[0] || '#FFFFFF'); + const prefixFontStyle = getPrefixFontStyle(prefixFont); + const displayParts = sanitizeDisplayOrder(displayOrder); + + const parts = displayParts.map(part => + { + switch(part) + { + case 'icon': + if(!nickIconUrl) return null; + + return ; + case 'prefix': + if(!prefixText?.length) return null; + + return ( + + { prefixIcon && { prefixIcon } } + + {'{'} + { hasMultiColor + ? [ ...prefixText ].map((char, index) => ( + { char } + )) + : prefixText } + {'}'} + + + ); + case 'name': + return { username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }; + default: + return null; + } + }).filter(Boolean); + + return ( + + { !!prefixEffect && } + { parts } + + ); +}; diff --git a/src/common/index.ts b/src/common/index.ts index d8c47ae..6802959 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -11,6 +11,7 @@ export * from './GridContext'; export * from './HorizontalRule'; export * from './InfiniteScroll'; export * from './Text'; +export * from './UserIdentityView'; export * from './card'; export * from './card/accordion'; export * from './card/tabs'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 41a7322..7861454 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -10,6 +10,7 @@ import { CameraWidgetView } from './camera/CameraWidgetView'; import { CampaignView } from './campaign/CampaignView'; import { CatalogView } from './catalog/CatalogView'; import { ChatHistoryView } from './chat-history/ChatHistoryView'; +import { CustomizeNickIconView } from './customize/CustomizeNickIconView'; import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView'; import { FurniEditorView } from './furni-editor/FurniEditorView'; import { FriendsView } from './friends/FriendsView'; @@ -30,6 +31,8 @@ import { GoogleAdsView } from './ads/GoogleAdsView'; import { RightSideView } from './right-side/RightSideView'; import { RoomView } from './room/RoomView'; import { ToolbarView } from './toolbar/ToolbarView'; +import { TranslationBootstrap } from './translation/TranslationBootstrap'; +import { TranslationSettingsView } from './translation/TranslationSettingsView'; import { UserProfileView } from './user-profile/UserProfileView'; import { UserSettingsView } from './user-settings/UserSettingsView'; import { WiredView } from './wired/WiredView'; @@ -39,6 +42,7 @@ 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)); @@ -88,8 +92,18 @@ export const MainView: FC<{}> = props => return () => RemoveLinkEventTracker(linkTracker); }, []); + useEffect(() => + { + const refreshLocalization = () => setLocalizationVersion(value => (value + 1)); + + window.addEventListener('nitro-localization-updated', refreshLocalization); + + return () => window.removeEventListener('nitro-localization-updated', refreshLocalization); + }, []); + return ( <> +
{ landingViewVisible && = props => } + + @@ -117,6 +133,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/ads/GoogleAdsView.tsx b/src/components/ads/GoogleAdsView.tsx index b31574e..4b65295 100644 --- a/src/components/ads/GoogleAdsView.tsx +++ b/src/components/ads/GoogleAdsView.tsx @@ -1,6 +1,7 @@ import { FC, useEffect, useRef, useState } from 'react'; import { GetConfigurationValue } from '../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; +import { configFileUrl } from '../../secure-assets'; interface AdsenseConfig { slot: string; @@ -70,7 +71,7 @@ export const GoogleAdsView: FC<{}> = () => { try { const [ adsTxtRes, configRes ] = await Promise.all([ fetch('/ads.txt', { cache: 'no-cache' }), - fetch('/adsense.json', { cache: 'no-cache' }) + fetch(configFileUrl('adsense.json', true), { cache: 'no-cache' }) ]); if (!adsTxtRes.ok) throw new Error(`ads.txt ${ adsTxtRes.status }`); @@ -156,7 +157,7 @@ export const GoogleAdsView: FC<{}> = () => { data-full-width-responsive={ (config.fullWidthResponsive ?? true) ? 'true' : 'false' } /> } { !loadError && publisherId && config && !config.slot && -
Ad slot not configured in adsense.json
} +
Ad slot not configured in configuration/adsense.json
}
diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index 9e4650f..a7ea8f1 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -1,13 +1,25 @@ import { FC } from 'react'; import { GetConfigurationValue } from '../../api'; +import { useCatalog } from '../../hooks'; import { CatalogClassicView } from './CatalogClassicView'; import { CatalogModernView } from './CatalogModernView'; export const CatalogView: FC<{}> = () => { + const { catalogLocalizationVersion = 0 } = useCatalog(); const useNewStyle = GetConfigurationValue('catalog.style.new', false); - if(useNewStyle) return ; + if(useNewStyle) return ( + <> +
+ + + ); - return ; + return ( + <> +
+ + + ); }; diff --git a/src/components/catalog/views/page/common/CatalogSearchView.tsx b/src/components/catalog/views/page/common/CatalogSearchView.tsx index 295abe3..e2c63cd 100644 --- a/src/components/catalog/views/page/common/CatalogSearchView.tsx +++ b/src/components/catalog/views/page/common/CatalogSearchView.tsx @@ -1,17 +1,24 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaSearch, FaTimes } from 'react-icons/fa'; -import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; +import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; import { useCatalog } from '../../../../../hooks'; export const CatalogSearchView: FC<{}> = () => { const [ searchValue, setSearchValue ] = useState(''); - const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog(); + const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog(); + + const normalizeSearchText = (value: string) => (value || '') + .toLocaleLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .trim(); useEffect(() => { - let search = searchValue?.toLocaleLowerCase().replace(' ', ''); + const search = normalizeSearchText(searchValue); if(!search || !search.length) { @@ -22,7 +29,7 @@ export const CatalogSearchView: FC<{}> = () => const timeout = setTimeout(() => { - if(!offersToNodes || !rootNode) return; + if(!rootNode) return; const furnitureDatas = GetSessionDataManager().getAllFurnitureData(); @@ -39,34 +46,35 @@ export const CatalogSearchView: FC<{}> = () => if((currentType === CatalogType.NORMAL) && furniture.excludeDynamic) continue; - const searchValues = [ furniture.className || '', furniture.name || '', furniture.description || '' ].join(' ').replace(/ /gi, '').toLowerCase(); + const name = normalizeSearchText(furniture.name || ''); + const matchesSearch = name.includes(search); if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1)) { if((furniture.furniLine !== '') && (foundFurniLines.indexOf(furniture.furniLine) < 0)) { - if(searchValues.indexOf(search) >= 0) foundFurniLines.push(furniture.furniLine); + if(matchesSearch) foundFurniLines.push(furniture.furniLine); } } - else + else if(matchesSearch) { - const foundNodes = [ - ...GetOfferNodes(offersToNodes, furniture.purchaseOfferId), - ...GetOfferNodes(offersToNodes, furniture.rentOfferId) - ]; + foundFurniture.push(furniture); - if(foundNodes.length) + if(furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0)) { - if(searchValues.indexOf(search) >= 0) foundFurniture.push(furniture); - - if(foundFurniture.length === 250) break; + foundFurniLines.push(furniture.furniLine); } + + if(foundFurniture.length === 250) break; } } const offers: IPurchasableOffer[] = []; - for(const furniture of foundFurniture) offers.push(new FurnitureOffer(furniture)); + for(const furniture of foundFurniture) + { + offers.push(new FurnitureOffer(furniture)); + } let nodes: ICatalogNode[] = []; @@ -77,7 +85,7 @@ export const CatalogSearchView: FC<{}> = () => }, 300); return () => clearTimeout(timeout); - }, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]); + }, [ currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]); return (
diff --git a/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx index 6554a64..e875791 100644 --- a/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx @@ -1,5 +1,5 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api'; +import { IPurchasableOffer } from '../../../../../api'; import { AutoGrid, AutoGridProps } from '../../../../../common'; import { useCatalog } from '../../../../../hooks'; import { useCatalogAdmin } from '../../../CatalogAdminContext'; @@ -13,7 +13,7 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps export const CatalogItemGridWidgetView: FC = props => { const { columnCount = 5, children = null, ...rest } = props; - const { currentOffer = null, setCurrentOffer = null, currentPage = null, setPurchaseOptions = null } = useCatalog(); + const { currentOffer = null, currentPage = null, selectCatalogOffer = null } = useCatalog(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; const elementRef = useRef(); @@ -29,23 +29,7 @@ export const CatalogItemGridWidgetView: FC = pro const selectOffer = (offer: IPurchasableOffer) => { - offer.activate(); - - if(offer.isLazy) return; - - setCurrentOffer(offer); - - if(offer.product && (offer.product.productType === ProductTypeEnum.WALL)) - { - setPurchaseOptions(prevValue => - { - const newValue = { ...prevValue }; - - newValue.extraData = (offer.product.extraParam || null); - - return newValue; - }); - } + selectCatalogOffer(offer); }; const handleDragStart = useCallback((index: number) => diff --git a/src/components/customize/CustomizeNickIconView.tsx b/src/components/customize/CustomizeNickIconView.tsx new file mode 100644 index 0000000..d91f0fa --- /dev/null +++ b/src/components/customize/CustomizeNickIconView.tsx @@ -0,0 +1,584 @@ +import { AddLinkEventTracker, ILinkEventTracker, PurchaseCatalogPrefixComposer, PurchaseNickIconComposer, PurchasePrefixComposer, RemoveLinkEventTracker, RequestNickIconsComposer, SetActiveNickIconComposer, SetActivePrefixComposer, SetDisplayOrderComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer'; +import data from '@emoji-mart/data'; +import Picker from '@emoji-mart/react'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { INickIconItem, IPrefixItem, PRESET_PREFIX_EFFECTS, PRESET_PREFIX_FONTS, SendMessageComposer, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../../api'; +import { GetNickIconUrl } from '../../assets/images/user_custom/nick_icons'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text, UserIdentityView } from '../../common'; +import { LayoutCurrencyIcon } from '../../common/layout/LayoutCurrencyIcon'; +import { useMessageEvent } from '../../hooks'; + +type CustomizeTab = 'icons' | 'prefix' | 'settings'; +type PrefixSubTab = 'library' | 'custom'; + +interface ICatalogPrefixItem extends IPrefixItem +{ + points: number; + pointsType: number; + owned: boolean; + ownedPrefixId: number; +} + +interface ICombinedPrefixItem extends IPrefixItem +{ + points: number; + pointsType: number; + owned: boolean; + ownedPrefixId: number; +} + +const ORDER_LABELS: Record = { + 'icon-prefix-name': 'Icon / Prefix / Name', + 'prefix-icon-name': 'Prefix / Icon / Name', + 'name-icon-prefix': 'Name / Icon / Prefix', + 'name-prefix-icon': 'Name / Prefix / Icon', + 'icon-name-prefix': 'Icon / Name / Prefix', + 'prefix-name-icon': 'Prefix / Name / Icon' +}; + +const PRESET_COLORS: string[] = [ + '#D62828', '#E85D04', '#F77F00', '#2A9D8F', + '#0077B6', '#4361EE', '#6A4C93', '#C1121F', + '#B5179E', '#3A86FF', '#3F8E00', '#8D5524' +]; + +export const CustomizeNickIconView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const [ activeTab, setActiveTab ] = useState('icons'); + const [ activePrefixSubTab, setActivePrefixSubTab ] = useState('library'); + const [ iconItems, setIconItems ] = useState([]); + const [ prefixItems, setPrefixItems ] = useState([]); + const [ catalogPrefixes, setCatalogPrefixes ] = useState([]); + const [ displayOrder, setDisplayOrder ] = useState('icon-prefix-name'); + const [ customPrefixMaxLength, setCustomPrefixMaxLength ] = useState(15); + const [ customPrefixPriceCredits, setCustomPrefixPriceCredits ] = useState(0); + const [ customPrefixPricePoints, setCustomPrefixPricePoints ] = useState(0); + const [ customPrefixPointsType, setCustomPrefixPointsType ] = useState(0); + const [ customPrefixFontPriceCredits, setCustomPrefixFontPriceCredits ] = useState(0); + const [ customPrefixFontPricePoints, setCustomPrefixFontPricePoints ] = useState(0); + const [ customPrefixFontPointsType, setCustomPrefixFontPointsType ] = useState(0); + const [ customPrefixText, setCustomPrefixText ] = useState(''); + const [ customPrefixColor, setCustomPrefixColor ] = useState('#FFFFFF'); + const [ customPrefixIcon, setCustomPrefixIcon ] = useState(''); + const [ customPrefixEffect, setCustomPrefixEffect ] = useState(''); + const [ customPrefixFont, setCustomPrefixFont ] = useState(''); + const [ showEmojiPicker, setShowEmojiPicker ] = useState(false); + + useMessageEvent(UserNickIconsEvent, event => + { + const parser = event.getParser(); + + setIconItems(parser.nickIcons.map(icon => ({ + id: icon.id, + iconKey: icon.iconKey, + displayName: icon.displayName, + points: icon.points, + pointsType: icon.pointsType, + owned: icon.owned, + active: icon.active + }))); + setPrefixItems(parser.ownedPrefixes.map(prefix => ({ + id: prefix.id, + displayName: prefix.displayName, + text: prefix.text, + color: prefix.color, + icon: prefix.icon || '', + effect: prefix.effect || '', + font: prefix.font || '', + active: prefix.active, + isCustom: prefix.isCustom, + points: prefix.points, + pointsType: prefix.pointsType, + catalogPrefixId: prefix.catalogPrefixId + }))); + setCatalogPrefixes(parser.prefixCatalog.map(prefix => ({ + id: prefix.id, + displayName: prefix.displayName, + text: prefix.text, + color: prefix.color, + icon: prefix.icon || '', + effect: prefix.effect || '', + font: prefix.font || '', + active: prefix.active, + points: prefix.points, + pointsType: prefix.pointsType, + owned: prefix.owned, + ownedPrefixId: prefix.ownedPrefixId + }))); + setDisplayOrder(parser.displayOrder || 'icon-prefix-name'); + setCustomPrefixMaxLength(parser.customPrefixMaxLength || 15); + setCustomPrefixPriceCredits(parser.customPrefixPriceCredits || 0); + setCustomPrefixPricePoints(parser.customPrefixPricePoints || 0); + setCustomPrefixPointsType(parser.customPrefixPointsType || 0); + setCustomPrefixFontPriceCredits(parser.customPrefixFontPriceCredits || 0); + setCustomPrefixFontPricePoints(parser.customPrefixFontPricePoints || 0); + setCustomPrefixFontPointsType(parser.customPrefixFontPointsType || 0); + setIsLoading(false); + }); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(previousValue => !previousValue); + return; + } + }, + eventUrlPrefix: 'customize/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(!isVisible) return; + + setIsLoading(true); + SendMessageComposer(new RequestNickIconsComposer()); + }, [ isVisible ]); + + const activeIcon = useMemo(() => iconItems.find(item => item.active) || null, [ iconItems ]); + const activePrefix = useMemo(() => prefixItems.find(item => item.active) || null, [ prefixItems ]); + const combinedPrefixes = useMemo(() => + { + const ownedByCatalogId = new Map(); + + for(const prefix of prefixItems) + { + if(prefix.catalogPrefixId && (prefix.catalogPrefixId > 0)) ownedByCatalogId.set(prefix.catalogPrefixId, prefix); + } + + const catalogEntries: ICombinedPrefixItem[] = catalogPrefixes.map(prefix => + { + const ownedPrefix = ownedByCatalogId.get(prefix.id); + + return { + id: ownedPrefix?.id || prefix.id, + displayName: ownedPrefix?.displayName || prefix.displayName, + text: ownedPrefix?.text || prefix.text, + color: ownedPrefix?.color || prefix.color, + icon: ownedPrefix?.icon || prefix.icon, + effect: ownedPrefix?.effect || prefix.effect, + font: ownedPrefix?.font || prefix.font, + active: ownedPrefix?.active || prefix.active, + isCustom: false, + points: prefix.points, + pointsType: prefix.pointsType, + catalogPrefixId: prefix.id, + owned: prefix.owned || !!ownedPrefix, + ownedPrefixId: prefix.ownedPrefixId || ownedPrefix?.id || 0 + }; + }); + + const customEntries: ICombinedPrefixItem[] = prefixItems + .filter(prefix => !prefix.catalogPrefixId || (prefix.catalogPrefixId <= 0)) + .map(prefix => ({ + id: prefix.id, + displayName: prefix.displayName, + text: prefix.text, + color: prefix.color, + icon: prefix.icon, + effect: prefix.effect, + font: prefix.font || '', + active: prefix.active, + isCustom: true, + points: prefix.points || customPrefixPricePoints, + pointsType: prefix.pointsType || customPrefixPointsType, + catalogPrefixId: 0, + owned: true, + ownedPrefixId: prefix.id + })); + + return [ ...catalogEntries, ...customEntries ]; + }, [ catalogPrefixes, customPrefixPointsType, customPrefixPricePoints, prefixItems ]); + const selectedEffectOption = useMemo(() => PRESET_PREFIX_EFFECTS.find(effect => effect.id === customPrefixEffect) || PRESET_PREFIX_EFFECTS[0], [ customPrefixEffect ]); + const selectedFontOption = useMemo(() => PRESET_PREFIX_FONTS.find(font => font.id === customPrefixFont) || PRESET_PREFIX_FONTS[0], [ customPrefixFont ]); + const basicEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'basic'), []); + const premiumEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'premium'), []); + const basicFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'basic'), []); + const premiumFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'premium'), []); + const prefixPreviewColors = useMemo(() => parsePrefixColors(customPrefixText || 'Preview', customPrefixColor || '#FFFFFF'), [ customPrefixText, customPrefixColor ]); + const customPrefixPreviewStyle = useMemo(() => getPrefixEffectStyle(customPrefixEffect, prefixPreviewColors[0] || '#FFFFFF'), [ customPrefixEffect, prefixPreviewColors ]); + const customPrefixFontStyle = useMemo(() => getPrefixFontStyle(customPrefixFont), [ customPrefixFont ]); + const customPrefixTotalCredits = useMemo(() => customPrefixPriceCredits + (customPrefixFont ? customPrefixFontPriceCredits : 0), [ customPrefixFont, customPrefixFontPriceCredits, customPrefixPriceCredits ]); + const customPrefixTotalPoints = useMemo(() => customPrefixPricePoints + ((customPrefixFont && (customPrefixFontPointsType === customPrefixPointsType)) ? customPrefixFontPricePoints : 0), [ customPrefixFont, customPrefixFontPointsType, customPrefixFontPricePoints, customPrefixPointsType, customPrefixPricePoints ]); + const customPrefixIsValid = useMemo(() => + { + const trimmed = customPrefixText.trim(); + + if(!trimmed.length || (trimmed.length > customPrefixMaxLength)) return false; + + return customPrefixColor.split(',').every(color => /^#[0-9A-Fa-f]{6}$/.test(color)); + }, [ customPrefixColor, customPrefixMaxLength, customPrefixText ]); + + const refreshCustomizeData = () => + { + setIsLoading(true); + SendMessageComposer(new RequestNickIconsComposer()); + }; + + const handleIconAction = (item: INickIconItem) => + { + setIsLoading(true); + + if(!item.owned) + { + SendMessageComposer(new PurchaseNickIconComposer(item.iconKey)); + return; + } + + SendMessageComposer(new SetActiveNickIconComposer(item.active ? 0 : item.id)); + }; + + const handleCombinedPrefixAction = (item: ICombinedPrefixItem) => + { + setIsLoading(true); + + if(item.owned) + { + SendMessageComposer(new SetActivePrefixComposer(item.active ? 0 : item.ownedPrefixId)); + return; + } + + SendMessageComposer(new PurchaseCatalogPrefixComposer(item.catalogPrefixId || item.id)); + }; + + const handleCustomPrefixPurchase = () => + { + if(!customPrefixIsValid) return; + + setIsLoading(true); + SendMessageComposer(new PurchasePrefixComposer(customPrefixText.trim(), customPrefixColor, customPrefixIcon, customPrefixEffect, customPrefixFont)); + }; + + const handleDisplayOrderChange = (nextDisplayOrder: string) => + { + if(nextDisplayOrder === displayOrder) return; + + setDisplayOrder(nextDisplayOrder); + setIsLoading(true); + SendMessageComposer(new SetDisplayOrderComposer(nextDisplayOrder)); + }; + + if(!isVisible) return null; + + return ( + + setIsVisible(false) } /> + + setActiveTab('icons') }> + Icons + + setActiveTab('prefix') }> + Prefix + + setActiveTab('settings') }> + Settings + + + +
+ Live preview +
+ +
+
+ + { activeTab === 'icons' && + <> +
+ Choose the icon shown in your bubble identity. +
+
+ { iconItems.map(item => + { + const iconUrl = GetNickIconUrl(item.iconKey); + + return ( +
+ { item.active && Active } + { +
+ { item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' } + { item.displayName || `Icon #${ item.iconKey }` } + + + { item.points } + +
+ +
+ ); + }) } +
+ } + + { activeTab === 'prefix' && +
+
+
+ + +
+
+ + { activePrefixSubTab === 'library' && + <> +
+ Choose a preset or custom prefix for your bubble identity. +
+
+ { combinedPrefixes.map(item => ( +
+ { item.active && Active } + +
+ { item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' } + { item.displayName || item.text }{ item.isCustom ? ' - Custom' : '' } + + + { item.points } + +
+ +
+ )) } +
+ } + + { activePrefixSubTab === 'custom' && +
+
+ Custom prefix + +
+
+
+ setCustomPrefixText(event.target.value) } /> + { customPrefixText.length }/{ customPrefixMaxLength } +
+
+ + { !!customPrefixIcon && } +
+
+
+ Safe colors only, chosen to stay readable on both light and dark backgrounds. +
+
+ { PRESET_COLORS.map(color => ( + + )) } +
+
+
+
+ Effect +
+
+ +
+ { selectedEffectOption.icon } { selectedEffectOption.label } +
+ { selectedEffectOption.tier } +
+
+
+
+
+
+ Font +
+
+ +
+ { selectedFontOption.label } +
+ { selectedFontOption.tier } +
+
+
+ { !!customPrefixFont && +
+ Premium fonts add an extra price on top of the custom prefix. +
} +
+
+ +
+
+
+ { customPrefixTotalCredits > 0 && { customPrefixTotalCredits } credits } + { customPrefixTotalPoints > 0 && + + + { customPrefixTotalPoints } + } + { !!customPrefixFont && (customPrefixFontPointsType !== customPrefixPointsType) && (customPrefixFontPricePoints > 0) && + + + { customPrefixFontPricePoints } + } +
+ +
+
+
} +
} + + { activeTab === 'settings' && +
+
+ Display order +
+ { Object.entries(ORDER_LABELS).map(([ key, label ]) => ( + + )) } +
+
+
+
+ Refresh data + +
+
+ Use this tab to control how your icon, prefix and username are ordered in bubbles, profile and infostand. +
+
+
} +
+ { showEmojiPicker && + <> +
setShowEmojiPicker(false) } /> +
+ { setCustomPrefixIcon(emoji.native); setShowEmojiPicker(false); } } + previewPosition="none" + set="native" + theme="dark" /> +
+ } + + ); +}; + diff --git a/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx b/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx index 3237277..b2a6edf 100644 --- a/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx +++ b/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx @@ -13,13 +13,19 @@ interface FriendsRemoveConfirmationViewProps export const FriendsRemoveConfirmationView: FC = props => { const { selectedFriendsIds = null, removeFriendsText = null, removeSelectedFriends = null, onCloseClick = null } = props; + const separatorIndex = removeFriendsText.indexOf(':'); + const removeFriendsLeadText = (separatorIndex >= 0) ? removeFriendsText.substring(0, separatorIndex + 1) : removeFriendsText; + const removeFriendsNamesText = (separatorIndex >= 0) ? removeFriendsText.substring(separatorIndex + 1).trimStart() : ''; return ( - + - -
{ removeFriendsText }
-
+ +
+
{ removeFriendsLeadText }
+ { removeFriendsNamesText.length > 0 &&
{ removeFriendsNamesText }
} +
+
diff --git a/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx b/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx index ad6a94a..cdff603 100644 --- a/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx +++ b/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx @@ -15,13 +15,13 @@ export const FriendsRoomInviteView: FC = props => const [ roomInviteMessage, setRoomInviteMessage ] = useState(''); return ( - + - - { LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) } - - { LocalizeText('friendlist.invite.note') } -
+ + { LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) } + + { LocalizeText('friendlist.invite.note') } +
diff --git a/src/components/friends/views/friends-list/FriendsListSearchView.tsx b/src/components/friends/views/friends-list/FriendsListSearchView.tsx index fd53481..c9a15b8 100644 --- a/src/components/friends/views/friends-list/FriendsListSearchView.tsx +++ b/src/components/friends/views/friends-list/FriendsListSearchView.tsx @@ -1,8 +1,10 @@ import { HabboSearchComposer, HabboSearchResultData, HabboSearchResultEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { LocalizeText, OpenMessengerChat, SendMessageComposer } from '../../../../api'; -import { Column, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common'; +import { Column, LayoutAvatarImageView, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common'; import { useFriends, useMessageEvent } from '../../../../hooks'; +import { resolveAvatarFigure } from './resolveAvatarFigure'; +import { resolveAvatarGender } from './resolveAvatarGender'; interface FriendsSearchViewProps extends NitroCardAccordionSetViewProps { @@ -17,6 +19,22 @@ export const FriendsSearchView: FC = props => const [ otherResults, setOtherResults ] = useState(null); const { canRequestFriend = null, requestFriend = null } = useFriends(); + const getSearchResultFigure = (result: HabboSearchResultData) => + { + if(!result) return null; + + const typedResult = (result as HabboSearchResultData & { figureString?: string; avatarFigure?: string; figure?: string; avatarFigureString?: string }); + + return typedResult.figureString || typedResult.avatarFigure || typedResult.figure || typedResult.avatarFigureString || null; + }; + + const getSearchResultGender = (result: HabboSearchResultData) => + { + const typedResult = (result as HabboSearchResultData & { gender?: string | number; avatarGender?: string | number }); + + return resolveAvatarGender(typedResult.avatarGender ?? typedResult.gender); + }; + useMessageEvent(HabboSearchResultEvent, event => { const parser = event.getParser(); @@ -55,10 +73,15 @@ export const FriendsSearchView: FC = props => { friendResults.map(result => { return ( - -
- -
{ result.avatarName }
+ +
+
+ +
+
+ +
+
{ result.avatarName }
{ result.isAvatarOnline && @@ -82,10 +105,15 @@ export const FriendsSearchView: FC = props => { otherResults.map(result => { return ( - -
- -
{ result.avatarName }
+ +
+
+ +
+
+ +
+
{ result.avatarName }
{ canRequestFriend(result.avatarId) && diff --git a/src/components/friends/views/friends-list/FriendsListView.tsx b/src/components/friends/views/friends-list/FriendsListView.tsx index ef30f23..b9ad497 100644 --- a/src/components/friends/views/friends-list/FriendsListView.tsx +++ b/src/components/friends/views/friends-list/FriendsListView.tsx @@ -34,7 +34,7 @@ export const FriendsListView: FC<{}> = props => userNames.push(existingFriend.name); } - return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join(', ') ]); + return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join('\n') ]); }, [ offlineFriends, onlineFriends, selectedFriendsIds ]); const selectFriend = useCallback((userId: number) => @@ -60,6 +60,27 @@ export const FriendsListView: FC<{}> = props => }); }, [ setSelectedFriendsIds ]); + const toggleSelectFriends = useCallback((friendIds: number[]) => + { + if(!friendIds.length) return; + + setSelectedFriendsIds(prevValue => + { + const allSelected = friendIds.every(friendId => (prevValue.indexOf(friendId) >= 0)); + + if(allSelected) return prevValue.filter(friendId => (friendIds.indexOf(friendId) === -1)); + + const nextValue = [ ...prevValue ]; + + for(const friendId of friendIds) + { + if(nextValue.indexOf(friendId) === -1) nextValue.push(friendId); + } + + return nextValue; + }); + }, []); + const sendRoomInvite = (message: string) => { if(!selectedFriendsIds.length || !message || !message.length || (message.length > 255)) return; @@ -125,10 +146,24 @@ export const FriendsListView: FC<{}> = props => setIsVisible(false) } /> - + + + { event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); } }> + { onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + ? LocalizeText('friendlist.unselect_all') + : LocalizeText('friendlist.select_all') } + + + + { event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); } }> + { offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + ? LocalizeText('friendlist.unselect_all') + : LocalizeText('friendlist.select_all') } + + diff --git a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx index 0b75b86..dc23a3b 100644 --- a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx +++ b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx @@ -1,7 +1,9 @@ import { FC, MouseEvent, useState } from 'react'; import { LocalizeText, MessengerFriend, OpenMessengerChat } from '../../../../../api'; -import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; +import { LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { useFriends } from '../../../../../hooks'; +import { resolveAvatarFigure } from '../resolveAvatarFigure'; +import { resolveAvatarGender } from '../resolveAvatarGender'; export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: boolean, selectFriend: (userId: number) => void }> = props => { @@ -55,14 +57,17 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b if(!friend) return null; return ( - selectFriend(friend.id) }> -
+ selectFriend(friend.id) }> +
+
+ +
event.stopPropagation() }>
-
{ friend.name }
+
{ friend.name }
-
+
{ !isRelationshipOpen && <> { friend.online && diff --git a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx index c06840e..e3ff05b 100644 --- a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx +++ b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx @@ -1,7 +1,9 @@ import { FC } from 'react'; -import { MessengerRequest } from '../../../../../api'; -import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; +import { LocalizeText, MessengerRequest } from '../../../../../api'; +import { Button, LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { useFriends } from '../../../../../hooks'; +import { resolveAvatarFigure } from '../resolveAvatarFigure'; +import { resolveAvatarGender } from '../resolveAvatarGender'; export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = props => { @@ -11,14 +13,23 @@ export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = pro if(!request) return null; return ( - -
- -
{ request.name }
+ +
+
+ +
+
+ +
+
{ request.name }
-
requestResponse(request.id, true) } /> -
requestResponse(request.id, false) } /> + +
); diff --git a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx index 686b32d..eb002c0 100644 --- a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx +++ b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx @@ -17,8 +17,11 @@ export const FriendsListRequestView: FC = props { requests.map((request, index) => ) } -
- +
diff --git a/src/components/friends/views/friends-list/resolveAvatarFigure.ts b/src/components/friends/views/friends-list/resolveAvatarFigure.ts new file mode 100644 index 0000000..98079b1 --- /dev/null +++ b/src/components/friends/views/friends-list/resolveAvatarFigure.ts @@ -0,0 +1,15 @@ +import { resolveAvatarGender } from './resolveAvatarGender'; + +const DEFAULT_AVATAR_FIGURES: Record = { + M: 'hd-180-1.ch-210-66.lg-270-82.sh-290-80', + F: 'hd-600-1.ch-630-66.lg-695-82.sh-725-80' +}; + +export const resolveAvatarFigure = (figure: string | null | undefined, gender?: string | number | null) => +{ + const normalizedFigure = (figure || '').trim(); + + if(normalizedFigure.length && normalizedFigure.includes('hd-')) return normalizedFigure; + + return DEFAULT_AVATAR_FIGURES[resolveAvatarGender(gender)] || DEFAULT_AVATAR_FIGURES.M; +}; diff --git a/src/components/friends/views/friends-list/resolveAvatarGender.ts b/src/components/friends/views/friends-list/resolveAvatarGender.ts new file mode 100644 index 0000000..730e1fe --- /dev/null +++ b/src/components/friends/views/friends-list/resolveAvatarGender.ts @@ -0,0 +1,20 @@ +export const resolveAvatarGender = (value: string | number | null | undefined) => +{ + if(typeof value === 'string') + { + const normalized = value.trim().toUpperCase(); + + if(normalized === 'F') return 'F'; + if(normalized === 'M') return 'M'; + if(normalized === 'FEMALE') return 'F'; + if(normalized === 'MALE') return 'M'; + } + + if(typeof value === 'number') + { + if(value === 2) return 'F'; + if(value === 1) return 'M'; + } + + return 'M'; +}; diff --git a/src/components/friends/views/messenger/FriendsMessengerView.tsx b/src/components/friends/views/messenger/FriendsMessengerView.tsx index bbb5222..6b3e761 100644 --- a/src/components/friends/views/messenger/FriendsMessengerView.tsx +++ b/src/components/friends/views/messenger/FriendsMessengerView.tsx @@ -2,9 +2,8 @@ import { AddLinkEventTracker, FollowFriendMessageComposer, GetSessionDataManager import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; import { FaTimes } from 'react-icons/fa'; import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api'; -import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutGridItem, LayoutItemCountView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useHelp, useMessenger } from '../../../../hooks'; -import { NitroInput } from '../../../../layout'; +import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useHelp, useMessenger, useTranslation } from '../../../../hooks'; import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView'; export const FriendsMessengerView: FC<{}> = props => @@ -14,15 +13,35 @@ export const FriendsMessengerView: FC<{}> = props => const [ messageText, setMessageText ] = useState(''); const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger(); const { report = null } = useHelp(); + const { settings, translateOutgoing } = useTranslation(); const messagesBox = useRef(); const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id))); const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id)); - const send = () => + const send = async () => { if(!activeThread || !messageText.length) return; + const trimmedText = messageText.trimStart(); + const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':'); + + if(!shouldTranslateOutgoing) + { + sendMessage(activeThread, GetSessionDataManager().userId, messageText); + setMessageText(''); + return; + } + + const translation = await translateOutgoing(messageText); + + if(translation && translation.translatedText?.length && (translation.translatedText.length <= 255)) + { + sendMessage(activeThread, GetSessionDataManager().userId, translation.translatedText, 0, null, undefined, translation); + setMessageText(''); + return; + } + sendMessage(activeThread, GetSessionDataManager().userId, messageText); setMessageText(''); @@ -32,7 +51,7 @@ export const FriendsMessengerView: FC<{}> = props => { if(event.key !== 'Enter') return; - send(); + void send(); }; useEffect(() => @@ -107,71 +126,60 @@ export const FriendsMessengerView: FC<{}> = props => if(!isVisible) return null; return ( - + setIsVisible(false) } /> - - - - { LocalizeText('toolbar.icon.label.messenger') } - - - { visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread => - { - return ( - setActiveThreadId(thread.threadId) } className="py-1 px-2"> - { thread.unread && } - - 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure } - headOnly={ true } - direction={ thread.participant.id > 0 ? 2 : 3 } - style={{ width: '50px', height: '80px', backgroundPosition: 'center 45%', flexShrink: 0, alignSelf: 'flex-end' }} - /> - { thread.participant.name } - - - ); - }) } - - - - - { activeThread && - <> - { LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } - + +
+
+ { visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread => + { + return ( + + ); + }) } +
+ + { activeThread && + <> +
+ { LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } +
{ (activeThread.participant.id > 0) && -
-
- - -
- + + -
} - - - - - - - -
- setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> - + + } +
- } - - +
+ +
+ +
+ +
+ setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> + +
+ } +
); diff --git a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx index b9ace6c..97d74bf 100644 --- a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx +++ b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx @@ -28,14 +28,11 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M <> { group.chats.map((chat, index) => { + if(chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) return null; + return ( - { (chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) && - - - { chat.message } - } { (chat.type === MessengerThreadChat.ROOM_INVITE) && @@ -50,24 +47,46 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M } return ( - + { ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) && - } + } { (groupChatData && !isOwnChat) && - } + } - - - { group.chats[0].date.toLocaleTimeString() } + + { isOwnChat && GetSessionDataManager().userName } { !isOwnChat && (groupChatData ? groupChatData.username : thread.participant.name) } + : - { group.chats.map((chat, index) => { chat.message }) } + + { group.chats.map((chat, index) => + { + if(!chat.showTranslation) + { + return { chat.message }; + } + + return ( + + + original: + { chat.originalMessage || chat.message } + + + translate: + { chat.translatedMessage || chat.message } + + + ); + }) } + + { group.chats[0].date.toLocaleTimeString() } { isOwnChat && - + } ); diff --git a/src/components/interface-settings/InterfaceImageTabView.tsx b/src/components/interface-settings/InterfaceImageTabView.tsx index 390a390..1b9860e 100644 --- a/src/components/interface-settings/InterfaceImageTabView.tsx +++ b/src/components/interface-settings/InterfaceImageTabView.tsx @@ -12,7 +12,7 @@ export const InterfaceImageTabView: FC<{}> = () => const baseUrl = useMemo(() => { - return GetConfigurationValue('ui.header.images.url', 'https://image.webbo.city/image/headerImage/image{id}.gif'); + return GetConfigurationValue('ui.header.images.url', ''); }, []); const images = useMemo(() => diff --git a/src/components/inventory/views/prefix/InventoryPrefixView.tsx b/src/components/inventory/views/prefix/InventoryPrefixView.tsx index d959546..f956e13 100644 --- a/src/components/inventory/views/prefix/InventoryPrefixView.tsx +++ b/src/components/inventory/views/prefix/InventoryPrefixView.tsx @@ -1,24 +1,29 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; -import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; -import { useInventoryPrefixes, useNotification } from '../../../../hooks'; +import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, getPrefixFontStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { Button } from '../../../../common'; +import { GetNickIconUrl } from '../../../../assets/images/user_custom/nick_icons'; +import { useInventoryNickIcons, useInventoryPrefixes, useNotification } from '../../../../hooks'; import { NitroButton } from '../../../../layout'; -const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) => +type InventoryIdentityTab = 'prefixes' | 'icons'; + +const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; font?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', font = '', className = '', textSize = 'text-sm' }) => { const colors = parsePrefixColors(text, color); const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF'); + const fontStyle = getPrefixFontStyle(font); return ( - - { effect === 'pulse' && } + + { !!effect && } { icon && { icon } } - + {'{'} { hasMultiColor ? [ ...text ].map((char, i) => ( - { char } + { char } )) : text } @@ -40,7 +45,30 @@ const PrefixItemView: FC<{ ${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' } ${ prefix.active ? 'ring-2 ring-green-400' : '' }` } onClick={ onClick }> - + +
+ ); +}; + +const NickIconItemView: FC<{ + iconKey: string; + displayName: string; + isSelected: boolean; + isActive: boolean; + onClick: () => void; +}> = ({ iconKey, displayName, isSelected, isActive, onClick }) => +{ + return ( +
+ { isActive && Active } +
+ { + { displayName || iconKey } +
); }; @@ -48,8 +76,13 @@ const PrefixItemView: FC<{ export const InventoryPrefixView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); + const [ activeTab, setActiveTab ] = useState('prefixes'); const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes(); + const { nickIcons = [], activeNickIcon = null, selectedNickIcon = null, setSelectedNickIcon = null, activateNickIcon = null, deactivateNickIcon = null, activate: activateNickIcons = null, deactivate: deactivateNickIcons = null } = useInventoryNickIcons(); const { showConfirm = null } = useNotification(); + const hasPrefixes = prefixes && (prefixes.length > 0); + const hasNickIcons = nickIcons && (nickIcons.length > 0); + const selectedIconUrl = useMemo(() => selectedNickIcon ? GetNickIconUrl(selectedNickIcon.iconKey) : '', [ selectedNickIcon ]); const attemptDeletePrefix = () => { @@ -69,10 +102,15 @@ export const InventoryPrefixView: FC<{}> = () => { if(!isVisible) return; - const id = activate(); + const prefixVisibilityId = activate(); + const iconVisibilityId = activateNickIcons(); - return () => deactivate(id); - }, [ isVisible, activate, deactivate ]); + return () => + { + deactivate(prefixVisibilityId); + deactivateNickIcons(iconVisibilityId); + }; + }, [ isVisible, activate, activateNickIcons, deactivate, deactivateNickIcons ]); useEffect(() => { @@ -82,55 +120,115 @@ export const InventoryPrefixView: FC<{}> = () => }, []); return ( -
-
-
- { prefixes.map(prefix => ( - setSelectedPrefix(prefix) } /> - )) } +
+
+
+ +
- { (!prefixes || prefixes.length === 0) && -
- { LocalizeText('inventory.empty.title') } -
}
-
- { activePrefix && -
- Active prefix -
- + + { activeTab === 'prefixes' && +
+
+
+ { prefixes.map(prefix => ( + setSelectedPrefix(prefix) } /> + )) }
-
} - { !activePrefix && -
- Active prefix -
- No active prefix + { !hasPrefixes && +
+ { LocalizeText('inventory.empty.title') } +
} +
+
+ { activePrefix && +
+ Active prefix +
+ +
+
} + { !activePrefix && +
+ Active prefix +
+ No active prefix +
+
} + { !!selectedPrefix && +
+
+ +
+
+ selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }> + { selectedPrefix.active ? 'Deactivate' : 'Activate' } + + { !selectedPrefix.active && + + + } +
+
} +
+
} + + { activeTab === 'icons' && +
+
+
+ { nickIcons.map(icon => ( + setSelectedNickIcon(icon) } /> + )) }
-
} - { !!selectedPrefix && -
-
- + { !hasNickIcons && +
+ No purchased icons yet +
} +
+
+
+ Active icon +
+ { activeNickIcon && { } + { !activeNickIcon && No active icon } +
-
- selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }> - { selectedPrefix.active ? 'Deactivate' : 'Activate' } - - { !selectedPrefix.active && - - - } -
-
} -
+ { !!selectedNickIcon && +
+
+ { + { selectedNickIcon.displayName || selectedNickIcon.iconKey } +
+ +
} +
+
}
); }; diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx index c8bb131..c4447f0 100644 --- a/src/components/loading/LoadingView.tsx +++ b/src/components/loading/LoadingView.tsx @@ -1,4 +1,5 @@ import { FC } from 'react'; +import loadingGif from '@/assets/images/loading/loading.gif'; import { Base, Column, Text } from '../../common'; interface LoadingViewProps { @@ -11,11 +12,9 @@ export const LoadingView: FC = props => { const { isError = false, message = '', homeUrl = '' } = props; return ( - + - { !isError && - } { isError && (message && message.length) ? @@ -32,12 +31,18 @@ export const LoadingView: FC = props => { } : - - The hotel is loading ... - + + + { message && message.length ? + + { message } + + : null + } + } ); -}; \ No newline at end of file +}; diff --git a/src/components/login/LoginView.tsx b/src/components/login/LoginView.tsx index 83a8c31..2da9120 100644 --- a/src/components/login/LoginView.tsx +++ b/src/components/login/LoginView.tsx @@ -1,38 +1,219 @@ +import { GetConfiguration } from '@nitrots/nitro-renderer'; import { FC, FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { GetConfigurationValue, persistAccessTokenFromPayload } from '../../api'; -import { ForgotDialog } from './components/ForgotDialog'; +import { ClearRememberLogin, GetConfigurationValue, GetRememberLogin, StoreRememberLoginFromPayload } from '../../api'; +import { configFileUrl } from '../../secure-assets'; +import flagBr from '../../assets/images/flag_icon/flag_icon_br.png'; +import flagDe from '../../assets/images/flag_icon/flag_icon_de.png'; +import flagEn from '../../assets/images/flag_icon/flag_icon_en.png'; +import flagEs from '../../assets/images/flag_icon/flag_icon_es.png'; +import flagFi from '../../assets/images/flag_icon/flag_icon_fi.png'; +import flagFr from '../../assets/images/flag_icon/flag_icon_fr.png'; +import flagIt from '../../assets/images/flag_icon/flag_icon_it.png'; +import flagNl from '../../assets/images/flag_icon/flag_icon_nl.png'; +import flagSelected from '../../assets/images/flag_icon/flag_icon_selected.png'; +import flagTr from '../../assets/images/flag_icon/flag_icon_tr.png'; +import { applyTextTranslationLocale } from '../../hooks/translation/useTranslation'; import { NewsWindow } from './components/NewsWindow'; -import { RegisterDialog } from './components/RegisterDialog'; import { TurnstileWidget } from './TurnstileWidget'; -import { BanInfo, formatRemaining, parseBan } from './utils/ban'; -import { interpolate, t } from './utils/i18n'; -import { LOCK_DURATION_MS, LOCK_WINDOW_MS, MAX_ATTEMPTS, readLock, writeLock } from './utils/lockState'; type DialogMode = 'login' | 'register' | 'forgot'; +type LoginLocale = { code: string; file: string; label: string; flag: string }; + +const interpolate = (value: string | null | undefined): string => +{ + if(!value) return ''; + + let output = value; + + try { output = GetConfiguration().interpolate(value) || value; } + catch {} + + return output.replace(/\$\{([^}]+)\}/g, (_, key: string) => + { + if(key === 'api.url' && typeof (window as any).NitroSecureApiUrl === 'string') + { + const secureApiUrl = (window as any).NitroSecureApiUrl.replace(/\/$/, ''); + + if(secureApiUrl) return secureApiUrl; + } + + try + { + const configValue = GetConfiguration().getValue(key, ''); + + if(configValue) return configValue; + } + catch {} + + try + { + const configValue = GetConfigurationValue(key, ''); + + if(configValue) return configValue; + } + catch {} + + return ''; + }); +}; + +const LOCK_KEY = 'nitro.login.lock'; +const CHAT_TRANSLATION_SETTINGS_KEY = 'chatTranslationSettings'; +const MAX_ATTEMPTS = 5; +const LOCK_WINDOW_MS = 60_000; +const LOCK_DURATION_MS = 2 * 60_000; +const getDefaultLoginImages = (): Record => +{ + const imagesBase = (GetConfigurationValue('images.url', '') || '').replace(/\/$/, ''); + + if(!imagesBase.length) return { 'background.colour': '#6eadc8' }; + + return { + background: `${ imagesBase }/reception/background_gradient_apr25.png`, + 'background.colour': '#6eadc8', + drape: `${ imagesBase }/reception/drape.png`, + left: `${ imagesBase }/reception/mute_reception_backdrop_left.png`, + right: `${ imagesBase }/reception/background_right.png` + }; +}; +const LOGIN_LOCALES: LoginLocale[] = [ + { code: 'it', file: 'it', label: 'Italiano', flag: flagIt }, + { code: 'en', file: 'com', label: 'English', flag: flagEn }, + { code: 'es', file: 'es', label: 'Español', flag: flagEs }, + { code: 'fr', file: 'fr', label: 'Français', flag: flagFr }, + { code: 'de', file: 'de', label: 'Deutsch', flag: flagDe }, + { code: 'pt-BR', file: 'br', label: 'Português', flag: flagBr }, + { code: 'nl', file: 'nl', label: 'Nederlands', flag: flagNl }, + { code: 'fi', file: 'fi', label: 'Suomi', flag: flagFi }, + { code: 'tr', file: 'tr', label: 'Türkçe', flag: flagTr } +]; + +type AttemptState = { attempts: number; firstAt: number; lockedUntil: number }; + +const readLock = (): AttemptState => +{ + try + { + const raw = sessionStorage.getItem(LOCK_KEY); + if(!raw) return { attempts: 0, firstAt: 0, lockedUntil: 0 }; + return JSON.parse(raw); + } + catch { return { attempts: 0, firstAt: 0, lockedUntil: 0 }; } +}; + +const writeLock = (state: AttemptState) => +{ + try { sessionStorage.setItem(LOCK_KEY, JSON.stringify(state)); } + catch { } +}; + +const normalizeLanguageCode = (value: string): string => +{ + if(!value) return ''; + + const normalized = value.trim().replace('_', '-'); + const parts = normalized.split('-'); + + if(parts.length === 1) return parts[0].toLowerCase(); + + return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`; +}; + +const resolveLoginLocale = (value: string): LoginLocale => +{ + const normalized = normalizeLanguageCode(value); + const exactMatch = LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code) === normalized); + + if(exactMatch) return exactMatch; + + const base = normalized.split('-')[0]; + + if(base === 'pt') return LOGIN_LOCALES.find(locale => locale.file === 'br') || LOGIN_LOCALES[0]; + + return LOGIN_LOCALES.find(locale => normalizeLanguageCode(locale.code).split('-')[0] === base) || LOGIN_LOCALES[0]; +}; + +const getBrowserLocale = (): LoginLocale => +{ + if(typeof navigator === 'undefined') return LOGIN_LOCALES[0]; + + return resolveLoginLocale(navigator.language || navigator.languages?.[0] || 'it'); +}; + +const readCachedLocale = (): LoginLocale => +{ + try + { + const settings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}'); + + if(typeof settings.uiTextLanguage === 'string' && settings.uiTextLanguage.length) return resolveLoginLocale(settings.uiTextLanguage); + } + catch {} + + return getBrowserLocale(); +}; + +const applyLocaleSelection = (locale: LoginLocale): void => +{ + try + { + const previousSettings = JSON.parse(localStorage.getItem(CHAT_TRANSLATION_SETTINGS_KEY) || '{}'); + const nextSettings = { + enabled: previousSettings.enabled ?? false, + incomingTargetLanguage: previousSettings.incomingTargetLanguage || locale.code, + outgoingTargetLanguage: previousSettings.outgoingTargetLanguage || locale.code, + ...previousSettings, + uiTextLanguage: locale.code + }; + + localStorage.setItem(CHAT_TRANSLATION_SETTINGS_KEY, JSON.stringify(nextSettings)); + } + catch {} +}; export interface LoginViewProps { onAuthenticated: (ssoTicket: string) => void; + isEntering?: boolean; } -export const LoginView: FC = ({ onAuthenticated }) => +export const LoginView: FC = ({ onAuthenticated, isEntering = false }) => { const [ mode, setMode ] = useState('login'); const [ username, setUsername ] = useState(''); const [ password, setPassword ] = useState(''); - const [ rememberMe, setRememberMe ] = useState(false); const [ error, setError ] = useState(null); - const [ banInfo, setBanInfo ] = useState(null); - const [ , setBanTick ] = useState(0); const [ info, setInfo ] = useState(null); const [ submitting, setSubmitting ] = useState(false); const [ loginTurnstileToken, setLoginTurnstileToken ] = useState(''); const [ loginTurnstileResetSignal, setLoginTurnstileResetSignal ] = useState(0); const [ loginServerReachable, setLoginServerReachable ] = useState(null); const [ loginPingingServer, setLoginPingingServer ] = useState(false); + const [ rememberMe, setRememberMe ] = useState(() => !!GetRememberLogin()); + const [ selectedLocale, setSelectedLocale ] = useState(() => readCachedLocale()); + const [ localeApplying, setLocaleApplying ] = useState(false); + const [ localeError, setLocaleError ] = useState(''); + const [ loginViewConfig, setLoginViewConfig ] = useState>(() => GetConfigurationValue>('loginview', {})); const submitTimeRef = useRef(0); - const loginImages: Record = ((GetConfigurationValue>('loginview', {})?.['images']) as Record) ?? {}; + const configuredLoginImages: Record = (loginViewConfig?.['images'] as Record) ?? {}; + const loginImages: Record = { ...getDefaultLoginImages(), ...configuredLoginImages }; + + const configuredLoginWidgets: Record = (loginViewConfig?.['widgets'] as Record) ?? {}; + const loginWidgetSlots = useMemo(() => + { + return Object.entries(configuredLoginWidgets) + .filter(([ key, value ]) => key.startsWith('slot.') && key.endsWith('.widget') && typeof value === 'string' && value.length > 0) + .map(([ key, value ]) => + { + const slotNum = key.match(/\d+/)?.[0] ?? ''; + const conf = configuredLoginWidgets[`slot.${ slotNum }.conf`] as Record ?? {}; + + return { key, slotNum: Number(slotNum), type: value as string, conf }; + }) + .filter(slot => slot.slotNum > 0) + .sort((a, b) => a.slotNum - b.slotNum); + }, [ configuredLoginWidgets ]); const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue('login_background.colour', '#6eadc8')); const background = interpolate(loginImages['background'] || GetConfigurationValue('login_background', '')); @@ -41,10 +222,15 @@ export const LoginView: FC = ({ onAuthenticated }) => const left = interpolate(loginImages['left'] || GetConfigurationValue('login_left', '')); const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue('login_right.repeat', '')); const right = interpolate(loginImages['right'] || GetConfigurationValue('login_right', '')); + const widgetImageUrls = useMemo(() => loginWidgetSlots + .map(slot => typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : '') + .filter(Boolean), [ loginWidgetSlots ]); + const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right, ...widgetImageUrls ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right, widgetImageUrls ]); + const [ loginImagesVersion, setLoginImagesVersion ] = useState(0); const loginUrl = GetConfigurationValue('login.endpoint', '/api/auth/login'); const registerUrl = GetConfigurationValue('login.register.endpoint', '/api/auth/register'); - const roomTemplatesUrl = GetConfigurationValue('login.room_templates.endpoint', '/api/auth/room-templates'); const forgotUrl = GetConfigurationValue('login.forgot.endpoint', '/api/auth/forgot-password'); + const newsUrl = interpolate(GetConfigurationValue('login.news.url', '')); const turnstileSiteKey = GetConfigurationValue('login.turnstile.sitekey', ''); const rawTurnstileEnabled = GetConfigurationValue('login.turnstile.enabled', false); const turnstileEnabled = (rawTurnstileEnabled === true @@ -61,24 +247,88 @@ export const LoginView: FC = ({ onAuthenticated }) => useEffect(() => { setError(null); - setBanInfo(null); if(mode === 'login') resetLoginTurnstile(); }, [ mode, resetLoginTurnstile ]); useEffect(() => { - if(!banInfo || banInfo.permanent || !banInfo.expiresAt) return; - const interval = window.setInterval(() => + let cancelled = false; + + const refreshLoginViewConfig = () => { - if(banInfo.expiresAt && banInfo.expiresAt <= Math.floor(Date.now() / 1000)) + if(cancelled) return; + + const nextConfig = GetConfigurationValue>('loginview', {}); + + setLoginViewConfig(previousConfig => { - setBanInfo(null); - return; - } - setBanTick(t => t + 1); - }, 1000); - return () => window.clearInterval(interval); - }, [ banInfo ]); + try + { + return JSON.stringify(previousConfig) === JSON.stringify(nextConfig) ? previousConfig : nextConfig; + } + catch + { + return nextConfig; + } + }); + }; + + refreshLoginViewConfig(); + + const timers = [ 50, 150, 300, 600, 1000, 2000 ].map(delay => window.setTimeout(refreshLoginViewConfig, delay)); + + return () => + { + cancelled = true; + timers.forEach(timer => window.clearTimeout(timer)); + }; + }, []); + + const confirmLocaleSelection = useCallback(async () => + { + if(localeApplying) return; + + setLocaleApplying(true); + setLocaleError(''); + + try + { + applyLocaleSelection(selectedLocale); + await applyTextTranslationLocale(selectedLocale.code); + } + catch + { + setLocaleError('Unable to load this language pack.'); + } + finally + { + setLocaleApplying(false); + } + }, [ localeApplying, selectedLocale ]); + + useEffect(() => + { + if(!loginImageUrls.length) return; + + let cancelled = false; + + loginImageUrls.forEach(url => + { + const image = new Image(); + + image.onload = image.onerror = () => + { + if(!cancelled) setLoginImagesVersion(version => version + 1); + }; + + image.src = url; + }); + + return () => + { + cancelled = true; + }; + }, [ loginImageUrls ]); useEffect(() => { @@ -132,7 +382,7 @@ export const LoginView: FC = ({ onAuthenticated }) => return { ok: response.ok, status: response.status, payload }; }, []); - const healthUrl = GetConfigurationValue('login.health.endpoint', '/api/health'); + const healthUrl = GetConfigurationValue('login.health.endpoint', ''); const healthMethodRaw = GetConfigurationValue('login.health.method', 'GET'); const healthMethod = (healthMethodRaw || 'GET').toUpperCase(); const checkServerReachable = useCallback(async (): Promise => @@ -175,22 +425,11 @@ export const LoginView: FC = ({ onAuthenticated }) => } }, [ checkServerReachable ]); - useEffect(() => - { - let cancelled = false; - (async () => - { - const ok = await checkServerReachable(); - if(!cancelled) setLoginServerReachable(ok); - })(); - return () => { cancelled = true; }; - }, [ checkServerReachable ]); - const handleLoginSubmit = useCallback(async (event: FormEvent) => { event.preventDefault(); - if(submitting) return; + if(submitting || isEntering) return; const nowTs = Date.now(); if(nowTs - submitTimeRef.current < 1000) return; @@ -200,34 +439,27 @@ export const LoginView: FC = ({ onAuthenticated }) => if(state.lockedUntil > nowTs) { const remaining = Math.ceil((state.lockedUntil - nowTs) / 1000); - setError(t('nitro.login.error.too_many_attempts', 'Too many attempts. Try again in %seconds%s.', [ 'seconds' ], [ String(remaining) ])); + setError(`Too many attempts. Try again in ${ remaining }s.`); return; } if(!username.trim() || !password) { - setError(t('nitro.login.error.missing_credentials', 'Please enter both your Habbo name and password.')); + setError('Please enter both your Habbo name and password.'); return; } if(turnstileEnabled && !loginTurnstileToken) { - setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); + setError('Please complete the security check.'); return; } setError(null); - setBanInfo(null); setSubmitting(true); try { - const serverOk = await pingLoginServer(); - if(!serverOk) - { - setError(t('nitro.login.error.server_offline', 'The gameserver is not running. Please try again later.')); - return; - } const { ok, payload } = await postJson(loginUrl, { username: username.trim(), password, @@ -239,51 +471,33 @@ export const LoginView: FC = ({ onAuthenticated }) => if(ok && ssoTicket) { - try - { - const rememberToken = typeof payload.rememberToken === 'string' ? payload.rememberToken : ''; - if(rememberMe && rememberToken) window.localStorage.setItem('nitro.remember.token', rememberToken); - else window.localStorage.removeItem('nitro.remember.token'); - persistAccessTokenFromPayload(payload); - } - catch {} - clearLock(); + if(rememberMe) StoreRememberLoginFromPayload(payload, typeof payload.username === 'string' ? payload.username : username.trim(), ssoTicket); + else ClearRememberLogin(); onAuthenticated(ssoTicket); return; } - const ban = parseBan(payload); - if(ban) - { - setBanInfo(ban); - resetLoginTurnstile(); - return; - } - recordFailure(); - const message = typeof payload.error === 'string' ? payload.error : t('nitro.login.error.invalid_credentials', 'Invalid Habbo name or password.'); + const message = typeof payload.error === 'string' ? payload.error : 'Invalid Habbo name or password.'; setError(message); resetLoginTurnstile(); } catch(err) { recordFailure(); - setError(t('nitro.login.error.login_unreachable', 'Unable to reach the login service. Please try again.')); + setError('Unable to reach the login service. Please try again.'); resetLoginTurnstile(); } finally { setSubmitting(false); } - }, [ submitting, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); - - const newsUrl = GetConfigurationValue('login.news.endpoint', '/api/auth/news'); + }, [ submitting, isEntering, username, password, rememberMe, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]); const checkEmailUrl = GetConfigurationValue('login.check-email.endpoint', '/api/auth/check-email'); const checkUsernameUrl = GetConfigurationValue('login.check-username.endpoint', '/api/auth/check-username'); - const imagingUrl = GetConfigurationValue('login.register.imaging.url', 'https://www.habbo.com/habbo-imaging/avatarimage?figure={figure}&gender={gender}&direction=2&head_direction=2&size=l'); - + const imagingUrl = GetConfigurationValue('login.register.imaging.url', ''); const interpretAvailability = (ok: boolean, status: number, payload: Record): { available: boolean; error?: string } => { const isTrue = (v: unknown) => v === true || v === 'true' || v === 1 || v === '1'; @@ -309,7 +523,7 @@ export const LoginView: FC = ({ onAuthenticated }) => const { ok, status, payload } = await postJson(checkEmailUrl, { email }); const result = interpretAvailability(ok, status, payload); if(result.available) return { available: true }; - return { available: false, error: result.error || t('nitro.login.error.email_taken', 'This email is already in use.') }; + return { available: false, error: result.error || 'This email is already in use.' }; } catch { @@ -324,7 +538,7 @@ export const LoginView: FC = ({ onAuthenticated }) => const { ok, status, payload } = await postJson(checkUsernameUrl, { username }); const result = interpretAvailability(ok, status, payload); if(result.available) return { available: true }; - return { available: false, error: result.error || t('nitro.login.error.username_taken', 'This Habbo name is already taken.') }; + return { available: false, error: result.error || 'This Habbo name is already taken.' }; } catch { @@ -332,11 +546,11 @@ export const LoginView: FC = ({ onAuthenticated }) => } }, [ checkUsernameUrl, postJson ]); - const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; templateId: number | null; }, onDialogReset: () => void) => + const handleRegisterSubmit = useCallback(async (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => { if(turnstileEnabled && !body.turnstileToken) { - setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); + setError('Please complete the security check.'); return; } @@ -352,13 +566,12 @@ export const LoginView: FC = ({ onAuthenticated }) => password: body.password, figure: body.figure, gender: body.gender, - templateId: body.templateId ?? undefined, turnstileToken: turnstileEnabled ? body.turnstileToken : undefined }); if(ok) { - const friendly = t('nitro.login.register.success', 'Welcome aboard, %username%! Your account is ready — log in below with the password you just chose.', [ 'username' ], [ body.username ]); + const friendly = `Welcome aboard, ${ body.username }! Your account is ready — log in below with the password you just chose.`; setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); setUsername(body.username); @@ -366,12 +579,12 @@ export const LoginView: FC = ({ onAuthenticated }) => return; } - setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.register_failed', 'Unable to create your account.')); + setError(typeof payload.error === 'string' ? payload.error : 'Unable to create your account.'); onDialogReset(); } catch { - setError(t('nitro.login.error.register_unreachable', 'Unable to reach the registration service.')); + setError('Unable to reach the registration service.'); onDialogReset(); } finally @@ -384,7 +597,7 @@ export const LoginView: FC = ({ onAuthenticated }) => { if(turnstileEnabled && !body.turnstileToken) { - setError(t('nitro.login.error.turnstile', 'Please complete the security check.')); + setError('Please complete the security check.'); return; } @@ -401,18 +614,18 @@ export const LoginView: FC = ({ onAuthenticated }) => if(ok) { - const friendly = t('nitro.login.forgot.success', 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).'); + const friendly = 'Email sent! If an account matches that address you\'ll find a reset link in your inbox shortly (check spam if it doesn\'t show up within a minute).'; setInfo(typeof payload.message === 'string' ? payload.message : friendly); setMode('login'); return; } - setError(typeof payload.error === 'string' ? payload.error : t('nitro.login.error.forgot_failed', 'Unable to send a reset email right now.')); + setError(typeof payload.error === 'string' ? payload.error : 'Unable to send a reset email right now.'); onDialogReset(); } catch { - setError(t('nitro.login.error.forgot_unreachable', 'Unable to reach the password reset service.')); + setError('Unable to reach the password reset service.'); onDialogReset(); } finally @@ -426,29 +639,86 @@ export const LoginView: FC = ({ onAuthenticated }) => className="nitro-login-view" style={ backgroundColor ? { background: backgroundColor } : undefined } > - { background ?
: null } - { sun ?
: null } - { drape ?
: null } - { left ?
: null } + { background ? : null } + { sun ? : null } + { drape ? : null } + { left ? : null } { rightRepeat ?
: null } - { right ?
: null } + { right ? : null } + - + { loginWidgetSlots.length > 0 && +
+ { loginWidgetSlots.map(slot => + { + const image = typeof slot.conf.image === 'string' ? interpolate(slot.conf.image) : ''; + const texts = typeof slot.conf.texts === 'string' ? slot.conf.texts : ''; + const btnText = typeof slot.conf.btnText === 'string' ? slot.conf.btnText : ''; + const btnLink = typeof slot.conf.btnLink === 'string' ? interpolate(slot.conf.btnLink) : ''; + const title = typeof slot.conf.title === 'string' ? slot.conf.title : (texts || slot.type); + const description = typeof slot.conf.description === 'string' ? slot.conf.description : ''; + + return ( +
+ { image && } +
+
{ title }
+ { description &&
{ description }
} + { btnText && + } +
+
+ ); + }) } +
} + + { newsUrl && }
+
+
Choose your language
+
+ { LOGIN_LOCALES.map(locale => + ) } +
+ { localeError.length > 0 &&
{ localeError }
} + +
+
-
{ t('nitro.login.firsttime.title', 'First time here?') }
+
First time here?
- { t('nitro.login.firsttime.text', 'Don\'t have a Habbo yet?') } - setMode('register') }>{ t('nitro.login.firsttime.link', 'You can create one here') } + Don't have a Habbo yet? + setMode('register') }>You can create one here
-
{ t('nitro.login.card.title', 'What\'s your Habbo called?') }
+
What's your Habbo called?
- + = ({ onAuthenticated }) => />
- + = ({ onAuthenticated }) => onChange={ e => setPassword(e.target.value) } />
-
+
{ mode === 'register' && @@ -530,7 +789,6 @@ export const LoginView: FC = ({ onAuthenticated }) => onCheckUsername={ checkUsernameAvailable } onCheckServer={ checkServerReachable } imagingUrl={ imagingUrl } - roomTemplatesUrl={ roomTemplatesUrl } submitting={ submitting } error={ error } info={ info } @@ -551,3 +809,700 @@ export const LoginView: FC = ({ onAuthenticated }) =>
); }; + +interface DialogSharedProps +{ + onCancel: () => void; + submitting: boolean; + error: string | null; + info: string | null; + turnstileEnabled: boolean; + turnstileSiteKey: string; +} + +interface RegisterDialogProps extends DialogSharedProps +{ + onSubmit: (body: { username: string; email: string; password: string; figure: string; gender: string; turnstileToken: string; }, onDialogReset: () => void) => void; + onCheckEmail: (email: string) => Promise<{ available: boolean; error?: string }>; + onCheckUsername: (username: string) => Promise<{ available: boolean; error?: string }>; + onCheckServer: () => Promise; + imagingUrl: string; +} + +type RegisterStep = 'credentials' | 'avatar'; + +const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +type GenderKey = 'M' | 'F'; + +const PART_ROWS: string[] = [ 'hr', 'hd', 'ch', 'lg', 'sh' ]; + +const FALLBACK_DEFAULTS: Record> = { + M: { + hr: { partId: 180, colors: [ 45 ] }, + hd: { partId: 180, colors: [ 1 ] }, + ch: { partId: 215, colors: [ 66 ] }, + lg: { partId: 270, colors: [ 82 ] }, + sh: { partId: 290, colors: [ 80 ] } + }, + F: { + hr: { partId: 515, colors: [ 45 ] }, + hd: { partId: 600, colors: [ 1 ] }, + ch: { partId: 660, colors: [ 100 ] }, + lg: { partId: 716, colors: [ 82 ] }, + sh: { partId: 725, colors: [ 61 ] } + } +}; + +const FALLBACK_HEX: Record = { + 1: '#ffcb98', 8: '#f4ac54', 14: '#f5da88', 19: '#b87560', 20: '#9c543f', + 45: '#e8c498', 61: '#f1ece3', 66: '#96743d', 80: '#4f4d4d', 82: '#7f4f30', + 92: '#ececec', 100: '#c7ddff', 106: '#c6e6bd', 110: '#91a7c8', 143: '#ffffff' +}; + +interface FigureColor { id: number; hexCode: string; club: number; selectable: boolean; } +interface FigurePalette { id: number; colors: FigureColor[]; } +interface FigureSet { id: number; gender: 'M' | 'F' | 'U'; club: number; selectable: boolean; } +interface FigureSetType { type: string; paletteId: number; sets: FigureSet[]; } +interface FigureData { palettes: FigurePalette[]; setTypes: FigureSetType[]; } + +interface PartSelection { partId: number; colors: number[]; } +type FigureSelection = Record; + +const buildFigureString = (selection: FigureSelection): string => +{ + const seen = new Set(); + const parts: string[] = []; + const push = (setType: string) => + { + if(seen.has(setType)) return; + seen.add(setType); + const sel = selection[setType]; + if(!sel || sel.partId < 0) return; + const tail = (sel.colors && sel.colors.length) ? `-${ sel.colors.join('-') }` : ''; + parts.push(`${ setType }-${ sel.partId }${ tail }`); + }; + for(const setType of PART_ROWS) push(setType); + for(const setType of Object.keys(selection)) push(setType); + return parts.join('.'); +}; + +const buildImagingUrl = (template: string, figure: string, gender: GenderKey): string => + template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + +const HEAD_ONLY_PARTS = new Set([ 'hr', 'hd' ]); + +const buildPartPreviewUrl = ( + template: string, + setType: string, + selection: FigureSelection, + gender: GenderKey +): string => +{ + const defaults = FALLBACK_DEFAULTS[gender]; + const partSel = selection[setType] ?? defaults[setType]; + const tail = (partSel.colors && partSel.colors.length) ? `-${ partSel.colors.join('-') }` : ''; + const isHeadOnly = HEAD_ONLY_PARTS.has(setType); + + let parts: string[]; + if(isHeadOnly) + { + const hd = defaults.hd; + const pieces = new Map(); + pieces.set('hd', `hd-${ hd.partId }-${ hd.colors.join('-') }`); + pieces.set(setType, `${ setType }-${ partSel.partId }${ tail }`); + parts = Array.from(pieces.values()); + } + else + { + const hd = defaults.hd; + parts = [ + `hd-${ hd.partId }-${ hd.colors.join('-') }`, + `${ setType }-${ partSel.partId }${ tail }` + ]; + } + + const figure = parts.join('.'); + let url = template + .replace(/\{figure\}/g, encodeURIComponent(figure)) + .replace(/\{gender\}/g, gender) + .replace(/\{direction\}/g, '2'); + + url = url.replace(/size=l/, 'size=s').replace(/size=m/, 'size=s'); + if(!/size=/.test(url)) url += (url.includes('?') ? '&' : '?') + 'size=s'; + if(isHeadOnly && !/headonly=/.test(url)) url += '&headonly=1'; + + return url; +}; + +const RegisterDialog: FC = props => +{ + const { onCancel, onSubmit, onCheckEmail, onCheckUsername, onCheckServer, imagingUrl, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + + const [ step, setStep ] = useState('credentials'); + const [ email, setEmail ] = useState(''); + const [ password, setPassword ] = useState(''); + const [ confirm, setConfirm ] = useState(''); + const [ username, setUsername ] = useState(''); + const [ gender, setGender ] = useState('F'); + const [ selection, setSelection ] = useState(() => ({ ...FALLBACK_DEFAULTS.F })); + const [ localError, setLocalError ] = useState(null); + const [ checking, setChecking ] = useState(false); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + const [ serverReachable, setServerReachable ] = useState(null); + const [ pingingServer, setPingingServer ] = useState(false); + + const pingServer = useCallback(async () => + { + setPingingServer(true); + try + { + const ok = await onCheckServer(); + setServerReachable(ok); + return ok; + } + finally + { + setPingingServer(false); + } + }, [ onCheckServer ]); + + useEffect(() => + { + let cancelled = false; + (async () => + { + const ok = await onCheckServer(); + if(!cancelled) setServerReachable(ok); + })(); + return () => { cancelled = true; }; + }, [ onCheckServer ]); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + useEffect(() => { setLocalError(null); }, [ step ]); + + const [ figureData, setFigureData ] = useState(null); + const figureDataUrlRaw = GetConfigurationValue('avatar.figuredata.url', ''); + const figureDataUrl = useMemo(() => + { + if(!figureDataUrlRaw) return ''; + try { return GetConfiguration().interpolate(figureDataUrlRaw); } + catch { return figureDataUrlRaw; } + }, [ figureDataUrlRaw ]); + + useEffect(() => + { + if(step !== 'avatar' || figureData || !figureDataUrl) return; + let cancelled = false; + fetch(figureDataUrl, { credentials: 'omit' }) + .then(r => r.ok ? r.json() : null) + .then(json => { if(!cancelled && json) setFigureData(json as FigureData); }) + .catch(() => { }); + return () => { cancelled = true; }; + }, [ step, figureData, figureDataUrl ]); + + const partOptions = useMemo(() => + { + const result: Record> = {}; + if(!figureData) return result; + for(const st of figureData.setTypes) + { + if(!PART_ROWS.includes(st.type)) continue; + const forGender = (g: GenderKey) => st.sets + .filter(s => s.selectable && s.club === 0 && (s.gender === g || s.gender === 'U')) + .map(s => s.id); + result[st.type] = { M: forGender('M'), F: forGender('F') }; + } + return result; + }, [ figureData ]); + + const paletteOptions = useMemo(() => + { + const result: Record = {}; + if(!figureData) return result; + for(const st of figureData.setTypes) + { + if(!PART_ROWS.includes(st.type)) continue; + const palette = figureData.palettes.find(p => p.id === st.paletteId); + if(!palette) { result[st.type] = []; continue; } + result[st.type] = palette.colors + .filter(c => c.selectable && c.club === 0) + .map(c => ({ id: c.id, hex: '#' + c.hexCode.toUpperCase() })); + } + return result; + }, [ figureData ]); + + const hexFor = useCallback((setType: string, colorId: number): string => + { + const list = paletteOptions[setType]; + if(list) + { + const found = list.find(c => c.id === colorId); + if(found) return found.hex; + } + return FALLBACK_HEX[colorId] || '#c9c9c9'; + }, [ paletteOptions ]); + + const [ hotLooks, setHotLooks ] = useState<{ gender: GenderKey; figure: string }[]>([]); + const [ hotLookIndex, setHotLookIndex ] = useState(-1); + + useEffect(() => + { + if(step !== 'avatar' || hotLooks.length) return; + let cancelled = false; + fetch(configFileUrl('hotlooks.json', true), { credentials: 'omit' }) + .then(r => r.ok ? r.json() : null) + .then((json: unknown) => + { + if(cancelled || !Array.isArray(json)) return; + const parsed: { gender: GenderKey; figure: string }[] = []; + for(const entry of json as Record[]) + { + const rawGender = typeof entry._gender === 'string' ? entry._gender.toUpperCase() : ''; + const figure = typeof entry._figure === 'string' ? entry._figure : ''; + if((rawGender !== 'M' && rawGender !== 'F') || !figure) continue; + parsed.push({ gender: rawGender as GenderKey, figure }); + } + if(parsed.length) setHotLooks(parsed); + }) + .catch(() => { }); + return () => { cancelled = true; }; + }, [ step, hotLooks.length ]); + + const applyLook = useCallback((figure: string, lookGender: GenderKey) => + { + const next: FigureSelection = {}; + for(const setPart of figure.split('.')) + { + const bits = setPart.split('-'); + if(bits.length < 2) continue; + const setType = bits[0]; + const partId = parseInt(bits[1], 10); + if(!setType || Number.isNaN(partId)) continue; + const colors: number[] = []; + for(let i = 2; i < bits.length; i++) + { + const c = parseInt(bits[i], 10); + if(!Number.isNaN(c)) colors.push(c); + } + next[setType] = { partId, colors }; + } + + for(const setType of PART_ROWS) + { + if(!next[setType]) next[setType] = { ...FALLBACK_DEFAULTS[lookGender][setType] }; + } + setGender(lookGender); + setSelection(next); + }, []); + + const cycleHotLook = useCallback(() => + { + if(!hotLooks.length) return; + const nextIdx = (hotLookIndex + 1) % hotLooks.length; + setHotLookIndex(nextIdx); + const look = hotLooks[nextIdx]; + applyLook(look.figure, look.gender); + }, [ hotLooks, hotLookIndex, applyLook ]); + + const credentialsValid = + EMAIL_REGEX.test(email.trim()) && + password.length >= 8 && + password === confirm; + + const handleCredentialsNext = async (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + if(!email.trim() || !password || !confirm) + { + setLocalError('Please fill in every field.'); + return; + } + if(!EMAIL_REGEX.test(email.trim())) + { + setLocalError('Please enter a valid email address.'); + return; + } + if(password.length < 8) + { + setLocalError('Your password must be at least 8 characters.'); + return; + } + if(password !== confirm) + { + setLocalError('Passwords do not match.'); + return; + } + + setChecking(true); + try + { + const serverOk = await pingServer(); + if(!serverOk) + { + setLocalError('The gameserver is not running. Please try again later.'); + return; + } + const result = await onCheckEmail(email.trim()); + if(!result.available) + { + setLocalError(result.error || 'This email is already in use.'); + return; + } + setStep('avatar'); + } + finally + { + setChecking(false); + } + }; + + const applyGender = (newGender: GenderKey) => + { + setGender(newGender); + setSelection({ ...FALLBACK_DEFAULTS[newGender] }); + setHotLookIndex(-1); + }; + + const getPartList = useCallback((setType: string): number[] => + { + const loaded = partOptions[setType]?.[gender]; + if(loaded && loaded.length) return loaded; + const fallback = FALLBACK_DEFAULTS[gender][setType]?.partId; + return fallback !== undefined ? [ fallback ] : []; + }, [ partOptions, gender ]); + + const getColorList = useCallback((setType: string): number[] => + { + const loaded = paletteOptions[setType]; + if(loaded && loaded.length) return loaded.map(c => c.id); + const fallback = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0]; + return fallback !== undefined ? [ fallback ] : []; + }, [ paletteOptions, gender ]); + + const cyclePart = (setType: string, direction: 1 | -1) => + { + const options = getPartList(setType); + if(!options.length) return; + const current = selection[setType]?.partId ?? options[0]; + const idx = options.indexOf(current); + const nextIdx = ((idx === -1 ? 0 : idx) + direction + options.length) % options.length; + const colors = getColorList(setType); + setSelection(prev => ({ + ...prev, + [setType]: { + partId: options[nextIdx], + colors: prev[setType]?.colors ?? [ colors[0] ?? 0 ] + } + })); + }; + + const cycleColor = (setType: string, direction: 1 | -1) => + { + const colors = getColorList(setType); + if(!colors.length) return; + const currentColor = selection[setType]?.colors?.[0] ?? colors[0]; + const idx = colors.indexOf(currentColor); + const nextIdx = ((idx === -1 ? 0 : idx) + direction + colors.length) % colors.length; + const parts = getPartList(setType); + setSelection(prev => ({ + ...prev, + [setType]: { + partId: prev[setType]?.partId ?? parts[0], + colors: [ colors[nextIdx] ] + } + })); + }; + + const figure = buildFigureString(selection); + const previewSrc = buildImagingUrl(imagingUrl, figure, gender); + + const handleAvatarSubmit = async (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + const trimmed = username.trim(); + if(!trimmed) + { + setLocalError('Please choose a Habbo name.'); + return; + } + if(trimmed.length < 3 || trimmed.length > 16) + { + setLocalError('Habbo name must be 3–16 characters.'); + return; + } + + if(turnstileEnabled && !turnstileToken) + { + setLocalError('Please complete the security check.'); + return; + } + + setChecking(true); + try + { + const serverOk = await pingServer(); + if(!serverOk) + { + setLocalError('The gameserver is not running. Please try again later.'); + return; + } + const result = await onCheckUsername(trimmed); + if(!result.available) + { + setLocalError(result.error || 'This Habbo name is already taken.'); + return; + } + } + finally + { + setChecking(false); + } + + onSubmit({ + username: trimmed, + email: email.trim(), + password, + figure, + gender, + turnstileToken + }, resetWidget); + }; + + const busy = submitting || checking || pingingServer; + const serverOffline = serverReachable === false; + + return ( +
+
+
+
+ Habbo Details + +
+ + { step === 'credentials' && +
+
+ Let's create your account. Enter your email and pick a password — we'll check that email isn't already in use. +
+ { serverOffline && +
+ The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment. + +
+ } +
+ + setEmail(e.target.value) } /> +
+
+ + setPassword(e.target.value) } /> +
+
+ + setConfirm(e.target.value) } /> +
+ { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ 1/2 + +
+
+ } + + { step === 'avatar' && +
+
+ Now it's time to make your own Habbo character! To make your own Habbo, please start by choosing your Habbo Name. +
+ { serverOffline && +
+ The gameserver isn't running right now, so new accounts can't be created. Please try again in a moment. + +
+ } +
+ setUsername(e.target.value) } /> +
+ +
+ + +
+ +
+
+ { PART_ROWS.map(setType => { + const partPreviewSrc = buildPartPreviewUrl(imagingUrl, setType, selection, gender); + return ( +
+ +
+ { { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ ); + }) } +
+ +
+ Habbo preview { (e.currentTarget as HTMLImageElement).style.visibility = 'hidden'; } } /> +
+ +
+ { PART_ROWS.map(setType => { + const fallbackColor = FALLBACK_DEFAULTS[gender][setType]?.colors?.[0] ?? 0; + const currentColor = selection[setType]?.colors?.[0] ?? fallbackColor; + const swatchHex = hexFor(setType, currentColor); + return ( +
+ +
+ +
+ ); + }) } +
+
+ +
+ +
+ + { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} + +
+ + 2/2 + +
+ + } +
+
+
+ ); +}; + + +interface ForgotDialogProps extends DialogSharedProps +{ + onSubmit: (body: { email: string; turnstileToken: string; }, onDialogReset: () => void) => void; +} + +const ForgotDialog: FC = props => +{ + const { onCancel, onSubmit, submitting, error, info, turnstileEnabled, turnstileSiteKey } = props; + const [ email, setEmail ] = useState(''); + const [ localError, setLocalError ] = useState(null); + const [ turnstileToken, setTurnstileToken ] = useState(''); + const [ resetSignal, setResetSignal ] = useState(0); + + const resetWidget = useCallback(() => + { + setTurnstileToken(''); + setResetSignal(prev => prev + 1); + }, []); + + const handle = (event: FormEvent) => + { + event.preventDefault(); + setLocalError(null); + + if(!email.trim()) + { + setLocalError('Please enter your email address.'); + return; + } + + onSubmit({ email: email.trim(), turnstileToken }, resetWidget); + }; + + return ( +
+
+
+
+ Reset password + +
+
+
+ + setEmail(e.target.value) } /> +
+ { turnstileEnabled && + setTurnstileToken('') } + onError={ () => setTurnstileToken('') } + resetSignal={ resetSignal } + /> } + { (localError || error) &&
{ localError || error }
} + { info &&
{ info }
} +
+ +
+ +
+
+
+ ); +}; diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 00a797c..0a50441 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -1,7 +1,7 @@ import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { FaChevronDown, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa'; -import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; +import { FaChevronDown, FaLanguage, FaQuestionCircle, FaSignOutAlt } from 'react-icons/fa'; +import { ClearRememberLogin, FriendlyTime, GetConfigurationValue, GetRememberLogin, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { usePurse } from '../../hooks'; import purseIcon from '../../assets/images/rightside/purse.gif'; @@ -64,9 +64,7 @@ export const PurseView: FC<{}> = props => { const logoutUrl = GetConfigurationValue('login.logout.endpoint', '/api/auth/logout'); const ssoTicket = (window.NitroConfig?.['sso.ticket'] as string) ?? ''; - let rememberToken = ''; - try { rememberToken = window.localStorage.getItem('nitro.remember.token') ?? ''; } - catch { /* localStorage may be disabled */ } + const rememberToken = GetRememberLogin()?.token || ''; try { @@ -84,7 +82,7 @@ export const PurseView: FC<{}> = props => { } catch { /* best-effort — proceed with local logout regardless */ } - try { window.localStorage.removeItem('nitro.remember.token'); } catch { /* noop */ } + ClearRememberLogin(); if(window.NitroConfig) window.NitroConfig['sso.ticket'] = ''; window.location.reload(); }, []); @@ -122,6 +120,9 @@ export const PurseView: FC<{}> = props => {
}
+ diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index 518bf7a..6ecb092 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -3,7 +3,7 @@ import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusI import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; -import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; +import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView, UserProfileIconView } from '../../../../../common'; import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks'; import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; @@ -32,7 +32,6 @@ export const InfoStandWidgetUserView: FC = props = const infostandStandClass = `stand-${standId ?? 'default'}`; const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`; const infostandCardBackgroundClass = cardBackgroundId ? `card-background-${cardBackgroundId}` : ''; - const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]); const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []); @@ -90,6 +89,12 @@ export const InfoStandWidgetUserView: FC = props = newValue.figure = event.figure; newValue.motto = event.customInfo; newValue.achievementScore = event.activityPoints; + newValue.nickIcon = event.nickIcon; + newValue.prefixText = event.prefixText; + newValue.prefixColor = event.prefixColor; + newValue.prefixIcon = event.prefixIcon; + newValue.prefixEffect = event.prefixEffect; + newValue.displayOrder = event.displayOrder; newValue.backgroundId = event.backgroundId; newValue.standId = event.standId; newValue.overlayId = event.overlayId; @@ -147,7 +152,17 @@ export const InfoStandWidgetUserView: FC = props =
- {avatarInfo.name} +
diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index 4f9bd07..4059eef 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -279,7 +279,7 @@ export const ChatInputView: FC<{}> = props => return ( createPortal( -
+
{ commandSelectorVisible && = ({ useEffect(() => { - setIsVisible(false); - const element = elementRef.current; if(!element) return; + const previousWidth = chat.width; + const previousHeight = chat.height; const { offsetWidth: width, offsetHeight: height } = element; chat.width = width; @@ -62,10 +63,14 @@ export const ChatWidgetMessageView: FC = ({ setIsReady(true); + if(isVisible && ((previousWidth !== width) || (previousHeight !== height)) && makeRoom) makeRoom(chat); + }, [ chat, chat.formattedText, chat.originalFormattedText, chat.showTranslation, chat.translatedFormattedText, isVisible, makeRoom ]); + + useEffect(() => + { return () => { chat.elementRef = null; - setIsReady(false); }; }, [ chat ]); @@ -77,6 +82,8 @@ export const ChatWidgetMessageView: FC = ({ setIsVisible(true); }, [ chat, isReady, isVisible, makeRoom ]); + const messageClassName = `message [overflow-wrap:anywhere] break-words${ chat.type === 1 ? ' italic text-[#595959]' : '' }${ chat.type === 2 ? ' font-bold' : '' }`; + return (
GetRoomEngine().selectRoomObject(chat.roomId, chat.senderId, RoomObjectCategory.UNIT) }> @@ -90,29 +97,33 @@ export const ChatWidgetMessageView: FC = ({ ) }
- { chat.prefixEffect === 'pulse' && } - { chat.prefixText && (() => { - const colors = parsePrefixColors(chat.prefixText, chat.prefixColor); - const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; - const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF'); - return ( - - { chat.prefixIcon && { chat.prefixIcon } } - - {'{'} - { hasMultiColor - ? [ ...chat.prefixText ].map((char, i) => ( - { char } - )) - : chat.prefixText - } - {'}'} - - - ); - })() } - - + + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
diff --git a/src/components/room/widgets/chat/ChatWidgetWindowView.tsx b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx index 7a6118a..7734bd8 100644 --- a/src/components/room/widgets/chat/ChatWidgetWindowView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx @@ -2,7 +2,7 @@ import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer'; import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChatEntryType, LocalizeText } from '../../../../api'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; -import { useChatHistory, useChatWindow } from '../../../../hooks'; +import { useChatHistory, useChatWindow, useOnClickChat } from '../../../../hooks'; import { useRoom } from '../../../../hooks/rooms'; const BOTTOM_SCROLL_THRESHOLD = 20; @@ -19,6 +19,7 @@ export const ChatWidgetWindowView: FC<{}> = () => const { chatHistory = [], clearChatHistory = null } = useChatHistory(); const [ , setChatWindowEnabled ] = useChatWindow(); const { roomSession = null } = useRoom(); + const { onClickChat } = useOnClickChat(); const ownUserId = (GetSessionDataManager()?.userId || -1); const roomChatHistory = useMemo(() => @@ -33,7 +34,7 @@ export const ChatWidgetWindowView: FC<{}> = () => if(!normalizedSearch.length) return true; - return (`${ chat.name } ${ chat.message }`.toLowerCase().includes(normalizedSearch)); + return (`${ chat.name } ${ chat.message || '' } ${ chat.originalMessage || '' } ${ chat.translatedMessage || '' }`.toLowerCase().includes(normalizedSearch)); }); }, [ chatHistory, roomSession?.roomId, hidePets, search ]); @@ -125,14 +126,27 @@ export const ChatWidgetWindowView: FC<{}> = () => { const isOwnMessage = (chat.webId === ownUserId); const rowClassName = `mb-1 flex items-start gap-1 break-words ${ isOwnMessage ? 'justify-end' : '' }`; + const messageClassName = `message${ chat.chatType === 1 ? ' italic text-[#595959]' : '' }${ chat.chatType === 2 ? ' font-bold' : '' }`; return (
{ hideBalloons && !hideAvatars &&
} { hideBalloons && ( -
+
- + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
) } { !hideBalloons && ( @@ -148,7 +162,19 @@ export const ChatWidgetWindowView: FC<{}> = () =>
- + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index c919d7f..4d822c6 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -44,17 +44,16 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => setYoutubeRoomEnabled(enabled); }); - useEffect(() => { - if (!isInRoom) { + useEffect(() => + { + if(!isInRoom) + { setYoutubeEnabled(false); setYoutubeRoomEnabled(false); } - }, [isInRoom]); + }, [ isInRoom ]); - const openYouTubePlayer = () => - { - window.dispatchEvent(new CustomEvent('youtube:toggle')); - }; + const openYouTubePlayer = () => window.dispatchEvent(new CustomEvent('youtube:toggle')); useMessageEvent(PerkAllowancesMessageEvent, event => { @@ -100,6 +99,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => return ( <> + { youtubeEnabled && } { isInRoom && @@ -183,8 +183,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } - { setMeExpanded(value => !value); event.stopPropagation(); } }> - + { setMeExpanded(value => !value); event.stopPropagation(); } }> + { (getTotalUnseen > 0) && } @@ -197,7 +197,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => CreateLinkEvent('camera/toggle') } className="tb-icon" /> } - { youtubeEnabled && + { (isInRoom && youtubeEnabled) && } @@ -279,7 +279,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => } { setMeExpanded(value => !value); event.stopPropagation(); } }> - + { (getTotalUnseen > 0) && } @@ -293,7 +293,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => CreateLinkEvent('camera/toggle') } className="tb-icon" /> } - { youtubeEnabled && + { (isInRoom && youtubeEnabled) && } @@ -318,3 +318,62 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => ); }; + +const TOOLBAR_STYLES = ` + .tb-icon { + opacity: 1; + transition: transform 0.15s ease; + cursor: pointer; + } + + .tb-icon:hover { + transform: translateY(-2px); + } + + .tb-icon:active { + transform: translateY(0); + } + + .tb-toggle { + width: 32px; + height: 32px; + flex-shrink: 0; + border-radius: 9px; + background: rgba(18, 16, 14, 0.80); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + border: 1px solid rgba(255, 255, 255, 0.08); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + box-shadow: 0 3px 12px rgba(0, 0, 0, 0.5); + transition: background 0.15s, border-color 0.15s; + } + + .tb-toggle:hover { + background: rgba(30, 26, 20, 0.88); + border-color: rgba(255, 255, 255, 0.13); + } + + .tb-bar-scroll { + overflow-x: auto; + overflow-y: visible; + scrollbar-width: none; + -ms-overflow-style: none; + flex-wrap: nowrap; + } + + .tb-bar-scroll::-webkit-scrollbar { + display: none; + } + + .tb-open-shell { + scrollbar-width: none; + -ms-overflow-style: none; + } + + .tb-open-shell::-webkit-scrollbar { + display: none; + } +`; diff --git a/src/components/translation/TranslationBootstrap.tsx b/src/components/translation/TranslationBootstrap.tsx new file mode 100644 index 0000000..389f566 --- /dev/null +++ b/src/components/translation/TranslationBootstrap.tsx @@ -0,0 +1,9 @@ +import { FC } from 'react'; +import { useTranslation } from '../../hooks'; + +export const TranslationBootstrap: FC<{}> = () => +{ + useTranslation(); + + return null; +}; diff --git a/src/components/translation/TranslationSettingsView.tsx b/src/components/translation/TranslationSettingsView.tsx new file mode 100644 index 0000000..efe4571 --- /dev/null +++ b/src/components/translation/TranslationSettingsView.tsx @@ -0,0 +1,138 @@ +import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; +import { useTranslation } from '../../hooks'; + +export const TranslationSettingsView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { + settings, + supportedLanguages = [], + availableTextLocales = [], + languagesLoading = false, + localizationTextsLoading = false, + lastIncomingLanguage = '', + lastOutgoingLanguage = '', + lastError = '', + updateSettings, + ensureSupportedLanguagesLoaded, + getLanguageName + } = useTranslation(); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(prevValue => !prevValue); + return; + } + }, + eventUrlPrefix: 'translation-settings/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(!isVisible) return; + + ensureSupportedLanguagesLoaded(); + }, [ ensureSupportedLanguagesLoaded, isVisible ]); + + if(!isVisible) return null; + + return ( + + setIsVisible(false) } /> + +
+ updateSettings({ enabled: event.target.checked }) } /> + Enable automatic translation +
+
+ When enabled, chat bubbles always show two lines: original: and translate:. +
+
+ Interface texts +
+ +
+
+
+ Incoming messages +
+ Detected language (auto): { getLanguageName(lastIncomingLanguage) } + +
+
+
+ Outgoing messages +
+ Detected writing language (auto): { getLanguageName(lastOutgoingLanguage) } + +
+
+
+ { languagesLoading ? 'Loading languages...' : `${ supportedLanguages.length } languages available` } + +
+ { localizationTextsLoading && +
+ Loading localized interface texts... +
} + { lastError.length > 0 && +
+ { lastError } +
} +
+
+ ); +}; diff --git a/src/components/user-profile/UserContainerView.tsx b/src/components/user-profile/UserContainerView.tsx index 1262bd4..851ccf6 100644 --- a/src/components/user-profile/UserContainerView.tsx +++ b/src/components/user-profile/UserContainerView.tsx @@ -1,7 +1,7 @@ import { GetSessionDataManager, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api'; -import { LayoutAvatarImageView, Text } from '../../common'; +import { LayoutAvatarImageView, Text, UserIdentityView } from '../../common'; export const UserContainerView: FC<{ userProfile: UserProfileParser; @@ -19,7 +19,6 @@ export const UserContainerView: FC<{ const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`; const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`; const profileCardBgClass = userProfile.cardBackgroundId ? `card-background-${userProfile.cardBackgroundId}` : ''; - const addFriend = () => { setRequestSent(true); @@ -42,7 +41,16 @@ export const UserContainerView: FC<{
-

{ userProfile.username }

+

{ userProfile.motto }

@@ -116,4 +124,4 @@ export const UserContainerView: FC<{
); -}; \ No newline at end of file +}; diff --git a/src/components/wired-tools/WiredCreatorToolsView.tsx b/src/components/wired-tools/WiredCreatorToolsView.tsx index 7d1f539..b2279db 100644 --- a/src/components/wired-tools/WiredCreatorToolsView.tsx +++ b/src/components/wired-tools/WiredCreatorToolsView.tsx @@ -1,13 +1,13 @@ -import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, GetStage, GetTicker, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer'; import { WiredMonitorDataEvent, WiredMonitorRequestComposer } from '@nitrots/nitro-renderer'; -import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import furniInspectionIcon from '../../assets/images/wiredtools/furni.png'; import globalInspectionIcon from '../../assets/images/wiredtools/global.png'; import userInspectionIcon from '../../assets/images/wiredtools/user.png'; import contextInspectionIcon from '../../assets/images/wiredtools/context.png'; import wiredGlobalPlaceholderImage from '../../assets/images/wiredtools/wired_global_placeholder.png'; import wiredMonitorImage from '../../assets/images/wiredtools/wired_monitor.png'; -import { AvatarInfoFurni, AvatarInfoUtilities, LocalizeText, NotificationAlertType, SendMessageComposer } from '../../api'; +import { AvatarInfoFurni, AvatarInfoUtilities, GetRoomObjectBounds, GetRoomObjectScreenLocation, LocalizeText, NotificationAlertType, SendMessageComposer, WiredSelectionVisualizer } from '../../api'; import { Button, DraggableWindowPosition, LayoutAvatarImageView, LayoutPetImageView, LayoutRoomObjectImageView, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common'; import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedEvent, useRoom, useWiredTools } from '../../hooks'; import { WiredToolsSettingsTabView } from './WiredToolsSettingsTabView'; @@ -184,6 +184,21 @@ interface VariableManageEntry value: number | null; } +interface VariableHighlightTarget +{ + category: number; + hasValue: boolean; + objectId: number; + value: number | null; +} + +interface VariableHighlightOverlay extends VariableHighlightTarget +{ + key: string; + x: number; + y: number; +} + interface ManagedHolderVariableEntry { availability: string; @@ -631,6 +646,9 @@ export const WiredCreatorToolsView: FC<{}> = () => const [ isManagedGiveOpen, setIsManagedGiveOpen ] = useState(false); const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0); const [ managedGiveValue, setManagedGiveValue ] = useState('0'); + const [ isVariableHighlightActive, setIsVariableHighlightActive ] = useState(false); + const [ variableHighlightOverlays, setVariableHighlightOverlays ] = useState([]); + const variableHighlightObjectsRef = useRef>([]); const shouldPauseVariableSnapshotRefresh = (!!editingVariable || !!editingManagedHolderVariableId || isInspectionGiveOpen || isManagedGiveOpen); const [ selectedVariableKeys, setSelectedVariableKeys ] = useState>({ furni: VARIABLE_DEFINITIONS.furni[0].key, @@ -2400,6 +2418,155 @@ export const WiredCreatorToolsView: FC<{}> = () => manageLabel: 'Manage' } ]; }, [ selectedVariableDefinition, variablesType, roomSession, userVariableAssignments, furniVariableAssignments, roomVariableAssignmentMap ]); + const canVariableHighlight = !!selectedVariableDefinition?.itemId + && (selectedVariableDefinition.type === 'Custom') + && ((variablesType === 'user') || (variablesType === 'furni')) + && !!roomSession; + const variableHighlightTargets = useMemo((): VariableHighlightTarget[] => + { + if(!isVariableHighlightActive || !canVariableHighlight || !roomSession || !selectedVariableDefinition?.itemId) return []; + + if(variablesType === 'user') + { + const targets: VariableHighlightTarget[] = []; + + for(const [ userIdString, assignments ] of Object.entries(userVariableAssignments)) + { + const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId)); + + if(!assignment) continue; + + const userId = Number(userIdString); + const userData = roomSession.userDataManager.getUserData(userId) + ?? roomSession.userDataManager.getBotData(userId) + ?? roomSession.userDataManager.getRentableBotData(userId) + ?? roomSession.userDataManager.getPetData(userId); + const roomIndex = Number(userData?.roomIndex ?? -1); + + if(roomIndex < 0) continue; + + targets.push({ + category: RoomObjectCategory.UNIT, + objectId: roomIndex, + hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined), + value: assignment.value + }); + } + + return targets; + } + + if(variablesType === 'furni') + { + const targets: VariableHighlightTarget[] = []; + + for(const [ furniIdString, assignments ] of Object.entries(furniVariableAssignments)) + { + const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId)); + + if(!assignment) continue; + + const furniId = Number(furniIdString); + const floorObject = GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.FLOOR); + const wallObject = floorObject ? null : GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.WALL); + const category = floorObject ? RoomObjectCategory.FLOOR : (wallObject ? RoomObjectCategory.WALL : -1); + + if(category < 0) continue; + + targets.push({ + category, + objectId: furniId, + hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined), + value: assignment.value + }); + } + + return targets; + } + + return []; + }, [ canVariableHighlight, furniVariableAssignments, isVariableHighlightActive, roomSession, selectedVariableDefinition, userVariableAssignments, variablesType ]); + useEffect(() => + { + if(isVisible && (activeTab === 'variables') && canVariableHighlight) return; + + setIsVariableHighlightActive(false); + }, [ activeTab, canVariableHighlight, isVisible ]); + useEffect(() => + { + if(variableHighlightObjectsRef.current.length) + { + WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current); + variableHighlightObjectsRef.current = []; + } + + if(!isVariableHighlightActive || !variableHighlightTargets.length) + { + + setVariableHighlightOverlays([]); + + return; + } + + const objects = variableHighlightTargets.map(target => ({ + category: target.category, + objectId: target.objectId + })); + + WiredSelectionVisualizer.applyVariableHighlightToObjects(objects); + variableHighlightObjectsRef.current = objects; + + return () => + { + if(!variableHighlightObjectsRef.current.length) return; + + WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current); + variableHighlightObjectsRef.current = []; + }; + }, [ isVariableHighlightActive, variableHighlightTargets ]); + useEffect(() => + { + if(!isVariableHighlightActive || !roomSession?.roomId || !variableHighlightTargets.length) + { + setVariableHighlightOverlays([]); + + return; + } + + const updateOverlays = () => + { + const stage = GetStage(); + const nextOverlays: VariableHighlightOverlay[] = []; + + for(const target of variableHighlightTargets) + { + const bounds = GetRoomObjectBounds(roomSession.roomId, target.objectId, target.category); + const location = GetRoomObjectScreenLocation(roomSession.roomId, target.objectId, target.category); + + if(!bounds || !location) continue; + + const x = Math.max(8, Math.min(Math.round(location.x), (stage.width - 8))); + const y = Math.max(8, Math.min(Math.round(bounds.top), (stage.height - 40))); + + nextOverlays.push({ + ...target, + key: `${ target.category }:${ target.objectId }`, + x, + y + }); + } + + setVariableHighlightOverlays(nextOverlays); + }; + + updateOverlays(); + + const ticker = GetTicker(); + + ticker.add(updateOverlays); + + return () => ticker.remove(updateOverlays); + }, [ isVariableHighlightActive, roomSession?.roomId, variableHighlightTargets ]); const variableManageTypeOptions = useMemo(() => { switch(variablesType) @@ -3465,6 +3632,27 @@ export const WiredCreatorToolsView: FC<{}> = () => return ( <> + { isVariableHighlightActive && !!variableHighlightOverlays.length && +
+ { variableHighlightOverlays.map(overlay => ( +
+ { overlay.hasValue && +
+
+ { overlay.value ?? 0 } + +
+
} +
+ )) } +
} setIsVisible(false) } /> @@ -3830,7 +4018,12 @@ export const WiredCreatorToolsView: FC<{}> = () =>
- +
}
{ LocalizeText('wiredfurni.params.message') } - ('wired.action.bot.talk.to.avatar.max.length', 64) } type="text" value={ message } onChange={ event => setMessage(event.target.value) } /> +