mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge branch 'Dev' into Dev
This commit is contained in:
+1
-3
@@ -44,6 +44,4 @@ Thumbs.db
|
|||||||
# the dev server takes minutes to start with 100k+ files under public/.
|
# the dev server takes minutes to start with 100k+ files under public/.
|
||||||
/public/nitro-assets
|
/public/nitro-assets
|
||||||
/public/swf
|
/public/swf
|
||||||
|
.superpowers/
|
||||||
# Temi custom locali di test (i temi veri stanno sul server, mai su git)
|
|
||||||
public/custom-themes/
|
|
||||||
|
|||||||
@@ -6,19 +6,27 @@ the ground running.
|
|||||||
|
|
||||||
## TL;DR
|
## TL;DR
|
||||||
|
|
||||||
This branch — **`feat/react19-modernization`** — is a long-running modernization
|
This client carries a long-running React 19.2 modernization: React 19
|
||||||
of the Nitro V3 client: bump to React 19.2 idioms, add the supporting
|
idioms + supporting infrastructure (TanStack Query, Zustand, Vitest,
|
||||||
infrastructure (TanStack Query, Zustand, Vitest, React Compiler, error
|
React Compiler, error boundaries), god-hook splits, and logic-bug audits.
|
||||||
boundaries), split a few god-hooks, and audit logic bugs along the way.
|
|
||||||
PR is **#2** on `simoleo89/Nitro-V3`.
|
|
||||||
|
|
||||||
Upstream `duckietm/Nitro-V3` (`origin/Dev`) is merged in through
|
**Working base is now `main`** (tracking `duckietm/Nitro-V3`). The earlier
|
||||||
`b2318b9` as of 2026-05-18 (merge commit `779a98c`). That brings in
|
`feat/react19-modernization` long-running branch was superseded — feature
|
||||||
JSON5 config support, user-settings (reset password / email / change
|
work now ships as small focused PRs against `duckietm:Dev`, staged through
|
||||||
username), wear-badge popup fix, login screen fix, About update, and
|
Dev then merged to main. (`feat/react19-modernization` still exists on the
|
||||||
the offer-selection refactor. When syncing the next batch of upstream
|
fork as backup; do not force-push it.)
|
||||||
commits, expect conflicts in `App.tsx` / `bootstrap.ts` / `LoginView.tsx`
|
|
||||||
on React 19 imports — always keep the modernized local version.
|
**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
|
Local-dev game assets are served by a small Vite plugin (`sirv` middleware
|
||||||
mounted on `/nitro-assets` and `/swf`, reading from
|
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.
|
refresh paths work the same as the initial request/response (e.g.
|
||||||
ClubGiftInfoEvent firing again after the user claims a gift).
|
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
|
### Singleton-filter split for `useBetween`-based hooks
|
||||||
|
|
||||||
When a hook backs many consumers but most only need either state OR
|
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) |
|
| 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 (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) |
|
| 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) |
|
| `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. |
|
| 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) |
|
| Form Actions | Login / Register / Forgot (LoginView.tsx) |
|
||||||
@@ -412,6 +435,11 @@ See `docs/ARCHITECTURE.md` "Recently fixed" for fix shapes.
|
|||||||
`useCatalogUiState` / `useCatalogActions` in
|
`useCatalogUiState` / `useCatalogActions` in
|
||||||
`src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated;
|
`src/hooks/catalog/useCatalog.ts` (all 48 consumers migrated;
|
||||||
deprecated `useCatalog` shim removed)
|
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`
|
- Renderer-SDK mock for Vitest: `src/nitro-renderer.mock.ts`
|
||||||
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
|
(aliased over `@nitrots/nitro-renderer` via `vitest.config.mts`).
|
||||||
Hosts the explicit `NitroLogger` mock, the `mockEventDispatcher` /
|
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 { NavigatorRoomInfoView } from './views/NavigatorRoomInfoView';
|
||||||
import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView';
|
import { NavigatorRoomLinkView } from './views/NavigatorRoomLinkView';
|
||||||
import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView';
|
import { NavigatorRoomSettingsView } from './views/room-settings/NavigatorRoomSettingsView';
|
||||||
|
import { NavigatorEmptyStateView } from './views/search/NavigatorEmptyStateView';
|
||||||
import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView';
|
import { NavigatorSearchResultView } from './views/search/NavigatorSearchResultView';
|
||||||
import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView';
|
import { NavigatorSearchSavesResultView } from './views/search/NavigatorSearchSavesResultView';
|
||||||
|
import { NavigatorSearchSkeletonView } from './views/search/NavigatorSearchSkeletonView';
|
||||||
import { NavigatorSearchView } from './views/search/NavigatorSearchView';
|
import { NavigatorSearchView } from './views/search/NavigatorSearchView';
|
||||||
|
|
||||||
export const NavigatorView: FC<{}> = props =>
|
export const NavigatorView: FC<{}> = props =>
|
||||||
@@ -107,7 +109,7 @@ export const NavigatorView: FC<{}> = props =>
|
|||||||
<>
|
<>
|
||||||
{ isVisible &&
|
{ isVisible &&
|
||||||
<NitroCard
|
<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">
|
uniqueKey="navigator">
|
||||||
<NitroCard.Header
|
<NitroCard.Header
|
||||||
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
|
headerText={ LocalizeText(isCreatorOpen ? 'navigator.createroom.title' : 'navigator.title') }
|
||||||
@@ -132,21 +134,21 @@ export const NavigatorView: FC<{}> = props =>
|
|||||||
<FaPlus className="fa-icon" />
|
<FaPlus className="fa-icon" />
|
||||||
</NitroCard.TabItem>
|
</NitroCard.TabItem>
|
||||||
</NitroCard.Tabs>
|
</NitroCard.Tabs>
|
||||||
<NitroCard.Content isLoading={ isFetching }>
|
<NitroCard.Content>
|
||||||
{ !isCreatorOpen &&
|
{ !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 &&
|
{ 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 || [] } />
|
<NavigatorSearchSavesResultView searches={ navigatorSearches || [] } />
|
||||||
</div> }
|
</div> }
|
||||||
<div className="flex flex-col w-full overflow-hidden gap-2">
|
<div className="flex flex-col w-full min-h-0 overflow-hidden gap-2">
|
||||||
<NavigatorSearchView />
|
<NavigatorSearchView searchResult={ searchResult } />
|
||||||
<div ref={ elementRef } className="flex flex-col flex-1 min-h-0 overflow-auto gap-2">
|
<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.map((result, index) => <NavigatorSearchResultView key={ index } searchResult={ result } />) }
|
||||||
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
|
{ searchResult && (!searchResult.results || searchResult.results.length === 0) &&
|
||||||
<div className="nitro-card-panel px-3 py-2 text-sm text-muted">
|
<NavigatorEmptyStateView code={ searchResult.code } onCreateRoom={ () => useNavigatorUiStore.getState().openCreator() } /> }
|
||||||
{ LocalizeText(searchResult.code === 'myworld_view' ? 'navigator.roomsettings.moderation.none' : 'navigator.search.returned.no.results') }
|
|
||||||
</div> }
|
|
||||||
</div>
|
</div>
|
||||||
<Flex className="nitro-card-divider pt-2 border-t gap-2">
|
<Flex className="nitro-card-divider pt-2 border-t gap-2">
|
||||||
<Flex pointer alignItems="center" justifyContent="center"
|
<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 { 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 { 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 { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
|
||||||
import { RoomWidgetThumbnailEvent } from '../../../events';
|
import { RoomWidgetThumbnailEvent } from '../../../events';
|
||||||
import { useHasPermission, useHelp, useNavigatorData, useRoom } from '../../../hooks';
|
import { useHasPermission, useHelp, useNavigatorData, useNavigatorFavourite, useRoom } from '../../../hooks';
|
||||||
import { classNames } from '../../../layout';
|
import { classNames } from '../../../layout';
|
||||||
|
|
||||||
export interface NavigatorRoomInfoViewProps {
|
export interface NavigatorRoomInfoViewProps {
|
||||||
@@ -17,12 +17,13 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
|||||||
const [ isRoomPicked, setIsRoomPicked ] = useState(false);
|
const [ isRoomPicked, setIsRoomPicked ] = useState(false);
|
||||||
const [ isRoomMuted, setIsRoomMuted ] = useState(false);
|
const [ isRoomMuted, setIsRoomMuted ] = useState(false);
|
||||||
const { report = null } = useHelp();
|
const { report = null } = useHelp();
|
||||||
const { navigatorData, favouriteRoomIds } = useNavigatorData();
|
const { navigatorData } = useNavigatorData();
|
||||||
const { roomSession = null } = useRoom();
|
const { roomSession = null } = useRoom();
|
||||||
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
|
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
|
||||||
const canStaffPick = useHasPermission('acc_staff_pick');
|
const canStaffPick = useHasPermission('acc_staff_pick');
|
||||||
|
|
||||||
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
|
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
|
||||||
|
const { isFavourite: isRoomInFavouritesList, toggle: toggleFavourite } = useNavigatorFavourite(enteredRoomId);
|
||||||
|
|
||||||
useEffect(() =>
|
useEffect(() =>
|
||||||
{
|
{
|
||||||
@@ -30,22 +31,6 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
|||||||
SendMessageComposer(new GetGuestRoomMessageComposer(enteredRoomId, false, false));
|
SendMessageComposer(new GetGuestRoomMessageComposer(enteredRoomId, false, false));
|
||||||
}, [ enteredRoomId ]);
|
}, [ 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) =>
|
const hasPermission = (permission: string) =>
|
||||||
{
|
{
|
||||||
if(!navigatorData?.enteredGuestRoom) return false;
|
if(!navigatorData?.enteredGuestRoom) return false;
|
||||||
@@ -115,7 +100,7 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
|||||||
report(ReportType.ROOM, { roomId, roomName: navigatorData.enteredGuestRoom.roomName });
|
report(ReportType.ROOM, { roomId, roomName: navigatorData.enteredGuestRoom.roomName });
|
||||||
return;
|
return;
|
||||||
case 'room_favourite':
|
case 'room_favourite':
|
||||||
ToggleFavoriteRoom(roomId, isRoomInFavouritesList);
|
toggleFavourite();
|
||||||
SendMessageComposer(new GetGuestRoomMessageComposer(roomId, false, false));
|
SendMessageComposer(new GetGuestRoomMessageComposer(roomId, false, false));
|
||||||
return;
|
return;
|
||||||
case 'remove_rights':
|
case 'remove_rights':
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RoomDataParser } from '@nitrots/nitro-renderer';
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { IRoomData, LocalizeText } from '../../../../api';
|
import { IRoomData, LocalizeText } from '../../../../api';
|
||||||
import { Column, Flex, Text } from '../../../../common';
|
import { Column, Flex, Text } from '../../../../common';
|
||||||
|
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
|
||||||
|
|
||||||
interface NavigatorRoomSettingsTabViewProps
|
interface NavigatorRoomSettingsTabViewProps
|
||||||
{
|
{
|
||||||
@@ -36,9 +37,8 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
|
|||||||
<Text bold>{ LocalizeText('navigator.roomsettings.roomaccess.caption') }</Text>
|
<Text bold>{ LocalizeText('navigator.roomsettings.roomaccess.caption') }</Text>
|
||||||
<Text>{ LocalizeText('navigator.roomsettings.roomaccess.info') }</Text>
|
<Text>{ LocalizeText('navigator.roomsettings.roomaccess.info') }</Text>
|
||||||
</Column>
|
</Column>
|
||||||
<Column overflow="auto">
|
<Column overflow="auto" gap={ 2 }>
|
||||||
<Column gap={ 1 }>
|
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.doormode') } gap={ 1 }>
|
||||||
<Text bold>{ LocalizeText('navigator.roomsettings.doormode') }</Text>
|
|
||||||
<Flex alignItems="center" 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) } />
|
<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>
|
<Text>{ LocalizeText('navigator.roomsettings.doormode.open') }</Text>
|
||||||
@@ -58,21 +58,20 @@ export const NavigatorRoomSettingsAccessTabView: FC<NavigatorRoomSettingsTabView
|
|||||||
{ (isTryingPassword || (roomData.lockState === RoomDataParser.PASSWORD_STATE)) &&
|
{ (isTryingPassword || (roomData.lockState === RoomDataParser.PASSWORD_STATE)) &&
|
||||||
<Column gap={ 1 }>
|
<Column gap={ 1 }>
|
||||||
<Text>{ LocalizeText('navigator.roomsettings.doormode.password') }</Text>
|
<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) &&
|
{ isTryingPassword && (password.length <= 0) &&
|
||||||
<Text bold small variant="danger">
|
<Text bold small variant="danger">
|
||||||
{ LocalizeText('navigator.roomsettings.passwordismandatory') }
|
{ LocalizeText('navigator.roomsettings.passwordismandatory') }
|
||||||
</Text> }
|
</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)) &&
|
{ isTryingPassword && ((password.length > 0) && (password !== confirmPassword)) &&
|
||||||
<Text bold small variant="danger">
|
<Text bold small variant="danger">
|
||||||
{ LocalizeText('navigator.roomsettings.invalidconfirm') }
|
{ LocalizeText('navigator.roomsettings.invalidconfirm') }
|
||||||
</Text> }
|
</Text> }
|
||||||
</Column> }
|
</Column> }
|
||||||
</Flex>
|
</Flex>
|
||||||
</Column>
|
</NavigatorRoomSettingsSectionView>
|
||||||
<Column gap={ 1 }>
|
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.pets') } gap={ 1 }>
|
||||||
<Text bold>{ LocalizeText('navigator.roomsettings.pets') }</Text>
|
|
||||||
<Flex alignItems="center" 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) } />
|
<input className="form-check-input" type="checkbox" checked={ roomData.allowPets } onChange={ event => handleChange('allow_pets', event.target.checked) } />
|
||||||
<Text>{ LocalizeText('navigator.roomsettings.allowpets') }</Text>
|
<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) } />
|
<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>
|
<Text>{ LocalizeText('navigator.roomsettings.allowfoodconsume') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Column>
|
</NavigatorRoomSettingsSectionView>
|
||||||
</Column>
|
</Column>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+55
-55
@@ -2,7 +2,7 @@ import { RoomDeleteComposer, RoomSettingsSaveErrorEvent, RoomSettingsSaveErrorPa
|
|||||||
import { FC, useEffect, useState } from 'react';
|
import { FC, useEffect, useState } from 'react';
|
||||||
import { FaTimes } from 'react-icons/fa';
|
import { FaTimes } from 'react-icons/fa';
|
||||||
import { CreateLinkEvent, GetMaxVisitorsList, IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
|
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';
|
import { useMessageEvent, useNavigatorData, useNotification } from '../../../../hooks';
|
||||||
|
|
||||||
const ROOM_NAME_MIN_LENGTH = 3;
|
const ROOM_NAME_MIN_LENGTH = 3;
|
||||||
@@ -39,6 +39,7 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
|
|||||||
{
|
{
|
||||||
case RoomSettingsSaveErrorParser.ERROR_INVALID_TAG:
|
case RoomSettingsSaveErrorParser.ERROR_INVALID_TAG:
|
||||||
setTypeError('navigator.roomsettings.unacceptablewords');
|
setTypeError('navigator.roomsettings.unacceptablewords');
|
||||||
|
break;
|
||||||
case RoomSettingsSaveErrorParser.ERROR_NON_USER_CHOOSABLE_TAG:
|
case RoomSettingsSaveErrorParser.ERROR_NON_USER_CHOOSABLE_TAG:
|
||||||
setTypeError('navigator.roomsettings.nonuserchoosabletag');
|
setTypeError('navigator.roomsettings.nonuserchoosabletag');
|
||||||
break;
|
break;
|
||||||
@@ -77,9 +78,9 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
|
|||||||
|
|
||||||
const saveTags = (index: number) =>
|
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('');
|
if(roomTag1 === '' && roomTag2 !== '') setRoomTag2('');
|
||||||
|
|
||||||
@@ -98,78 +99,77 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
<Column gap={ 1 }>
|
||||||
<Text className="col-3">{ LocalizeText('navigator.roomname') }</Text>
|
<Text bold>{ 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 } />
|
||||||
<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) &&
|
||||||
{ (roomName.length < ROOM_NAME_MIN_LENGTH) &&
|
<Text bold small variant="danger">
|
||||||
<Text bold small variant="danger">
|
{ LocalizeText('navigator.roomsettings.roomnameismandatory') }
|
||||||
{ LocalizeText('navigator.roomsettings.roomnameismandatory') }
|
</Text> }
|
||||||
</Text> }
|
</Column>
|
||||||
</Column>
|
<Column gap={ 1 }>
|
||||||
</Flex>
|
<Text bold>{ LocalizeText('navigator.roomsettings.desc') }</Text>
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
|
||||||
<Text className="col-3">{ 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 } />
|
<textarea className="form-control form-control-sm" value={ roomDescription } maxLength={ DESC_MAX_LENGTH } onChange={ event => setRoomDescription(event.target.value) } onBlur={ saveRoomDescription } />
|
||||||
</Flex>
|
</Column>
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
<Column gap={ 1 }>
|
||||||
<Text className="col-3">{ LocalizeText('navigator.category') }</Text>
|
<Text bold>{ LocalizeText('navigator.category') }</Text>
|
||||||
<select className="form-select form-select-sm" value={ roomData.categoryId } onChange={ event => handleChange('category', event.target.value) }>
|
<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>) }
|
{ categories && categories.map(category => <option key={ category.id } value={ category.id }>{ LocalizeText(category.name) }</option>) }
|
||||||
</select>
|
</select>
|
||||||
</Flex>
|
</Column>
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
<Column gap={ 1 }>
|
||||||
<Text className="col-3">{ LocalizeText('navigator.maxvisitors') }</Text>
|
<Text bold>{ LocalizeText('navigator.maxvisitors') }</Text>
|
||||||
<select className="form-select form-select-sm" value={ roomData.userCount } onChange={ event => handleChange('max_visitors', event.target.value) }>
|
<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>) }
|
{ GetMaxVisitorsList && GetMaxVisitorsList.map(value => <option key={ value } value={ value }>{ value }</option>) }
|
||||||
</select>
|
</select>
|
||||||
</Flex>
|
</Column>
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
<Column gap={ 1 }>
|
||||||
<Text className="col-3">{ LocalizeText('navigator.tradesettings') }</Text>
|
<Text bold>{ LocalizeText('navigator.tradesettings') }</Text>
|
||||||
<select className="form-select form-select-sm" value={ roomData.tradeState } onChange={ event => handleChange('trade_state', event.target.value) }>
|
<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="0">{ LocalizeText('navigator.roomsettings.trade_not_allowed') }</option>
|
||||||
<option value="1">{ LocalizeText('navigator.roomsettings.trade_not_with_Controller') }</option>
|
<option value="1">{ LocalizeText('navigator.roomsettings.trade_not_with_Controller') }</option>
|
||||||
<option value="2">{ LocalizeText('navigator.roomsettings.trade_allowed') }</option>
|
<option value="2">{ LocalizeText('navigator.roomsettings.trade_allowed') }</option>
|
||||||
</select>
|
</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 }>
|
<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) } />
|
<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>
|
<Text>{ LocalizeText('navigator.roomsettings.allow_walk_through') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
<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) } />
|
<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>
|
<Text>{ LocalizeText('navigator.roomsettings.allow_underpass') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Text variant="danger" underline bold pointer className="d-flex justify-content-center align-items-center gap-1" onClick={ deleteRoom }>
|
<Flex pointer alignItems="center" justifyContent="center" gap={ 1 } onClick={ deleteRoom }>
|
||||||
<FaTimes className="fa-icon" /> { LocalizeText('navigator.roomsettings.delete') }
|
<FaTimes className="fa-icon shrink-0 text-[#a81a12]" />
|
||||||
</Text>
|
<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 { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
|
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
|
||||||
import { useMessageEvent } from '../../../../hooks';
|
import { useMessageEvent } from '../../../../hooks';
|
||||||
|
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
|
||||||
|
|
||||||
interface NavigatorRoomSettingsTabViewProps
|
interface NavigatorRoomSettingsTabViewProps
|
||||||
{
|
{
|
||||||
@@ -51,28 +52,29 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
|
|||||||
return (
|
return (
|
||||||
<Grid overflow="auto">
|
<Grid overflow="auto">
|
||||||
<Column size={ 6 }>
|
<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">
|
<Flex overflow="hidden" className="nitro-card-panel list-container p-2">
|
||||||
<Column fullWidth overflow="auto" gap={ 1 }>
|
<Column fullWidth overflow="auto" gap={ 1 }>
|
||||||
{ bannedUsers && (bannedUsers.length > 0) && bannedUsers.map((user, index) =>
|
{ bannedUsers && (bannedUsers.length > 0) && bannedUsers.map((user, index) =>
|
||||||
{
|
{
|
||||||
return (
|
return (
|
||||||
<Flex key={ index } shrink alignItems="center" gap={ 1 } overflow="hidden">
|
<Flex key={ index } shrink alignItems="center" gap={ 1 } overflow="hidden">
|
||||||
<UserProfileIconView userId={ user.userId } />
|
<UserProfileIconView userId={ user.userId } />
|
||||||
<Text pointer grow onClick={ event => setSelectedUserId(user.userId) }> { user.userName }</Text>
|
<Text pointer grow onClick={ event => setSelectedUserId(user.userId) }> { user.userName }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}) }
|
}) }
|
||||||
</Column>
|
</Column>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Button disabled={ (selectedUserId <= 0) } onClick={ event => unBanUser(selectedUserId) }>
|
<Button disabled={ (selectedUserId <= 0) } onClick={ event => unBanUser(selectedUserId) }>
|
||||||
{ LocalizeText('navigator.roomsettings.moderation.unban') } { selectedUserId > 0 && bannedUsers.find(user => (user.userId === selectedUserId))?.userName }
|
{ LocalizeText('navigator.roomsettings.moderation.unban') } { selectedUserId > 0 && bannedUsers.find(user => (user.userId === selectedUserId))?.userName }
|
||||||
</Button>
|
</Button>
|
||||||
|
</NavigatorRoomSettingsSectionView>
|
||||||
</Column>
|
</Column>
|
||||||
<Column size={ 6 }>
|
<Column size={ 6 }>
|
||||||
<Column gap={ 1 }>
|
<NavigatorRoomSettingsSectionView title={ LocalizeText('navigator.roomsettings.moderation') } gap={ 2 } className="h-full">
|
||||||
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.mute.header') }</Text>
|
<Column gap={ 1 }>
|
||||||
<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) }>
|
<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 }>
|
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
|
||||||
{ LocalizeText('navigator.roomsettings.moderation.none') }
|
{ LocalizeText('navigator.roomsettings.moderation.none') }
|
||||||
@@ -81,11 +83,9 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
|
|||||||
{ LocalizeText('navigator.roomsettings.moderation.rights') }
|
{ LocalizeText('navigator.roomsettings.moderation.rights') }
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</Flex>
|
</Column>
|
||||||
</Column>
|
<Column gap={ 1 }>
|
||||||
<Column gap={ 1 }>
|
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.kick.header') }</Text>
|
||||||
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.kick.header') }</Text>
|
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
|
||||||
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowKick } onChange={ event => handleChange('moderation_kick', event.target.value) }>
|
<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 }>
|
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
|
||||||
{ LocalizeText('navigator.roomsettings.moderation.none') }
|
{ LocalizeText('navigator.roomsettings.moderation.none') }
|
||||||
@@ -97,11 +97,9 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
|
|||||||
{ LocalizeText('navigator.roomsettings.moderation.all') }
|
{ LocalizeText('navigator.roomsettings.moderation.all') }
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</Flex>
|
</Column>
|
||||||
</Column>
|
<Column gap={ 1 }>
|
||||||
<Column gap={ 1 }>
|
<Text bold small>{ LocalizeText('navigator.roomsettings.moderation.ban.header') }</Text>
|
||||||
<Text bold>{ LocalizeText('navigator.roomsettings.moderation.ban.header') }</Text>
|
|
||||||
<Flex alignItems="center" gap={ 1 }>
|
|
||||||
<select className="form-select form-select-sm" value={ roomData.moderationSettings.allowBan } onChange={ event => handleChange('moderation_ban', event.target.value) }>
|
<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 }>
|
<option value={ RoomModerationSettings.MODERATION_LEVEL_NONE }>
|
||||||
{ LocalizeText('navigator.roomsettings.moderation.none') }
|
{ LocalizeText('navigator.roomsettings.moderation.none') }
|
||||||
@@ -110,8 +108,8 @@ export const NavigatorRoomSettingsModTabView: FC<NavigatorRoomSettingsTabViewPro
|
|||||||
{ LocalizeText('navigator.roomsettings.moderation.rights') }
|
{ LocalizeText('navigator.roomsettings.moderation.rights') }
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</Flex>
|
</Column>
|
||||||
</Column>
|
</NavigatorRoomSettingsSectionView>
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</Grid>
|
||||||
);
|
);
|
||||||
|
|||||||
+51
-52
@@ -3,6 +3,7 @@ import { FC, useEffect, useRef, useState } from 'react';
|
|||||||
import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
|
import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
|
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
|
||||||
import { useFriends, useMessageEvent } from '../../../../hooks';
|
import { useFriends, useMessageEvent } from '../../../../hooks';
|
||||||
|
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
|
||||||
|
|
||||||
interface NavigatorRoomSettingsTabViewProps
|
interface NavigatorRoomSettingsTabViewProps
|
||||||
{
|
{
|
||||||
@@ -105,74 +106,72 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
|
|||||||
return (
|
return (
|
||||||
<Grid>
|
<Grid>
|
||||||
<Column size={ 6 }>
|
<Column size={ 6 }>
|
||||||
<Text bold>
|
<NavigatorRoomSettingsSectionView gap={ 1 } className="h-full"
|
||||||
{ LocalizeText(
|
title={ LocalizeText(
|
||||||
'navigator.flatctrls.userswithrights',
|
'navigator.flatctrls.userswithrights',
|
||||||
[ 'displayed', 'total' ],
|
[ 'displayed', 'total' ],
|
||||||
[
|
[
|
||||||
filteredUsersWithRights.size.toString(),
|
filteredUsersWithRights.size.toString(),
|
||||||
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">
|
<Button
|
||||||
<Column fullWidth overflow="auto" gap={ 1 }>
|
variant="danger"
|
||||||
{ Array.from(filteredUsersWithRights.entries()).map(([ id, name ], index) =>
|
disabled={ !filteredUsersWithRights.size }
|
||||||
{
|
onClick={ () => roomData && guardedSend('removeAll', new RemoveAllRightsMessageComposer(roomData.roomId)) }>
|
||||||
return (
|
{ LocalizeText('navigator.flatctrls.clear') }
|
||||||
<Flex key={ `${id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
|
</Button>
|
||||||
<UserProfileIconView userId={ id } />
|
</NavigatorRoomSettingsSectionView>
|
||||||
<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>
|
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column size={ 6 }>
|
<Column size={ 6 }>
|
||||||
<Text bold>
|
<NavigatorRoomSettingsSectionView gap={ 1 } className="h-full"
|
||||||
{ LocalizeText(
|
title={ LocalizeText(
|
||||||
'navigator.flatctrls.friends',
|
'navigator.flatctrls.friends',
|
||||||
[ 'displayed', 'total' ],
|
[ 'displayed', 'total' ],
|
||||||
[
|
[
|
||||||
friendsWithoutRights.length.toString(),
|
friendsWithoutRights.length.toString(),
|
||||||
allFriends.length.toString()
|
allFriends.length.toString()
|
||||||
]
|
]
|
||||||
) }
|
) }>
|
||||||
</Text>
|
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
|
||||||
|
<Column fullWidth overflow="auto" gap={ 1 }>
|
||||||
<Flex overflow="hidden" className="nitro-card-panel p-2 list-container">
|
{ friendsWithoutRights.map((friend, index) =>
|
||||||
<Column fullWidth overflow="auto" gap={ 1 }>
|
{
|
||||||
{ friendsWithoutRights.map((friend, index) =>
|
return (
|
||||||
{
|
<Flex key={ `${friend.id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
|
||||||
return (
|
<UserProfileIconView userId={ friend.id } />
|
||||||
<Flex key={ `${friend.id}-${index}` } shrink alignItems="center" gap={ 1 } overflow="hidden">
|
<Text
|
||||||
<UserProfileIconView userId={ friend.id } />
|
pointer
|
||||||
<Text
|
grow
|
||||||
pointer
|
onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }>
|
||||||
grow
|
{ friend.name }
|
||||||
onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }>
|
</Text>
|
||||||
{ friend.name }
|
</Flex>
|
||||||
</Text>
|
);
|
||||||
</Flex>
|
}) }
|
||||||
);
|
</Column>
|
||||||
}) }
|
</Flex>
|
||||||
</Column>
|
</NavigatorRoomSettingsSectionView>
|
||||||
</Flex>
|
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+43
-40
@@ -3,6 +3,7 @@ import { FC, useEffect, useState } from 'react';
|
|||||||
import { GetClubMemberLevel, IRoomData, LocalizeText } from '../../../../api';
|
import { GetClubMemberLevel, IRoomData, LocalizeText } from '../../../../api';
|
||||||
import { Column, Grid, Text } from '../../../../common';
|
import { Column, Grid, Text } from '../../../../common';
|
||||||
import { NitroInput } from '../../../../layout';
|
import { NitroInput } from '../../../../layout';
|
||||||
|
import { NavigatorRoomSettingsSectionView } from './NavigatorRoomSettingsSectionView';
|
||||||
|
|
||||||
interface NavigatorRoomSettingsTabViewProps
|
interface NavigatorRoomSettingsTabViewProps
|
||||||
{
|
{
|
||||||
@@ -29,48 +30,50 @@ export const NavigatorRoomSettingsVipChatTabView: FC<NavigatorRoomSettingsTabVie
|
|||||||
</div>
|
</div>
|
||||||
<Grid className={ !isHC ? 'opacity-50 pointer-events-none' : '' } overflow="auto">
|
<Grid className={ !isHC ? 'opacity-50 pointer-events-none' : '' } overflow="auto">
|
||||||
<Column gap={ 1 } size={ 6 }>
|
<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>
|
<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) }>
|
<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_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>
|
<option value={ RoomChatSettings.CHAT_MODE_LINE_BY_LINE }>{ LocalizeText('navigator.roomsettings.chat.mode.line.by.line') }</option>
|
||||||
</select>
|
</select>
|
||||||
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.weight } onChange={ event => handleChange('chat_weight', event.target.value) }>
|
<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_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_THIN }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.thin') }</option>
|
||||||
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_WIDE }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.wide') }</option>
|
<option value={ RoomChatSettings.CHAT_BUBBLE_WIDTH_WIDE }>{ LocalizeText('navigator.roomsettings.chat.bubbles.width.wide') }</option>
|
||||||
</select>
|
</select>
|
||||||
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.speed } onChange={ event => handleChange('bubble_speed', event.target.value) }>
|
<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_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_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.speed.normal') }</option>
|
||||||
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_SLOW }>{ LocalizeText('navigator.roomsettings.chat.speed.slow') }</option>
|
<option value={ RoomChatSettings.CHAT_SCROLL_SPEED_SLOW }>{ LocalizeText('navigator.roomsettings.chat.speed.slow') }</option>
|
||||||
</select>
|
</select>
|
||||||
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.chatSettings.protection } onChange={ event => handleChange('flood_protection', event.target.value) }>
|
<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_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_NORMAL }>{ LocalizeText('navigator.roomsettings.chat.flood.normal') }</option>
|
||||||
<option value={ RoomChatSettings.FLOOD_FILTER_STRICT }>{ LocalizeText('navigator.roomsettings.chat.flood.strict') }</option>
|
<option value={ RoomChatSettings.FLOOD_FILTER_STRICT }>{ LocalizeText('navigator.roomsettings.chat.flood.strict') }</option>
|
||||||
</select>
|
</select>
|
||||||
<Text small>{ LocalizeText('navigator.roomsettings.chat_settings.hearing.distance') }</Text>
|
<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) } />
|
<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>
|
||||||
<Column gap={ 1 } size={ 6 }>
|
<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">
|
<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) } />
|
<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>
|
<Text small>{ LocalizeText('navigator.roomsettings.hide_walls') }</Text>
|
||||||
</div>
|
</div>
|
||||||
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.wallThickness } onChange={ event => handleChange('wall_thickness', event.target.value) }>
|
<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="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.thick') }</option>
|
||||||
<option value="-1">{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
|
<option value="-1">{ LocalizeText('navigator.roomsettings.wall_thickness.thin') }</option>
|
||||||
<option value="-2">{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
|
<option value="-2">{ LocalizeText('navigator.roomsettings.wall_thickness.thinnest') }</option>
|
||||||
</select>
|
</select>
|
||||||
<select className="form-select form-select-sm" disabled={ !isHC } value={ roomData.floorThickness } onChange={ event => handleChange('floor_thickness', event.target.value) }>
|
<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="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.thick') }</option>
|
||||||
<option value="-1">{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
|
<option value="-1">{ LocalizeText('navigator.roomsettings.floor_thickness.thin') }</option>
|
||||||
<option value="-2">{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
|
<option value="-2">{ LocalizeText('navigator.roomsettings.floor_thickness.thinnest') }</option>
|
||||||
</select>
|
</select>
|
||||||
|
</NavigatorRoomSettingsSectionView>
|
||||||
</Column>
|
</Column>
|
||||||
</Grid>
|
</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 * as Popover from '@radix-ui/react-popover';
|
||||||
import React, { FC, useRef, useState } from 'react';
|
import React, { FC, useRef, useState } from 'react';
|
||||||
import { FaUser } from 'react-icons/fa';
|
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 { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
|
||||||
import { useHelp, useNavigatorData } from '../../../../hooks';
|
import { useHelp, useNavigatorData, useNavigatorFavourite } from '../../../../hooks';
|
||||||
import { classNames } from '../../../../layout';
|
import { classNames } from '../../../../layout';
|
||||||
|
|
||||||
interface NavigatorSearchResultItemInfoViewProps
|
interface NavigatorSearchResultItemInfoViewProps
|
||||||
@@ -20,7 +20,8 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
|||||||
const { roomData = null, isVisible = undefined, onToggle, setIsPopoverActive } = props;
|
const { roomData = null, isVisible = undefined, onToggle, setIsPopoverActive } = props;
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
const [ internalVisible, setInternalVisible ] = useState(false);
|
const [ internalVisible, setInternalVisible ] = useState(false);
|
||||||
const { navigatorData, favouriteRoomIds } = useNavigatorData();
|
const { navigatorData } = useNavigatorData();
|
||||||
|
const { isFavourite, toggle: toggleFavourite } = useNavigatorFavourite(roomData?.roomId);
|
||||||
const { report = null } = useHelp();
|
const { report = null } = useHelp();
|
||||||
|
|
||||||
const isControlled = isVisible !== undefined;
|
const isControlled = isVisible !== undefined;
|
||||||
@@ -63,7 +64,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
|||||||
report(ReportType.ROOM, { roomId: roomData.roomId, roomName: roomData.roomName });
|
report(ReportType.ROOM, { roomId: roomData.roomId, roomName: roomData.roomName });
|
||||||
return;
|
return;
|
||||||
case 'room_favourite':
|
case 'room_favourite':
|
||||||
ToggleFavoriteRoom(roomData.roomId, favouriteRoomIds.includes(roomData.roomId));
|
toggleFavourite();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -163,7 +164,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
|||||||
</Column>
|
</Column>
|
||||||
<Column alignItems="start" gap={ 2 } className="w-2/5">
|
<Column alignItems="start" gap={ 2 } className="w-2/5">
|
||||||
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('room_favourite') }>
|
<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>
|
<Text className="text-xs">{ LocalizeText('navigator.room.popup.room.info.favorite') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('set_home_room') }>
|
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('set_home_room') }>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NavigatorDeleteSavedSearchComposer, NavigatorSavedSearch, NavigatorSearchComposer } from '@nitrots/nitro-renderer';
|
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 { LocalizeText, SendMessageComposer } from '../../../../api';
|
||||||
import { Flex, Text } from '../../../../common';
|
import { Flex, Text } from '../../../../common';
|
||||||
|
|
||||||
@@ -11,7 +12,6 @@ export interface NavigatorSearchSavesResultItemViewProps
|
|||||||
export const NavigatorSearchSavesResultItemView: FC<NavigatorSearchSavesResultItemViewProps> = props =>
|
export const NavigatorSearchSavesResultItemView: FC<NavigatorSearchSavesResultItemViewProps> = props =>
|
||||||
{
|
{
|
||||||
const { search = null } = props;
|
const { search = null } = props;
|
||||||
const [ isHovered, setIsHovered ] = useState(false);
|
|
||||||
|
|
||||||
const getResultTitle = () =>
|
const getResultTitle = () =>
|
||||||
{
|
{
|
||||||
@@ -24,23 +24,33 @@ export const NavigatorSearchSavesResultItemView: FC<NavigatorSearchSavesResultIt
|
|||||||
return ('navigator.searchcode.title.' + name);
|
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 (
|
return (
|
||||||
<Flex grow pointer alignItems="center" gap={ 1 } onMouseEnter={ () => setIsHovered(true) } onMouseLeave={ () => setIsHovered(false) }>
|
<Flex
|
||||||
{ isHovered &&
|
grow
|
||||||
<i
|
pointer
|
||||||
className="nitro-icon icon-navigator-search-delete cursor-pointer flex-shrink-0"
|
alignItems="center"
|
||||||
title={ LocalizeText('navigator.tooltip.remove.saved.search') }
|
gap={ 1 }
|
||||||
onClick={ () => SendMessageComposer(new NavigatorDeleteSavedSearchComposer(search.id)) }
|
className="saved-search-row group px-1 py-0.5"
|
||||||
/> }
|
title={ LocalizeText('navigator.tooltip.open.saved.search') }
|
||||||
<Text
|
onClick={ openSearch }
|
||||||
small
|
>
|
||||||
pointer
|
<FaBolt className="text-orange-500 shrink-0 text-[10px]" />
|
||||||
variant="black"
|
<Text small pointer truncate variant="black" className="grow! min-w-0">
|
||||||
title={ LocalizeText('navigator.tooltip.open.saved.search') }
|
|
||||||
onClick={ () => SendMessageComposer(new NavigatorSearchComposer(search.code.split('.').reverse()[0], search.filter)) }
|
|
||||||
>
|
|
||||||
{ LocalizeText(getResultTitle()) }
|
{ LocalizeText(getResultTitle()) }
|
||||||
</Text>
|
</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>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,16 +15,19 @@ export const NavigatorSearchSavesResultView: FC<NavigatorSearchSavesResultViewPr
|
|||||||
const { searches = [] } = props;
|
const { searches = [] } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Column className="nitro-navigator-search-saves-result min-w-[100px]">
|
<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" gap={ 1 } alignItems="center">
|
<Flex className="rounded px-2 py-1 bg-orange-500 shrink-0" gap={ 1 } alignItems="center">
|
||||||
<FaBolt color="white" />
|
<FaBolt color="white" />
|
||||||
<Text variant="white">{ LocalizeText('navigator.quick.links.title') }</Text>
|
<Text variant="white" truncate>{ LocalizeText('navigator.quick.links.title') }</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
<Column className="p-1 overflow-x-hidden overflow-y-auto">
|
<Column className="flex-1 min-h-0 p-1 overflow-x-hidden overflow-y-auto" gap={ 0 }>
|
||||||
{ (searches && searches.length > 0) &&
|
{ (searches && searches.length > 0)
|
||||||
searches.map((search: NavigatorSavedSearch) => (
|
? searches.map((search: NavigatorSavedSearch) => (
|
||||||
<NavigatorSearchSavesResultItemView key={ search.id } search={ search } />
|
<NavigatorSearchSavesResultItemView key={ search.id } search={ search } />
|
||||||
)) }
|
))
|
||||||
|
: <Flex center className="py-4 opacity-30">
|
||||||
|
<FaBolt className="text-orange-500" size={ 22 } />
|
||||||
|
</Flex> }
|
||||||
</Column>
|
</Column>
|
||||||
</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 { FaSearch } from 'react-icons/fa';
|
||||||
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
|
import { INavigatorSearchFilter, LocalizeText, SearchFilterOptions } from '../../../../api';
|
||||||
import { Button } from '../../../../common';
|
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 [ searchFilterIndex, setSearchFilterIndex ] = useState(0);
|
||||||
const [ inputText, setInputText ] = useState('');
|
const [ inputText, setInputText ] = useState('');
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const { topLevelContext } = useNavigatorData();
|
const { topLevelContext } = useNavigatorData();
|
||||||
const { searchResult } = useNavigatorSearch();
|
|
||||||
|
|
||||||
// Sync the input text display when a server result arrives (e.g. on tab switch
|
// 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).
|
// or deep-link navigation that sets the filter through the store directly).
|
||||||
@@ -54,38 +62,27 @@ export const NavigatorSearchView: FC<{}> = props =>
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [ inputText, searchFilterIndex ]);
|
}, [ 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;
|
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 searchFilter = SearchFilterOptions[searchFilterIndex] ?? SearchFilterOptions[0];
|
||||||
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + inputText;
|
const searchQuery = (searchFilter.query ? (searchFilter.query + ':') : '') + value;
|
||||||
useNavigatorUiStore.getState().setFilter(searchQuery);
|
useNavigatorUiStore.getState().setFilter(searchQuery);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) =>
|
|
||||||
{
|
|
||||||
if(event.key !== 'Enter') return;
|
|
||||||
|
|
||||||
processSearch();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full gap-1">
|
<div className="flex w-full flex-col gap-1">
|
||||||
<div className="flex shrink-0">
|
<NavigatorFilterChipsView value={ searchFilterIndex } onChange={ setSearchFilterIndex } />
|
||||||
<select className="form-select" value={ searchFilterIndex } onChange={ event => setSearchFilterIndex(parseInt(event.target.value)) }>
|
<form ref={ formRef } action={ submitSearch } className="flex w-full gap-1">
|
||||||
{ SearchFilterOptions.map((filter, index) =>
|
<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() }>
|
||||||
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 }>
|
|
||||||
<FaSearch className="fa-icon" />
|
<FaSearch className="fa-icon" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -476,6 +476,24 @@ body {
|
|||||||
border-color: #aeb7aa !important;
|
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 {
|
.nitro-card-divider {
|
||||||
border-color: #c4cabf !important;
|
border-color: #c4cabf !important;
|
||||||
box-shadow: none !important;
|
box-shadow: none !important;
|
||||||
@@ -523,6 +541,9 @@ body {
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
padding: 4px 6px 0;
|
padding: 4px 6px 0;
|
||||||
|
max-height: none;
|
||||||
|
height: auto;
|
||||||
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nitro-card-tab-item {
|
.nitro-card-tab-item {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export { useNavigatorData } from './useNavigatorData';
|
export { useNavigatorData } from './useNavigatorData';
|
||||||
|
export { useNavigatorFavourite } from './useNavigatorFavourite';
|
||||||
export { useNavigatorSearch } from './useNavigatorSearch';
|
export { useNavigatorSearch } from './useNavigatorSearch';
|
||||||
export { useNavigatorUiState } from './useNavigatorUiState';
|
export { useNavigatorUiState } from './useNavigatorUiState';
|
||||||
export { useNavigatorUiStore } from './navigatorUiStore';
|
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 = () =>
|
export const useNavigatorData = () =>
|
||||||
{
|
{
|
||||||
const {
|
const {
|
||||||
categories, eventCategories, favouriteRoomIds,
|
categories, eventCategories,
|
||||||
topLevelContext, topLevelContexts,
|
topLevelContext, topLevelContexts,
|
||||||
navigatorSearches, navigatorData
|
navigatorSearches, navigatorData
|
||||||
} = useBetween(useNavigatorStore);
|
} = useBetween(useNavigatorStore);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories, eventCategories, favouriteRoomIds,
|
categories, eventCategories,
|
||||||
topLevelContext, topLevelContexts,
|
topLevelContext, topLevelContexts,
|
||||||
navigatorSearches, navigatorData
|
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 */
|
/* @vitest-environment jsdom */
|
||||||
|
|
||||||
import { FlatCreatedEvent, NavigatorSearchEvent,
|
import { NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||||
NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
||||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { mockEventDispatcher } from '../../nitro-renderer.mock';
|
import { mockEventDispatcher } from '../../nitro-renderer.mock';
|
||||||
@@ -13,23 +11,12 @@ import { useNavigatorSearch } from './useNavigatorSearch';
|
|||||||
// Helpers
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Create a fresh QueryClient with retries off so failures are immediate. */
|
// NOTE: useNavigatorSearch uses useMessageEvent + useState (NOT useNitroQuery).
|
||||||
const makeQueryClient = () =>
|
// The one-shot query pattern was reverted upstream (05d71dd1) because it left
|
||||||
new QueryClient({
|
// the UI blank when the listener never matched. These tests exercise the
|
||||||
defaultOptions: {
|
// event-driven implementation directly — no QueryClient scaffolding.
|
||||||
queries: { retry: false, gcTime: 0 }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/** Wrapper factory — each test gets its own QueryClient instance. */
|
/** Build a fake NavigatorSearchEvent whose getParser() returns a result with `code`. */
|
||||||
const makeWrapper = (client: QueryClient) =>
|
|
||||||
({ children }: { children: React.ReactNode }) => (
|
|
||||||
<QueryClientProvider client={ client }>
|
|
||||||
{ children }
|
|
||||||
</QueryClientProvider>
|
|
||||||
);
|
|
||||||
|
|
||||||
/** Build a fake NavigatorSearchEvent that getParser() returns a result with `code`. */
|
|
||||||
const makeSearchEvent = (code: string) =>
|
const makeSearchEvent = (code: string) =>
|
||||||
{
|
{
|
||||||
// Cast constructors as `any` so tsgo doesn't check required args against
|
// Cast constructors as `any` so tsgo doesn't check required args against
|
||||||
@@ -66,7 +53,6 @@ describe('useNavigatorSearch', () =>
|
|||||||
{
|
{
|
||||||
beforeEach(() =>
|
beforeEach(() =>
|
||||||
{
|
{
|
||||||
// Reset UI store state before each test
|
|
||||||
useNavigatorUiStore.setState(INITIAL_UI);
|
useNavigatorUiStore.setState(INITIAL_UI);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -76,59 +62,44 @@ describe('useNavigatorSearch', () =>
|
|||||||
vi.clearAllMocks();
|
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());
|
||||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
|
||||||
|
|
||||||
// Dispatch a search event — should be ignored (query disabled)
|
// No tab selected → the request effect short-circuits, nothing fetches.
|
||||||
act(() =>
|
|
||||||
{
|
|
||||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Data must stay null
|
|
||||||
expect(result.current.searchResult).toBeNull();
|
|
||||||
expect(result.current.isFetching).toBe(false);
|
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());
|
||||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
|
||||||
|
|
||||||
// Activate the query
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
useNavigatorUiStore.getState().setTab('public');
|
useNavigatorUiStore.getState().setTab('public');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Hook should start fetching
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||||
|
|
||||||
// Simulate server response
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Query should resolve with the matching result
|
|
||||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||||
expect((result.current.searchResult as any).code).toBe('public');
|
expect((result.current.searchResult as any).code).toBe('public');
|
||||||
expect(result.current.isFetching).toBe(false);
|
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());
|
||||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
|
||||||
|
|
||||||
// First establish a tab
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
useNavigatorUiStore.getState().setTab('public');
|
useNavigatorUiStore.getState().setTab('public');
|
||||||
});
|
});
|
||||||
// Resolve the initial query
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
@@ -136,7 +107,6 @@ describe('useNavigatorSearch', () =>
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||||
|
|
||||||
// Now set a filter — triggers new query
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
useNavigatorUiStore.getState().setFilter('cocco');
|
useNavigatorUiStore.getState().setFilter('cocco');
|
||||||
@@ -144,7 +114,6 @@ describe('useNavigatorSearch', () =>
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||||
|
|
||||||
// Resolve with matching event
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||||
@@ -152,24 +121,19 @@ describe('useNavigatorSearch', () =>
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||||
expect((result.current.searchResult as any).code).toBe('public');
|
expect((result.current.searchResult as any).code).toBe('public');
|
||||||
|
|
||||||
// Confirm filter is set
|
|
||||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('cocco');
|
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());
|
||||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
|
||||||
|
|
||||||
// Establish public tab with a filter
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
useNavigatorUiStore.getState().setTab('public');
|
useNavigatorUiStore.getState().setTab('public');
|
||||||
useNavigatorUiStore.getState().setFilter('some-filter');
|
useNavigatorUiStore.getState().setFilter('some-filter');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Resolve the public+filter query
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
@@ -177,20 +141,16 @@ describe('useNavigatorSearch', () =>
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
await waitFor(() => expect(result.current.isFetching).toBe(false));
|
||||||
|
|
||||||
// Switch to events tab — should atomically reset filter
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
useNavigatorUiStore.getState().setTab('events');
|
useNavigatorUiStore.getState().setTab('events');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Filter must be cleared
|
|
||||||
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
expect(useNavigatorUiStore.getState().currentFilter).toBe('');
|
||||||
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
|
expect(useNavigatorUiStore.getState().currentTabCode).toBe('events');
|
||||||
|
|
||||||
// New query for 'events' fires
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||||
|
|
||||||
// Resolve with events result
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('events') as any);
|
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 () =>
|
it('5. NavigatorSearchEvent with result.code === currentTabCode is accepted and updates data', async () =>
|
||||||
{
|
{
|
||||||
const client = makeQueryClient();
|
const { result } = renderHook(() => useNavigatorSearch());
|
||||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
@@ -226,8 +185,7 @@ describe('useNavigatorSearch', () =>
|
|||||||
|
|
||||||
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
|
it('6. NavigatorSearchEvent with result.code !== currentTabCode is REJECTED — data unchanged', async () =>
|
||||||
{
|
{
|
||||||
const client = makeQueryClient();
|
const { result } = renderHook(() => useNavigatorSearch());
|
||||||
const { result } = renderHook(() => useNavigatorSearch(), { wrapper: makeWrapper(client) });
|
|
||||||
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
@@ -236,67 +194,20 @@ describe('useNavigatorSearch', () =>
|
|||||||
|
|
||||||
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
await waitFor(() => expect(result.current.isFetching).toBe(true));
|
||||||
|
|
||||||
// Dispatch an event for a DIFFERENT tab — should be rejected by accept filter
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any);
|
mockEventDispatcher.dispatchEvent(makeSearchEvent('wrong_tab') as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Still fetching — the wrong-tab event was ignored
|
// The wrong-tab event is filtered out by the accept guard.
|
||||||
// (the query promise stays pending until it times out or a matching event arrives)
|
|
||||||
// After the wrong-tab dispatch, data should NOT be updated
|
|
||||||
expect(result.current.searchResult).toBeNull();
|
expect(result.current.searchResult).toBeNull();
|
||||||
|
|
||||||
// Now dispatch the correct one to unblock the test
|
|
||||||
act(() =>
|
act(() =>
|
||||||
{
|
{
|
||||||
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
mockEventDispatcher.dispatchEvent(makeSearchEvent('public') as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
await waitFor(() => expect(result.current.searchResult).not.toBeNull());
|
||||||
// Only the correct-tab result is stored
|
|
||||||
expect((result.current.searchResult as any).code).toBe('public');
|
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 { FlatCreatedEvent, NavigatorSearchComposer, NavigatorSearchEvent, NavigatorSearchResultSet } from '@nitrots/nitro-renderer';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { SendMessageComposer } from '../../api';
|
import { SendMessageComposer } from '../../api';
|
||||||
import { useMessageEvent } from '../events';
|
import { useMessageEvent } from '../events';
|
||||||
@@ -23,7 +22,6 @@ export const useNavigatorSearch = () =>
|
|||||||
{
|
{
|
||||||
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
const tabCode = useNavigatorUiStore(s => s.currentTabCode);
|
||||||
const filter = useNavigatorUiStore(s => s.currentFilter);
|
const filter = useNavigatorUiStore(s => s.currentFilter);
|
||||||
const queryClient = useQueryClient();
|
|
||||||
|
|
||||||
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet | null>(null);
|
const [ searchResult, setSearchResult ] = useState<NavigatorSearchResultSet | null>(null);
|
||||||
const [ isFetching, setIsFetching ] = useState(false);
|
const [ isFetching, setIsFetching ] = useState(false);
|
||||||
@@ -49,11 +47,9 @@ export const useNavigatorSearch = () =>
|
|||||||
setIsFetching(false);
|
setIsFetching(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
// A newly created room invalidates the current search so it refetches.
|
// A newly created room refetches the current search.
|
||||||
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, () =>
|
useMessageEvent<FlatCreatedEvent>(FlatCreatedEvent, () =>
|
||||||
{
|
{
|
||||||
queryClient.invalidateQueries({ queryKey: [ 'navigator', 'search' ] });
|
|
||||||
|
|
||||||
if(!tabCode) return;
|
if(!tabCode) return;
|
||||||
|
|
||||||
setIsFetching(true);
|
setIsFetching(true);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('navigator filter shapes (smoke)', () =>
|
|||||||
{
|
{
|
||||||
const { result } = renderHook(() => useNavigatorData());
|
const { result } = renderHook(() => useNavigatorData());
|
||||||
expect(Object.keys(result.current).sort()).toEqual([
|
expect(Object.keys(result.current).sort()).toEqual([
|
||||||
'categories', 'eventCategories', 'favouriteRoomIds',
|
'categories', 'eventCategories',
|
||||||
'navigatorData', 'navigatorSearches',
|
'navigatorData', 'navigatorSearches',
|
||||||
'topLevelContext', 'topLevelContexts'
|
'topLevelContext', 'topLevelContexts'
|
||||||
].sort());
|
].sort());
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import { CreateRoomSession, GetConfigurationValue, INavigatorData,
|
|||||||
TryVisitRoom, VisitDesktop } from '../../api';
|
TryVisitRoom, VisitDesktop } from '../../api';
|
||||||
import { useMessageEvent, useNitroEvent } from '../events';
|
import { useMessageEvent, useNitroEvent } from '../events';
|
||||||
import { useNotification } from '../notification';
|
import { useNotification } from '../notification';
|
||||||
|
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
|
||||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||||
|
|
||||||
export const useNavigatorStore = () =>
|
export const useNavigatorStore = () =>
|
||||||
{
|
{
|
||||||
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
|
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
|
||||||
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
|
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
|
||||||
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
|
|
||||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||||
@@ -48,21 +48,13 @@ export const useNavigatorStore = () =>
|
|||||||
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
|
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
|
||||||
{
|
{
|
||||||
const parser = event.getParser();
|
const parser = event.getParser();
|
||||||
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
|
useNavigatorFavouritesStore.getState().setAll(parser.favoriteRoomIds || []);
|
||||||
setFavouriteRoomIds(favoriteIds);
|
|
||||||
}, []));
|
}, []));
|
||||||
|
|
||||||
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
|
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
|
||||||
{
|
{
|
||||||
const parser = event.getParser();
|
const parser = event.getParser();
|
||||||
const roomId = Number(parser.flatId);
|
useNavigatorFavouritesStore.getState().apply(parser.flatId, !!parser.added);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}, []));
|
}, []));
|
||||||
|
|
||||||
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
|
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
|
||||||
@@ -280,7 +272,7 @@ export const useNavigatorStore = () =>
|
|||||||
}, []));
|
}, []));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
categories, eventCategories, favouriteRoomIds,
|
categories, eventCategories,
|
||||||
topLevelContext, topLevelContexts,
|
topLevelContext, topLevelContexts,
|
||||||
navigatorSearches, navigatorData
|
navigatorSearches, navigatorData
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user