diff --git a/src/api/avatar/dedupeBadges.ts b/src/api/avatar/dedupeBadges.ts new file mode 100644 index 0000000..e3a8d14 --- /dev/null +++ b/src/api/avatar/dedupeBadges.ts @@ -0,0 +1,21 @@ +/** + * Strips duplicate badge codes from a server-supplied badge array, + * preserving slot indices: a duplicate is replaced by an empty string + * rather than shifted out, so badge[i] still corresponds to slot i. + * + * Empty / falsy entries are normalized to '' (some servers emit null + * inside the array for unused slots). + */ +export const dedupeBadges = (badges: ReadonlyArray): string[] => +{ + const seen = new Set(); + + return badges.map(code => + { + if(!code || seen.has(code)) return ''; + + seen.add(code); + + return code; + }); +}; diff --git a/src/api/avatar/index.ts b/src/api/avatar/index.ts index 6049e7a..11f6376 100644 --- a/src/api/avatar/index.ts +++ b/src/api/avatar/index.ts @@ -3,5 +3,6 @@ export * from './AvatarEditorColorSorter'; export * from './AvatarEditorPartSorter'; export * from './AvatarEditorThumbnailsHelper'; export * from './BuildPurchasableClothingFigure'; +export * from './dedupeBadges'; export * from './IAvatarEditorCategory'; export * from './IAvatarEditorCategoryPartItem'; diff --git a/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx b/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx index da25e4d..7e4ea5c 100644 --- a/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx +++ b/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx @@ -228,7 +228,7 @@ export const AvatarInfoWidgetView: FC<{}> = props => } } - return setAvatarInfo(null) } />; + return setAvatarInfo(null) } />; case AvatarInfoUser.BOT: return setAvatarInfo(null) } />; case AvatarInfoRentableBot.RENTABLE_BOT: diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index bec0273..d5c31d3 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -1,10 +1,10 @@ import React from 'react'; -import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer'; -import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; +import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer'; +import { FC, FocusEvent, KeyboardEvent, useCallback, useEffect, useState } from 'react'; import { FaPencilAlt, FaTimes } from 'react-icons/fa'; -import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; +import { AvatarInfoUser, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView, UserProfileIconView } from '../../../../../common'; -import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks'; +import { useMessageEvent, useRoom } from '../../../../../hooks'; import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView'; @@ -12,13 +12,12 @@ import { BackgroundsView } from '../../../../backgrounds/BackgroundsView'; interface InfoStandWidgetUserViewProps { avatarInfo: AvatarInfoUser; - setAvatarInfo: Dispatch>; onClose: () => void; } export const InfoStandWidgetUserView: FC = props => { - const { avatarInfo = null, setAvatarInfo = null, onClose = null } = props; + const { avatarInfo = null, onClose = null } = props; const [motto, setMotto] = useState(null); const [isEditingMotto, setIsEditingMotto] = useState(false); const [relationships, setRelationships] = useState(null); @@ -65,77 +64,6 @@ export const InfoStandWidgetUserView: FC = props = } }; - useNitroEvent(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => - { - if (!avatarInfo || avatarInfo.webID !== event.userId) return; - - // Deduplicate badges from server - const seen = new Set(); - const dedupedBadges = event.badges.map(code => - { - if (!code || seen.has(code)) return ''; - seen.add(code); - return code; - }); - - const oldBadges = avatarInfo.badges.join(''); - - if (oldBadges === dedupedBadges.join('')) return; - - setAvatarInfo(prevValue => - { - if (!prevValue) return prevValue; - - const newValue = CloneObject(prevValue); - newValue.badges = dedupedBadges; - return newValue; - }); - }); - - useNitroEvent(RoomSessionUserFigureUpdateEvent.USER_FIGURE, event => - { - if (!avatarInfo || avatarInfo.roomIndex !== event.roomIndex) return; - - setAvatarInfo(prevValue => - { - if (!prevValue) return prevValue; - - const newValue = CloneObject(prevValue); - newValue.figure = event.figure; - newValue.motto = event.customInfo; - newValue.achievementScore = event.activityPoints; - newValue.nickIcon = event.nickIcon; - newValue.prefixText = event.prefixText; - newValue.prefixColor = event.prefixColor; - newValue.prefixIcon = event.prefixIcon; - newValue.prefixEffect = event.prefixEffect; - newValue.displayOrder = event.displayOrder; - newValue.backgroundId = event.backgroundId; - newValue.standId = event.standId; - newValue.overlayId = event.overlayId; - newValue.cardBackgroundId = event.cardBackgroundId ?? 0; - return newValue; - }); - }); - - useNitroEvent(RoomSessionFavoriteGroupUpdateEvent.FAVOURITE_GROUP_UPDATE, event => - { - if (!avatarInfo || avatarInfo.roomIndex !== event.roomIndex) return; - - setAvatarInfo(prevValue => - { - if (!prevValue) return prevValue; - - const newValue = CloneObject(prevValue); - const clearGroup = (event.status === -1) || (event.habboGroupId <= 0); - - newValue.groupId = clearGroup ? -1 : event.habboGroupId; - newValue.groupName = clearGroup ? null : event.habboGroupName; - newValue.groupBadgeId = clearGroup ? null : GetSessionDataManager().getGroupBadge(event.habboGroupId); - return newValue; - }); - }); - useMessageEvent(RelationshipStatusInfoEvent, event => { const parser = event.getParser(); diff --git a/src/hooks/rooms/widgets/avatarInfo.reducers.ts b/src/hooks/rooms/widgets/avatarInfo.reducers.ts new file mode 100644 index 0000000..037ab73 --- /dev/null +++ b/src/hooks/rooms/widgets/avatarInfo.reducers.ts @@ -0,0 +1,80 @@ +import { RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent } from '@nitrots/nitro-renderer'; +import { AvatarInfoUser, dedupeBadges, IAvatarInfo } from '../../../api'; + +/** + * Pure reducers consumed by useAvatarInfoWidget to update the inspected + * AvatarInfoUser when room-session events fire. Exported standalone for + * Vitest coverage — no React, no renderer dispatcher access. + * + * Each reducer returns the same reference if the event doesn't apply + * (state unchanged) so React bail-outs work and consumers don't re-render + * uselessly. + */ + +const cloneAvatarInfoUser = (state: AvatarInfoUser): AvatarInfoUser => +{ + const clone = new AvatarInfoUser(state.type); + + Object.assign(clone, state); + + return clone; +}; + +export const applyUserBadgesUpdate = (state: IAvatarInfo | null, event: RoomSessionUserBadgesEvent): IAvatarInfo | null => +{ + if(!(state instanceof AvatarInfoUser)) return state; + if(state.webID !== event.userId) return state; + + const dedupedBadges = dedupeBadges(event.badges); + + if(state.badges.join('') === dedupedBadges.join('')) return state; + + const next = cloneAvatarInfoUser(state); + + next.badges = dedupedBadges; + + return next; +}; + +export const applyUserFigureUpdate = (state: IAvatarInfo | null, event: RoomSessionUserFigureUpdateEvent): IAvatarInfo | null => +{ + if(!(state instanceof AvatarInfoUser)) return state; + if(state.roomIndex !== event.roomIndex) return state; + + const next = cloneAvatarInfoUser(state); + + next.figure = event.figure; + next.motto = event.customInfo; + next.achievementScore = event.activityPoints; + next.nickIcon = event.nickIcon; + next.prefixText = event.prefixText; + next.prefixColor = event.prefixColor; + next.prefixIcon = event.prefixIcon; + next.prefixEffect = event.prefixEffect; + next.displayOrder = event.displayOrder; + next.backgroundId = event.backgroundId; + next.standId = event.standId; + next.overlayId = event.overlayId; + next.cardBackgroundId = event.cardBackgroundId ?? 0; + + return next; +}; + +export const applyFavouriteGroupUpdate = ( + state: IAvatarInfo | null, + event: RoomSessionFavoriteGroupUpdateEvent, + resolveGroupBadge: (groupId: number) => string +): IAvatarInfo | null => +{ + if(!(state instanceof AvatarInfoUser)) return state; + if(state.roomIndex !== event.roomIndex) return state; + + const clearGroup = (event.status === -1) || (event.habboGroupId <= 0); + const next = cloneAvatarInfoUser(state); + + next.groupId = clearGroup ? -1 : event.habboGroupId; + next.groupName = clearGroup ? null : event.habboGroupName; + next.groupBadgeId = clearGroup ? null : resolveGroupBadge(event.habboGroupId); + + return next; +}; diff --git a/src/hooks/rooms/widgets/useAvatarInfoWidget.ts b/src/hooks/rooms/widgets/useAvatarInfoWidget.ts index 6296326..0c5196b 100644 --- a/src/hooks/rooms/widgets/useAvatarInfoWidget.ts +++ b/src/hooks/rooms/widgets/useAvatarInfoWidget.ts @@ -1,4 +1,4 @@ -import { GetRoomEngine, GetSessionDataManager, RoomEngineObjectEvent, RoomEngineUseProductEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionPetInfoUpdateEvent, RoomSessionPetStatusUpdateEvent, RoomSessionUserDataUpdateEvent } from '@nitrots/nitro-renderer'; +import { GetRoomEngine, GetSessionDataManager, RoomEngineObjectEvent, RoomEngineUseProductEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionFavoriteGroupUpdateEvent, RoomSessionPetInfoUpdateEvent, RoomSessionPetStatusUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserDataUpdateEvent, RoomSessionUserFigureUpdateEvent } from '@nitrots/nitro-renderer'; import { useEffect, useRef, useState } from 'react'; import { AvatarInfoFurni, AvatarInfoName, AvatarInfoPet, AvatarInfoRentableBot, AvatarInfoUser, AvatarInfoUtilities, CanManipulateFurniture, FurniCategory, IAvatarInfo, IsOwnerOfFurniture, RoomWidgetUpdateRoomObjectEvent, UseProductItem } from '../../../api'; import { useNitroEvent, useUiEvent } from '../../events'; @@ -6,6 +6,7 @@ import { useFriends } from '../../friends'; import { useWired } from '../../wired'; import { useObjectDeselectedEvent, useObjectRollOutEvent, useObjectRollOverEvent, useObjectSelectedEvent } from '../engine'; import { useRoom } from '../useRoom'; +import { applyFavouriteGroupUpdate, applyUserBadgesUpdate, applyUserFigureUpdate } from './avatarInfo.reducers'; const useAvatarInfoWidgetState = () => { @@ -296,6 +297,21 @@ const useAvatarInfoWidgetState = () => setIsDecorating(true); }); + useNitroEvent(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => + { + setAvatarInfo(prev => applyUserBadgesUpdate(prev, event)); + }); + + useNitroEvent(RoomSessionUserFigureUpdateEvent.USER_FIGURE, event => + { + setAvatarInfo(prev => applyUserFigureUpdate(prev, event)); + }); + + useNitroEvent(RoomSessionFavoriteGroupUpdateEvent.FAVOURITE_GROUP_UPDATE, event => + { + setAvatarInfo(prev => applyFavouriteGroupUpdate(prev, event, groupId => GetSessionDataManager().getGroupBadge(groupId))); + }); + useObjectSelectedEvent(event => { getObjectInfo(event.id, event.category);