From 73ee9c7603d4323a48110e28a477edd36d65abae Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 13:34:53 +0200 Subject: [PATCH 1/7] Badge DnD rework: fix duplicate/disappearing badges, add visual feedback Fix slot 0 drag bug ('0' is falsy), prevent badge duplication from stale props fallback in InfoStand, add sparse slot support, fix race condition with pending server updates. Add drag preview, glow animations, drop settle effect, and remove-badge indicator overlay. --- .../views/badge/InventoryBadgeItemView.tsx | 15 ++- .../views/badge/InventoryBadgeView.tsx | 52 ++++++++-- .../infostand/InfoStandBadgeSlotView.tsx | 43 +++++++-- .../infostand/InfoStandWidgetUserView.tsx | 12 ++- src/hooks/inventory/useInventoryBadges.ts | 94 ++++++++++--------- tailwind.config.js | 24 ++++- 6 files changed, 173 insertions(+), 67 deletions(-) diff --git a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx index 4bf666b..f11ca02 100644 --- a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx @@ -1,5 +1,5 @@ -import { FC, PropsWithChildren } from 'react'; -import { UnseenItemCategory } from '../../../../api'; +import { FC, PropsWithChildren, useState } from 'react'; +import { GetConfigurationValue, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks'; import { InfiniteGrid } from '../../../../layout'; @@ -10,20 +10,31 @@ export const InventoryBadgeItemView: FC const { selectedBadgeCode = null, setSelectedBadgeCode = null, toggleBadge = null, getBadgeId = null } = useInventoryBadges(); const { isUnseen = null } = useInventoryUnseenTracker(); const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode)); + const [ isDragging, setIsDragging ] = useState(false); const onDragStart = (event: React.DragEvent) => { event.dataTransfer.setData('badgeCode', badgeCode); event.dataTransfer.setData('source', 'inventory'); event.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode); + const img = new Image(); + img.src = badgeUrl; + event.dataTransfer.setDragImage(img, 20, 20); }; + const onDragEnd = () => setIsDragging(false); + return ( toggleBadge(selectedBadgeCode) } + onDragEnd={ onDragEnd } onDragStart={ onDragStart } onMouseDown={ event => setSelectedBadgeCode(badgeCode) } { ...rest }> diff --git a/src/components/inventory/views/badge/InventoryBadgeView.tsx b/src/components/inventory/views/badge/InventoryBadgeView.tsx index 1bf6d13..ea8228f 100644 --- a/src/components/inventory/views/badge/InventoryBadgeView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeView.tsx @@ -1,7 +1,7 @@ import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer'; import { FC, useCallback, useEffect, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; -import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; +import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks'; import { InfiniteGrid, NitroButton } from '../../../../layout'; @@ -18,6 +18,8 @@ const ActiveBadgeSlot: FC<{ }> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) => { const [ isDragOver, setIsDragOver ] = useState(false); + const [ isDragging, setIsDragging ] = useState(false); + const [ justDropped, setJustDropped ] = useState(false); const onDragOver = useCallback((event: React.DragEvent) => { @@ -35,24 +37,36 @@ const ActiveBadgeSlot: FC<{ const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); const sourceSlotStr = event.dataTransfer.getData('activeSlot'); - const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined; + const sourceSlot = sourceSlotStr !== '' ? parseInt(sourceSlotStr) : undefined; - if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot); + if(droppedBadgeCode) + { + onDropBadge(droppedBadgeCode, slotIndex, sourceSlot); + setJustDropped(true); + setTimeout(() => setJustDropped(false), 300); + } }, [ slotIndex, onDropBadge ]); const onDragStart = useCallback((event: React.DragEvent) => { if(!badgeCode) return; onDragStartFromSlot(event, badgeCode, slotIndex); + setIsDragging(true); }, [ badgeCode, slotIndex, onDragStartFromSlot ]); + const onDragEnd = useCallback(() => setIsDragging(false), []); + return (
= props = const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker(); const { showConfirm = null } = useNotification(); const [ isDragOverInventory, setIsDragOverInventory ] = useState(false); + const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false); const maxSlots = 5; const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); @@ -95,12 +110,10 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = { if(sourceSlot !== undefined) { - // Reorder within active badges reorderBadges(sourceSlot, slotIndex); } else { - // Drop from inventory to active slot setBadgeAtSlot(badgeCode, slotIndex); } }, [ setBadgeAtSlot, reorderBadges ]); @@ -111,6 +124,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = event.dataTransfer.setData('activeSlot', slotIndex.toString()); event.dataTransfer.setData('source', 'active'); event.dataTransfer.effectAllowed = 'move'; + + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode); + const img = new Image(); + img.src = badgeUrl; + event.dataTransfer.setDragImage(img, 20, 20); }, []); const handleRemoveBadge = useCallback((badgeCode: string) => @@ -121,18 +139,24 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = // Handle drop on inventory area (remove from active) const onInventoryDragOver = useCallback((event: React.DragEvent) => { - const source = event.dataTransfer.types.includes('activeslot') ? 'active' : ''; event.preventDefault(); event.dataTransfer.dropEffect = 'move'; + const fromActive = event.dataTransfer.types.includes('activeslot'); + setIsDraggingFromActive(fromActive); setIsDragOverInventory(true); }, []); - const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []); + const onInventoryDragLeave = useCallback(() => + { + setIsDragOverInventory(false); + setIsDraggingFromActive(false); + }, []); const onInventoryDrop = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragOverInventory(false); + setIsDraggingFromActive(false); const badgeCode = event.dataTransfer.getData('badgeCode'); const source = event.dataTransfer.getData('source'); @@ -169,10 +193,18 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = return (
+ { isDragOverInventory && isDraggingFromActive && ( +
+ + { LocalizeText('inventory.badges.clearbadge') } +
+ ) } columnCount={ 5 } estimateSize={ 50 } diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx index 6a504d2..9bda07f 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -1,6 +1,6 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { FaPlus } from 'react-icons/fa'; -import { LocalizeText } from '../../../../../api'; +import { GetConfigurationValue, LocalizeText } from '../../../../../api'; import { LayoutBadgeImageView } from '../../../../../common'; import { useInventoryBadges } from '../../../../../hooks'; @@ -14,7 +14,7 @@ interface InfoStandBadgeSlotProps const BadgeMiniPicker: FC<{ onSelect: (badgeCode: string) => void; onClose: () => void; - activeBadgeCodes: string[]; + activeBadgeCodes: (string | null)[]; }> = ({ onSelect, onClose, activeBadgeCodes }) => { const { badgeCodes = [], requestBadges = null } = useInventoryBadges(); @@ -26,7 +26,8 @@ const BadgeMiniPicker: FC<{ if(badgeCodes.length === 0) requestBadges(); }, []); - const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code)); + const activeSet = new Set(activeBadgeCodes.filter(Boolean)); + const availableBadges = badgeCodes.filter(code => !activeSet.has(code)); const filtered = search.length > 0 ? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase())) : availableBadges; @@ -78,12 +79,24 @@ const BadgeMiniPicker: FC<{ export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) => { - const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges(); + const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null, requestBadges = null } = useInventoryBadges(); const [ isDragOver, setIsDragOver ] = useState(false); + const [ isDragging, setIsDragging ] = useState(false); + const [ justDropped, setJustDropped ] = useState(false); const [ showPicker, setShowPicker ] = useState(false); - const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null; - const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null); + const hookInitialized = activeBadgeCodes.length > 0; + + // Load badge data for own user so hook is initialized before any DnD + useEffect(() => + { + if(isOwnUser && !hookInitialized) requestBadges(); + }, [ isOwnUser, hookInitialized, requestBadges ]); + const hookBadge = hookInitialized ? (activeBadgeCodes[slotIndex] ?? null) : null; + // Once hook has data, use ONLY hook data for own user (no stale props fallback) + const badgeCode = isOwnUser + ? (hookInitialized ? hookBadge : (badgeCodeFromProps ?? null)) + : (badgeCodeFromProps ?? null); const onDragStart = useCallback((event: React.DragEvent) => { @@ -91,8 +104,16 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, event.dataTransfer.setData('badgeCode', badgeCode); event.dataTransfer.setData('infostandSlot', slotIndex.toString()); event.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode); + const img = new Image(); + img.src = badgeUrl; + event.dataTransfer.setDragImage(img, 20, 20); }, [ badgeCode, slotIndex, isOwnUser ]); + const onDragEnd = useCallback(() => setIsDragging(false), []); + const onDragOver = useCallback((event: React.DragEvent) => { if(!isOwnUser) return; @@ -124,6 +145,9 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, { setBadgeAtSlot(droppedBadgeCode, slotIndex); } + + setJustDropped(true); + setTimeout(() => setJustDropped(false), 300); }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]); const handleSlotClick = useCallback(() => @@ -145,10 +169,13 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, className={ `flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center transition-all duration-150 ${ isOwnUser && badgeCode ? 'cursor-grab active:cursor-grabbing' : '' } ${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' } - ${ isOwnUser ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' } - ${ isDragOver ? 'scale-115 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15' : '' } + ${ isDragging ? 'opacity-30 scale-90' : '' } + ${ isOwnUser && !isDragging ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' } + ${ isDragOver ? 'scale-110 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15 animate-pulse-glow' : '' } + ${ justDropped ? 'animate-drop-settle' : '' } ${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` } draggable={ isOwnUser && !!badgeCode } + onDragEnd={ onDragEnd } onDragLeave={ onDragLeave } onDragOver={ onDragOver } onDragStart={ onDragStart } diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index 12aca46..62aa0b2 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -56,15 +56,23 @@ 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 === event.badges.join('')) return; + if (oldBadges === dedupedBadges.join('')) return; setAvatarInfo(prevValue => { if (!prevValue) return prevValue; const newValue = CloneObject(prevValue); - newValue.badges = event.badges; + newValue.badges = dedupedBadges; return newValue; }); }); diff --git a/src/hooks/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts index 39e0667..f456c1f 100644 --- a/src/hooks/inventory/useInventoryBadges.ts +++ b/src/hooks/inventory/useInventoryBadges.ts @@ -11,19 +11,31 @@ const useInventoryBadgesState = () => const [ needsUpdate, setNeedsUpdate ] = useState(true); const [ badgeCodes, setBadgeCodes ] = useState([]); const [ badgeIds, setBadgeIds ] = useState>(new Map()); - const [ activeBadgeCodes, setActiveBadgeCodes ] = useState([]); + const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<(string | null)[]>([]); const [ selectedBadgeCode, setSelectedBadgeCode ] = useState(null); const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility(); const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); const maxBadgeCount = GetConfigurationValue('user.badges.max.slots', 5); - const localChangeRef = useRef(false); - const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0); - const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount); + const pendingUpdatesRef = useRef(0); + const isWearingBadge = (badgeCode: string) => activeBadgeCodes.some(code => code === badgeCode); + const canWearBadges = () => (activeBadgeCodes.filter(Boolean).length < maxBadgeCount); - const sendActiveBadges = (badges: string[]) => + const toFixedSlots = (arr: (string | null)[]): (string | null)[] => { - localChangeRef.current = true; + const seen = new Set(); + return Array.from({ length: maxBadgeCount }, (_, i) => + { + const code = arr[i] || null; + if(!code || seen.has(code)) return null; + seen.add(code); + return code; + }); + }; + + const sendActiveBadges = (badges: (string | null)[]) => + { + pendingUpdatesRef.current++; const composer = new SetActivatedBadgesComposer(); for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? ''); SendMessageComposer(composer); @@ -33,24 +45,23 @@ const useInventoryBadgesState = () => { setActiveBadgeCodes(prevValue => { - const newValue = [ ...prevValue ]; - - const index = newValue.indexOf(badgeCode); + const slots = toFixedSlots(prevValue); + const index = slots.indexOf(badgeCode); if(index === -1) { - if(newValue.length >= maxBadgeCount) return prevValue; + const emptySlot = slots.indexOf(null); + if(emptySlot === -1) return prevValue; - newValue.push(badgeCode); + slots[emptySlot] = badgeCode; } else { - newValue.splice(index, 1); + slots[index] = null; } - sendActiveBadges(newValue); - - return newValue; + sendActiveBadges(slots); + return slots; }); }; @@ -82,14 +93,15 @@ const useInventoryBadgesState = () => return newValue; }); - // Skip overwriting activeBadgeCodes if we recently made a local change - if(localChangeRef.current) + // Skip overwriting activeBadgeCodes if we have pending local changes + if(pendingUpdatesRef.current > 0) { - localChangeRef.current = false; + pendingUpdatesRef.current--; } else { - setActiveBadgeCodes(parser.getActiveBadgeCodes()); + const serverBadges = parser.getActiveBadgeCodes(); + setActiveBadgeCodes(toFixedSlots(serverBadges)); } setBadgeCodes(allBadgeCodes); @@ -159,8 +171,7 @@ const useInventoryBadgesState = () => { setActiveBadgeCodes(prevValue => { - // Build a fixed-size array of maxBadgeCount slots - const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + const slots = toFixedSlots(prevValue); // Remove badge if already in another slot const existingIndex = slots.indexOf(badgeCode); @@ -169,11 +180,8 @@ const useInventoryBadgesState = () => // Place badge at target slot slots[slotIndex] = badgeCode; - // Compact: remove nulls, keep order - const result = slots.filter(Boolean) as string[]; - - sendActiveBadges(result); - return result; + sendActiveBadges(slots); + return slots; }); }; @@ -181,10 +189,14 @@ const useInventoryBadgesState = () => { setActiveBadgeCodes(prevValue => { - const result = prevValue.filter(code => code !== badgeCode); + const slots = toFixedSlots(prevValue); + const index = slots.indexOf(badgeCode); + if(index === -1) return prevValue; - sendActiveBadges(result); - return result; + slots[index] = null; + + sendActiveBadges(slots); + return slots; }); }; @@ -193,14 +205,14 @@ const useInventoryBadgesState = () => setActiveBadgeCodes(prevValue => { if(fromIndex === toIndex) return prevValue; - if(fromIndex >= prevValue.length) return prevValue; - const newValue = [ ...prevValue ]; - const [ moved ] = newValue.splice(fromIndex, 1); - newValue.splice(toIndex, 0, moved); + const slots = toFixedSlots(prevValue); + const temp = slots[fromIndex]; + slots[fromIndex] = slots[toIndex]; + slots[toIndex] = temp; - sendActiveBadges(newValue); - return newValue; + sendActiveBadges(slots); + return slots; }); }; @@ -210,19 +222,13 @@ const useInventoryBadgesState = () => { if(fromIndex === toIndex) return prevValue; - // Build fixed-size array so swap works even with empty slots - const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); - - // Swap the two slots + const slots = toFixedSlots(prevValue); const temp = slots[fromIndex]; slots[fromIndex] = slots[toIndex]; slots[toIndex] = temp; - // Compact: remove nulls, keep order - const result = slots.filter(Boolean) as string[]; - - sendActiveBadges(result); - return result; + sendActiveBadges(slots); + return slots; }); }; diff --git a/tailwind.config.js b/tailwind.config.js index 1932f7b..26a5564 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -102,6 +102,25 @@ module.exports = { dropShadow: { 'hover': '2px 2px 0 rgba(0,0,0,0.8)' }, + keyframes: { + pulseGlow: { + '0%, 100%': { boxShadow: '0 0 6px rgba(59,130,246,0.3)' }, + '50%': { boxShadow: '0 0 14px rgba(59,130,246,0.6)' }, + }, + pulseGlowRed: { + '0%, 100%': { boxShadow: '0 0 6px rgba(239,68,68,0.3)' }, + '50%': { boxShadow: '0 0 14px rgba(239,68,68,0.6)' }, + }, + dropSettle: { + '0%': { transform: 'scale(1.15)' }, + '100%': { transform: 'scale(1)' }, + }, + }, + animation: { + 'pulse-glow': 'pulseGlow 1.2s ease-in-out infinite', + 'pulse-glow-red': 'pulseGlowRed 1.2s ease-in-out infinite', + 'drop-settle': 'dropSettle 0.3s ease-out', + }, }, }, safelist: [ @@ -144,7 +163,10 @@ module.exports = { 'grid-rows-11', 'grid-rows-12', 'justify-end', - 'items-end' + 'items-end', + 'animate-pulse-glow', + 'animate-pulse-glow-red', + 'animate-drop-settle' ], darkMode: 'class', variants: { From 020db838700f60a4cd749686e9eecf0c7eee087c Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 13:55:29 +0200 Subject: [PATCH 2/7] Add golden glow for new badges and badge received toast notification New unseen badges pulse with a gold glow instead of a flat green background. When a badge is received, a bubble notification appears with the badge image, name, and a "Wear" button that opens inventory. --- .../views/bubble-layouts/GetBubbleLayout.tsx | 3 ++ .../NotificationBadgeReceivedBubbleView.tsx | 38 +++++++++++++++++++ src/hooks/notification/useNotification.ts | 11 +++++- src/layout/InfiniteGrid.tsx | 2 +- tailwind.config.js | 8 +++- 5 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx diff --git a/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx b/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx index f1298a4..f86f0f6 100644 --- a/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx +++ b/src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx @@ -1,4 +1,5 @@ import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api'; +import { NotificationBadgeReceivedBubbleView } from './NotificationBadgeReceivedBubbleView'; import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView'; import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView'; @@ -10,6 +11,8 @@ export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => voi switch(item.notificationType) { + case NotificationBubbleType.BADGE_RECEIVED: + return ; case NotificationBubbleType.CLUBGIFT: return ; default: diff --git a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx new file mode 100644 index 0000000..380c121 --- /dev/null +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -0,0 +1,38 @@ +import { FC } from 'react'; +import { LocalizeText, NotificationBubbleItem, OpenUrl } from '../../../../api'; +import { Flex, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common'; + +export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotificationBubbleViewProps +{ + item: NotificationBubbleItem; +} + +export const NotificationBadgeReceivedBubbleView: FC = props => +{ + const { item = null, onClose = null, ...rest } = props; + + return ( + + + + { item.iconUrl && } + + + { LocalizeText('notification.badge.received') } + { item.message } + + + + + + { LocalizeText('notifications.button.later') } + + + + ); +}; diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index 41e85d1..8fea817 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -1,4 +1,4 @@ -import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer'; +import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, BadgeReceivedEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer'; import { useCallback, useState } from 'react'; import { useBetween } from 'use-between'; import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api'; @@ -217,6 +217,15 @@ const useNotificationState = () => showSingleBubble((text1 + ' ' + badgeName), NotificationBubbleType.ACHIEVEMENT, badgeImage, internalLink); }); + useMessageEvent(BadgeReceivedEvent, event => + { + const parser = event.getParser(); + const badgeName = LocalizeBadgeName(parser.badgeCode); + const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode); + + showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, 'inventory/toggle'); + }); + useMessageEvent(ClubGiftNotificationEvent, event => { const parser = event.getParser(); diff --git a/src/layout/InfiniteGrid.tsx b/src/layout/InfiniteGrid.tsx index cb58e75..147ae01 100644 --- a/src/layout/InfiniteGrid.tsx +++ b/src/layout/InfiniteGrid.tsx @@ -172,7 +172,7 @@ const InfiniteGridItem = forwardRef 0)) && 'unique-item', itemUniqueSoldout && 'sold-out', - itemUnseen && ' bg-green-500 bg-opacity-40', + itemUnseen && ' animate-pulse-glow-gold border-yellow-400/60', className ) } style={ styleNames( diff --git a/tailwind.config.js b/tailwind.config.js index 26a5564..6177ad9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -115,11 +115,16 @@ module.exports = { '0%': { transform: 'scale(1.15)' }, '100%': { transform: 'scale(1)' }, }, + pulseGlowGold: { + '0%, 100%': { boxShadow: '0 0 6px rgba(255,193,7,0.4)' }, + '50%': { boxShadow: '0 0 14px rgba(255,193,7,0.7)' }, + }, }, animation: { 'pulse-glow': 'pulseGlow 1.2s ease-in-out infinite', 'pulse-glow-red': 'pulseGlowRed 1.2s ease-in-out infinite', 'drop-settle': 'dropSettle 0.3s ease-out', + 'pulse-glow-gold': 'pulseGlowGold 1.5s ease-in-out infinite', }, }, }, @@ -166,7 +171,8 @@ module.exports = { 'items-end', 'animate-pulse-glow', 'animate-pulse-glow-red', - 'animate-drop-settle' + 'animate-drop-settle', + 'animate-pulse-glow-gold' ], darkMode: 'class', variants: { From c9e746171422fdd1f640ca20647c2490282795fa Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 14:32:33 +0200 Subject: [PATCH 3/7] Dynamic badge slots from config, double-click remove, direct wear from toast Read user.badges.max.slots from config instead of hardcoded 5. InfoStand layout adapts: 5 slots shows group badge, 6 slots replaces group with 6th badge. Double-click on InfoStand badge removes it. Badge received toast now directly equips the badge via toggleBadge and closes. --- .../views/badge/InventoryBadgeView.tsx | 4 +- .../NotificationBadgeReceivedBubbleView.tsx | 14 +++- .../infostand/InfoStandBadgeSlotView.tsx | 12 +++- .../infostand/InfoStandWidgetUserView.tsx | 70 +++++++++---------- src/hooks/notification/useNotification.ts | 2 +- 5 files changed, 57 insertions(+), 45 deletions(-) diff --git a/src/components/inventory/views/badge/InventoryBadgeView.tsx b/src/components/inventory/views/badge/InventoryBadgeView.tsx index ea8228f..f1fad00 100644 --- a/src/components/inventory/views/badge/InventoryBadgeView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeView.tsx @@ -1,5 +1,5 @@ import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; @@ -89,7 +89,7 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = const [ isDragOverInventory, setIsDragOverInventory ] = useState(false); const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false); - const maxSlots = 5; + const maxSlots = useMemo(() => GetConfigurationValue('user.badges.max.slots', 5), []); const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); const attemptDeleteBadge = () => diff --git a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx index 380c121..0969c8e 100644 --- a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -1,6 +1,7 @@ import { FC } from 'react'; -import { LocalizeText, NotificationBubbleItem, OpenUrl } from '../../../../api'; +import { LocalizeText, NotificationBubbleItem } from '../../../../api'; import { Flex, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common'; +import { useInventoryBadges } from '../../../../hooks'; export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotificationBubbleViewProps { @@ -10,9 +11,16 @@ export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotifica export const NotificationBadgeReceivedBubbleView: FC = props => { const { item = null, onClose = null, ...rest } = props; + const { toggleBadge = null } = useInventoryBadges(); + + const handleWear = () => + { + if(item.linkUrl) toggleBadge(item.linkUrl); + onClose(); + }; return ( - + { item.iconUrl && } @@ -26,7 +34,7 @@ export const NotificationBadgeReceivedBubbleView: FC { OpenUrl(item.linkUrl); onClose(); } }> + onClick={ handleWear }> { LocalizeText('inventory.badges.wearbadge') } diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx index 9bda07f..267f535 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -79,7 +79,7 @@ const BadgeMiniPicker: FC<{ export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) => { - const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null, requestBadges = null } = useInventoryBadges(); + const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null, removeBadge = null, requestBadges = null } = useInventoryBadges(); const [ isDragOver, setIsDragOver ] = useState(false); const [ isDragging, setIsDragging ] = useState(false); const [ justDropped, setJustDropped ] = useState(false); @@ -157,6 +157,13 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, setShowPicker(true); }, [ isOwnUser, badgeCode ]); + const handleDoubleClick = useCallback(() => + { + if(!isOwnUser || !badgeCode) return; + + removeBadge(badgeCode); + }, [ isOwnUser, badgeCode, removeBadge ]); + const handlePickerSelect = useCallback((code: string) => { setBadgeAtSlot(code, slotIndex); @@ -180,7 +187,8 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, onDragOver={ onDragOver } onDragStart={ onDragStart } onDrop={ onDrop } - onClick={ handleSlotClick }> + onClick={ handleSlotClick } + onDoubleClick={ handleDoubleClick }> { badgeCode ? : isOwnUser && } diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index 62aa0b2..91ced43 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -1,5 +1,6 @@ +import React from 'react'; import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer'; -import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react'; +import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; @@ -173,43 +174,38 @@ export const InfoStandWidgetUserView: FC = props = /> )} - { GetConfigurationValue('user.badges.group.slot.enabled', true) - ? ( - <> -
- - 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> - {avatarInfo.groupId > 0 && - } - -
- - - + { (() => { + const maxSlots = GetConfigurationValue('user.badges.max.slots', 5); + const isOwnUser = avatarInfo.type === AvatarInfoUser.OWN_USER; + const showGroup = maxSlots <= 5; + + const items: React.ReactNode[] = []; + items.push(); + + if(showGroup) { + items.push( + 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> + {avatarInfo.groupId > 0 && } - - - - - - ) - : ( - <> - - - - - - - - - - - - - - ) - } + ); + } else { + items.push(); + } + + const startIdx = showGroup ? 1 : 2; + for(let i = startIdx; i < maxSlots; i++) { + items.push(); + } + + const rows: React.ReactNode[][] = []; + for(let i = 0; i < items.length; i += 2) { + rows.push(items.slice(i, i + 2)); + } + + return rows.map((row, idx) => ( + {row} + )); + })() }

diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index 8fea817..3195133 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -223,7 +223,7 @@ const useNotificationState = () => const badgeName = LocalizeBadgeName(parser.badgeCode); const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode); - showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, 'inventory/toggle'); + showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode); }); useMessageEvent(ClubGiftNotificationEvent, event => From 48f92e093df38426a1518ddda83a7c79729fab0b Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 14:34:44 +0200 Subject: [PATCH 4/7] Add CHANGELOG documenting badge system rework --- CHANGELOG.md | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d3e6978 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +## Badge System Rework (2026-04-04) + +### Bug Fixes +- **Slot 0 drag bug**: Dragging from slot 0 no longer causes badges to disappear. The root cause was `'0'` being falsy in JavaScript, which made the drop handler take the wrong code path and overwrite the target badge. +- **Badge duplication**: Fixed badges appearing in multiple slots when dragging in the InfoStand. The issue was a stale props fallback — after a drag operation, the hook updated correctly but the component fell back to old server props for empty slots, showing ghost copies. +- **Race condition**: Replaced single boolean `localChangeRef` with a counter (`pendingUpdatesRef`) to correctly handle rapid sequential drag operations without the server overwriting local state. +- **Badge deduplication**: `toFixedSlots()` now deduplicates badges, preventing the same badge from appearing in multiple slots even if the server returns duplicates. +- **Server badge dedup in InfoStand**: `RoomSessionUserBadgesEvent` handler now deduplicates badges from the server before updating the avatar info. + +### Drag & Drop Visual Feedback +- **Custom drag preview**: Badge image is used as the drag ghost instead of the browser default (via `setDragImage`). +- **Source opacity**: The dragged item becomes semi-transparent (`opacity-40`) during drag. +- **Pulsing glow on drop targets**: Valid drop targets pulse with a blue glow animation (`animate-pulse-glow`). +- **Drop settle animation**: A brief scale-down animation (`animate-drop-settle`, 300ms) plays when a badge lands in a slot. +- **Remove indicator**: Dragging an active badge over the inventory area shows a red pulsing background with a trash icon overlay. +- **Grab cursor**: All draggable badge elements now show `cursor-grab` / `cursor-grabbing`. + +### Sparse Slot Support +- `activeBadgeCodes` changed from compact `string[]` to fixed-size `(string | null)[]` array. Empty slots are `null` instead of being collapsed, allowing gaps between badges. +- All operations (`setBadgeAtSlot`, `removeBadge`, `reorderBadges`, `swapBadges`, `toggleBadge`) work on the fixed-size array without compaction. + +### New Badge Glow (Feature) +- Unseen (newly received) badges in the inventory now pulse with a **gold glow** (`animate-pulse-glow-gold`) instead of the previous flat green background. +- The glow disappears when the badge is selected (unseen status cleared). + +### Badge Received Toast Notification (Feature) +- When a new badge is received, a bubble notification appears with: + - Badge image and localized name + - **"Indossa" / "Wear"** button that directly equips the badge via `toggleBadge` and closes the notification + - **"Non ora" / "Later"** link to dismiss +- Auto-fades after 8 seconds (standard bubble behavior). +- Uses the existing `NotificationBubbleType.BADGE_RECEIVED` (was defined but unused). +- New component: `NotificationBadgeReceivedBubbleView`. + +### Dynamic Badge Slot Count +- Badge slot count is now fully driven by `user.badges.max.slots` config (default: 5). + - **5 slots**: 5 badge slots + group badge in InfoStand (6 boxes total) + - **6 slots**: 6 badge slots, group badge is replaced by the 6th slot +- Both the inventory grid and InfoStand layout adapt automatically. +- Removed all hardcoded `maxSlots = 5` references. + +### InfoStand Double-Click to Remove +- Double-clicking a badge in the InfoStand removes it from active badges (own user only). + +### Localization +- Added `notification.badge.received` key: + - IT: "Nuovo Distintivo!" + - EN: "New Badge!" +- Located in `public/nitro-assets/config/UITexts.json` and `UITexts_en.json`. + +### Files Modified +| File | Changes | +|------|---------| +| `src/hooks/inventory/useInventoryBadges.ts` | Sparse slots, dedup, race condition fix, toFixedSlots | +| `src/hooks/notification/useNotification.ts` | BadgeReceivedEvent listener | +| `src/components/inventory/views/badge/InventoryBadgeView.tsx` | Visual feedback, dynamic maxSlots, fix '0' falsy | +| `src/components/inventory/views/badge/InventoryBadgeItemView.tsx` | Drag preview, opacity, cursor | +| `src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx` | Visual feedback, double-click remove, no stale props | +| `src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx` | Dynamic layout, server badge dedup | +| `src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx` | BADGE_RECEIVED routing | +| `src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx` | New component | +| `src/layout/InfiniteGrid.tsx` | Gold glow for unseen items | +| `tailwind.config.js` | Custom keyframes and animations | + +### Configuration +```json +{ + "user.badges.max.slots": 5 +} +``` +Set to `6` to replace the group badge slot with a 6th badge slot. From edf4cabb8e6f2fb4c1e934f5c5c9d3786af918bd Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 14:35:21 +0200 Subject: [PATCH 5/7] Add localization files for badge notification texts New keys to merge into UITexts.json / UITexts_en.json. --- localization/badge-texts-en.json | 3 +++ localization/badge-texts-it.json | 3 +++ 2 files changed, 6 insertions(+) create mode 100644 localization/badge-texts-en.json create mode 100644 localization/badge-texts-it.json diff --git a/localization/badge-texts-en.json b/localization/badge-texts-en.json new file mode 100644 index 0000000..a8ab19a --- /dev/null +++ b/localization/badge-texts-en.json @@ -0,0 +1,3 @@ +{ + "notification.badge.received": "New Badge!" +} diff --git a/localization/badge-texts-it.json b/localization/badge-texts-it.json new file mode 100644 index 0000000..10c1271 --- /dev/null +++ b/localization/badge-texts-it.json @@ -0,0 +1,3 @@ +{ + "notification.badge.received": "Nuovo Distintivo!" +} From bc6a33a3ba8e6d2d303686216cde8b1b73cb77f9 Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 17:48:17 +0200 Subject: [PATCH 6/7] Deduplicate badge notifications from Achievement and BadgeReceived events Both events fire for the same badge, causing two notifications. Track recently notified badge codes in a Set so only the first event shows the notification bubble, the second is silently skipped. --- src/hooks/notification/useNotification.ts | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index 3195133..c13c499 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -14,6 +14,7 @@ const getTimeZeroPadded = (time: number) => }; let modDisclaimerTimeout: ReturnType = null; +const recentBadgeNotifications = new Set(); const useNotificationState = () => { @@ -209,17 +210,28 @@ const useNotificationState = () => { const parser = event.getParser(); - const text1 = LocalizeText('achievements.levelup.desc'); + // Skip if BadgeReceivedEvent already showed a notification for this badge + if(recentBadgeNotifications.has(parser.data.badgeCode)) return; + + recentBadgeNotifications.add(parser.data.badgeCode); + setTimeout(() => recentBadgeNotifications.delete(parser.data.badgeCode), 3000); + const badgeName = LocalizeBadgeName(parser.data.badgeCode); const badgeImage = GetSessionDataManager().getBadgeUrl(parser.data.badgeCode); - const internalLink = 'questengine/achievements/' + parser.data.category; - showSingleBubble((text1 + ' ' + badgeName), NotificationBubbleType.ACHIEVEMENT, badgeImage, internalLink); + showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.data.badgeCode); }); useMessageEvent(BadgeReceivedEvent, event => { const parser = event.getParser(); + + // Skip if AchievementNotificationMessageEvent already showed a notification for this badge + if(recentBadgeNotifications.has(parser.badgeCode)) return; + + recentBadgeNotifications.add(parser.badgeCode); + setTimeout(() => recentBadgeNotifications.delete(parser.badgeCode), 3000); + const badgeName = LocalizeBadgeName(parser.badgeCode); const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode); From 0b033742d0f8750d398d48af7e25c4c3ed9c6bbc Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 18:45:19 +0200 Subject: [PATCH 7/7] Fix badge notification: single bubble, working Wear button Block NotificationDialogMessageEvent badge types to prevent duplicate bubbles. Wrap bubble content in stopPropagation div so button clicks don't close the notification before toggleBadge runs. Request badge data on mount so Wear works even if inventory was never opened. --- .../NotificationBadgeReceivedBubbleView.tsx | 68 ++++++++++++------- src/hooks/notification/useNotification.ts | 3 + 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx index 0969c8e..7b57db3 100644 --- a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -1,5 +1,6 @@ -import { FC } from 'react'; -import { LocalizeText, NotificationBubbleItem } from '../../../../api'; +import { RequestBadgesComposer } from '@nitrots/nitro-renderer'; +import { FC, useEffect } from 'react'; +import { LocalizeText, NotificationBubbleItem, SendMessageComposer } from '../../../../api'; import { Flex, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common'; import { useInventoryBadges } from '../../../../hooks'; @@ -11,36 +12,55 @@ export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotifica export const NotificationBadgeReceivedBubbleView: FC = props => { const { item = null, onClose = null, ...rest } = props; - const { toggleBadge = null } = useInventoryBadges(); + const { badgeCodes = [], toggleBadge = null } = useInventoryBadges(); - const handleWear = () => + useEffect(() => { - if(item.linkUrl) toggleBadge(item.linkUrl); - onClose(); + if(badgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer()); + }, []); + + const handleWear = (event: React.MouseEvent) => + { + event.stopPropagation(); + + if(item.linkUrl) + { + toggleBadge(item.linkUrl); + } + + if(onClose) onClose(); + }; + + const handleDismiss = (event: React.MouseEvent) => + { + event.stopPropagation(); + if(onClose) onClose(); }; return ( - - - { item.iconUrl && } +
e.stopPropagation() }> + + + { item.iconUrl && } + + + { LocalizeText('notification.badge.received') } + { item.message } + - - { LocalizeText('notification.badge.received') } - { item.message } + + + + { LocalizeText('notifications.button.later') } + - - - - - { LocalizeText('notifications.button.later') } - - +
); }; diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index c13c499..007a61c 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -356,6 +356,9 @@ const useNotificationState = () => { const parser = event.getParser(); + // Skip badge notifications — handled by BadgeReceivedEvent with "Wear" button + if(parser.type === 'badge_received' || parser.type === 'badges' || parser.type.includes('badge')) return; + showNotification(parser.type, parser.parameters); });