mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
feat(navigator): reactive favourites via fine-grained store (P3)
Move favourite room ids out of the useBetween navigator store into a dedicated Zustand store. useNavigatorFavourite(roomId) subscribes only to s.ids.has(roomId) (a boolean), so a FavouriteChangedEvent for one room no longer re-renders every favourite-aware view. apply() returns the same state reference when membership is unchanged.
This commit is contained in:
@@ -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,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,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 };
|
||||||
|
};
|
||||||
@@ -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