{ LocalizeText('inventory.badges.activebadges') }
-
- columnCount={ 3 }
- estimateSize={ 50 }
- itemRender={ item => }
- items={ activeBadgeCodes } />
+
+ { Array.from({ length: maxSlots }).map((_, index) => (
+
+ )) }
+
{ !!selectedBadgeCode &&
diff --git a/src/components/plugins/NitroPluginApi.ts b/src/components/plugins/NitroPluginApi.ts
index d52ffa0..5fbbf8b 100644
--- a/src/components/plugins/NitroPluginApi.ts
+++ b/src/components/plugins/NitroPluginApi.ts
@@ -1,5 +1,5 @@
-import { GetRoomEngine } from '@nitrots/nitro-renderer';
-import { CreateLinkEvent, GetRoomSession, SendMessageComposer } from '../../api';
+import { FurnitureStackHeightComposer, GetRoomEngine, TextureUtils } from '@nitrots/nitro-renderer';
+import { CreateLinkEvent, GetRoomSession, SendMessageComposer, VisitDesktop } from '../../api';
/**
* Plugin descriptor registered by external plugin scripts.
@@ -39,6 +39,14 @@ export interface INitroPluginApi
getRoomSession: () => ReturnType
;
/** Send a message composer to the server */
sendMessage: typeof SendMessageComposer;
+ /** Send a chat message to the server (processed as command if starts with ':') */
+ sendChat: (text: string, styleId?: number) => void;
+ /** Send stack height update for a furniture item (objectId, heightInCentimeters) */
+ sendStackHeight: (objectId: number, height: number) => void;
+ /** Take a screenshot of the room and download it as PNG */
+ takeScreenshot: () => Promise;
+ /** Leave the room and go to hotel view */
+ visitDesktop: () => void;
/** Create a draggable floating window and return its container element */
createWindow: (id: string, title: string, width: number) => HTMLDivElement | null;
/** Destroy a floating window by id */
@@ -96,6 +104,50 @@ const pluginApi: INitroPluginApi = {
sendMessage: SendMessageComposer,
+ sendChat(text: string, styleId: number = 0)
+ {
+ const session = GetRoomSession();
+ if (!session) return;
+ session.sendChatMessage(text, styleId, '');
+ },
+
+ sendStackHeight(objectId: number, height: number)
+ {
+ SendMessageComposer(new FurnitureStackHeightComposer(objectId, height));
+ },
+
+ async takeScreenshot()
+ {
+ try
+ {
+ const session = GetRoomSession();
+ if (!session) return;
+
+ const texture = GetRoomEngine().createTextureFromRoom(session.roomId, 1);
+ if (!texture) return;
+
+ const imageUrl = await TextureUtils.generateImageUrl(texture);
+ if (!imageUrl) return;
+
+ // Download the image
+ const link = document.createElement('a');
+ link.href = imageUrl;
+ link.download = `room_${session.roomId}_${Date.now()}.png`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ catch (e)
+ {
+ console.warn('[NitroPlugins] Screenshot failed:', e);
+ }
+ },
+
+ visitDesktop()
+ {
+ VisitDesktop();
+ },
+
createWindow(id: string, title: string, width: number): HTMLDivElement | null
{
// Remove existing window with same id
diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx
new file mode 100644
index 0000000..05c54d4
--- /dev/null
+++ b/src/components/room/widgets/avatar-info/infostand/InfoStandBadgeSlotView.tsx
@@ -0,0 +1,172 @@
+import { FC, useCallback, useEffect, useRef, useState } from 'react';
+import { FaPlus } from 'react-icons/fa';
+import { LayoutBadgeImageView } from '../../../../../common';
+import { useInventoryBadges } from '../../../../../hooks';
+
+interface InfoStandBadgeSlotProps
+{
+ slotIndex: number;
+ badgeCode?: string;
+ isOwnUser: boolean;
+}
+
+const BadgeMiniPicker: FC<{
+ onSelect: (badgeCode: string) => void;
+ onClose: () => void;
+ activeBadgeCodes: string[];
+}> = ({ onSelect, onClose, activeBadgeCodes }) =>
+{
+ const { badgeCodes = [], requestBadges = null } = useInventoryBadges();
+ const ref = useRef(null);
+ const [ search, setSearch ] = useState('');
+
+ useEffect(() =>
+ {
+ if(badgeCodes.length === 0) requestBadges();
+ }, []);
+
+ const availableBadges = badgeCodes.filter(code => !activeBadgeCodes.includes(code));
+ const filtered = search.length > 0
+ ? availableBadges.filter(code => code.toLowerCase().includes(search.toLowerCase()))
+ : availableBadges;
+
+ useEffect(() =>
+ {
+ const handleClickOutside = (event: MouseEvent) =>
+ {
+ if(ref.current && !ref.current.contains(event.target as Node)) onClose();
+ };
+
+ document.addEventListener('mousedown', handleClickOutside);
+ return () => document.removeEventListener('mousedown', handleClickOutside);
+ }, [ onClose ]);
+
+ return (
+ e.stopPropagation() }>
+
setSearch(e.target.value) }
+ />
+ { badgeCodes.length === 0
+ ?
Caricamento...
+ : (
+
+ { filtered.slice(0, 40).map(code => (
+
onSelect(code) }>
+
+
+ )) }
+ { filtered.length === 0 && (
+
Nessun badge
+ ) }
+
+ ) }
+
+ );
+};
+
+export const InfoStandBadgeSlotView: FC = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) =>
+{
+ const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges();
+ const [ isDragOver, setIsDragOver ] = useState(false);
+ const [ showPicker, setShowPicker ] = useState(false);
+
+ // For own user, use activeBadgeCodes from the hook (updates immediately on drag/drop)
+ // For other users, use the badge code from props (from server via avatarInfo)
+ const badgeCode = isOwnUser ? (activeBadgeCodes[slotIndex] ?? null) : badgeCodeFromProps;
+
+ const onDragStart = useCallback((event: React.DragEvent) =>
+ {
+ if(!badgeCode || !isOwnUser) return;
+ event.dataTransfer.setData('badgeCode', badgeCode);
+ event.dataTransfer.setData('infostandSlot', slotIndex.toString());
+ event.dataTransfer.effectAllowed = 'move';
+ }, [ badgeCode, slotIndex, isOwnUser ]);
+
+ const onDragOver = useCallback((event: React.DragEvent) =>
+ {
+ if(!isOwnUser) return;
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ setIsDragOver(true);
+ }, [ isOwnUser ]);
+
+ const onDragLeave = useCallback(() => setIsDragOver(false), []);
+
+ const onDrop = useCallback((event: React.DragEvent) =>
+ {
+ event.preventDefault();
+ setIsDragOver(false);
+ if(!isOwnUser) return;
+
+ const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
+ const sourceSlotStr = event.dataTransfer.getData('infostandSlot');
+
+ if(!droppedBadgeCode) return;
+
+ if(sourceSlotStr !== '')
+ {
+ // Dragged from another infostand slot -> always swap (works with empty slots too)
+ const sourceSlot = parseInt(sourceSlotStr);
+
+ if(sourceSlot !== slotIndex) swapBadges(sourceSlot, slotIndex);
+ }
+ else
+ {
+ // Dragged from inventory or external -> place at this slot
+ setBadgeAtSlot(droppedBadgeCode, slotIndex);
+ }
+ }, [ isOwnUser, slotIndex, swapBadges, setBadgeAtSlot ]);
+
+ const handleSlotClick = useCallback(() =>
+ {
+ if(!isOwnUser || badgeCode) return;
+
+ setShowPicker(true);
+ }, [ isOwnUser, badgeCode ]);
+
+ const handlePickerSelect = useCallback((code: string) =>
+ {
+ setBadgeAtSlot(code, slotIndex);
+ setShowPicker(false);
+ }, [ setBadgeAtSlot, slotIndex ]);
+
+ return (
+
+
+ { badgeCode
+ ?
+ : isOwnUser && }
+
+ { showPicker && (
+
setShowPicker(false) }
+ onSelect={ handlePickerSelect }
+ />
+ ) }
+
+ );
+};
diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx
index a53e35c..1791979 100644
--- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx
+++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx
@@ -4,6 +4,7 @@ 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';
import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
+import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView';
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
import { InfoStandWidgetUserTagsView } from './InfoStandWidgetUserTagsView';
import { BackgroundsView } from '../../../../backgrounds/BackgroundsView';
@@ -158,31 +159,43 @@ export const InfoStandWidgetUserView: FC = props =
/>
)}
-
-
- {avatarInfo.badges[0] && }
-
-
0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
- {avatarInfo.groupId > 0 &&
- }
-
-
-
-
- {avatarInfo.badges[1] && }
-
-
- {avatarInfo.badges[2] && }
-
-
-
-
- {avatarInfo.badges[3] && }
-
-
- {avatarInfo.badges[4] && }
-
-
+ { GetConfigurationValue('user.badges.group.slot.enabled', true)
+ ? (
+ <>
+
+
+ 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
+ {avatarInfo.groupId > 0 &&
+ }
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ : (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )
+ }
diff --git a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx
index 19d382a..5105cf5 100644
--- a/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx
+++ b/src/components/room/widgets/room-tools/RoomToolsWidgetView.tsx
@@ -5,6 +5,7 @@ import { FC, useEffect, useState } from 'react';
import { GetConfigurationValue, LocalizeText, SendMessageComposer, SetLocalStorage, TryVisitRoom } from '../../../../api';
import { Text } from '../../../../common';
import { useMessageEvent, useNavigator, useRoom } from '../../../../hooks';
+import { getRegisteredPlugins, INitroPlugin, subscribePlugins } from '../../../plugins/NitroPluginApi';
export const RoomToolsWidgetView: FC<{}> = props => {
const [areBubblesMuted, setAreBubblesMuted] = useState(false);
@@ -15,12 +16,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
const [isOpen, setIsOpen] = useState
(false);
const [isOpenHistory, setIsOpenHistory] = useState(false);
const [roomHistory, setRoomHistory] = useState<{ roomId: number, roomName: string }[]>([]);
+ const [plugins, setPlugins] = useState([]);
const { navigatorData = null } = useNavigator();
const { roomSession = null } = useRoom();
+ // Subscribe to external plugin changes
+ useEffect(() =>
+ {
+ setPlugins(getRegisteredPlugins());
+ return subscribePlugins(() => setPlugins(getRegisteredPlugins()));
+ }, []);
+
const handleToolClick = (action: string, value?: string) => {
if (!roomSession) return;
-
+
switch (action) {
case 'settings':
CreateLinkEvent('navigator/toggle-room-info');
@@ -114,12 +123,20 @@ export const RoomToolsWidgetView: FC<{}> = props => {
handleToolClick('zoom')} />
handleToolClick('chat_history')} />
handleToolClick('hiddenbubbles')} />
-
+
{navigatorData.canRate && (
handleToolClick('like_room')} />
)}
handleToolClick('toggle_room_link')} />
handleToolClick('room_history')} />
+ {plugins.map(plugin => (
+
plugin.onOpen()}
+ />
+ ))}
@@ -159,4 +176,4 @@ export const RoomToolsWidgetView: FC<{}> = props => {
);
-};
\ No newline at end of file
+};
diff --git a/src/hooks/inventory/useInventoryBadges.ts b/src/hooks/inventory/useInventoryBadges.ts
index aebe155..39e0667 100644
--- a/src/hooks/inventory/useInventoryBadges.ts
+++ b/src/hooks/inventory/useInventoryBadges.ts
@@ -1,5 +1,5 @@
import { BadgeReceivedEvent, BadgesEvent, RequestBadgesComposer, SetActivatedBadgesComposer } from '@nitrots/nitro-renderer';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { GetConfigurationValue, SendMessageComposer, UnseenItemCategory } from '../../api';
import { useMessageEvent } from '../events';
@@ -17,9 +17,18 @@ const useInventoryBadgesState = () =>
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 sendActiveBadges = (badges: string[]) =>
+ {
+ localChangeRef.current = true;
+ const composer = new SetActivatedBadgesComposer();
+ for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(badges[i] ?? '');
+ SendMessageComposer(composer);
+ };
+
const toggleBadge = (badgeCode: string) =>
{
setActiveBadgeCodes(prevValue =>
@@ -30,7 +39,7 @@ const useInventoryBadgesState = () =>
if(index === -1)
{
- if(!canWearBadges()) return prevValue;
+ if(newValue.length >= maxBadgeCount) return prevValue;
newValue.push(badgeCode);
}
@@ -39,11 +48,7 @@ const useInventoryBadgesState = () =>
newValue.splice(index, 1);
}
- const composer = new SetActivatedBadgesComposer();
-
- for(let i = 0; i < maxBadgeCount; i++) composer.addActivatedBadge(newValue[i] ?? '');
-
- SendMessageComposer(composer);
+ sendActiveBadges(newValue);
return newValue;
});
@@ -77,7 +82,16 @@ const useInventoryBadgesState = () =>
return newValue;
});
- setActiveBadgeCodes(parser.getActiveBadgeCodes());
+ // Skip overwriting activeBadgeCodes if we recently made a local change
+ if(localChangeRef.current)
+ {
+ localChangeRef.current = false;
+ }
+ else
+ {
+ setActiveBadgeCodes(parser.getActiveBadgeCodes());
+ }
+
setBadgeCodes(allBadgeCodes);
});
@@ -141,7 +155,83 @@ const useInventoryBadgesState = () =>
setNeedsUpdate(false);
}, [ isVisible, needsUpdate ]);
- return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, activate, deactivate };
+ const setBadgeAtSlot = (badgeCode: string, slotIndex: number) =>
+ {
+ setActiveBadgeCodes(prevValue =>
+ {
+ // Build a fixed-size array of maxBadgeCount slots
+ const slots: (string | null)[] = Array.from({ length: maxBadgeCount }, (_, i) => prevValue[i] ?? null);
+
+ // Remove badge if already in another slot
+ const existingIndex = slots.indexOf(badgeCode);
+ if(existingIndex >= 0) slots[existingIndex] = null;
+
+ // Place badge at target slot
+ slots[slotIndex] = badgeCode;
+
+ // Compact: remove nulls, keep order
+ const result = slots.filter(Boolean) as string[];
+
+ sendActiveBadges(result);
+ return result;
+ });
+ };
+
+ const removeBadge = (badgeCode: string) =>
+ {
+ setActiveBadgeCodes(prevValue =>
+ {
+ const result = prevValue.filter(code => code !== badgeCode);
+
+ sendActiveBadges(result);
+ return result;
+ });
+ };
+
+ const reorderBadges = (fromIndex: number, toIndex: number) =>
+ {
+ 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);
+
+ sendActiveBadges(newValue);
+ return newValue;
+ });
+ };
+
+ const swapBadges = (fromIndex: number, toIndex: number) =>
+ {
+ setActiveBadgeCodes(prevValue =>
+ {
+ 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 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;
+ });
+ };
+
+ const requestBadges = () =>
+ {
+ SendMessageComposer(new RequestBadgesComposer());
+ };
+
+ return { badgeCodes, activeBadgeCodes, selectedBadgeCode, setSelectedBadgeCode, isWearingBadge, canWearBadges, toggleBadge, getBadgeId, setBadgeAtSlot, removeBadge, reorderBadges, swapBadges, requestBadges, activate, deactivate };
};
export const useInventoryBadges = () => useBetween(useInventoryBadgesState);
diff --git a/src/hooks/rooms/widgets/useChatInputWidget.ts b/src/hooks/rooms/widgets/useChatInputWidget.ts
index 3f32d3f..b21efab 100644
--- a/src/hooks/rooms/widgets/useChatInputWidget.ts
+++ b/src/hooks/rooms/widgets/useChatInputWidget.ts
@@ -116,12 +116,22 @@ const useChatInputWidgetState = () =>
(async () =>
{
- const image = new Image();
+ try
+ {
+ const imageUrl = await TextureUtils.generateImageUrl(texture);
+ if (!imageUrl) return;
- image.src = await TextureUtils.generateImageUrl(texture);
-
- const newWindow = window.open('');
- newWindow.document.write(image.outerHTML);
+ const link = document.createElement('a');
+ link.href = imageUrl;
+ link.download = `room_${ roomSession.roomId }_${ Date.now() }.png`;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ }
+ catch (e)
+ {
+ console.warn('[Screenshot] Failed:', e);
+ }
})();
return null;
case ':pickall':