Merge branch 'Dev' into Dev

This commit is contained in:
DuckieTM
2026-05-31 15:43:52 +02:00
committed by GitHub
28 changed files with 626 additions and 416 deletions
+1 -3
View File
@@ -44,6 +44,4 @@ Thumbs.db
# the dev server takes minutes to start with 100k+ files under public/.
/public/nitro-assets
/public/swf
# Temi custom locali di test (i temi veri stanno sul server, mai su git)
public/custom-themes/
.superpowers/
+40 -12
View File
@@ -6,19 +6,27 @@ the ground running.
## TL;DR
This branch — **`feat/react19-modernization`** — is a long-running modernization
of the Nitro V3 client: bump to React 19.2 idioms, add the supporting
infrastructure (TanStack Query, Zustand, Vitest, React Compiler, error
boundaries), split a few god-hooks, and audit logic bugs along the way.
PR is **#2** on `simoleo89/Nitro-V3`.
This client carries a long-running React 19.2 modernization: React 19
idioms + supporting infrastructure (TanStack Query, Zustand, Vitest,
React Compiler, error boundaries), god-hook splits, and logic-bug audits.
Upstream `duckietm/Nitro-V3` (`origin/Dev`) is merged in through
`b2318b9` as of 2026-05-18 (merge commit `779a98c`). That brings in
JSON5 config support, user-settings (reset password / email / change
username), wear-badge popup fix, login screen fix, About update, and
the offer-selection refactor. When syncing the next batch of upstream
commits, expect conflicts in `App.tsx` / `bootstrap.ts` / `LoginView.tsx`
on React 19 imports — always keep the modernized local version.
**Working base is now `main`** (tracking `duckietm/Nitro-V3`). The earlier
`feat/react19-modernization` long-running branch was superseded — feature
work now ships as small focused PRs against `duckietm:Dev`, staged through
Dev then merged to main. (`feat/react19-modernization` still exists on the
fork as backup; do not force-push it.)
**Navigator modernization landed** (merged to main 2026-05-28, PRs
#168/#169/#170): the 492-line `useNavigator` god-hook was split into
`useNavigatorStore` + `useNavigatorData`/`useNavigatorUiState`/
`useNavigatorSearch` filters (wired-tools layout), door lifecycle extracted
to `src/hooks/rooms/widgets/useDoorState.ts`, 9 UI flags moved to a Zustand
`navigatorUiStore`, search migrated to a query hook, and 5 sub-views wrapped
in `WidgetErrorBoundary`. **Caveat**: duckietm patched `useNavigatorSearch`
post-merge (`05d71dd1`) — see the `useNitroQuery` fragility note below.
When syncing upstream, expect conflicts in `App.tsx` / `bootstrap.ts` /
`LoginView.tsx` on React 19 imports — always keep the modernized version.
Local-dev game assets are served by a small Vite plugin (`sirv` middleware
mounted on `/nitro-assets` and `/swf`, reading from
@@ -236,6 +244,20 @@ and invalidates the query slot on every push, so server-driven
refresh paths work the same as the initial request/response (e.g.
ClubGiftInfoEvent firing again after the user claims a gift).
**⚠️ Fragility — do NOT use `useNitroQuery` for primary visible data.**
The one-shot listener inside `awaitNitroResponse` (register listener →
await one matching response → remove itself) is fragile against
renderer-bundle quirks: for some parsers the event fires but the listener
never matches, so the promise never resolves and `query.data` stays
`undefined` forever — the UI shows the server's response arriving in logs
but renders blank. This bit **ModTools Room/CFH chatlog** (reverted to
`useMessageEvent + useEffect`) and then **Navigator search** (P2 shipped
with `useNitroQuery`, duckietm reverted it in `05d71dd1` to the god-hook
pattern). **Rule: reserve `useNitroQuery` for config / secondary fetches
where a brief blank is tolerable. For anything that is the primary visible
content of a panel, use `useMessageEvent + useState/useEffect`** — that's
what the rest of the codebase does and it's robust.
### Singleton-filter split for `useBetween`-based hooks
When a hook backs many consumers but most only need either state OR
@@ -339,6 +361,7 @@ into `configurePreviewServer` so `yarn preview` keeps working.
| Zustand | `NavigatorRoomCreatorView` (`useRoomCreatorStore`), `WiredCreatorToolsView` (`useWiredCreatorToolsUiStore` — every panel-lifecycle-relevant flag, snapshot, selection, highlight, inline editor, picker chain hoisted; what's left in the component as `useState` is genuinely transient: keepSelected, globalClock, roomEnteredAt, selectedMonitorErrorType, selectedMonitorLogDetails) |
| God-hook split (state + actions + shim) | `doorbell`, `poll`, `furni-chooser`, `user-chooser`, `friend-request`, `chat-input` |
| God-hook split (`useBetween` singleton + state filter + actions filter + shim) | `wired-tools`, `translation`, `notification`, `friends`, `catalog` (three-way: `useCatalogData` / `useCatalogUiState` / `useCatalogActions` — all 48 consumers migrated, deprecated `useCatalog` shim removed) |
| Navigator modernization (merged to main 2026-05-28, PRs #168/#169/#170) | 492-line `useNavigator` god-hook split into `useNavigatorStore` (internal `useBetween` closure) + flat filters `useNavigatorData` / `useNavigatorUiState` / `useNavigatorSearch`; door bell/password lifecycle extracted to `src/hooks/rooms/widgets/useDoorState.ts` (dual-subscribes `GetGuestRoomResultEvent` + `GenericErrorEvent` alongside the nav store, each filtering by branch/errorCode); 9 UI flags + `currentTabCode`/`currentFilter` in Zustand `navigatorUiStore` (`src/hooks/navigator/navigatorUiStore.ts`); all 5 Navigator sub-views wrapped in `WidgetErrorBoundary`; old shim deleted. **`useNavigatorSearch` was reverted by duckietm (`05d71dd1`) from `useNitroQuery` to `useMessageEvent + useEffect`** — see the useNitroQuery fragility note. Specs/plans under `docs/superpowers/`. |
| `WidgetErrorBoundary` | `RoomWidgetsView` umbrella + per-widget wrap on all 13 room widgets and all 20 furniture widgets (so a crash in one widget no longer takes down its siblings) |
| Vitest | 207/207 cases — pure helpers (incl. 4 new on `getPetPackageNameError`) + 2 Zustand store suites (`navigatorRoomCreatorStore`, `wiredCreatorToolsUiStore` with 45 cases including the picker-chain hoists) + 2 component-/hook-level pilots (WidgetErrorBoundary, useDoorbellState) on top of the renderer-SDK mock at `src/nitro-renderer.mock.ts`, 34 cases on the catalog pure helpers, 4 contract cases on the catalog filters. **Tests are co-located** under `src/`, alongside their subject. |
| Form Actions | Login / Register / Forgot (LoginView.tsx) |
@@ -412,6 +435,11 @@ See `docs/ARCHITECTURE.md` "Recently fixed" for fix shapes.
`useCatalogUiState` / `useCatalogActions` in
`src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated;
deprecated `useCatalog` shim removed)
- Navigator hooks: `src/hooks/navigator/``useNavigatorStore.ts`
(internal closure), `useNavigatorData.ts` / `useNavigatorUiState.ts` /
`useNavigatorSearch.ts` (filters), `navigatorUiStore.ts` (Zustand UI
flags + `setTab`/`setFilter`). Door lifecycle: `src/hooks/rooms/widgets/useDoorState.ts`.
Specs/plans: `docs/superpowers/specs/2026-05-2*-navigator-*.md`
- Renderer-SDK mock for Vitest: `src/nitro-renderer.mock.ts`
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
@@ -0,0 +1,71 @@
# Navigator — Room Settings "Base" tab: stacked-label layout
**Date:** 2026-05-31
**Component:** Nitro-V3 client
**File:** `src/components/navigator/views/room-settings/NavigatorRoomSettingsBasicTabView.tsx`
**Type:** Layout-only refactor (no logic / data-flow change)
## Problem
The Base tab uses a horizontal two-column row layout: a fixed-width label on the
left, the control on the right. In the narrow room-settings panel the label column
is too tight, so multi-word Italian labels ("Visitatori massimi", "Impostazioni
scambio") wrap onto two lines and look broken. An earlier fix replaced dead
Bootstrap `col-3` classes with `w-1/4 shrink-0`, which stopped the crushing but
still leaves the labels cramped and occasionally wrapping.
The other five room-settings tabs (Access, Rights, VIP/Chat, Mod, Misc) already use
idiomatic vertical/grouped layouts. Base is the outlier.
## Decision
Adopt the **stacked-label** pattern (chosen from three mockup options — A stacked,
B sectioned cards, C wider label column). Each field becomes a vertical block: bold
label on top, full-width control below, validation message underneath. This mirrors
the sibling **Access** tab's existing `<Column gap={1}>` + `<Text bold>` shape, so
the two tabs become visually consistent and labels can never wrap.
## Layout
Every field → its own `<Column gap={1}>` block:
```tsx
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomname') }</Text>
<input className="form-control form-control-sm" value={ roomName } onBlur={ saveRoomName } />
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
<Text bold small variant="danger">{ LocalizeText('navigator.roomsettings.roomnameismandatory') }</Text> }
</Column>
```
Field-by-field:
- **Nome stanza** — stacked block, mandatory-name validation preserved.
- **Descrizione** — stacked block, `<textarea>` full width.
- **Categoria** — stacked block, `<select>` from `categories`.
- **Visitatori massimi** — stacked block, `<select>` from `GetMaxVisitorsList`.
- **Impostazioni scambio** — stacked block, 3-option `<select>`.
- **Tag** — one "Tag" label, then the two tag inputs side-by-side in a
`<Flex gap={1}>`, each `fullWidth`, each keeping its own length/type validation.
- **allow_walkthrough / allow_underpass** — remain inline `checkbox + label` rows;
remove the empty `<Base className="w-1/4 shrink-0" />` spacers that only existed
to align with the old label column.
- **Delete link** — unchanged at the bottom.
## Explicit non-goals
- No change to `handleChange` field names or values.
- No change to validation thresholds (`ROOM_NAME_MIN_LENGTH=3`,
`ROOM_NAME_MAX_LENGTH=60`, `DESC_MAX_LENGTH=255`, `TAGS_MAX_LENGTH=15`).
- No change to save-on-blur handlers (`saveRoomName`, `saveRoomDescription`,
`saveTags`), the `RoomSettingsSaveErrorEvent` subscription, or `deleteRoom`.
- No change to field order or any localization key.
- No change to the other five tabs.
- The `w-1/4 shrink-0` utility classes added in the prior fix are removed (labels
are full-width now).
## Risk
Single-file, JSX-only diff. No test covers this view, so no test impact. Manual
check: open Room Settings → Base, confirm no label wraps, all controls full width,
validation still appears, save-on-blur still fires.
+11 -9
View File
@@ -14,8 +14,10 @@ import { NavigatorRoomCreatorView } from './views/NavigatorRoomCreatorView';
import { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView';
import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView';
import { NavigatorEmptyStateView } from './views/search/NavigatorEmptyStateView';
import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView';
import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView';
import { NavigatorSearchSkeletonView } from './views/search/NavigatorSearchSkeletonView';
import { NavigatorSearchView } from './views/search/NavigatorSearchView';
export const NavigatorView: FC<{}> = props =>
@@ -107,7 +109,7 @@ export const NavigatorView: FC<{}> = props =>
<>
{ isVisible &&
<NitroCard
className={ `${ isOpenSavesSearches ? 'w-[600px] min-w-[600px]' : 'w-navigator-w min-w-navigator-w' } h-navigator-h min-h-navigator-h` }
className={ `${ isOpenSavesSearches ? 'w-[600px] sm:min-w-[600px]' : 'w-navigator-w sm:min-w-navigator-w' } max-w-[calc(100vw-1rem)] h-navigator-h min-h-navigator-h` }
uniqueKey="navigator">
<NitroCard.Header
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
@@ -132,21 +134,21 @@ export const NavigatorView: FC<{}> = props =>
<FaPlus className="fa-icon" />
</NitroCard.TabItem>
</NitroCard.Tabs>
<NitroCard.Content isLoading={ isFetching }>
<NitroCard.Content>
{ !isCreatorOpen &&
<div className="flex h-full overflow-hidden gap-2">
<div className="flex flex-col sm:flex-row h-full overflow-hidden gap-2">
{ isOpenSavesSearches &&
<div className="overflow-hidden pr-1 shrink-0">
<div className="overflow-hidden pr-1 shrink-0 w-full sm:w-auto max-h-40 sm:max-h-none">
<NavigatorSearchSavesResultView searches={ navigatorSearches || [] } />
</div> }
<div className="flex flex-col w-full overflow-hidden gap-2">
<NavigatorSearchView />
<div className="flex flex-col w-full min-h-0 overflow-hidden gap-2">
<NavigatorSearchView searchResult={ searchResult } />
<div ref={ elementRef } className="flex flex-col flex-1 min-h-0 overflow-auto gap-2">
{ (isFetching && !searchResult) &&
<NavigatorSearchSkeletonView /> }
{ searchResult && searchResult.results.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />) }
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
<div className="nitro-card-panel px-3 py-2 text-sm text-muted">
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
</div> }
<NavigatorEmptyStateView code={ searchResult.code } onCreateRoom={ () => useNavigatorUiStore.getState().openCreator() } /> }
</div>
<Flex className="nitro-card-divider pt-2 border-t gap-2">
<Flex pointer alignItems="center" justifyContent="center"
@@ -1,10 +1,10 @@
import { CreateLinkEvent, GetCustomRoomFilterMessageComposer, GetGuestRoomMessageComposer, GetSessionDataManager, NavigatorSearchComposer, RemoveOwnRoomRightsRoomMessageComposer, RoomControllerLevel, RoomMuteComposer, RoomSettingsComposer, ToggleStaffPickMessageComposer, UpdateHomeRoomMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FC, useEffect, useState } from 'react';
import { FaLink, FaSignOutAlt } from 'react-icons/fa';
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api';
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer } from '../../../api';
import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
import { RoomWidgetThumbnailEvent } from '../../../events';
import { useHasPermission, useHelp, useNavigatorData, useRoom } from '../../../hooks';
import { useHasPermission, useHelp, useNavigatorData, useNavigatorFavourite, useRoom } from '../../../hooks';
import { classNames } from '../../../layout';
export interface NavigatorRoomInfoViewProps {
@@ -17,12 +17,13 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
const [ isRoomPicked, setIsRoomPicked ] = useState(false);
const [ isRoomMuted, setIsRoomMuted ] = useState(false);
const { report = null } = useHelp();
const { navigatorData, favouriteRoomIds } = useNavigatorData();
const { navigatorData } = useNavigatorData();
const { roomSession = null } = useRoom();
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
const canStaffPick = useHasPermission('acc_staff_pick');
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
const { isFavourite: isRoomInFavouritesList, toggle: toggleFavourite } = useNavigatorFavourite(enteredRoomId);
useEffect(() =>
{
@@ -30,22 +31,6 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
SendMessageComposer(new GetGuestRoomMessageComposer(enteredRoomId, false, false));
}, [ enteredRoomId ]);
const isRoomInFavouritesList = useMemo(() =>
{
if(!enteredRoomId) return false;
return favouriteRoomIds.some((id: any) =>
{
if(id && typeof id === 'object')
{
if('roomId' in id) return Number(id.roomId) === enteredRoomId;
if('id' in id) return Number(id.id) === enteredRoomId;
}
return String(id) === String(enteredRoomId);
});
}, [ favouriteRoomIds, enteredRoomId ]);
const hasPermission = (permission: string) =>
{
if(!navigatorData?.enteredGuestRoom) return false;
@@ -115,7 +100,7 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
report(ReportType.ROOM, { roomId, roomName: navigatorData.enteredGuestRoom.roomName });
return;
case 'room_favourite':
ToggleFavoriteRoom(roomId, isRoomInFavouritesList);
toggleFavourite();
SendMessageComposer(new GetGuestRoomMessageComposer(roomId, false, false));
return;
case 'remove_rights':
@@ -2,6 +2,7 @@ import { RoomDataParser } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { IRoomData, LocalizeText } from '../../../../api';
import { Column, Flex, Text } from '../../../../common';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -36,9 +37,8 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
<Text bold>{ LocalizeText('navigator.roomsettings.roomaccess.caption') }</Text>
<Text>{ LocalizeText('navigator.roomsettings.roomaccess.info') }</Text>
</Column>
<Column overflow="auto">
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.doormode') }</Text>
<Column overflow="auto" gap={ 2 }>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.doormode') } gap={ 1 }>
<Flex alignItems="center" gap={ 1 }>
<input className="form-check-input" type="radio" name="lockState" checked={ (roomData.lockState === RoomDataParser.OPEN_STATE) && !isTryingPassword } onChange={ event => handleChange('lock_state', RoomDataParser.OPEN_STATE) } />
<Text>{ LocalizeText('navigator.roomsettings.doormode.open') }</Text>
@@ -58,21 +58,20 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
{ (isTryingPassword || (roomData.lockState === RoomDataParser.PASSWORD_STATE)) &&
<Column gap={ 1 }>
<Text>{ LocalizeText('navigator.roomsettings.doormode.password') }</Text>
<input type="password" className="form-control form-control-sm col-4" value={ password } onChange={ event => setPassword(event.target.value) } placeholder={ LocalizeText('navigator.roomsettings.password') } onFocus={ event => setIsTryingPassword(true) } />
<input type="password" className="form-control form-control-sm" value={ password } onChange={ event => setPassword(event.target.value) } placeholder={ LocalizeText('navigator.roomsettings.password') } onFocus={ event => setIsTryingPassword(true) } />
{ isTryingPassword && (password.length <= 0) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.passwordismandatory') }
</Text> }
<input type="password" className="form-control form-control-sm col-4" value={ confirmPassword } onChange={ event => setConfirmPassword(event.target.value) } onBlur={ saveRoomPassword } placeholder={ LocalizeText('navigator.roomsettings.passwordconfirm') } />
<input type="password" className="form-control form-control-sm" value={ confirmPassword } onChange={ event => setConfirmPassword(event.target.value) } onBlur={ saveRoomPassword } placeholder={ LocalizeText('navigator.roomsettings.passwordconfirm') } />
{ isTryingPassword && ((password.length > 0) && (password !== confirmPassword)) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.invalidconfirm') }
</Text> }
</Column> }
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.pets') }</Text>
</NavigatorRoomSettingsSectionView>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.pets') } gap={ 1 }>
<Flex alignItems="center" gap={ 1 }>
<input className="form-check-input" type="checkbox" checked={ roomData.allowPets } onChange={ event => handleChange('allow_pets', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allowpets') }</Text>
@@ -81,7 +80,7 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
<input className="form-check-input" type="checkbox" checked={ roomData.allowPetsEat } onChange={ event => handleChange('allow_pets_eat', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allowfoodconsume') }</Text>
</Flex>
</Column>
</NavigatorRoomSettingsSectionView>
</Column>
</>
);
@@ -2,7 +2,7 @@ import { RoomDeleteComposer, RoomSettingsSaveErrorEvent, RoomSettingsSaveErrorPa
import { FC, useEffect, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { CreateLinkEvent, GetMaxVisitorsList, IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Base, Column, Flex, Text } from '../../../../common';
import { Column, Flex, Text } from '../../../../common';
import { useMessageEvent, useNavigatorData, useNotification } from '../../../../hooks';
const ROOM_NAME_MIN_LENGTH = 3;
@@ -39,6 +39,7 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
{
case RoomSettingsSaveErrorParser.ERROR_INVALID_TAG:
setTypeError('navigator.roomsettings.unacceptablewords');
break;
case RoomSettingsSaveErrorParser.ERROR_NON_USER_CHOOSABLE_TAG:
setTypeError('navigator.roomsettings.nonuserchoosabletag');
break;
@@ -77,9 +78,9 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
const saveTags = (index: number) =>
{
if(index === 0 && (roomTag1 === roomData.tags[0]) || (roomTag1.length > TAGS_MAX_LENGTH)) return;
if(index === 0 && ((roomTag1 === roomData.tags[0]) || (roomTag1.length > TAGS_MAX_LENGTH))) return;
if(index === 1 && (roomTag2 === roomData.tags[1]) || (roomTag2.length > TAGS_MAX_LENGTH)) return;
if(index === 1 && ((roomTag2 === roomData.tags[1]) || (roomTag2.length > TAGS_MAX_LENGTH))) return;
if(roomTag1 === '' && roomTag2 !== '') setRoomTag2('');
@@ -98,78 +99,77 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
return (
<>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.roomname') }</Text>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomName } maxLength={ ROOM_NAME_MAX_LENGTH } onChange={ event => setRoomName(event.target.value) } onBlur={ saveRoomName } />
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.roomnameismandatory') }
</Text> }
</Column>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.roomsettings.desc') }</Text>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomname') }</Text>
<input className="form-control form-control-sm" value={ roomName } maxLength={ ROOM_NAME_MAX_LENGTH } onChange={ event => setRoomName(event.target.value) } onBlur={ saveRoomName } />
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.roomnameismandatory') }
</Text> }
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.desc') }</Text>
<textarea className="form-control form-control-sm" value={ roomDescription } maxLength={ DESC_MAX_LENGTH } onChange={ event => setRoomDescription(event.target.value) } onBlur={ saveRoomDescription } />
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.category') }</Text>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.category') }</Text>
<select className="form-select form-select-sm" value={ roomData.categoryId } onChange={ event => handleChange('category', event.target.value) }>
{ categories && categories.map(category => <option key={ category.id } value={ category.id }>{ LocalizeText(category.name) }</option>) }
</select>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.maxvisitors') }</Text>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.maxvisitors') }</Text>
<select className="form-select form-select-sm" value={ roomData.userCount } onChange={ event => handleChange('max_visitors', event.target.value) }>
{ GetMaxVisitorsList && GetMaxVisitorsList.map(value => <option key={ value } value={ value }>{ value }</option>) }
</select>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.tradesettings') }</Text>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.tradesettings') }</Text>
<select className="form-select form-select-sm" value={ roomData.tradeState } onChange={ event => handleChange('trade_state', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.trade_not_allowed') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.trade_not_with_Controller') }</option>
<option value="2">{ LocalizeText('navigator.roomsettings.trade_allowed') }</option>
</select>
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.tags') }</Text>
<Flex gap={ 1 }>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag1 } onChange={ event => setRoomTag1(event.target.value) } onBlur={ () => saveTags(0) } />
{ (roomTag1.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 0 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag2 } onChange={ event => setRoomTag2(event.target.value) } onBlur={ () => saveTags(1) } />
{ (roomTag2.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 1 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
</Flex>
</Column>
<Flex alignItems="center" gap={ 1 }>
<Text className="col-3">{ LocalizeText('navigator.tags') }</Text>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag1 } onChange={ event => setRoomTag1(event.target.value) } onBlur={ () => saveTags(0) } />
{ (roomTag1.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 0 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
<Column fullWidth gap={ 0 }>
<input className="form-control form-control-sm" value={ roomTag2 } onChange={ event => setRoomTag2(event.target.value) } onBlur={ () => saveTags(1) } />
{ (roomTag2.length > TAGS_MAX_LENGTH) &&
<Text bold small variant="danger">
{ LocalizeText('navigator.roomsettings.toomanycharacters') }
</Text> }
{ (tagIndex === 1 && typeError != '') &&
<Text bold small variant="danger">
{ LocalizeText(typeError) }
</Text> }
</Column>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Base className="col-3" />
<input className="form-check-input" type="checkbox" checked={ roomData.allowWalkthrough } onChange={ event => handleChange('allow_walkthrough', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allow_walk_through') }</Text>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Base className="col-3" />
<input className="form-check-input" type="checkbox" checked={ roomData.allowUnderpass } onChange={ event => handleChange('allow_underpass', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allow_underpass') }</Text>
</Flex>
<Text variant="danger" underline bold pointer className="d-flex justify-content-center align-items-center gap-1" onClick={ deleteRoom }>
<FaTimes className="fa-icon" /> { LocalizeText('navigator.roomsettings.delete') }
</Text>
<Flex pointer alignItems="center" justifyContent="center" gap={ 1 } onClick={ deleteRoom }>
<FaTimes className="fa-icon shrink-0 text-[#a81a12]" />
<Text variant="danger" underline bold className="whitespace-nowrap">{ LocalizeText('navigator.roomsettings.delete') }</Text>
</Flex>
</>
);
};
@@ -3,6 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
import { useMessageEvent } from '../../../../hooks';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -51,28 +52,29 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
return (
<Grid overflow="auto">
<Column size={ 6 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.banned.users') } ({ bannedUsers.length })</Text>
<Flex overflow="hidden" className="nitro-card-panel list-container p-2">
<Column fullWidth overflow="auto" gap={ 1 }>
{ bannedUsers && (bannedUsers.length > 0) && bannedUsers.map((user, index) =>
{
return (
<Flex key={ index } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ user.userId } />
<Text pointer grow onClick={ event => setSelectedUserId(user.userId) }> { user.userName }</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Button disabled={ (selectedUserId <= 0) } onClick={ event => unBanUser(selectedUserId) }>
{ LocalizeText('navigator.roomsettings.moderation.unban') } { selectedUserId > 0 && bannedUsers.find(user => (user.userId === selectedUserId))?.userName }
</Button>
<NavigatorRoomSettingsSectionView title={ `${ LocalizeText('navigator.roomsettings.moderation.banned.users') } (${ bannedUsers.length })` } gap={ 1 } className="h-full">
<Flex overflow="hidden" className="nitro-card-panel list-container p-2">
<Column fullWidth overflow="auto" gap={ 1 }>
{ bannedUsers && (bannedUsers.length > 0) && bannedUsers.map((user, index) =>
{
return (
<Flex key={ index } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ user.userId } />
<Text pointer grow onClick={ event => setSelectedUserId(user.userId) }> { user.userName }</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Button disabled={ (selectedUserId <= 0) } onClick={ event => unBanUser(selectedUserId) }>
{ LocalizeText('navigator.roomsettings.moderation.unban') } { selectedUserId > 0 && bannedUsers.find(user => (user.userId === selectedUserId))?.userName }
</Button>
</NavigatorRoomSettingsSectionView>
</Column>
<Column size={ 6 }>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.mute.header') }</Text>
<Flex alignItems="center" gap={ 1 }>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.moderation') } gap={ 2 } className="h-full">
<Column gap={ 1 }>
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.mute.header') }</Text>
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowMute } onChange={ event => handleChange('moderation_mute', event.target.value) }>
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
{ LocalizeText('navigator.roomsettings.moderation.none') }
@@ -81,11 +83,9 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
{ LocalizeText('navigator.roomsettings.moderation.rights') }
</option>
</select>
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.kick.header') }</Text>
<Flex alignItems="center" gap={ 1 }>
</Column>
<Column gap={ 1 }>
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.kick.header') }</Text>
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowKick } onChange={ event => handleChange('moderation_kick', event.target.value) }>
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
{ LocalizeText('navigator.roomsettings.moderation.none') }
@@ -97,11 +97,9 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
{ LocalizeText('navigator.roomsettings.moderation.all') }
</option>
</select>
</Flex>
</Column>
<Column gap={ 1 }>
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.ban.header') }</Text>
<Flex alignItems="center" gap={ 1 }>
</Column>
<Column gap={ 1 }>
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.ban.header') }</Text>
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowBan } onChange={ event => handleChange('moderation_ban', event.target.value) }>
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
{ LocalizeText('navigator.roomsettings.moderation.none') }
@@ -110,8 +108,8 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
{ LocalizeText('navigator.roomsettings.moderation.rights') }
</option>
</select>
</Flex>
</Column>
</Column>
</NavigatorRoomSettingsSectionView>
</Column>
</Grid>
);
@@ -3,6 +3,7 @@ import { FC, useEffect, useRef, useState } from 'react';
import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
import { useFriends, useMessageEvent } from '../../../../hooks';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -105,74 +106,72 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
return (
<Grid>
<Column size={ 6 }>
<Text bold>
{ LocalizeText(
<NavigatorRoomSettingsSectionView gap={ 1 } className="h-full"
title={ LocalizeText(
'navigator.flatctrls.userswithrights',
[ 'displayed', 'total' ],
[
filteredUsersWithRights.size.toString(),
filteredUsersWithRights.size.toString()
]
) }
</Text>
) }>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ Array.from(filteredUsersWithRights.entries()).map(([ id, name ], index) =>
{
return (
<Flex key={ `${id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ id } />
<Text
pointer
grow
onClick={ () => guardedSend(`take_${id}`, new RoomTakeRightsComposer(id)) }>
{ name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ Array.from(filteredUsersWithRights.entries()).map(([ id, name ], index) =>
{
return (
<Flex key={ `${id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ id } />
<Text
pointer
grow
onClick={ () => guardedSend(`take_${id}`, new RoomTakeRightsComposer(id)) }>
{ name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
<Button
variant="danger"
disabled={ !filteredUsersWithRights.size }
onClick={ () => roomData && guardedSend('removeAll', new RemoveAllRightsMessageComposer(roomData.roomId)) }>
{ LocalizeText('navigator.flatctrls.clear') }
</Button>
<Button
variant="danger"
disabled={ !filteredUsersWithRights.size }
onClick={ () => roomData && guardedSend('removeAll', new RemoveAllRightsMessageComposer(roomData.roomId)) }>
{ LocalizeText('navigator.flatctrls.clear') }
</Button>
</NavigatorRoomSettingsSectionView>
</Column>
<Column size={ 6 }>
<Text bold>
{ LocalizeText(
<NavigatorRoomSettingsSectionView gap={ 1 } className="h-full"
title={ LocalizeText(
'navigator.flatctrls.friends',
[ 'displayed', 'total' ],
[
friendsWithoutRights.length.toString(),
allFriends.length.toString()
]
) }
</Text>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ friendsWithoutRights.map((friend, index) =>
{
return (
<Flex key={ `${friend.id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ friend.id } />
<Text
pointer
grow
onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }>
{ friend.name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
) }>
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
<Column fullWidth overflow="auto" gap={ 1 }>
{ friendsWithoutRights.map((friend, index) =>
{
return (
<Flex key={ `${friend.id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
<UserProfileIconView userId={ friend.id } />
<Text
pointer
grow
onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }>
{ friend.name }
</Text>
</Flex>
);
}) }
</Column>
</Flex>
</NavigatorRoomSettingsSectionView>
</Column>
</Grid>
);
@@ -0,0 +1,22 @@
import { FC, ReactNode } from 'react';
import { Column, Text } from '../../../../common';
interface NavigatorRoomSettingsSectionViewProps
{
title?: string;
gap?: 1 | 2 | 3;
className?: string;
children: ReactNode;
}
export const NavigatorRoomSettingsSectionView: FC<NavigatorRoomSettingsSectionViewProps> = props =>
{
const { title = null, gap = 2, className = '', children = null } = props;
return (
<Column gap={ gap } className={ `rounded bg-gray-100 p-3 ${ className }`.trim() }>
{ title && <Text bold small>{ title }</Text> }
{ children }
</Column>
);
};
@@ -3,6 +3,7 @@ import { FC, useEffect, useState } from 'react';
import { GetClubMemberLevel, IRoomData, LocalizeText } from '../../../../api';
import { Column, Grid, Text } from '../../../../common';
import { NitroInput } from '../../../../layout';
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
interface NavigatorRoomSettingsTabViewProps
{
@@ -29,48 +30,50 @@ export const NavigatorRoomSettingsVipChatTabView: FC<NavigatorRoomSettingsTabVie
</div>
<Grid className={ !isHC ? 'opacity-50 pointer-events-none' : '' } overflow="auto">
<Column gap={ 1 } size={ 6 }>
<Text small bold>{ LocalizeText('navigator.roomsettings.chat_settings') }</Text>
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.info') }</Text>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.mode } onChange={ event => handleChange('bubble_mode', event.target.value) }>
<option value={ RoomChatSettings.CHAT_MODE_FREE_FLOW }>{ LocalizeText('navigator.roomsettings.chat.mode.free.flow') }</option>
<option value={ RoomChatSettings.CHAT_MODE_LINE_BY_LINE }>{ LocalizeText('navigator.roomsettings.chat.mode.line.by.line') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.weight } onChange={ event => handleChange('chat_weight', event.target.value) }>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.normal') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_THIN }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.thin') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_WIDE }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.wide') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.speed } onChange={ event => handleChange('bubble_speed', event.target.value) }>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_FAST }>{ LocalizeText('navigator.roomsettings.chat.speed.fast') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.speed.normal') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_SLOW }>{ LocalizeText('navigator.roomsettings.chat.speed.slow') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.protection } onChange={ event => handleChange('flood_protection', event.target.value) }>
<option value={ RoomChatSettings.FLOOD_FILTER_LOOSE }>{ LocalizeText('navigator.roomsettings.chat.flood.loose') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.flood.normal') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_STRICT }>{ LocalizeText('navigator.roomsettings.chat.flood.strict') }</option>
</select>
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.hearing.distance') }</Text>
<NitroInput className="form-control-sm" disabled={ !isHC } min="0" type="number" value={ chatDistance } onBlur={ event => handleChange('chat_distance', chatDistance) } onChange={ event => setChatDistance(event.target.valueAsNumber) } />
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.chat_settings') } gap={ 1 } className="h-full">
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.info') }</Text>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.mode } onChange={ event => handleChange('bubble_mode', event.target.value) }>
<option value={ RoomChatSettings.CHAT_MODE_FREE_FLOW }>{ LocalizeText('navigator.roomsettings.chat.mode.free.flow') }</option>
<option value={ RoomChatSettings.CHAT_MODE_LINE_BY_LINE }>{ LocalizeText('navigator.roomsettings.chat.mode.line.by.line') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.weight } onChange={ event => handleChange('chat_weight', event.target.value) }>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.normal') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_THIN }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.thin') }</option>
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_WIDE }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.wide') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.speed } onChange={ event => handleChange('bubble_speed', event.target.value) }>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_FAST }>{ LocalizeText('navigator.roomsettings.chat.speed.fast') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.speed.normal') }</option>
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_SLOW }>{ LocalizeText('navigator.roomsettings.chat.speed.slow') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.protection } onChange={ event => handleChange('flood_protection', event.target.value) }>
<option value={ RoomChatSettings.FLOOD_FILTER_LOOSE }>{ LocalizeText('navigator.roomsettings.chat.flood.loose') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.flood.normal') }</option>
<option value={ RoomChatSettings.FLOOD_FILTER_STRICT }>{ LocalizeText('navigator.roomsettings.chat.flood.strict') }</option>
</select>
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.hearing.distance') }</Text>
<NitroInput className="form-control-sm" disabled={ !isHC } min="0" type="number" value={ chatDistance } onBlur={ event => handleChange('chat_distance', chatDistance) } onChange={ event => setChatDistance(event.target.valueAsNumber) } />
</NavigatorRoomSettingsSectionView>
</Column>
<Column gap={ 1 } size={ 6 }>
<Text small bold>{ LocalizeText('navigator.roomsettings.vip_settings') }</Text>
<div className="flex items-center gap-1">
<input checked={ roomData.hideWalls } className="form-check-input" disabled={ !isHC } type="checkbox" onChange={ event => handleChange('hide_walls', event.target.checked) } />
<Text small>{ LocalizeText('navigator.roomsettings.hide_walls') }</Text>
</div>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.wallThickness } onChange={ event => handleChange('wall_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.floorThickness } onChange={ event => handleChange('floor_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
</select>
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.vip_settings') } gap={ 1 } className="h-full">
<div className="flex items-center gap-1">
<input checked={ roomData.hideWalls } className="form-check-input" disabled={ !isHC } type="checkbox" onChange={ event => handleChange('hide_walls', event.target.checked) } />
<Text small>{ LocalizeText('navigator.roomsettings.hide_walls') }</Text>
</div>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.wallThickness } onChange={ event => handleChange('wall_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.wall_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.wall_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
</select>
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.floorThickness } onChange={ event => handleChange('floor_thickness', event.target.value) }>
<option value="0">{ LocalizeText('navigator.roomsettings.floor_thickness.normal') }</option>
<option value="1">{ LocalizeText('navigator.roomsettings.floor_thickness.thick') }</option>
<option value="-1">{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
<option value="-2">{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
</select>
</NavigatorRoomSettingsSectionView>
</Column>
</Grid>
</>
@@ -0,0 +1,33 @@
import { FC } from 'react';
import { FaPlus, FaSearch } from 'react-icons/fa';
import { LocalizeText } from '../../../../api';
import { Button } from '../../../../common';
interface NavigatorEmptyStateViewProps
{
code: string;
onCreateRoom: () => void;
}
export const NavigatorEmptyStateView: FC<NavigatorEmptyStateViewProps> = props =>
{
const { code, onCreateRoom } = props;
const isMyWorld = (code === 'myworld_view');
const messageKey = isMyWorld ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results';
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-4 py-8 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-black/5 text-muted">
<FaSearch size={ 26 } className="opacity-40" />
</div>
<div className="text-sm text-muted max-w-[240px]">
{ LocalizeText(messageKey) }
</div>
<Button variant="primary" onClick={ onCreateRoom }>
<FaPlus className="fa-icon me-1" />
{ LocalizeText('navigator.createroom.create') }
</Button>
</div>
);
};
@@ -0,0 +1,32 @@
import { FC } from 'react';
import { LocalizeText, SearchFilterOptions } from '../../../../api';
interface NavigatorFilterChipsViewProps
{
value: number;
onChange: (index: number) => void;
}
export const NavigatorFilterChipsView: FC<NavigatorFilterChipsViewProps> = props =>
{
const { value, onChange } = props;
return (
<div className="flex flex-wrap gap-1">
{ SearchFilterOptions.map((filter, index) =>
{
const isActive = (value === index);
return (
<button
key={ index }
type="button"
onClick={ () => onChange(index) }
className={ `px-2 py-0.5 rounded-full text-[11px] border cursor-pointer transition-colors ${ isActive ? 'bg-primary text-white border-primary' : 'bg-card-grid-item text-gray-600 border-card-grid-item-border hover:bg-primary hover:text-white hover:border-primary' }` }>
{ LocalizeText('navigator.filter.' + filter.name) }
</button>
);
}) }
</div>
);
};
@@ -2,9 +2,9 @@ import { RoomDataParser, RoomSettingsComposer, UpdateHomeRoomMessageComposer } f
import * as Popover from '@radix-ui/react-popover';
import React, { FC, useRef, useState } from 'react';
import { FaUser } from 'react-icons/fa';
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api';
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
import { useHelp, useNavigatorData } from '../../../../hooks';
import { useHelp, useNavigatorData, useNavigatorFavourite } from '../../../../hooks';
import { classNames } from '../../../../layout';
interface NavigatorSearchResultItemInfoViewProps
@@ -20,7 +20,8 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
const { roomData = null, isVisible = undefined, onToggle, setIsPopoverActive } = props;
const elementRef = useRef<HTMLDivElement>(null);
const [ internalVisible, setInternalVisible ] = useState(false);
const { navigatorData, favouriteRoomIds } = useNavigatorData();
const { navigatorData } = useNavigatorData();
const { isFavourite, toggle: toggleFavourite } = useNavigatorFavourite(roomData?.roomId);
const { report = null } = useHelp();
const isControlled = isVisible !== undefined;
@@ -63,7 +64,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
report(ReportType.ROOM, { roomId: roomData.roomId, roomName: roomData.roomName });
return;
case 'room_favourite':
ToggleFavoriteRoom(roomData.roomId, favouriteRoomIds.includes(roomData.roomId));
toggleFavourite();
return;
}
};
@@ -163,7 +164,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
</Column>
<Column alignItems="start" gap={ 2 } className="w-2/5">
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('room_favourite') }>
<i className={ classNames('icon icon-navigator-favorite-room', favouriteRoomIds.includes(roomData.roomId) ? 'active' : '') } />
<i className={ classNames('icon icon-navigator-favorite-room', isFavourite ? 'active' : '') } />
<Text className="text-xs">{ LocalizeText('navigator.room.popup.room.info.favorite') }</Text>
</Flex>
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('set_home_room') }>
@@ -1,5 +1,6 @@
import { NavigatorDeleteSavedSearchComposer, NavigatorSavedSearch, NavigatorSearchComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FC, MouseEvent } from 'react';
import { FaBolt } from 'react-icons/fa';
import { LocalizeText, SendMessageComposer } from '../../../../api';
import { Flex, Text } from '../../../../common';
@@ -11,7 +12,6 @@ export interface NavigatorSearchSavesResultItemViewProps
export const NavigatorSearchSavesResultItemView: FC<NavigatorSearchSavesResultItemViewProps> = props =>
{
const { search = null } = props;
const [ isHovered, setIsHovered ] = useState(false);
const getResultTitle = () =>
{
@@ -24,23 +24,33 @@ export const NavigatorSearchSavesResultItemView: FC<NavigatorSearchSavesResultIt
return ('navigator.searchcode.title.' + name);
};
const openSearch = () => SendMessageComposer(new NavigatorSearchComposer(search.code.split('.').reverse()[0], search.filter));
const deleteSearch = (event: MouseEvent) =>
{
event.stopPropagation();
SendMessageComposer(new NavigatorDeleteSavedSearchComposer(search.id));
};
return (
<Flex grow pointer alignItems="center" gap={ 1 } onMouseEnter={ () => setIsHovered(true) } onMouseLeave={ () => setIsHovered(false) }>
{ isHovered &&
<i
className="nitro-icon icon-navigator-search-delete cursor-pointer flex-shrink-0"
title={ LocalizeText('navigator.tooltip.remove.saved.search') }
onClick={ () => SendMessageComposer(new NavigatorDeleteSavedSearchComposer(search.id)) }
/> }
<Text
small
pointer
variant="black"
title={ LocalizeText('navigator.tooltip.open.saved.search') }
onClick={ () => SendMessageComposer(new NavigatorSearchComposer(search.code.split('.').reverse()[0], search.filter)) }
>
<Flex
grow
pointer
alignItems="center"
gap={ 1 }
className="saved-search-row group px-1 py-0.5"
title={ LocalizeText('navigator.tooltip.open.saved.search') }
onClick={ openSearch }
>
<FaBolt className="text-orange-500 shrink-0 text-[10px]" />
<Text small pointer truncate variant="black" className="grow! min-w-0">
{ LocalizeText(getResultTitle()) }
</Text>
<i
className="nitro-icon icon-navigator-search-delete cursor-pointer flex-shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
title={ LocalizeText('navigator.tooltip.remove.saved.search') }
onClick={ deleteSearch }
/>
</Flex>
);
};
@@ -15,16 +15,19 @@ export const NavigatorSearchSavesResultView: FC<NavigatorSearchSavesResultViewPr
const { searches = [] } = props;
return (
<Column className="nitro-navigator-search-saves-result min-w-[100px]">
<Flex className="rounded px-2 py-1 bg-orange-500" gap={ 1 } alignItems="center">
<Column className="nitro-navigator-search-saves-result h-full min-w-[100px] sm:w-[150px]" gap={ 1 }>
<Flex className="rounded px-2 py-1 bg-orange-500 shrink-0" gap={ 1 } alignItems="center">
<FaBolt color="white" />
<Text variant="white">{ LocalizeText('navigator.quick.links.title') }</Text>
<Text variant="white" truncate>{ LocalizeText('navigator.quick.links.title') }</Text>
</Flex>
<Column className="p-1 overflow-x-hidden overflow-y-auto">
{ (searches && searches.length > 0) &&
searches.map((search: NavigatorSavedSearch) => (
<Column className="flex-1 min-h-0 p-1 overflow-x-hidden overflow-y-auto" gap={ 0 }>
{ (searches && searches.length > 0)
? searches.map((search: NavigatorSavedSearch) => (
<NavigatorSearchSavesResultItemView key={ search.id } search={ search } />
)) }
))
: <Flex center className="py-4 opacity-30">
<FaBolt className="text-orange-500" size={ 22 } />
</Flex> }
</Column>
</Column>
);
@@ -0,0 +1,25 @@
import { FC } from 'react';
interface NavigatorSearchSkeletonViewProps
{
rows?: number;
}
export const NavigatorSearchSkeletonView: FC<NavigatorSearchSkeletonViewProps> = props =>
{
const { rows = 5 } = props;
return (
<div className="flex flex-col gap-2" aria-hidden="true">
{ Array.from({ length: rows }).map((_, index) =>
<div key={ index } className="nitro-card-panel flex items-center gap-2 px-2 py-2">
<div className="h-10 w-10 shrink-0 rounded bg-black/10 animate-pulse" />
<div className="flex flex-1 flex-col gap-1">
<div className="h-3 w-1/2 rounded bg-black/10 animate-pulse" />
<div className="h-2.5 w-1/3 rounded bg-black/10 animate-pulse" />
</div>
<div className="h-4 w-8 shrink-0 rounded bg-black/10 animate-pulse" />
</div>) }
</div>
);
};
@@ -1,15 +1,23 @@
import { FC, KeyboardEvent, useEffect, useState } from 'react';
import { NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { FC, useEffect, useRef, useState } from 'react';
import { FaSearch } from 'react-icons/fa';
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
import { Button } from '../../../../common';
import { useNavigatorData, useNavigatorSearch, useNavigatorUiStore } from '../../../../hooks';
import { useNavigatorData, useNavigatorUiStore } from '../../../../hooks';
import { NavigatorFilterChipsView } from './NavigatorFilterChipsView';
export const NavigatorSearchView: FC<{}> = props =>
interface NavigatorSearchViewProps
{
searchResult: NavigatorSearchResultSet | null;
}
export const NavigatorSearchView: FC<NavigatorSearchViewProps> = props =>
{
const { searchResult } = props;
const [ searchFilterIndex, setSearchFilterIndex ] = useState(0);
const [ inputText, setInputText ] = useState('');
const formRef = useRef<HTMLFormElement>(null);
const { topLevelContext } = useNavigatorData();
const { searchResult } = useNavigatorSearch();
// Sync the input text display when a server result arrives (e.g. on tab switch
// or deep-link navigation that sets the filter through the store directly).
@@ -54,38 +62,27 @@ export const NavigatorSearchView: FC<{}> = props =>
return () => clearTimeout(timer);
}, [ inputText, searchFilterIndex ]);
const processSearch = () =>
// React 19 form action — fires on Enter or the submit button, skipping the
// debounce timer for an immediate search.
const submitSearch = (formData: FormData) =>
{
if(!topLevelContext) return;
// Immediate submit — skip the debounce timer
const raw = formData.get('q');
const value = (typeof raw === 'string') ? raw : inputText;
const searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText;
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + value;
useNavigatorUiStore.getState().setFilter(searchQuery);
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) =>
{
if(event.key !== 'Enter') return;
processSearch();
};
return (
<div className="flex w-full gap-1">
<div className="flex shrink-0">
<select className="form-select" value={ searchFilterIndex } onChange={ event => setSearchFilterIndex(parseInt(event.target.value)) }>
{ SearchFilterOptions.map((filter, index) =>
{
return <option key={ index } value={ index }>{ LocalizeText('navigator.filter.' + filter.name) }</option>;
}) }
</select>
</div>
<div className="flex w-full gap-1">
<input className="w-full form-control" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ inputText } onChange={ event => setInputText(event.target.value) } onKeyDown={ event => handleKeyDown(event) } />
<Button variant="primary" onClick={ processSearch }>
<div className="flex w-full flex-col gap-1">
<NavigatorFilterChipsView value={ searchFilterIndex } onChange={ setSearchFilterIndex } />
<form ref={ formRef } action={ submitSearch } className="flex w-full gap-1">
<input className="w-full form-control" name="q" placeholder={ LocalizeText('navigator.filter.input.placeholder') } type="text" value={ inputText } onChange={ event => setInputText(event.target.value) } />
<Button variant="primary" onClick={ () => formRef.current?.requestSubmit() }>
<FaSearch className="fa-icon" />
</Button>
</div>
</form>
</div>
);
};
+21
View File
@@ -476,6 +476,24 @@ body {
border-color: #aeb7aa !important;
}
.navigator-grid .navigator-item {
border-radius: 6px;
transition: background-color .15s ease;
}
.navigator-grid .navigator-item:hover {
background: rgba(0, 0, 0, 0.07);
}
.nitro-navigator-search-saves-result .saved-search-row {
border-radius: 6px;
transition: background-color .15s ease;
}
.nitro-navigator-search-saves-result .saved-search-row:hover {
background: rgba(0, 0, 0, 0.07);
}
.nitro-card-divider {
border-color: #c4cabf !important;
box-shadow: none !important;
@@ -523,6 +541,9 @@ body {
flex-wrap: wrap;
gap: 3px;
padding: 4px 6px 0;
max-height: none;
height: auto;
flex: 0 0 auto;
}
.nitro-card-tab-item {
+1
View File
@@ -1,4 +1,5 @@
export { useNavigatorData } from './useNavigatorData';
export { useNavigatorFavourite } from './useNavigatorFavourite';
export { useNavigatorSearch } from './useNavigatorSearch';
export { useNavigatorUiState } from './useNavigatorUiState';
export { useNavigatorUiStore } from './navigatorUiStore';
@@ -0,0 +1,46 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
const reset = () => useNavigatorFavouritesStore.setState({ ids: new Set<number>() });
describe('navigatorFavouritesStore', () =>
{
beforeEach(reset);
it('setAll replaces membership and coerces ids to numbers', () =>
{
useNavigatorFavouritesStore.getState().setAll([ '1', 2, '3' ] as any);
const { ids } = useNavigatorFavouritesStore.getState();
expect(ids.has(1)).toBe(true);
expect(ids.has(2)).toBe(true);
expect(ids.has(3)).toBe(true);
expect(ids.size).toBe(3);
});
it('apply(true) adds and apply(false) removes', () =>
{
const { apply } = useNavigatorFavouritesStore.getState();
apply(7, true);
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(true);
apply(7, false);
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(false);
});
it('apply returns the same state reference when nothing changes (no needless re-render)', () =>
{
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
const before = useNavigatorFavouritesStore.getState().ids;
useNavigatorFavouritesStore.getState().apply(5, true); // already present
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
useNavigatorFavouritesStore.getState().apply(99, false); // already absent
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
});
it('apply creates a new Set reference when membership actually changes', () =>
{
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
const before = useNavigatorFavouritesStore.getState().ids;
useNavigatorFavouritesStore.getState().apply(6, true);
expect(useNavigatorFavouritesStore.getState().ids).not.toBe(before);
});
});
@@ -0,0 +1,25 @@
import { createNitroStore } from '../../state/createNitroStore';
export type NavigatorFavouritesState = {
ids: Set<number>;
};
export type NavigatorFavouritesActions = {
setAll(roomIds: number[]): void;
apply(roomId: number, added: boolean): void;
};
export const useNavigatorFavouritesStore = createNitroStore<NavigatorFavouritesState & NavigatorFavouritesActions>()((set) => ({
ids: new Set<number>(),
setAll: (roomIds) => set({ ids: new Set(roomIds.map(Number)) }),
apply: (roomId, added) => set((s) =>
{
const id = Number(roomId);
if(added ? s.ids.has(id) : !s.ids.has(id)) return s;
const ids = new Set(s.ids);
if(added) ids.add(id);
else ids.delete(id);
return { ids };
})
}));
+2 -2
View File
@@ -4,13 +4,13 @@ import { useNavigatorStore } from './useNavigatorStore';
export const useNavigatorData = () =>
{
const {
categories, eventCategories, favouriteRoomIds,
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
} = useBetween(useNavigatorStore);
return {
categories, eventCategories, favouriteRoomIds,
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
};
@@ -0,0 +1,12 @@
import { useCallback } from 'react';
import { ToggleFavoriteRoom } from '../../api';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
export const useNavigatorFavourite = (roomId: number) =>
{
const isFavourite = useNavigatorFavouritesStore((s) => s.ids.has(Number(roomId)));
const toggle = useCallback(() => ToggleFavoriteRoom(Number(roomId), isFavourite), [ roomId, isFavourite ]);
return { isFavourite, toggle };
};
+19 -108
View File
@@ -1,8 +1,6 @@
/* @vitest-environment jsdom */
import { FlatCreatedEvent, NavigatorSearchEvent,
NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mockEventDispatcher } from '../../nitro-renderer.mock';
@@ -13,23 +11,12 @@ import { useNavigatorSearch } from './useNavigatorSearch';
// Helpers
// ---------------------------------------------------------------------------
/** Create a fresh QueryClient with retries off so failures are immediate. */
const makeQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0 }
}
});
// NOTE: useNavigatorSearch uses useMessageEvent + useState (NOT useNitroQuery).
// The one-shot query pattern was reverted upstream (05d71dd1) because it left
// the UI blank when the listener never matched. These tests exercise the
// event-driven implementation directly — no QueryClient scaffolding.
/** Wrapper factory — each test gets its own QueryClient instance. */
const makeWrapper = (client: QueryClient) =>
({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={ client }>
{ children }
</QueryClientProvider>
);
/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */
/** Build a fake NavigatorSearchEvent whose getParser() returns a result with `code`. */
const makeSearchEvent = (code: string) =>
{
// Cast constructors as `any` so tsgo doesn't check required args against
@@ -66,7 +53,6 @@ describe('useNavigatorSearch', () =>
{
beforeEach(() =>
{
// Reset UI store state before each test
useNavigatorUiStore.setState(INITIAL_UI);
});
@@ -76,59 +62,44 @@ describe('useNavigatorSearch', () =>
vi.clearAllMocks();
});
it('1. with empty tabCode query is disabled — NavigatorSearchEvent does not update data', async () =>
it('1. with empty tabCode no fetch starts (the request effect is gated)', () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// Dispatch a search event — should be ignored (query disabled)
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
// Data must stay null
expect(result.current.searchResult).toBeNull();
// No tab selected → the request effect short-circuits, nothing fetches.
expect(result.current.isFetching).toBe(false);
expect(result.current.searchResult).toBeNull();
});
it('2. after setTab("public"), NavigatorSearchComposer is fired and NavigatorSearchEvent resolves query', async () =>
it('2. after setTab("public"), the hook starts fetching and a matching event resolves it', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// Activate the query
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
// Hook should start fetching
await waitFor(() => expect(result.current.isFetching).toBe(true));
// Simulate server response
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
// Query should resolve with the matching result
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
expect((result.current.searchResult as any).code).toBe('public');
expect(result.current.isFetching).toBe(false);
});
it('3. after setFilter("cocco"), a new query fires and NavigatorSearchEvent resolves it', async () =>
it('3. after setFilter("cocco"), a new fetch fires and a matching event resolves it', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// First establish a tab
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
// Resolve the initial query
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
@@ -136,7 +107,6 @@ describe('useNavigatorSearch', () =>
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
// Now set a filter — triggers new query
act(() =>
{
useNavigatorUiStore.getState().setFilter('cocco');
@@ -144,7 +114,6 @@ describe('useNavigatorSearch', () =>
await waitFor(() => expect(result.current.isFetching).toBe(true));
// Resolve with matching event
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
@@ -152,24 +121,19 @@ describe('useNavigatorSearch', () =>
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect((result.current.searchResult as any).code).toBe('public');
// Confirm filter is set
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
});
it('4. after setTab("events"), currentFilter resets to "" and new query fires for events', async () =>
it('4. after setTab("events"), currentFilter resets to "" and a new fetch fires for events', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
// Establish public tab with a filter
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
useNavigatorUiStore.getState().setFilter('some-filter');
});
// Resolve the public+filter query
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
@@ -177,20 +141,16 @@ describe('useNavigatorSearch', () =>
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
// Switch to events tab — should atomically reset filter
act(() =>
{
useNavigatorUiStore.getState().setTab('events');
});
// Filter must be cleared
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
// New query for 'events' fires
await waitFor(() => expect(result.current.isFetching).toBe(true));
// Resolve with events result
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any);
@@ -202,8 +162,7 @@ describe('useNavigatorSearch', () =>
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
@@ -226,8 +185,7 @@ describe('useNavigatorSearch', () =>
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
{
const client = makeQueryClient();
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
const { result } = renderHook(() => useNavigatorSearch());
act(() =>
{
@@ -236,67 +194,20 @@ describe('useNavigatorSearch', () =>
await waitFor(() => expect(result.current.isFetching).toBe(true));
// Dispatch an event for a DIFFERENT tab — should be rejected by accept filter
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any);
});
// Still fetching — the wrong-tab event was ignored
// (the query promise stays pending until it times out or a matching event arrives)
// After the wrong-tab dispatch, data should NOT be updated
// The wrong-tab event is filtered out by the accept guard.
expect(result.current.searchResult).toBeNull();
// Now dispatch the correct one to unblock the test
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
// Only the correct-tab result is stored
expect((result.current.searchResult as any).code).toBe('public');
});
it('7. dispatching FlatCreatedEvent triggers query invalidation (refetch starts)', async () =>
{
const client = makeQueryClient();
// Spy on invalidateQueries to confirm the invalidator calls it
const invalidateSpy = vi.spyOn(client, 'invalidateQueries');
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
// Establish a resolved query so there is something to invalidate
act(() =>
{
useNavigatorUiStore.getState().setTab('public');
});
await waitFor(() => expect(result.current.isFetching).toBe(true));
act(() =>
{
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
// Dispatch FlatCreatedEvent — should trigger invalidateQueries
const flatCreatedEv = new (FlatCreatedEvent as any)() as any;
flatCreatedEv.getParser = () => ({ roomId: 999 });
act(() =>
{
mockEventDispatcher.dispatchEvent(flatCreatedEv as any);
});
await waitFor(() => expect(invalidateSpy).toHaveBeenCalled());
// The invalidation should target the 'navigator', 'search' key prefix
const calls = invalidateSpy.mock.calls;
const calledWithSearchKey = calls.some(call =>
{
const opts = call[0] as any;
const key: string[] = opts?.queryKey ?? [];
return key[0] === 'navigator' && key[1] === 'search';
});
expect(calledWithSearchKey).toBe(true);
});
});
+1 -5
View File
@@ -1,5 +1,4 @@
import { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
import { useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react';
import { SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
@@ -23,7 +22,6 @@ export const useNavigatorSearch = () =>
{
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
const filter = useNavigatorUiStore(s => s.currentFilter);
const queryClient = useQueryClient();
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet | null>(null);
const [ isFetching, setIsFetching ] = useState(false);
@@ -49,11 +47,9 @@ export const useNavigatorSearch = () =>
setIsFetching(false);
});
// A newly created room invalidates the current search so it refetches.
// A newly created room refetches the current search.
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, () =>
{
queryClient.invalidateQueries({ queryKey: [ 'navigator', 'search' ] });
if(!tabCode) return;
setIsFetching(true);
@@ -8,7 +8,7 @@ describe('navigator filter shapes (smoke)', () =>
{
const { result } = renderHook(() => useNavigatorData());
expect(Object.keys(result.current).sort()).toEqual([
'categories', 'eventCategories', 'favouriteRoomIds',
'categories', 'eventCategories',
'navigatorData', 'navigatorSearches',
'topLevelContext', 'topLevelContexts'
].sort());
+4 -12
View File
@@ -18,13 +18,13 @@ import { CreateRoomSession, GetConfigurationValue, INavigatorData,
TryVisitRoom, VisitDesktop } from '../../api';
import { useMessageEvent, useNitroEvent } from '../events';
import { useNotification } from '../notification';
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
import { useNavigatorUiStore } from './navigatorUiStore';
export const useNavigatorStore = () =>
{
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
@@ -48,21 +48,13 @@ export const useNavigatorStore = () =>
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
{
const parser = event.getParser();
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
setFavouriteRoomIds(favoriteIds);
useNavigatorFavouritesStore.getState().setAll(parser.favoriteRoomIds || []);
}, []));
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
{
const parser = event.getParser();
const roomId = Number(parser.flatId);
const added = !!parser.added;
setFavouriteRoomIds(prev =>
{
const ids = (prev || []).map((x: any) => Number(x));
if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ];
return ids.filter(id => id !== roomId);
});
useNavigatorFavouritesStore.getState().apply(parser.flatId, !!parser.added);
}, []));
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
@@ -280,7 +272,7 @@ export const useNavigatorStore = () =>
}, []));
return {
categories, eventCategories, favouriteRoomIds,
categories, eventCategories,
topLevelContext, topLevelContexts,
navigatorSearches, navigatorData
};