From 73ee9c7603d4323a48110e28a477edd36d65abae Mon Sep 17 00:00:00 2001 From: Life Date: Sat, 4 Apr 2026 13:34:53 +0200 Subject: [PATCH 01/17] 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 02/17] 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 03/17] 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 04/17] 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 05/17] 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 06/17] 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 07/17] 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); }); From 3ae51eb5e6360e58b233ea492c027a6fac5d4ce2 Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 8 Apr 2026 12:44:59 +0200 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=86=99=20Fix=20chatbar=20for=20mobi?= =?UTF-8?q?le?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/room/widgets/chat-input/ChatInputView.tsx | 2 +- src/components/toolbar/ToolbarView.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/room/widgets/chat-input/ChatInputView.tsx b/src/components/room/widgets/chat-input/ChatInputView.tsx index 4c22e85..75fbb7e 100644 --- a/src/components/room/widgets/chat-input/ChatInputView.tsx +++ b/src/components/room/widgets/chat-input/ChatInputView.tsx @@ -279,7 +279,7 @@ export const ChatInputView: FC<{}> = props => return ( createPortal( -
+
{ commandSelectorVisible && = props => { isMod && CreateLinkEvent('furni-editor/toggle') } /> } - + CreateLinkEvent('friends/toggle') }> From 5bff312b3b670964c0b906921b2e3d5ec0bb7c5f Mon Sep 17 00:00:00 2001 From: duckietm Date: Wed, 8 Apr 2026 14:06:25 +0200 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=86=99=20Fixed=20some=20minor=20bug?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- postcss.config.js | 3 +- .../notification/NotificationBubbleItem.ts | 9 ++- src/common/layout/LayoutAvatarImageView.tsx | 65 +++++++------------ src/common/layout/LayoutFurniImageView.tsx | 31 ++++++--- src/common/layout/LayoutRoomPreviewerView.tsx | 22 +++++-- .../NotificationBadgeReceivedBubbleView.tsx | 6 +- src/css/index.css | 27 ++++++++ src/hooks/notification/useNotification.ts | 8 +-- tailwind.config.js | 24 ------- 9 files changed, 109 insertions(+), 86 deletions(-) diff --git a/postcss.config.js b/postcss.config.js index f16a034..f5c8618 100644 --- a/postcss.config.js +++ b/postcss.config.js @@ -2,7 +2,6 @@ module.exports = { plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {} + '@tailwindcss/postcss': {} } } diff --git a/src/api/notification/NotificationBubbleItem.ts b/src/api/notification/NotificationBubbleItem.ts index fe90dab..d04d445 100644 --- a/src/api/notification/NotificationBubbleItem.ts +++ b/src/api/notification/NotificationBubbleItem.ts @@ -9,8 +9,9 @@ export class NotificationBubbleItem private _notificationType: string; private _iconUrl: string; private _linkUrl: string; + private _senderName: string; - constructor(message: string, notificationType: string = NotificationBubbleType.INFO, iconUrl: string = null, linkUrl: string = null) + constructor(message: string, notificationType: string = NotificationBubbleType.INFO, iconUrl: string = null, linkUrl: string = null, senderName: string = '') { NotificationBubbleItem.ITEM_ID += 1; @@ -19,6 +20,7 @@ export class NotificationBubbleItem this._notificationType = notificationType; this._iconUrl = iconUrl; this._linkUrl = linkUrl; + this._senderName = senderName; } public get id(): number @@ -45,4 +47,9 @@ export class NotificationBubbleItem { return this._linkUrl; } + + public get senderName(): string + { + return this._senderName; + } } diff --git a/src/common/layout/LayoutAvatarImageView.tsx b/src/common/layout/LayoutAvatarImageView.tsx index fdde1ac..2b3b156 100644 --- a/src/common/layout/LayoutAvatarImageView.tsx +++ b/src/common/layout/LayoutAvatarImageView.tsx @@ -1,7 +1,6 @@ -import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager, NitroEventType } from '@nitrots/nitro-renderer'; +import { AvatarScaleType, AvatarSetType, GetAvatarRenderManager } from '@nitrots/nitro-renderer'; import { CSSProperties, FC, useEffect, useMemo, useRef, useState } from 'react'; import { Base, BaseProps } from '../Base'; -import { useNitroEvent } from '../../hooks/events'; const AVATAR_IMAGE_CACHE: Map = new Map(); @@ -19,18 +18,7 @@ export const LayoutAvatarImageView: FC = props => const { figure = '', gender = '', headOnly = false, direction = 0, scale = 1, classNames = [], style = {}, ...rest } = props; const [ avatarUrl, setAvatarUrl ] = useState(null); const [ isReady, setIsReady ] = useState(false); - const [ updateId, setUpdateId ] = useState(0); const isDisposed = useRef(false); - const figureKeyRef = useRef(null); - - useNitroEvent(NitroEventType.AVATAR_ASSET_LOADED, () => - { - if(figureKeyRef.current) - { - AVATAR_IMAGE_CACHE.delete(figureKeyRef.current); - setUpdateId(prev => prev + 1); - } - }); const getClassNames = useMemo(() => { @@ -65,44 +53,39 @@ export const LayoutAvatarImageView: FC = props => const figureKey = [ figure, gender, direction, headOnly ].join('-'); - figureKeyRef.current = figureKey; - if(AVATAR_IMAGE_CACHE.has(figureKey)) { setAvatarUrl(AVATAR_IMAGE_CACHE.get(figureKey)); } else { - const avatarImage = GetAvatarRenderManager().createAvatarImage(figure, AvatarScaleType.LARGE, gender, { - resetFigure: (figure: string) => - { - if(isDisposed.current) return; - - AVATAR_IMAGE_CACHE.delete(figureKey); - setUpdateId(prev => prev + 1); - }, - dispose: null, - disposed: false - }); - - let setType = AvatarSetType.FULL; - - if(headOnly) setType = AvatarSetType.HEAD; - - avatarImage.setDirection(setType, direction); - - const imageUrl = avatarImage.processAsImageUrl(setType); - - if(imageUrl && !isDisposed.current) + const resetFigure = (_figure: string) => { - if(!avatarImage.isPlaceholder()) AVATAR_IMAGE_CACHE.set(figureKey, imageUrl); + if(isDisposed.current) return; - setAvatarUrl(imageUrl); - } + const avatarImage = GetAvatarRenderManager().createAvatarImage(_figure, AvatarScaleType.LARGE, gender, { resetFigure: (figure: string) => resetFigure(figure), dispose: null, disposed: false }); - avatarImage.dispose(); + let setType = AvatarSetType.FULL; + + if(headOnly) setType = AvatarSetType.HEAD; + + avatarImage.setDirection(setType, direction); + + const imageUrl = avatarImage.processAsImageUrl(setType); + + if(imageUrl && !isDisposed.current) + { + if(!avatarImage.isPlaceholder()) AVATAR_IMAGE_CACHE.set(figureKey, imageUrl); + + setAvatarUrl(imageUrl); + } + + avatarImage.dispose(); + }; + + resetFigure(figure); } - }, [ figure, gender, direction, headOnly, isReady, updateId ]); + }, [ figure, gender, direction, headOnly, isReady ]); useEffect(() => { diff --git a/src/common/layout/LayoutFurniImageView.tsx b/src/common/layout/LayoutFurniImageView.tsx index b83d811..f7fe9dc 100644 --- a/src/common/layout/LayoutFurniImageView.tsx +++ b/src/common/layout/LayoutFurniImageView.tsx @@ -1,5 +1,5 @@ import { GetRoomEngine, IGetImageListener, ImageResult, TextureUtils, Vector3d } from '@nitrots/nitro-renderer'; -import { CSSProperties, FC, useEffect, useMemo, useState } from 'react'; +import { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ProductTypeEnum } from '../../api'; import { Base, BaseProps } from '../Base'; @@ -16,6 +16,23 @@ export const LayoutFurniImageView: FC = props => { const { productType = 's', productClassId = -1, direction = 2, extraData = '', scale = 1, style = {}, ...rest } = props; const [ imageElement, setImageElement ] = useState(null); + const isMounted = useRef(true); + + useEffect(() => + { + isMounted.current = true; + + return () => { isMounted.current = false; }; + }, []); + + const updateImage = useCallback(async (texture: any) => + { + if(!texture) return; + + const image = await TextureUtils.generateImage(texture); + + if(image && isMounted.current) setImageElement(image); + }, []); const getStyle = useMemo(() => { @@ -42,10 +59,12 @@ export const LayoutFurniImageView: FC = props => useEffect(() => { + setImageElement(null); + let imageResult: ImageResult = null; const listener: IGetImageListener = { - imageReady: async (id, texture, image) => setImageElement(await TextureUtils.generateImage(texture)), + imageReady: (result) => updateImage(result?.data), imageFailed: null }; @@ -59,12 +78,8 @@ export const LayoutFurniImageView: FC = props => break; } - if(!imageResult) return; - - (async () => setImageElement(await TextureUtils.generateImage(imageResult.data)))(); - }, [ productType, productClassId, direction, extraData ]); - - if(!imageElement) return null; + if(imageResult?.data) updateImage(imageResult.data); + }, [ productType, productClassId, direction, extraData, updateImage ]); return ; }; diff --git a/src/common/layout/LayoutRoomPreviewerView.tsx b/src/common/layout/LayoutRoomPreviewerView.tsx index daceeef..ca1ef26 100644 --- a/src/common/layout/LayoutRoomPreviewerView.tsx +++ b/src/common/layout/LayoutRoomPreviewerView.tsx @@ -24,15 +24,13 @@ export const LayoutRoomPreviewerView: FC<{ const width = elementRef.current.parentElement.clientWidth; const texture = TextureUtils.createRenderTexture(width, height); - const update = async (ticker: NitroTicker) => + const paintToDOM = () => { if(!roomPreviewer || !elementRef.current) return; - roomPreviewer.updatePreviewRoomView(); - const renderingCanvas = roomPreviewer.getRenderingCanvas(); - if(!renderingCanvas.canvasUpdated) return; + if(!renderingCanvas) return; GetRenderer().render({ target: texture, @@ -48,6 +46,20 @@ export const LayoutRoomPreviewerView: FC<{ elementRef.current.style.backgroundImage = `url(${ base64 })`; }; + const update = (ticker: NitroTicker) => + { + if(!roomPreviewer || !elementRef.current) return; + + roomPreviewer.updatePreviewRoomView(); + + const renderingCanvas = roomPreviewer.getRenderingCanvas(); + + if(renderingCanvas && renderingCanvas.canvasUpdated) + { + paintToDOM(); + } + }; + GetTicker().add(update); const resizeObserver = new ResizeObserver(() => @@ -58,7 +70,7 @@ export const LayoutRoomPreviewerView: FC<{ roomPreviewer.modifyRoomCanvas(width, height); - update(GetTicker()); + paintToDOM(); }); roomPreviewer.getRoomCanvas(width, height); diff --git a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx index 7b57db3..8c1154e 100644 --- a/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -45,7 +45,11 @@ export const NotificationBadgeReceivedBubbleView: FC } - { LocalizeText('notification.badge.received') } + + { item.senderName + ? LocalizeText('notifications.text.received.badge', [ 'user_name' ], [ item.senderName ]) + : LocalizeText('prereg.reward.you.received') } + { item.message } diff --git a/src/css/index.css b/src/css/index.css index 760427e..a857640 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -2,6 +2,33 @@ @config "../../tailwind.config.js"; +@theme { + --animate-pulse-glow: pulseGlow 1.2s ease-in-out infinite; + --animate-pulse-glow-red: pulseGlowRed 1.2s ease-in-out infinite; + --animate-drop-settle: dropSettle 0.3s ease-out; + --animate-pulse-glow-gold: pulseGlowGold 1.5s ease-in-out infinite; +} + +@keyframes pulseGlow { + 0%, 100% { box-shadow: 0 0 6px rgba(59, 130, 246, 0.3); } + 50% { box-shadow: 0 0 14px rgba(59, 130, 246, 0.6); } +} + +@keyframes pulseGlowRed { + 0%, 100% { box-shadow: 0 0 6px rgba(239, 68, 68, 0.3); } + 50% { box-shadow: 0 0 14px rgba(239, 68, 68, 0.6); } +} + +@keyframes dropSettle { + 0% { transform: scale(1.15); } + 100% { transform: scale(1); } +} + +@keyframes pulseGlowGold { + 0%, 100% { box-shadow: 0 0 6px rgba(255, 193, 7, 0.4); } + 50% { box-shadow: 0 0 14px rgba(255, 193, 7, 0.7); } +} + @font-face { font-family: Ubuntu; src: url("@/assets/webfonts/Ubuntu-C.ttf"); diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index 007a61c..911fd63 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -68,11 +68,11 @@ const useNotificationState = () => const showNitroAlert = useCallback(() => simpleAlert(null, NotificationAlertType.NITRO), [ simpleAlert ]); - const showSingleBubble = useCallback((message: string, type: string, imageUrl: string = null, internalLink: string = null) => + const showSingleBubble = useCallback((message: string, type: string, imageUrl: string = null, internalLink: string = null, senderName: string = '') => { if(bubblesDisabled) return; - const notificationItem = new NotificationBubbleItem(message, type, imageUrl, internalLink); + const notificationItem = new NotificationBubbleItem(message, type, imageUrl, internalLink, senderName); setBubbleAlerts(prevValue => [ notificationItem, ...prevValue ]); }, [ bubblesDisabled ]); @@ -226,7 +226,6 @@ const useNotificationState = () => { const parser = event.getParser(); - // Skip if AchievementNotificationMessageEvent already showed a notification for this badge if(recentBadgeNotifications.has(parser.badgeCode)) return; recentBadgeNotifications.add(parser.badgeCode); @@ -234,8 +233,9 @@ const useNotificationState = () => const badgeName = LocalizeBadgeName(parser.badgeCode); const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode); + const senderName = parser.senderName || ''; - showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode); + showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode, senderName); }); useMessageEvent(ClubGiftNotificationEvent, event => diff --git a/tailwind.config.js b/tailwind.config.js index 6177ad9..d1bdbd6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -102,30 +102,6 @@ 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: [ From bbd4ccf30cf295e651bcd7103ea89e3f871274a0 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 9 Apr 2026 11:54:57 +0200 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=86=99=20Stage=201=20Youtube=20broa?= =?UTF-8?q?dcast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/assets/images/toolbar/icons/youtube.svg | 4 + src/components/toolbar/ToolbarView.tsx | 8 + src/components/toolbar/YouTubePlayerView.tsx | 748 ++++++++++++++++++ src/css/icons/icons.css | 7 + .../furniture/useFurnitureYoutubeWidget.ts | 2 +- 5 files changed, 768 insertions(+), 1 deletion(-) create mode 100644 src/assets/images/toolbar/icons/youtube.svg create mode 100644 src/components/toolbar/YouTubePlayerView.tsx diff --git a/src/assets/images/toolbar/icons/youtube.svg b/src/assets/images/toolbar/icons/youtube.svg new file mode 100644 index 0000000..fea9d48 --- /dev/null +++ b/src/assets/images/toolbar/icons/youtube.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 3f8e76c..1788b76 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -6,6 +6,7 @@ import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; import { useAchievements, useFriends, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; import { ToolbarMeView } from './ToolbarMeView'; +import { YouTubePlayerView } from './YouTubePlayerView'; export const ToolbarView: FC<{ isInRoom: boolean }> = props => { @@ -19,6 +20,11 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const isMod = GetSessionDataManager().isModerator; + const openYouTubePlayer = () => + { + window.dispatchEvent(new CustomEvent('youtube:toggle')); + }; + useMessageEvent(PerkAllowancesMessageEvent, event => { setUseGuideTool(event.getParser().isAllowed(PerkEnum.USE_GUIDE_TOOL)); @@ -65,6 +71,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => return ( <> + { isMeExpanded && ( )} @@ -94,6 +101,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { isInRoom && CreateLinkEvent('camera/toggle') } /> } + { isMod && CreateLinkEvent('mod-tools/toggle') } /> } { isMod && diff --git a/src/components/toolbar/YouTubePlayerView.tsx b/src/components/toolbar/YouTubePlayerView.tsx new file mode 100644 index 0000000..b06bc49 --- /dev/null +++ b/src/components/toolbar/YouTubePlayerView.tsx @@ -0,0 +1,748 @@ +import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer"; +import { FC, useEffect, useRef, useState } from "react"; +import YouTube from "react-youtube"; +import { + GetRoomSession, + GetSessionDataManager, + SendMessageComposer, + YoutubeVideoPlaybackStateEnum, +} from "../../api"; +import { + NitroCardContentView, + NitroCardHeaderView, + NitroCardView, +} from "../../common"; +import { useFurnitureYoutubeWidget, useMessageEvent } from "../../hooks"; + +const CONTROL_COMMAND_PREVIOUS_VIDEO = 0; +const CONTROL_COMMAND_NEXT_VIDEO = 1; +const CONTROL_COMMAND_PAUSE_VIDEO = 2; +const CONTROL_COMMAND_CONTINUE_VIDEO = 3; + +const extractVideoId = (input: string): string => { + const patterns = [ + /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]{11})/, + /^([a-zA-Z0-9_-]{11})$/, + ]; + for (const pattern of patterns) { + const match = input.match(pattern); + if (match) return match[1]; + } + return input; +}; + +export const YouTubePlayerView: FC<{}> = () => { + const [isOpen, setIsOpen] = useState(false); + const [tab, setTab] = useState< + | "player" + | "playlist" + | "spectators" + | "settings" + | "history" + | "share" + >("player"); + const [inputValue, setInputValue] = useState(""); + const [isRoomMode, setIsRoomMode] = useState(false); + const [volume, setVolume] = useState(100); + const [isMuted, setIsMuted] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [isLooping, setIsLooping] = useState(false); + const [volumePreset, setVolumePreset] = useState(100); + const [playlist, setPlaylist] = useState([]); + const [history, setHistory] = useState([]); + const [showVolumeSlider, setShowVolumeSlider] = useState(true); + const playerRef = useRef(null); + + const { + objectId: youtubeObjectId, + videoId: roomVideoId, + currentVideoState, + hasControl, + } = useFurnitureYoutubeWidget(); + + const [spectators, setSpectators] = useState< + { id: number; name: string; look: string }[] + >([]); + // Room broadcast state: set when someone broadcasts a video to the room + const [broadcastVideo, setBroadcastVideo] = useState(""); + const [broadcastSender, setBroadcastSender] = useState(""); + const [broadcastPlaylist, setBroadcastPlaylist] = useState([]); + const [watcherIds, setWatcherIds] = useState>(new Set()); + + // Listen for room-wide YouTube broadcast from the server + useMessageEvent(YouTubeRoomBroadcastEvent, event => { + const parser = event.getParser(); + setBroadcastVideo(parser.videoId); + setBroadcastSender(parser.senderName); + setBroadcastPlaylist(parser.playlist); + // Auto-open the player and load the broadcast video + if (parser.videoId) { + setInputValue(parser.videoId); + setIsOpen(true); + setTab("player"); + } + }); + + // Listen for updated watcher list from the server + useMessageEvent(YouTubeRoomWatchersEvent, event => { + setWatcherIds(new Set(event.getParser().watcherIds)); + loadRoomUsers(); // refresh spectator list so we can mark watchers + }); + + // Notify server when we open/close the YouTube player + useEffect(() => { + if (isOpen) { + try { SendMessageComposer(new YouTubeRoomWatchingComposer(true)); } catch(e) {} + } + return () => { + try { SendMessageComposer(new YouTubeRoomWatchingComposer(false)); } catch(e) {} + }; + }, [isOpen]); + + // Enumerate room users via the session's userDataManager. Uses the + // same brute-force index scan that the old FurnitureYoutubeDisplayView + // used (and which worked). The fancier GetRoomEngine().getRoomObjects() + // approach doesn't reliably return objects when called from the toolbar + // context (outside the room widget tree). + const loadRoomUsers = () => { + try { + const roomSession = GetRoomSession(); + if (!roomSession) { setSpectators([]); return; } + const users: { id: number; name: string; look: string }[] = []; + for (let i = 0; i < 500; i++) { + const userData = roomSession.userDataManager.getUserDataByIndex(i); + if (userData && userData.name && userData.type === 1) { + users.push({ id: userData.userId, name: userData.name, look: userData.figure }); + } + } + setSpectators(users); + } catch (e) { + setSpectators([]); + } + }; + + // Load room users when the player opens so the spectators count + // is visible on the tab button immediately. + useEffect(() => { + if (isOpen) loadRoomUsers(); + }, [isOpen]); + + useEffect(() => { + if (youtubeObjectId && youtubeObjectId !== -1) { + setIsRoomMode(true); + if (roomVideoId) { + setInputValue(roomVideoId); + } + } else { + setIsRoomMode(false); + } + }, [youtubeObjectId, roomVideoId]); + + useEffect(() => { + // Hold the same handler reference for both add and remove. Using a + // fresh arrow function in the cleanup is a no-op because + // removeEventListener requires reference equality; every mount + // would otherwise leak a permanent listener on window. + const handler = () => setIsOpen((p) => !p); + window.addEventListener("youtube:toggle", handler); + return () => window.removeEventListener("youtube:toggle", handler); + }, []); + + useEffect(() => { + const savedHistory = localStorage.getItem("youtube_history"); + if (savedHistory) { + try { + const parsed = JSON.parse(savedHistory); + if (Array.isArray(parsed)) { + // Accept both legacy {id,title,...} objects and plain string[] + setHistory(parsed.map((entry: any) => typeof entry === "string" ? entry : entry?.id).filter(Boolean)); + } + } catch (e) {} + } + const savedPlaylist = localStorage.getItem("youtube_playlist"); + if (savedPlaylist) { + try { + const parsed = JSON.parse(savedPlaylist); + if (Array.isArray(parsed)) { + setPlaylist(parsed.map((entry: any) => typeof entry === "string" ? entry : entry?.id).filter(Boolean)); + } + } catch (e) {} + } + }, []); + + useEffect(() => { + localStorage.setItem( + "youtube_history", + JSON.stringify(history.slice(0, 50)), + ); + }, [history]); + + useEffect(() => { + localStorage.setItem("youtube_playlist", JSON.stringify(playlist)); + }, [playlist]); + + const addToHistory = (id: string) => { + if (!id) return; + setHistory((prev) => { + const filtered = prev.filter((v) => v !== id); + return [id, ...filtered].slice(0, 50); + }); + }; + + const handlePlay = () => + isRoomMode && + youtubeObjectId && + hasControl && + SendMessageComposer( + new ControlYoutubeDisplayPlaybackMessageComposer( + youtubeObjectId, + CONTROL_COMMAND_CONTINUE_VIDEO, + ), + ); + const handlePause = () => + isRoomMode && + youtubeObjectId && + hasControl && + SendMessageComposer( + new ControlYoutubeDisplayPlaybackMessageComposer( + youtubeObjectId, + CONTROL_COMMAND_PAUSE_VIDEO, + ), + ); + const handlePrev = () => + isRoomMode && + youtubeObjectId && + hasControl && + SendMessageComposer( + new ControlYoutubeDisplayPlaybackMessageComposer( + youtubeObjectId, + CONTROL_COMMAND_PREVIOUS_VIDEO, + ), + ); + const handleNext = () => + isRoomMode && + youtubeObjectId && + hasControl && + SendMessageComposer( + new ControlYoutubeDisplayPlaybackMessageComposer( + youtubeObjectId, + CONTROL_COMMAND_NEXT_VIDEO, + ), + ); + + const addToPlaylist = () => { + const id = extractVideoId(inputValue); + if (id && !playlist.includes(id)) { + setPlaylist((p) => [...p, id]); + } + }; + + if (!isOpen) return null; + + const videoId = extractVideoId(inputValue); + const isPlaying = + currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING; + const isPaused = currentVideoState === YoutubeVideoPlaybackStateEnum.PAUSED; + const isMyRoom = GetSessionDataManager().isModerator || hasControl; + + const QuickVolumeButton = ({ + value, + label, + }: { + value: number; + label: string; + }) => ( + + ); + + return ( + + setIsOpen(false)} + /> + +
+ + + + + {spectators.length > 0 && ( + + )} + +
+ + {tab === "player" && ( + <> + {isRoomMode && ( +
+ + đŸ“ē Verbonden met YouTube TV + +
+ {isPlaying && ( + + â–ļ Speelt + + )} + {isPaused && ( + + ⏸ Gepauzeerd + + )} + {isMyRoom && ( + + ✓ Jij bent eigenaar + + )} +
+
+ )} + + {videoId ? ( + { + playerRef.current = e.target; + addToHistory(videoId); + }} + /> + ) : ( +
+ Geen video geladen +
+ )} + + {isRoomMode && hasControl && ( +
+ + + +
+ )} + + {broadcastVideo && broadcastSender && ( +
+ 📡 {broadcastSender} speelt voor de kamer +
+ )} + +
+ setInputValue(e.target.value)} + disabled={!!broadcastVideo && !isMyRoom} + className={`flex-1 p-2 rounded text-white text-sm ${(!!broadcastVideo && !isMyRoom) ? "bg-gray-800" : "bg-gray-700"}`} + placeholder="YouTube URL of video ID" + /> + {isMyRoom && videoId && ( + + )} +
+ + )} + + {tab === "playlist" && ( +
+
+ setInputValue(e.target.value)} + placeholder="Video URL toevoegen..." + className="flex-1 p-2 bg-gray-700 text-white rounded text-sm" + onKeyDown={(e) => + e.key === "Enter" && addToPlaylist() + } + /> + +
+
+ + +
+ {playlist.length === 0 ? ( +
+ Playlist is leeg +
+ ) : ( +
+ {playlist.map((id, i) => ( +
{ + setInputValue(id); + setTab("player"); + }} + > + + {i + 1}. + +
+ {id} +
+ +
+ ))} +
+ )} +
+ )} + + {tab === "history" && ( +
+
+
+ 🕐 Bekeken video's ({history.length}) +
+ +
+ {history.length === 0 ? ( +
+ Nog geen video's bekeken +
+ ) : ( +
+ {history.map((id, i) => ( +
{ + setInputValue(id); + setTab("player"); + }} + > +
+ {id} +
+
+ ))} +
+ )} +
+ )} + + {tab === "share" && ( +
+
+
+ 📤 Video delen +
+ {videoId ? ( +
+
+ + +
+
+ ) : ( +
+ Selecteer eerst een video om te delen +
+ )} +
+
+
+ 📋 Snel delen +
+
+ + +
+
+
+ )} + + {tab === "spectators" && ( +
+
+
+ đŸ‘ī¸ Gebruikers in kamer ({spectators.length}) +
+ +
+ {spectators.length === 0 ? ( +
+ Geen gebruikers in deze kamer +
+ ) : ( +
+ {spectators.map((user) => ( +
+ {user.name} { + ( + e.target as HTMLImageElement + ).src = + "data:image/svg+xml,"; + }} + /> + + {user.name} + + {watcherIds.has(user.id) && ( + đŸ“ē + )} +
+ ))} +
+ )} +
+ )} + + {tab === "settings" && ( +
+
+
+ + +
+ {showVolumeSlider && ( + { + setVolume(parseInt(e.target.value)); + setVolumePreset( + parseInt(e.target.value), + ); + }} + className="w-full" + /> + )} +
+ + + + + +
+
+ +
+ + + +
+ +
+
â„šī¸ Info
+
+ Room Mode:{" "} + {isRoomMode ? "✓ Actief" : "✕ Niet actief"} +
+
+ Controle:{" "} + {hasControl + ? "✓ Je hebt controle" + : "✕ Alleen kijken"} +
+
+
+ )} +
+
+ ); +}; diff --git a/src/css/icons/icons.css b/src/css/icons/icons.css index 2feaedf..7e2fcc7 100644 --- a/src/css/icons/icons.css +++ b/src/css/icons/icons.css @@ -172,6 +172,13 @@ height: 45px; } +.nitro-icon.icon-youtube { + background-image: url("@/assets/images/toolbar/icons/youtube.svg"); + background-size: contain; + width: 36px; + height: 36px; +} + .nitro-icon.icon-message { background-image: url("@/assets/images/toolbar/icons/message.png"); width: 36px; diff --git a/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts b/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts index 0069063..b24470d 100644 --- a/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts +++ b/src/hooks/rooms/widgets/furniture/useFurnitureYoutubeWidget.ts @@ -127,7 +127,7 @@ const useFurnitureYoutubeWidgetState = () => onClose(); }); - return { objectId, videoId, videoStart, videoEnd, currentVideoState, selectedVideo, playlists, onClose, previous, next, pause, play, selectVideo }; + return { objectId, videoId, videoStart, videoEnd, currentVideoState, selectedVideo, playlists, hasControl, onClose, previous, next, pause, play, selectVideo }; }; export const useFurnitureYoutubeWidget = useFurnitureYoutubeWidgetState; From c7348a950984faf82446028fe1caa1ccdb981378 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 9 Apr 2026 15:35:58 +0200 Subject: [PATCH 11/17] Stage 2 Youtube & upgrade to vite 8 --- .../draggable-window/DraggableWindow.tsx | 4 +- src/components/toolbar/YouTubePlayerView.tsx | 215 +++++++++--------- src/css/chat/Chats.css | 5 +- src/css/widgets/FurnitureWidgets.css | 1 - src/hooks/notification/useNotification.ts | 4 + 5 files changed, 108 insertions(+), 121 deletions(-) diff --git a/src/common/draggable-window/DraggableWindow.tsx b/src/common/draggable-window/DraggableWindow.tsx index 9bb7e34..be5e552 100644 --- a/src/common/draggable-window/DraggableWindow.tsx +++ b/src/common/draggable-window/DraggableWindow.tsx @@ -80,12 +80,10 @@ export const DraggableWindow: FC = props => { const windowHeight = elementRef.current.offsetHeight; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; - const maxOutX = windowWidth * DRAG_OUTSIDE_PERCENT; const maxOutY = windowHeight * DRAG_OUTSIDE_PERCENT; - const clampedX = Math.max(-maxOutX, Math.min(newX, viewportWidth - windowWidth + maxOutX)); - const clampedY = Math.max(-maxOutY, Math.min(newY, viewportHeight - windowHeight + maxOutY)); + const clampedY = Math.max(BOUNDS_THRESHOLD_TOP, Math.min(newY, viewportHeight - windowHeight + maxOutY)); return { x: clampedX, y: clampedY }; }, []); diff --git a/src/components/toolbar/YouTubePlayerView.tsx b/src/components/toolbar/YouTubePlayerView.tsx index b06bc49..9b1507e 100644 --- a/src/components/toolbar/YouTubePlayerView.tsx +++ b/src/components/toolbar/YouTubePlayerView.tsx @@ -1,17 +1,8 @@ import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer"; import { FC, useEffect, useRef, useState } from "react"; import YouTube from "react-youtube"; -import { - GetRoomSession, - GetSessionDataManager, - SendMessageComposer, - YoutubeVideoPlaybackStateEnum, -} from "../../api"; -import { - NitroCardContentView, - NitroCardHeaderView, - NitroCardView, -} from "../../common"; +import { GetRoomSession, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from "../../api"; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from "../../common"; import { useFurnitureYoutubeWidget, useMessageEvent } from "../../hooks"; const CONTROL_COMMAND_PREVIOUS_VIDEO = 0; @@ -33,14 +24,7 @@ const extractVideoId = (input: string): string => { export const YouTubePlayerView: FC<{}> = () => { const [isOpen, setIsOpen] = useState(false); - const [tab, setTab] = useState< - | "player" - | "playlist" - | "spectators" - | "settings" - | "history" - | "share" - >("player"); + const [tab, setTab] = useState< | "player" | "playlist" | "spectators" | "settings" | "history" | "share" >("player"); const [inputValue, setInputValue] = useState(""); const [isRoomMode, setIsRoomMode] = useState(false); const [volume, setVolume] = useState(100); @@ -53,65 +37,54 @@ export const YouTubePlayerView: FC<{}> = () => { const [showVolumeSlider, setShowVolumeSlider] = useState(true); const playerRef = useRef(null); - const { - objectId: youtubeObjectId, - videoId: roomVideoId, - currentVideoState, - hasControl, - } = useFurnitureYoutubeWidget(); + const { objectId: youtubeObjectId, videoId: roomVideoId, currentVideoState, hasControl } = useFurnitureYoutubeWidget(); - const [spectators, setSpectators] = useState< - { id: number; name: string; look: string }[] - >([]); - // Room broadcast state: set when someone broadcasts a video to the room + const [spectators, setSpectators] = useState< { id: number; name: string; look: string }[] >([]); const [broadcastVideo, setBroadcastVideo] = useState(""); const [broadcastSender, setBroadcastSender] = useState(""); const [broadcastPlaylist, setBroadcastPlaylist] = useState([]); const [watcherIds, setWatcherIds] = useState>(new Set()); - - // Listen for room-wide YouTube broadcast from the server useMessageEvent(YouTubeRoomBroadcastEvent, event => { const parser = event.getParser(); setBroadcastVideo(parser.videoId); setBroadcastSender(parser.senderName); setBroadcastPlaylist(parser.playlist); - // Auto-open the player and load the broadcast video if (parser.videoId) { setInputValue(parser.videoId); setIsOpen(true); setTab("player"); + } else { + setInputValue(""); + setBroadcastVideo(""); + setBroadcastSender(""); + setBroadcastPlaylist([]); } }); - // Listen for updated watcher list from the server - useMessageEvent(YouTubeRoomWatchersEvent, event => { - setWatcherIds(new Set(event.getParser().watcherIds)); - loadRoomUsers(); // refresh spectator list so we can mark watchers - }); + useMessageEvent(YouTubeRoomWatchersEvent, event => { setWatcherIds(new Set(event.getParser().watcherIds)); loadRoomUsers(); }); - // Notify server when we open/close the YouTube player + const sentWatchingRef = useRef(false); + const hasVideo = !!(inputValue && extractVideoId(inputValue)); useEffect(() => { - if (isOpen) { + if (isOpen && hasVideo && !sentWatchingRef.current) { try { SendMessageComposer(new YouTubeRoomWatchingComposer(true)); } catch(e) {} - } - return () => { + sentWatchingRef.current = true; + } else if ((!isOpen || !hasVideo) && sentWatchingRef.current) { try { SendMessageComposer(new YouTubeRoomWatchingComposer(false)); } catch(e) {} - }; - }, [isOpen]); + sentWatchingRef.current = false; + } + }, [isOpen, hasVideo]); - // Enumerate room users via the session's userDataManager. Uses the - // same brute-force index scan that the old FurnitureYoutubeDisplayView - // used (and which worked). The fancier GetRoomEngine().getRoomObjects() - // approach doesn't reliably return objects when called from the toolbar - // context (outside the room widget tree). const loadRoomUsers = () => { try { const roomSession = GetRoomSession(); if (!roomSession) { setSpectators([]); return; } const users: { id: number; name: string; look: string }[] = []; + const seen = new Set(); for (let i = 0; i < 500; i++) { const userData = roomSession.userDataManager.getUserDataByIndex(i); - if (userData && userData.name && userData.type === 1) { + if (userData && userData.name && userData.type === 1 && !seen.has(userData.userId)) { + seen.add(userData.userId); users.push({ id: userData.userId, name: userData.name, look: userData.figure }); } } @@ -121,8 +94,6 @@ export const YouTubePlayerView: FC<{}> = () => { } }; - // Load room users when the player opens so the spectators count - // is visible on the tab button immediately. useEffect(() => { if (isOpen) loadRoomUsers(); }, [isOpen]); @@ -139,10 +110,6 @@ export const YouTubePlayerView: FC<{}> = () => { }, [youtubeObjectId, roomVideoId]); useEffect(() => { - // Hold the same handler reference for both add and remove. Using a - // fresh arrow function in the cleanup is a no-op because - // removeEventListener requires reference equality; every mount - // would otherwise leak a permanent listener on window. const handler = () => setIsOpen((p) => !p); window.addEventListener("youtube:toggle", handler); return () => window.removeEventListener("youtube:toggle", handler); @@ -154,7 +121,6 @@ export const YouTubePlayerView: FC<{}> = () => { try { const parsed = JSON.parse(savedHistory); if (Array.isArray(parsed)) { - // Accept both legacy {id,title,...} objects and plain string[] setHistory(parsed.map((entry: any) => typeof entry === "string" ? entry : entry?.id).filter(Boolean)); } } catch (e) {} @@ -240,10 +206,10 @@ export const YouTubePlayerView: FC<{}> = () => { if (!isOpen) return null; const videoId = extractVideoId(inputValue); - const isPlaying = - currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING; + const isPlaying = currentVideoState === YoutubeVideoPlaybackStateEnum.PLAYING; const isPaused = currentVideoState === YoutubeVideoPlaybackStateEnum.PAUSED; - const isMyRoom = GetSessionDataManager().isModerator || hasControl; + const roomSession = GetRoomSession(); + const isMyRoom = GetSessionDataManager().isModerator || (roomSession && roomSession.isRoomOwner); const QuickVolumeButton = ({ value, @@ -265,10 +231,10 @@ export const YouTubePlayerView: FC<{}> = () => { return ( setIsOpen(false)} /> @@ -297,12 +263,12 @@ export const YouTubePlayerView: FC<{}> = () => { > 📤 - {spectators.length > 0 && ( + {watcherIds.size > 0 && ( )} + )}
)} @@ -402,7 +383,7 @@ export const YouTubePlayerView: FC<{}> = () => { onChange={(e) => setInputValue(e.target.value)} disabled={!!broadcastVideo && !isMyRoom} className={`flex-1 p-2 rounded text-white text-sm ${(!!broadcastVideo && !isMyRoom) ? "bg-gray-800" : "bg-gray-700"}`} - placeholder="YouTube URL of video ID" + placeholder="YouTube URL / video ID" /> {isMyRoom && videoId && ( )}
@@ -428,7 +409,7 @@ export const YouTubePlayerView: FC<{}> = () => { type="text" value={inputValue} onChange={(e) => setInputValue(e.target.value)} - placeholder="Video URL toevoegen..." + placeholder="Add video URL..." className="flex-1 p-2 bg-gray-700 text-white rounded text-sm" onKeyDown={(e) => e.key === "Enter" && addToPlaylist() @@ -446,18 +427,18 @@ export const YouTubePlayerView: FC<{}> = () => { onClick={() => setInputValue("")} className="flex-1 px-3 py-2 bg-gray-700 rounded text-white text-sm" > - 🔄 Nieuwe video + 🔄 New video
{playlist.length === 0 ? (
- Playlist is leeg + Playlist is empty
) : (
@@ -498,18 +479,18 @@ export const YouTubePlayerView: FC<{}> = () => {
- 🕐 Bekeken video's ({history.length}) + 🕐 Watch history ({history.length})
{history.length === 0 ? (
- Nog geen video's bekeken + No videos watched yet
) : (
@@ -536,7 +517,7 @@ export const YouTubePlayerView: FC<{}> = () => {
- 📤 Video delen + 📤 Share video
{videoId ? (
@@ -561,13 +542,13 @@ export const YouTubePlayerView: FC<{}> = () => {
) : (
- Selecteer eerst een video om te delen + Select a video first to share
)}
- 📋 Snel delen + 📋 Quick share
)} - {tab === "spectators" && ( + {tab === "spectators" && (() => { + const watchers: { id: number; name: string; look: string }[] = []; + const rs = GetRoomSession(); + if (rs) { + for (const uid of watcherIds) { + const ud = rs.userDataManager.getUserData(uid); + if (ud && ud.name) { + watchers.push({ id: ud.userId, name: ud.name, look: ud.figure }); + } + } + } + return (
- đŸ‘ī¸ Gebruikers in kamer ({spectators.length}) + đŸ“ē {watchers.length} watching
- {spectators.length === 0 ? ( + {watchers.length === 0 ? (
- Geen gebruikers in deze kamer + No one is watching
) : (
- {spectators.map((user) => ( + {watchers.map((user) => (
- {user.name} { - ( - e.target as HTMLImageElement - ).src = - "data:image/svg+xml,"; - }} - /> +
+ +
{user.name} - {watcherIds.has(user.id) && ( - đŸ“ē - )} + đŸ“ē
))}
)}
- )} + ); + })()} {tab === "settings" && (
@@ -701,7 +684,7 @@ export const YouTubePlayerView: FC<{}> = () => { } className="w-4 h-4" /> - 🔇 Dempen + 🔇 Mute
diff --git a/src/css/chat/Chats.css b/src/css/chat/Chats.css index c6cdde2..84ca763 100644 --- a/src/css/chat/Chats.css +++ b/src/css/chat/Chats.css @@ -9,7 +9,6 @@ &.type-0 { - // normal .message { font-weight: 400; } @@ -17,7 +16,6 @@ &.type-1 { - // whisper .message { font-weight: 400; font-style: italic; @@ -27,7 +25,6 @@ &.type-2 { - // shout .message { font-weight: 700; } @@ -1097,4 +1094,4 @@ &.bubble-53 { background-image: url('@/assets/images/chat/chatbubbles/bubble_53.png'); } -} \ No newline at end of file +} diff --git a/src/css/widgets/FurnitureWidgets.css b/src/css/widgets/FurnitureWidgets.css index 71eae75..e5f9c4c 100644 --- a/src/css/widgets/FurnitureWidgets.css +++ b/src/css/widgets/FurnitureWidgets.css @@ -177,7 +177,6 @@ &.stickie-yellow { background-image: url('@/assets/images/room-widgets/stickie-widget/stickie-yellow.png'); - //background-position: -191px -184px; } &.stickie-green { diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index 911fd63..4422e27 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -226,6 +226,7 @@ const useNotificationState = () => { const parser = event.getParser(); + // Skip if AchievementNotificationMessageEvent already showed a notification for this badge if(recentBadgeNotifications.has(parser.badgeCode)) return; recentBadgeNotifications.add(parser.badgeCode); @@ -233,6 +234,9 @@ const useNotificationState = () => const badgeName = LocalizeBadgeName(parser.badgeCode); const badgeImage = GetSessionDataManager().getBadgeUrl(parser.badgeCode); + // senderName is non-empty only when a staff member awarded the badge + // via the `:badge` command. Empty for achievements, catalog buys, + // wired rewards, poll rewards, etc. const senderName = parser.senderName || ''; showSingleBubble(badgeName, NotificationBubbleType.BADGE_RECEIVED, badgeImage, parser.badgeCode, senderName); From 4afdfd8f33b9f4d25d739fb5e8df1467f78f958b Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 10 Apr 2026 09:26:50 +0200 Subject: [PATCH 12/17] =?UTF-8?q?=F0=9F=86=95=20YoutubeTV=20Broadcasting?= =?UTF-8?q?=20in=20rooms=20!?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/index.ts | 1 + src/api/youtube/YouTubeRoomState.ts | 4 ++ src/api/youtube/index.ts | 1 + .../NavigatorRoomSettingsMiscTabView.tsx | 58 +++++++++++++++++++ .../NavigatorRoomSettingsView.tsx | 6 +- src/components/toolbar/ToolbarView.tsx | 26 +++++++-- src/components/toolbar/YouTubePlayerView.tsx | 13 +++-- 7 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 src/api/youtube/YouTubeRoomState.ts create mode 100644 src/api/youtube/index.ts create mode 100644 src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx diff --git a/src/api/index.ts b/src/api/index.ts index 7089277..0f11ac4 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -26,3 +26,4 @@ export * from './room/widgets'; export * from './user'; export * from './utils'; export * from './wired'; +export * from './youtube'; diff --git a/src/api/youtube/YouTubeRoomState.ts b/src/api/youtube/YouTubeRoomState.ts new file mode 100644 index 0000000..364bf19 --- /dev/null +++ b/src/api/youtube/YouTubeRoomState.ts @@ -0,0 +1,4 @@ +let _youtubeEnabled = false; + +export const getYoutubeRoomEnabled = () => _youtubeEnabled; +export const setYoutubeRoomEnabled = (enabled: boolean) => { _youtubeEnabled = enabled; }; diff --git a/src/api/youtube/index.ts b/src/api/youtube/index.ts new file mode 100644 index 0000000..f8e540f --- /dev/null +++ b/src/api/youtube/index.ts @@ -0,0 +1 @@ +export * from './YouTubeRoomState'; diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx new file mode 100644 index 0000000..5d151d3 --- /dev/null +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsMiscTabView.tsx @@ -0,0 +1,58 @@ +import { YouTubeRoomSettingsComposer, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; +import { FC, useState } from 'react'; +import { getYoutubeRoomEnabled, IRoomData, LocalizeText, SendMessageComposer, setYoutubeRoomEnabled } from '../../../../api'; +import { useMessageEvent } from '../../../../hooks'; + +interface NavigatorRoomSettingsMiscTabViewProps +{ + roomData: IRoomData; +} + +export const NavigatorRoomSettingsMiscTabView: FC = props => +{ + const { roomData = null } = props; + const [ youtubeEnabled, setYoutubeEnabled ] = useState(getYoutubeRoomEnabled()); + const [ cooldown, setCooldown ] = useState(false); + + useMessageEvent(YouTubeRoomSettingsEvent, event => + { + setYoutubeEnabled(event.getParser().youtubeEnabled); + }); + + const toggleYouTube = (enabled: boolean) => + { + if (cooldown) return; + setYoutubeEnabled(enabled); + setYoutubeRoomEnabled(enabled); + SendMessageComposer(new YouTubeRoomSettingsComposer(enabled)); + setCooldown(true); + setTimeout(() => setCooldown(false), 300); + }; + + return ( + <> +
+
{ LocalizeText('product.type.other') }
+
+
+
+
+
+
đŸ“ē YouTube TV
+
Allow YouTube video broadcasting in this room
+
+ +
+
+
+ + ); +}; diff --git a/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx b/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx index 29b7f85..2db729f 100644 --- a/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx +++ b/src/components/navigator/views/room-settings/NavigatorRoomSettingsView.tsx @@ -7,6 +7,7 @@ import { NavigatorRoomSettingsAccessTabView } from './NavigatorRoomSettingsAcces import { NavigatorRoomSettingsBasicTabView } from './NavigatorRoomSettingsBasicTabView'; import { NavigatorRoomSettingsModTabView } from './NavigatorRoomSettingsModTabView'; import { NavigatorRoomSettingsRightsTabView } from './NavigatorRoomSettingsRightsTabView'; +import { NavigatorRoomSettingsMiscTabView } from './NavigatorRoomSettingsMiscTabView'; import { NavigatorRoomSettingsVipChatTabView } from './NavigatorRoomSettingsVipChatTabView'; const TABS: string[] = [ @@ -14,7 +15,8 @@ const TABS: string[] = [ 'navigator.roomsettings.tab.2', 'navigator.roomsettings.tab.3', 'navigator.roomsettings.tab.4', - 'navigator.roomsettings.tab.5' + 'navigator.roomsettings.tab.5', + 'product.type.other' ]; export const NavigatorRoomSettingsView: FC<{}> = props => @@ -205,6 +207,8 @@ export const NavigatorRoomSettingsView: FC<{}> = props => } { (currentTab === TABS[4]) && } + { (currentTab === TABS[5]) && + } ); diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 1788b76..3e7b98d 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -1,7 +1,7 @@ -import { CreateLinkEvent, Dispose, DropBounce, EaseOut, GetSessionDataManager, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait } from '@nitrots/nitro-renderer'; +import { CreateLinkEvent, Dispose, DropBounce, EaseOut, GetSessionDataManager, JumpBy, Motions, NitroToolbarAnimateIconEvent, PerkAllowancesMessageEvent, PerkEnum, Queue, Wait, YouTubeRoomSettingsEvent } from '@nitrots/nitro-renderer'; import { AnimatePresence, motion } from 'framer-motion'; -import { FC, useState } from 'react'; -import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, VisitDesktop } from '../../api'; +import { FC, useEffect, useState } from 'react'; +import { GetConfigurationValue, MessengerIconState, OpenMessengerChat, setYoutubeRoomEnabled, VisitDesktop } from '../../api'; import { Flex, LayoutAvatarImageView, LayoutItemCountView } from '../../common'; import { useAchievements, useFriends, useInventoryUnseenTracker, useMessageEvent, useMessenger, useNitroEvent, useSessionInfo } from '../../hooks'; import { ToolbarItemView } from './ToolbarItemView'; @@ -13,6 +13,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { isInRoom } = props; const [ isMeExpanded, setMeExpanded ] = useState(false); const [ useGuideTool, setUseGuideTool ] = useState(false); + const [ youtubeEnabled, setYoutubeEnabled ] = useState(false); const { userFigure = null } = useSessionInfo(); const { getFullCount = 0 } = useInventoryUnseenTracker(); const { getTotalUnseen = 0 } = useAchievements(); @@ -20,6 +21,20 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => const { iconState = MessengerIconState.HIDDEN } = useMessenger(); const isMod = GetSessionDataManager().isModerator; + useMessageEvent(YouTubeRoomSettingsEvent, event => + { + const enabled = event.getParser().youtubeEnabled; + setYoutubeEnabled(enabled); + setYoutubeRoomEnabled(enabled); + }); + + useEffect(() => { + if (!isInRoom) { + setYoutubeEnabled(false); + setYoutubeRoomEnabled(false); + } + }, [isInRoom]); + const openYouTubePlayer = () => { window.dispatchEvent(new CustomEvent('youtube:toggle')); @@ -71,7 +86,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => return ( <> - + { youtubeEnabled && } { isMeExpanded && ( )} @@ -101,7 +116,8 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => { isInRoom && CreateLinkEvent('camera/toggle') } /> } - + { youtubeEnabled && + } { isMod && CreateLinkEvent('mod-tools/toggle') } /> } { isMod && diff --git a/src/components/toolbar/YouTubePlayerView.tsx b/src/components/toolbar/YouTubePlayerView.tsx index 9b1507e..029b36f 100644 --- a/src/components/toolbar/YouTubePlayerView.tsx +++ b/src/components/toolbar/YouTubePlayerView.tsx @@ -1,7 +1,7 @@ -import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer"; +import { ControlYoutubeDisplayPlaybackMessageComposer, YouTubeRoomBroadcastEvent, YouTubeRoomPlayComposer, YouTubeRoomSettingsEvent, YouTubeRoomWatchersEvent, YouTubeRoomWatchingComposer } from "@nitrots/nitro-renderer"; import { FC, useEffect, useRef, useState } from "react"; import YouTube from "react-youtube"; -import { GetRoomSession, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from "../../api"; +import { GetRoomSession, getYoutubeRoomEnabled, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from "../../api"; import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } from "../../common"; import { useFurnitureYoutubeWidget, useMessageEvent } from "../../hooks"; @@ -36,14 +36,17 @@ export const YouTubePlayerView: FC<{}> = () => { const [history, setHistory] = useState([]); const [showVolumeSlider, setShowVolumeSlider] = useState(true); const playerRef = useRef(null); - const { objectId: youtubeObjectId, videoId: roomVideoId, currentVideoState, hasControl } = useFurnitureYoutubeWidget(); - const [spectators, setSpectators] = useState< { id: number; name: string; look: string }[] >([]); const [broadcastVideo, setBroadcastVideo] = useState(""); const [broadcastSender, setBroadcastSender] = useState(""); const [broadcastPlaylist, setBroadcastPlaylist] = useState([]); const [watcherIds, setWatcherIds] = useState>(new Set()); + const [youtubeEnabled, setYoutubeEnabled] = useState(getYoutubeRoomEnabled()); + + useMessageEvent(YouTubeRoomSettingsEvent, event => { + setYoutubeEnabled(event.getParser().youtubeEnabled); + }); useMessageEvent(YouTubeRoomBroadcastEvent, event => { const parser = event.getParser(); setBroadcastVideo(parser.videoId); @@ -385,7 +388,7 @@ export const YouTubePlayerView: FC<{}> = () => { className={`flex-1 p-2 rounded text-white text-sm ${(!!broadcastVideo && !isMyRoom) ? "bg-gray-800" : "bg-gray-700"}`} placeholder="YouTube URL / video ID" /> - {isMyRoom && videoId && ( + {isMyRoom && youtubeEnabled && videoId && (
-