From 313c814b1ebb6587482673c54edd1ae19d5c000d Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 2 May 2025 10:32:06 +0200 Subject: [PATCH] :up: Added badge loading system --- src/App.scss | 2 +- src/App.tsx | 25 ++- src/common/layout/BadgeContext.tsx | 198 ++++++++++++++++++ src/common/layout/LayoutBadgeImageView.tsx | 132 +++++++----- src/common/layout/index.ts | 3 +- .../AvatarEditorFigureSetItemView.tsx | 4 + .../avatar-info/AvatarInfoWidgetView.scss | 7 +- .../user-profile/UserProfileView.tsx | 50 +++-- .../views/BadgesContainerView.tsx | 21 +- submodules/renderer/package.json | 1 + 10 files changed, 357 insertions(+), 86 deletions(-) create mode 100644 src/common/layout/BadgeContext.tsx diff --git a/src/App.scss b/src/App.scss index 0563da2..9b05e7b 100644 --- a/src/App.scss +++ b/src/App.scss @@ -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; diff --git a/src/App.tsx b/src/App.tsx index e242393..80e2084 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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 ( - { (!isReady || isError) && - } - - { isReady && ( - - - )} - - + + { (!isReady || isError) && + } + + { isReady && ( + + + + )} + + + ); -} +} \ No newline at end of file diff --git a/src/common/layout/BadgeContext.tsx b/src/common/layout/BadgeContext.tsx new file mode 100644 index 0000000..d156f9b --- /dev/null +++ b/src/common/layout/BadgeContext.tsx @@ -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; + requestBadge: (badgeCode: string, isGroup: boolean) => Promise; + updateBadgeImage: (badgeCode: string, image: HTMLImageElement) => void; +} + +const BadgeContext = createContext({ + 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>(new Map()); + + console.log('BadgeProvider: Initialized'); + + const requestBadge = async (badgeCode: string, isGroup: boolean): Promise => + { + 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>((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 ( + + { children } + + ); +}; + +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; +}; \ No newline at end of file diff --git a/src/common/layout/LayoutBadgeImageView.tsx b/src/common/layout/LayoutBadgeImageView.tsx index 11a230f..5f53e15 100644 --- a/src/common/layout/LayoutBadgeImageView.tsx +++ b/src/common/layout/LayoutBadgeImageView.tsx @@ -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 { @@ -17,6 +18,13 @@ export const LayoutBadgeImageView: FC = props => { const { badgeCode = null, isGroup = false, showInfo = false, customTitle = null, isGrayscale = false, scale = 1, classNames = [], style = {}, children = null, ...rest } = props; const [ imageElement, setImageElement ] = useState(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 = props => const badgeUrl = isGroup ? imageElement.src : GetConfiguration('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 = 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 ( diff --git a/src/common/layout/index.ts b/src/common/layout/index.ts index d17d547..1cfdb5d 100644 --- a/src/common/layout/index.ts +++ b/src/common/layout/index.ts @@ -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'; \ No newline at end of file diff --git a/src/components/avatar-editor/views/figure-set/AvatarEditorFigureSetItemView.tsx b/src/components/avatar-editor/views/figure-set/AvatarEditorFigureSetItemView.tsx index c433244..d26d890 100644 --- a/src/components/avatar-editor/views/figure-set/AvatarEditorFigureSetItemView.tsx +++ b/src/components/avatar-editor/views/figure-set/AvatarEditorFigureSetItemView.tsx @@ -26,6 +26,7 @@ export const AvatarEditorFigureSetItemView: FC void) => { + const parser = event.getParser(); + + if (currentUserId === null || parser.userId !== currentUserId) return; + + setUserBadges(parser.badges); +}; + export const UserProfileView: FC<{}> = props => { const [ userProfile, setUserProfile ] = useState(null); - const [ userBadges, setUserBadges ] = useState([]); + const [ userBadges, setUserBadges ] = useState(null); const [ userRelationships, setUserRelationships ] = useState(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, event => - { - const parser = event.getParser(); - - if(!userProfile || (parser.userId !== userProfile.id)) return; - - setUserBadges(parser.badges); + useMessageEvent(UserCurrentBadgesEvent, event => { + handleUserCurrentBadgesEvent(event, setUserBadges); }); useMessageEvent(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 ( - + - - + + { userBadges === null ? ( + Loading badges... + ) : ( + + ) } @@ -119,4 +139,4 @@ export const UserProfileView: FC<{}> = props => ) -} +} \ No newline at end of file diff --git a/src/components/user-profile/views/BadgesContainerView.tsx b/src/components/user-profile/views/BadgesContainerView.tsx index ca59fc2..35ba8a2 100644 --- a/src/components/user-profile/views/BadgesContainerView.tsx +++ b/src/components/user-profile/views/BadgesContainerView.tsx @@ -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 = 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 ( - <> + { badges && (badges.length > 0) && badges.map((badge, index) => { + const isGroup = isGroupBadge(badge); + return ( - - - +
+ +
); }) } - +
); -} +} \ No newline at end of file diff --git a/submodules/renderer/package.json b/submodules/renderer/package.json index 0374f03..965d196 100644 --- a/submodules/renderer/package.json +++ b/submodules/renderer/package.json @@ -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",