Merge branch 'duckietm:main' into main

This commit is contained in:
Lorenzune
2026-03-18 17:18:48 +01:00
committed by GitHub
38 changed files with 815 additions and 170 deletions
+1
View File
@@ -11,6 +11,7 @@ export interface IRoomData
tags: string[];
tradeState: number;
allowWalkthrough: boolean;
allowUnderpass: boolean;
lockState: number;
password: string;
allowPets: boolean;
@@ -8,6 +8,7 @@ const CURRENT_WINDOWS: HTMLElement[] = [];
const POS_MEMORY: Map<Key, { x: number, y: number }> = new Map();
const BOUNDS_THRESHOLD_TOP: number = 0;
const BOUNDS_THRESHOLD_LEFT: number = 0;
const DRAG_OUTSIDE_PERCENT: number = 0.80;
export interface DraggableWindowProps {
uniqueKey?: Key;
@@ -80,8 +81,11 @@ export const DraggableWindow: FC<DraggableWindowProps> = props => {
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const clampedX = Math.max(BOUNDS_THRESHOLD_LEFT, Math.min(newX, viewportWidth - windowWidth));
const clampedY = Math.max(BOUNDS_THRESHOLD_TOP, Math.min(newY, viewportHeight - windowHeight));
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));
return { x: clampedX, y: clampedY };
}, []);
+28 -9
View File
@@ -67,11 +67,20 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
{
if(event.badgeId !== badgeCode) return;
const element = await TextureUtils.generateImage(new NitroSprite(event.image));
console.log ('boe');
if(isGroup)
{
const element = await TextureUtils.generateImage(new NitroSprite(event.image));
element.onload = () => setImageElement(element);
element.onload = () => setImageElement(element);
}
else
{
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode.toString());
const img = new Image();
img.onload = () => setImageElement(img);
img.src = badgeUrl;
}
didSetBadge = true;
@@ -84,13 +93,23 @@ export const LayoutBadgeImageView: FC<LayoutBadgeImageViewProps> = props =>
if(texture && !didSetBadge)
{
(async () =>
if(isGroup)
{
const element = await TextureUtils.generateImage(new NitroSprite(texture));
(async () =>
{
const element = await TextureUtils.generateImage(new NitroSprite(texture));
element.onload = () => setImageElement(element);
})();
element.onload = () => setImageElement(element);
})();
}
else
{
const badgeUrl = GetConfigurationValue<string>('badge.asset.url').replace('%badgename%', badgeCode.toString());
const img = new Image();
img.onload = () => setImageElement(img);
img.src = badgeUrl;
}
}
return () => GetEventDispatcher().removeEventListener(BadgeImageReadyEvent.IMAGE_READY, onBadgeImageReadyEvent);
+1
View File
@@ -85,6 +85,7 @@ export const MainView: FC<{}> = props =>
<AnimatePresence>
{ landingViewVisible &&
<motion.div
className="w-full h-full"
initial={ { opacity: 0 }}
animate={ { opacity: 1 }}
exit={ { opacity: 0 }}>
@@ -60,7 +60,7 @@ export const CameraWidgetCaptureView: FC<CameraWidgetCaptureViewProps> = props =
return (
<DraggableWindow uniqueKey="nitro-camera-capture">
<Column center className="relative" gap={ 0 }>
{ selectedPicture && <img alt="" className="absolute top-[37px] left-[10px] w-[320px] h-[320px]" src={ selectedPicture.imageUrl } /> }
{ selectedPicture && <img alt="" className="absolute top-[37px] left-[10px] w-[325px] h-[325px]" src={ selectedPicture.imageUrl } /> }
<div className="relative w-[340px] h-[462px] bg-[url('@/assets/images/room-widgets/camera-widget/camera-spritesheet.png')] bg-position-[-1px_-1px] drag-handler">
<div className="absolute top-[8px] right-[8px] rounded-[.25rem] [box-shadow:0_0_0_1.5px_#fff] border-2 border-[solid] border-[#921911] bg-[repeating-linear-gradient(rgb(245,80,65),rgb(245,80,65)_50%,rgb(194,48,39)_50%,rgb(194,48,39)_100%)] cursor-pointer leading-none px-[3px] py-px" onClick={ onClose }>
<FaTimes className="fa-icon" />
@@ -1,8 +1,8 @@
import { GetRoomCameraWidgetManager, IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect, NitroLogger, RoomCameraWidgetSelectedEffect } from '@nitrots/nitro-renderer';
import { GetRoomCameraWidgetManager, IRoomCameraWidgetEffect, IRoomCameraWidgetSelectedEffect, NitroLogger, NitroTexture, RoomCameraWidgetSelectedEffect } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FaSave, FaSearchMinus, FaSearchPlus, FaTrash } from 'react-icons/fa';
import { CameraEditorTabs, CameraPicture, CameraPictureThumbnail, LocalizeText } from '../../../../api';
import { Button, Column, Flex, Grid, LayoutImage, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Slider, Text } from '../../../../common';
import { Button, Column, Flex, Grid, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Slider, Text } from '../../../../common';
import { CameraWidgetEffectListView } from './effect-list';
export interface CameraWidgetEditorViewProps {
@@ -23,10 +23,18 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
const [ selectedEffects, setSelectedEffects ] = useState<IRoomCameraWidgetSelectedEffect[]>([]);
const [ effectsThumbnails, setEffectsThumbnails ] = useState<CameraPictureThumbnail[]>([]);
const [ isZoomed, setIsZoomed ] = useState(false);
const [ currentPictureUrl, setCurrentPictureUrl ] = useState<string>('');
const [ currentPictureUrl, setCurrentPictureUrl ] = useState<string>(picture?.imageUrl ?? '');
const [ stableTexture, setStableTexture ] = useState<NitroTexture>(null);
const debounceTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
const requestIdRef = useRef<number>(0);
useEffect(() =>
{
const img = new Image();
img.onload = () => setStableTexture(NitroTexture.from(img));
img.src = picture.imageUrl;
}, [ picture ]);
const getColorMatrixEffects = useMemo(() => {
return availableEffects.filter(effect => effect.colorMatrix);
}, [ availableEffects ]);
@@ -104,16 +112,27 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
return;
}
case 'clear_effects':
setSelectedEffectName(null);
setSelectedEffects([]);
onCancel();
return;
case 'download': {
(async () => {
const image = new Image();
image.src = currentPictureUrl;
const newWindow = window.open('');
newWindow.document.write(image.outerHTML);
})();
if(!currentPictureUrl) return;
const parts = currentPictureUrl.split(',');
const mime = parts[0].match(/:(.*?);/)?.[1] || 'image/png';
const binary = atob(parts[1]);
const bytes = new Uint8Array(binary.length);
for(let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
const blob = new Blob([ bytes ], { type: mime });
const blobUrl = URL.createObjectURL(blob);
const w = window.open('', '_blank');
if(w)
{
w.document.title = 'camera_photo.png';
w.document.body.style.margin = '0';
w.document.body.innerHTML = `<img src="${ blobUrl }" style="max-width:100%"/>`;
}
return;
}
case 'zoom':
@@ -123,25 +142,29 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
}, [ availableEffects, selectedEffectName, currentPictureUrl, getSelectedEffectIndex, onCancel, onCheckout, onClose ]);
useEffect(() => {
if(!stableTexture) return;
const processThumbnails = async () => {
const renderedEffects = await Promise.all(
availableEffects.map(effect =>
GetRoomCameraWidgetManager().applyEffects(picture.texture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false)
GetRoomCameraWidgetManager().applyEffects(stableTexture, [ new RoomCameraWidgetSelectedEffect(effect, 1) ], false)
)
);
setEffectsThumbnails(renderedEffects.map((image, index) => new CameraPictureThumbnail(availableEffects[index].name, image.src)));
};
processThumbnails();
}, [ picture, availableEffects ]);
}, [ stableTexture, availableEffects ]);
useEffect(() => {
if(!stableTexture) return;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
const id = ++requestIdRef.current;
GetRoomCameraWidgetManager()
.applyEffects(picture.texture, selectedEffects, false)
.applyEffects(stableTexture, selectedEffects, false)
.then(imageElement => {
if (id !== requestIdRef.current) return;
setCurrentPictureUrl(imageElement.src);
@@ -152,7 +175,7 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
return () => {
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
};
}, [ picture, selectedEffects ]);
}, [ stableTexture, selectedEffects ]);
return (
<NitroCardView className="w-[600px] h-[500px]">
@@ -177,16 +200,14 @@ export const CameraWidgetEditorView: FC<CameraWidgetEditorViewProps> = props =>
</Column>
<Column justifyContent="between" overflow="hidden" size={ 7 }>
<Column center>
<LayoutImage
style={{
width: '320px',
height: '320px',
backgroundImage: `url(${currentPictureUrl})`,
backgroundPosition: isZoomed ? 'center' : 'top left',
backgroundSize: isZoomed ? 'contain' : 'auto', // Zoom only affects display
backgroundRepeat: 'no-repeat'
}}
/>
<div className="w-[325px] h-[325px] overflow-hidden">
{ currentPictureUrl && <img
alt=""
src={ currentPictureUrl }
className="w-[325px] h-[325px] [image-rendering:pixelated]"
style={ isZoomed ? { transform: 'scale(2)', transformOrigin: 'center' } : undefined }
/> }
</div>
{ selectedEffectName && (
<Column center fullWidth gap={ 1 }>
<Text>{ LocalizeText('camera.effect.name.' + selectedEffectName) }</Text>
@@ -7,6 +7,8 @@ import { CatalogEvent, CatalogInitGiftEvent, CatalogPurchasedEvent } from '../..
import { useCatalog, useFriends, useMessageEvent, useUiEvent } from '../../../../hooks';
import { classNames } from '../../../../layout';
let isBuyingGift = false;
export const CatalogGiftView: FC<{}> = props =>
{
const [ isVisible, setIsVisible ] = useState<boolean>(false);
@@ -32,6 +34,7 @@ export const CatalogGiftView: FC<{}> = props =>
const onClose = useCallback(() =>
{
isBuyingGift = false;
setIsVisible(false);
setPageId(0);
setOfferId(0);
@@ -122,6 +125,10 @@ export const CatalogGiftView: FC<{}> = props =>
return;
}
if(isBuyingGift) return;
isBuyingGift = true;
SendMessageComposer(new PurchaseFromCatalogAsGiftComposer(pageId, offerId, extraData, receiverName, message, colourId, selectedBoxIndex, selectedRibbonIndex, showMyFace));
return;
}
@@ -136,6 +143,7 @@ export const CatalogGiftView: FC<{}> = props =>
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
isBuyingGift = false;
onClose();
return;
case CatalogEvent.INIT_GIFT:
@@ -6,6 +6,8 @@ import { useCatalog, useMessageEvent, useNavigator, useRoomPromote } from '../..
import { NitroInput } from '../../../../../layout';
import { CatalogLayoutProps } from './CatalogLayout.types';
let isPurchasingAd = false;
export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
{
const { page = null } = props;
@@ -45,6 +47,10 @@ export const CatalogLayoutRoomAdsView: FC<CatalogLayoutProps> = props =>
const purchaseAd = () =>
{
if(isPurchasingAd) return;
isPurchasingAd = true;
const pageId = page.pageId;
const offerId = page.offers.length >= 1 ? page.offers[0].offerId : -1;
const flatId = roomId;
@@ -1,5 +1,5 @@
import { ClubOfferData, GetClubOffersMessageComposer, PurchaseFromCatalogComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CatalogPurchaseState, LocalizeText, SendMessageComposer } from '../../../../../api';
import { AutoGrid, Button, Column, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, LayoutLoadingSpinnerView, Text } from '../../../../../common';
import { CatalogEvent, CatalogPurchaseFailureEvent, CatalogPurchasedEvent } from '../../../../../events';
@@ -13,15 +13,18 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
const { currentPage = null, catalogOptions = null } = useCatalog();
const { purse = null, getCurrencyAmount = null } = usePurse();
const { clubOffers = null } = catalogOptions;
const isPurchasingRef = useRef<boolean>(false);
const onCatalogEvent = useCallback((event: CatalogEvent) =>
{
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.NONE);
return;
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
isPurchasingRef.current = false;
setPurchaseState(CatalogPurchaseState.FAILED);
return;
}
@@ -83,8 +86,9 @@ export const CatalogLayoutVipBuyView: FC<CatalogLayoutProps> = props =>
const purchaseSubscription = useCallback(() =>
{
if(!pendingOffer) return;
if(!pendingOffer || isPurchasingRef.current) return;
isPurchasingRef.current = true;
setPurchaseState(CatalogPurchaseState.PURCHASE);
SendMessageComposer(new PurchaseFromCatalogComposer(currentPage.pageId, pendingOffer.offerId, null, 1));
}, [ pendingOffer, currentPage ]);
@@ -1,5 +1,5 @@
import { CancelMarketplaceOfferMessageComposer, GetMarketplaceOwnOffersMessageComposer, MarketplaceCancelOfferResultEvent, MarketplaceOwnOffersEvent, RedeemMarketplaceOfferCreditsMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { LocalizeText, MarketplaceOfferData, MarketPlaceOfferState, NotificationAlertType, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Text } from '../../../../../../common';
import { useMessageEvent, useNotification } from '../../../../../../hooks';
@@ -11,6 +11,8 @@ export const CatalogLayoutMarketplaceOwnItemsView: FC<CatalogLayoutProps> = prop
const [ creditsWaiting, setCreditsWaiting ] = useState(0);
const [ offers, setOffers ] = useState<MarketplaceOfferData[]>([]);
const { simpleAlert = null } = useNotification();
const isRedeemingRef = useRef<boolean>(false);
const pendingCancelsRef = useRef<Set<number>>(new Set());
useMessageEvent<MarketplaceOwnOffersEvent>(MarketplaceOwnOffersEvent, event =>
{
@@ -54,6 +56,10 @@ export const CatalogLayoutMarketplaceOwnItemsView: FC<CatalogLayoutProps> = prop
const redeemSoldOffers = useCallback(() =>
{
if(isRedeemingRef.current) return;
isRedeemingRef.current = true;
setOffers(prevValue =>
{
const idsToDelete = soldOffers.map(value => value.offerId);
@@ -62,11 +68,19 @@ export const CatalogLayoutMarketplaceOwnItemsView: FC<CatalogLayoutProps> = prop
});
SendMessageComposer(new RedeemMarketplaceOfferCreditsMessageComposer());
setTimeout(() => isRedeemingRef.current = false, 3000);
}, [ soldOffers ]);
const takeItemBack = (offerData: MarketplaceOfferData) =>
{
if(pendingCancelsRef.current.has(offerData.offerId)) return;
pendingCancelsRef.current.add(offerData.offerId);
SendMessageComposer(new CancelMarketplaceOfferMessageComposer(offerData.offerId));
setTimeout(() => pendingCancelsRef.current.delete(offerData.offerId), 2000);
};
useEffect(() =>
@@ -1,5 +1,5 @@
import { BuyMarketplaceOfferMessageComposer, GetMarketplaceOffersMessageComposer, MarketplaceBuyOfferResultEvent, MarketPlaceOffersEvent } from '@nitrots/nitro-renderer';
import { FC, useCallback, useMemo, useState } from 'react';
import { FC, useCallback, useMemo, useRef, useState } from 'react';
import { IMarketplaceSearchOptions, LocalizeText, MarketplaceOfferData, MarketplaceSearchType, NotificationAlertType, SendMessageComposer } from '../../../../../../api';
import { Button, Column, Text } from '../../../../../../common';
import { useMessageEvent, useNotification, usePurse } from '../../../../../../hooks';
@@ -23,6 +23,7 @@ export const CatalogLayoutMarketplacePublicItemsView: FC<CatalogLayoutMarketplac
const [ lastSearch, setLastSearch ] = useState<IMarketplaceSearchOptions>({ minPrice: -1, maxPrice: -1, query: '', type: 3 });
const { getCurrencyAmount = null } = usePurse();
const { simpleAlert = null, showConfirm = null } = useNotification();
const isBuyingRef = useRef<boolean>(false);
const requestOffers = useCallback((options: IMarketplaceSearchOptions) =>
{
@@ -56,6 +57,9 @@ export const CatalogLayoutMarketplacePublicItemsView: FC<CatalogLayoutMarketplac
showConfirm(LocalizeText('catalog.marketplace.confirm_header'), () =>
{
if(isBuyingRef.current) return;
isBuyingRef.current = true;
SendMessageComposer(new BuyMarketplaceOfferMessageComposer(offerId));
},
null, null, null, LocalizeText('catalog.marketplace.confirm_title'));
@@ -83,6 +87,8 @@ export const CatalogLayoutMarketplacePublicItemsView: FC<CatalogLayoutMarketplac
{
const parser = event.getParser();
isBuyingRef.current = false;
if(!parser) return;
switch(parser.result)
@@ -6,6 +6,8 @@ import { CatalogPostMarketplaceOfferEvent } from '../../../../../../events';
import { useCatalog, useMessageEvent, useNotification, useUiEvent } from '../../../../../../hooks';
import { NitroInput } from '../../../../../../layout';
let isPostingMarketplaceOffer = false;
export const MarketplacePostOfferView: FC<{}> = props =>
{
const [ item, setItem ] = useState<FurnitureItem>(null);
@@ -65,10 +67,15 @@ export const MarketplacePostOfferView: FC<{}> = props =>
const postItem = () =>
{
if(!item || (askingPrice < marketplaceConfiguration.minimumPrice)) return;
if(!item || (askingPrice < marketplaceConfiguration.minimumPrice) || isPostingMarketplaceOffer) return;
showConfirm(LocalizeText('inventory.marketplace.confirm_offer.info', [ 'furniname', 'price' ], [ getFurniTitle, askingPrice.toString() ]), () =>
{
if(isPostingMarketplaceOffer) return;
isPostingMarketplaceOffer = true;
setTimeout(() => isPostingMarketplaceOffer = false, 5000);
SendMessageComposer(new MakeOfferMessageComposer(askingPrice, item.isWallItem ? 2 : 1, item.id));
setItem(null);
},
@@ -6,6 +6,8 @@ import { useCatalog, useNotification, usePurse } from '../../../../../../hooks';
import { CatalogLayoutProps } from '../CatalogLayout.types';
import { VipGiftItem } from './VipGiftItemView';
let isSelectingGift = false;
export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
{
const { purse = null } = usePurse();
@@ -30,6 +32,10 @@ export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
{
showConfirm(LocalizeText('catalog.club_gift.confirm'), () =>
{
if(isSelectingGift) return;
isSelectingGift = true;
SendMessageComposer(new SelectClubGiftComposer(localizationId));
setCatalogOptions(prevValue =>
@@ -38,6 +44,8 @@ export const CatalogLayoutVipGiftsView: FC<CatalogLayoutProps> = props =>
return { ...prevValue };
});
setTimeout(() => isSelectingGift = false, 5000);
}, null);
}, [ setCatalogOptions, showConfirm ]);
@@ -11,6 +11,8 @@ interface CatalogPurchaseWidgetViewProps
purchaseCallback?: () => void;
}
let isPurchasingCatalogItem = false;
export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = props =>
{
const { noGiftOption = false, purchaseCallback = null } = props;
@@ -25,15 +27,19 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
switch(event.type)
{
case CatalogPurchasedEvent.PURCHASE_SUCCESS:
isPurchasingCatalogItem = false;
setPurchaseState(CatalogPurchaseState.NONE);
return;
case CatalogPurchaseFailureEvent.PURCHASE_FAILED:
isPurchasingCatalogItem = false;
setPurchaseState(CatalogPurchaseState.FAILED);
return;
case CatalogPurchaseNotAllowedEvent.NOT_ALLOWED:
isPurchasingCatalogItem = false;
setPurchaseState(CatalogPurchaseState.FAILED);
return;
case CatalogPurchaseSoldOutEvent.SOLD_OUT:
isPurchasingCatalogItem = false;
setPurchaseState(CatalogPurchaseState.SOLD_OUT);
return;
}
@@ -62,7 +68,7 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
const purchase = (isGift: boolean = false) =>
{
if(!currentOffer) return;
if(!currentOffer || isPurchasingCatalogItem) return;
if(GetClubMemberLevel() < currentOffer.clubLevel)
{
@@ -78,6 +84,7 @@ export const CatalogPurchaseWidgetView: FC<CatalogPurchaseWidgetViewProps> = pro
return;
}
isPurchasingCatalogItem = true;
setPurchaseState(CatalogPurchaseState.PURCHASE);
if(purchaseCallback)
@@ -4,6 +4,8 @@ import { FriendlyTime, GetConfigurationValue, LocalizeText, SendMessageComposer
import { Button, Column, Flex, LayoutCurrencyIcon, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { usePurse } from '../../../../hooks';
let isBuyingOffer = false;
export const OfferWindowView = (props: { offer: TargetedOfferData, setOpen: Dispatch<SetStateAction<boolean>> }) =>
{
const { offer = null, setOpen = null } = props;
@@ -37,8 +39,14 @@ export const OfferWindowView = (props: { offer: TargetedOfferData, setOpen: Disp
const buyOffer = () =>
{
if(isBuyingOffer) return;
isBuyingOffer = true;
SendMessageComposer(new PurchaseTargetedOfferComposer(offer.id, amount));
SendMessageComposer(new GetTargetedOfferComposer());
setTimeout(() => isBuyingOffer = false, 5000);
};
if(!offer) return;
@@ -15,6 +15,8 @@ interface GroupCreatorViewProps
const TABS: number[] = [ 1, 2, 3, 4 ];
let isBuyingGroup = false;
export const GroupCreatorView: FC<GroupCreatorViewProps> = props =>
{
const { onClose = null } = props;
@@ -34,7 +36,10 @@ export const GroupCreatorView: FC<GroupCreatorViewProps> = props =>
const buyGroup = () =>
{
if(!groupData) return;
if(!groupData || isBuyingGroup) return;
isBuyingGroup = true;
setTimeout(() => isBuyingGroup = false, 5000);
const badge = [];
@@ -1,5 +1,5 @@
import { AddLinkEventTracker, GetSessionDataManager, GroupAdminGiveComposer, GroupAdminTakeComposer, GroupConfirmMemberRemoveEvent, GroupConfirmRemoveMemberComposer, GroupMemberParser, GroupMembersComposer, GroupMembersEvent, GroupMembershipAcceptComposer, GroupMembershipDeclineComposer, GroupMembersParser, GroupRank, GroupRemoveMemberComposer, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { GetUserProfile, LocalizeText, SendMessageComposer } from '../../../api';
import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutBadgeImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../common';
@@ -16,6 +16,7 @@ export const GroupMembersView: FC<{}> = props =>
const [ searchQuery, setSearchQuery ] = useState<string>('');
const [ removingMemberName, setRemovingMemberName ] = useState<string>(null);
const { showConfirm = null } = useNotification();
const pendingActionsRef = useRef<Set<string>>(new Set());
const getRankDescription = (member: GroupMemberParser) =>
{
@@ -42,6 +43,11 @@ export const GroupMembersView: FC<{}> = props =>
{
if(!membersData.admin || (member.rank === GroupRank.OWNER)) return;
const key = `admin_${member.id}`;
if(pendingActionsRef.current.has(key)) return;
pendingActionsRef.current.add(key);
setTimeout(() => pendingActionsRef.current.delete(key), 2000);
if(member.rank !== GroupRank.ADMIN) SendMessageComposer(new GroupAdminGiveComposer(membersData.groupId, member.id));
else SendMessageComposer(new GroupAdminTakeComposer(membersData.groupId, member.id));
@@ -52,6 +58,11 @@ export const GroupMembersView: FC<{}> = props =>
{
if(!membersData.admin || (member.rank !== GroupRank.REQUESTED)) return;
const key = `accept_${member.id}`;
if(pendingActionsRef.current.has(key)) return;
pendingActionsRef.current.add(key);
setTimeout(() => pendingActionsRef.current.delete(key), 2000);
SendMessageComposer(new GroupMembershipAcceptComposer(membersData.groupId, member.id));
refreshMembers();
@@ -61,6 +72,11 @@ export const GroupMembersView: FC<{}> = props =>
{
if(!membersData.admin) return;
const key = `remove_${member.id}`;
if(pendingActionsRef.current.has(key)) return;
pendingActionsRef.current.add(key);
setTimeout(() => pendingActionsRef.current.delete(key), 2000);
if(member.rank === GroupRank.REQUESTED)
{
SendMessageComposer(new GroupMembershipDeclineComposer(membersData.groupId, member.id));
+27 -18
View File
@@ -83,27 +83,36 @@ export const HotelView: FC<{}> = props =>
if(!container) return;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight - 55;
const lobbyEl = container.querySelector<HTMLElement>('.nitro-hotel-view-lobby');
if(lobbyEl)
const centerView = () =>
{
const containerRect = container.getBoundingClientRect();
const lobbyRect = lobbyEl.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight - 55;
const lobbyCenterX = (lobbyRect.left - containerRect.left) + container.scrollLeft + lobbyRect.width / 2;
const lobbyCenterY = (lobbyRect.top - containerRect.top) + container.scrollTop + lobbyRect.height / 2;
const lobbyEl = container.querySelector<HTMLElement>('.nitro-hotel-view-lobby');
container.scrollLeft = Math.max(0, lobbyCenterX - viewportWidth / 2);
container.scrollTop = Math.max(0, lobbyCenterY - viewportHeight / 2);
}
else
{
container.scrollLeft = Math.max(0, (2600 - viewportWidth) / 2);
container.scrollTop = Math.max(0, (1425 - viewportHeight) / 2);
}
if(lobbyEl)
{
const containerRect = container.getBoundingClientRect();
const lobbyRect = lobbyEl.getBoundingClientRect();
const lobbyCenterX = (lobbyRect.left - containerRect.left) + container.scrollLeft + lobbyRect.width / 2;
const lobbyCenterY = (lobbyRect.top - containerRect.top) + container.scrollTop + lobbyRect.height / 2;
container.scrollLeft = Math.max(0, lobbyCenterX - viewportWidth / 2);
container.scrollTop = Math.max(0, lobbyCenterY - viewportHeight / 2);
}
else
{
container.scrollLeft = Math.max(0, (2600 - viewportWidth) / 2);
container.scrollTop = Math.max(0, (1425 - viewportHeight) / 2);
}
};
centerView();
window.addEventListener('resize', centerView);
return () => window.removeEventListener('resize', centerView);
}, []);
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) =>
@@ -11,8 +11,22 @@ export const InventoryBadgeItemView: FC<PropsWithChildren<{ badgeCode: string }>
const { isUnseen = null } = useInventoryUnseenTracker();
const unseen = isUnseen(UnseenItemCategory.BADGE, getBadgeId(badgeCode));
const onDragStart = (event: React.DragEvent<HTMLDivElement>) =>
{
event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('source', 'inventory');
event.dataTransfer.effectAllowed = 'move';
};
return (
<InfiniteGrid.Item itemActive={ (selectedBadgeCode === badgeCode) } itemUnseen={ unseen } onDoubleClick={ event => toggleBadge(selectedBadgeCode) } onMouseDown={ event => setSelectedBadgeCode(badgeCode) } { ...rest }>
<InfiniteGrid.Item
draggable
itemActive={ (selectedBadgeCode === badgeCode) }
itemUnseen={ unseen }
onDoubleClick={ event => toggleBadge(selectedBadgeCode) }
onDragStart={ onDragStart }
onMouseDown={ event => setSelectedBadgeCode(badgeCode) }
{ ...rest }>
<LayoutBadgeImageView badgeCode={ badgeCode } />
{ children }
</InfiniteGrid.Item>
@@ -1,5 +1,5 @@
import { DeleteBadgeMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FC, useCallback, useEffect, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { LocalizeBadgeName, LocalizeText, SendMessageComposer, UnseenItemCategory } from '../../../../api';
import { LayoutBadgeImageView } from '../../../../common';
@@ -7,14 +7,74 @@ import { useInventoryBadges, useInventoryUnseenTracker, useNotification } from '
import { InfiniteGrid, NitroButton } from '../../../../layout';
import { InventoryBadgeItemView } from './InventoryBadgeItemView';
const ActiveBadgeSlot: FC<{
slotIndex: number;
badgeCode?: string;
onDropBadge: (badgeCode: string, slotIndex: number, sourceSlot?: number) => void;
onRemoveBadge: (badgeCode: string) => void;
onDragStartFromSlot: (event: React.DragEvent, badgeCode: string, slotIndex: number) => void;
onSelectBadge: (badgeCode: string) => void;
isSelected: boolean;
}> = ({ slotIndex, badgeCode, onDropBadge, onRemoveBadge, onDragStartFromSlot, onSelectBadge, isSelected }) =>
{
const [ isDragOver, setIsDragOver ] = useState(false);
const onDragOver = useCallback((event: React.DragEvent) =>
{
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setIsDragOver(true);
}, []);
const onDragLeave = useCallback(() => setIsDragOver(false), []);
const onDrop = useCallback((event: React.DragEvent) =>
{
event.preventDefault();
setIsDragOver(false);
const droppedBadgeCode = event.dataTransfer.getData('badgeCode');
const sourceSlotStr = event.dataTransfer.getData('activeSlot');
const sourceSlot = sourceSlotStr ? parseInt(sourceSlotStr) : undefined;
if(droppedBadgeCode) onDropBadge(droppedBadgeCode, slotIndex, sourceSlot);
}, [ slotIndex, onDropBadge ]);
const onDragStart = useCallback((event: React.DragEvent) =>
{
if(!badgeCode) return;
onDragStartFromSlot(event, badgeCode, slotIndex);
}, [ badgeCode, slotIndex, onDragStartFromSlot ]);
return (
<div
className={ `flex items-center justify-center rounded-md border-2 cursor-pointer aspect-square transition-colors
${ isDragOver ? 'border-blue-400 bg-blue-400/20' : '' }
${ isSelected && badgeCode ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ !badgeCode ? 'border-dashed opacity-60' : '' }` }
draggable={ !!badgeCode }
onDragLeave={ onDragLeave }
onDragOver={ onDragOver }
onDragStart={ onDragStart }
onDrop={ onDrop }
onMouseDown={ () => badgeCode && onSelectBadge(badgeCode) }>
{ badgeCode
? <LayoutBadgeImageView badgeCode={ badgeCode } />
: <span className="text-xs text-white/30">{ slotIndex + 1 }</span> }
</div>
);
};
export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =>
{
const { filteredBadgeCodes = null } = props;
const [ isVisible, setIsVisible ] = useState(false);
const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, activate = null, deactivate = null } = useInventoryBadges();
const { badgeCodes = [], activeBadgeCodes = [], selectedBadgeCode = null, isWearingBadge = null, canWearBadges = null, toggleBadge = null, getBadgeId = null, setBadgeAtSlot = null, removeBadge = null, reorderBadges = null, setSelectedBadgeCode = null, activate = null, deactivate = null } = useInventoryBadges();
const { isUnseen = null, removeUnseen = null } = useInventoryUnseenTracker();
const { showConfirm = null } = useNotification();
const [ isDragOverInventory, setIsDragOverInventory ] = useState(false);
const maxSlots = 5;
const displayCodes = (filteredBadgeCodes !== null ? filteredBadgeCodes : badgeCodes);
const attemptDeleteBadge = () =>
@@ -31,6 +91,58 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
);
};
const handleDropOnSlot = useCallback((badgeCode: string, slotIndex: number, sourceSlot?: number) =>
{
if(sourceSlot !== undefined)
{
// Reorder within active badges
reorderBadges(sourceSlot, slotIndex);
}
else
{
// Drop from inventory to active slot
setBadgeAtSlot(badgeCode, slotIndex);
}
}, [ setBadgeAtSlot, reorderBadges ]);
const handleDragStartFromSlot = useCallback((event: React.DragEvent, badgeCode: string, slotIndex: number) =>
{
event.dataTransfer.setData('badgeCode', badgeCode);
event.dataTransfer.setData('activeSlot', slotIndex.toString());
event.dataTransfer.setData('source', 'active');
event.dataTransfer.effectAllowed = 'move';
}, []);
const handleRemoveBadge = useCallback((badgeCode: string) =>
{
removeBadge(badgeCode);
}, [ removeBadge ]);
// 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';
setIsDragOverInventory(true);
}, []);
const onInventoryDragLeave = useCallback(() => setIsDragOverInventory(false), []);
const onInventoryDrop = useCallback((event: React.DragEvent) =>
{
event.preventDefault();
setIsDragOverInventory(false);
const badgeCode = event.dataTransfer.getData('badgeCode');
const source = event.dataTransfer.getData('source');
if(source === 'active' && badgeCode)
{
removeBadge(badgeCode);
}
}, [ removeBadge ]);
useEffect(() =>
{
if(!selectedBadgeCode || !isUnseen(UnseenItemCategory.BADGE, getBadgeId(selectedBadgeCode))) return;
@@ -56,7 +168,11 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
return (
<div className="grid h-full grid-cols-12 gap-2">
<div className="flex flex-col col-span-7 gap-1 overflow-hidden">
<div
className={ `flex flex-col col-span-7 gap-1 overflow-hidden rounded transition-colors ${ isDragOverInventory ? 'bg-blue-400/10' : '' }` }
onDragLeave={ onInventoryDragLeave }
onDragOver={ onInventoryDragOver }
onDrop={ onInventoryDrop }>
<InfiniteGrid<string>
columnCount={ 5 }
estimateSize={ 50 }
@@ -66,11 +182,20 @@ export const InventoryBadgeView: FC<{ filteredBadgeCodes?: string[] }> = props =
<div className="flex flex-col justify-between col-span-5 overflow-auto">
<div className="flex flex-col gap-2 overflow-hidden">
<span className="text-sm truncate min-h-[1.25rem] leading-5">{ LocalizeText('inventory.badges.activebadges') }</span>
<InfiniteGrid<string>
columnCount={ 3 }
estimateSize={ 50 }
itemRender={ item => <InventoryBadgeItemView badgeCode={ item } /> }
items={ activeBadgeCodes } />
<div className="grid grid-cols-3 gap-1">
{ Array.from({ length: maxSlots }).map((_, index) => (
<ActiveBadgeSlot
key={ index }
badgeCode={ activeBadgeCodes[index] }
isSelected={ selectedBadgeCode === activeBadgeCodes[index] && !!activeBadgeCodes[index] }
slotIndex={ index }
onDropBadge={ handleDropOnSlot }
onDragStartFromSlot={ handleDragStartFromSlot }
onRemoveBadge={ handleRemoveBadge }
onSelectBadge={ setSelectedBadgeCode }
/>
)) }
</div>
</div>
{ !!selectedBadgeCode &&
<div className="flex flex-col gap-2">
+18 -10
View File
@@ -117,23 +117,31 @@ export const ModToolsView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker);
}, [ openRoomInfo, closeRoomInfo, toggleRoomInfo, openRoomChatlog, closeRoomChatlog, toggleRoomChatlog, openUserInfo, closeUserInfo, toggleUserInfo, openUserChatlog, closeUserChatlog, toggleUserChatlog ]);
const isRoomInfoOpen = currentRoomId > 0 && openRooms.includes(currentRoomId);
const isRoomChatlogOpen = currentRoomId > 0 && openRoomChatlogs.includes(currentRoomId);
const isUserInfoOpen = selectedUser && openUserInfos.includes(selectedUser.userId);
return (
<>
{ isVisible &&
<NitroCardView className="nitro-mod-tools" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardView className="nitro-mod-tools min-w-[200px]" theme="primary-slim" uniqueKey="mod-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT } >
<NitroCardHeaderView headerText={ 'Mod Tools' } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black" gap={ 1 }>
<Button className="relative" disabled={ (currentRoomId <= 0) } gap={ 1 } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room absolute inset-s-1" /> Room Tool
<NitroCardContentView className="text-black" gap={ 2 }>
<Button active={ isRoomInfoOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } justifyContent="start" onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-info/${ currentRoomId }`) }>
<div className="nitro-icon icon-small-room shrink-0" /> Room Tool
</Button>
<Button className="relative" disabled={ (currentRoomId <= 0) } gap={ 1 } innerRef={ elementRef } onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history absolute inset-s-1" /> Chatlog Tool
<Button active={ isRoomChatlogOpen } disabled={ (currentRoomId <= 0) } gap={ 2 } innerRef={ elementRef } justifyContent="start" onClick={ event => CreateLinkEvent(`mod-tools/toggle-room-chatlog/${ currentRoomId }`) }>
<div className="nitro-icon icon-chat-history shrink-0" /> Chatlog Tool
</Button>
<Button className="relative" disabled={ !selectedUser } gap={ 1 } onClick={ () => CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<div className="nitro-icon icon-user absolute inset-s-1" /> User: { selectedUser ? selectedUser.username : '' }
<Button active={ !!isUserInfoOpen } disabled={ !selectedUser } gap={ 2 } justifyContent="start" onClick={ () => CreateLinkEvent(`mod-tools/toggle-user-info/${ selectedUser.userId }`) }>
<div className="nitro-icon icon-user shrink-0" />
{ selectedUser
? <span className="truncate">{ selectedUser.username }</span>
: <span className="opacity-50 italic">Select a user</span>
}
</Button>
<Button className="relative" gap={ 1 } onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets absolute inset-s-1" /> Report Tool
<Button active={ isTicketsVisible } gap={ 2 } justifyContent="start" onClick={ () => setIsTicketsVisible(prevValue => !prevValue) }>
<div className="nitro-icon icon-tickets shrink-0" /> Report Tool
</Button>
</NitroCardContentView>
</NitroCardView> }
@@ -46,14 +46,11 @@ export const ChatlogView: FC<ChatlogViewProps> = props =>
const RoomInfo = (props: { roomId: number, roomName: string }) =>
{
return (
<Flex alignItems="center" className="bg-muted rounded p-1" gap={ 2 } justifyContent="between">
<div className="flex gap-1">
<Text bold>Room name:</Text>
<Text>{ props.roomName }</Text>
</div>
<div className="flex gap-1">
<Button onClick={ event => TryVisitRoom(props.roomId) }>Visit Room</Button>
<Button onClick={ event => openRoomInfo(props.roomId) }>Room Tools</Button>
<Flex alignItems="center" className="bg-muted rounded p-2" gap={ 2 } justifyContent="between">
<Text bold truncate>{ props.roomName }</Text>
<div className="flex gap-1 shrink-0">
<Button size="sm" onClick={ event => TryVisitRoom(props.roomId) }>Visit</Button>
<Button size="sm" onClick={ event => openRoomInfo(props.roomId) }>Room Tools</Button>
</div>
</Flex>
);
@@ -63,7 +60,7 @@ export const ChatlogView: FC<ChatlogViewProps> = props =>
<>
<Column fit gap={ 0 } overflow="hidden">
<Column gap={ 2 }>
<Grid className="text-black font-bold border-bottom pb-1" gap={ 1 }>
<Grid className="text-black font-bold border-bottom pb-1 text-[11px] uppercase opacity-60 tracking-wider" gap={ 1 }>
<div className="col-span-2">Time</div>
<div className="col-span-3">User</div>
<div className="col-span-7">Message</div>
@@ -77,8 +74,8 @@ export const ChatlogView: FC<ChatlogViewProps> = props =>
{ row.isRoomInfo &&
<RoomInfo roomId={ row.roomId } roomName={ row.roomName } /> }
{ !row.isRoomInfo &&
<Grid alignItems="center" className="log-entry py-1 border-bottom" fullHeight={ false } gap={ 1 }>
<Text className="col-span-2">{ row.timestamp }</Text>
<Grid alignItems="center" className="log-entry py-1.5 border-bottom even:bg-black/[0.03]" fullHeight={ false } gap={ 1 }>
<Text className="col-span-2 opacity-60 text-[11px]">{ row.timestamp }</Text>
<Text bold pointer underline className="col-span-3" onClick={ event => CreateLinkEvent(`mod-tools/open-user-info/${ row.habboId }`) }>{ row.username }</Text>
<Text textBreak wrap className="col-span-7">{ row.message }</Text>
</Grid> }
@@ -33,9 +33,9 @@ export const ModToolsChatlogView: FC<ModToolsChatlogViewProps> = props =>
if(!roomChatlog) return null;
return (
<NitroCardView className="nitro-mod-tools-chatlog" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `Room Chatlog ${ roomChatlog.roomName }` } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" overflow="hidden">
<NitroCardView className="nitro-mod-tools-chatlog min-w-[400px] max-h-[500px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ `Room Chatlog` } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black" overflow="auto">
{ roomChatlog &&
<ChatlogView records={ [ roomChatlog ] } /> }
</NitroCardContentView>
@@ -69,47 +69,52 @@ export const ModToolsRoomView: FC<ModToolsRoomViewProps> = props =>
}, [ roomId, infoRequested, setInfoRequested ]);
return (
<NitroCardView className="nitro-mod-tools-room" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Room Info' + (name ? ': ' + name : '') } onCloseClick={ event => onCloseClick() } />
<NitroCardContentView className="text-black">
<NitroCardView className="nitro-mod-tools-room min-w-[280px]" theme="primary-slim" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText={ 'Room Info' } onCloseClick={ event => onCloseClick() } />
<NitroCardContentView className="text-black" gap={ 2 }>
{ name &&
<div className="bg-muted rounded px-2 py-1.5 text-center">
<Text bold truncate>{ name }</Text>
</div>
}
<div className="flex gap-2">
<Column grow gap={ 1 } justifyContent="center">
<div className="items-center gap-2">
<Text bold align="end" className="col-span-7">Room Owner:</Text>
<Text pointer truncate underline>{ ownerName }</Text>
<Column grow gap={ 1 }>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Owner:</Text>
<Text bold pointer truncate underline onClick={ () => CreateLinkEvent(`mod-tools/open-user-info/${ ownerId }`) }>{ ownerName }</Text>
</div>
<div className="items-center gap-2">
<Text bold align="end" className="col-span-7">Users in room:</Text>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Users in room:</Text>
<Text>{ usersInRoom }</Text>
</div>
<div className="items-center gap-2">
<Text bold align="end" className="col-span-7">Owner in room:</Text>
<Text>{ ownerInRoom ? 'Yes' : 'No' }</Text>
<div className="flex items-center gap-1">
<Text bold className="opacity-60 shrink-0">Owner here:</Text>
<Text className={ ownerInRoom ? 'text-green-700' : 'text-red-700' }>{ ownerInRoom ? 'Yes' : 'No' }</Text>
</div>
</Column>
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-1 shrink-0">
<Button onClick={ event => TryVisitRoom(roomId) }>Visit Room</Button>
<Button onClick={ event => CreateLinkEvent(`mod-tools/open-room-chatlog/${ roomId }`) }>Chatlog</Button>
</div>
</div>
<Column className="bg-muted rounded p-2" gap={ 1 }>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<input checked={ kickUsers } className="form-check-input" type="checkbox" onChange={ event => setKickUsers(event.target.checked) } />
<Text small>Kick everyone out</Text>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<input checked={ lockRoom } className="form-check-input" type="checkbox" onChange={ event => setLockRoom(event.target.checked) } />
<Text small>Enable the doorbell</Text>
</div>
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<input checked={ changeRoomName } className="form-check-input" type="checkbox" onChange={ event => setChangeRoomName(event.target.checked) } />
<Text small>Change room name</Text>
</div>
</Column>
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" placeholder="Type a mandatory message to the users in this text box..." value={ message } onChange={ event => setMessage(event.target.value) }></textarea>
<div className="flex justify-between">
<Button variant="danger" onClick={ event => handleClick('send_message') }>Send Caution</Button>
<Button onClick={ event => handleClick('alert_only') }>Send Alert only</Button>
<textarea className="min-h-[60px] px-2 py-1.5 rounded text-sm border border-black/10" placeholder="Type a mandatory message..." value={ message } onChange={ event => setMessage(event.target.value) }></textarea>
<div className="flex gap-2">
<Button className="grow" variant="danger" onClick={ event => handleClick('send_message') }>Send Caution</Button>
<Button className="grow" onClick={ event => handleClick('alert_only') }>Send Alert</Button>
</div>
</NitroCardContentView>
</NitroCardView>
@@ -1,5 +1,5 @@
import { IssueMessageData, ReleaseIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api';
import { Button, Column, Grid } from '../../../../common';
@@ -12,6 +12,17 @@ interface ModToolsMyIssuesTabViewProps
export const ModToolsMyIssuesTabView: FC<ModToolsMyIssuesTabViewProps> = props =>
{
const { myIssues = null, handleIssue = null } = props;
const pendingReleasesRef = useRef<Set<number>>(new Set());
const releaseIssue = (issueId: number) =>
{
if(pendingReleasesRef.current.has(issueId)) return;
pendingReleasesRef.current.add(issueId);
SendMessageComposer(new ReleaseIssuesMessageComposer([ issueId ]));
setTimeout(() => pendingReleasesRef.current.delete(issueId), 2000);
};
return (
<Column gap={ 0 } overflow="hidden">
@@ -36,7 +47,7 @@ export const ModToolsMyIssuesTabView: FC<ModToolsMyIssuesTabViewProps> = props =
<Button variant="primary" onClick={ event => handleIssue(issue.issueId) }>Handle</Button>
</div>
<div className="col-span-2">
<Button variant="danger" onClick={ event => SendMessageComposer(new ReleaseIssuesMessageComposer([ issue.issueId ])) }>Release</Button>
<Button variant="danger" onClick={ () => releaseIssue(issue.issueId) }>Release</Button>
</div>
</Grid>
);
@@ -1,5 +1,5 @@
import { IssueMessageData, PickIssuesMessageComposer } from '@nitrots/nitro-renderer';
import { FC } from 'react';
import { FC, useRef } from 'react';
import { SendMessageComposer } from '../../../../api';
import { Button, Column, Grid } from '../../../../common';
@@ -11,6 +11,17 @@ interface ModToolsOpenIssuesTabViewProps
export const ModToolsOpenIssuesTabView: FC<ModToolsOpenIssuesTabViewProps> = props =>
{
const { openIssues = null } = props;
const pendingPicksRef = useRef<Set<number>>(new Set());
const pickIssue = (issueId: number) =>
{
if(pendingPicksRef.current.has(issueId)) return;
pendingPicksRef.current.add(issueId);
SendMessageComposer(new PickIssuesMessageComposer([ issueId ], false, 0, 'pick issue button'));
setTimeout(() => pendingPicksRef.current.delete(issueId), 2000);
};
return (
<Column gap={ 0 } overflow="hidden">
@@ -31,7 +42,7 @@ export const ModToolsOpenIssuesTabView: FC<ModToolsOpenIssuesTabViewProps> = pro
<div className="col-span-3">{ issue.reportedUserName }</div>
<div className="col-span-4">{ new Date(Date.now() - issue.issueAgeInMilliseconds).toLocaleTimeString() }</div>
<div className="col-span-3">
<Button variant="success" onClick={ event => SendMessageComposer(new PickIssuesMessageComposer([ issue.issueId ], false, 0, 'pick issue button')) }>Pick Issue</Button>
<Button variant="success" onClick={ () => pickIssue(issue.issueId) }>Pick Issue</Button>
</div>
</Grid>
);
@@ -1,5 +1,5 @@
import { CallForHelpTopicData, DefaultSanctionMessageComposer, ModAlertMessageComposer, ModBanMessageComposer, ModKickMessageComposer, ModMessageMessageComposer, ModMuteMessageComposer, ModTradingLockMessageComposer } from '@nitrots/nitro-renderer';
import { FC, useMemo, useState } from 'react';
import { FC, useMemo, useRef, useState } from 'react';
import { ISelectedUser, LocalizeText, ModActionDefinition, NotificationAlertType, SendMessageComposer } from '../../../../api';
import { Button, DraggableWindowPosition, Flex, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useModTools, useNotification } from '../../../../hooks';
@@ -33,6 +33,7 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
const [ message, setMessage ] = useState<string>('');
const { cfhCategories = null, settings = null } = useModTools();
const { simpleAlert = null } = useNotification();
const isSendingRef = useRef<boolean>(false);
const topics = useMemo(() =>
{
@@ -53,6 +54,8 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
const sendDefaultSanction = () =>
{
if(isSendingRef.current) return;
let errorMessage: string = null;
const category = topics[selectedTopic];
@@ -63,6 +66,8 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
const messageOrDefault = (message.trim().length === 0) ? LocalizeText(`help.cfh.topic.${ category.id }`) : message;
isSendingRef.current = true;
SendMessageComposer(new DefaultSanctionMessageComposer(user.userId, selectedTopic, messageOrDefault));
onCloseClick();
@@ -70,6 +75,8 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
const sendSanction = () =>
{
if(isSendingRef.current) return;
let errorMessage: string = null;
const category = topics[selectedTopic];
@@ -145,6 +152,8 @@ export const ModToolsUserModActionView: FC<ModToolsUserModActionViewProps> = pro
}
}
isSendingRef.current = true;
onCloseClick();
};
@@ -6,6 +6,9 @@ import { Button, Flex, Grid, LayoutCurrencyIcon, LayoutGridItem, Text } from '..
import { useNavigator } from '../../../hooks';
import { NitroInput } from '../../../layout';
let isCreatingRoom = false;
let createRoomTimeout: ReturnType<typeof setTimeout> = null;
export const NavigatorRoomCreatorView: FC<{}> = props =>
{
const [ maxVisitorsList, setMaxVisitorsList ] = useState<number[]>(null);
@@ -16,6 +19,7 @@ export const NavigatorRoomCreatorView: FC<{}> = props =>
const [ tradesSetting, setTradesSetting ] = useState<number>(0);
const [ roomModels, setRoomModels ] = useState<IRoomModel[]>([]);
const [ selectedModelName, setSelectedModelName ] = useState<string>('');
const [ isCreating, setIsCreating ] = useState<boolean>(isCreatingRoom);
const { categories = null } = useNavigator();
const hcDisabled = GetConfigurationValue<boolean>('hc.disabled', false);
@@ -31,7 +35,19 @@ export const NavigatorRoomCreatorView: FC<{}> = props =>
const createRoom = () =>
{
if(isCreatingRoom) return;
isCreatingRoom = true;
setIsCreating(true);
SendMessageComposer(new CreateFlatMessageComposer(name, description, 'model_' + selectedModelName, Number(category), Number(visitorsCount), tradesSetting));
if(createRoomTimeout) clearTimeout(createRoomTimeout);
createRoomTimeout = setTimeout(() =>
{
isCreatingRoom = false;
setIsCreating(false);
}, 5000);
};
useEffect(() =>
@@ -117,7 +133,7 @@ export const NavigatorRoomCreatorView: FC<{}> = props =>
}
</div>
</Grid>
<Button fullWidth disabled={ (!name || (name.length < 3)) } variant={ (!name || (name.length < 3)) ? 'danger' : 'success' } onClick={ createRoom }>{ LocalizeText('navigator.createroom.create') }</Button>
<Button fullWidth disabled={ isCreating || !name || (name.length < 3) } variant={ (isCreating || !name || (name.length < 3)) ? 'danger' : 'success' } onClick={ createRoom }>{ LocalizeText('navigator.createroom.create') }</Button>
</div>
);
};
@@ -162,6 +162,11 @@ export const NavigatorRoomSettingsBasicTabView: FC<NavigatorRoomSettingsTabViewP
<input className="form-check-input" type="checkbox" checked={ roomData.allowWalkthrough } onChange={ event => handleChange('allow_walkthrough', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allow_walk_through') }</Text>
</Flex>
<Flex alignItems="center" gap={ 1 }>
<Base className="col-3" />
<input className="form-check-input" type="checkbox" checked={ roomData.allowUnderpass } onChange={ event => handleChange('allow_underpass', event.target.checked) } />
<Text>{ LocalizeText('navigator.roomsettings.allow_underpass') }</Text>
</Flex>
<Text variant="danger" underline bold pointer className="d-flex justify-content-center align-items-center gap-1" onClick={ deleteRoom }>
<FaTimes className="fa-icon" /> { LocalizeText('navigator.roomsettings.delete') }
</Text>
@@ -1,5 +1,5 @@
import { FlatControllerAddedEvent, FlatControllerRemovedEvent, FlatControllersEvent, RemoveAllRightsMessageComposer, RoomGiveRightsComposer, RoomTakeRightsComposer, RoomUsersWithRightsComposer } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FC, useEffect, useRef, useState } from 'react';
import { IRoomData, LocalizeText, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, Text, UserProfileIconView } from '../../../../common';
import { useFriends, useMessageEvent } from '../../../../hooks';
@@ -18,6 +18,17 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
const { roomData = null } = props;
const [ usersWithRights, setUsersWithRights ] = useState<Map<number, string>>(new Map());
const { onlineFriends = [], offlineFriends = [] } = useFriends();
const pendingActionsRef = useRef<Set<string>>(new Set());
const guardedSend = (key: string, composer: any) =>
{
if(pendingActionsRef.current.has(key)) return;
pendingActionsRef.current.add(key);
SendMessageComposer(composer);
setTimeout(() => pendingActionsRef.current.delete(key), 2000);
};
const allFriendsRaw = [ ...onlineFriends, ...offlineFriends ];
@@ -115,7 +126,7 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
<Text
pointer
grow
onClick={ () => SendMessageComposer(new RoomTakeRightsComposer(id)) }>
onClick={ () => guardedSend(`take_${id}`, new RoomTakeRightsComposer(id)) }>
{ name }
</Text>
</Flex>
@@ -127,7 +138,7 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
<Button
variant="danger"
disabled={ !filteredUsersWithRights.size }
onClick={ () => roomData && SendMessageComposer(new RemoveAllRightsMessageComposer(roomData.roomId)) }>
onClick={ () => roomData && guardedSend('removeAll', new RemoveAllRightsMessageComposer(roomData.roomId)) }>
{ LocalizeText('navigator.flatctrls.clear') }
</Button>
</Column>
@@ -154,7 +165,7 @@ export const NavigatorRoomSettingsRightsTabView: FC<NavigatorRoomSettingsTabView
<Text
pointer
grow
onClick={ () => SendMessageComposer(new RoomGiveRightsComposer(friend.id)) }>
onClick={ () => guardedSend(`give_${friend.id}`, new RoomGiveRightsComposer(friend.id)) }>
{ friend.name }
</Text>
</Flex>
@@ -39,6 +39,7 @@ export const NavigatorRoomSettingsView: FC<{}> = props =>
tags: data.tags,
tradeState: data.tradeMode,
allowWalkthrough: data.allowWalkThrough,
allowUnderpass: data.allowUnderpass,
lockState: data.doorMode,
password: null,
allowPets: data.allowPets,
@@ -98,6 +99,9 @@ export const NavigatorRoomSettingsView: FC<{}> = props =>
case 'allow_walkthrough':
newValue.allowWalkthrough = Boolean(value);
break;
case 'allow_underpass':
newValue.allowUnderpass = Boolean(value);
break;
case 'allow_pets':
newValue.allowPets = Boolean(value);
break;
@@ -171,7 +175,8 @@ export const NavigatorRoomSettingsView: FC<{}> = props =>
newValue.chatSettings.weight,
newValue.chatSettings.speed,
newValue.chatSettings.distance,
newValue.chatSettings.protection
newValue.chatSettings.protection,
newValue.allowUnderpass
));
return newValue;
+2 -1
View File
@@ -43,10 +43,11 @@ export const RoomView: FC<{}> = (props) =>
<AnimatePresence>
{
<motion.div
className="w-full h-full"
initial={ { opacity: 0 }}
animate={ { opacity: 1 }}
exit={ { opacity: 0 }}>
<div ref={ elementRef } className="w-100 h-100">
<div ref={ elementRef } className="w-full h-full">
{ roomSession instanceof RoomSession &&
<>
<RoomWidgetsView />
@@ -0,0 +1,170 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { FaPlus } from 'react-icons/fa';
import { LocalizeText } from '../../../../../api';
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<HTMLDivElement>(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 (
<div
ref={ ref }
className="absolute right-[calc(100%+8px)] top-0 z-50 bg-[rgba(28,28,32,0.97)] border border-white/20 rounded-md p-2 shadow-lg min-w-[160px]"
onClick={ e => e.stopPropagation() }>
<input
autoFocus
className="w-full text-xs text-white bg-white/10 border border-white/20 rounded px-2 py-1 mb-2 outline-none focus:border-white/40"
placeholder={ LocalizeText('catalog.search') }
type="text"
value={ search }
onChange={ e => setSearch(e.target.value) }
/>
{ badgeCodes.length === 0
? <span className="text-xs text-white/40 text-center py-2 block">{ LocalizeText('generic.loading') }</span>
: (
<div className="grid grid-cols-4 gap-1 max-h-[160px] overflow-y-auto">
{ filtered.slice(0, 40).map(code => (
<div
key={ code }
className="flex items-center justify-center w-[36px] h-[36px] cursor-pointer rounded border border-transparent hover:border-white/40 hover:bg-white/10 transition-all"
onClick={ () => onSelect(code) }>
<LayoutBadgeImageView badgeCode={ code } />
</div>
)) }
{ filtered.length === 0 && (
<span className="text-xs text-white/40 col-span-4 text-center py-2">{ LocalizeText('generic.no_results_found') }</span>
) }
</div>
) }
</div>
);
};
export const InfoStandBadgeSlotView: FC<InfoStandBadgeSlotProps> = ({ slotIndex, badgeCode: badgeCodeFromProps, isOwnUser }) =>
{
const { activeBadgeCodes = [], setBadgeAtSlot = null, swapBadges = null } = useInventoryBadges();
const [ isDragOver, setIsDragOver ] = 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 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 !== '')
{
const sourceSlot = parseInt(sourceSlotStr);
if(sourceSlot !== slotIndex) swapBadges(sourceSlot, slotIndex);
}
else
{
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 (
<div className="relative">
<div
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' : '' }
${ isOwnUser && !badgeCode ? 'opacity-40 hover:opacity-70 border border-dashed border-white/20 rounded-sm' : '' }` }
draggable={ isOwnUser && !!badgeCode }
onDragLeave={ onDragLeave }
onDragOver={ onDragOver }
onDragStart={ onDragStart }
onDrop={ onDrop }
onClick={ handleSlotClick }>
{ badgeCode
? <LayoutBadgeImageView badgeCode={ badgeCode } showInfo={ true } />
: isOwnUser && <FaPlus className="text-white/30 text-[10px]" /> }
</div>
{ showPicker && (
<BadgeMiniPicker
activeBadgeCodes={ activeBadgeCodes }
onClose={ () => setShowPicker(false) }
onSelect={ handlePickerSelect }
/>
) }
</div>
);
};
@@ -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<InfoStandWidgetUserViewProps> = props =
/>
)}
<Column grow alignItems="center" gap={0}>
<div className="flex gap-1">
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[0] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[0]} showInfo={true} />}
</div>
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
{avatarInfo.groupId > 0 &&
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
</Flex>
</div>
<Flex center gap={1}>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[1] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[1]} showInfo={true} />}
</div>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[2] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[2]} showInfo={true} />}
</div>
</Flex>
<Flex center gap={1}>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[3] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[3]} showInfo={true} />}
</div>
<div className="flex items-center justify-center relative w-[40px] h-[40px] bg-no-repeat bg-center">
{avatarInfo.badges[4] && <LayoutBadgeImageView badgeCode={avatarInfo.badges[4]} showInfo={true} />}
</div>
</Flex>
{ GetConfigurationValue<boolean>('user.badges.group.slot.enabled', true)
? (
<>
<div className="flex gap-1">
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<Flex center className="relative w-[40px] h-[40px] bg-no-repeat bg-center" pointer={avatarInfo.groupId > 0} onClick={event => GetGroupInformation(avatarInfo.groupId)}>
{avatarInfo.groupId > 0 &&
<LayoutBadgeImageView badgeCode={avatarInfo.groupBadgeId} customTitle={avatarInfo.groupName} isGroup={true} showInfo={true} />}
</Flex>
</div>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
</>
)
: (
<>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={0} badgeCode={avatarInfo.badges[0]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={1} badgeCode={avatarInfo.badges[1]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={2} badgeCode={avatarInfo.badges[2]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={3} badgeCode={avatarInfo.badges[3]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
<Flex center gap={1}>
<InfoStandBadgeSlotView slotIndex={4} badgeCode={avatarInfo.badges[4]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
<InfoStandBadgeSlotView slotIndex={5} badgeCode={avatarInfo.badges[5]} isOwnUser={avatarInfo.type === AvatarInfoUser.OWN_USER} />
</Flex>
</>
)
}
</Column>
</div>
<hr className="m-0 bg-[#0003] border-0 opacity-[0.5] h-px" />
+5
View File
@@ -13,11 +13,16 @@ body {
width: 100%;
height: 100%;
overflow: hidden;
background-color: #000;
-webkit-user-select: none;
user-select: none;
scrollbar-width: thin;
}
.image-rendering-pixelated {
image-rendering: pixelated;
}
*,
*:focus,
*:hover {
+99 -9
View File
@@ -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<number>('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);
+12 -7
View File
@@ -1,7 +1,7 @@
import { ColorConverter, GetRenderer, GetRoomEngine, GetStage, IRoomSession, NitroAdjustmentFilter, NitroSprite, NitroTexture, RoomBackgroundColorEvent, RoomEngineEvent, RoomEngineObjectEvent, RoomGeometry, RoomId, RoomObjectCategory, RoomObjectHSLColorEnabledEvent, RoomObjectOperationType, RoomSessionEvent, RoomVariableEnum, Vector3d } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { CanManipulateFurniture, DispatchUiEvent, GetRoomSession, InitializeRoomInstanceRenderingCanvas, IsFurnitureSelectionDisabled, ProcessRoomObjectOperation, RoomWidgetUpdateBackgroundColorPreviewEvent, RoomWidgetUpdateRoomObjectEvent, SetActiveRoomId, StartRoomSession } from '../../api';
import { CanManipulateFurniture, DispatchUiEvent, GetRoomSession, IsFurnitureSelectionDisabled, ProcessRoomObjectOperation, RoomWidgetUpdateBackgroundColorPreviewEvent, RoomWidgetUpdateRoomObjectEvent, SetActiveRoomId, StartRoomSession } from '../../api';
import { useNitroEvent, useUiEvent } from '../events';
const useRoomState = () =>
@@ -253,15 +253,20 @@ const useRoomState = () =>
const resize = (event: UIEvent) =>
{
const width = Math.floor(window.innerWidth);
const height = Math.floor(window.innerHeight);
const newWidth = Math.floor(window.innerWidth);
const newHeight = Math.floor(window.innerHeight);
renderer.resize(width, height, window.devicePixelRatio);
const offsetX = canvas.screenOffsetX - (newWidth - canvas.width) / 2;
const offsetY = canvas.screenOffsetY - (newHeight - canvas.height) / 2;
background.width = width;
background.height = height;
renderer.resize(newWidth, newHeight, window.devicePixelRatio);
InitializeRoomInstanceRenderingCanvas(width, height, 1);
background.width = newWidth;
background.height = newHeight;
canvas.initialize(newWidth, newHeight);
canvas.screenOffsetX = ~~offsetX;
canvas.screenOffsetY = ~~offsetY;
};
window.addEventListener('resize', resize);
+1 -1
View File
@@ -16,7 +16,7 @@ export default defineConfig({
chunkSizeWarningLimit: 200000,
rollupOptions: {
output: {
assetFileNames: 'src/assets/[name].[ext]',
assetFileNames: 'src/assets/[name]-[hash].[ext]',
manualChunks: id =>
{
if(id.includes('node_modules'))