diff --git a/src/components/navigator/views/NavigatorRoomInfoView.tsx b/src/components/navigator/views/NavigatorRoomInfoView.tsx index ca5686c..4fefcc9 100644 --- a/src/components/navigator/views/NavigatorRoomInfoView.tsx +++ b/src/components/navigator/views/NavigatorRoomInfoView.tsx @@ -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 = 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 = 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 = 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': diff --git a/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx b/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx index c60d21d..b5cc0e6 100644 --- a/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx +++ b/src/components/navigator/views/search/NavigatorSearchResultItemInfoView.tsx @@ -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(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 processAction('room_favourite') }> - + { LocalizeText('navigator.room.popup.room.info.favorite') } processAction('set_home_room') }> diff --git a/src/hooks/navigator/index.ts b/src/hooks/navigator/index.ts index 67660d0..2aca523 100644 --- a/src/hooks/navigator/index.ts +++ b/src/hooks/navigator/index.ts @@ -1,4 +1,5 @@ export { useNavigatorData } from './useNavigatorData'; +export { useNavigatorFavourite } from './useNavigatorFavourite'; export { useNavigatorSearch } from './useNavigatorSearch'; export { useNavigatorUiState } from './useNavigatorUiState'; export { useNavigatorUiStore } from './navigatorUiStore'; diff --git a/src/hooks/navigator/navigatorFavouritesStore.test.ts b/src/hooks/navigator/navigatorFavouritesStore.test.ts new file mode 100644 index 0000000..f2b1abc --- /dev/null +++ b/src/hooks/navigator/navigatorFavouritesStore.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { useNavigatorFavouritesStore } from './navigatorFavouritesStore'; + +const reset = () => useNavigatorFavouritesStore.setState({ ids: new Set() }); + +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); + }); +}); diff --git a/src/hooks/navigator/navigatorFavouritesStore.ts b/src/hooks/navigator/navigatorFavouritesStore.ts new file mode 100644 index 0000000..cba51c7 --- /dev/null +++ b/src/hooks/navigator/navigatorFavouritesStore.ts @@ -0,0 +1,25 @@ +import { createNitroStore } from '../../state/createNitroStore'; + +export type NavigatorFavouritesState = { + ids: Set; +}; + +export type NavigatorFavouritesActions = { + setAll(roomIds: number[]): void; + apply(roomId: number, added: boolean): void; +}; + +export const useNavigatorFavouritesStore = createNitroStore()((set) => ({ + ids: new Set(), + + 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 }; + }) +})); diff --git a/src/hooks/navigator/useNavigatorData.ts b/src/hooks/navigator/useNavigatorData.ts index 500fb9e..5d19b83 100644 --- a/src/hooks/navigator/useNavigatorData.ts +++ b/src/hooks/navigator/useNavigatorData.ts @@ -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 }; diff --git a/src/hooks/navigator/useNavigatorFavourite.ts b/src/hooks/navigator/useNavigatorFavourite.ts new file mode 100644 index 0000000..f2e8ba8 --- /dev/null +++ b/src/hooks/navigator/useNavigatorFavourite.ts @@ -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 }; +}; diff --git a/src/hooks/navigator/useNavigatorStore.test.tsx b/src/hooks/navigator/useNavigatorStore.test.tsx index 2e09036..46fc97a 100644 --- a/src/hooks/navigator/useNavigatorStore.test.tsx +++ b/src/hooks/navigator/useNavigatorStore.test.tsx @@ -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()); diff --git a/src/hooks/navigator/useNavigatorStore.ts b/src/hooks/navigator/useNavigatorStore.ts index bf1a513..c8c5cb5 100644 --- a/src/hooks/navigator/useNavigatorStore.ts +++ b/src/hooks/navigator/useNavigatorStore.ts @@ -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(null); const [ eventCategories, setEventCategories ] = useState(null); - const [ favouriteRoomIds, setFavouriteRoomIds ] = useState([]); const [ topLevelContext, setTopLevelContext ] = useState(null); const [ topLevelContexts, setTopLevelContexts ] = useState(null); const [ navigatorSearches, setNavigatorSearches ] = useState(null); @@ -48,21 +48,13 @@ export const useNavigatorStore = () => useMessageEvent(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, 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, useCallback(event => @@ -280,7 +272,7 @@ export const useNavigatorStore = () => }, [])); return { - categories, eventCategories, favouriteRoomIds, + categories, eventCategories, topLevelContext, topLevelContexts, navigatorSearches, navigatorData };