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 { FC, useEffect, useMemo, useState } from 'react';
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FaLink, FaSignOutAlt } from 'react-icons/fa';
|
||||
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../api';
|
||||
import { DispatchUiEvent, GetGroupInformation, LocalizeText, ReportType, SendMessageComposer } from '../../../api';
|
||||
import { Button, Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text, UserProfileIconView } from '../../../common';
|
||||
import { RoomWidgetThumbnailEvent } from '../../../events';
|
||||
import { useHasPermission, useHelp, useNavigatorData, useRoom } from '../../../hooks';
|
||||
import { useHasPermission, useHelp, useNavigatorData, useNavigatorFavourite, useRoom } from '../../../hooks';
|
||||
import { classNames } from '../../../layout';
|
||||
|
||||
export interface NavigatorRoomInfoViewProps {
|
||||
@@ -17,12 +17,13 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
||||
const [ isRoomPicked, setIsRoomPicked ] = useState(false);
|
||||
const [ isRoomMuted, setIsRoomMuted ] = useState(false);
|
||||
const { report = null } = useHelp();
|
||||
const { navigatorData, favouriteRoomIds } = useNavigatorData();
|
||||
const { navigatorData } = useNavigatorData();
|
||||
const { roomSession = null } = useRoom();
|
||||
const canManageAnyRoom = useHasPermission('acc_anyroomowner');
|
||||
const canStaffPick = useHasPermission('acc_staff_pick');
|
||||
|
||||
const enteredRoomId = navigatorData?.enteredGuestRoom?.roomId ?? 0;
|
||||
const { isFavourite: isRoomInFavouritesList, toggle: toggleFavourite } = useNavigatorFavourite(enteredRoomId);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -30,22 +31,6 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(enteredRoomId, false, false));
|
||||
}, [ enteredRoomId ]);
|
||||
|
||||
const isRoomInFavouritesList = useMemo(() =>
|
||||
{
|
||||
if(!enteredRoomId) return false;
|
||||
|
||||
return favouriteRoomIds.some((id: any) =>
|
||||
{
|
||||
if(id && typeof id === 'object')
|
||||
{
|
||||
if('roomId' in id) return Number(id.roomId) === enteredRoomId;
|
||||
if('id' in id) return Number(id.id) === enteredRoomId;
|
||||
}
|
||||
|
||||
return String(id) === String(enteredRoomId);
|
||||
});
|
||||
}, [ favouriteRoomIds, enteredRoomId ]);
|
||||
|
||||
const hasPermission = (permission: string) =>
|
||||
{
|
||||
if(!navigatorData?.enteredGuestRoom) return false;
|
||||
@@ -115,7 +100,7 @@ export const NavigatorRoomInfoView: FC<NavigatorRoomInfoViewProps> = props =>
|
||||
report(ReportType.ROOM, { roomId, roomName: navigatorData.enteredGuestRoom.roomName });
|
||||
return;
|
||||
case 'room_favourite':
|
||||
ToggleFavoriteRoom(roomId, isRoomInFavouritesList);
|
||||
toggleFavourite();
|
||||
SendMessageComposer(new GetGuestRoomMessageComposer(roomId, false, false));
|
||||
return;
|
||||
case 'remove_rights':
|
||||
|
||||
@@ -2,9 +2,9 @@ import { RoomDataParser, RoomSettingsComposer, UpdateHomeRoomMessageComposer } f
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import React, { FC, useRef, useState } from 'react';
|
||||
import { FaUser } from 'react-icons/fa';
|
||||
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer, ToggleFavoriteRoom } from '../../../../api';
|
||||
import { GetGroupInformation, GetSessionDataManager, GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
|
||||
import { Column, Flex, LayoutBadgeImageView, LayoutRoomThumbnailView, NitroCardContentView, Text, UserProfileIconView } from '../../../../common';
|
||||
import { useHelp, useNavigatorData } from '../../../../hooks';
|
||||
import { useHelp, useNavigatorData, useNavigatorFavourite } from '../../../../hooks';
|
||||
import { classNames } from '../../../../layout';
|
||||
|
||||
interface NavigatorSearchResultItemInfoViewProps
|
||||
@@ -20,7 +20,8 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
||||
const { roomData = null, isVisible = undefined, onToggle, setIsPopoverActive } = props;
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
const [ internalVisible, setInternalVisible ] = useState(false);
|
||||
const { navigatorData, favouriteRoomIds } = useNavigatorData();
|
||||
const { navigatorData } = useNavigatorData();
|
||||
const { isFavourite, toggle: toggleFavourite } = useNavigatorFavourite(roomData?.roomId);
|
||||
const { report = null } = useHelp();
|
||||
|
||||
const isControlled = isVisible !== undefined;
|
||||
@@ -63,7 +64,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
||||
report(ReportType.ROOM, { roomId: roomData.roomId, roomName: roomData.roomName });
|
||||
return;
|
||||
case 'room_favourite':
|
||||
ToggleFavoriteRoom(roomData.roomId, favouriteRoomIds.includes(roomData.roomId));
|
||||
toggleFavourite();
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -163,7 +164,7 @@ export const NavigatorSearchResultItemInfoView: FC<NavigatorSearchResultItemInfo
|
||||
</Column>
|
||||
<Column alignItems="start" gap={ 2 } className="w-2/5">
|
||||
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('room_favourite') }>
|
||||
<i className={ classNames('icon icon-navigator-favorite-room', favouriteRoomIds.includes(roomData.roomId) ? 'active' : '') } />
|
||||
<i className={ classNames('icon icon-navigator-favorite-room', isFavourite ? 'active' : '') } />
|
||||
<Text className="text-xs">{ LocalizeText('navigator.room.popup.room.info.favorite') }</Text>
|
||||
</Flex>
|
||||
<Flex pointer alignItems="center" gap={ 2 } onClick={ () => processAction('set_home_room') }>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export { useNavigatorData } from './useNavigatorData';
|
||||
export { useNavigatorFavourite } from './useNavigatorFavourite';
|
||||
export { useNavigatorSearch } from './useNavigatorSearch';
|
||||
export { useNavigatorUiState } from './useNavigatorUiState';
|
||||
export { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
|
||||
|
||||
const reset = () => useNavigatorFavouritesStore.setState({ ids: new Set<number>() });
|
||||
|
||||
describe('navigatorFavouritesStore', () =>
|
||||
{
|
||||
beforeEach(reset);
|
||||
|
||||
it('setAll replaces membership and coerces ids to numbers', () =>
|
||||
{
|
||||
useNavigatorFavouritesStore.getState().setAll([ '1', 2, '3' ] as any);
|
||||
const { ids } = useNavigatorFavouritesStore.getState();
|
||||
expect(ids.has(1)).toBe(true);
|
||||
expect(ids.has(2)).toBe(true);
|
||||
expect(ids.has(3)).toBe(true);
|
||||
expect(ids.size).toBe(3);
|
||||
});
|
||||
|
||||
it('apply(true) adds and apply(false) removes', () =>
|
||||
{
|
||||
const { apply } = useNavigatorFavouritesStore.getState();
|
||||
apply(7, true);
|
||||
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(true);
|
||||
apply(7, false);
|
||||
expect(useNavigatorFavouritesStore.getState().ids.has(7)).toBe(false);
|
||||
});
|
||||
|
||||
it('apply returns the same state reference when nothing changes (no needless re-render)', () =>
|
||||
{
|
||||
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
|
||||
const before = useNavigatorFavouritesStore.getState().ids;
|
||||
useNavigatorFavouritesStore.getState().apply(5, true); // already present
|
||||
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
|
||||
useNavigatorFavouritesStore.getState().apply(99, false); // already absent
|
||||
expect(useNavigatorFavouritesStore.getState().ids).toBe(before);
|
||||
});
|
||||
|
||||
it('apply creates a new Set reference when membership actually changes', () =>
|
||||
{
|
||||
useNavigatorFavouritesStore.getState().setAll([ 5 ]);
|
||||
const before = useNavigatorFavouritesStore.getState().ids;
|
||||
useNavigatorFavouritesStore.getState().apply(6, true);
|
||||
expect(useNavigatorFavouritesStore.getState().ids).not.toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createNitroStore } from '../../state/createNitroStore';
|
||||
|
||||
export type NavigatorFavouritesState = {
|
||||
ids: Set<number>;
|
||||
};
|
||||
|
||||
export type NavigatorFavouritesActions = {
|
||||
setAll(roomIds: number[]): void;
|
||||
apply(roomId: number, added: boolean): void;
|
||||
};
|
||||
|
||||
export const useNavigatorFavouritesStore = createNitroStore<NavigatorFavouritesState & NavigatorFavouritesActions>()((set) => ({
|
||||
ids: new Set<number>(),
|
||||
|
||||
setAll: (roomIds) => set({ ids: new Set(roomIds.map(Number)) }),
|
||||
apply: (roomId, added) => set((s) =>
|
||||
{
|
||||
const id = Number(roomId);
|
||||
if(added ? s.ids.has(id) : !s.ids.has(id)) return s;
|
||||
const ids = new Set(s.ids);
|
||||
if(added) ids.add(id);
|
||||
else ids.delete(id);
|
||||
return { ids };
|
||||
})
|
||||
}));
|
||||
@@ -4,13 +4,13 @@ import { useNavigatorStore } from './useNavigatorStore';
|
||||
export const useNavigatorData = () =>
|
||||
{
|
||||
const {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
categories, eventCategories,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
} = useBetween(useNavigatorStore);
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
categories, eventCategories,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ToggleFavoriteRoom } from '../../api';
|
||||
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
|
||||
|
||||
export const useNavigatorFavourite = (roomId: number) =>
|
||||
{
|
||||
const isFavourite = useNavigatorFavouritesStore((s) => s.ids.has(Number(roomId)));
|
||||
|
||||
const toggle = useCallback(() => ToggleFavoriteRoom(Number(roomId), isFavourite), [ roomId, isFavourite ]);
|
||||
|
||||
return { isFavourite, toggle };
|
||||
};
|
||||
@@ -8,7 +8,7 @@ describe('navigator filter shapes (smoke)', () =>
|
||||
{
|
||||
const { result } = renderHook(() => useNavigatorData());
|
||||
expect(Object.keys(result.current).sort()).toEqual([
|
||||
'categories', 'eventCategories', 'favouriteRoomIds',
|
||||
'categories', 'eventCategories',
|
||||
'navigatorData', 'navigatorSearches',
|
||||
'topLevelContext', 'topLevelContexts'
|
||||
].sort());
|
||||
|
||||
@@ -18,13 +18,13 @@ import { CreateRoomSession, GetConfigurationValue, INavigatorData,
|
||||
TryVisitRoom, VisitDesktop } from '../../api';
|
||||
import { useMessageEvent, useNitroEvent } from '../events';
|
||||
import { useNotification } from '../notification';
|
||||
import { useNavigatorFavouritesStore } from './navigatorFavouritesStore';
|
||||
import { useNavigatorUiStore } from './navigatorUiStore';
|
||||
|
||||
export const useNavigatorStore = () =>
|
||||
{
|
||||
const [ categories, setCategories ] = useState<NavigatorCategoryDataParser[]>(null);
|
||||
const [ eventCategories, setEventCategories ] = useState<NavigatorEventCategoryDataParser[]>(null);
|
||||
const [ favouriteRoomIds, setFavouriteRoomIds ] = useState<number[]>([]);
|
||||
const [ topLevelContext, setTopLevelContext ] = useState<NavigatorTopLevelContext>(null);
|
||||
const [ topLevelContexts, setTopLevelContexts ] = useState<NavigatorTopLevelContext[]>(null);
|
||||
const [ navigatorSearches, setNavigatorSearches ] = useState<NavigatorSavedSearch[]>(null);
|
||||
@@ -48,21 +48,13 @@ export const useNavigatorStore = () =>
|
||||
useMessageEvent<FavouritesEvent>(FavouritesEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const favoriteIds = (parser.favoriteRoomIds || []).map((x: any) => Number(x));
|
||||
setFavouriteRoomIds(favoriteIds);
|
||||
useNavigatorFavouritesStore.getState().setAll(parser.favoriteRoomIds || []);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<FavouriteChangedEvent>(FavouriteChangedEvent, useCallback(event =>
|
||||
{
|
||||
const parser = event.getParser();
|
||||
const roomId = Number(parser.flatId);
|
||||
const added = !!parser.added;
|
||||
setFavouriteRoomIds(prev =>
|
||||
{
|
||||
const ids = (prev || []).map((x: any) => Number(x));
|
||||
if(added) return ids.includes(roomId) ? ids : [ ...ids, roomId ];
|
||||
return ids.filter(id => id !== roomId);
|
||||
});
|
||||
useNavigatorFavouritesStore.getState().apply(parser.flatId, !!parser.added);
|
||||
}, []));
|
||||
|
||||
useMessageEvent<CanCreateRoomEventEvent>(CanCreateRoomEventEvent, useCallback(event =>
|
||||
@@ -280,7 +272,7 @@ export const useNavigatorStore = () =>
|
||||
}, []));
|
||||
|
||||
return {
|
||||
categories, eventCategories, favouriteRoomIds,
|
||||
categories, eventCategories,
|
||||
topLevelContext, topLevelContexts,
|
||||
navigatorSearches, navigatorData
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user