diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md new file mode 100644 index 0000000..54f5702 --- /dev/null +++ b/docs/PERFORMANCE.md @@ -0,0 +1,586 @@ +# Nitro V3 — Cold-load performance + +Practical recipe to take a Nitro V3 cold load from the typical +60-90 s (and intermittent "Session expired") baseline down to ~4 s. +The wins compound: each section below has measurable impact, in +roughly the order of cost vs benefit. + +Three things matter on the client (this repo): granular code split, +a real progress bar driven by boot stages, and capturing the +remember-token from the iframe URL on first boot. The other three — +gzip on the static server, long cache on gamedata, and a server +endpoint to mint fresh SSO tickets — are documented further down +under §5 (nginx) and §6 (IIS) plus a quick note in §4 about the +CMS contract. + +--- + +## 1. The three Nitro-side changes that matter + +1. **Granular code split** (`vite.config.mjs`) — a 1 MB vendor bundle + is replaced by ~12 smaller chunks the browser fetches in parallel + via HTTP/2 multiplexing. +2. **Loading screen with a real progress bar** + per-stage labels + (`src/components/loading/LoadingView.tsx`, driven by + `src/App.tsx::prepare()`) so a slow boot looks like progress, not + a frozen GIF. +3. **Remember-token capture from URL** (`src/App.tsx::prepare()`) so + that when the WS drops the existing `tryRememberLogin()` round + can hit the CMS `POST /api/auth/remember` endpoint and get a + fresh SSO ticket instead of falling through to "Session expired". + +The server-side wins (gzip, cache, SSO TTL) live outside this repo — +without them this client still loads, but you stay at the 60-90 s +baseline. + +--- + +## 2. Vite `manualChunks` — split the vendor blob + +Default `yarn build` ships: + +- `vendor` ~1 MB (react + tanstack-query + framer-motion + jodit + + emoji-mart + react-icons + howler + zustand + json5 — everything + merged) +- `nitro-renderer` ~2.5 MB (renderer source + pixi.js inlined) +- `src` ~1.7 MB (app code) + +The vendor blob forces the browser to wait on the slowest dependency +before it can hydrate. Split it by domain — see +[`vite.config.mjs`](../vite.config.mjs) for the live version, the +intent is captured below: + +```js +manualChunks: id => { + const norm = id.replace(/\\/g, '/'); + + // Vendors first — pixi.js / howler / emoji-mart / jodit are aliased + // to ../Nitro_Render_V3/node_modules, so they would otherwise be + // swallowed by the `Nitro_Render_V3` branch lower down and pulled + // into the renderer chunk. + if(norm.includes('pixi.js') || norm.includes('pixi-filters')) return 'vendor-pixi'; + if(norm.includes('howler')) return 'vendor-audio'; + if(norm.includes('@emoji-mart')) return 'vendor-emoji'; + if(norm.includes('jodit') || norm.includes('@react-page')) return 'vendor-editor'; + + if(id.includes('Nitro_Render_V3') || id.includes(`${ rendererRoot }`)) { + if(id.includes('/packages/avatar/')) return 'nitro-renderer-avatar'; + if(id.includes('/packages/communication/')) return 'nitro-renderer-comm'; + if(id.includes('/packages/room/')) return 'nitro-renderer-room'; + if(id.includes('/packages/assets/')) return 'nitro-renderer-assets'; + return 'nitro-renderer'; + } + + if(id.includes('node_modules')) { + if(id.includes('@nitrots/nitro-renderer') || id.includes('renderer3')) return 'nitro-renderer'; + if(id.match(/\/react(-dom)?\/|\/scheduler\//) || id.includes('react-error-boundary')) return 'vendor-react'; + if(id.includes('framer-motion')) return 'vendor-motion'; + if(id.includes('@tanstack')) return 'vendor-query'; + if(id.includes('zustand') || id.includes('use-between')) return 'vendor-state'; + if(id.includes('react-icons')) return 'vendor-icons'; + if(id.includes('json5')) return 'vendor-json5'; + return 'vendor'; + } +} +``` + +Two practical points the comments don't make obvious: + +- **Vendor checks come first.** Pixi.js, howler, emoji-mart and jodit + are pulled in via an alias to `../Nitro_Render_V3/node_modules`, + so their `id` matches `Nitro_Render_V3`. If the renderer branch + runs before the vendor one, those modules end up bundled into the + renderer chunk instead of their own — defeating the whole point. + +- **Pixi often stays inlined.** Rollup keeps a module in the chunk + of its sole importer, and `pixi.js` is consumed only through the + `@nitrots/nitro-renderer` umbrella. Expect `vendor-pixi` to be + near-empty until something *outside* the renderer also imports + pixi. This is fine — pixi gets the renderer chunk's cache lifetime + anyway. + +Verify after `yarn build`: + +``` +dist/assets/nitro-renderer-*.js ~2.5 MB raw, ~765 KB gzip +dist/assets/vendor-*.js ~12 chunks, 4-430 KB each +dist/assets/src-*.js ~1.7 MB raw, ~550 KB gzip +``` + +If you see a single `vendor-*.js` over 800 KB raw, the chunk +function isn't matching the way you expect — log `id` from inside +`manualChunks` during build to find out what's actually being +handed in. + +Also add the connection hint to [`index.html`](../index.html): + +```html + +``` + +Saves one TLS handshake on cold load — the Turnstile script tag +already loads from that domain. + +--- + +## 3. LoadingView — real progress, real labels + +[`src/components/loading/LoadingView.tsx`](../src/components/loading/LoadingView.tsx) +renders the dark-blue boot screen the user sees before `isReady` +flips. It accepts a `progress` number (0-100) and a `currentTask` +string. The progress bar is hidden when `progress` is `undefined` +(error / Suspense fallback path) and animates between updates. + +The state lives in [`src/App.tsx`](../src/App.tsx): + +```ts +const [ loadingProgress, setLoadingProgress ] = useState(0); +const [ loadingTask, setLoadingTask ] = useState(''); + +const taskLabel = useCallback((key: string, fallback: string): string => { + // … reads from renderer-config so the strings are translatable +}); + +const bumpProgress = useCallback((value: number, task?: string) => { + setLoadingProgress(prev => (value > prev ? value : prev)); + if(task !== undefined) setLoadingTask(task); +}, []); +``` + +`prepare()` bumps the progress through 12 stages as it goes: + +| % | Stage | Default label | +|---|---|---| +| 5 | App start | `Avvio in corso...` | +| 10 | NitroConfig validated | `Verifica sessione` | +| 20 | Renderer constructed | `Inizializzazione renderer` | +| 25 | Config init done | `Caricamento contenuti...` | +| 36, 47, 58, 70 | each warmup task resolves | per-task (`Sto caricando il guardaroba`, …) | +| 78 | `GetSessionDataManager().init()` done | `Caricamento dati utente` | +| 85 | `GetRoomSessionManager().init()` done | `Caricamento stanze` | +| 92 | `GetRoomEngine().init()` done | `Caricamento engine grafico` | +| 98 | `GetCommunication().init()` done | `Connessione al server` | +| 100 | `setIsReady(true)` about to fire | `Pronto!` | + +The labels are config-driven — `taskLabel('loading.task.boot', 'Avvio in corso...')` +reads `loading.task.*` keys from the renderer-config and falls back +to the Italian baseline if unset. To localise, add the keys to +`public/configuration/renderer-config.json` (see the `.example` +file for the full list). + +Logo and background are also configurable via the same mechanism — +`loading.logo.url`, `loading.background`, `loading.progress.color`. +Leaving them empty keeps the shipped dark-blue radial + Nitro V3 +logo top-left. + +### 3.1 The pre-React shell (asset-loader.js) + +There is a second, tiny loading screen that the asset loader writes +into `#root` *before* React mounts. It used to be a light-blue +login-skeleton with two grey rectangles — visible for ~200 ms before +React took over, producing a hated flash. The template lives in +[`scripts/write-asset-loader.mjs`](../scripts/write-asset-loader.mjs) +(`renderShell`) and now paints the same `radial-gradient(#1d1a24,#003a6b)` +as the React `LoadingView`, so the handoff is invisible. + +Don't hand-edit `public/configuration/asset-loader.js` — the +`prebuild` hook regenerates it from the template every `yarn build`. + +--- + +## 4. Remember-token capture — making reconnect work + +Arcturus clears `auth_ticket` to `''` the moment it consumes an SSO +ticket. Without a remember-token the client retries reconnect with +the same (now empty) ticket and falls through to "Session expired" +after 2-7 attempts. + +The CMS issues a UUID family token when it serves `/client`, and +passes it on the iframe URL as `&token=&token_exp=`. Nitro +captures it on first boot: + +```ts +// src/App.tsx::prepare() +try { + const urlParams = new URLSearchParams(window.location.search); + const tokenParam = urlParams.get('token'); + const tokenExpParam = urlParams.get('token_exp'); + if(tokenParam && !GetRememberLogin()) { + const parsedExpiry = Number(tokenExpParam || 0); + const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0) + ? parsedExpiry + : Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60); + SetRememberLogin({ token: tokenParam, expiresAt }); + } +} catch(e) { + console.warn('[App] failed to persist remember token from URL', e); +} +``` + +The capture is guarded by `!GetRememberLogin()` — if the user has +visited before, the stored token wins and the URL one is ignored. + +Once stored, the existing `tryRememberLogin()` machinery picks it up +on every reconnect: it POSTs to +`${api.url}/api/auth/remember` (configurable via the +`login.remember.endpoint` renderer-config key), receives a fresh +SSO ticket back, and rotates the connection. See the CMS doc for the +server endpoint's contract. + +Verify the stored token in browser DevTools: + +``` +Application → Local Storage → https:// + Key: nitro.auth.remember + Value: {"token":"","expiresAt":1781912345,"username":""} +``` + +If `nitro.auth.remember` is missing after a successful first load, +the CMS isn't passing `token=` on the iframe URL. Check +`AuthController.client` on the CMS side. + +--- + +## 5. Server-side: nginx gzip + long cache (the single biggest win) + +The Nitro client ships ~4.3 MB raw across the main bundle, renderer +chunk and vendor splits. If the server doesn't compress and doesn't +let the browser cache, every visitor pays the full price on every +load — that's exactly the 60-90 s baseline you avoid by configuring +nginx properly. + +### 5.1 Enable gzip globally + +Default nginx ships with the `gzip` block commented out. Edit +`/etc/nginx/nginx.conf` and replace the `#gzip on;` line inside the +`http {}` block: + +```nginx +gzip on; +gzip_vary on; +gzip_proxied any; +gzip_comp_level 6; +gzip_min_length 1024; +gzip_types + text/plain + text/css + text/xml + text/javascript + application/javascript + application/x-javascript + application/json + application/xml + application/rss+xml + application/atom+xml + image/svg+xml + font/ttf + font/otf + application/font-woff + application/vnd.ms-fontobject; +``` + +Back up the file before editing, then reload: + +```bash +cp /etc/nginx/nginx.conf /etc/nginx/nginx.conf.bak-$(date +%Y%m%d-%H%M%S) +nginx -t # validate syntax first +systemctl reload nginx +``` + +The impact is *enormous* — `palettes.json5` drops from 330 KB to 18 KB +on the wire (~17×), and the renderer JS bundle from 2.5 MB to 765 KB +(~3.3×). Verify: + +```bash +curl -sI -H 'Accept-Encoding: gzip' \ + 'https:///nitro/assets/nitro-renderer-XXXXX.js' \ + | grep -i 'content-encoding' +# expected: content-encoding: gzip +``` + +If you forget `application/json` from `gzip_types` you lose the +gamedata compression — that's the one that matters the most because +the gamedata files are by far the heaviest payload. + +### 5.2 Long Cache-Control on gamedata + +Inside the `/nitro-assets/` or `/nitro-assets/` location +block, the gamedata `.json5` files deserve a 30-day cache because +they only change on deploy: + +```nginx +location /nitro-assets/ { + alias /var/www/cmsjs/public/nitro-assets/; + try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404; + autoindex off; + default_type application/json; + expires 7d; + add_header Cache-Control "public, max-age=604800, immutable"; + + location ~ \.json5?$ { + types {} default_type application/json; + expires 30d; + add_header Cache-Control "public, max-age=2592000"; + } +} +``` + +The outer 7-day cache covers PNG / nitro / mp3 files. The inner +location block raises the JSON5 lifetime to 30 days because the +content is effectively immutable per deploy. Cloudflare honours +`Last-Modified` so revalidation still works — you don't need to +cache-bust by filename. + +For the JS / CSS chunks the filenames are content-hashed by Vite, so +a long cache is safe — apply the same `Cache-Control: max-age=2592000` +to the `/nitro/assets/` location. + +### 5.3 The `try_files → manifest.json5` fallback + +`loadGamedata(url)` in the renderer SDK can be pointed at either a +single JSON file or a directory containing `manifest.json5` + tier +sub-directories. The directory pattern is what we use in production, +so requests like `/nitro-assets/gamedata/figuremap/` (note the +trailing slash) need to resolve to the directory's manifest. + +The `try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;` +above does exactly that — try the URI as-is, fall back to the +`manifest.json5` inside the directory, fall back to `.json` for +legacy deploys, then 404. Without it nginx returns 403 (autoindex +off) on directory URLs and the loader cascades into the manifest 404 +path. + +--- + +## 6. Server-side: Windows + IIS deployment + +You can reach the same 4 s cold load on Windows Server with IIS. The +same three wins (gzip, long cache, JSON5 fallback) are replicable — +syntax changes, performance ceiling doesn't. + +### 6.1 Don't host Node inside IIS + +`IISNode` is unmaintained. The current MS recommendation is to run +Node as a Windows service and let IIS reverse-proxy to it: + +1. Install Node 22 LTS, run the CMS app as a Windows Service (via + `nssm`, `pm2-windows-startup`, or a scheduled task on boot) bound + to `127.0.0.1:3003` — same layout as `docker-compose.yml` on the + Linux host. +2. Install Arcturus separately as a Windows service running + `Habbo-x.y.z-jar-with-dependencies.jar` against MariaDB. WS ports + 30001 + 30002 stay on `127.0.0.1`. +3. IIS handles HTTPS termination, static file serving, compression + and reverse-proxying `/api/*` + `/client` + the Inertia entry + point to `127.0.0.1:3003`. + +Install these IIS features (Server Manager → Web Server → Add Roles +& Features): + +- **URL Rewrite** — proxy rules +- **Application Request Routing (ARR)** — lets IIS act as a forward + proxy; *enable proxy in the ARR feature page* after install +- **WebSocket Protocol** — required for the Arcturus WS upgrade +- **Static Content** + **Static Content Compression** +- **Dynamic Content Compression** — **off by default**, this is the + single most important toggle on a vanilla Windows Server + +### 6.2 Enable compression site-wide + +IIS Manager → site → **Compression** feature → tick *both* +"Enable dynamic content compression" and "Enable static content +compression". Equivalent of nginx's `gzip on;`. + +Without ticking both you ship raw bytes. Static covers JS / CSS / +JSON files, Dynamic covers Node responses (HTML from the Inertia +render). Add `application/json` to the compressor (and `.json5` to +its MIME map) in `applicationHost.config` or the site's `web.config`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +Verify with PowerShell: + +```powershell +Invoke-WebRequest -Uri 'https:///nitro-assets/gamedata/figuredata/core/palettes.json5' ` + -Headers @{ 'Accept-Encoding' = 'gzip' } ` + -MaximumRedirection 0 | Select-Object -ExpandProperty Headers +# expected: Content-Encoding = gzip +``` + +### 6.3 Long cache for gamedata + +Drop a `web.config` inside the `nitro-assets/` virtual +directory (or nest under ``): + +```xml + + + + + + + + + +``` + +`30.00:00:00` is the IIS TimeSpan for 30 days — same effect as +`Cache-Control: public, max-age=2592000` on nginx. + +Set a separate, shorter cache (e.g. 5 minutes) on `index.html` so +deploys propagate without forcing visitors to clear their cache. + +### 6.4 Directory → manifest.json5 fallback + +nginx's `try_files $uri ${uri}manifest.json5 ${uri}manifest.json =404;` +has no native IIS equivalent. Use **URL Rewrite** to chain two rules +inside the same ``: + +```xml + + + + + + + + + + + + + + + + + + + + +``` + +### 6.5 Reverse proxy to Node + WebSocket upgrade + +Once ARR is installed and proxy enabled (IIS Manager → server node → +ARR → Server Proxy Settings → check "Enable proxy"), add a top-level +rule that forwards everything *not* matching a static file: + +```xml + + + + + + + + + + + + + + + + + + + + + + + +``` + +ARR transparently handles the WebSocket upgrade once the WebSocket +Protocol IIS feature is installed. + +### 6.6 IIS trade-offs (honest) + +- **Compression CPU**: IIS dynamic compression is more CPU-hungry than + nginx's worker-pool gzip. On 2-vCPU droplets expect ~10-15 % extra + CPU during peak concurrency. +- **Docker overhead**: Docker Desktop on Windows goes through the + WSL2 file-system bridge. Bind-mounting Linux-style paths into a + container is measurably slower than the same on a native Linux + host. Recommendation: run Node + Arcturus as native Windows + services, *not* containerised. +- **Java JDBC on Windows**: Arcturus's JDBC pool exhibits slightly + higher lock-wait under concurrent room load on Windows than on + Linux. Re-tune `db.pool.maxsize` if you saturate. + +Browser-perceived performance is identical to nginx once the config +above is in place. The 4 s cold-load target is achievable on any +Windows Server 2019 / 2022 box. + +The one deployment to **avoid**: shared Windows hosting where the +hoster doesn't let you enable Dynamic Compression at the application +host level. You stay stuck at the 60-90 s baseline because neither +Node's gzip nor IIS's compressor can be turned on. + +--- + +## 7. End-to-end verification + +Run each probe in order — they walk the request through every layer +covered above. A green light on all four means the cold load is +correctly tuned. + +```bash +# 1. Build artefact has the granular chunks +yarn build +ls dist/assets/ | grep -E '^(vendor|nitro-renderer)-' | wc -l +# expected: ~12-14 chunks + +# 2. Server is compressing JSON5 (or JS — pick either) +curl -sI -H 'Accept-Encoding: gzip' \ + 'https:///nitro-assets/gamedata/figuredata/core/palettes.json5' \ + | grep -iE 'content-encoding|cache-control' +# expected: +# content-encoding: gzip +# cache-control: public, max-age=2592000 + +# 3. Directory → manifest.json5 fallback +curl -sI 'https:///nitro-assets/gamedata/figuremap/' \ + | head -1 +# expected: HTTP/2 200 (not 403 or 404) + +# 4. LoadingView renders the progress bar — easiest from the live site: +# DevTools → Performance → Record → reload /client +# Look for the progress bar transitioning 5→100% within 4s on a +# warm-cache load, ~10-20s on a cold one with empty CF cache. + +# 5. Remember-token captured to localStorage: +# DevTools → Application → Local Storage → check nitro.auth.remember +# is populated after the first successful load. +``` + +If the build artefact is correct but the live site doesn't pick up +the new chunks, the deploy didn't replace `dist/` on the server. +Wipe the target dir's `assets/*.js` and `src/assets/*.css` before +extracting the new tarball — old chunk filenames stick around +otherwise. diff --git a/index.html b/index.html index 48e1e0a..b162833 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,9 @@ Nitro + + diff --git a/public/configuration/UITexts.example b/public/configuration/UITexts.example index acf246e..2b87ccb 100644 --- a/public/configuration/UITexts.example +++ b/public/configuration/UITexts.example @@ -1,116 +1,268 @@ { - "notification.badge.received": "Nuovo Distintivo!", - "wiredfurni.badgereceived.title": "Distintivo ricevuto!", - "wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!", - "friendlist.search": "Search friends", - "purse.seasonal.currency.101": "cash", - "widget.chooser.checkall": "Select furniture", - "widget.chooser.btn.pickall": "pick up selected items!", - "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", - "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", - "widget.settings.general": "General", - "widget.settings.general.title": "Adjust the default Nitro settings", - "widget.settings.volume": "Volume", - "widget.settings.interface": "Interface", - "widget.settings.interface.title": "Adjust the interface settings", - "widget.settings.interface.fps.automatic": "Set FPS to unlimited", - "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", - "widget.settings.interface.secondary": "Change the window header color", - "widget.settings.interface.reset": "Reset header color to default", - "widget.room.chat.hide_pets": "Hide pets", - "widget.room.chat.hide_avatars": "Hide avatars", - "widget.room.chat.hide_balloon": "Hide speech bubble", - "widget.room.chat.show_balloon": "Speech bubble", - "widget.room.chat.clear_history": "clear history", - "widget.room.youtube.shared": "YouTube is being shared", - "widget.room.youtube.open_video": "Open the video", - "wiredfurni.tooltip.select.tile": "Select tile", - "wiredfurni.tooltip.remove.tile": "Deselect tile", - "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", - "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", - "wiredfurni.params.furni_neighborhood.group.user": "Players", - "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", - "wiredfurni.params.selector_option.bot": "No bots", - "wiredfurni.params.selector_option.pet": "No pets", - "catalog.title": "Catalog", - "catalog.favorites": "Favorites", - "catalog.favorites.pages": "Pages", - "catalog.favorites.furni": "Furni", - "catalog.favorites.empty": "No favorites", - "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", - "catalog.admin": "Admin", - "catalog.admin.new": "New", - "catalog.admin.root": "Root", - "catalog.admin.new.root.category": "New root category", - "catalog.admin.edit.root": "Edit Root", - "catalog.admin.edit": "Edit:", - "catalog.admin.edit.page": "Edit Page", - "catalog.admin.hidden": "hidden", - "catalog.admin.edit.title": "Edit \"%name%\"", - "catalog.admin.show": "Show", - "catalog.admin.hide": "Hide", - "catalog.admin.delete": "Delete", - "catalog.admin.delete.title": "Delete \"%name%\"", - "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", - "catalog.admin.delete.page": "Delete page", - "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", - "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", - "catalog.admin.create": "Create", - "catalog.admin.save": "Save", - "catalog.admin.create.subpage": "Create sub-page", - "catalog.admin.order": "Order", - "catalog.admin.visible": "Visible", - "catalog.admin.enabled": "Enabled", - "catalog.admin.offer.new": "New Offer", - "catalog.admin.offer.edit": "Edit Offer", - "catalog.admin.offer.name": "Catalog Name", - "catalog.admin.offer.general": "General", - "catalog.admin.offer.quantity": "Quantity", - "catalog.admin.offer.prices": "Prices", - "catalog.admin.offer.credits": "Credits", - "catalog.admin.offer.points": "Points", - "catalog.admin.offer.points.type": "Points Type", - "catalog.admin.offer.options": "Options", - "catalog.admin.offer.club.only": "Club Only", - "catalog.admin.offer.extradata": "Extra Data (optional)....", - "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", - "catalog.trophies.title": "Trophies", - "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", - "catalog.trophies.inscription": "Trophy Inscription", - "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", - "catalog.pets.show.colors": "Show colors", - "catalog.pets.choose.color": "Choose color", - "catalog.pets.choose.breed": "Choose breed", - "catalog.pets.back.breeds": "? Breeds", - "catalog.prefix.text": "Text", - "catalog.prefix.text.placeholder": "Enter text...", - "catalog.prefix.icon": "Icon", - "catalog.prefix.icon.remove": "Remove icon", - "catalog.prefix.effect": "Effect", - "catalog.prefix.color": "Color", - "catalog.prefix.color.single": "?? Single", - "catalog.prefix.color.per.letter": "?? Per Letter", - "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", - "catalog.prefix.color.apply.all.title": "Apply current color to all letters", - "catalog.prefix.color.apply.all": "Apply to all", - "catalog.prefix.color.selected": "Selected letter:", - "catalog.prefix.price": "Price:", - "catalog.prefix.price.amount": "5 Credits", - "catalog.prefix.purchased": "? Purchased!", - "catalog.prefix.purchase": "Purchase", - "groupforum.list.tab.most_active": "Most active threads", - "groupforum.list.tab.my_forums": "My group forums", - "groupforum.list.no_forums": "There are no forums", - "groupforum.view.threads": "Number of threads", - "groupforum.thread.pin": "Pin thread", - "groupforum.thread.unpin": "Unpin thread", - "groupforum.thread.lock": "Lock thread", - "groupforum.thread.unlock": "Unlock thread", - "groupforum.thread.hide": "Hide thread", - "groupforum.thread.restore": "Restore thread", - "groupforum.thread.delete": "Delete thread + posts", - "groupforum.message.hide": "Hide message", - "group.forum.enable.caption": "Enable / Disable group forum", - "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", - "groupforum.view.no_threads": "There are currently no active threads" + "notification.badge.received": "Nuovo Distintivo!", + "wiredfurni.badgereceived.title": "Distintivo ricevuto!", + "wiredfurni.badgereceived.body": "Hai appena ricevuto un nuovo Distintivo! Controlla nel tuo Inventario!", + "friendlist.search": "Search friends", + "purse.seasonal.currency.101": "cash", + "widget.chooser.checkall": "Select furniture", + "widget.chooser.btn.pickall": "pick up selected items!", + "wiredfurni.params.requireall.2": "If one of the selected furni has an avatar", + "wiredfurni.params.requireall.3": "If all selected furni have avatars on them", + "widget.settings.general": "General", + "widget.settings.general.title": "Adjust the default Nitro settings", + "widget.settings.volume": "Volume", + "widget.settings.interface": "Interface", + "widget.settings.interface.title": "Adjust the interface settings", + "widget.settings.interface.fps.automatic": "Set FPS to unlimited", + "widget.settings.interface.fps.warning": "Setting FPS to unlimited may cause performance issues!", + "widget.settings.interface.secondary": "Change the window header color", + "widget.settings.interface.reset": "Reset header color to default", + "widget.room.chat.hide_pets": "Hide pets", + "widget.room.chat.hide_avatars": "Hide avatars", + "widget.room.chat.hide_balloon": "Hide speech bubble", + "widget.room.chat.show_balloon": "Speech bubble", + "widget.room.chat.clear_history": "clear history", + "widget.room.youtube.shared": "YouTube is being shared", + "widget.room.youtube.open_video": "Open the video", + "wiredfurni.tooltip.select.tile": "Select tile", + "wiredfurni.tooltip.remove.tile": "Deselect tile", + "wiredfurni.tooltip.remove.5x5_tile": "select 5x5 tiles", + "wiredfurni.tooltip.remove.clear_tile": "Clear all selections", + "wiredfurni.params.furni_neighborhood.group.user": "Players", + "wiredfurni.params.furni_neighborhood.group.furni": "Furniture", + "wiredfurni.params.selector_option.bot": "No bots", + "wiredfurni.params.selector_option.pet": "No pets", + "catalog.title": "Catalog", + "catalog.favorites": "Favorites", + "catalog.favorites.pages": "Pages", + "catalog.favorites.furni": "Furni", + "catalog.favorites.empty": "No favorites", + "catalog.favorites.empty.hint": "Click the heart on furni or the star on pages to add them.", + "catalog.admin": "Admin", + "catalog.admin.new": "New", + "catalog.admin.root": "Root", + "catalog.admin.new.root.category": "New root category", + "catalog.admin.edit.root": "Edit Root", + "catalog.admin.edit": "Edit:", + "catalog.admin.edit.page": "Edit Page", + "catalog.admin.hidden": "hidden", + "catalog.admin.edit.title": "Edit \"%name%\"", + "catalog.admin.show": "Show", + "catalog.admin.hide": "Hide", + "catalog.admin.delete": "Delete", + "catalog.admin.delete.title": "Delete \"%name%\"", + "catalog.admin.delete.category.confirm": "Delete category \"%name%\" and all its content?", + "catalog.admin.delete.page": "Delete page", + "catalog.admin.delete.page.confirm": "Delete page \"%name%\"?", + "catalog.admin.delete.offer.confirm": "Are you sure you want to delete this offer?", + "catalog.admin.create": "Create", + "catalog.admin.save": "Save", + "catalog.admin.create.subpage": "Create sub-page", + "catalog.admin.order": "Order", + "catalog.admin.visible": "Visible", + "catalog.admin.enabled": "Enabled", + "catalog.admin.offer.new": "New Offer", + "catalog.admin.offer.edit": "Edit Offer", + "catalog.admin.offer.name": "Catalog Name", + "catalog.admin.offer.general": "General", + "catalog.admin.offer.quantity": "Quantity", + "catalog.admin.offer.prices": "Prices", + "catalog.admin.offer.credits": "Credits", + "catalog.admin.offer.points": "Points", + "catalog.admin.offer.points.type": "Points Type", + "catalog.admin.offer.options": "Options", + "catalog.admin.offer.club.only": "Club Only", + "catalog.admin.offer.extradata": "Extra Data (optional)....", + "catalog.admin.offer.have.offer": "Multi-discount (have_offer)", + "catalog.trophies.title": "Trophies", + "catalog.trophies.write.hint": "Write a text for the trophy before purchasing", + "catalog.trophies.inscription": "Trophy Inscription", + "catalog.trophies.inscription.placeholder": "Write the text that will appear on the trophy...", + "catalog.pets.show.colors": "Show colors", + "catalog.pets.choose.color": "Choose color", + "catalog.pets.choose.breed": "Choose breed", + "catalog.pets.back.breeds": "? Breeds", + "catalog.prefix.text": "Text", + "catalog.prefix.text.placeholder": "Enter text...", + "catalog.prefix.icon": "Icon", + "catalog.prefix.icon.remove": "Remove icon", + "catalog.prefix.effect": "Effect", + "catalog.prefix.color": "Color", + "catalog.prefix.color.single": "?? Single", + "catalog.prefix.color.per.letter": "?? Per Letter", + "catalog.prefix.color.hint": "Select a letter, then choose the color. Auto-advances.", + "catalog.prefix.color.apply.all.title": "Apply current color to all letters", + "catalog.prefix.color.apply.all": "Apply to all", + "catalog.prefix.color.selected": "Selected letter:", + "catalog.prefix.price": "Price:", + "catalog.prefix.price.amount": "5 Credits", + "catalog.prefix.purchased": "? Purchased!", + "catalog.prefix.purchase": "Purchase", + "modtools.userinfo.title": "User Info: %username%", + "modtools.userinfo.userName": "Name", + "modtools.userinfo.cfhCount": "CFHs", + "modtools.userinfo.abusiveCfhCount": "Abusive CFHs", + "modtools.userinfo.cautionCount": "Cautions", + "modtools.userinfo.banCount": "Bans", + "modtools.userinfo.lastSanctionTime": "Last Sanction", + "modtools.userinfo.tradingLockCount": "Trade Locks", + "modtools.userinfo.tradingExpiryDate": "Lock Expires", + "modtools.userinfo.minutesSinceLastLogin": "Last Login", + "modtools.userinfo.lastPurchaseDate": "Last Purchase", + "modtools.userinfo.primaryEmailAddress": "Email", + "modtools.userinfo.identityRelatedBanCount": "Banned Accs", + "modtools.userinfo.registrationAgeInMinutes": "Registered", + "modtools.userinfo.userClassification": "Rank", + "modtools.window.title": "Mod Tools", + "modtools.window.tools.room": "Room Tool", + "modtools.window.tools.chatlog": "Chatlog Tool", + "modtools.window.tools.report": "Report Tool", + "modtools.window.select.user": "Select a user", + "modtools.window.no.room": "Enter a room first", + "modtools.window.user.in_room": "Still in this room", + "modtools.window.user.left_room": "No longer in this room", + "modtools.window.user.clear": "Clear selection", + "modtools.window.tickets.open": "%count% open ticket", + "modtools.window.tickets.open.many": "%count% open tickets", + "modtools.window.section.room": "Room", + "modtools.window.section.user": "User", + "modtools.window.section.reports": "Reports", + "modtools.window.user.open_info": "Open Info", + "modtools.userinfo.refresh": "Refresh user info", + "modtools.userinfo.presence.in_room": "In room", + "modtools.userinfo.presence.in_room.title": "In the room you are observing", + "modtools.userinfo.presence.online": "Online", + "modtools.userinfo.presence.online.title": "Online on the hotel", + "modtools.userinfo.presence.offline": "Offline", + "modtools.userinfo.presence.offline.title": "Offline at panel open", + "modtools.userinfo.section.account": "Account", + "modtools.userinfo.section.activity": "Activity", + "modtools.userinfo.section.sanctions": "Sanctions", + "modtools.userinfo.section.trading": "Trading", + "modtools.userinfo.button.room.chat": "Room Chat", + "modtools.userinfo.button.send.message": "Send Message", + "modtools.userinfo.button.room.visits": "Room Visits", + "modtools.userinfo.button.mod.action": "Mod Action", + "modtools.userinfo.stat.cfh": "CFH", + "modtools.userinfo.stat.cautions": "Cautions", + "modtools.userinfo.stat.bans": "Bans", + "modtools.userinfo.stat.trade.locks": "Trade locks", + "modtools.roominfo.title": "Room Info", + "modtools.roominfo.refresh": "Refresh room info", + "modtools.roominfo.loading": "Loading…", + "modtools.roominfo.owner.here": "Owner here", + "modtools.roominfo.owner.away": "Owner away", + "modtools.roominfo.owner.title.here": "The room owner is currently inside", + "modtools.roominfo.owner.title.away": "The room owner is NOT inside", + "modtools.roominfo.stat.users": "Users", + "modtools.roominfo.stat.owner": "Owner", + "modtools.roominfo.owner.open": "Open %username%'s info", + "modtools.roominfo.button.visit": "Visit Room", + "modtools.roominfo.button.chatlog": "Chatlog", + "modtools.roominfo.moderate.title": "Moderate room", + "modtools.roominfo.moderate.kick": "Kick everyone out", + "modtools.roominfo.moderate.doorbell": "Enable the doorbell", + "modtools.roominfo.moderate.rename": "Change room name", + "modtools.roominfo.moderate.message.placeholder": "Mandatory message to deliver with the action…", + "modtools.roominfo.moderate.send.caution": "Send Caution", + "modtools.roominfo.moderate.send.alert": "Send Alert", + "modtools.user.message.title": "Send Message", + "modtools.user.message.recipient": "Message to", + "modtools.user.message.label": "Message", + "modtools.user.message.placeholder": "Write something useful — the user will see it as a moderator message.", + "modtools.user.message.empty": "Empty", + "modtools.user.message.chars": "%count% chars", + "modtools.user.message.send": "Send Message", + "modtools.user.modaction.title": "Mod Action: %username%", + "modtools.user.modaction.sanctioning": "Sanctioning", + "modtools.user.modaction.step.topic": "1. CFH Topic", + "modtools.user.modaction.step.topic.placeholder": "Select a topic…", + "modtools.user.modaction.step.sanction": "2. Sanction", + "modtools.user.modaction.step.sanction.placeholder": "Select a sanction…", + "modtools.user.modaction.step.message": "3. Custom message", + "modtools.user.modaction.step.message.optional": "(optional — overrides default)", + "modtools.user.modaction.message.placeholder": "Leave empty to use the default topic message", + "modtools.user.modaction.preview": "Preview", + "modtools.user.modaction.button.default": "Default Sanction", + "modtools.user.modaction.button.apply": "Apply Sanction", + "modtools.user.modaction.error.no.topic": "You must select a CFH topic", + "modtools.user.modaction.error.no.action": "You must select a CFH topic and Sanction", + "modtools.user.modaction.error.no.permission": "You do not have permission to do this", + "modtools.user.modaction.error.no.message": "Please write a message to user", + "modtools.user.modaction.error.no.permission.alert": "You have insufficient permissions", + "modtools.user.visits.title": "User Visits", + "modtools.user.visits.recent": "Recent visited rooms", + "modtools.user.visits.entries.one": "%count% entry", + "modtools.user.visits.entries.many": "%count% entries", + "modtools.user.visits.empty": "No recent visits", + "modtools.user.visits.time": "Time", + "modtools.user.visits.room": "Room name", + "modtools.user.visits.action": "Action", + "modtools.user.visits.visit": "Visit", + "modtools.user.visits.visit.title": "Visit room", + "modtools.user.chatlog.title": "User Chatlog", + "modtools.user.chatlog.title.with": "User Chatlog: %username%", + "modtools.user.chatlog.loading": "Loading chatlog…", + "modtools.room.chatlog.title": "Room Chatlog", + "modtools.chatlog.column.time": "Time", + "modtools.chatlog.column.user": "User", + "modtools.chatlog.column.message": "Message", + "modtools.chatlog.empty": "No messages", + "modtools.chatlog.visit": "Visit", + "modtools.chatlog.tools": "Tools", + "modtools.tickets.title": "Tickets", + "modtools.tickets.tab.open": "Open", + "modtools.tickets.tab.mine": "Mine", + "modtools.tickets.tab.picked": "All picked", + "modtools.tickets.column.type": "Type", + "modtools.tickets.column.reported": "Reported", + "modtools.tickets.column.opened": "Opened", + "modtools.tickets.column.picker": "Picker", + "modtools.tickets.empty.open": "No open issues", + "modtools.tickets.empty.mine": "No issues picked by you", + "modtools.tickets.empty.picked": "No picked issues", + "modtools.tickets.action.pick": "Pick", + "modtools.tickets.action.handle": "Handle", + "modtools.tickets.action.release": "Release", + "modtools.tickets.issue.title": "Resolving issue #%issueId%", + "modtools.tickets.issue.label": "Issue #%issueId%", + "modtools.tickets.issue.details": "Details", + "modtools.tickets.issue.field.source": "Source", + "modtools.tickets.issue.field.category": "Category", + "modtools.tickets.issue.field.description": "Description", + "modtools.tickets.issue.field.caller": "Caller", + "modtools.tickets.issue.field.reported": "Reported", + "modtools.tickets.issue.chatlog.view": "View chatlog", + "modtools.tickets.issue.chatlog.close": "Close chatlog", + "modtools.tickets.issue.resolve.heading": "Resolve as", + "modtools.tickets.issue.resolve.resolved": "Resolved", + "modtools.tickets.issue.resolve.useless": "Useless", + "modtools.tickets.issue.resolve.abusive": "Abusive", + "modtools.tickets.issue.release": "Release back to queue", + "modtools.tickets.cfh.chatlog.title": "Issue #%issueId% Chatlog", + "groupforum.list.tab.most_active": "Most active threads", + "groupforum.list.tab.my_forums": "My group forums", + "groupforum.list.no_forums": "There are no forums", + "groupforum.view.threads": "Number of threads", + "groupforum.thread.pin": "Pin thread", + "groupforum.thread.unpin": "Unpin thread", + "groupforum.thread.lock": "Lock thread", + "groupforum.thread.unlock": "Unlock thread", + "groupforum.thread.hide": "Hide thread", + "groupforum.thread.restore": "Restore thread", + "groupforum.thread.delete": "Delete thread + posts", + "groupforum.message.hide": "Hide message", + "group.forum.enable.caption": "Enable / Disable group forum", + "group.forum.enable.help": "If you disable the group forum, all posts will also be deleted!", + "groupforum.view.no_threads": "There are currently no active threads", + "loading.task.session": "Verifying session...", + "loading.task.renderer": "Initializing renderer...", + "loading.task.assets": "loading game assets...", + "loading.task.localization": "loading translations...", + "loading.task.avatar": "loading wardrobe...", + "loading.task.sounds": "loading sounds...", + "loading.task.startsession": "Starting session...", + "loading.task.userdata": "loading user data...", + "loading.task.rooms": "loading rooms...", + "loading.task.engine": "loading graphics engine...", } diff --git a/public/configuration/asset-loader.js b/public/configuration/asset-loader.js index 7483733..8a4a916 100644 --- a/public/configuration/asset-loader.js +++ b/public/configuration/asset-loader.js @@ -57,7 +57,10 @@ const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // Match the React LoadingView background so the pre-React shell paints + // the same gradient — no light-blue login-skeleton flash before the + // loader takes over. + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/public/configuration/renderer-config.example b/public/configuration/renderer-config.example index cff14e5..5745409 100644 --- a/public/configuration/renderer-config.example +++ b/public/configuration/renderer-config.example @@ -48,6 +48,9 @@ "timezone.settings": "Europe/Amsterdam", "youtube.publish.disabled": false, "user.badges.group.slot.enabled": true, + "loading.logo.url": "", + "loading.background": "", + "loading.progress.color": "linear-gradient(90deg,#4f8cff,#2563eb)", "login.screen.enabled": true, "login.endpoint": "${api.url}/api/auth/login", "login.register.endpoint": "${api.url}/api/auth/register", diff --git a/scripts/write-asset-loader.mjs b/scripts/write-asset-loader.mjs index 4b082fc..44976c6 100644 --- a/scripts/write-asset-loader.mjs +++ b/scripts/write-asset-loader.mjs @@ -228,7 +228,10 @@ const ASSET_LOADER_JS = `(() => { const renderShell = () => { const root = document.getElementById("root"); if(!root || root.firstChild) return; - root.innerHTML = '
'; + // Match the React LoadingView background so the pre-React shell paints + // the same gradient — no light-blue login-skeleton flash before the + // loader takes over. + root.innerHTML = '
'; }; const decodeAsset = (bytes) => { diff --git a/src/App.tsx b/src/App.tsx index 6c3f00f..55fce02 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useEffectEvent, useRef, useState } from 'react'; -import { ClearRememberLogin, GetRememberLogin, GetUIVersion, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; +import { ClearRememberLogin, GetRememberLogin, GetUIVersion, SetRememberLogin, StoreRememberLoginFromPayload, persistAccessTokenFromPayload } from './api'; import { Base } from './common'; import { LoadingView } from './components/loading/LoadingView'; import { LoginView } from './components/login/LoginView'; @@ -72,6 +72,38 @@ export const App: FC<{}> = props => const [ showLogin, setShowLogin ] = useState(false); const [ isEnteringHotel, setIsEnteringHotel ] = useState(() => !!window.NitroConfig?.['sso.ticket'] || hasRememberLogin()); const [ prepareTrigger, setPrepareTrigger ] = useState(0); + const [ loadingProgress, setLoadingProgress ] = useState(0); + const [ loadingTask, setLoadingTask ] = useState(''); + const taskLabel = useCallback((key: string, fallback: string): string => + { + try + { + const locManager = GetLocalizationManager(); + if(locManager && typeof locManager.getValue === 'function') + { + const fromLoc = locManager.getValue(key, false); + + if(typeof fromLoc === 'string' && fromLoc.length && fromLoc !== key) return fromLoc; + } + } + catch + { } + + try + { + const fromConfig = GetConfiguration().getValue(key, ''); + if(typeof fromConfig === 'string' && fromConfig.length) return fromConfig; + } + catch + { } + + return fallback; + }, []); + const bumpProgress = useCallback((value: number, task?: string) => + { + setLoadingProgress(prev => (value > prev ? value : prev)); + if(task !== undefined) setLoadingTask(task); + }, []); const warmupPromiseRef = useRef>(null); const rendererPromiseRef = useRef>(null); const gameInitPromiseRef = useRef | null>(null); @@ -88,9 +120,6 @@ export const App: FC<{}> = props => ClearRememberLogin(); try { delete (window as any).NitroConfig?.['sso.ticket']; } catch {} try { GetConfiguration().setValue('sso.ticket', ''); } catch {} - // Drop `?sso=` from the URL too — otherwise the next reload re-applies - // the same already-consumed ticket via bootstrap.ts and we loop right - // back into "Session expired" without ever showing the login form. try { const url = new URL(window.location.href); @@ -104,23 +133,6 @@ export const App: FC<{}> = props => catch {} }, []); - const fallbackToLogin = useCallback(() => - { - // Using console.warn (not NitroLogger.log) on purpose: NitroLogger - // is gated on LOG_DEBUG, which only flips to true once startWarmup's - // GetConfiguration().init() completes. Auth-failure paths fire before - // that, so NitroLogger swallows their messages silently. - console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared'); - // Wipe whatever credential the server just rejected so the form is - // pristine and the next attempt isn't sabotaged by the same stale data. - clearStoredCredentials(); - setHomeUrl(''); - setErrorMessage(''); - setIsReady(false); - setShowLogin(true); - setIsEnteringHotel(false); - }, [ clearStoredCredentials ]); - const showSessionExpired = useCallback(() => { console.warn('[App] showSessionExpired — diagnostic shown (mid-game close)'); @@ -134,6 +146,26 @@ export const App: FC<{}> = props => setIsEnteringHotel(false); }, [ clearStoredCredentials ]); + const fallbackToLogin = useCallback(() => + { + const rawLoginEnabled = GetConfiguration().getValue('login.screen.enabled', false); + const loginScreenEnabled = rawLoginEnabled === true || rawLoginEnabled === 'true' || rawLoginEnabled === 1; + + if(!loginScreenEnabled) + { + console.warn('[App] fallbackToLogin — login.screen.enabled=false, redirecting to home instead'); + showSessionExpired(); + return; + } + console.warn('[App] fallbackToLogin — surfacing login form, credentials cleared'); + clearStoredCredentials(); + setHomeUrl(''); + setErrorMessage(''); + setIsReady(false); + setShowLogin(true); + setIsEnteringHotel(false); + }, [ clearStoredCredentials, showSessionExpired ]); + const applySsoTicket = useCallback((ssoTicket: string) => { if(!ssoTicket) return; @@ -162,8 +194,6 @@ export const App: FC<{}> = props => if(!remembered?.token?.length) { - // No remember token means we'd be reusing a one-shot ssoTicket that - // the server already consumed. Force the login screen instead. if(remembered) ClearRememberLogin(); console.warn('[App] tryRememberLogin → no token, returning empty'); return ''; @@ -212,9 +242,6 @@ export const App: FC<{}> = props => console.warn('[App] tryRememberLogin → fetch threw', error); } - // Any failure (rejected token, bad payload, network error) — drop the - // stored credentials. Never fall back to the cached ssoTicket: it's - // one-shot and reusing it leads straight to "Session expired". ClearRememberLogin(); console.warn('[App] tryRememberLogin → cleared remember, returning empty'); @@ -265,35 +292,12 @@ export const App: FC<{}> = props => } }, []); - // Mirror isReady into a ref so the socket handlers below can read the - // freshest value without needing to re-subscribe on every state change. useEffect(() => { isReadyRef.current = isReady; }, [ isReady ]); - - // Track whether a reconnect cycle is active. The renderer dispatches - // SOCKET_RECONNECTING when it starts retrying after an abnormal close - // (code != 1000/1001). On exhausted retries it fires SOCKET_RECONNECT_FAILED - // *and* a final SOCKET_CLOSED — we keep the flag set through that pair - // so ReconnectView's own overlay owns the UX and we don't double-render. useNitroEvent(NitroEventType.SOCKET_RECONNECTING, () => { reconnectInProgressRef.current = true; }); useNitroEvent(NitroEventType.SOCKET_REAUTHENTICATED, () => { reconnectInProgressRef.current = false; }); useNitroEvent(NitroEventType.SOCKET_CLOSED, () => { - // Three distinct close scenarios converge here: - // - // 1. !isReady — initial handshake just failed (server replied - // with "Bye" / code 1000 to a bad SSO ticket). The user never - // had a session. Surface the login form instead of the - // misleading "Session expired" diagnostic. - // - // 2. isReady && reconnect in progress — ReconnectView already - // owns the UX (its overlay shows attempts and the "Session - // expired" message on RECONNECT_FAILED). Stay out of its way. - // - // 3. isReady && no reconnect — instant server kick mid-game - // (code 1000 from the server side). No reconnect path will - // run. Show the legacy session-expired diagnostic so the - // user knows to reload. console.warn('[App] SOCKET_CLOSED fired', { isReady: isReadyRef.current, reconnectInProgress: reconnectInProgressRef.current @@ -352,6 +356,7 @@ export const App: FC<{}> = props => warmupPromiseRef.current = (async () => { await GetConfiguration().init(); + bumpProgress(25, taskLabel('loader.waiting', 'Loading content...')); GetTicker().maxFPS = GetConfiguration().getValue('system.fps.max', 24); NitroLogger.LOG_DEBUG = GetConfiguration().getValue('system.log.debug', true); @@ -388,18 +393,25 @@ export const App: FC<{}> = props => loginImageUrls.forEach(preloadImage); gamedataUrls.forEach(url => preloadUrl(url)); - await Promise.all( - [ - GetAssetManager().downloadAssets(assetUrls), - GetLocalizationManager().init(), - GetAvatarRenderManager().init(), - GetSoundManager().init() - ] - ); + const warmupTasks: { promise: Promise; label: string }[] = [ + { promise: GetAssetManager().downloadAssets(assetUrls), label: taskLabel('loading.task.assets', 'Loading game assets...') }, + { promise: GetLocalizationManager().init(), label: taskLabel('loading.task.localization', 'Loading translations...') }, + { promise: GetAvatarRenderManager().init(), label: taskLabel('loading.task.avatar', 'Loading wardrobe...') }, + { promise: GetSoundManager().init(), label: taskLabel('loading.task.sounds', 'Loading sounds...') } + ]; + let warmupDone = 0; + const warmupStart = 25; + const warmupSpan = 45; + await Promise.all(warmupTasks.map(t => t.promise.then(value => + { + warmupDone++; + bumpProgress(warmupStart + Math.round((warmupSpan * warmupDone) / warmupTasks.length), t.label); + return value; + }))); })(); return warmupPromiseRef.current; - }, [ startRenderer ]); + }, [ startRenderer, bumpProgress, taskLabel ]); useEffect(() => { @@ -431,9 +443,14 @@ export const App: FC<{}> = props => hasNitroConfig: !!window.NitroConfig, ssoTicketInConfig: !!window.NitroConfig?.['sso.ticket'], hasRememberLocal: !!GetRememberLogin(), - urlSso: new URLSearchParams(window.location.search).get('sso') + hasUrlSso: !!new URLSearchParams(window.location.search).get('sso') }); + const bootLabel = taskLabel('loader', 'Booting...'); + setLoadingProgress(0); + setLoadingTask(bootLabel); + bumpProgress(5, bootLabel); + try { if(!window.NitroConfig) throw new Error('NitroConfig is not defined!'); @@ -441,10 +458,29 @@ export const App: FC<{}> = props => let ssoTicket = window.NitroConfig['sso.ticket']; if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket); + try + { + const urlParams = new URLSearchParams(window.location.search); + const tokenParam = urlParams.get('token'); + const tokenExpParam = urlParams.get('token_exp'); + if(tokenParam && !GetRememberLogin()) + { + const parsedExpiry = Number(tokenExpParam || 0); + const expiresAt = (Number.isFinite(parsedExpiry) && parsedExpiry > 0) + ? parsedExpiry + : Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60); + SetRememberLogin({ token: tokenParam, expiresAt }); + } + } + catch(e) + { + console.warn('[App] failed to persist remember token from URL', e); + } + + bumpProgress(10, taskLabel('loading.task.session', 'Verifying session...')); + if(!ssoTicket || ssoTicket === '') { - // Configuration is loaded lazily — fetch it up-front so the login - // screen toggle and Turnstile keys are available before we decide. let configInitError: unknown = null; try { @@ -506,17 +542,23 @@ export const App: FC<{}> = props => } const renderer = await startRenderer(width, height); + bumpProgress(20, taskLabel('loading.task.renderer', 'Initializing renderer...')); await startWarmup(width, height); + bumpProgress(70, taskLabel('loading.task.startsession', 'Starting session...')); if(!gameInitPromiseRef.current) { gameInitPromiseRef.current = (async () => { await GetSessionDataManager().init(); + bumpProgress(78, taskLabel('loading.task.userdata', 'Loading user data...')); await GetRoomSessionManager().init(); + bumpProgress(85, taskLabel('loading.task.rooms', 'Loading rooms...')); await GetRoomEngine().init(); + bumpProgress(92, taskLabel('loading.task.engine', 'Loading graphics engine...')); await GetCommunication().init(); + bumpProgress(98, taskLabel('generic.reconnecting', 'Connecting to server...')); })(); } @@ -545,6 +587,7 @@ export const App: FC<{}> = props => GetTicker().add(ticker => GetTexturePool().run()); } + bumpProgress(100, taskLabel('onboarding.button.ready', 'Ready!')); setIsReady(true); setShowLogin(false); setIsEnteringHotel(false); @@ -552,23 +595,10 @@ export const App: FC<{}> = props => catch(err) { NitroLogger.error('[App] Initialization failed — falling back to login', err); - // Anything thrown out of the post-auth chain (renderer init, - // asset download, GetCommunication().init()) is an init/connect - // failure, not session expiration. The credential we used is - // suspect — drop it and present the login form so the user - // can retry instead of getting stuck on a stale "Session expired". onInitFailure(); } }; - // React Strict Mode in dev runs every effect twice (mount → cleanup → mount). - // `prepare()` is full of one-shot side effects (renderer init, websocket - // connect, NitroConfig mutation) — calling it twice with the same trigger - // value causes the second pass to race with the first and clobber state - // (e.g. the second pass falls through to onSessionExpired while the first - // had just set showLogin=true). Guard by trigger value: skip duplicate - // runs at the same trigger, but still re-run when handleAuthenticated - // bumps prepareTrigger after a successful login. if(lastPrepareTriggerRef.current === prepareTrigger) return; lastPrepareTriggerRef.current = prepareTrigger; @@ -581,20 +611,14 @@ export const App: FC<{}> = props => if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current); if(rememberRotateIntervalRef.current !== null) window.clearInterval(rememberRotateIntervalRef.current); }; - }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin ]); + }, [ prepareTrigger, startWarmup, startRenderer, tryRememberLogin, applySsoTicket, rotateRememberLogin, bumpProgress, taskLabel ]); return ( { !isReady && !showLogin && - 0 } message={ errorMessage } homeUrl={ homeUrl } /> } + 0 } message={ errorMessage } homeUrl={ homeUrl } progress={ loadingProgress } currentTask={ loadingTask } /> } { !isReady && showLogin && } { isReady && } - { /* Reconnect overlay must NOT render before we've actually entered - the hotel — otherwise the renderer's auto-retry on an initial - handshake failure (e.g. emulator unreachable) would cover the - login form with "Reconnecting..." → "Session expired" and the - user wouldn't be able to interact with the form we just put up - via fallbackToLogin. */ } { isReady && } diff --git a/src/common/Button.tsx b/src/common/Button.tsx index 06d6bd7..b7cd1b0 100644 --- a/src/common/Button.tsx +++ b/src/common/Button.tsx @@ -19,7 +19,7 @@ export const Button: FC = props => // fucked up method i know (i dont have a clue what im doing because im a ninja) - const newClassNames: string[] = [ 'pointer-events-auto inline-block font-normal leading-normal text-[#fff] text-center no-underline align-middle cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ]; + const newClassNames: string[] = [ 'pointer-events-auto font-normal leading-normal text-[#fff] text-center no-underline cursor-pointer select-none border border-[solid] border-transparent px-[.75rem] py-[.375rem] text-[.9rem] rounded-[.25rem] [transition:color_.15s_ease-in-out,background-color_.15s_ease-in-out,border-color_.15s_ease-in-out,box-shadow_.15s_ease-in-out]' ]; if(variant) { @@ -67,5 +67,5 @@ export const Button: FC = props => return newClassNames; }, [ variant, size, active, disabled, classNames ]); - return ; + return ; }; diff --git a/src/common/draggable-window/DraggableWindow.tsx b/src/common/draggable-window/DraggableWindow.tsx index e3b8695..20a17e9 100644 --- a/src/common/draggable-window/DraggableWindow.tsx +++ b/src/common/draggable-window/DraggableWindow.tsx @@ -30,7 +30,7 @@ export const DraggableWindow: FC = props => const [isDragging, setIsDragging] = useState(false); const [isPositioned, setIsPositioned] = useState(false); const [dragHandler, setDragHandler] = useState(null); - const elementRef = useRef(); + const elementRef = useRef(null); const bringToTop = useCallback(() => { let zIndex = 400; for (const existingWindow of CURRENT_WINDOWS) diff --git a/src/components/avatar-effects/AvatarEffectsView.tsx b/src/components/avatar-effects/AvatarEffectsView.tsx index 887b9fb..92adb01 100644 --- a/src/components/avatar-effects/AvatarEffectsView.tsx +++ b/src/components/avatar-effects/AvatarEffectsView.tsx @@ -1,4 +1,4 @@ -import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, AvatarDirectionAngle, AvatarEffectActivatedComposer, GetConfiguration, GetSessionDataManager, ILinkEventTracker, loadGamedata, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; import { ChangeEvent, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FaChevronLeft, FaChevronRight, FaSearch } from 'react-icons/fa'; import { LocalizeText, SendMessageComposer } from '../../api'; @@ -65,9 +65,11 @@ export const AvatarEffectsView: FC<{}> = () => { try { - const response = await fetch(url); - if(!response.ok) throw new Error(`HTTP ${ response.status }`); - const json = await response.json(); + // The effectmap is served either as a single JSON file or as a + // tiered directory with core/custom/seasonal manifests using + // JSON5 syntax (// comments allowed). loadGamedata picks the + // right mode for us and merges tiers. + const json = await loadGamedata<{ effects?: EffectMapEntry[] }>(url); if(cancelled) return; const list: EffectMapEntry[] = Array.isArray(json?.effects) diff --git a/src/components/loading/LoadingView.tsx b/src/components/loading/LoadingView.tsx index 30e3c0c..f0542dd 100644 --- a/src/components/loading/LoadingView.tsx +++ b/src/components/loading/LoadingView.tsx @@ -1,22 +1,77 @@ -import { FC } from 'react'; +import { GetConfiguration } from '@nitrots/nitro-renderer'; +import { FC, useMemo } from 'react'; import loadingGif from '@/assets/images/loading/loading.gif'; +import nitroV3Logo from '@/assets/images/notifications/nitro_v3.png'; import { Base, Column, Text } from '../../common'; interface LoadingViewProps { isError?: boolean; message?: string; homeUrl?: string; + progress?: number; + currentTask?: string; } +const resolveConfigUrl = (key: string): string => +{ + try + { + const raw = GetConfiguration().getValue(key, ''); + if(!raw) return ''; + + const interpolated = GetConfiguration().interpolate(raw) || raw; + return interpolated; + } + catch + { + return ''; + } +}; + +const resolveConfigString = (key: string, fallback = ''): string => +{ + try + { + const raw = GetConfiguration().getValue(key, ''); + if(!raw) return fallback; + return raw; + } + catch + { + return fallback; + } +}; + export const LoadingView: FC = props => { - const { isError = false, message = '', homeUrl = '' } = props; + const { isError = false, message = '', homeUrl = '', progress, currentTask = '' } = props; + + const customLogoUrl = useMemo(() => resolveConfigUrl('loading.logo.url'), []); + const customBackground = useMemo(() => resolveConfigString('loading.background', ''), []); + const progressBarColor = useMemo(() => resolveConfigString('loading.progress.color', 'linear-gradient(90deg,#4f8cff,#2563eb)'), []); + + const clampedProgress = typeof progress === 'number' && Number.isFinite(progress) + ? Math.max(0, Math.min(100, Math.round(progress))) + : null; + + const backgroundStyle = customBackground + ? { background: customBackground } + : undefined; + + const backgroundClassName = customBackground + ? 'fixed inset-0 z-[2147483000]' + : 'fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]'; return ( - + + Nitro V3 - { isError && (message && message.length) ? @@ -32,15 +87,59 @@ export const LoadingView: FC = props => } : - - - { message && message.length ? - - { message } - - : null + <> + + + { message && message.length ? + + { message } + + : null + } + + { clampedProgress !== null && + + + + + { clampedProgress }% + + + + { currentTask } + + } - + } diff --git a/src/components/mod-tools/ModToolsView.tsx b/src/components/mod-tools/ModToolsView.tsx index 181eb0c..fa602ff 100644 --- a/src/components/mod-tools/ModToolsView.tsx +++ b/src/components/mod-tools/ModToolsView.tsx @@ -1,6 +1,6 @@ import { AddLinkEventTracker, CreateLinkEvent, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomId, RoomObjectCategory, RoomObjectType } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { FaTimes } from 'react-icons/fa'; +import { FaTimes, FaUserSlash } from 'react-icons/fa'; import { GetRoomSession, ISelectedUser, LocalizeText } from '../../api'; import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../common'; import { useModTools, useNitroEvent, useObjectSelectedEvent, useRoomUserListSnapshot } from '../../hooks'; @@ -134,63 +134,82 @@ export const ModToolsView: FC<{}> = props => return () => RemoveLinkEventTracker(linkTracker); }, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]); - const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId); - const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId); + const isInRoom = currentRoomId > 0; + const isRoomInfoOpen = isInRoom && openRooms.includes(currentRoomId); + const isRoomChatlogOpen = isInRoom && openRoomChatlogs.includes(currentRoomId); const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId); - const noRoomHint = LocalizeText('mod.tools.no.room') || 'Enter a room first'; + const noRoomHint = LocalizeText('modtools.window.no.room'); return ( <> { isVisible && - - setIsVisible(false) } /> + + setIsVisible(false) } /> - - - + + + + {/* Selected user */} +
+
{ LocalizeText('modtools.window.section.user') }
{ selectedUser ? ( - <> - { selectedUser.username } - - - { - event.stopPropagation(); - setSelectedUser(null); - } } - role="button" - tabIndex={ 0 } - title="Clear selection"> - - - +
+
+ + { selectedUser.username } + +
+ +
+ ) + : ( +
+ + { LocalizeText('modtools.window.select.user') } +
) - : Select a user } - - +
+ + {/* Reports */} +
+
{ LocalizeText('modtools.window.section.reports') }
+ +
} { (openRooms.length > 0) && openRooms.map(roomId => CreateLinkEvent(`mod-tools/close-room-info/${ roomId }`) } />) } diff --git a/src/components/mod-tools/views/chatlog/ChatlogView.tsx b/src/components/mod-tools/views/chatlog/ChatlogView.tsx index 63e5201..ffd489e 100644 --- a/src/components/mod-tools/views/chatlog/ChatlogView.tsx +++ b/src/components/mod-tools/views/chatlog/ChatlogView.tsx @@ -1,7 +1,8 @@ import { ChatRecordData, CreateLinkEvent } from '@nitrots/nitro-renderer'; import { FC, useMemo } from 'react'; -import { TryVisitRoom } from '../../../../api'; -import { Button, Column, Flex, Grid, InfiniteScroll, Text } from '../../../../common'; +import { FaCommentDots, FaDoorOpen, FaSignInAlt, FaTools } from 'react-icons/fa'; +import { LocalizeText, TryVisitRoom } from '../../../../api'; +import { Column, InfiniteScroll } from '../../../../common'; import { useModTools } from '../../../../hooks'; import { ChatlogRecord } from './ChatlogRecord'; @@ -43,46 +44,61 @@ export const ChatlogView: FC = props => return results; }, [ records ]); - const RoomInfo = (props: { roomId: number, roomName: string }) => - { - return ( - - { props.roomName } -
- - -
-
- ); - }; + const totalMessages = useMemo( + () => allRecords.filter(r => !r.isRoomInfo).length, + [ allRecords ] + ); + + const RoomInfo = (props: { roomId: number, roomName: string }) => ( +
+ +
{ props.roomName }
+
+ + +
+
+ ); + + const isEmpty = !records || records.length === 0 || totalMessages === 0; return ( - <> - - - -
Time
-
User
-
Message
-
-
- { (records && (records.length > 0)) && - - { - return ( - <> - { row.isRoomInfo && - } - { !row.isRoomInfo && - - { row.timestamp } - CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username } - { row.message } - } - - ); - } } rows={ allRecords } /> } -
- + + {/* Column headers */} +
+
{ LocalizeText('modtools.chatlog.column.time') }
+
{ LocalizeText('modtools.chatlog.column.user') }
+
{ LocalizeText('modtools.chatlog.column.message') }
+
+ { isEmpty + ?
+ + { LocalizeText('modtools.chatlog.empty') } +
+ : + { + if(row.isRoomInfo) return ; + + return ( +
+ { row.timestamp } + + { row.message } +
+ ); + } } rows={ allRecords } /> } +
); }; diff --git a/src/components/mod-tools/views/room/ModToolsChatlogView.tsx b/src/components/mod-tools/views/room/ModToolsChatlogView.tsx index 65061be..137d225 100644 --- a/src/components/mod-tools/views/room/ModToolsChatlogView.tsx +++ b/src/components/mod-tools/views/room/ModToolsChatlogView.tsx @@ -1,7 +1,9 @@ import { ChatRecordData, GetRoomChatlogMessageComposer, RoomChatlogEvent } from '@nitrots/nitro-renderer'; -import { FC } from 'react'; -import { useNitroQuery } from '../../../../api/nitro-query'; +import { FC, useEffect, useState } from 'react'; +import { FaSpinner } from 'react-icons/fa'; +import { LocalizeText, SendMessageComposer } from '../../../../api'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useMessageEvent } from '../../../../hooks'; import { ChatlogView } from '../chatlog/ChatlogView'; interface ModToolsChatlogViewProps @@ -13,24 +15,32 @@ interface ModToolsChatlogViewProps export const ModToolsChatlogView: FC = props => { const { roomId = null, onCloseClick = null } = props; + const [ roomChatlog, setRoomChatlog ] = useState(null); - const { data: roomChatlog } = useNitroQuery({ - key: [ 'nitro', 'mod-tools', 'room-chatlog', roomId ], - request: () => new GetRoomChatlogMessageComposer(roomId), - parser: RoomChatlogEvent, - accept: e => e.getParser()?.data.roomId === roomId, - select: e => e.getParser().data, - enabled: roomId !== null + useMessageEvent(RoomChatlogEvent, event => + { + const parser = event.getParser(); + + if(!parser || parser.data.roomId !== roomId) return; + + setRoomChatlog(parser.data); }); - if(!roomChatlog) return null; + useEffect(() => + { + SendMessageComposer(new GetRoomChatlogMessageComposer(roomId)); + }, [ roomId ]); return ( - - - - { roomChatlog && - } + + + + { roomChatlog + ? + :
+ + { LocalizeText('modtools.user.chatlog.loading') } +
}
); diff --git a/src/components/mod-tools/views/room/ModToolsRoomView.tsx b/src/components/mod-tools/views/room/ModToolsRoomView.tsx index 37d9fc5..951413f 100644 --- a/src/components/mod-tools/views/room/ModToolsRoomView.tsx +++ b/src/components/mod-tools/views/room/ModToolsRoomView.tsx @@ -1,7 +1,8 @@ import { CreateLinkEvent, GetModeratorRoomInfoMessageComposer, ModerateRoomMessageComposer, ModeratorActionMessageComposer, ModeratorRoomInfoEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; -import { SendMessageComposer, TryVisitRoom } from '../../../../api'; -import { Button, Column, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; +import { FaBullhorn, FaCommentDots, FaDoorOpen, FaExclamationTriangle, FaSignInAlt, FaSync, FaUserShield, FaUsers } from 'react-icons/fa'; +import { LocalizeText, SendMessageComposer, TryVisitRoom } from '../../../../api'; +import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { useMessageEvent } from '../../../../hooks'; interface ModToolsRoomViewProps @@ -25,7 +26,9 @@ export const ModToolsRoomView: FC = props => const [ changeRoomName, setChangeRoomName ] = useState(false); const [ message, setMessage ] = useState(''); - const handleClick = (action: string, value?: string) => + const refresh = () => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId)); + + const handleClick = (action: string) => { if(!action) return; @@ -66,55 +69,102 @@ export const ModToolsRoomView: FC = props => SendMessageComposer(new GetModeratorRoomInfoMessageComposer(roomId)); setInfoRequested(true); - }, [ roomId, infoRequested, setInfoRequested ]); + }, [ roomId, infoRequested ]); + + const isLoaded = loadedRoomId !== null; + const hasMessage = message.trim().length > 0; + const ownerPillClass = ownerInRoom + ? 'bg-emerald-100 text-emerald-700 border-emerald-200' + : 'bg-zinc-100 text-zinc-600 border-zinc-200'; + const ownerDotClass = ownerInRoom ? 'bg-emerald-500' : 'bg-zinc-400'; return ( - - onCloseClick() } /> + + onCloseClick() } /> - { name && -
- { name } + {/* Identity header */} +
+ +
+ { name || LocalizeText('modtools.roominfo.loading') } + #{ roomId }
- } -
- -
- Owner: - CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName } + + + { ownerInRoom ? LocalizeText('modtools.roominfo.owner.here') : LocalizeText('modtools.roominfo.owner.away') } + + +
+ + {/* Stat strip */} +
+
+
+ { LocalizeText('modtools.roominfo.stat.users') }
-
- Users in room: - { usersInRoom } +
{ usersInRoom }
+
+
+
+ { LocalizeText('modtools.roominfo.stat.owner') }
-
- Owner here: - { ownerInRoom ? 'Yes' : 'No' } +
ownerId && CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) } + title={ ownerName ? LocalizeText('modtools.roominfo.owner.open', [ 'username' ], [ ownerName ]) : '' }> + { ownerName || '-' }
- -
- -
- -
+ + {/* Quick actions */} +
+ + +
+ + {/* Moderate panel */} +
+
+ { LocalizeText('modtools.roominfo.moderate.title') } +
+
-
+ { LocalizeText('modtools.roominfo.moderate.kick') } + +
-
+ { LocalizeText('modtools.roominfo.moderate.doorbell') } + + + -
- -
diff --git a/src/components/mod-tools/views/tickets/CfhChatlogView.tsx b/src/components/mod-tools/views/tickets/CfhChatlogView.tsx index 33cc52d..e80b70a 100644 --- a/src/components/mod-tools/views/tickets/CfhChatlogView.tsx +++ b/src/components/mod-tools/views/tickets/CfhChatlogView.tsx @@ -1,7 +1,9 @@ import { CfhChatlogData, CfhChatlogEvent, GetCfhChatlogMessageComposer } from '@nitrots/nitro-renderer'; -import { FC } from 'react'; -import { useNitroQuery } from '../../../../api/nitro-query'; -import { NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { FC, useEffect, useState } from 'react'; +import { FaSpinner } from 'react-icons/fa'; +import { LocalizeText, SendMessageComposer } from '../../../../api'; +import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useMessageEvent } from '../../../../hooks'; import { ChatlogView } from '../chatlog/ChatlogView'; interface CfhChatlogViewProps @@ -13,21 +15,32 @@ interface CfhChatlogViewProps export const CfhChatlogView: FC = props => { const { onCloseClick = null, issueId = null } = props; + const [ chatlogData, setChatlogData ] = useState(null); - const { data: chatlogData } = useNitroQuery({ - key: [ 'nitro', 'mod-tools', 'cfh-chatlog', issueId ], - request: () => new GetCfhChatlogMessageComposer(issueId), - parser: CfhChatlogEvent, - accept: e => e.getParser()?.data.issueId === issueId, - select: e => e.getParser().data, - enabled: issueId !== null + useMessageEvent(CfhChatlogEvent, event => + { + const parser = event.getParser(); + + if(!parser || parser.data.issueId !== issueId) return; + + setChatlogData(parser.data); }); + useEffect(() => + { + SendMessageComposer(new GetCfhChatlogMessageComposer(issueId)); + }, [ issueId ]); + return ( - - - - { chatlogData && } + + + + { chatlogData + ? + :
+ + { LocalizeText('modtools.user.chatlog.loading') } +
}
); diff --git a/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx b/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx index 7444a73..02911d4 100644 --- a/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsIssueInfoView.tsx @@ -1,7 +1,8 @@ import { CloseIssuesMessageComposer, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useState } from 'react'; +import { FaBan, FaCheck, FaCommentDots, FaExternalLinkAlt, FaSignOutAlt, FaTrashAlt } from 'react-icons/fa'; import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api'; -import { Button, Column, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; +import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { useModTools } from '../../../../hooks'; import { CfhChatlogView } from './CfhChatlogView'; @@ -11,76 +12,102 @@ interface IssueInfoViewProps onIssueInfoClosed(issueId: number): void; } +const Field: FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => ( + <> +
{ label }
+
{ children || - }
+ +); + export const ModToolsIssueInfoView: FC = props => { const { issueId = null, onIssueInfoClosed = null } = props; - const [ cfhChatlogOpen, setcfhChatlogOpen ] = useState(false); + const [ cfhChatlogOpen, setCfhChatlogOpen ] = useState(false); const { tickets = [], openUserInfo = null } = useModTools(); const ticket = tickets.find(issue => (issue.issueId === issueId)); - const releaseIssue = (issueId: number) => + const releaseIssue = () => { SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ])); - onIssueInfoClosed(issueId); }; const closeIssue = (resolutionType: number) => { SendMessageComposer(new CloseIssuesMessageComposer([ issueId ], resolutionType)); - onIssueInfoClosed(issueId); }; + if(!ticket) return null; + return ( <> - - onIssueInfoClosed(issueId) } /> - - Issue Information - - - - - - - - - - - - - - - - - - - - - - - - - -
Source{ GetIssueCategoryName(ticket.categoryId) }
Category{ LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) }
Description{ ticket.message }
Caller - openUserInfo(ticket.reporterUserId) }>{ ticket.reporterUserName } -
Reported User - openUserInfo(ticket.reportedUserId) }>{ ticket.reportedUserName } -
-
- - - - - - - -
+ + onIssueInfoClosed(issueId) } /> + + {/* Issue header */} +
+ +
+
{ LocalizeText('modtools.tickets.issue.label', [ 'issueId' ], [ issueId.toString() ]) }
+
{ GetIssueCategoryName(ticket.categoryId) }
+
+ + { LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) } + +
+ + {/* Details */} +
+
{ LocalizeText('modtools.tickets.issue.details') }
+
+ { GetIssueCategoryName(ticket.categoryId) } + { LocalizeText('help.cfh.topic.' + ticket.reportedCategoryId) } + { ticket.message } + + + + + + +
+
+ + {/* Tools */} + + + {/* Resolution buttons */} +
+
{ LocalizeText('modtools.tickets.issue.resolve.heading') }
+
+ + + +
+ +
{ cfhChatlogOpen && - setcfhChatlogOpen(false) }/> } + setCfhChatlogOpen(false) } /> } ); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx index 9aaa441..e474775 100644 --- a/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsMyIssuesTabView.tsx @@ -1,7 +1,7 @@ import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useRef } from 'react'; -import { SendMessageComposer } from '../../../../api'; -import { Button, Column, Grid } from '../../../../common'; +import { FaClock, FaInbox, FaSignOutAlt, FaTools, FaUser } from 'react-icons/fa'; +import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api'; interface ModToolsMyIssuesTabViewProps { @@ -24,35 +24,45 @@ export const ModToolsMyIssuesTabView: FC = props = setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000); }; + const isEmpty = !myIssues || myIssues.length === 0; + return ( - - - -
Type
-
Room/Player
-
Opened
-
-
-
-
- - { myIssues && (myIssues.length > 0) && myIssues.map(issue => - { - return ( - -
{ issue.categoryId }
-
{ issue.reportedUserName }
-
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
-
- -
-
- -
-
- ); - }) } -
-
+
+
+
{ LocalizeText('modtools.tickets.column.type') }
+
{ LocalizeText('modtools.tickets.column.reported') }
+
{ LocalizeText('modtools.tickets.column.opened') }
+
+
+
+ { isEmpty + ?
+ + { LocalizeText('modtools.tickets.empty.mine') } +
+ :
+ { myIssues.map(issue => ( +
+ + { GetIssueCategoryName(issue.categoryId) } + + { issue.reportedUserName } + + { new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() } + + + +
+ )) } +
} +
); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx index 387580b..a3b3c54 100644 --- a/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsOpenIssuesTabView.tsx @@ -1,7 +1,7 @@ import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useRef } from 'react'; -import { SendMessageComposer } from '../../../../api'; -import { Button, Column, Grid } from '../../../../common'; +import { FaClock, FaHandPointer, FaInbox, FaUser } from 'react-icons/fa'; +import { GetIssueCategoryName, LocalizeText, SendMessageComposer } from '../../../../api'; interface ModToolsOpenIssuesTabViewProps { @@ -23,31 +23,39 @@ export const ModToolsOpenIssuesTabView: FC = pro setTimeout(() => pendingPicksRef.current.delete(issueId), 2000); }; + const isEmpty = !openIssues || openIssues.length === 0; + return ( - - - -
Type
-
Room/Player
-
Opened
-
-
-
- - { openIssues && (openIssues.length > 0) && openIssues.map(issue => - { - return ( - -
{ issue.categoryId }
-
{ issue.reportedUserName }
-
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
-
- -
-
- ); - }) } -
-
+
+
+
{ LocalizeText('modtools.tickets.column.type') }
+
{ LocalizeText('modtools.tickets.column.reported') }
+
{ LocalizeText('modtools.tickets.column.opened') }
+
+
+ { isEmpty + ?
+ + { LocalizeText('modtools.tickets.empty.open') } +
+ :
+ { openIssues.map(issue => ( +
+ + { GetIssueCategoryName(issue.categoryId) } + + { issue.reportedUserName } + + { new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() } + + +
+ )) } +
} +
); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx b/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx index ca6003e..f8c1194 100644 --- a/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsPickedIssuesTabView.tsx @@ -1,6 +1,7 @@ import { IssueMessageData } from '@nitrots/nitro-renderer'; import { FC } from 'react'; -import { Column, Grid } from '../../../../common'; +import { FaClock, FaInbox, FaUser, FaUserShield } from 'react-icons/fa'; +import { GetIssueCategoryName, LocalizeText } from '../../../../api'; interface ModToolsPickedIssuesTabViewProps { @@ -10,30 +11,35 @@ interface ModToolsPickedIssuesTabViewProps export const ModToolsPickedIssuesTabView: FC = props => { const { pickedIssues = null } = props; + const isEmpty = !pickedIssues || pickedIssues.length === 0; return ( - - - -
Type
-
Room/Player
-
Opened
-
Picker
-
-
- - { pickedIssues && (pickedIssues.length > 0) && pickedIssues.map(issue => - { - return ( - -
{ issue.categoryId }
-
{ issue.reportedUserName }
-
{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }
-
{ issue.pickerUserName }
-
- ); - }) } -
-
+
+
+
{ LocalizeText('modtools.tickets.column.type') }
+
{ LocalizeText('modtools.tickets.column.reported') }
+
{ LocalizeText('modtools.tickets.column.opened') }
+
{ LocalizeText('modtools.tickets.column.picker') }
+
+ { isEmpty + ?
+ + { LocalizeText('modtools.tickets.empty.picked') } +
+ :
+ { pickedIssues.map(issue => ( +
+ + { GetIssueCategoryName(issue.categoryId) } + + { issue.reportedUserName } + + { new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() } + + { issue.pickerUserName } +
+ )) } +
} +
); }; diff --git a/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx b/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx index ab7ac35..aa7d43f 100644 --- a/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx +++ b/src/components/mod-tools/views/tickets/ModToolsTicketsView.tsx @@ -1,5 +1,7 @@ import { GetSessionDataManager, IssueMessageData } from '@nitrots/nitro-renderer'; -import { FC, useState } from 'react'; +import { FC, useMemo, useState } from 'react'; +import { FaCheckSquare, FaListUl, FaUserCheck } from 'react-icons/fa'; +import { LocalizeText } from '../../../../api'; import { NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView } from '../../../../common'; import { useModTools } from '../../../../hooks'; import { ModToolsIssueInfoView } from './ModToolsIssueInfoView'; @@ -12,11 +14,30 @@ interface ModToolsTicketsViewProps onCloseClick: () => void; } -const TABS: string[] = [ - 'Open Issues', - 'My Issues', - 'Picked Issues' -]; +interface TabBadgeProps +{ + label: string; + count: number; + icon: React.ReactNode; + tone: 'amber' | 'sky' | 'zinc'; +} + +const TONE_MAP: Record = { + amber: 'bg-amber-500 text-white', + sky: 'bg-sky-500 text-white', + zinc: 'bg-zinc-400 text-white' +}; + +const TabLabel: FC = ({ label, count, icon, tone }) => ( + + { icon } + { label } + { count > 0 && + + { count > 99 ? '99+' : count } + } + +); export const ModToolsTicketsView: FC = props => { @@ -25,9 +46,15 @@ export const ModToolsTicketsView: FC = props => const [ issueInfoWindows, setIssueInfoWindows ] = useState([]); const { tickets = [] } = useModTools(); - const openIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN); - const myIssues = tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === GetSessionDataManager().userId)); - const pickedIssues = tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED); + const { openIssues, myIssues, pickedIssues } = useMemo(() => + { + const ownId = GetSessionDataManager()?.userId; + return { + openIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_OPEN), + myIssues: tickets.filter(issue => (issue.state === IssueMessageData.STATE_PICKED) && (issue.pickerUserId === ownId)), + pickedIssues: tickets.filter(issue => issue.state === IssueMessageData.STATE_PICKED) + }; + }, [ tickets ]); const closeIssue = (issueId: number) => { @@ -56,32 +83,34 @@ export const ModToolsTicketsView: FC = props => }); }; - const CurrentTabComponent = () => + const renderTab = () => { switch(currentTab) { - case 0: return ; - case 1: return ; - case 2: return ; + case 0: return ; + case 1: return ; + case 2: return ; } - return null; }; return ( <> - - + + - { TABS.map((tab, index) => - { - return ( setCurrentTab(index) }> - { tab } - ); - }) } + setCurrentTab(0) }> + } tone="amber" /> + + setCurrentTab(1) }> + } tone="sky" /> + + setCurrentTab(2) }> + } tone="zinc" /> + - + { renderTab() } { issueInfoWindows && (issueInfoWindows.length > 0) && issueInfoWindows.map(issueId => ) } diff --git a/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx b/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx index acae308..ef7622e 100644 --- a/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx +++ b/src/components/mod-tools/views/user/ModToolsUserChatlogView.tsx @@ -1,6 +1,7 @@ import { ChatRecordData, GetUserChatlogMessageComposer, UserChatlogEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; -import { SendMessageComposer } from '../../../../api'; +import { FaSpinner } from 'react-icons/fa'; +import { LocalizeText, SendMessageComposer } from '../../../../api'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { useMessageEvent } from '../../../../hooks'; import { ChatlogView } from '../chatlog/ChatlogView'; @@ -33,11 +34,15 @@ export const ModToolsUserChatlogView: FC = props = }, [ userId ]); return ( - - - - { userChatlog && - } + + + + { userChatlog + ? + :
+ + { LocalizeText('modtools.user.chatlog.loading') } +
}
); diff --git a/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx b/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx index 1bea10a..76c265f 100644 --- a/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx +++ b/src/components/mod-tools/views/user/ModToolsUserModActionView.tsx @@ -1,7 +1,8 @@ import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useMemo, useRef, useState } from 'react'; +import { FaBan, FaBolt, FaEnvelope, FaExclamationTriangle, FaGavel, FaUserSlash, FaVolumeMute } from 'react-icons/fa'; import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api'; -import { Button, DraggableWindowPosition, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; +import { Button, DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { useModTools, useNotification } from '../../../../hooks'; interface ModToolsUserModActionViewProps @@ -25,6 +26,24 @@ const MOD_ACTION_DEFINITIONS = [ new ModActionDefinition(105, 'Message', ModActionDefinition.MESSAGE, 0, 0), ]; +const ACTION_ICONS: Record = { + [ModActionDefinition.ALERT]: , + [ModActionDefinition.MUTE]: , + [ModActionDefinition.BAN]: , + [ModActionDefinition.KICK]: , + [ModActionDefinition.TRADE_LOCK]: , + [ModActionDefinition.MESSAGE]: , +}; + +const ACTION_TONE: Record = { + [ModActionDefinition.ALERT]: 'bg-amber-100 text-amber-800 border-amber-200', + [ModActionDefinition.MUTE]: 'bg-sky-100 text-sky-800 border-sky-200', + [ModActionDefinition.BAN]: 'bg-rose-100 text-rose-800 border-rose-200', + [ModActionDefinition.KICK]: 'bg-orange-100 text-orange-800 border-orange-200', + [ModActionDefinition.TRADE_LOCK]: 'bg-fuchsia-100 text-fuchsia-800 border-fuchsia-200', + [ModActionDefinition.MESSAGE]: 'bg-zinc-100 text-zinc-800 border-zinc-200', +}; + export const ModToolsUserModActionView: FC = props => { const { user = null, onCloseClick = null } = props; @@ -50,26 +69,20 @@ export const ModToolsUserModActionView: FC = pro return values; }, [ cfhCategories ]); - const sendAlert = (message: string) => simpleAlert(message, NotificationAlertType.DEFAULT, null, null, 'Error'); + const sendAlert = (m: string) => simpleAlert(m, NotificationAlertType.DEFAULT, null, null, 'Error'); const sendDefaultSanction = () => { if(isSendingRef.current) return; - let errorMessage: string = null; - const category = topics[selectedTopic]; - if(selectedTopic === -1) errorMessage = 'You must select a CFH topic'; - - if(errorMessage) return sendAlert(errorMessage); + if(selectedTopic === -1) return sendAlert(LocalizeText('modtools.user.modaction.error.no.topic')); const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message; isSendingRef.current = true; - SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault)); - onCloseClick(); }; @@ -78,34 +91,22 @@ export const ModToolsUserModActionView: FC = pro if(isSendingRef.current) return; let errorMessage: string = null; - const category = topics[selectedTopic]; const sanction = MOD_ACTION_DEFINITIONS[selectedAction]; - if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = 'You must select a CFH topic and Sanction'; - else if(!settings || !settings.cfhPermission) errorMessage = 'You do not have permission to do this'; - else if(!category) errorMessage = 'You must select a CFH topic'; - else if(!sanction) errorMessage = 'You must select a sanction'; + if((selectedTopic === -1) || (selectedAction === -1)) errorMessage = LocalizeText('modtools.user.modaction.error.no.action'); + else if(!settings || !settings.cfhPermission) errorMessage = LocalizeText('modtools.user.modaction.error.no.permission'); + else if(!category) errorMessage = LocalizeText('modtools.user.modaction.error.no.topic'); + else if(!sanction) errorMessage = LocalizeText('modtools.user.modaction.error.no.action'); - if(errorMessage) - { - sendAlert(errorMessage); - - return; - } + if(errorMessage) return sendAlert(errorMessage); const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message; switch(sanction.actionType) { case ModActionDefinition.ALERT: { - if(!settings.alertPermission) - { - sendAlert('You have insufficient permissions'); - - return; - } - + if(!settings.alertPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert')); SendMessageComposer(new ModAlertMessageComposer(user.userId, messageOrDefault, category.id)); break; } @@ -113,72 +114,108 @@ export const ModToolsUserModActionView: FC = pro SendMessageComposer(new ModMuteMessageComposer(user.userId, messageOrDefault, category.id)); break; case ModActionDefinition.BAN: { - if(!settings.banPermission) - { - sendAlert('You have insufficient permissions'); - - return; - } - + if(!settings.banPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert')); SendMessageComposer(new ModBanMessageComposer(user.userId, messageOrDefault, category.id, selectedAction, (sanction.actionId === 106))); break; } case ModActionDefinition.KICK: { - if(!settings.kickPermission) - { - sendAlert('You have insufficient permissions'); - return; - } - + if(!settings.kickPermission) return sendAlert(LocalizeText('modtools.user.modaction.error.no.permission.alert')); SendMessageComposer(new ModKickMessageComposer(user.userId, messageOrDefault, category.id)); break; } case ModActionDefinition.TRADE_LOCK: { const numSeconds = (sanction.actionLengthHours * 60); - SendMessageComposer(new ModTradingLockMessageComposer(user.userId, messageOrDefault, numSeconds, category.id)); break; } case ModActionDefinition.MESSAGE: { - if(message.trim().length === 0) - { - sendAlert('Please write a message to user'); - - return; - } - + if(message.trim().length === 0) return sendAlert(LocalizeText('modtools.user.modaction.error.no.message')); SendMessageComposer(new ModMessageMessageComposer(user.userId, message, category.id)); break; } } isSendingRef.current = true; - onCloseClick(); }; if(!user) return null; + const selectedSanction = selectedAction >= 0 ? MOD_ACTION_DEFINITIONS[selectedAction] : null; + const selectedTopicName = selectedTopic >= 0 && topics[selectedTopic] ? LocalizeText('help.cfh.topic.' + topics[selectedTopic].id) : null; + const sanctionTone = selectedSanction ? ACTION_TONE[selectedSanction.actionType] : ''; + const sanctionIcon = selectedSanction ? ACTION_ICONS[selectedSanction.actionType] : null; + const canSubmit = (selectedTopic !== -1); + return ( - - onCloseClick() } /> - - - -
- Optional message type, overrides default - - + + onCloseClick() } /> + + {/* Recipient header */} +
+ +
+
{ LocalizeText('modtools.user.message.recipient') }
+
+ + { user.username } +
+
+
+ + {/* Body */} +
+ +