URLs reached window.open from user/server-controlled content without a protocol check or noopener, allowing reverse-tabnabbing and (for the chat link handler) a javascript:/data: href running in our origin.
- add isSafeExternalUrl() (http/https only) + tests; gate the chat link opener (useOnClickChat) and external photo opener with it
- SanitizeHtml: afterSanitizeAttributes hook forces rel="noopener noreferrer" on any target=_blank anchor (overrides attacker-supplied rel)
- add noopener,noreferrer to the remaining window.open(_blank) sites (YouTube share, external photo, guide forum link); drop a stray console.log
Several dangerouslySetInnerHTML sinks rendered user-controlled strings (chat messages, usernames, chat history) without sanitisation, relying implicitly on upstream formatting or server-side charset limits. Route them all through the existing SanitizeHtml (DOMPurify) helper so the security guarantee is local to each render site.
Sinks fixed: ChatWidgetWindowView (name/message/original/translated), ChatHistoryView (name/message), AvatarInfoWidgetNameView + AvatarInfoWidgetAvatarView (username), SelectReportedUserView (username).
Add regression suites: SanitizeHtml.test.ts (XSS neutralised, chat markup preserved) and RoomChatFormatter.test.ts (pins the existing encodeHTML defence). No behaviour change: SanitizeHtml's allow-list keeps the b/i/u/span/strong/em/br markup the chat/profile UI relies on.
The flood/mute warning was rendered inside the inline-grid input-sizer with no
single-line constraint, so a long message (e.g. a large remaining time) wrapped
to a second line and, with the bar's overflow-visible, spilled out the top. Give
it its own centered, single-line, truncating container so it stays within the
bar regardless of length.
Replace the dynamic bubble-style preview with the hotel's actual chat-styles
icon (styles-icon.png) shown in color, next to the caret — matching the
reference exactly.
Replace the generic grayscale styles-icon trigger with a ▼ caret plus a small
clipped preview of the currently-selected chat bubble (chat-bubble bubble-N),
matching the reference layout.
Match the reference layout: the chat-style picker now sits before the text
field (left side) instead of after it. Adds left padding + a small gap so the
trigger, input and emoji selector are evenly spaced.
Upstream 7007752 removed the TEXT constant + its handler case while migrating
in-component inserts to setChatValue, but NotificationDefaultAlertView still
dispatches TEXT to copy a command into the chat input (the only cross-component
path to set the input). Restore the constant and the handler case (setChatValue
+ focus, matching the command-selector path). Fixes TS2339.
The "Edit Furni" button was nested inside the `(!avatarInfo.isWallItem
&& canMove)` guard, together with the floor-only Buildtools position/
height/rotation controls, so it never rendered for wall furni (e.g.
`ads_campguitar`). Move it out so it shows for any furni when
`godMode` + `isModerator`, leaving the position controls floor-only.
The onClick already resolved WALL vs FLOOR correctly.
While touching this file, clean up two pre-existing lint errors:
- hoist `getValidRoomObjectDirection` to module scope (it is pure and
uses no component state) so it is no longer accessed before its
declaration (react-hooks/immutability)
- expand the inline `'scale'` branch to Allman braces (brace-style)
Chat tagging:
- Any @user is a visible tag in chat bubbles (the .mention-tag CSS never
existed, so highlighting was invisible); self/alias mentions get a gold
emphasis. Fixes cross-room tags not being highlighted.
Mentions window:
- Redesigned: unread count in the header, restyled filter chips + a refresh
button, CSS-driven list/date-groups, adaptive height (compact when few,
capped + scroll when many), polished empty state.
- Rows: framed avatar (friends-list head crop so the face is never clipped),
per-row unread dot, type marker, icon action buttons (goto / remove).
- Re-requests from the server each time it opens.
Autocomplete:
- Never suggests the viewer themselves; suggests room users + online friends +
aliases.
Notifications:
- Mention toast removed; mentions flow through the client's standard
notification stream via a dedicated mention bubble (avatar + actions) in the
default position. EVERY received mention surfaces (independent of the generic
info-feed toggle, gated only by mentions_ui.enabled).
Refactor (behaviour-preserving):
- Centralised @-token classification in api/mentions/mentionTokens.
- Moved mentionsFormat -> api/mentions, useMentionActions -> hooks/mentions.
- Extracted ChatInputView @-autocomplete into a tested useChatMentions hook +
pure helper; removed the dead duplicate useMentionAutocomplete.
- Chat input @ autocomplete: typing @ shows online users (room users +
online friends + room aliases) with avatars; arrows/Tab/Enter to pick.
- Any valid @nick token is highlighted blue in chat bubbles (like @all),
giving visual feedback that it is a recognised mention.
- Side notification toast on a received mention: sender avatar (from the
new senderFigure wire field) + message + dismiss; dismiss marks it read
so the toolbar unread badge updates. Auto-hides after 8s.
- IMentionEntry/parsers carry senderFigure end to end.
Adds an "Editor Posizione" button to the furni infostand action bar for
branding / MPU furni, opening a dialog to position and zoom the image:
- draggable dot moves offsetX/Y (live, local preview only)
- slider zooms the image (scale, via the renderer's per-sprite scale)
- offsetZ kept as z-index; Save persists + broadcasts via SetObjectData
- radio "Live" + all editor labels go through LocalizeText (external texts)
Pairs with the renderer branding scale/offset support and Arcturus' `scale`
default on InteractionRoomAds.
Replaced the cached `avatarInfo.targetRoomControllerLevel` derivation
with a local `controllerLevel` state that:
- starts from the popup-open snapshot
- listens to FlatControllerAddedEvent / FlatControllerRemovedEvent
filtered by avatarInfo.webID
- is optimistically bumped on `give_rights` / `remove_rights` clicks
so the moderate submenu flips immediately without waiting for the
server roundtrip
Same shape as the recent useIsUserIgnored migration: the popup now
auto-flips the button without forcing the user to close+reopen it.
Replace the rank-level family (useHasRankLevel + STAFF_LEVELS
constants + useIsRank) with a permission-driven family that reads
straight from the deployment's `permission_definitions` table — no
more hardcoded SecurityLevel/rank-id thresholds on the client. A new
rank in permission_ranks or a re-shuffling of permission_definitions
rank columns now propagates through the UI automatically.
Renderer-side wire shipped in companion commit
feat/react19-event-bus@159c5eb (UserPermissionsMapParser + Event,
SessionDataManager.getPermissionsSnapshot + USER_PERMISSIONS_UPDATED).
New public API in `useSessionSnapshots.ts`:
- useUserPermissions(): ReadonlyMap<string, number> — full map
- useHasPermission(key): boolean — > 0 ⇒ true
- usePermissionValue(key): number — raw 1/2 or 0
- useIsAmbassador() now aliases useHasPermission('acc_ambassador')
- useUserRank() kept for PRESENTATIONAL use only (badge, prefix,
prefix color) — documented as such in JSDoc; gating must use
useHasPermission.
Dropped:
- src/api/nitro/session/RankLevels.ts (STAFF_LEVELS constants)
- useHasRankLevel / useIsRank exports (rank-based gating)
11 consumer migrations, each mapped to the right
`permission_definitions.permission_key`:
- ToolbarView (mod-only chat-input button) → acc_supporttool
- ChooserWidgetView (room-object id column) → acc_supporttool
- CatalogClassicView (admin toggles) → acc_catalogfurni
- CatalogModernView (admin toggles) → acc_catalogfurni
- FurniEditorView (panel access) → acc_catalogfurni
- CalendarView (force-open day) → acc_calendar_force
- InfoStandWidgetFurniView (mod buildtools btn) → acc_anyroomowner
- AvatarInfoWidgetPetView (canPickUp) → acc_anyroomowner
- FurnitureMannequinView (controller mode) → acc_anyroomowner
- YouTubePlayerView (isMyRoom) → acc_anyroomowner
- NavigatorRoomInfoView 'settings' → acc_anyroomowner
- NavigatorRoomInfoView 'staff_pick' → acc_staff_pick
Test refresh:
- useUserRank still tested for the presentational shape.
- useHasPermission: true for non-zero, false for absent/zero.
- usePermissionValue: raw 1 / 2 / 0 (default).
- useUserPermissions: full map exposure.
- Runtime promote test: mutate the permissions map + dispatch
USER_PERMISSIONS_UPDATED, assert useHasPermission flips false→true.
Locks in the new reactive contract.
Mock unchanged (the test sets getPermissionsSnapshot via vi.mocked).
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
214/214 (213 prior + 1 net new for useUserPermissions). Backward
compatible: older Arcturus deployments don't ship the map → empty
snapshot → every gate is false → mod UI hidden (safe default).
Drop the SecurityLevel-named family (useIsModerator / useIsAdmin /
useIsCommunity / useIsPlayerSupport / useHasSecurityLevel /
useUserSecurityLevel) in favour of a rank-based family tied to the
operator's actual `permission_ranks` rows in the Arcturus DB:
- `useUserRank()` returns `{ id, name, level, badge, prefix,
prefixColor }` derived from the snapshot. Powered by the renderer's
extended IUserDataSnapshot (companion commit 87e67d5 on
feat/react19-event-bus).
- `useHasRankLevel(min)` replaces useHasSecurityLevel; consumers
pass a `permission_ranks.level` threshold from the deployment.
- `useIsRank(name)` matches `permission_ranks.rank_name` exactly.
To avoid bare integers in widget bodies, added a deployment-scoped
constants file at `src/api/nitro/session/RankLevels.ts`:
export const STAFF_LEVELS = {
MEMBER: 1, SUPPORT: 4, MOD: 5, SUPER_MOD: 6, ADMIN: 7
};
A deployment that re-numbers `permission_ranks` only edits this file.
Migrated all 11 consumer reads (same set as the earlier session's
useIsModerator migration plus the audit catch): ToolbarView,
CatalogClassicView, CatalogModernView, ChooserWidgetView,
CalendarView, YouTubePlayerView, FurniEditorView,
InfoStandWidgetFurniView, AvatarInfoWidgetPetView,
FurnitureMannequinView, NavigatorRoomInfoView. The
NavigatorRoomInfoView `staff_pick` permission was previously
`securityLevel >= COMMUNITY (7)` via the renderer-enum wrapper —
ported to `useHasRankLevel(STAFF_LEVELS.ADMIN)` because in the
default seed level 7 is Administrator, which is the actual rank that
gets the `acc_anyroomowner`-style permissions for staff-picking.
Tests refreshed under `useSessionSnapshots.test.tsx`:
- useUserRank surfaces the full metadata block;
- useHasRankLevel does `>=` against the threshold;
- useIsRank exact-matches against rank_name;
- a runtime promote (snapshot mutation + SESSION_DATA_UPDATED
dispatch) flips the result, locking in the reactive contract.
Mock extended only minimally — kept the SecurityLevel enum class for
any consumer outside the dropped family that still imports it.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213. The Arcturus-side composer change (UserPermissionsComposer
appending the 5 extra fields) is staged but UNCOMMITTED on Arcturus
main (which has unrelated WIP); the wire is backward-compatible so
the React client works against both pre- and post-extension
emulators.
Build on the useIsModerator landing (532cb28c) along three axes:
1. Family. Extract `useHasSecurityLevel(min)` as the primitive,
backed by a fresh `useUserSecurityLevel()` raw-level reader. The
six SecurityLevel constants (1..9) deserve named wrappers so the
"show this only to X-and-up" pattern doesn't get re-derived ad-hoc
each time: shipped `useIsModerator` / `useIsPlayerSupport` /
`useIsCommunity` / `useIsAdmin` as one-line shims. Also added
`useIsAmbassador()` as a sibling — not derived from security level,
reads the boolean field on the snapshot directly.
2. Audit. The 532cb28c migration covered 6 React-render reads but
missed 5 more discovered by a follow-up grep:
- FurniEditorView (top-level `const isMod`)
- InfoStandWidgetFurniView (inline JSX, mod-only build-tools button)
- NavigatorRoomInfoView (3 reads in hasPermission(): isModerator
and securityLevel >= COMMUNITY for the staff-pick gate. The
userId read stays imperative — userId doesn't flip at runtime in
practice, no reactivity gain.)
- AvatarInfoWidgetPetView (inside useMemo with [roomSession] deps;
migrated and isModerator added to the deps so a runtime
promote/demote re-derives canPickUp without remount)
- FurnitureMannequinView (inside useEffect; same treatment — added
isModerator to the deps so the mode re-resolves on flip)
The remaining ~17 reads (CanManipulateFurniture,
AvatarInfoUtilities.populate*, useChatInputActions,
useFurnitureDimmerWidget / useFurniturePlaylistEditorWidget /
useFurnitureStickieWidget canModify checks, useCatalog admin
filter, useNavigator door-mode guard) are click-time / event-time
imperative — they read at the moment a user action fires, so a
reactive value would be cached at hook execution and stale by the
time the action runs. Leaving them on the synchronous manager read
is correct.
3. Test. Added four cases pinning the contract:
- useUserSecurityLevel returns the raw level.
- useHasSecurityLevel does `>=` against the threshold.
- Named wrappers map to the right constants (MODERATOR=5,
COMMUNITY=7, ADMINISTRATOR=8).
- **Reactive flip** — mutate the snapshot, dispatch the
SESSION_DATA_UPDATED event on the mock dispatcher, assert the
hook re-derives. Locks in the whole point of the snapshot
pattern (a static read would pass cases 1-3 but fail case 4).
Mock changes:
- Added SecurityLevel class (mirrors the renderer enum 0..9) so the
family wrappers resolve to actual numbers in jsdom — without it
`useIsModerator()` would call `useHasSecurityLevel(undefined)` and
the test would silently pass false-positives.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
213/213 (209 baseline + 4 new family/reactivity cases).
Adds a reactive `useIsModerator()` derived from
`useUserDataSnapshot().securityLevel >= SecurityLevel.MODERATOR`
(mirrors the renderer-side getter at SessionDataManager.ts:684), and
migrates the six React component-body reads of
`GetSessionDataManager().isModerator`:
- ToolbarView (mod-only chat-input button)
- CatalogClassicView, CatalogModernView (admin toggles in catalog
header)
- ChooserWidgetView (room-object id column visibility)
- YouTubePlayerView (room-control affordance — hook moved above the
`if (!isOpen) return null` early return so the hook order stays
stable when the player opens/closes)
- CalendarView (mod-only "open all" affordance)
UX impact: any future promote/demote that flips
SESSION_DATA_UPDATED now re-renders the mod-only UI live, instead of
requiring an F5. Imperative call sites
(AvatarInfoUtilities.populate*, CanManipulateFurniture,
RoomChatHandler) still read the manager directly — they run at click
time, not in a React render, so reactivity has no upside there.
Five of the six call sites are top-level component-body reads (no
early-return interaction). YouTubePlayerView has an
`if (!isOpen) return null` below the hook list, so the hook had to
move ABOVE it; same shape as the recent CatalogPurchaseWidgetView and
CatalogItemGridWidgetView fixes.
Verification: yarn typecheck clean, yarn lint:hooks clean, yarn test
209/209.
Root cause of last session's "(intermediate value)() is undefined" at
ToolbarView.tsx:46:
use-between 1.x ships its own React-dispatcher proxy (ownDispatcher
in node_modules/use-between/release/index.esm.js:54-169) that
re-implements only useState, useReducer, useEffect, useLayoutEffect,
useCallback, useMemo, useRef and useImperativeHandle. It does NOT
implement useSyncExternalStore. When the inner state function of
useBetween(stateFn) calls useSyncExternalStore (directly or via
useExternalSnapshot / useUserDataSnapshot), React resolves the
dispatcher to use-between's proxy, finds .useSyncExternalStore
missing, and calls undefined() — that's the exact production crash
in Firefox. Chrome reports the same as
"dispatcher.useSyncExternalStore is not a function".
Neither the vite alias (790ad2b) nor the defensive renderer-method
guards (c35a2d4) could fix it — both addressed downstream symptoms
(stale dist / missing manager methods) but the dispatcher is upstream
of both. That's why every retry kept reproducing the same error.
Fix is structural: snapshot hooks (useUserDataSnapshot,
useIsUserIgnored, etc.) MUST run outside any useBetween scope. Three
re-applied migrations:
- useSessionInfo: snapshot read moved into the outer wrapper. The
inner useSessionInfoState (useBetween-shared) now contains only
use-between-safe hooks: useState, useMessageEvent, plain actions.
userFigure / userRespectRemaining / petRespectRemaining come from
useUserDataSnapshot() OUTSIDE useBetween, so useSyncExternalStore
installs against the real React dispatcher.
- useChatWidget.ownUserId: direct snapshot read. useChatWidget is
exported as `useChatWidget = useChatWidgetState` (NOT wrapped in
useBetween), so this hook never sat inside the broken scope — the
precautionary rollback was unnecessary in retrospect. Gains
session-change reactivity (e.g. reconnect under a different user id).
- AvatarInfoWidgetAvatarView Ignore/Unignore: useIsUserIgnored(name)
read directly in the component body. Same reasoning as
useChatWidget — never inside useBetween. The menu auto-flips
Ignore <-> Unignore while the popup is open.
Added regression guard at src/hooks/session/useSessionSnapshots.test.tsx
with two cases: (1) useSyncExternalStore inside useBetween throws,
(2) useSyncExternalStore outside useBetween in the same render works.
Pins the constraint so future migrations cannot reintroduce the bad
shape silently.
Verification: yarn typecheck clean, yarn test 209/209 (207 baseline
+ 2 new regression cases), no consumer surface changes — every
destructured field (userFigure, userRespectRemaining, respectUser,
petRespectRemaining, respectPet, chatStyleId, updateChatStyleId) is
still returned with the same name and shape.
The migrations of useSessionInfo, useChatWidget.ownUserId and the
AvatarInfo Ignore/Unignore menu to the new useSessionSnapshots hooks
were correct in code but produce a persistent runtime error in the
user's deployment:
TypeError: (intermediate value)() is undefined
ToolbarView ToolbarView.tsx:46
The error fires from React's render loop on the first paint —
ToolbarView is the first mounted consumer of useSessionInfo, which is
why it carries the boundary message. Two attempted fixes did not
resolve it on the user's side:
- 790ad2b: vite alias forcing @nitrots/nitro-renderer to source index.ts
- c35a2d4: defensive typeof guards on every Manager method call inside
useSessionSnapshots (so a missing method degrades to a frozen default
rather than calling undefined)
Both are correct defenses but the error persists, meaning the failure
mode is upstream of those guards. Rather than burn more cycles
remote-debugging, roll back the three consumer migrations:
- useSessionInfo: restored to the pre-71a0eee shape — 5 useState
fields driven by useMessageEvent<UserInfoEvent, FigureUpdateEvent,
UserSettingsEvent>. The five consumers (ToolbarView, HcCenterView,
ChatInputView, AvatarInfoPetTrainingPanelView, InfoStandWidgetPetView,
AvatarInfo{Avatar,Pet,OwnPet}View) get the same destructured shape
they had before this session.
- useChatWidget.ownUserId: restored to `GetSessionDataManager()?.userId`
(synchronous, captured at mount). Loses the session-change reactivity
but matches the previous, working behaviour.
- AvatarInfoWidgetAvatarView Ignore/Unignore: restored to
`avatarInfo.isIgnored` (captured by AvatarInfoUtilities at click
time, not reactive). Loses the live-toggle if the user is
ignored/unignored while the popup is open — known small regression,
worth it for stability.
Kept intact:
- The useSessionSnapshots.ts hook file itself, with defensive guards,
so the API stays available for any future opt-in consumer.
- 790ad2b vite alias for the umbrella, still useful as defence in
depth for future migrations.
- All the other non-snapshot modernizations from this session
(usePetPackageWidget reducer, useWordQuizWidget bug fix,
useChatCommandSelector Zustand store, useAvatarInfoWidget typed
globalThis accessor).
Verification: yarn typecheck clean, yarn test 207/207, yarn build green.
The toolbar should boot without the error now — the call chain on the
first paint no longer touches the new useExternalSnapshot / snapshot
getter path.
The Ignore <-> Unignore context-menu entry was driven by
avatarInfo.isIgnored — a boolean captured by AvatarInfoUtilities once,
at the time the avatar was clicked. If the user got ignored / unignored
*while the popup was already open* (e.g. via the friends panel, or
because a server push flipped the state), the menu kept showing the
stale option and clicking it would no-op (or worse, double-ignore).
Switch the menu items to read useIsUserIgnored(avatarInfo.name) — the
reactive hook backed by IgnoredUsersManager.getIgnoredUsersSnapshot()
+ NitroEventType.IGNORED_USERS_UPDATED. Now the menu flips automatically
the moment the ignore list changes, without re-opening.
avatarInfo.isIgnored stays on the data object (other code paths still
consume it) — only the user-facing menu toggle is now reactive.