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. 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!" +} 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..f1fad00 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 { FC, useCallback, useEffect, useMemo, 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 maxSlots = useMemo(() => GetConfigurationValue('user.badges.max.slots', 5), []); const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); const attemptDeleteBadge = () => @@ -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/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..7b57db3 --- /dev/null +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -0,0 +1,66 @@ +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'; + +export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotificationBubbleViewProps +{ + item: NotificationBubbleItem; +} + +export const NotificationBadgeReceivedBubbleView: FC = props => +{ + const { item = null, onClose = null, ...rest } = props; + const { badgeCodes = [], toggleBadge = null } = useInventoryBadges(); + + useEffect(() => + { + 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 ( + +
e.stopPropagation() }> + + + { item.iconUrl && } + + + { LocalizeText('notification.badge.received') } + { item.message } + + + + + + { LocalizeText('notifications.button.later') } + + +
+
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx index 6a504d2..267f535 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, removeBadge = 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(() => @@ -133,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); @@ -145,15 +176,19 @@ 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 } 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 12aca46..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'; @@ -56,15 +57,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; }); }); @@ -165,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/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/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index 41e85d1..007a61c 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'; @@ -14,6 +14,7 @@ const getTimeZeroPadded = (time: number) => }; let modDisclaimerTimeout: ReturnType = null; +const recentBadgeNotifications = new Set(); const useNotificationState = () => { @@ -209,12 +210,32 @@ 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); + + showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode); }); useMessageEvent(ClubGiftNotificationEvent, event => @@ -335,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); }); 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 1932f7b..6177ad9 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -102,6 +102,30 @@ 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)' }, + }, + 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', + }, }, }, safelist: [ @@ -144,7 +168,11 @@ module.exports = { 'grid-rows-11', 'grid-rows-12', 'justify-end', - 'items-end' + 'items-end', + 'animate-pulse-glow', + 'animate-pulse-glow-red', + 'animate-drop-settle', + 'animate-pulse-glow-gold' ], darkMode: 'class', variants: {