mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-19 15:06:20 +00:00
Merge pull request #78 from simoleo89/badge-dnd-rework
Badge System Rework: DnD fixes, visual feedback, dynamic slots
This commit is contained in:
@@ -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.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"notification.badge.received": "New Badge!"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"notification.badge.received": "Nuovo Distintivo!"
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, PropsWithChildren } from 'react';
|
import { FC, PropsWithChildren, useState } from 'react';
|
||||||
import { UnseenItemCategory } from '../../../../api';
|
import { GetConfigurationValue, UnseenItemCategory } from '../../../../api';
|
||||||
import { LayoutBadgeImageView } from '../../../../common';
|
import { LayoutBadgeImageView } from '../../../../common';
|
||||||
import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks';
|
import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks';
|
||||||
import { InfiniteGrid } from '../../../../layout';
|
import { InfiniteGrid } from '../../../../layout';
|
||||||
@@ -10,20 +10,31 @@ export const InventoryBadgeItemView: FC<PropsWithChildren<{ badgeCode: string }>
|
|||||||
const { selectedBadgeCode = null, setSelectedBadgeCode = null, toggleBadge = null, getBadgeId = null } = useInventoryBadges();
|
const { selectedBadgeCode = null, setSelectedBadgeCode = null, toggleBadge = null, getBadgeId = null } = useInventoryBadges();
|
||||||
const { isUnseen = null } = useInventoryUnseenTracker();
|
const { isUnseen = null } = useInventoryUnseenTracker();
|
||||||
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
|
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
|
||||||
|
const [ isDragging, setIsDragging ] = useState(false);
|
||||||
|
|
||||||
const onDragStart = (event: React.DragEvent<HTMLDivElement>) =>
|
const onDragStart = (event: React.DragEvent<HTMLDivElement>) =>
|
||||||
{
|
{
|
||||||
event.dataTransfer.setData('badgeCode', badgeCode);
|
event.dataTransfer.setData('badgeCode', badgeCode);
|
||||||
event.dataTransfer.setData('source', 'inventory');
|
event.dataTransfer.setData('source', 'inventory');
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
|
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
|
||||||
|
const img = new Image();
|
||||||
|
img.src = badgeUrl;
|
||||||
|
event.dataTransfer.setDragImage(img, 20, 20);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onDragEnd = () => setIsDragging(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<InfiniteGrid.Item
|
<InfiniteGrid.Item
|
||||||
draggable
|
draggable
|
||||||
|
className={ `cursor-grab active:cursor-grabbing ${ isDragging ? 'opacity-40 scale-95' : '' }` }
|
||||||
itemActive={ (selectedBadgeCode === badgeCode) }
|
itemActive={ (selectedBadgeCode === badgeCode) }
|
||||||
itemUnseen={ unseen }
|
itemUnseen={ unseen }
|
||||||
onDoubleClick={ event => toggleBadge(selectedBadgeCode) }
|
onDoubleClick={ event => toggleBadge(selectedBadgeCode) }
|
||||||
|
onDragEnd={ onDragEnd }
|
||||||
onDragStart={ onDragStart }
|
onDragStart={ onDragStart }
|
||||||
onMouseDown={ event => setSelectedBadgeCode(badgeCode) }
|
onMouseDown={ event => setSelectedBadgeCode(badgeCode) }
|
||||||
{ ...rest }>
|
{ ...rest }>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
|
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 { 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 { LayoutBadgeImageView } from '../../../../common';
|
||||||
import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks';
|
import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks';
|
||||||
import { InfiniteGrid, NitroButton } from '../../../../layout';
|
import { InfiniteGrid, NitroButton } from '../../../../layout';
|
||||||
@@ -18,6 +18,8 @@ const ActiveBadgeSlot: FC<{
|
|||||||
}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) =>
|
}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) =>
|
||||||
{
|
{
|
||||||
const [ isDragOver, setIsDragOver ] = useState(false);
|
const [ isDragOver, setIsDragOver ] = useState(false);
|
||||||
|
const [ isDragging, setIsDragging ] = useState(false);
|
||||||
|
const [ justDropped, setJustDropped ] = useState(false);
|
||||||
|
|
||||||
const onDragOver = useCallback((event: React.DragEvent) =>
|
const onDragOver = useCallback((event: React.DragEvent) =>
|
||||||
{
|
{
|
||||||
@@ -35,24 +37,36 @@ const ActiveBadgeSlot: FC<{
|
|||||||
|
|
||||||
const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
|
const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
|
||||||
const sourceSlotStr = event.dataTransfer.getData('activeSlot');
|
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 ]);
|
}, [ slotIndex, onDropBadge ]);
|
||||||
|
|
||||||
const onDragStart = useCallback((event: React.DragEvent) =>
|
const onDragStart = useCallback((event: React.DragEvent) =>
|
||||||
{
|
{
|
||||||
if(!badgeCode) return;
|
if(!badgeCode) return;
|
||||||
onDragStartFromSlot(event, badgeCode, slotIndex);
|
onDragStartFromSlot(event, badgeCode, slotIndex);
|
||||||
|
setIsDragging(true);
|
||||||
}, [ badgeCode, slotIndex, onDragStartFromSlot ]);
|
}, [ badgeCode, slotIndex, onDragStartFromSlot ]);
|
||||||
|
|
||||||
|
const onDragEnd = useCallback(() => setIsDragging(false), []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer aspect-square transition-colors
|
className={ `flex items-center justify-center rounded-md border-2 aspect-square transition-all duration-150
|
||||||
${ isDragOver ? 'border-blue-400 bg-blue-400/20' : '' }
|
${ badgeCode ? 'cursor-grab active:cursor-grabbing' : 'cursor-default' }
|
||||||
|
${ isDragging ? 'opacity-30 scale-95' : '' }
|
||||||
|
${ isDragOver ? 'border-blue-400 bg-blue-400/20 animate-pulse-glow scale-105' : '' }
|
||||||
|
${ justDropped ? 'animate-drop-settle' : '' }
|
||||||
${ isSelected && badgeCode ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
|
${ isSelected && badgeCode ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
|
||||||
${ !badgeCode ? 'border-dashed opacity-60' : '' }` }
|
${ !badgeCode ? 'border-dashed opacity-60' : '' }` }
|
||||||
draggable={ !!badgeCode }
|
draggable={ !!badgeCode }
|
||||||
|
onDragEnd={ onDragEnd }
|
||||||
onDragLeave={ onDragLeave }
|
onDragLeave={ onDragLeave }
|
||||||
onDragOver={ onDragOver }
|
onDragOver={ onDragOver }
|
||||||
onDragStart={ onDragStart }
|
onDragStart={ onDragStart }
|
||||||
@@ -73,8 +87,9 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
|
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
|
||||||
const { showConfirm = null } = useNotification();
|
const { showConfirm = null } = useNotification();
|
||||||
const [ isDragOverInventory, setIsDragOverInventory ] = useState(false);
|
const [ isDragOverInventory, setIsDragOverInventory ] = useState(false);
|
||||||
|
const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false);
|
||||||
|
|
||||||
const maxSlots = 5;
|
const maxSlots = useMemo(() => GetConfigurationValue<number>('user.badges.max.slots', 5), []);
|
||||||
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
|
||||||
|
|
||||||
const attemptDeleteBadge = () =>
|
const attemptDeleteBadge = () =>
|
||||||
@@ -95,12 +110,10 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
{
|
{
|
||||||
if(sourceSlot !== undefined)
|
if(sourceSlot !== undefined)
|
||||||
{
|
{
|
||||||
// Reorder within active badges
|
|
||||||
reorderBadges(sourceSlot, slotIndex);
|
reorderBadges(sourceSlot, slotIndex);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Drop from inventory to active slot
|
|
||||||
setBadgeAtSlot(badgeCode, slotIndex);
|
setBadgeAtSlot(badgeCode, slotIndex);
|
||||||
}
|
}
|
||||||
}, [ setBadgeAtSlot, reorderBadges ]);
|
}, [ setBadgeAtSlot, reorderBadges ]);
|
||||||
@@ -111,6 +124,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
event.dataTransfer.setData('activeSlot', slotIndex.toString());
|
event.dataTransfer.setData('activeSlot', slotIndex.toString());
|
||||||
event.dataTransfer.setData('source', 'active');
|
event.dataTransfer.setData('source', 'active');
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
|
||||||
|
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
|
||||||
|
const img = new Image();
|
||||||
|
img.src = badgeUrl;
|
||||||
|
event.dataTransfer.setDragImage(img, 20, 20);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleRemoveBadge = useCallback((badgeCode: string) =>
|
const handleRemoveBadge = useCallback((badgeCode: string) =>
|
||||||
@@ -121,18 +139,24 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
// Handle drop on inventory area (remove from active)
|
// Handle drop on inventory area (remove from active)
|
||||||
const onInventoryDragOver = useCallback((event: React.DragEvent) =>
|
const onInventoryDragOver = useCallback((event: React.DragEvent) =>
|
||||||
{
|
{
|
||||||
const source = event.dataTransfer.types.includes('activeslot') ? 'active' : '';
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.dataTransfer.dropEffect = 'move';
|
event.dataTransfer.dropEffect = 'move';
|
||||||
|
const fromActive = event.dataTransfer.types.includes('activeslot');
|
||||||
|
setIsDraggingFromActive(fromActive);
|
||||||
setIsDragOverInventory(true);
|
setIsDragOverInventory(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []);
|
const onInventoryDragLeave = useCallback(() =>
|
||||||
|
{
|
||||||
|
setIsDragOverInventory(false);
|
||||||
|
setIsDraggingFromActive(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onInventoryDrop = useCallback((event: React.DragEvent) =>
|
const onInventoryDrop = useCallback((event: React.DragEvent) =>
|
||||||
{
|
{
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setIsDragOverInventory(false);
|
setIsDragOverInventory(false);
|
||||||
|
setIsDraggingFromActive(false);
|
||||||
|
|
||||||
const badgeCode = event.dataTransfer.getData('badgeCode');
|
const badgeCode = event.dataTransfer.getData('badgeCode');
|
||||||
const source = event.dataTransfer.getData('source');
|
const source = event.dataTransfer.getData('source');
|
||||||
@@ -169,10 +193,18 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
|
|||||||
return (
|
return (
|
||||||
<div className="grid h-full grid-cols-12 gap-2">
|
<div className="grid h-full grid-cols-12 gap-2">
|
||||||
<div
|
<div
|
||||||
className={ `flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-colors ${ isDragOverInventory ? 'bg-blue-400/10' : '' }` }
|
className={ `relative flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-all duration-200
|
||||||
|
${ isDragOverInventory && isDraggingFromActive ? 'bg-red-500/10 ring-2 ring-inset ring-red-400/30 animate-pulse-glow-red' : '' }
|
||||||
|
${ isDragOverInventory && !isDraggingFromActive ? 'bg-blue-400/10' : '' }` }
|
||||||
onDragLeave={ onInventoryDragLeave }
|
onDragLeave={ onInventoryDragLeave }
|
||||||
onDragOver={ onInventoryDragOver }
|
onDragOver={ onInventoryDragOver }
|
||||||
onDrop={ onInventoryDrop }>
|
onDrop={ onInventoryDrop }>
|
||||||
|
{ isDragOverInventory && isDraggingFromActive && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center pointer-events-none">
|
||||||
|
<FaTrashAlt className="text-red-400/60 text-2xl mb-1" />
|
||||||
|
<span className="text-red-400/60 text-xs font-medium">{ LocalizeText('inventory.badges.clearbadge') }</span>
|
||||||
|
</div>
|
||||||
|
) }
|
||||||
<InfiniteGrid<string>
|
<InfiniteGrid<string>
|
||||||
columnCount={ 5 }
|
columnCount={ 5 }
|
||||||
estimateSize={ 50 }
|
estimateSize={ 50 }
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
|
import { NotificationBubbleItem, NotificationBubbleType } from '../../../../api';
|
||||||
|
import { NotificationBadgeReceivedBubbleView } from './NotificationBadgeReceivedBubbleView';
|
||||||
import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView';
|
import { NotificationClubGiftBubbleView } from './NotificationClubGiftBubbleView';
|
||||||
import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView';
|
import { NotificationDefaultBubbleView } from './NotificationDefaultBubbleView';
|
||||||
|
|
||||||
@@ -10,6 +11,8 @@ export const GetBubbleLayout = (item: NotificationBubbleItem, onClose: () => voi
|
|||||||
|
|
||||||
switch(item.notificationType)
|
switch(item.notificationType)
|
||||||
{
|
{
|
||||||
|
case NotificationBubbleType.BADGE_RECEIVED:
|
||||||
|
return <NotificationBadgeReceivedBubbleView key={ item.id } { ...props } />;
|
||||||
case NotificationBubbleType.CLUBGIFT:
|
case NotificationBubbleType.CLUBGIFT:
|
||||||
return <NotificationClubGiftBubbleView key={ item.id } { ...props } />;
|
return <NotificationClubGiftBubbleView key={ item.id } { ...props } />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
+66
@@ -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<NotificationBadgeReceivedBubbleViewProps> = 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 (
|
||||||
|
<LayoutNotificationBubbleView className="flex-col" onClose={ onClose } { ...rest }>
|
||||||
|
<div onClick={ (e) => e.stopPropagation() }>
|
||||||
|
<Flex alignItems="center" gap={ 2 } className="mb-2">
|
||||||
|
<Flex center className="w-[50px] h-[50px] shrink-0">
|
||||||
|
{ item.iconUrl && <img alt="" className="no-select" src={ item.iconUrl } /> }
|
||||||
|
</Flex>
|
||||||
|
<Flex column gap={ 0 }>
|
||||||
|
<Text bold variant="white">{ LocalizeText('notification.badge.received') }</Text>
|
||||||
|
<Text variant="white" small>{ item.message }</Text>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
<Flex alignItems="center" justifyContent="end" gap={ 2 }>
|
||||||
|
<button
|
||||||
|
className="btn btn-success w-full btn-sm"
|
||||||
|
type="button"
|
||||||
|
onClick={ handleWear }>
|
||||||
|
{ LocalizeText('inventory.badges.wearbadge') }
|
||||||
|
</button>
|
||||||
|
<span className="underline cursor-pointer text-nowrap" onClick={ handleDismiss }>
|
||||||
|
{ LocalizeText('notifications.button.later') }
|
||||||
|
</span>
|
||||||
|
</Flex>
|
||||||
|
</div>
|
||||||
|
</LayoutNotificationBubbleView>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
import { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { FaPlus } from 'react-icons/fa';
|
import { FaPlus } from 'react-icons/fa';
|
||||||
import { LocalizeText } from '../../../../../api';
|
import { GetConfigurationValue, LocalizeText } from '../../../../../api';
|
||||||
import { LayoutBadgeImageView } from '../../../../../common';
|
import { LayoutBadgeImageView } from '../../../../../common';
|
||||||
import { useInventoryBadges } from '../../../../../hooks';
|
import { useInventoryBadges } from '../../../../../hooks';
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ interface InfoStandBadgeSlotProps
|
|||||||
const BadgeMiniPicker: FC<{
|
const BadgeMiniPicker: FC<{
|
||||||
onSelect: (badgeCode: string) => void;
|
onSelect: (badgeCode: string) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
activeBadgeCodes: string[];
|
activeBadgeCodes: (string | null)[];
|
||||||
}> = ({ onSelect, onClose, activeBadgeCodes }) =>
|
}> = ({ onSelect, onClose, activeBadgeCodes }) =>
|
||||||
{
|
{
|
||||||
const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
|
const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
|
||||||
@@ -26,7 +26,8 @@ const BadgeMiniPicker: FC<{
|
|||||||
if(badgeCodes.length === 0) requestBadges();
|
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
|
const filtered = search.length > 0
|
||||||
? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase()))
|
? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase()))
|
||||||
: availableBadges;
|
: availableBadges;
|
||||||
@@ -78,12 +79,24 @@ const BadgeMiniPicker: FC<{
|
|||||||
|
|
||||||
export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) =>
|
export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ 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 [ isDragOver, setIsDragOver ] = useState(false);
|
||||||
|
const [ isDragging, setIsDragging ] = useState(false);
|
||||||
|
const [ justDropped, setJustDropped ] = useState(false);
|
||||||
const [ showPicker, setShowPicker ] = useState(false);
|
const [ showPicker, setShowPicker ] = useState(false);
|
||||||
|
|
||||||
const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null;
|
const hookInitialized = activeBadgeCodes.length > 0;
|
||||||
const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null);
|
|
||||||
|
// 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) =>
|
const onDragStart = useCallback((event: React.DragEvent) =>
|
||||||
{
|
{
|
||||||
@@ -91,8 +104,16 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
|||||||
event.dataTransfer.setData('badgeCode', badgeCode);
|
event.dataTransfer.setData('badgeCode', badgeCode);
|
||||||
event.dataTransfer.setData('infostandSlot', slotIndex.toString());
|
event.dataTransfer.setData('infostandSlot', slotIndex.toString());
|
||||||
event.dataTransfer.effectAllowed = 'move';
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
|
setIsDragging(true);
|
||||||
|
|
||||||
|
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode);
|
||||||
|
const img = new Image();
|
||||||
|
img.src = badgeUrl;
|
||||||
|
event.dataTransfer.setDragImage(img, 20, 20);
|
||||||
}, [ badgeCode, slotIndex, isOwnUser ]);
|
}, [ badgeCode, slotIndex, isOwnUser ]);
|
||||||
|
|
||||||
|
const onDragEnd = useCallback(() => setIsDragging(false), []);
|
||||||
|
|
||||||
const onDragOver = useCallback((event: React.DragEvent) =>
|
const onDragOver = useCallback((event: React.DragEvent) =>
|
||||||
{
|
{
|
||||||
if(!isOwnUser) return;
|
if(!isOwnUser) return;
|
||||||
@@ -124,6 +145,9 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
|||||||
{
|
{
|
||||||
setBadgeAtSlot(droppedBadgeCode, slotIndex);
|
setBadgeAtSlot(droppedBadgeCode, slotIndex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setJustDropped(true);
|
||||||
|
setTimeout(() => setJustDropped(false), 300);
|
||||||
}, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]);
|
}, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]);
|
||||||
|
|
||||||
const handleSlotClick = useCallback(() =>
|
const handleSlotClick = useCallback(() =>
|
||||||
@@ -133,6 +157,13 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
|||||||
setShowPicker(true);
|
setShowPicker(true);
|
||||||
}, [ isOwnUser, badgeCode ]);
|
}, [ isOwnUser, badgeCode ]);
|
||||||
|
|
||||||
|
const handleDoubleClick = useCallback(() =>
|
||||||
|
{
|
||||||
|
if(!isOwnUser || !badgeCode) return;
|
||||||
|
|
||||||
|
removeBadge(badgeCode);
|
||||||
|
}, [ isOwnUser, badgeCode, removeBadge ]);
|
||||||
|
|
||||||
const handlePickerSelect = useCallback((code: string) =>
|
const handlePickerSelect = useCallback((code: string) =>
|
||||||
{
|
{
|
||||||
setBadgeAtSlot(code, slotIndex);
|
setBadgeAtSlot(code, slotIndex);
|
||||||
@@ -145,15 +176,19 @@ export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex,
|
|||||||
className={ `flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center transition-all duration-150
|
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-grab active:cursor-grabbing' : '' }
|
||||||
${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' }
|
${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' }
|
||||||
${ isOwnUser ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' }
|
${ isDragging ? 'opacity-30 scale-90' : '' }
|
||||||
${ isDragOver ? 'scale-115 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15' : '' }
|
${ 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' : '' }` }
|
${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` }
|
||||||
draggable={ isOwnUser && !!badgeCode }
|
draggable={ isOwnUser && !!badgeCode }
|
||||||
|
onDragEnd={ onDragEnd }
|
||||||
onDragLeave={ onDragLeave }
|
onDragLeave={ onDragLeave }
|
||||||
onDragOver={ onDragOver }
|
onDragOver={ onDragOver }
|
||||||
onDragStart={ onDragStart }
|
onDragStart={ onDragStart }
|
||||||
onDrop={ onDrop }
|
onDrop={ onDrop }
|
||||||
onClick={ handleSlotClick }>
|
onClick={ handleSlotClick }
|
||||||
|
onDoubleClick={ handleDoubleClick }>
|
||||||
{ badgeCode
|
{ badgeCode
|
||||||
? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } />
|
? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } />
|
||||||
: isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> }
|
: isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import React from 'react';
|
||||||
import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer';
|
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 { FaPencilAlt, FaTimes } from 'react-icons/fa';
|
||||||
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
|
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api';
|
||||||
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common';
|
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common';
|
||||||
@@ -56,15 +57,23 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
|||||||
useNitroEvent<RoomSessionUserBadgesEvent>(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => {
|
useNitroEvent<RoomSessionUserBadgesEvent>(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => {
|
||||||
if (!avatarInfo || avatarInfo.webID !== event.userId) return;
|
if (!avatarInfo || avatarInfo.webID !== event.userId) return;
|
||||||
|
|
||||||
|
// Deduplicate badges from server
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const dedupedBadges = event.badges.map(code => {
|
||||||
|
if (!code || seen.has(code)) return '';
|
||||||
|
seen.add(code);
|
||||||
|
return code;
|
||||||
|
});
|
||||||
|
|
||||||
const oldBadges = avatarInfo.badges.join('');
|
const oldBadges = avatarInfo.badges.join('');
|
||||||
|
|
||||||
if (oldBadges === event.badges.join('')) return;
|
if (oldBadges === dedupedBadges.join('')) return;
|
||||||
|
|
||||||
setAvatarInfo(prevValue => {
|
setAvatarInfo(prevValue => {
|
||||||
if (!prevValue) return prevValue;
|
if (!prevValue) return prevValue;
|
||||||
|
|
||||||
const newValue = CloneObject(prevValue);
|
const newValue = CloneObject(prevValue);
|
||||||
newValue.badges = event.badges;
|
newValue.badges = dedupedBadges;
|
||||||
return newValue;
|
return newValue;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -165,43 +174,38 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<Column grow alignItems="center" gap={0}>
|
<Column grow alignItems="center" gap={0}>
|
||||||
{ GetConfigurationValue<boolean>('user.badges.group.slot.enabled', true)
|
{ (() => {
|
||||||
? (
|
const maxSlots = GetConfigurationValue<number>('user.badges.max.slots', 5);
|
||||||
<>
|
const isOwnUser = avatarInfo.type === AvatarInfoUser.OWN_USER;
|
||||||
<div className="flex gap-1">
|
const showGroup = maxSlots <= 5;
|
||||||
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
|
||||||
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
|
const items: React.ReactNode[] = [];
|
||||||
{avatarInfo.groupId > 0 &&
|
items.push(<InfoStandBadgeSlotView key={0} slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={isOwnUser} />);
|
||||||
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
|
|
||||||
</Flex>
|
if(showGroup) {
|
||||||
</div>
|
items.push(
|
||||||
<Flex center gap={1}>
|
<Flex key="group" center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
|
||||||
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
{avatarInfo.groupId > 0 && <LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
|
||||||
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
|
||||||
</Flex>
|
</Flex>
|
||||||
<Flex center gap={1}>
|
);
|
||||||
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
} else {
|
||||||
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
items.push(<InfoStandBadgeSlotView key="slot1" slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={isOwnUser} />);
|
||||||
</Flex>
|
}
|
||||||
</>
|
|
||||||
)
|
const startIdx = showGroup ? 1 : 2;
|
||||||
: (
|
for(let i = startIdx; i < maxSlots; i++) {
|
||||||
<>
|
items.push(<InfoStandBadgeSlotView key={i} slotIndex={i} badgeCode={avatarInfo.badges[i]} isOwnUser={isOwnUser} />);
|
||||||
<Flex center gap={1}>
|
}
|
||||||
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
|
||||||
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
const rows: React.ReactNode[][] = [];
|
||||||
</Flex>
|
for(let i = 0; i < items.length; i += 2) {
|
||||||
<Flex center gap={1}>
|
rows.push(items.slice(i, i + 2));
|
||||||
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
}
|
||||||
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
|
||||||
</Flex>
|
return rows.map((row, idx) => (
|
||||||
<Flex center gap={1}>
|
<Flex key={idx} center gap={1}>{row}</Flex>
|
||||||
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
));
|
||||||
<InfoStandBadgeSlotView slotIndex={5} badgeCode={avatarInfo.badges[5]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
|
})() }
|
||||||
</Flex>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
</Column>
|
</Column>
|
||||||
</div>
|
</div>
|
||||||
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
|
||||||
|
|||||||
@@ -11,19 +11,31 @@ const useInventoryBadgesState = () =>
|
|||||||
const [ needsUpdate, setNeedsUpdate ] = useState(true);
|
const [ needsUpdate, setNeedsUpdate ] = useState(true);
|
||||||
const [ badgeCodes, setBadgeCodes ] = useState<string[]>([]);
|
const [ badgeCodes, setBadgeCodes ] = useState<string[]>([]);
|
||||||
const [ badgeIds, setBadgeIds ] = useState<Map<string, number>>(new Map<string, number>());
|
const [ badgeIds, setBadgeIds ] = useState<Map<string, number>>(new Map<string, number>());
|
||||||
const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<string[]>([]);
|
const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<(string | null)[]>([]);
|
||||||
const [ selectedBadgeCode, setSelectedBadgeCode ] = useState<string>(null);
|
const [ selectedBadgeCode, setSelectedBadgeCode ] = useState<string>(null);
|
||||||
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
|
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
|
||||||
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
|
const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker();
|
||||||
|
|
||||||
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
|
const maxBadgeCount = GetConfigurationValue<number>('user.badges.max.slots', 5);
|
||||||
const localChangeRef = useRef(false);
|
const pendingUpdatesRef = useRef(0);
|
||||||
const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0);
|
const isWearingBadge = (badgeCode: string) => activeBadgeCodes.some(code => code === badgeCode);
|
||||||
const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount);
|
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<string>();
|
||||||
|
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();
|
const composer = new SetActivatedBadgesComposer();
|
||||||
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
|
for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
|
||||||
SendMessageComposer(composer);
|
SendMessageComposer(composer);
|
||||||
@@ -33,24 +45,23 @@ const useInventoryBadgesState = () =>
|
|||||||
{
|
{
|
||||||
setActiveBadgeCodes(prevValue =>
|
setActiveBadgeCodes(prevValue =>
|
||||||
{
|
{
|
||||||
const newValue = [ ...prevValue ];
|
const slots = toFixedSlots(prevValue);
|
||||||
|
const index = slots.indexOf(badgeCode);
|
||||||
const index = newValue.indexOf(badgeCode);
|
|
||||||
|
|
||||||
if(index === -1)
|
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
|
else
|
||||||
{
|
{
|
||||||
newValue.splice(index, 1);
|
slots[index] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendActiveBadges(newValue);
|
sendActiveBadges(slots);
|
||||||
|
return slots;
|
||||||
return newValue;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,14 +93,15 @@ const useInventoryBadgesState = () =>
|
|||||||
return newValue;
|
return newValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skip overwriting activeBadgeCodes if we recently made a local change
|
// Skip overwriting activeBadgeCodes if we have pending local changes
|
||||||
if(localChangeRef.current)
|
if(pendingUpdatesRef.current > 0)
|
||||||
{
|
{
|
||||||
localChangeRef.current = false;
|
pendingUpdatesRef.current--;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
setActiveBadgeCodes(parser.getActiveBadgeCodes());
|
const serverBadges = parser.getActiveBadgeCodes();
|
||||||
|
setActiveBadgeCodes(toFixedSlots(serverBadges));
|
||||||
}
|
}
|
||||||
|
|
||||||
setBadgeCodes(allBadgeCodes);
|
setBadgeCodes(allBadgeCodes);
|
||||||
@@ -159,8 +171,7 @@ const useInventoryBadgesState = () =>
|
|||||||
{
|
{
|
||||||
setActiveBadgeCodes(prevValue =>
|
setActiveBadgeCodes(prevValue =>
|
||||||
{
|
{
|
||||||
// Build a fixed-size array of maxBadgeCount slots
|
const slots = toFixedSlots(prevValue);
|
||||||
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
|
|
||||||
|
|
||||||
// Remove badge if already in another slot
|
// Remove badge if already in another slot
|
||||||
const existingIndex = slots.indexOf(badgeCode);
|
const existingIndex = slots.indexOf(badgeCode);
|
||||||
@@ -169,11 +180,8 @@ const useInventoryBadgesState = () =>
|
|||||||
// Place badge at target slot
|
// Place badge at target slot
|
||||||
slots[slotIndex] = badgeCode;
|
slots[slotIndex] = badgeCode;
|
||||||
|
|
||||||
// Compact: remove nulls, keep order
|
sendActiveBadges(slots);
|
||||||
const result = slots.filter(Boolean) as string[];
|
return slots;
|
||||||
|
|
||||||
sendActiveBadges(result);
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -181,10 +189,14 @@ const useInventoryBadgesState = () =>
|
|||||||
{
|
{
|
||||||
setActiveBadgeCodes(prevValue =>
|
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);
|
slots[index] = null;
|
||||||
return result;
|
|
||||||
|
sendActiveBadges(slots);
|
||||||
|
return slots;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,14 +205,14 @@ const useInventoryBadgesState = () =>
|
|||||||
setActiveBadgeCodes(prevValue =>
|
setActiveBadgeCodes(prevValue =>
|
||||||
{
|
{
|
||||||
if(fromIndex === toIndex) return prevValue;
|
if(fromIndex === toIndex) return prevValue;
|
||||||
if(fromIndex >= prevValue.length) return prevValue;
|
|
||||||
|
|
||||||
const newValue = [ ...prevValue ];
|
const slots = toFixedSlots(prevValue);
|
||||||
const [ moved ] = newValue.splice(fromIndex, 1);
|
const temp = slots[fromIndex];
|
||||||
newValue.splice(toIndex, 0, moved);
|
slots[fromIndex] = slots[toIndex];
|
||||||
|
slots[toIndex] = temp;
|
||||||
|
|
||||||
sendActiveBadges(newValue);
|
sendActiveBadges(slots);
|
||||||
return newValue;
|
return slots;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -210,19 +222,13 @@ const useInventoryBadgesState = () =>
|
|||||||
{
|
{
|
||||||
if(fromIndex === toIndex) return prevValue;
|
if(fromIndex === toIndex) return prevValue;
|
||||||
|
|
||||||
// Build fixed-size array so swap works even with empty slots
|
const slots = toFixedSlots(prevValue);
|
||||||
const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
|
|
||||||
|
|
||||||
// Swap the two slots
|
|
||||||
const temp = slots[fromIndex];
|
const temp = slots[fromIndex];
|
||||||
slots[fromIndex] = slots[toIndex];
|
slots[fromIndex] = slots[toIndex];
|
||||||
slots[toIndex] = temp;
|
slots[toIndex] = temp;
|
||||||
|
|
||||||
// Compact: remove nulls, keep order
|
sendActiveBadges(slots);
|
||||||
const result = slots.filter(Boolean) as string[];
|
return slots;
|
||||||
|
|
||||||
sendActiveBadges(result);
|
|
||||||
return result;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useCallback, useState } from 'react';
|
||||||
import { useBetween } from 'use-between';
|
import { useBetween } from 'use-between';
|
||||||
import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api';
|
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<typeof setTimeout> = null;
|
let modDisclaimerTimeout: ReturnType<typeof setTimeout> = null;
|
||||||
|
const recentBadgeNotifications = new Set<string>();
|
||||||
|
|
||||||
const useNotificationState = () =>
|
const useNotificationState = () =>
|
||||||
{
|
{
|
||||||
@@ -209,12 +210,32 @@ const useNotificationState = () =>
|
|||||||
{
|
{
|
||||||
const parser = event.getParser();
|
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 badgeName = LocalizeBadgeName(parser.data.badgeCode);
|
||||||
const badgeImage = GetSessionDataManager().getBadgeUrl(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>(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>(ClubGiftNotificationEvent, event =>
|
useMessageEvent<ClubGiftNotificationEvent>(ClubGiftNotificationEvent, event =>
|
||||||
@@ -335,6 +356,9 @@ const useNotificationState = () =>
|
|||||||
{
|
{
|
||||||
const parser = event.getParser();
|
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);
|
showNotification(parser.type, parser.parameters);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ const InfiniteGridItem = forwardRef<HTMLDivElement, {
|
|||||||
: (itemColor ? 'border-card-grid-item-border' : 'border-card-grid-item-border bg-card-grid-item'),
|
: (itemColor ? 'border-card-grid-item-border' : 'border-card-grid-item-border bg-card-grid-item'),
|
||||||
(itemUniqueSoldout || (itemUniqueNumber > 0)) && 'unique-item',
|
(itemUniqueSoldout || (itemUniqueNumber > 0)) && 'unique-item',
|
||||||
itemUniqueSoldout && 'sold-out',
|
itemUniqueSoldout && 'sold-out',
|
||||||
itemUnseen && ' bg-green-500 bg-opacity-40',
|
itemUnseen && ' animate-pulse-glow-gold border-yellow-400/60',
|
||||||
className
|
className
|
||||||
) }
|
) }
|
||||||
style={ styleNames(
|
style={ styleNames(
|
||||||
|
|||||||
+29
-1
@@ -102,6 +102,30 @@ module.exports = {
|
|||||||
dropShadow: {
|
dropShadow: {
|
||||||
'hover': '2px 2px 0 rgba(0,0,0,0.8)'
|
'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: [
|
safelist: [
|
||||||
@@ -144,7 +168,11 @@ module.exports = {
|
|||||||
'grid-rows-11',
|
'grid-rows-11',
|
||||||
'grid-rows-12',
|
'grid-rows-12',
|
||||||
'justify-end',
|
'justify-end',
|
||||||
'items-end'
|
'items-end',
|
||||||
|
'animate-pulse-glow',
|
||||||
|
'animate-pulse-glow-red',
|
||||||
|
'animate-drop-settle',
|
||||||
|
'animate-pulse-glow-gold'
|
||||||
],
|
],
|
||||||
darkMode: 'class',
|
darkMode: 'class',
|
||||||
variants: {
|
variants: {
|
||||||
|
|||||||
Reference in New Issue
Block a user