diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d3e6978 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +## Badge System Rework (2026-04-04) + +### Bug Fixes +- **Slot 0 drag bug**: Dragging from slot 0 no longer causes badges to disappear. The root cause was `'0'` being falsy in JavaScript, which made the drop handler take the wrong code path and overwrite the target badge. +- **Badge duplication**: Fixed badges appearing in multiple slots when dragging in the InfoStand. The issue was a stale props fallback — after a drag operation, the hook updated correctly but the component fell back to old server props for empty slots, showing ghost copies. +- **Race condition**: Replaced single boolean `localChangeRef` with a counter (`pendingUpdatesRef`) to correctly handle rapid sequential drag operations without the server overwriting local state. +- **Badge deduplication**: `toFixedSlots()` now deduplicates badges, preventing the same badge from appearing in multiple slots even if the server returns duplicates. +- **Server badge dedup in InfoStand**: `RoomSessionUserBadgesEvent` handler now deduplicates badges from the server before updating the avatar info. + +### Drag & Drop Visual Feedback +- **Custom drag preview**: Badge image is used as the drag ghost instead of the browser default (via `setDragImage`). +- **Source opacity**: The dragged item becomes semi-transparent (`opacity-40`) during drag. +- **Pulsing glow on drop targets**: Valid drop targets pulse with a blue glow animation (`animate-pulse-glow`). +- **Drop settle animation**: A brief scale-down animation (`animate-drop-settle`, 300ms) plays when a badge lands in a slot. +- **Remove indicator**: Dragging an active badge over the inventory area shows a red pulsing background with a trash icon overlay. +- **Grab cursor**: All draggable badge elements now show `cursor-grab` / `cursor-grabbing`. + +### Sparse Slot Support +- `activeBadgeCodes` changed from compact `string[]` to fixed-size `(string | null)[]` array. Empty slots are `null` instead of being collapsed, allowing gaps between badges. +- All operations (`setBadgeAtSlot`, `removeBadge`, `reorderBadges`, `swapBadges`, `toggleBadge`) work on the fixed-size array without compaction. + +### New Badge Glow (Feature) +- Unseen (newly received) badges in the inventory now pulse with a **gold glow** (`animate-pulse-glow-gold`) instead of the previous flat green background. +- The glow disappears when the badge is selected (unseen status cleared). + +### Badge Received Toast Notification (Feature) +- When a new badge is received, a bubble notification appears with: + - Badge image and localized name + - **"Indossa" / "Wear"** button that directly equips the badge via `toggleBadge` and closes the notification + - **"Non ora" / "Later"** link to dismiss +- Auto-fades after 8 seconds (standard bubble behavior). +- Uses the existing `NotificationBubbleType.BADGE_RECEIVED` (was defined but unused). +- New component: `NotificationBadgeReceivedBubbleView`. + +### Dynamic Badge Slot Count +- Badge slot count is now fully driven by `user.badges.max.slots` config (default: 5). + - **5 slots**: 5 badge slots + group badge in InfoStand (6 boxes total) + - **6 slots**: 6 badge slots, group badge is replaced by the 6th slot +- Both the inventory grid and InfoStand layout adapt automatically. +- Removed all hardcoded `maxSlots = 5` references. + +### InfoStand Double-Click to Remove +- Double-clicking a badge in the InfoStand removes it from active badges (own user only). + +### Localization +- Added `notification.badge.received` key: + - IT: "Nuovo Distintivo!" + - EN: "New Badge!" +- Located in `public/nitro-assets/config/UITexts.json` and `UITexts_en.json`. + +### Files Modified +| File | Changes | +|------|---------| +| `src/hooks/inventory/useInventoryBadges.ts` | Sparse slots, dedup, race condition fix, toFixedSlots | +| `src/hooks/notification/useNotification.ts` | BadgeReceivedEvent listener | +| `src/components/inventory/views/badge/InventoryBadgeView.tsx` | Visual feedback, dynamic maxSlots, fix '0' falsy | +| `src/components/inventory/views/badge/InventoryBadgeItemView.tsx` | Drag preview, opacity, cursor | +| `src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx` | Visual feedback, double-click remove, no stale props | +| `src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx` | Dynamic layout, server badge dedup | +| `src/components/notification-center/views/bubble-layouts/GetBubbleLayout.tsx` | BADGE_RECEIVED routing | +| `src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx` | New component | +| `src/layout/InfiniteGrid.tsx` | Gold glow for unseen items | +| `tailwind.config.js` | Custom keyframes and animations | + +### Configuration +```json +{ + "user.badges.max.slots": 5 +} +``` +Set to `6` to replace the group badge slot with a 6th badge slot. diff --git a/localization/badge-texts-en.json b/localization/badge-texts-en.json new file mode 100644 index 0000000..a8ab19a --- /dev/null +++ b/localization/badge-texts-en.json @@ -0,0 +1,3 @@ +{ + "notification.badge.received": "New Badge!" +} diff --git a/localization/badge-texts-it.json b/localization/badge-texts-it.json new file mode 100644 index 0000000..10c1271 --- /dev/null +++ b/localization/badge-texts-it.json @@ -0,0 +1,3 @@ +{ + "notification.badge.received": "Nuovo Distintivo!" +} diff --git a/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/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/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/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/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/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/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/inventory/views/badge/InventoryBadgeItemView.tsx b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx index 4bf666b..f11ca02 100644 --- a/src/components/inventory/views/badge/InventoryBadgeItemView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeItemView.tsx @@ -1,5 +1,5 @@ -import { FC, PropsWithChildren } from 'react'; -import { UnseenItemCategory } from '../../../../api'; +import { FC, PropsWithChildren, useState } from 'react'; +import { GetConfigurationValue, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; import { useInventoryBadges, useInventoryUnseenTracker } from '../../../../hooks'; import { InfiniteGrid } from '../../../../layout'; @@ -10,20 +10,31 @@ export const InventoryBadgeItemView: FC const { selectedBadgeCode = null, setSelectedBadgeCode = null, toggleBadge = null, getBadgeId = null } = useInventoryBadges(); const { isUnseen = null } = useInventoryUnseenTracker(); const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode)); + const [ isDragging, setIsDragging ] = useState(false); const onDragStart = (event: React.DragEvent) => { event.dataTransfer.setData('badgeCode', badgeCode); event.dataTransfer.setData('source', 'inventory'); event.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode); + const img = new Image(); + img.src = badgeUrl; + event.dataTransfer.setDragImage(img, 20, 20); }; + const onDragEnd = () => setIsDragging(false); + return ( toggleBadge(selectedBadgeCode) } + onDragEnd={ onDragEnd } onDragStart={ onDragStart } onMouseDown={ event => setSelectedBadgeCode(badgeCode) } { ...rest }> diff --git a/src/components/inventory/views/badge/InventoryBadgeView.tsx b/src/components/inventory/views/badge/InventoryBadgeView.tsx index 1bf6d13..f1fad00 100644 --- a/src/components/inventory/views/badge/InventoryBadgeView.tsx +++ b/src/components/inventory/views/badge/InventoryBadgeView.tsx @@ -1,7 +1,7 @@ import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer'; -import { FC, useCallback, useEffect, useState } from 'react'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; -import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; +import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api'; import { LayoutBadgeImageView } from '../../../../common'; import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '../../../../hooks'; import { InfiniteGrid, NitroButton } from '../../../../layout'; @@ -18,6 +18,8 @@ const ActiveBadgeSlot: FC<{ }> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) => { const [ isDragOver, setIsDragOver ] = useState(false); + const [ isDragging, setIsDragging ] = useState(false); + const [ justDropped, setJustDropped ] = useState(false); const onDragOver = useCallback((event: React.DragEvent) => { @@ -35,24 +37,36 @@ const ActiveBadgeSlot: FC<{ const droppedBadgeCode = event.dataTransfer.getData('badgeCode'); const sourceSlotStr = event.dataTransfer.getData('activeSlot'); - const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined; + const sourceSlot = sourceSlotStr !== '' ? parseInt(sourceSlotStr) : undefined; - if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot); + if(droppedBadgeCode) + { + onDropBadge(droppedBadgeCode, slotIndex, sourceSlot); + setJustDropped(true); + setTimeout(() => setJustDropped(false), 300); + } }, [ slotIndex, onDropBadge ]); const onDragStart = useCallback((event: React.DragEvent) => { if(!badgeCode) return; onDragStartFromSlot(event, badgeCode, slotIndex); + setIsDragging(true); }, [ badgeCode, slotIndex, onDragStartFromSlot ]); + const onDragEnd = useCallback(() => setIsDragging(false), []); + return (
= props = const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker(); const { showConfirm = null } = useNotification(); const [ isDragOverInventory, setIsDragOverInventory ] = useState(false); + const [ isDraggingFromActive, setIsDraggingFromActive ] = useState(false); - const maxSlots = 5; + const maxSlots = useMemo(() => GetConfigurationValue('user.badges.max.slots', 5), []); const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes); const attemptDeleteBadge = () => @@ -95,12 +110,10 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = { if(sourceSlot !== undefined) { - // Reorder within active badges reorderBadges(sourceSlot, slotIndex); } else { - // Drop from inventory to active slot setBadgeAtSlot(badgeCode, slotIndex); } }, [ setBadgeAtSlot, reorderBadges ]); @@ -111,6 +124,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = event.dataTransfer.setData('activeSlot', slotIndex.toString()); event.dataTransfer.setData('source', 'active'); event.dataTransfer.effectAllowed = 'move'; + + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode); + const img = new Image(); + img.src = badgeUrl; + event.dataTransfer.setDragImage(img, 20, 20); }, []); const handleRemoveBadge = useCallback((badgeCode: string) => @@ -121,18 +139,24 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = // Handle drop on inventory area (remove from active) const onInventoryDragOver = useCallback((event: React.DragEvent) => { - const source = event.dataTransfer.types.includes('activeslot') ? 'active' : ''; event.preventDefault(); event.dataTransfer.dropEffect = 'move'; + const fromActive = event.dataTransfer.types.includes('activeslot'); + setIsDraggingFromActive(fromActive); setIsDragOverInventory(true); }, []); - const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []); + const onInventoryDragLeave = useCallback(() => + { + setIsDragOverInventory(false); + setIsDraggingFromActive(false); + }, []); const onInventoryDrop = useCallback((event: React.DragEvent) => { event.preventDefault(); setIsDragOverInventory(false); + setIsDraggingFromActive(false); const badgeCode = event.dataTransfer.getData('badgeCode'); const source = event.dataTransfer.getData('source'); @@ -169,10 +193,18 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props = return (
+ { isDragOverInventory && isDraggingFromActive && ( +
+ + { LocalizeText('inventory.badges.clearbadge') } +
+ ) } columnCount={ 5 } estimateSize={ 50 } diff --git a/src/components/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/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..8c1154e --- /dev/null +++ b/src/components/notification-center/views/bubble-layouts/NotificationBadgeReceivedBubbleView.tsx @@ -0,0 +1,70 @@ +import { RequestBadgesComposer } from '@nitrots/nitro-renderer'; +import { FC, useEffect } from 'react'; +import { LocalizeText, NotificationBubbleItem, SendMessageComposer } from '../../../../api'; +import { Flex, LayoutNotificationBubbleView, LayoutNotificationBubbleViewProps, Text } from '../../../../common'; +import { useInventoryBadges } from '../../../../hooks'; + +export interface NotificationBadgeReceivedBubbleViewProps extends LayoutNotificationBubbleViewProps +{ + item: NotificationBubbleItem; +} + +export const NotificationBadgeReceivedBubbleView: FC = props => +{ + const { item = null, onClose = null, ...rest } = props; + const { badgeCodes = [], toggleBadge = null } = useInventoryBadges(); + + useEffect(() => + { + if(badgeCodes.length === 0) SendMessageComposer(new RequestBadgesComposer()); + }, []); + + const handleWear = (event: React.MouseEvent) => + { + event.stopPropagation(); + + if(item.linkUrl) + { + toggleBadge(item.linkUrl); + } + + if(onClose) onClose(); + }; + + const handleDismiss = (event: React.MouseEvent) => + { + event.stopPropagation(); + if(onClose) onClose(); + }; + + return ( + +
e.stopPropagation() }> + + + { item.iconUrl && } + + + + { item.senderName + ? LocalizeText('notifications.text.received.badge', [ 'user_name' ], [ item.senderName ]) + : LocalizeText('prereg.reward.you.received') } + + { item.message } + + + + + + { LocalizeText('notifications.button.later') } + + +
+
+ ); +}; diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx index 6a504d2..267f535 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx @@ -1,6 +1,6 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { FaPlus } from 'react-icons/fa'; -import { LocalizeText } from '../../../../../api'; +import { GetConfigurationValue, LocalizeText } from '../../../../../api'; import { LayoutBadgeImageView } from '../../../../../common'; import { useInventoryBadges } from '../../../../../hooks'; @@ -14,7 +14,7 @@ interface InfoStandBadgeSlotProps const BadgeMiniPicker: FC<{ onSelect: (badgeCode: string) => void; onClose: () => void; - activeBadgeCodes: string[]; + activeBadgeCodes: (string | null)[]; }> = ({ onSelect, onClose, activeBadgeCodes }) => { const { badgeCodes = [], requestBadges = null } = useInventoryBadges(); @@ -26,7 +26,8 @@ const BadgeMiniPicker: FC<{ if(badgeCodes.length === 0) requestBadges(); }, []); - const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code)); + const activeSet = new Set(activeBadgeCodes.filter(Boolean)); + const availableBadges = badgeCodes.filter(code => !activeSet.has(code)); const filtered = search.length > 0 ? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase())) : availableBadges; @@ -78,12 +79,24 @@ const BadgeMiniPicker: FC<{ export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) => { - const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges(); + const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null, removeBadge = null, requestBadges = null } = useInventoryBadges(); const [ isDragOver, setIsDragOver ] = useState(false); + const [ isDragging, setIsDragging ] = useState(false); + const [ justDropped, setJustDropped ] = useState(false); const [ showPicker, setShowPicker ] = useState(false); - const hookBadge = activeBadgeCodes.length > 0 ? (activeBadgeCodes[slotIndex] ?? null) : null; - const badgeCode = isOwnUser ? (hookBadge ?? badgeCodeFromProps ?? null) : (badgeCodeFromProps ?? null); + const hookInitialized = activeBadgeCodes.length > 0; + + // Load badge data for own user so hook is initialized before any DnD + useEffect(() => + { + if(isOwnUser && !hookInitialized) requestBadges(); + }, [ isOwnUser, hookInitialized, requestBadges ]); + const hookBadge = hookInitialized ? (activeBadgeCodes[slotIndex] ?? null) : null; + // Once hook has data, use ONLY hook data for own user (no stale props fallback) + const badgeCode = isOwnUser + ? (hookInitialized ? hookBadge : (badgeCodeFromProps ?? null)) + : (badgeCodeFromProps ?? null); const onDragStart = useCallback((event: React.DragEvent) => { @@ -91,8 +104,16 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, event.dataTransfer.setData('badgeCode', badgeCode); event.dataTransfer.setData('infostandSlot', slotIndex.toString()); event.dataTransfer.effectAllowed = 'move'; + setIsDragging(true); + + const badgeUrl = GetConfigurationValue('badge.asset.url').replace('%badgename%', badgeCode); + const img = new Image(); + img.src = badgeUrl; + event.dataTransfer.setDragImage(img, 20, 20); }, [ badgeCode, slotIndex, isOwnUser ]); + const onDragEnd = useCallback(() => setIsDragging(false), []); + const onDragOver = useCallback((event: React.DragEvent) => { if(!isOwnUser) return; @@ -124,6 +145,9 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, { setBadgeAtSlot(droppedBadgeCode, slotIndex); } + + setJustDropped(true); + setTimeout(() => setJustDropped(false), 300); }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]); const handleSlotClick = useCallback(() => @@ -133,6 +157,13 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, setShowPicker(true); }, [ isOwnUser, badgeCode ]); + const handleDoubleClick = useCallback(() => + { + if(!isOwnUser || !badgeCode) return; + + removeBadge(badgeCode); + }, [ isOwnUser, badgeCode, removeBadge ]); + const handlePickerSelect = useCallback((code: string) => { setBadgeAtSlot(code, slotIndex); @@ -145,15 +176,19 @@ export const InfoStandBadgeSlotView: FC = ({ slotIndex, className={ `flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center transition-all duration-150 ${ isOwnUser && badgeCode ? 'cursor-grab active:cursor-grabbing' : '' } ${ isOwnUser && !badgeCode ? 'cursor-pointer' : '' } - ${ isOwnUser ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' } - ${ isDragOver ? 'scale-115 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15' : '' } + ${ isDragging ? 'opacity-30 scale-90' : '' } + ${ isOwnUser && !isDragging ? 'hover:scale-110 hover:brightness-125 hover:drop-shadow-[0_0_6px_rgba(255,255,255,0.3)]' : '' } + ${ isDragOver ? 'scale-110 ring-2 ring-blue-400/60 rounded-sm bg-blue-400/15 animate-pulse-glow' : '' } + ${ justDropped ? 'animate-drop-settle' : '' } ${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` } draggable={ isOwnUser && !!badgeCode } + onDragEnd={ onDragEnd } onDragLeave={ onDragLeave } onDragOver={ onDragOver } onDragStart={ onDragStart } onDrop={ onDrop } - onClick={ handleSlotClick }> + onClick={ handleSlotClick } + onDoubleClick={ handleDoubleClick }> { badgeCode ? : isOwnUser && } diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index 12aca46..91ced43 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -1,5 +1,6 @@ +import React from 'react'; import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusInfoMessageParser, RoomSessionFavoriteGroupUpdateEvent, RoomSessionUserBadgesEvent, RoomSessionUserFigureUpdateEvent, UserRelationshipsComposer } from '@nitrots/nitro-renderer'; -import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react'; +import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useMemo, useState } from 'react'; import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; @@ -56,15 +57,23 @@ export const InfoStandWidgetUserView: FC = props = useNitroEvent(RoomSessionUserBadgesEvent.RSUBE_BADGES, event => { if (!avatarInfo || avatarInfo.webID !== event.userId) return; + // Deduplicate badges from server + const seen = new Set(); + const dedupedBadges = event.badges.map(code => { + if (!code || seen.has(code)) return ''; + seen.add(code); + return code; + }); + const oldBadges = avatarInfo.badges.join(''); - if (oldBadges === event.badges.join('')) return; + if (oldBadges === dedupedBadges.join('')) return; setAvatarInfo(prevValue => { if (!prevValue) return prevValue; const newValue = CloneObject(prevValue); - newValue.badges = event.badges; + newValue.badges = dedupedBadges; return newValue; }); }); @@ -165,43 +174,38 @@ export const InfoStandWidgetUserView: FC = props = /> )} - { GetConfigurationValue('user.badges.group.slot.enabled', true) - ? ( - <> -
- - 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> - {avatarInfo.groupId > 0 && - } - -
- - - + { (() => { + const maxSlots = GetConfigurationValue('user.badges.max.slots', 5); + const isOwnUser = avatarInfo.type === AvatarInfoUser.OWN_USER; + const showGroup = maxSlots <= 5; + + const items: React.ReactNode[] = []; + items.push(); + + if(showGroup) { + items.push( + 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}> + {avatarInfo.groupId > 0 && } - - - - - - ) - : ( - <> - - - - - - - - - - - - - - ) - } + ); + } else { + items.push(); + } + + const startIdx = showGroup ? 1 : 2; + for(let i = startIdx; i < maxSlots; i++) { + items.push(); + } + + const rows: React.ReactNode[][] = []; + for(let i = 0; i < items.length; i += 2) { + rows.push(items.slice(i, i + 2)); + } + + return rows.map((row, idx) => ( + {row} + )); + })() }

diff --git a/src/components/toolbar/ToolbarView.tsx b/src/components/toolbar/ToolbarView.tsx index 3f8e76c..3e7b98d 100644 --- a/src/components/toolbar/ToolbarView.tsx +++ b/src/components/toolbar/ToolbarView.tsx @@ -1,17 +1,19 @@ -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'; import { ToolbarMeView } from './ToolbarMeView'; +import { YouTubePlayerView } from './YouTubePlayerView'; 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(); @@ -19,6 +21,25 @@ 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')); + }; + useMessageEvent(PerkAllowancesMessageEvent, event => { setUseGuideTool(event.getParser().isAllowed(PerkEnum.USE_GUIDE_TOOL)); @@ -65,6 +86,7 @@ export const ToolbarView: FC<{ isInRoom: boolean }> = props => return ( <> + { youtubeEnabled && } { isMeExpanded && ( )} @@ -94,6 +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 new file mode 100644 index 0000000..029b36f --- /dev/null +++ b/src/components/toolbar/YouTubePlayerView.tsx @@ -0,0 +1,740 @@ +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, getYoutubeRoomEnabled, GetSessionDataManager, LocalizeText, SendMessageComposer, YoutubeVideoPlaybackStateEnum } from "../../api"; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView, LayoutAvatarImageView } 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 }[] >([]); + 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); + setBroadcastSender(parser.senderName); + setBroadcastPlaylist(parser.playlist); + if (parser.videoId) { + setInputValue(parser.videoId); + setIsOpen(true); + setTab("player"); + } else { + setInputValue(""); + setBroadcastVideo(""); + setBroadcastSender(""); + setBroadcastPlaylist([]); + } + }); + + useMessageEvent(YouTubeRoomWatchersEvent, event => { setWatcherIds(new Set(event.getParser().watcherIds)); loadRoomUsers(); }); + + const sentWatchingRef = useRef(false); + const hasVideo = !!(inputValue && extractVideoId(inputValue)); + useEffect(() => { + if (isOpen && hasVideo && !sentWatchingRef.current) { + try { SendMessageComposer(new YouTubeRoomWatchingComposer(true)); } catch(e) {} + sentWatchingRef.current = true; + } else if ((!isOpen || !hasVideo) && sentWatchingRef.current) { + try { SendMessageComposer(new YouTubeRoomWatchingComposer(false)); } catch(e) {} + sentWatchingRef.current = false; + } + }, [isOpen, hasVideo]); + + 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 && !seen.has(userData.userId)) { + seen.add(userData.userId); + users.push({ id: userData.userId, name: userData.name, look: userData.figure }); + } + } + setSpectators(users); + } catch (e) { + setSpectators([]); + } + }; + + useEffect(() => { + if (isOpen) loadRoomUsers(); + }, [isOpen]); + + useEffect(() => { + if (youtubeObjectId && youtubeObjectId !== -1) { + setIsRoomMode(true); + if (roomVideoId) { + setInputValue(roomVideoId); + } + } else { + setIsRoomMode(false); + } + }, [youtubeObjectId, roomVideoId]); + + useEffect(() => { + 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)) { + 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 roomSession = GetRoomSession(); + const isMyRoom = GetSessionDataManager().isModerator || (roomSession && roomSession.isRoomOwner); + + const QuickVolumeButton = ({ + value, + label, + }: { + value: number; + label: string; + }) => ( + + ); + + return ( + + setIsOpen(false)} + /> + +
+ + + + + {watcherIds.size > 0 && ( + + )} + +
+ + {tab === "player" && ( + <> + {isRoomMode && ( +
+ + đŸ“ē Connected with YouTube TV + +
+ {isPlaying && ( + + â–ļ { LocalizeText('connection.login.play') } + + )} + {isPaused && ( + + ⏸ { LocalizeText('wiredfurni.params.clock_control.3') } + + )} + {isMyRoom && ( + + ✓ { LocalizeText('navigator.filter.owner') } + + )} +
+
+ )} + + {videoId ? ( + { + playerRef.current = e.target; + addToHistory(videoId); + }} + /> + ) : ( +
+ { LocalizeText('widget.furni.video_viewer.no_videos') } +
+ )} + + {isRoomMode && hasControl && ( +
+ + + +
+ )} + + {broadcastVideo && broadcastSender && ( +
+ 📡 {broadcastSender} broadcasting + {isMyRoom && ( + + )} +
+ )} + +
+ 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 / video ID" + /> + {isMyRoom && youtubeEnabled && videoId && ( + + )} +
+ + )} + + {tab === "playlist" && ( +
+
+ setInputValue(e.target.value)} + placeholder="Add video URL..." + className="flex-1 p-2 bg-gray-700 text-white rounded text-sm" + onKeyDown={(e) => + e.key === "Enter" && addToPlaylist() + } + /> + +
+
+ + +
+ {playlist.length === 0 ? ( +
+ Playlist is empty +
+ ) : ( +
+ {playlist.map((id, i) => ( +
{ + setInputValue(id); + setTab("player"); + }} + > + + {i + 1}. + +
+ {id} +
+ +
+ ))} +
+ )} +
+ )} + + {tab === "history" && ( +
+
+
+ 🕐 Watch history ({history.length}) +
+ +
+ {history.length === 0 ? ( +
+ No videos watched yet +
+ ) : ( +
+ {history.map((id, i) => ( +
{ + setInputValue(id); + setTab("player"); + }} + > +
+ {id} +
+
+ ))} +
+ )} +
+ )} + + {tab === "share" && ( +
+
+
+ 📤 Share video +
+ {videoId ? ( +
+
+ + +
+
+ ) : ( +
+ Select a video first to share +
+ )} +
+
+
+ 📋 Quick share +
+
+ + +
+
+
+ )} + + {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 ( +
+
+
+ đŸ“ē {watchers.length} watching +
+ +
+ {watchers.length === 0 ? ( +
+ No one is watching +
+ ) : ( +
+ {watchers.map((user) => ( +
+
+ +
+ + {user.name} + + đŸ“ē +
+ ))} +
+ )} +
+ ); + })()} + + {tab === "settings" && ( +
+
+
+ + +
+ {showVolumeSlider && ( + { + setVolume(parseInt(e.target.value)); + setVolumePreset( + parseInt(e.target.value), + ); + }} + className="w-full" + /> + )} +
+ + + + + +
+
+ +
+ + + +
+ +
+
â„šī¸ Info
+
+ 📡 Broadcast:{" "} + {broadcastVideo + ? ✓ Active ({broadcastSender} playing) + : ✕ No video} +
+
+ 🎮 Controle:{" "} + {isMyRoom + ? ✓ You are the owner + : ✕ Viewing only} +
+
+ đŸ‘ī¸ Viewers:{" "} + {watcherIds.size} +
+
+
+ )} +
+
+ ); +}; 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/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/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/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/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts index 39e0667..f456c1f 100644 --- a/src/hooks/inventory/useInventoryBadges.ts +++ b/src/hooks/inventory/useInventoryBadges.ts @@ -11,19 +11,31 @@ const useInventoryBadgesState = () => const [ needsUpdate, setNeedsUpdate ] = useState(true); const [ badgeCodes, setBadgeCodes ] = useState([]); const [ badgeIds, setBadgeIds ] = useState>(new Map()); - const [ activeBadgeCodes, setActiveBadgeCodes ] = useState([]); + const [ activeBadgeCodes, setActiveBadgeCodes ] = useState<(string | null)[]>([]); const [ selectedBadgeCode, setSelectedBadgeCode ] = useState(null); const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility(); const { isUnseen = null, resetCategory = null } = useInventoryUnseenTracker(); const maxBadgeCount = GetConfigurationValue('user.badges.max.slots', 5); - const localChangeRef = useRef(false); - const isWearingBadge = (badgeCode: string) => (activeBadgeCodes.indexOf(badgeCode) >= 0); - const canWearBadges = () => (activeBadgeCodes.length < maxBadgeCount); + const pendingUpdatesRef = useRef(0); + const isWearingBadge = (badgeCode: string) => activeBadgeCodes.some(code => code === badgeCode); + const canWearBadges = () => (activeBadgeCodes.filter(Boolean).length < maxBadgeCount); - const sendActiveBadges = (badges: string[]) => + const toFixedSlots = (arr: (string | null)[]): (string | null)[] => { - localChangeRef.current = true; + const seen = new Set(); + return Array.from({ length: maxBadgeCount }, (_, i) => + { + const code = arr[i] || null; + if(!code || seen.has(code)) return null; + seen.add(code); + return code; + }); + }; + + const sendActiveBadges = (badges: (string | null)[]) => + { + pendingUpdatesRef.current++; const composer = new SetActivatedBadgesComposer(); for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? ''); SendMessageComposer(composer); @@ -33,24 +45,23 @@ const useInventoryBadgesState = () => { setActiveBadgeCodes(prevValue => { - const newValue = [ ...prevValue ]; - - const index = newValue.indexOf(badgeCode); + const slots = toFixedSlots(prevValue); + const index = slots.indexOf(badgeCode); if(index === -1) { - if(newValue.length >= maxBadgeCount) return prevValue; + const emptySlot = slots.indexOf(null); + if(emptySlot === -1) return prevValue; - newValue.push(badgeCode); + slots[emptySlot] = badgeCode; } else { - newValue.splice(index, 1); + slots[index] = null; } - sendActiveBadges(newValue); - - return newValue; + sendActiveBadges(slots); + return slots; }); }; @@ -82,14 +93,15 @@ const useInventoryBadgesState = () => return newValue; }); - // Skip overwriting activeBadgeCodes if we recently made a local change - if(localChangeRef.current) + // Skip overwriting activeBadgeCodes if we have pending local changes + if(pendingUpdatesRef.current > 0) { - localChangeRef.current = false; + pendingUpdatesRef.current--; } else { - setActiveBadgeCodes(parser.getActiveBadgeCodes()); + const serverBadges = parser.getActiveBadgeCodes(); + setActiveBadgeCodes(toFixedSlots(serverBadges)); } setBadgeCodes(allBadgeCodes); @@ -159,8 +171,7 @@ const useInventoryBadgesState = () => { setActiveBadgeCodes(prevValue => { - // Build a fixed-size array of maxBadgeCount slots - const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); + const slots = toFixedSlots(prevValue); // Remove badge if already in another slot const existingIndex = slots.indexOf(badgeCode); @@ -169,11 +180,8 @@ const useInventoryBadgesState = () => // Place badge at target slot slots[slotIndex] = badgeCode; - // Compact: remove nulls, keep order - const result = slots.filter(Boolean) as string[]; - - sendActiveBadges(result); - return result; + sendActiveBadges(slots); + return slots; }); }; @@ -181,10 +189,14 @@ const useInventoryBadgesState = () => { setActiveBadgeCodes(prevValue => { - const result = prevValue.filter(code => code !== badgeCode); + const slots = toFixedSlots(prevValue); + const index = slots.indexOf(badgeCode); + if(index === -1) return prevValue; - sendActiveBadges(result); - return result; + slots[index] = null; + + sendActiveBadges(slots); + return slots; }); }; @@ -193,14 +205,14 @@ const useInventoryBadgesState = () => setActiveBadgeCodes(prevValue => { if(fromIndex === toIndex) return prevValue; - if(fromIndex >= prevValue.length) return prevValue; - const newValue = [ ...prevValue ]; - const [ moved ] = newValue.splice(fromIndex, 1); - newValue.splice(toIndex, 0, moved); + const slots = toFixedSlots(prevValue); + const temp = slots[fromIndex]; + slots[fromIndex] = slots[toIndex]; + slots[toIndex] = temp; - sendActiveBadges(newValue); - return newValue; + sendActiveBadges(slots); + return slots; }); }; @@ -210,19 +222,13 @@ const useInventoryBadgesState = () => { if(fromIndex === toIndex) return prevValue; - // Build fixed-size array so swap works even with empty slots - const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null); - - // Swap the two slots + const slots = toFixedSlots(prevValue); const temp = slots[fromIndex]; slots[fromIndex] = slots[toIndex]; slots[toIndex] = temp; - // Compact: remove nulls, keep order - const result = slots.filter(Boolean) as string[]; - - sendActiveBadges(result); - return result; + sendActiveBadges(slots); + return slots; }); }; diff --git a/src/hooks/notification/useNotification.ts b/src/hooks/notification/useNotification.ts index 41e85d1..4422e27 100644 --- a/src/hooks/notification/useNotification.ts +++ b/src/hooks/notification/useNotification.ts @@ -1,4 +1,4 @@ -import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer'; +import { AchievementNotificationMessageEvent, ActivityPointNotificationMessageEvent, BadgeReceivedEvent, ClubGiftNotificationEvent, ClubGiftSelectedEvent, ConnectionErrorEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, HabboBroadcastMessageEvent, HotelClosedAndOpensEvent, HotelClosesAndWillOpenAtEvent, HotelWillCloseInMinutesEvent, InfoFeedEnableMessageEvent, MaintenanceStatusMessageEvent, ModeratorCautionEvent, ModeratorMessageEvent, MOTDNotificationEvent, NotificationDialogMessageEvent, PetLevelNotificationEvent, PetReceivedMessageEvent, RespectReceivedEvent, RoomEnterEffect, RoomEnterEvent, SimpleAlertMessageEvent, UserBannedMessageEvent, Vector3d, WiredRewardResultMessageEvent } from '@nitrots/nitro-renderer'; import { useCallback, useState } from 'react'; import { useBetween } from 'use-between'; import { GetConfigurationValue, LocalizeBadgeName, LocalizeText, NotificationAlertItem, NotificationAlertType, NotificationBubbleItem, NotificationBubbleType, NotificationConfirmItem, PlaySound, ProductImageUtility, TradingNotificationType } from '../../api'; @@ -14,6 +14,7 @@ const getTimeZeroPadded = (time: number) => }; let modDisclaimerTimeout: ReturnType = null; +const recentBadgeNotifications = new Set(); const useNotificationState = () => { @@ -67,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 ]); @@ -209,12 +210,36 @@ 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); + // 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); }); useMessageEvent(ClubGiftNotificationEvent, event => @@ -335,6 +360,9 @@ const useNotificationState = () => { const parser = event.getParser(); + // Skip badge notifications — handled by BadgeReceivedEvent with "Wear" button + if(parser.type === 'badge_received' || parser.type === 'badges' || parser.type.includes('badge')) return; + showNotification(parser.type, parser.parameters); }); diff --git a/src/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; diff --git a/src/layout/InfiniteGrid.tsx b/src/layout/InfiniteGrid.tsx index cb58e75..147ae01 100644 --- a/src/layout/InfiniteGrid.tsx +++ b/src/layout/InfiniteGrid.tsx @@ -172,7 +172,7 @@ const InfiniteGridItem = forwardRef 0)) && 'unique-item', itemUniqueSoldout && 'sold-out', - itemUnseen && ' bg-green-500 bg-opacity-40', + itemUnseen && ' animate-pulse-glow-gold border-yellow-400/60', className ) } style={ styleNames( diff --git a/tailwind.config.js b/tailwind.config.js index 1932f7b..d1bdbd6 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -144,7 +144,11 @@ module.exports = { 'grid-rows-11', 'grid-rows-12', 'justify-end', - 'items-end' + 'items-end', + 'animate-pulse-glow', + 'animate-pulse-glow-red', + 'animate-drop-settle', + 'animate-pulse-glow-gold' ], darkMode: 'class', variants: {