mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 15:36:18 +00:00
559d860a7b
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.
261 lines
12 KiB
TypeScript
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 />
|
|
</>
|
|
);
|
|
};
|