Files
Nitro-V3/src/components/room/widgets/avatar-info/AvatarInfoWidgetView.tsx
T
simoleo89 559d860a7b Pilot: move InfoStand event listeners to useAvatarInfoWidget owner
InfoStandWidgetUserView previously subscribed to three room-session
events (RSUBE_BADGES, USER_FIGURE, FAVOURITE_GROUP_UPDATE) and pushed
the result back to its parent via a setAvatarInfo prop, with each
handler running CloneObject(prev) before patching one field. Three
issues with that shape:

- CloneObject was deep-cloning the whole AvatarInfoUser shape blindly
  with no class-prototype awareness;
- the three listeners raced on shallow merges across the same prev
  reference in StrictMode dev;
- the subscriptions lived outside the state owner, forcing a prop
  callback barrier per event.

The subscriptions are now in useAvatarInfoWidget — the actual owner of
avatarInfo — and call three pure reducers extracted to
src/hooks/rooms/widgets/avatarInfo.reducers.ts (applyUserBadgesUpdate,
applyUserFigureUpdate, applyFavouriteGroupUpdate). Each reducer returns
the same reference when the event doesn't apply so React bail-outs work.
The clone now constructs a fresh AvatarInfoUser preserving prototype.

dedupeBadges is extracted to its own pure module under src/api/avatar/
so Vitest can cover it without pulling in the renderer.

InfoStandWidgetUserView loses the setAvatarInfo prop (parent updated)
and the CloneObject import.
2026-05-11 21:11:02 +02:00

261 lines
12 KiB
TypeScript

import { AddLinkEventTracker, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomEngineEvent, RoomEngineObjectEvent, RoomEnterEffect, RoomSessionDanceEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { AvatarInfoFurni, AvatarInfoPet, AvatarInfoRentableBot, AvatarInfoUser, GetConfigurationValue, RoomWidgetUpdateRentableBotChatEvent } from '../../../../api';
import { Column, LayoutFurniIconImageView } from '../../../../common';
import { useAvatarInfoWidget, useNitroEvent, useRoom, useUiEvent } from '../../../../hooks';
import { AvatarInfoPetTrainingPanelView } from './AvatarInfoPetTrainingPanelView';
import { AvatarInfoRentableBotChatView } from './AvatarInfoRentableBotChatView';
import { AvatarInfoUseProductConfirmView } from './AvatarInfoUseProductConfirmView';
import { AvatarInfoUseProductView } from './AvatarInfoUseProductView';
import { InfoStandWidgetBotView } from './infostand/InfoStandWidgetBotView';
import { InfoStandWidgetFurniView } from './infostand/InfoStandWidgetFurniView';
import { InfoStandWidgetPetView } from './infostand/InfoStandWidgetPetView';
import { InfoStandWidgetRentableBotView } from './infostand/InfoStandWidgetRentableBotView';
import { InfoStandWidgetUserView } from './infostand/InfoStandWidgetUserView';
import { AvatarInfoWidgetAvatarView } from './menu/AvatarInfoWidgetAvatarView';
import { AvatarInfoWidgetDecorateView } from './menu/AvatarInfoWidgetDecorateView';
import { AvatarInfoWidgetFurniView } from './menu/AvatarInfoWidgetFurniView';
import { AvatarInfoWidgetNameView } from './menu/AvatarInfoWidgetNameView';
import { AvatarInfoWidgetOwnAvatarView } from './menu/AvatarInfoWidgetOwnAvatarView';
import { AvatarInfoWidgetOwnPetView } from './menu/AvatarInfoWidgetOwnPetView';
import { AvatarInfoWidgetPetView } from './menu/AvatarInfoWidgetPetView';
import { AvatarInfoWidgetRentableBotView } from './menu/AvatarInfoWidgetRentableBotView';
export const AvatarInfoWidgetView: FC<{}> = props =>
{
const BLOCK_MENU_WINDOW_MS = 500;
const BLOCK_ROTATE_WINDOW_MS = 500;
const [ isGameMode, setGameMode ] = useState(false);
const [ isDancing, setIsDancing ] = useState(false);
const [ isTouchLayout, setIsTouchLayout ] = useState(false);
const [ mobileFurniDetailsOpen, setMobileFurniDetailsOpen ] = useState(false);
const [ mobileUserDetailsOpen, setMobileUserDetailsOpen ] = useState(false);
const [ rentableBotChatEvent, setRentableBotChatEvent ] = useState<RoomWidgetUpdateRentableBotChatEvent>(null);
const { avatarInfo = null, setAvatarInfo = null, activeNameBubble = null, setActiveNameBubble = null, nameBubbles = [], removeNameBubble = null, productBubbles = [], confirmingProduct = null, updateConfirmingProduct = null, removeProductBubble = null, isDecorating = false, setIsDecorating = null } = useAvatarInfoWidget();
const { roomSession = null } = useRoom();
const updateAvatarClickControl = (updates: { suppressMenuUntil?: number; suppressRotateUntil?: number; }) =>
{
const globalScope = (globalThis as any);
if(!globalScope.__nitroAvatarClickControl)
{
globalScope.__nitroAvatarClickControl = {
suppressMenuUntil: 0,
suppressRotateUntil: 0
};
}
Object.assign(globalScope.__nitroAvatarClickControl, updates);
};
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.NORMAL_MODE, event =>
{
if(isGameMode) setGameMode(false);
});
useNitroEvent<RoomEngineEvent>(RoomEngineEvent.GAME_MODE, event =>
{
if(!isGameMode) setGameMode(true);
});
useEffect(() =>
{
const query = window.matchMedia('(pointer: coarse), (hover: none)');
const updateTouchLayout = () => setIsTouchLayout(query.matches);
updateTouchLayout();
query.addEventListener('change', updateTouchLayout);
return () => query.removeEventListener('change', updateTouchLayout);
}, []);
useNitroEvent<RoomSessionDanceEvent>(RoomSessionDanceEvent.RSDE_DANCE, event =>
{
if(event.roomIndex !== roomSession.ownRoomIndex) return;
setIsDancing((event.danceId !== 0));
});
useUiEvent<RoomWidgetUpdateRentableBotChatEvent>(RoomWidgetUpdateRentableBotChatEvent.UPDATE_CHAT, event => setRentableBotChatEvent(event));
useNitroEvent<RoomEngineObjectEvent>(RoomEngineObjectEvent.REQUEST_MANIPULATION, event =>
{
if(event.category !== avatarInfo?.category || event.objectId !== avatarInfo?.id) return;
setMobileFurniDetailsOpen(false);
});
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'hide':
setAvatarInfo(null);
setActiveNameBubble(null);
if(roomSession) GetRoomEngine().clearSelectedAvatar(roomSession.roomId);
return;
case 'block-menu':
updateAvatarClickControl({ suppressMenuUntil: Date.now() + BLOCK_MENU_WINDOW_MS });
setAvatarInfo(null);
setActiveNameBubble(null);
return;
case 'block-rotate':
updateAvatarClickControl({ suppressRotateUntil: Date.now() + BLOCK_ROTATE_WINDOW_MS });
return;
}
},
eventUrlPrefix: 'avatar-info/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, [ roomSession, setActiveNameBubble, setAvatarInfo ]);
useEffect(() =>
{
if(avatarInfo?.type !== AvatarInfoFurni.FURNI)
{
setMobileFurniDetailsOpen(false);
}
if(avatarInfo?.type !== AvatarInfoUser.OWN_USER && avatarInfo?.type !== AvatarInfoUser.PEER)
{
setMobileUserDetailsOpen(false);
}
}, [ avatarInfo ]);
const getMenuView = () =>
{
if(!roomSession || isGameMode) return null;
if(activeNameBubble) return <AvatarInfoWidgetNameView nameInfo={ activeNameBubble } onClose={ () => setActiveNameBubble(null) } />;
if(avatarInfo)
{
switch(avatarInfo.type)
{
case AvatarInfoFurni.FURNI: {
const info = (avatarInfo as AvatarInfoFurni);
if(!isDecorating) return null;
return <AvatarInfoWidgetFurniView avatarInfo={ info } onClose={ () => setAvatarInfo(null) } />;
}
case AvatarInfoUser.OWN_USER:
case AvatarInfoUser.PEER: {
const info = (avatarInfo as AvatarInfoUser);
if(isTouchLayout && !mobileUserDetailsOpen) return null;
if(GetConfigurationValue('user.tags.enabled')) GetSessionDataManager().getUserTags(info.roomIndex);
if(info.isSpectatorMode) return null;
if(info.isOwnUser)
{
if(RoomEnterEffect.isRunning()) return null;
return <AvatarInfoWidgetOwnAvatarView avatarInfo={ info } isDancing={ isDancing } setIsDecorating={ setIsDecorating } onClose={ () => setAvatarInfo(null) } />;
}
return <AvatarInfoWidgetAvatarView avatarInfo={ info } onClose={ () => setAvatarInfo(null) } />;
}
case AvatarInfoPet.PET_INFO: {
const info = (avatarInfo as AvatarInfoPet);
if(info.isOwner) return <AvatarInfoWidgetOwnPetView avatarInfo={ info } onClose={ () => setAvatarInfo(null) } />;
return <AvatarInfoWidgetPetView avatarInfo={ info } onClose={ () => setAvatarInfo(null) } />;
}
case AvatarInfoRentableBot.RENTABLE_BOT: {
return <AvatarInfoWidgetRentableBotView avatarInfo={ (avatarInfo as AvatarInfoRentableBot) } onClose={ () => setAvatarInfo(null) } />;
}
}
}
return null;
};
const getInfostandView = () =>
{
if(!avatarInfo) return null;
switch(avatarInfo.type)
{
case AvatarInfoFurni.FURNI:
if(isTouchLayout && !isDecorating)
{
const info = (avatarInfo as AvatarInfoFurni);
if(!mobileFurniDetailsOpen)
{
return (
<button className="nitro-mobile-furni-infostand-trigger" type="button" onClick={ () => setMobileFurniDetailsOpen(true) }>
<LayoutFurniIconImageView productType={ info.productType } productClassId={ info.spriteId } />
</button>
);
}
}
return <InfoStandWidgetFurniView avatarInfo={ (avatarInfo as AvatarInfoFurni) } onClose={ () => setAvatarInfo(null) } />;
case AvatarInfoUser.OWN_USER:
case AvatarInfoUser.PEER:
if(isTouchLayout)
{
const info = (avatarInfo as AvatarInfoUser);
const figure = encodeURIComponent(info.figure || '');
const avatarHeadUrl = `https://www.habbo.com/habbo-imaging/avatarimage?figure=${ figure }&direction=2&head_direction=2&gesture=sml&size=m&headonly=1`;
if(!mobileUserDetailsOpen)
{
return (
<button className="nitro-mobile-furni-infostand-trigger nitro-mobile-user-infostand-trigger" type="button" onClick={ () => setMobileUserDetailsOpen(true) }>
<div className="nitro-mobile-user-infostand-avatar">
<img className="nitro-mobile-user-infostand-avatar-image" src={ avatarHeadUrl } alt={ info.name } draggable={ false } />
</div>
</button>
);
}
}
return <InfoStandWidgetUserView avatarInfo={ (avatarInfo as AvatarInfoUser) } onClose={ () => setAvatarInfo(null) } />;
case AvatarInfoUser.BOT:
return <InfoStandWidgetBotView avatarInfo={ (avatarInfo as AvatarInfoUser) } onClose={ () => setAvatarInfo(null) } />;
case AvatarInfoRentableBot.RENTABLE_BOT:
return <InfoStandWidgetRentableBotView avatarInfo={ (avatarInfo as AvatarInfoRentableBot) } onClose={ () => setAvatarInfo(null) } />;
case AvatarInfoPet.PET_INFO:
return <InfoStandWidgetPetView avatarInfo={ (avatarInfo as AvatarInfoPet) } onClose={ () => setAvatarInfo(null) } />;
}
};
return (
<>
{ isDecorating &&
<AvatarInfoWidgetDecorateView roomIndex={ roomSession.ownRoomIndex } setIsDecorating={ setIsDecorating } userId={ GetSessionDataManager().userId } userName={ GetSessionDataManager().userName } /> }
{ getMenuView() }
{ avatarInfo &&
<Column alignItems="end" className="absolute right-[10px] bottom-[65px] pointer-events-none z-30 text-white">
{ getInfostandView() }
</Column> }
{ (nameBubbles.length > 0) && nameBubbles.map((name, index) => <AvatarInfoWidgetNameView key={ index } nameInfo={ name } onClose={ () => removeNameBubble(index) } />) }
{ (productBubbles.length > 0) && productBubbles.map((item, index) =>
{
return <AvatarInfoUseProductView key={ item.id } item={ item } updateConfirmingProduct={ updateConfirmingProduct } onClose={ () => removeProductBubble(index) } />;
}) }
{ rentableBotChatEvent && <AvatarInfoRentableBotChatView chatEvent={ rentableBotChatEvent } onClose={ () => setRentableBotChatEvent(null) } /> }
{ confirmingProduct && <AvatarInfoUseProductConfirmView item={ confirmingProduct } onClose={ () => updateConfirmingProduct(null) } /> }
<AvatarInfoPetTrainingPanelView />
</>
);
};