🆙 Added badge loading system

This commit is contained in:
duckietm 2025-05-02 10:32:06 +02:00
parent a59c093e04
commit 313c814b1e
10 changed files with 357 additions and 86 deletions

View File

@ -21,7 +21,7 @@ $toolbar-height: 55px;
$achievement-width: 375px;
$achievement-height: 405px;
$avatar-editor-width: 545px;
$avatar-editor-width: 520px;
$avatar-editor-height: 553px;
$backgrounds-width: 534px;

View File

@ -1,5 +1,6 @@
import { ConfigurationEvent, GetAssetManager, HabboWebTools, LegacyExternalInterface, Nitro, NitroCommunicationDemoEvent, NitroConfiguration, NitroEvent, NitroLocalizationEvent, NitroVersion, RoomEngineEvent } from '@nitrots/nitro-renderer';
import { AnimatePresence, motion } from 'framer-motion';
import { BadgeProvider } from './common/layout/BadgeContext';
import { Base } from './common';
import { FC, useCallback, useEffect, useState } from 'react';
import { GetCommunication, GetConfiguration, GetNitroInstance, GetUIVersion } from './api';
@ -7,6 +8,7 @@ import { LoadingView } from './components/loading/LoadingView';
import { MainView } from './components/main/MainView';
import { useConfigurationEvent, useLocalizationEvent, useMainEvent, useRoomEngineEvent } from './hooks';
NitroVersion.UI_VERSION = GetUIVersion();
export const App: FC<{}> = props =>
@ -131,15 +133,18 @@ export const App: FC<{}> = props =>
return (
<Base fit overflow="hidden" className={ imageRendering && 'image-rendering-pixelated' }>
{ (!isReady || isError) &&
<LoadingView isError={ isError } message={ message } percent={ percent } /> }
<AnimatePresence>
{ isReady && ( <motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }}>
<MainView />
</motion.div>
)}
</AnimatePresence>
<Base id="draggable-windows-container" />
<BadgeProvider>
{ (!isReady || isError) &&
<LoadingView isError={ isError } message={ message } percent={ percent } /> }
<AnimatePresence>
{ isReady && (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.3 }}>
<MainView />
</motion.div>
)}
</AnimatePresence>
<Base id="draggable-windows-container" />
</BadgeProvider>
</Base>
);
}
}

View File

@ -0,0 +1,198 @@
import { BadgeImageReadyEvent, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer';
import { createContext, FC, useContext, useEffect, useState } from 'react';
import { GetSessionDataManager } from '../../api';
interface BadgeContextType
{
badgeImages: Map<string, HTMLImageElement>;
requestBadge: (badgeCode: string, isGroup: boolean) => Promise<HTMLImageElement>;
updateBadgeImage: (badgeCode: string, image: HTMLImageElement) => void;
}
const BadgeContext = createContext<BadgeContextType>({
badgeImages: new Map(),
requestBadge: async () =>
{
console.warn('BadgeContext: Default requestBadge called - BadgeProvider not initialized');
throw new Error('BadgeProvider not initialized - ensure BadgeProvider is wrapped around the app');
},
updateBadgeImage: () =>
{
console.warn('BadgeContext: Default updateBadgeImage called - BadgeProvider not initialized');
throw new Error('BadgeProvider not initialized - ensure BadgeProvider is wrapped around the app');
},
});
export const BadgeProvider: FC<{}> = ({ children }) =>
{
const [ badgeImages, setBadgeImages ] = useState<Map<string, HTMLImageElement>>(new Map());
console.log('BadgeProvider: Initialized');
const requestBadge = async (badgeCode: string, isGroup: boolean): Promise<HTMLImageElement> =>
{
console.log('BadgeProvider: requestBadge called', { badgeCode, isGroup });
if(!badgeCode || !badgeCode.length)
{
console.warn('BadgeProvider: Invalid or empty badgeCode', badgeCode);
return null;
}
// Check if badge is already cached
const cachedImage = badgeImages.get(badgeCode);
if(cachedImage)
{
console.log('BadgeProvider: Badge loaded from cache', { badgeCode });
return cachedImage;
}
const maxRetries = 3;
let retryCount = 0;
while(retryCount < maxRetries)
{
try
{
console.log('BadgeProvider: Attempting to load badge', { badgeCode, retryCount, isGroup });
// Check if GetSessionDataManager is ready
if(!GetSessionDataManager().events)
{
console.warn('BadgeProvider: GetSessionDataManager events not available', { badgeCode, isGroup });
return null;
}
// Check if badge is already in session data
let texture = isGroup ? GetSessionDataManager().getGroupBadgeImage(badgeCode) : GetSessionDataManager().getBadgeImage(badgeCode);
if(!texture)
{
console.log('BadgeProvider: Badge not in cache, requesting', { badgeCode, isGroup });
// Create a Promise to wait for the BadgeImageReadyEvent
const badgePromise = new Promise<Texture<Resource>>((resolve, reject) =>
{
console.log('BadgeProvider: Setting up BadgeImageReadyEvent listener', { badgeCode, isGroup });
const onBadgeImageReadyEvent = (event: BadgeImageReadyEvent) =>
{
console.log('BadgeProvider: BadgeImageReadyEvent received', { badgeCode, eventBadgeId: event.badgeId, isGroup });
if(event.badgeId === badgeCode)
{
resolve(event.image);
GetSessionDataManager().events.removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
}
};
GetSessionDataManager().events.addEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
// Timeout after 20 seconds to avoid hanging
setTimeout(() =>
{
console.warn('BadgeProvider: Badge loading timed out', { badgeCode, retryCount, isGroup });
GetSessionDataManager().events.removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
reject(new Error('Badge loading timed out'));
}, 20000);
});
// Request the badge image
console.log('BadgeProvider: Requesting badge image', { badgeCode, isGroup });
if(isGroup)
{
GetSessionDataManager().requestGroupBadgeImage(badgeCode);
}
else
{
GetSessionDataManager().requestBadgeImage(badgeCode);
}
// Wait for the badge image to be ready
texture = await badgePromise;
console.log('BadgeProvider: Badge image received via event', { badgeCode, texture, isGroup });
}
else
{
console.log('BadgeProvider: Badge found in session data', { badgeCode, isGroup });
}
if(texture)
{
try
{
console.log('BadgeProvider: Generating image from texture', { badgeCode, isGroup });
const sprite = new NitroSprite(texture);
const element = await TextureUtils.generateImage(sprite);
if(element && element.src && element.src.startsWith('data:image/'))
{
setBadgeImages(prev =>
{
const newMap = new Map(prev);
newMap.set(badgeCode, element);
return newMap;
});
console.log('BadgeProvider: Badge loaded and cached', { badgeCode, isGroup });
return element;
}
else
{
console.warn('BadgeProvider: Invalid badge image', { badgeCode, element, isGroup });
}
}
catch(error)
{
console.warn('BadgeProvider: Error generating badge image', { error: error.message, badgeCode, isGroup });
}
}
else
{
console.warn('BadgeProvider: Failed to load badge image', { badgeCode, isGroup });
}
}
catch(error)
{
console.error('BadgeProvider: Error loading badge', { error: error.message, badgeCode, retryCount, isGroup });
}
retryCount++;
if(retryCount < maxRetries)
{
console.log('BadgeProvider: Retrying badge load', { badgeCode, retryCount, isGroup });
await new Promise(resolve => setTimeout(resolve, 2000)); // Wait 2 seconds before retrying
}
}
console.warn('BadgeProvider: Max retries reached, returning null', { badgeCode, maxRetries, isGroup });
return null;
};
const updateBadgeImage = (badgeCode: string, image: HTMLImageElement) =>
{
if(!badgeCode || !image) return;
console.log('BadgeProvider: Updating badge image in context', { badgeCode });
setBadgeImages(prev =>
{
const newMap = new Map(prev);
newMap.set(badgeCode, image);
return newMap;
});
};
return (
<BadgeContext.Provider value={ { badgeImages, requestBadge, updateBadgeImage } }>
{ children }
</BadgeContext.Provider>
);
};
export const useBadgeContext = () => {
const context = useContext(BadgeContext);
if(!context.requestBadge || !context.updateBadgeImage || context.requestBadge.toString().includes('Default requestBadge')) {
throw new Error('BadgeContext not initialized - ensure BadgeProvider is wrapped around the app');
}
return context;
};

View File

@ -1,7 +1,8 @@
import { BadgeImageReadyEvent, NitroSprite, TextureUtils } from '@nitrots/nitro-renderer';
import { CSSProperties, FC, useEffect, useMemo, useState } from 'react';
import { GetConfiguration, GetSessionDataManager, LocalizeBadgeDescription, LocalizeBadgeName, LocalizeText } from '../../api';
import { Base, BaseProps } from '../Base';
import { NitroSprite, TextureUtils } from '@nitrots/nitro-renderer';
import { useBadgeContext } from './BadgeContext';
export interface LayoutBadgeImageViewProps extends BaseProps<HTMLDivElement>
{
@ -17,6 +18,13 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
{
const { badgeCode = null, isGroup = false, showInfo = false, customTitle = null, isGrayscale = false, scale = 1, classNames = [], style = {}, children = null, ...rest } = props;
const [ imageElement, setImageElement ] = useState<HTMLImageElement>(null);
const [ isLoading, setIsLoading ] = useState(true);
const [ retryCount, setRetryCount ] = useState(0);
const maxRetries = 5; // Maximum number of retries
const retryInterval = 2000; // Retry every 2 seconds
const { badgeImages, requestBadge, updateBadgeImage } = useBadgeContext();
console.log('LayoutBadgeImageView: Rendered', { badgeCode, isGroup, badgeImagesSize: badgeImages.size });
const getClassNames = useMemo(() =>
{
@ -38,15 +46,16 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
const badgeUrl = isGroup ? imageElement.src : GetConfiguration<string>('badge.asset.url', '').replace('%badgename%', badgeCode.toString());
newStyle.backgroundImage = `url(${ badgeUrl })`;
newStyle.width = imageElement.width;
newStyle.height = imageElement.height;
// Remove inline width and height to let SCSS control the size
// newStyle.width = imageElement.width;
// newStyle.height = imageElement.height;
if(scale !== 1)
{
newStyle.transform = `scale(${ scale })`;
if(!(scale % 1)) newStyle.imageRendering = 'pixelated';
newStyle.width = (imageElement.width * scale);
newStyle.height = (imageElement.height * scale);
// If scaling, adjust the dimensions in SCSS instead
}
}
@ -57,80 +66,103 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
useEffect(() =>
{
console.log('LayoutBadgeImageView: useEffect triggered', { badgeCode, isGroup, retryCount });
if(!badgeCode || !badgeCode.length)
{
console.warn('LayoutBadgeImageView: Invalid or empty badgeCode', badgeCode);
setImageElement(null);
setIsLoading(false);
return;
}
let didSetBadge = false;
const onBadgeImageReadyEvent = async (event: BadgeImageReadyEvent) =>
const loadBadgeImage = async () =>
{
if(event.badgeId !== badgeCode) return;
console.log('LayoutBadgeImageView: loadBadgeImage started', { badgeCode, retryCount, isGroup });
setIsLoading(true);
try
{
const sprite = new NitroSprite(event.image);
const element = await TextureUtils.generateImage(sprite);
// Check if badge is already in context
const cachedImage = badgeImages.get(badgeCode);
if(cachedImage)
{
setImageElement(cachedImage);
setIsLoading(false);
console.log('LayoutBadgeImageView: Badge loaded from context', { badgeCode, isGroup });
return;
}
if(element && element.src && element.src.startsWith('data:image/'))
console.log('LayoutBadgeImageView: Requesting badge via context', { badgeCode, isGroup });
// Request the badge image via the context
const element = await requestBadge(badgeCode, isGroup);
if(element)
{
setImageElement(element);
didSetBadge = true;
console.log('LayoutBadgeImageView: Badge loaded via request', { badgeCode, isGroup });
}
else
{
console.warn('LayoutBadgeImageView: Invalid badge image (event)', element);
console.warn('LayoutBadgeImageView: Failed to load badge image via context, attempting direct fetch', { badgeCode, isGroup });
// Fallback: Try fetching directly from session data
let texture = isGroup ? GetSessionDataManager().getGroupBadgeImage(badgeCode) : GetSessionDataManager().getBadgeImage(badgeCode);
if(texture)
{
const sprite = new NitroSprite(texture);
const fallbackElement = await TextureUtils.generateImage(sprite);
if(fallbackElement && fallbackElement.src && fallbackElement.src.startsWith('data:image/'))
{
setImageElement(fallbackElement);
updateBadgeImage(badgeCode, fallbackElement);
console.log('LayoutBadgeImageView: Badge loaded via direct fetch and cached in context', { badgeCode, isGroup });
}
else
{
console.warn('LayoutBadgeImageView: Invalid badge image from direct fetch', { badgeCode, isGroup });
}
}
else if(retryCount < maxRetries)
{
console.log('LayoutBadgeImageView: Retrying badge load', { badgeCode, retryCount, isGroup });
setTimeout(() =>
{
setRetryCount(prev => prev + 1);
}, retryInterval);
return;
}
else
{
console.warn('LayoutBadgeImageView: Max retries reached, failed to load badge', { badgeCode, maxRetries, isGroup });
}
}
}
catch(error)
{
console.warn('LayoutBadgeImageView: Error generating badge image (event)', error);
console.error('LayoutBadgeImageView: Error loading badge', { error: error.message, badgeCode, isGroup });
}
GetSessionDataManager().events.removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
};
GetSessionDataManager().events.addEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
const loadBadgeImage = async () =>
{
const texture = isGroup ? GetSessionDataManager().getGroupBadgeImage(badgeCode) : GetSessionDataManager().getBadgeImage(badgeCode);
if(texture && !didSetBadge)
finally
{
try
{
const sprite = new NitroSprite(texture);
const element = await TextureUtils.generateImage(sprite);
if(element && element.src && element.src.startsWith('data:image/'))
{
setImageElement(element);
}
else
{
console.warn('LayoutBadgeImageView: Invalid badge image (direct)', element);
}
}
catch(error)
{
console.warn('LayoutBadgeImageView: Error generating badge image (direct)', error);
}
}
else
{
console.log('LayoutBadgeImageView: No texture found for badge', badgeCode);
setIsLoading(false);
console.log('LayoutBadgeImageView: loadBadgeImage completed', { badgeCode, isLoading: false, isGroup });
}
};
loadBadgeImage();
}, [ badgeCode, isGroup, badgeImages, requestBadge, retryCount, updateBadgeImage ]);
return () => GetSessionDataManager().events.removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
}, [ badgeCode, isGroup ]);
if(isLoading)
{
console.log('LayoutBadgeImageView: Rendering loading state', { badgeCode, isGroup });
return null; // Optionally render a loading placeholder
}
console.log('LayoutBadgeImageView: Rendering badge', { badgeCode, hasImage: !!imageElement, isGroup });
return (
<Base classNames={ getClassNames } style={ getStyle } { ...rest }>

View File

@ -1,3 +1,4 @@
export * from './BadgeContext';
export * from './LayoutAvatarImageView';
export * from './LayoutBackgroundImage';
export * from './LayoutBadgeImageView';
@ -24,4 +25,4 @@ export * from './LayoutTrophyView';
export * from "./LayoutSearchSavesView";
export * from './limited-edition';
export * from './RoomCreatorGridItem';
export * from './UserProfileIconView';
export * from './UserProfileIconView';

View File

@ -26,6 +26,7 @@ export const AvatarEditorFigureSetItemView: FC<AvatarEditorFigureSetItemViewProp
if(!partItem)
{
console.warn(`AvatarEditorFigureSetItemView: Part item missing`, partItem);
setImageUrl(null);
setIsValid(false);
return;
@ -51,13 +52,16 @@ export const AvatarEditorFigureSetItemView: FC<AvatarEditorFigureSetItemViewProp
if(!resolvedImageUrl || !resolvedImageUrl.startsWith('data:image/'))
{
console.warn(`AvatarEditorFigureSetItemView: Invalid or missing imageUrl for item ${partItem.id}`, { resolvedImageUrl, type: typeof resolvedImageUrl });
if(retryCount.current < maxRetries)
{
retryCount.current += 1;
console.log(`AvatarEditorFigureSetItemView: Retrying load for item ${partItem.id} (retry: ${retryCount.current}/${maxRetries})`);
setTimeout(loadPartImage, 1500);
}
else
{
console.log(`AvatarEditorFigureSetItemView: Max retries reached, skipping item ${partItem.id}`);
setImageUrl(null);
setIsValid(false);
}

View File

@ -86,8 +86,11 @@
}
.badge-image {
width: 45px;
height: 45px;
max-width: 45px;
max-height: 45px;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.motto-content {

View File

@ -1,5 +1,5 @@
import { ExtendedProfileChangedMessageEvent, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomEngineObjectEvent, RoomObjectCategory, RoomObjectType, UserCurrentBadgesComposer, UserCurrentBadgesEvent, UserProfileEvent, UserProfileParser, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
import { FC, useState } from 'react';
import { FC, useEffect, useState } from 'react';
import { CreateLinkEvent, GetRoomSession, GetSessionDataManager, GetUserProfile, LocalizeText, SendMessageComposer } from '../../api';
import { Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useMessageEvent, useRoomEngineEvent } from '../../hooks';
@ -8,17 +8,28 @@ import { FriendsContainerView } from './views/FriendsContainerView';
import { GroupsContainerView } from './views/GroupsContainerView';
import { UserContainerView } from './views/UserContainerView';
let currentUserId: number | null = null;
const handleUserCurrentBadgesEvent = (event: UserCurrentBadgesEvent, setUserBadges: (badges: string[]) => void) => {
const parser = event.getParser();
if (currentUserId === null || parser.userId !== currentUserId) return;
setUserBadges(parser.badges);
};
export const UserProfileView: FC<{}> = props =>
{
const [ userProfile, setUserProfile ] = useState<UserProfileParser>(null);
const [ userBadges, setUserBadges ] = useState<string[]>([]);
const [ userBadges, setUserBadges ] = useState<string[]>(null);
const [ userRelationships, setUserRelationships ] = useState<RelationshipStatusInfoMessageParser>(null);
const onClose = () =>
{
setUserProfile(null);
setUserBadges([]);
setUserBadges(null);
setUserRelationships(null);
currentUserId = null;
}
const onLeaveGroup = () =>
@ -28,13 +39,8 @@ export const UserProfileView: FC<{}> = props =>
GetUserProfile(userProfile.id);
}
useMessageEvent<UserCurrentBadgesEvent>(UserCurrentBadgesEvent, event =>
{
const parser = event.getParser();
if(!userProfile || (parser.userId !== userProfile.id)) return;
setUserBadges(parser.badges);
useMessageEvent<UserCurrentBadgesEvent>(UserCurrentBadgesEvent, event => {
handleUserCurrentBadgesEvent(event, setUserBadges);
});
useMessageEvent<RelationshipStatusInfoEvent>(RelationshipStatusInfoEvent, event =>
@ -61,10 +67,12 @@ export const UserProfileView: FC<{}> = props =>
if(!isSameProfile)
{
setUserBadges([]);
setUserBadges(null);
setUserRelationships(null);
}
currentUserId = parser.id;
SendMessageComposer(new UserCurrentBadgesComposer(parser.id));
SendMessageComposer(new UserRelationshipsComposer(parser.id));
});
@ -73,6 +81,8 @@ export const UserProfileView: FC<{}> = props =>
{
const parser = event.getParser();
console.log('UserProfileView: Received ExtendedProfileChangedMessageEvent', { userId: parser.userId, currentUserId });
if(parser.userId != userProfile?.id) return;
GetUserProfile(parser.userId);
@ -91,17 +101,27 @@ export const UserProfileView: FC<{}> = props =>
GetUserProfile(userData.webID);
});
useEffect(() => {
return () => {
currentUserId = null;
};
}, []);
if(!userProfile) return null;
return (
<NitroCardView uniqueKey="nitro-user-profile" theme="primary-slim" className="user-profile">
<NitroCardHeaderView headerText={ LocalizeText('extendedprofile.caption') } onCloseClick={ onClose } />
<NitroCardContentView overflow="hidden">
<Grid fullHeight={ false } gap={ 2 }>
<Grid fullHeight={ false } gap={ 2 } style={{ minHeight: '200px' }}>
<Column size={ 7 } gap={ 1 } className="user-container pe-2">
<UserContainerView userProfile={ userProfile } />
<Grid columnCount={ 5 } fullHeight className="bg-muted rounded px-2 py-1">
<BadgesContainerView fullWidth center badges={ userBadges } />
<Grid fullHeight className="bg-muted rounded px-2 py-1" style={{ minHeight: '70px' }}>
{ userBadges === null ? (
<Text>Loading badges...</Text>
) : (
<BadgesContainerView key={ userBadges.join('-') } fullWidth center badges={ userBadges } />
) }
</Grid>
</Column>
<Column size={ 5 }>
@ -119,4 +139,4 @@ export const UserProfileView: FC<{}> = props =>
</NitroCardContentView>
</NitroCardView>
)
}
}

View File

@ -1,5 +1,5 @@
import { FC } from 'react';
import { Column, FlexProps, LayoutBadgeImageView } from '../../../common';
import { Flex, FlexProps, LayoutBadgeImageView } from '../../../common';
interface BadgesContainerViewProps extends FlexProps
{
@ -10,16 +10,23 @@ export const BadgesContainerView: FC<BadgesContainerViewProps> = props =>
{
const { badges = null, gap = 1, justifyContent = 'between', ...rest } = props;
const isGroupBadge = (badgeCode: string): boolean =>
{
return badgeCode && badgeCode.startsWith('b') && badgeCode.length > 10; // Example heuristic
};
return (
<>
<Flex gap={ 2 } alignItems="center" {...rest}>
{ badges && (badges.length > 0) && badges.map((badge, index) =>
{
const isGroup = isGroupBadge(badge);
return (
<Column key={ badge } center>
<LayoutBadgeImageView key={ badge } badgeCode={ badge } />
</Column>
<div key={ badge } style={{ maxWidth: 45, maxHeight: 45 }}>
<LayoutBadgeImageView badgeCode={ badge } isGroup={ isGroup } />
</div>
);
}) }
</>
</Flex>
);
}
}

View File

@ -28,6 +28,7 @@
"@pixi/canvas-display": "~7.4.3",
"@pixi/canvas-extract": "~7.4.3",
"@pixi/canvas-renderer": "~7.4.3",
"@pixi/filter-color-matrix": "~7.4.3",
"@pixi/constants": "~7.4.3",
"@pixi/core": "~7.4.3",
"@pixi/display": "~7.4.3",