From 7b062299de994ee5c26b056e6b82b2c9b8a819a8 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Mon, 11 May 2026 22:38:32 +0200 Subject: [PATCH] useClubGifts + useNitroEventInvalidator: close the catalogOptions bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit drains the last field out of ICatalogOptions (clubGifts) and deletes the interface — useCatalog no longer owns a catch-all mutable object that downstream components stuff data into. Two pieces: 1) New useNitroEventInvalidator(eventType, queryKey, accept?) — a small companion to useNitroQuery for the case where the server pushes the same event unprompted (e.g. ClubGiftInfoEvent fires both as the response to GetClubGiftInfo and again after the user claims a gift via SelectClubGiftComposer). It calls queryClient.invalidateQueries() on each matching push so the next render of any subscriber triggers a fresh queryFn. 2) New useClubGifts() — useNitroQuery on the ClubGiftInfoEvent pair, paired with useNitroEventInvalidator so server-driven pushes refresh the cache automatically. CatalogLayoutVipGiftsView now consumes the query directly. The local optimistic 'giftsAvailable--' mutation (which side-effected the parser object passed back to the catalog state!) is dropped — the server's authoritative ClubGiftInfoEvent push is the single source of truth via the invalidator. useCatalog drops the matching listener + the GetClubGiftInfo dispatch from the catalog-open effect. ICatalogOptions is now empty and deleted; the catalogOptions / setCatalogOptions state + return-shape field are removed from useCatalog along with the import. --- src/api/catalog/ICatalogOptions.ts | 6 --- src/api/catalog/index.ts | 1 - src/api/nitro-query/index.ts | 1 + .../nitro-query/useNitroEventInvalidator.ts | 48 +++++++++++++++++++ .../vip-gifts/CatalogLayoutVipGiftsView.tsx | 33 ++++++------- src/hooks/catalog/index.ts | 1 + src/hooks/catalog/useCatalog.ts | 20 ++------ src/hooks/catalog/useClubGifts.ts | 38 +++++++++++++++ 8 files changed, 106 insertions(+), 42 deletions(-) delete mode 100644 src/api/catalog/ICatalogOptions.ts create mode 100644 src/api/nitro-query/useNitroEventInvalidator.ts create mode 100644 src/hooks/catalog/useClubGifts.ts diff --git a/src/api/catalog/ICatalogOptions.ts b/src/api/catalog/ICatalogOptions.ts deleted file mode 100644 index d95b16a..0000000 --- a/src/api/catalog/ICatalogOptions.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { ClubGiftInfoParser } from '@nitrots/nitro-renderer'; - -export interface ICatalogOptions -{ - clubGifts?: ClubGiftInfoParser; -} diff --git a/src/api/catalog/index.ts b/src/api/catalog/index.ts index 6c5b9e2..027bd2a 100644 --- a/src/api/catalog/index.ts +++ b/src/api/catalog/index.ts @@ -10,7 +10,6 @@ export * from './FurnitureOffer'; export * from './GetImageIconUrlForProduct'; export * from './GiftWrappingConfiguration'; export * from './ICatalogNode'; -export * from './ICatalogOptions'; export * from './ICatalogPage'; export * from './IMarketplaceSearchOptions'; export * from './IPageLocalization'; diff --git a/src/api/nitro-query/index.ts b/src/api/nitro-query/index.ts index 49c1bfd..3eda014 100644 --- a/src/api/nitro-query/index.ts +++ b/src/api/nitro-query/index.ts @@ -1 +1,2 @@ export * from './createNitroQuery'; +export * from './useNitroEventInvalidator'; diff --git a/src/api/nitro-query/useNitroEventInvalidator.ts b/src/api/nitro-query/useNitroEventInvalidator.ts new file mode 100644 index 0000000..d1a160c --- /dev/null +++ b/src/api/nitro-query/useNitroEventInvalidator.ts @@ -0,0 +1,48 @@ +import { IMessageEvent, MessageEvent } from '@nitrots/nitro-renderer'; +import { QueryKey, useQueryClient } from '@tanstack/react-query'; +import { useMessageEvent } from '../../hooks/events/useMessageEvent'; + +/** + * Invalidate a TanStack query slot every time the renderer pushes the + * matching parser event. Companion to useNitroQuery for the case where + * the server can push fresh data unprompted (e.g. ClubGiftInfoEvent + * fires both as the response to GetClubGiftInfo and again after the + * user claims a gift via SelectClubGiftComposer). + * + * Usage: + * + * const { data: clubGifts } = useNitroQuery({ + * key: ['nitro', 'catalog', 'clubGifts'], + * request: () => new GetClubGiftInfo(), + * parser: ClubGiftInfoEvent, + * select: e => e.getParser(), + * }); + * + * // re-fetch on every server push: + * useNitroEventInvalidator(ClubGiftInfoEvent, ['nitro', 'catalog', 'clubGifts']); + * + * Optional `accept` predicate filters out events that don't belong to + * this query slot — useful when the same parser is multiplexed across + * multiple correlated queries (mirrors useNitroQuery.accept). + * + * Implementation: the renderer push triggers `queryClient.invalidateQueries`, + * which marks the slot stale; the next subscriber render triggers a + * fresh fetch via useNitroQuery's queryFn. If nobody is currently + * subscribed, the invalidation is a no-op (TanStack drops stale entries + * with no active observers per its garbage-collection policy). + */ +export const useNitroEventInvalidator = ( + eventType: typeof MessageEvent, + queryKey: QueryKey, + accept?: (event: T) => boolean +) => +{ + const queryClient = useQueryClient(); + + useMessageEvent(eventType, event => + { + if(accept && !accept(event)) return; + + queryClient.invalidateQueries({ queryKey }); + }); +}; diff --git a/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx b/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx index f627250..2614b13 100644 --- a/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx +++ b/src/components/catalog/views/page/layout/vip-gifts/CatalogLayoutVipGiftsView.tsx @@ -2,7 +2,7 @@ import { SelectClubGiftComposer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useMemo } from 'react'; import { LocalizeText, SendMessageComposer } from '../../../../../../api'; import { AutoGrid, Text } from '../../../../../../common'; -import { useCatalog, useNotification, usePurse } from '../../../../../../hooks'; +import { useClubGifts, useNotification, usePurse } from '../../../../../../hooks'; import { CatalogLayoutProps } from '../CatalogLayout.types'; import { VipGiftItem } from './VipGiftItemView'; @@ -11,8 +11,7 @@ let isSelectingGift = false; export const CatalogLayoutVipGiftsView: FC = props => { const { purse = null } = usePurse(); - const { catalogOptions = null, setCatalogOptions = null } = useCatalog(); - const { clubGifts = null } = catalogOptions; + const { data: clubGifts = null } = useClubGifts(); const { showConfirm = null } = useNotification(); const giftsAvailable = useCallback(() => @@ -36,34 +35,32 @@ export const CatalogLayoutVipGiftsView: FC = props => isSelectingGift = true; + // The server replies with a fresh ClubGiftInfoEvent after + // accepting the selection; useClubGifts subscribes to that + // event via useNitroEventInvalidator, so giftsAvailable + // refreshes from the authoritative source — no need to + // mutate the parser locally. SendMessageComposer(new SelectClubGiftComposer(localizationId)); - setCatalogOptions(prevValue => - { - prevValue.clubGifts.giftsAvailable--; - - return { ...prevValue }; - }); - setTimeout(() => isSelectingGift = false, 5000); }, null); - }, [ setCatalogOptions, showConfirm ]); + }, [ showConfirm ]); const sortGifts = useMemo(() => { - let gifts = clubGifts.offers.sort((a,b) => - { - return clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired; - }); - return gifts; - },[ clubGifts ]); + if(!clubGifts) return []; + + return [ ...clubGifts.offers ].sort((a, b) => + (clubGifts.getOfferExtraData(a.offerId).daysRequired - clubGifts.getOfferExtraData(b.offerId).daysRequired) + ); + }, [ clubGifts ]); return ( <> { giftsAvailable() } - { (clubGifts.offers.length > 0) && sortGifts.map(offer => 0)) } offer={ offer } onSelect={ selectGift }/>) } + { clubGifts && (clubGifts.offers.length > 0) && sortGifts.map(offer => 0)) } offer={ offer } onSelect={ selectGift }/>) } ); diff --git a/src/hooks/catalog/index.ts b/src/hooks/catalog/index.ts index b706172..2a817fa 100644 --- a/src/hooks/catalog/index.ts +++ b/src/hooks/catalog/index.ts @@ -2,6 +2,7 @@ export * from './useCatalog'; export * from './useCatalogFavorites'; export * from './useCatalogPlaceMultipleItems'; export * from './useCatalogSkipPurchaseConfirmation'; +export * from './useClubGifts'; export * from './useClubOffers'; export * from './useGiftConfiguration'; export * from './useMarketplaceConfiguration'; diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index b21a39a..9676eba 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -1,7 +1,7 @@ -import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, ClubGiftInfoEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetClubGiftInfo, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, NodeData, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomControllerLevel, RoomEngineObjectPlacedEvent, RoomObjectCategory, RoomObjectPlacementSource, RoomObjectType, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; +import { BuildersClubFurniCountMessageEvent, BuildersClubPlaceRoomItemMessageComposer, BuildersClubPlaceWallItemMessageComposer, BuildersClubQueryFurniCountMessageComposer, BuildersClubSubscriptionStatusMessageEvent, CatalogPageMessageEvent, CatalogPagesListEvent, CatalogPublishedMessageEvent, CreateLinkEvent, FrontPageItem, FurniturePlaceComposer, FurniturePlacePaintComposer, GetCatalogIndexComposer, GetCatalogPageComposer, GetRoomEngine, GetSessionDataManager, GetTickerTime, LegacyDataType, LimitedEditionSoldOutEvent, MarketplaceMakeOfferResult, NodeData, ProductOfferEvent, PurchaseErrorMessageEvent, PurchaseFromCatalogComposer, PurchaseNotAllowedMessageEvent, PurchaseOKMessageEvent, RoomControllerLevel, RoomEngineObjectPlacedEvent, RoomObjectCategory, RoomObjectPlacementSource, RoomObjectType, RoomObjectVariable, RoomPreviewer, Vector3d } from '@nitrots/nitro-renderer'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useBetween } from 'use-between'; -import { BuilderFurniPlaceableStatus, CatalogNode, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogOptions, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api'; +import { BuilderFurniPlaceableStatus, CatalogNode, CatalogPage, CatalogType, DispatchUiEvent, FurniCategory, GetFurnitureData, GetProductDataForLocalization, GetRoomSession, ICatalogNode, ICatalogPage, IPageLocalization, IProduct, IPurchasableOffer, IPurchaseOptions, LocalizeText, NotificationAlertType, Offer, PageLocalization, PlacedObjectPurchaseData, PlaySound, Product, ProductTypeEnum, RequestedPage, SearchResult, SendMessageComposer, SoundNames } from '../../api'; import { CatalogPurchaseFailureEvent, CatalogPurchaseNotAllowedEvent, CatalogPurchaseSoldOutEvent, CatalogPurchasedEvent, InventoryFurniAddedEvent } from '../../events'; import { useMessageEvent, useNitroEvent, useUiEvent } from '../events'; import { useNotification } from '../notification'; @@ -28,7 +28,6 @@ const useCatalogState = () => const [ roomPreviewer, setRoomPreviewer ] = useState(null); const [ navigationHidden, setNavigationHidden ] = useState(false); const [ purchaseOptions, setPurchaseOptions ] = useState({ quantity: 1, extraData: null, extraParamRequired: false, previewStuffData: null }); - const [ catalogOptions, setCatalogOptions ] = useState({}); const [ objectMoverRequested, setObjectMoverRequested ] = useState(false); const [ catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects ] = useCatalogPlaceMultipleItems(); const [ catalogSkipPurchaseConfirmation, setCatalogSkipPurchaseConfirmation ] = useCatalogSkipPurchaseConfirmation(); @@ -734,18 +733,6 @@ const useCatalogState = () => simpleAlert(message, NotificationAlertType.DEFAULT, null, null, title); }); - useMessageEvent(ClubGiftInfoEvent, event => - { - const parser = event.getParser(); - - setCatalogOptions(prevValue => - { - const clubGifts = parser; - - return { ...prevValue, clubGifts }; - }); - }); - useMessageEvent(CatalogPublishedMessageEvent, event => { const wasVisible = isVisible; @@ -1024,7 +1011,6 @@ const useCatalogState = () => { if(!isVisible || rootNode) return; - SendMessageComposer(new GetClubGiftInfo()); SendMessageComposer(new GetCatalogIndexComposer(currentType)); SendMessageComposer(new BuildersClubQueryFurniCountMessageComposer()); }, [ isVisible, rootNode, currentType ]); @@ -1044,7 +1030,7 @@ const useCatalogState = () => }; }, []); - return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogOptions, setCatalogOptions, catalogLocalizationVersion, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus, selectCatalogOffer }; + return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogLocalizationVersion, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus, selectCatalogOffer }; }; export const useCatalog = () => useBetween(useCatalogState); diff --git a/src/hooks/catalog/useClubGifts.ts b/src/hooks/catalog/useClubGifts.ts new file mode 100644 index 0000000..a941e60 --- /dev/null +++ b/src/hooks/catalog/useClubGifts.ts @@ -0,0 +1,38 @@ +import { ClubGiftInfoEvent, ClubGiftInfoParser, GetClubGiftInfo } from '@nitrots/nitro-renderer'; +import { UseQueryResult } from '@tanstack/react-query'; +import { useNitroEventInvalidator, useNitroQuery } from '../../api/nitro-query'; + +const CLUB_GIFTS_KEY = [ 'nitro', 'catalog', 'clubGifts' ] as const; + +/** + * Habbo Club gift availability (counts of pending gifts, days until + * next gift, gift list). The server replies once to GetClubGiftInfo + * and then pushes ClubGiftInfoEvent again every time the user claims + * a gift via SelectClubGiftComposer — so the cache needs to be + * invalidated on each push, not just hydrated by the first response. + * + * Pair the query with useNitroEventInvalidator so unsolicited pushes + * mark the slot stale; the next render of any consumer triggers a + * re-fetch (which, since the server just pushed, will resolve almost + * immediately with the fresh data the server already sent us). + * + * Replaces the previous useCatalog listener that stuffed + * `parser` into `catalogOptions.clubGifts`. + */ +export const useClubGifts = ( + options: { enabled?: boolean } = {} +): UseQueryResult => +{ + const query = useNitroQuery({ + key: CLUB_GIFTS_KEY as unknown as string[], + request: () => new GetClubGiftInfo(), + parser: ClubGiftInfoEvent, + select: event => event.getParser(), + enabled: options.enabled, + staleTime: Infinity + }); + + useNitroEventInvalidator(ClubGiftInfoEvent, CLUB_GIFTS_KEY as unknown as string[]); + + return query; +};