mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge pull request #175 from simoleo89/feat/navigator-p4-visual-wave1
feat(navigator): empty-state + skeleton + double-fetch fix (P4 wave 1a)
This commit is contained in:
@@ -44,3 +44,4 @@ Thumbs.db
|
||||
# the dev server takes minutes to start with 100k+ files under public/.
|
||||
/public/nitro-assets
|
||||
/public/swf
|
||||
.superpowers/
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
+26
-26
@@ -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,42 +99,41 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex alignItems="center" gap={ 1 }>
|
||||
<Text className="col-3">{ LocalizeText('navigator.roomname') }</Text>
|
||||
<Column fullWidth gap={ 0 }>
|
||||
<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>
|
||||
</Flex>
|
||||
<Flex alignItems="center" gap={ 1 }>
|
||||
<Text className="col-3">{ LocalizeText('navigator.roomsettings.desc') }</Text>
|
||||
<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>
|
||||
<Flex alignItems="center" gap={ 1 }>
|
||||
<Text className="col-3">{ LocalizeText('navigator.tags') }</Text>
|
||||
</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) &&
|
||||
@@ -157,19 +157,19 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
|
||||
</Text> }
|
||||
</Column>
|
||||
</Flex>
|
||||
</Column>
|
||||
<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,7 +52,7 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
|
||||
return (
|
||||
<Grid overflow="auto">
|
||||
<Column size={ 6 }>
|
||||
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.banned.users') } ({ bannedUsers.length })</Text>
|
||||
<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) =>
|
||||
@@ -68,11 +69,12 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
|
||||
<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 }>
|
||||
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.moderation') } gap={ 2 } className="h-full">
|
||||
<Column gap={ 1 }>
|
||||
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.mute.header') }</Text>
|
||||
<Flex alignItems="center" 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 }>
|
||||
<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 }>
|
||||
<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>
|
||||
</NavigatorRoomSettingsSectionView>
|
||||
</Column>
|
||||
</Grid>
|
||||
);
|
||||
|
||||
+9
-10
@@ -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,17 +106,15 @@ 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) =>
|
||||
@@ -141,20 +140,19 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
|
||||
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) =>
|
||||
@@ -173,6 +171,7 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
|
||||
}) }
|
||||
</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>
|
||||
);
|
||||
};
|
||||
+5
-2
@@ -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,7 +30,7 @@ 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>
|
||||
<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>
|
||||
@@ -52,9 +53,10 @@ export const NavigatorRoomSettingsVipChatTabView: FC<NavigatorRoomSettingsTabVie
|
||||
</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>
|
||||
<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>
|
||||
@@ -71,6 +73,7 @@ export const NavigatorRoomSettingsVipChatTabView: FC<NavigatorRoomSettingsTabVie
|
||||
<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
|
||||
<Flex
|
||||
grow
|
||||
pointer
|
||||
variant="black"
|
||||
alignItems="center"
|
||||
gap={ 1 }
|
||||
className="saved-search-row group px-1 py-0.5"
|
||||
title={ LocalizeText('navigator.tooltip.open.saved.search') }
|
||||
onClick={ () => SendMessageComposer(new NavigatorSearchComposer(search.code.split('.').reverse()[0], search.filter)) }
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,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 };
|
||||
})
|
||||
}));
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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 +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());
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user