WIP preserve local changes before duckie merge

This commit is contained in:
Lorenzune
2026-04-21 11:13:32 +02:00
parent e0174e450c
commit 9b36513def
74 changed files with 4419 additions and 408 deletions
+17
View File
@@ -8,6 +8,7 @@ import { CameraWidgetView } from './camera/CameraWidgetView';
import { CampaignView } from './campaign/CampaignView';
import { CatalogView } from './catalog/CatalogView';
import { ChatHistoryView } from './chat-history/ChatHistoryView';
import { CustomizeNickIconView } from './customize/CustomizeNickIconView';
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
import { FurniEditorView } from './furni-editor/FurniEditorView';
import { FriendsView } from './friends/FriendsView';
@@ -27,6 +28,8 @@ import { ExternalPluginLoader } from './plugins/ExternalPluginLoader';
import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView';
import { TranslationBootstrap } from './translation/TranslationBootstrap';
import { TranslationSettingsView } from './translation/TranslationSettingsView';
import { UserProfileView } from './user-profile/UserProfileView';
import { UserSettingsView } from './user-settings/UserSettingsView';
import { WiredView } from './wired/WiredView';
@@ -37,6 +40,7 @@ export const MainView: FC<{}> = props =>
{
const [ isReady, setIsReady ] = useState(false);
const [ landingViewVisible, setLandingViewVisible ] = useState(true);
const [ localizationVersion, setLocalizationVersion ] = useState(0);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
@@ -86,8 +90,18 @@ export const MainView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
const refreshLocalization = () => setLocalizationVersion(value => (value + 1));
window.addEventListener('nitro-localization-updated', refreshLocalization);
return () => window.removeEventListener('nitro-localization-updated', refreshLocalization);
}, []);
return (
<>
<div className="hidden" data-localization-version={ localizationVersion } />
<AnimatePresence>
{ landingViewVisible &&
<motion.div
@@ -98,10 +112,12 @@ export const MainView: FC<{}> = props =>
</motion.div> }
</AnimatePresence>
<ToolbarView isInRoom={ !landingViewVisible } />
<TranslationBootstrap />
<ModToolsView />
<WiredCreatorToolsView />
<RoomView />
<ChatHistoryView />
<CustomizeNickIconView />
<WiredView />
<AvatarEditorView />
<AchievementsView />
@@ -112,6 +128,7 @@ export const MainView: FC<{}> = props =>
<FriendsView />
<RightSideView />
<UserSettingsView />
<TranslationSettingsView />
<UserProfileView />
<GroupsView />
<GroupForumView />
+14 -2
View File
@@ -1,13 +1,25 @@
import { FC } from 'react';
import { GetConfigurationValue } from '../../api';
import { useCatalog } from '../../hooks';
import { CatalogClassicView } from './CatalogClassicView';
import { CatalogModernView } from './CatalogModernView';
export const CatalogView: FC<{}> = () =>
{
const { catalogLocalizationVersion = 0 } = useCatalog();
const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false);
if(useNewStyle) return <CatalogModernView />;
if(useNewStyle) return (
<>
<div className="hidden" data-catalog-localization-version={ catalogLocalizationVersion } />
<CatalogModernView />
</>
);
return <CatalogClassicView />;
return (
<>
<div className="hidden" data-catalog-localization-version={ catalogLocalizationVersion } />
<CatalogClassicView />
</>
);
};
@@ -1,17 +1,24 @@
import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api';
import { useCatalog } from '../../../../../hooks';
export const CatalogSearchView: FC<{}> = () =>
{
const [ searchValue, setSearchValue ] = useState('');
const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog();
const normalizeSearchText = (value: string) => (value || '')
.toLocaleLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\s+/g, ' ')
.trim();
useEffect(() =>
{
let search = searchValue?.toLocaleLowerCase().replace(' ', '');
const search = normalizeSearchText(searchValue);
if(!search || !search.length)
{
@@ -22,7 +29,7 @@ export const CatalogSearchView: FC<{}> = () =>
const timeout = setTimeout(() =>
{
if(!offersToNodes || !rootNode) return;
if(!rootNode) return;
const furnitureDatas = GetSessionDataManager().getAllFurnitureData();
@@ -39,34 +46,35 @@ export const CatalogSearchView: FC<{}> = () =>
if((currentType === CatalogType.NORMAL) && furniture.excludeDynamic) continue;
const searchValues = [ furniture.className || '', furniture.name || '', furniture.description || '' ].join(' ').replace(/ /gi, '').toLowerCase();
const name = normalizeSearchText(furniture.name || '');
const matchesSearch = name.includes(search);
if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1))
{
if((furniture.furniLine !== '') && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
if(searchValues.indexOf(search) >= 0) foundFurniLines.push(furniture.furniLine);
if(matchesSearch) foundFurniLines.push(furniture.furniLine);
}
}
else
else if(matchesSearch)
{
const foundNodes = [
...GetOfferNodes(offersToNodes, furniture.purchaseOfferId),
...GetOfferNodes(offersToNodes, furniture.rentOfferId)
];
foundFurniture.push(furniture);
if(foundNodes.length)
if(furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{
if(searchValues.indexOf(search) >= 0) foundFurniture.push(furniture);
if(foundFurniture.length === 250) break;
foundFurniLines.push(furniture.furniLine);
}
if(foundFurniture.length === 250) break;
}
}
const offers: IPurchasableOffer[] = [];
for(const furniture of foundFurniture) offers.push(new FurnitureOffer(furniture));
for(const furniture of foundFurniture)
{
offers.push(new FurnitureOffer(furniture));
}
let nodes: ICatalogNode[] = [];
@@ -77,7 +85,7 @@ export const CatalogSearchView: FC<{}> = () =>
}, 300);
return () => clearTimeout(timeout);
}, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
}, [ currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
return (
<div className="relative w-full">
@@ -1,5 +1,5 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api';
import { IPurchasableOffer } from '../../../../../api';
import { AutoGrid, AutoGridProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext';
@@ -13,7 +13,7 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps
export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = props =>
{
const { columnCount = 5, children = null, ...rest } = props;
const { currentOffer = null, setCurrentOffer = null, currentPage = null, setPurchaseOptions = null } = useCatalog();
const { currentOffer = null, currentPage = null, selectCatalogOffer = null } = useCatalog();
const catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false;
const elementRef = useRef<HTMLDivElement>();
@@ -29,23 +29,7 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
const selectOffer = (offer: IPurchasableOffer) =>
{
offer.activate();
if(offer.isLazy) return;
setCurrentOffer(offer);
if(offer.product && (offer.product.productType === ProductTypeEnum.WALL))
{
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraData = (offer.product.extraParam || null);
return newValue;
});
}
selectCatalogOffer(offer);
};
const handleDragStart = useCallback((index: number) =>
@@ -0,0 +1,584 @@
import { AddLinkEventTracker, ILinkEventTracker, PurchaseCatalogPrefixComposer, PurchaseNickIconComposer, PurchasePrefixComposer, RemoveLinkEventTracker, RequestNickIconsComposer, SetActiveNickIconComposer, SetActivePrefixComposer, SetDisplayOrderComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { FC, useEffect, useMemo, useState } from 'react';
import { INickIconItem, IPrefixItem, PRESET_PREFIX_EFFECTS, PRESET_PREFIX_FONTS, SendMessageComposer, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../../api';
import { GetNickIconUrl } from '../../assets/images/user_custom/nick_icons';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text, UserIdentityView } from '../../common';
import { LayoutCurrencyIcon } from '../../common/layout/LayoutCurrencyIcon';
import { useMessageEvent } from '../../hooks';
type CustomizeTab = 'icons' | 'prefix' | 'settings';
type PrefixSubTab = 'library' | 'custom';
interface ICatalogPrefixItem extends IPrefixItem
{
points: number;
pointsType: number;
owned: boolean;
ownedPrefixId: number;
}
interface ICombinedPrefixItem extends IPrefixItem
{
points: number;
pointsType: number;
owned: boolean;
ownedPrefixId: number;
}
const ORDER_LABELS: Record<string, string> = {
'icon-prefix-name': 'Icon / Prefix / Name',
'prefix-icon-name': 'Prefix / Icon / Name',
'name-icon-prefix': 'Name / Icon / Prefix',
'name-prefix-icon': 'Name / Prefix / Icon',
'icon-name-prefix': 'Icon / Name / Prefix',
'prefix-name-icon': 'Prefix / Name / Icon'
};
const PRESET_COLORS: string[] = [
'#D62828', '#E85D04', '#F77F00', '#2A9D8F',
'#0077B6', '#4361EE', '#6A4C93', '#C1121F',
'#B5179E', '#3A86FF', '#3F8E00', '#8D5524'
];
export const CustomizeNickIconView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ activeTab, setActiveTab ] = useState<CustomizeTab>('icons');
const [ activePrefixSubTab, setActivePrefixSubTab ] = useState<PrefixSubTab>('library');
const [ iconItems, setIconItems ] = useState<INickIconItem[]>([]);
const [ prefixItems, setPrefixItems ] = useState<IPrefixItem[]>([]);
const [ catalogPrefixes, setCatalogPrefixes ] = useState<ICatalogPrefixItem[]>([]);
const [ displayOrder, setDisplayOrder ] = useState('icon-prefix-name');
const [ customPrefixMaxLength, setCustomPrefixMaxLength ] = useState(15);
const [ customPrefixPriceCredits, setCustomPrefixPriceCredits ] = useState(0);
const [ customPrefixPricePoints, setCustomPrefixPricePoints ] = useState(0);
const [ customPrefixPointsType, setCustomPrefixPointsType ] = useState(0);
const [ customPrefixFontPriceCredits, setCustomPrefixFontPriceCredits ] = useState(0);
const [ customPrefixFontPricePoints, setCustomPrefixFontPricePoints ] = useState(0);
const [ customPrefixFontPointsType, setCustomPrefixFontPointsType ] = useState(0);
const [ customPrefixText, setCustomPrefixText ] = useState('');
const [ customPrefixColor, setCustomPrefixColor ] = useState('#FFFFFF');
const [ customPrefixIcon, setCustomPrefixIcon ] = useState('');
const [ customPrefixEffect, setCustomPrefixEffect ] = useState('');
const [ customPrefixFont, setCustomPrefixFont ] = useState('');
const [ showEmojiPicker, setShowEmojiPicker ] = useState(false);
useMessageEvent<UserNickIconsEvent>(UserNickIconsEvent, event =>
{
const parser = event.getParser();
setIconItems(parser.nickIcons.map(icon => ({
id: icon.id,
iconKey: icon.iconKey,
displayName: icon.displayName,
points: icon.points,
pointsType: icon.pointsType,
owned: icon.owned,
active: icon.active
})));
setPrefixItems(parser.ownedPrefixes.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon || '',
effect: prefix.effect || '',
font: prefix.font || '',
active: prefix.active,
isCustom: prefix.isCustom,
points: prefix.points,
pointsType: prefix.pointsType,
catalogPrefixId: prefix.catalogPrefixId
})));
setCatalogPrefixes(parser.prefixCatalog.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon || '',
effect: prefix.effect || '',
font: prefix.font || '',
active: prefix.active,
points: prefix.points,
pointsType: prefix.pointsType,
owned: prefix.owned,
ownedPrefixId: prefix.ownedPrefixId
})));
setDisplayOrder(parser.displayOrder || 'icon-prefix-name');
setCustomPrefixMaxLength(parser.customPrefixMaxLength || 15);
setCustomPrefixPriceCredits(parser.customPrefixPriceCredits || 0);
setCustomPrefixPricePoints(parser.customPrefixPricePoints || 0);
setCustomPrefixPointsType(parser.customPrefixPointsType || 0);
setCustomPrefixFontPriceCredits(parser.customPrefixFontPriceCredits || 0);
setCustomPrefixFontPricePoints(parser.customPrefixFontPricePoints || 0);
setCustomPrefixFontPointsType(parser.customPrefixFontPointsType || 0);
setIsLoading(false);
});
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(previousValue => !previousValue);
return;
}
},
eventUrlPrefix: 'customize/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(!isVisible) return;
setIsLoading(true);
SendMessageComposer(new RequestNickIconsComposer());
}, [ isVisible ]);
const activeIcon = useMemo(() => iconItems.find(item => item.active) || null, [ iconItems ]);
const activePrefix = useMemo(() => prefixItems.find(item => item.active) || null, [ prefixItems ]);
const combinedPrefixes = useMemo(() =>
{
const ownedByCatalogId = new Map<number, IPrefixItem>();
for(const prefix of prefixItems)
{
if(prefix.catalogPrefixId && (prefix.catalogPrefixId > 0)) ownedByCatalogId.set(prefix.catalogPrefixId, prefix);
}
const catalogEntries: ICombinedPrefixItem[] = catalogPrefixes.map(prefix =>
{
const ownedPrefix = ownedByCatalogId.get(prefix.id);
return {
id: ownedPrefix?.id || prefix.id,
displayName: ownedPrefix?.displayName || prefix.displayName,
text: ownedPrefix?.text || prefix.text,
color: ownedPrefix?.color || prefix.color,
icon: ownedPrefix?.icon || prefix.icon,
effect: ownedPrefix?.effect || prefix.effect,
font: ownedPrefix?.font || prefix.font,
active: ownedPrefix?.active || prefix.active,
isCustom: false,
points: prefix.points,
pointsType: prefix.pointsType,
catalogPrefixId: prefix.id,
owned: prefix.owned || !!ownedPrefix,
ownedPrefixId: prefix.ownedPrefixId || ownedPrefix?.id || 0
};
});
const customEntries: ICombinedPrefixItem[] = prefixItems
.filter(prefix => !prefix.catalogPrefixId || (prefix.catalogPrefixId <= 0))
.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon,
effect: prefix.effect,
font: prefix.font || '',
active: prefix.active,
isCustom: true,
points: prefix.points || customPrefixPricePoints,
pointsType: prefix.pointsType || customPrefixPointsType,
catalogPrefixId: 0,
owned: true,
ownedPrefixId: prefix.id
}));
return [ ...catalogEntries, ...customEntries ];
}, [ catalogPrefixes, customPrefixPointsType, customPrefixPricePoints, prefixItems ]);
const selectedEffectOption = useMemo(() => PRESET_PREFIX_EFFECTS.find(effect => effect.id === customPrefixEffect) || PRESET_PREFIX_EFFECTS[0], [ customPrefixEffect ]);
const selectedFontOption = useMemo(() => PRESET_PREFIX_FONTS.find(font => font.id === customPrefixFont) || PRESET_PREFIX_FONTS[0], [ customPrefixFont ]);
const basicEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'basic'), []);
const premiumEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'premium'), []);
const basicFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'basic'), []);
const premiumFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'premium'), []);
const prefixPreviewColors = useMemo(() => parsePrefixColors(customPrefixText || 'Preview', customPrefixColor || '#FFFFFF'), [ customPrefixText, customPrefixColor ]);
const customPrefixPreviewStyle = useMemo(() => getPrefixEffectStyle(customPrefixEffect, prefixPreviewColors[0] || '#FFFFFF'), [ customPrefixEffect, prefixPreviewColors ]);
const customPrefixFontStyle = useMemo(() => getPrefixFontStyle(customPrefixFont), [ customPrefixFont ]);
const customPrefixTotalCredits = useMemo(() => customPrefixPriceCredits + (customPrefixFont ? customPrefixFontPriceCredits : 0), [ customPrefixFont, customPrefixFontPriceCredits, customPrefixPriceCredits ]);
const customPrefixTotalPoints = useMemo(() => customPrefixPricePoints + ((customPrefixFont && (customPrefixFontPointsType === customPrefixPointsType)) ? customPrefixFontPricePoints : 0), [ customPrefixFont, customPrefixFontPointsType, customPrefixFontPricePoints, customPrefixPointsType, customPrefixPricePoints ]);
const customPrefixIsValid = useMemo(() =>
{
const trimmed = customPrefixText.trim();
if(!trimmed.length || (trimmed.length > customPrefixMaxLength)) return false;
return customPrefixColor.split(',').every(color => /^#[0-9A-Fa-f]{6}$/.test(color));
}, [ customPrefixColor, customPrefixMaxLength, customPrefixText ]);
const refreshCustomizeData = () =>
{
setIsLoading(true);
SendMessageComposer(new RequestNickIconsComposer());
};
const handleIconAction = (item: INickIconItem) =>
{
setIsLoading(true);
if(!item.owned)
{
SendMessageComposer(new PurchaseNickIconComposer(item.iconKey));
return;
}
SendMessageComposer(new SetActiveNickIconComposer(item.active ? 0 : item.id));
};
const handleCombinedPrefixAction = (item: ICombinedPrefixItem) =>
{
setIsLoading(true);
if(item.owned)
{
SendMessageComposer(new SetActivePrefixComposer(item.active ? 0 : item.ownedPrefixId));
return;
}
SendMessageComposer(new PurchaseCatalogPrefixComposer(item.catalogPrefixId || item.id));
};
const handleCustomPrefixPurchase = () =>
{
if(!customPrefixIsValid) return;
setIsLoading(true);
SendMessageComposer(new PurchasePrefixComposer(customPrefixText.trim(), customPrefixColor, customPrefixIcon, customPrefixEffect, customPrefixFont));
};
const handleDisplayOrderChange = (nextDisplayOrder: string) =>
{
if(nextDisplayOrder === displayOrder) return;
setDisplayOrder(nextDisplayOrder);
setIsLoading(true);
SendMessageComposer(new SetDisplayOrderComposer(nextDisplayOrder));
};
if(!isVisible) return null;
return (
<NitroCardView className="customize-nick-icon-window w-[680px] max-w-[95vw]" theme="primary-slim" uniqueKey="customize-nick-icons">
<NitroCardHeaderView headerText="Customize Bubble Identity" onCloseClick={ () => setIsVisible(false) } />
<NitroCardTabsView>
<NitroCardTabsItemView isActive={ activeTab === 'icons' } onClick={ () => setActiveTab('icons') }>
<Text>Icons</Text>
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === 'prefix' } onClick={ () => setActiveTab('prefix') }>
<Text>Prefix</Text>
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === 'settings' } onClick={ () => setActiveTab('settings') }>
<Text>Settings</Text>
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView className="flex max-h-[78vh] flex-col gap-3 overflow-y-auto text-black">
<div className="rounded border border-black/10 bg-black/5 p-3">
<Text bold>Live preview</Text>
<div className="mt-2 flex min-h-[54px] items-center justify-center rounded border border-black/10 bg-[#1f2937] px-3 py-2 text-white">
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ activePrefix?.color || customPrefixColor }
prefixEffect={ activePrefix?.effect || customPrefixEffect }
prefixFont={ activePrefix?.font || customPrefixFont }
prefixIcon={ activePrefix?.icon || customPrefixIcon }
prefixText={ activePrefix?.text || customPrefixText }
username="Username" />
</div>
</div>
{ activeTab === 'icons' &&
<>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
Choose the icon shown in your bubble identity.
</div>
<div className="grid grid-cols-3 gap-2">
{ iconItems.map(item =>
{
const iconUrl = GetNickIconUrl(item.iconKey);
return (
<div
key={ item.iconKey }
className={ `relative flex min-h-[126px] flex-col items-center justify-between gap-2 rounded border p-3 transition-colors ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<img className="h-auto max-h-[28px] w-auto object-contain" src={ iconUrl } alt={ item.iconKey } />
<div className="flex flex-col items-center gap-1 text-center text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="max-w-[140px] truncate">{ item.displayName || `Icon #${ item.iconKey }` }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleIconAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
);
}) }
</div>
</> }
{ activeTab === 'prefix' &&
<div className="flex flex-col gap-3">
<div className="rounded border border-black/10 bg-black/5 p-1">
<div className="flex items-center gap-2">
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activePrefixSubTab === 'library' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActivePrefixSubTab('library') }>
Library
</button>
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activePrefixSubTab === 'custom' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActivePrefixSubTab('custom') }>
Custom
</button>
</div>
</div>
{ activePrefixSubTab === 'library' &&
<>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
Choose a preset or custom prefix for your bubble identity.
</div>
<div className="grid grid-cols-2 gap-2">
{ combinedPrefixes.map(item => (
<div key={ `${ item.catalogPrefixId || 'custom' }-${ item.ownedPrefixId || item.id }` } className={ `relative flex min-h-[96px] flex-col gap-2 rounded border p-2.5 ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ item.color }
prefixEffect={ item.effect }
prefixFont={ item.font || '' }
prefixIcon={ item.icon }
prefixText={ item.text }
username="Username" />
<div className="flex flex-col gap-1 text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="truncate">{ item.displayName || item.text }{ item.isCustom ? ' - Custom' : '' }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleCombinedPrefixAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
)) }
</div>
</> }
{ activePrefixSubTab === 'custom' &&
<div className="rounded border border-black/10 bg-black/5 p-3">
<div className="mb-2 flex items-center justify-between">
<Text bold>Custom prefix</Text>
<Button disabled={ isLoading } onClick={ refreshCustomizeData }>Refresh</Button>
</div>
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-black/10 bg-white px-3 py-2 text-sm"
maxLength={ customPrefixMaxLength }
placeholder="Enter your prefix"
type="text"
value={ customPrefixText }
onChange={ event => setCustomPrefixText(event.target.value) } />
<span className="text-[11px] text-black/60">{ customPrefixText.length }/{ customPrefixMaxLength }</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button className="rounded border border-black/10 bg-white px-3 py-2 text-sm" type="button" onClick={ () => setShowEmojiPicker(true) }>
{ customPrefixIcon || 'Emoji' }
</button>
{ !!customPrefixIcon && <Button onClick={ () => setCustomPrefixIcon('') }>Clear</Button> }
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Safe colors only, chosen to stay readable on both light and dark backgrounds.
</div>
<div className="grid grid-cols-6 gap-2">
{ PRESET_COLORS.map(color => (
<button
key={ color }
className={ `flex h-[28px] items-center justify-center rounded border text-[10px] font-bold uppercase ${ customPrefixColor === color ? 'border-[#1e7295] ring-1 ring-[#1e7295]' : 'border-black/10' }` }
style={ { backgroundColor: color } }
type="button"
onClick={ () => setCustomPrefixColor(color) }>
{ customPrefixColor === color ? 'ON' : '' }
</button>
)) }
</div>
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Effect
</div>
<div className="flex items-center gap-2">
<select
className="flex-1 rounded border border-black/10 bg-white px-2 py-2 text-sm"
value={ customPrefixEffect }
onChange={ event => setCustomPrefixEffect(event.target.value) }>
<optgroup label="Basic">
{ basicEffects.map(effect => (
<option key={ effect.id || 'none' } value={ effect.id }>
{ effect.label }
</option>
)) }
</optgroup>
<optgroup label="Premium">
{ premiumEffects.map(effect => (
<option key={ effect.id } value={ effect.id }>
{ effect.label }
</option>
)) }
</optgroup>
</select>
<div className="min-w-[130px] rounded border border-black/10 bg-black/5 px-2 py-2 text-center text-[11px] font-bold">
{ selectedEffectOption.icon } { selectedEffectOption.label }
<div className="mt-1 text-[9px] uppercase text-black/60">
{ selectedEffectOption.tier }
</div>
</div>
</div>
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Font
</div>
<div className="flex items-center gap-2">
<select
className="flex-1 rounded border border-black/10 bg-white px-2 py-2 text-sm"
value={ customPrefixFont }
onChange={ event => setCustomPrefixFont(event.target.value) }>
<optgroup label="Basic">
{ basicFonts.map(font => (
<option key={ font.id || 'default' } value={ font.id }>
{ font.label }
</option>
)) }
</optgroup>
<optgroup label="Premium">
{ premiumFonts.map(font => (
<option key={ font.id } value={ font.id }>
{ font.label }
</option>
)) }
</optgroup>
</select>
<div className="min-w-[130px] rounded border border-black/10 bg-black/5 px-2 py-2 text-center text-[11px] font-bold">
<span style={ customPrefixFontStyle }>{ selectedFontOption.label }</span>
<div className="mt-1 text-[9px] uppercase text-black/60">
{ selectedFontOption.tier }
</div>
</div>
</div>
{ !!customPrefixFont &&
<div className="mt-2 text-[10px] leading-4 text-black/60">
Premium fonts add an extra price on top of the custom prefix.
</div> }
</div>
<div className="rounded border border-black/10 bg-[#1f2937] px-3 py-2 text-white" style={ customPrefixPreviewStyle }>
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ customPrefixColor }
prefixEffect={ customPrefixEffect }
prefixFont={ customPrefixFont }
prefixIcon={ customPrefixIcon }
prefixText={ customPrefixText || 'Preview' }
username="Username" />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[12px]">
{ customPrefixTotalCredits > 0 && <span>{ customPrefixTotalCredits } credits</span> }
{ customPrefixTotalPoints > 0 &&
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ customPrefixPointsType } />
{ customPrefixTotalPoints }
</span> }
{ !!customPrefixFont && (customPrefixFontPointsType !== customPrefixPointsType) && (customPrefixFontPricePoints > 0) &&
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ customPrefixFontPointsType } />
{ customPrefixFontPricePoints }
</span> }
</div>
<Button disabled={ !customPrefixIsValid || isLoading } onClick={ handleCustomPrefixPurchase }>
Buy custom prefix
</Button>
</div>
</div>
</div> }
</div> }
{ activeTab === 'settings' &&
<div className="flex flex-col gap-3">
<div className="rounded border border-black/10 bg-black/5 p-3">
<Text bold>Display order</Text>
<div className="mt-2 grid grid-cols-2 gap-2">
{ Object.entries(ORDER_LABELS).map(([ key, label ]) => (
<Button key={ key } disabled={ isLoading && (displayOrder === key) } onClick={ () => handleDisplayOrderChange(key) }>
{ displayOrder === key ? '* ' : '' }{ label }
</Button>
)) }
</div>
</div>
<div className="rounded border border-black/10 bg-black/5 p-3">
<div className="mb-2 flex items-center justify-between">
<Text bold>Refresh data</Text>
<Button disabled={ isLoading } onClick={ refreshCustomizeData }>Refresh</Button>
</div>
<div className="text-[11px] leading-4 text-black/70">
Use this tab to control how your icon, prefix and username are ordered in bubbles, profile and infostand.
</div>
</div>
</div> }
</NitroCardContentView>
{ showEmojiPicker &&
<>
<div className="fixed inset-0 z-[999]" onClick={ () => setShowEmojiPicker(false) } />
<div className="fixed left-1/2 top-1/2 z-[1000] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl shadow-2xl">
<Picker
data={ data }
locale="en"
onEmojiSelect={ (emoji: { native: string }) => { setCustomPrefixIcon(emoji.native); setShowEmojiPicker(false); } }
previewPosition="none"
set="native"
theme="dark" />
</div>
</> }
</NitroCardView>
);
};
@@ -13,13 +13,19 @@ interface FriendsRemoveConfirmationViewProps
export const FriendsRemoveConfirmationView: FC<FriendsRemoveConfirmationViewProps> = props =>
{
const { selectedFriendsIds = null, removeFriendsText = null, removeSelectedFriends = null, onCloseClick = null } = props;
const separatorIndex = removeFriendsText.indexOf(':');
const removeFriendsLeadText = (separatorIndex >= 0) ? removeFriendsText.substring(0, separatorIndex + 1) : removeFriendsText;
const removeFriendsNamesText = (separatorIndex >= 0) ? removeFriendsText.substring(separatorIndex + 1).trimStart() : '';
return (
<NitroCardView className="nitro-friends-remove-confirmation" theme="primary-slim">
<NitroCardView className="nitro-friends-remove-confirmation" theme="primary-slim" isResizable={ false } style={ { width: 270, height: 225, minWidth: 270, minHeight: 225, maxWidth: 270, maxHeight: 225 } }>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.removefriendconfirm.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black">
<div>{ removeFriendsText }</div>
<div className="flex gap-1">
<NitroCardContentView className="nitro-friends-remove-confirmation-content text-black">
<div className="nitro-friends-remove-confirmation-text">
<div>{ removeFriendsLeadText }</div>
{ removeFriendsNamesText.length > 0 && <div className="nitro-friends-remove-confirmation-names">{ removeFriendsNamesText }</div> }
</div>
<div className="nitro-friends-remove-confirmation-actions">
<Button fullWidth disabled={ (selectedFriendsIds.length === 0) } variant="danger" onClick={ removeSelectedFriends }>{ LocalizeText('generic.ok') }</Button>
<Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button>
</div>
@@ -15,13 +15,13 @@ export const FriendsRoomInviteView: FC<FriendsRoomInviteViewProps> = props =>
const [ roomInviteMessage, setRoomInviteMessage ] = useState<string>('');
return (
<NitroCardView className="nitro-friends-room-invite" theme="primary-slim" uniqueKey="nitro-friends-room-invite">
<NitroCardView className="nitro-friends-room-invite" theme="primary-slim" uniqueKey="nitro-friends-room-invite" isResizable={ false } style={ { width: 270, height: 225, minWidth: 270, minHeight: 225, maxWidth: 270, maxHeight: 225 } }>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.invite.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black">
{ LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) }
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" maxLength={ 255 } value={ roomInviteMessage } onChange={ event => setRoomInviteMessage(event.target.value) }></textarea>
<Text center className="bg-muted rounded p-1">{ LocalizeText('friendlist.invite.note') }</Text>
<div className="flex gap-1">
<NitroCardContentView className="nitro-friends-room-invite-content text-black" gap={ 2 }>
<Text className="nitro-friends-room-invite-summary">{ LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) }</Text>
<textarea className="nitro-friends-room-invite-textarea" maxLength={ 255 } value={ roomInviteMessage } onChange={ event => setRoomInviteMessage(event.target.value) }></textarea>
<Text center className="nitro-friends-room-invite-note">{ LocalizeText('friendlist.invite.note') }</Text>
<div className="nitro-friends-room-invite-actions">
<Button fullWidth disabled={ ((roomInviteMessage.length === 0) || (selectedFriendsIds.length === 0)) } variant="success" onClick={ () => sendRoomInvite(roomInviteMessage) }>{ LocalizeText('friendlist.invite.send') }</Button>
<Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button>
</div>
@@ -1,8 +1,10 @@
import { HabboSearchComposer, HabboSearchResultData, HabboSearchResultEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { LocalizeText, OpenMessengerChat, SendMessageComposer } from '../../../../api';
import { Column, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common';
import { Column, LayoutAvatarImageView, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common';
import { useFriends, useMessageEvent } from '../../../../hooks';
import { resolveAvatarFigure } from './resolveAvatarFigure';
import { resolveAvatarGender } from './resolveAvatarGender';
interface FriendsSearchViewProps extends NitroCardAccordionSetViewProps
{
@@ -17,6 +19,22 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
const [ otherResults, setOtherResults ] = useState<HabboSearchResultData[]>(null);
const { canRequestFriend = null, requestFriend = null } = useFriends();
const getSearchResultFigure = (result: HabboSearchResultData) =>
{
if(!result) return null;
const typedResult = (result as HabboSearchResultData & { figureString?: string; avatarFigure?: string; figure?: string; avatarFigureString?: string });
return typedResult.figureString || typedResult.avatarFigure || typedResult.figure || typedResult.avatarFigureString || null;
};
const getSearchResultGender = (result: HabboSearchResultData) =>
{
const typedResult = (result as HabboSearchResultData & { gender?: string | number; avatarGender?: string | number });
return resolveAvatarGender(typedResult.avatarGender ?? typedResult.gender);
};
useMessageEvent<HabboSearchResultEvent>(HabboSearchResultEvent, event =>
{
const parser = event.getParser();
@@ -55,10 +73,15 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
{ friendResults.map(result =>
{
return (
<NitroCardAccordionItemView key={ result.avatarId } className="px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={ result.avatarId } />
<div>{ result.avatarName }</div>
<NitroCardAccordionItemView key={ result.avatarId } className="friends-list-item px-2 py-1" justifyContent="between">
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(getSearchResultFigure(result), getSearchResultGender(result)) } gender={ getSearchResultGender(result) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ result.avatarId } />
</div>
<div className="friends-list-name">{ result.avatarName }</div>
</div>
<div className="flex items-center gap-1">
{ result.isAvatarOnline &&
@@ -82,10 +105,15 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
{ otherResults.map(result =>
{
return (
<NitroCardAccordionItemView key={ result.avatarId } className="px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={ result.avatarId } />
<div>{ result.avatarName }</div>
<NitroCardAccordionItemView key={ result.avatarId } className="friends-list-item px-2 py-1" justifyContent="between">
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(getSearchResultFigure(result), getSearchResultGender(result)) } gender={ getSearchResultGender(result) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ result.avatarId } />
</div>
<div className="friends-list-name">{ result.avatarName }</div>
</div>
<div className="flex items-center gap-1">
{ canRequestFriend(result.avatarId) &&
@@ -34,7 +34,7 @@ export const FriendsListView: FC<{}> = props =>
userNames.push(existingFriend.name);
}
return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join(', ') ]);
return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join('\n') ]);
}, [ offlineFriends, onlineFriends, selectedFriendsIds ]);
const selectFriend = useCallback((userId: number) =>
@@ -60,6 +60,27 @@ export const FriendsListView: FC<{}> = props =>
});
}, [ setSelectedFriendsIds ]);
const toggleSelectFriends = useCallback((friendIds: number[]) =>
{
if(!friendIds.length) return;
setSelectedFriendsIds(prevValue =>
{
const allSelected = friendIds.every(friendId => (prevValue.indexOf(friendId) >= 0));
if(allSelected) return prevValue.filter(friendId => (friendIds.indexOf(friendId) === -1));
const nextValue = [ ...prevValue ];
for(const friendId of friendIds)
{
if(nextValue.indexOf(friendId) === -1) nextValue.push(friendId);
}
return nextValue;
});
}, []);
const sendRoomInvite = (message: string) =>
{
if(!selectedFriendsIds.length || !message || !message.length || (message.length > 255)) return;
@@ -125,10 +146,24 @@ export const FriendsListView: FC<{}> = props =>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black p-0" gap={ 1 } overflow="hidden">
<NitroCardAccordionView fullHeight overflow="hidden">
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
<NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); } }>
{ onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ onlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView>
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); } }>
{ offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ offlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView>
<FriendsListRequestView headerText={ LocalizeText('friendlist.tab.friendrequests') + ` (${ requests.length })` } isExpanded={ true } />
@@ -1,7 +1,9 @@
import { FC, MouseEvent, useState } from 'react';
import { LocalizeText, MessengerFriend, OpenMessengerChat } from '../../../../../api';
import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { useFriends } from '../../../../../hooks';
import { resolveAvatarFigure } from '../resolveAvatarFigure';
import { resolveAvatarGender } from '../resolveAvatarGender';
export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: boolean, selectFriend: (userId: number) => void }> = props =>
{
@@ -55,14 +57,17 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b
if(!friend) return null;
return (
<NitroCardAccordionItemView className={ `px-2 py-1 ${ selected && 'bg-primary text-white' }` } justifyContent="between" onClick={ event => selectFriend(friend.id) }>
<div className="flex items-center gap-1">
<NitroCardAccordionItemView className={ `friends-list-item ${ selected ? 'selected' : '' }` } justifyContent="between" onClick={ event => selectFriend(friend.id) }>
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(friend.figure, friend.gender) } gender={ resolveAvatarGender(friend.gender) } headOnly={ true } direction={ 2 } />
</div>
<div onClick={ event => event.stopPropagation() }>
<UserProfileIconView userId={ friend.id } />
</div>
<div>{ friend.name }</div>
<div className="friends-list-name">{ friend.name }</div>
</div>
<div className="flex items-center gap-1">
<div className="friends-list-actions">
{ !isRelationshipOpen &&
<>
{ friend.online &&
@@ -1,7 +1,9 @@
import { FC } from 'react';
import { MessengerRequest } from '../../../../../api';
import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { LocalizeText, MessengerRequest } from '../../../../../api';
import { Button, LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { useFriends } from '../../../../../hooks';
import { resolveAvatarFigure } from '../resolveAvatarFigure';
import { resolveAvatarGender } from '../resolveAvatarGender';
export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = props =>
{
@@ -11,14 +13,23 @@ export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = pro
if(!request) return null;
return (
<NitroCardAccordionItemView className="px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={ request.id } />
<div>{ request.name }</div>
<NitroCardAccordionItemView className="friends-list-item px-2 py-1" justifyContent="between">
<div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(request.figureString) } gender={ resolveAvatarGender(undefined) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ request.requesterUserId } />
</div>
<div className="friends-list-name">{ request.name }</div>
</div>
<div className="flex items-center gap-1">
<div className="nitro-friends-spritesheet icon-accept cursor-pointer" onClick={ event => requestResponse(request.id, true) } />
<div className="nitro-friends-spritesheet icon-deny cursor-pointer" onClick={ event => requestResponse(request.id, false) } />
<Button size="sm" onClick={ event => requestResponse(request.id, true) }>
{ LocalizeText('friendlist.request_accept') }
</Button>
<Button size="sm" variant="danger" onClick={ event => requestResponse(request.id, false) }>
{ LocalizeText('friendlist.request_decline') }
</Button>
</div>
</NitroCardAccordionItemView>
);
@@ -17,8 +17,11 @@ export const FriendsListRequestView: FC<NitroCardAccordionSetViewProps> = props
<Column gap={ 0 }>
{ requests.map((request, index) => <FriendsListRequestItemView key={ index } request={ request } />) }
</Column>
<div className="flex justify-center px-2 py-1">
<Button onClick={ event => requestResponse(-1, false) }>
<div className="flex justify-center gap-2 px-2 py-1">
<Button onClick={ event => requests.forEach(request => requestResponse(request.id, true)) }>
{ LocalizeText('friendlist.requests.acceptall') }
</Button>
<Button variant="danger" onClick={ event => requestResponse(-1, false) }>
{ LocalizeText('friendlist.requests.dismissall') }
</Button>
</div>
@@ -0,0 +1,15 @@
import { resolveAvatarGender } from './resolveAvatarGender';
const DEFAULT_AVATAR_FIGURES: Record<string, string> = {
M: 'hd-180-1.ch-210-66.lg-270-82.sh-290-80',
F: 'hd-600-1.ch-630-66.lg-695-82.sh-725-80'
};
export const resolveAvatarFigure = (figure: string | null | undefined, gender?: string | number | null) =>
{
const normalizedFigure = (figure || '').trim();
if(normalizedFigure.length && normalizedFigure.includes('hd-')) return normalizedFigure;
return DEFAULT_AVATAR_FIGURES[resolveAvatarGender(gender)] || DEFAULT_AVATAR_FIGURES.M;
};
@@ -0,0 +1,20 @@
export const resolveAvatarGender = (value: string | number | null | undefined) =>
{
if(typeof value === 'string')
{
const normalized = value.trim().toUpperCase();
if(normalized === 'F') return 'F';
if(normalized === 'M') return 'M';
if(normalized === 'FEMALE') return 'F';
if(normalized === 'MALE') return 'M';
}
if(typeof value === 'number')
{
if(value === 2) return 'F';
if(value === 1) return 'M';
}
return 'M';
};
@@ -2,9 +2,8 @@ import { AddLinkEventTracker, FollowFriendMessageComposer, GetSessionDataManager
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa';
import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutGridItem, LayoutItemCountView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common';
import { useHelp, useMessenger } from '../../../../hooks';
import { NitroInput } from '../../../../layout';
import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useHelp, useMessenger, useTranslation } from '../../../../hooks';
import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView';
export const FriendsMessengerView: FC<{}> = props =>
@@ -14,15 +13,35 @@ export const FriendsMessengerView: FC<{}> = props =>
const [ messageText, setMessageText ] = useState('');
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger();
const { report = null } = useHelp();
const { settings, translateOutgoing } = useTranslation();
const messagesBox = useRef<HTMLDivElement>();
const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id)));
const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id));
const send = () =>
const send = async () =>
{
if(!activeThread || !messageText.length) return;
const trimmedText = messageText.trimStart();
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
if(!shouldTranslateOutgoing)
{
sendMessage(activeThread, GetSessionDataManager().userId, messageText);
setMessageText('');
return;
}
const translation = await translateOutgoing(messageText);
if(translation && translation.translatedText?.length && (translation.translatedText.length <= 255))
{
sendMessage(activeThread, GetSessionDataManager().userId, translation.translatedText, 0, null, undefined, translation);
setMessageText('');
return;
}
sendMessage(activeThread, GetSessionDataManager().userId, messageText);
setMessageText('');
@@ -32,7 +51,7 @@ export const FriendsMessengerView: FC<{}> = props =>
{
if(event.key !== 'Enter') return;
send();
void send();
};
useEffect(() =>
@@ -107,71 +126,60 @@ export const FriendsMessengerView: FC<{}> = props =>
if(!isVisible) return null;
return (
<NitroCardView className="nitro-friends-messenger w-[800px] h-[720px]" theme="primary-slim" uniqueKey="nitro-friends-messenger">
<NitroCardView className="messenger-card" theme="primary-slim" uniqueKey={ null } windowPosition={ DraggableWindowPosition.TOP_CENTER } offsetTop={ 8 } isResizable={ false }>
<NitroCardHeaderView headerText={ LocalizeText('messenger.window.title', [ 'OPEN_CHAT_COUNT' ], [ visibleThreads.length.toString() ]) } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView>
<Grid overflow="hidden">
<Column overflow="hidden" size={ 4 }>
<Text bold>{ LocalizeText('toolbar.icon.label.messenger') }</Text>
<Column fit overflow="auto">
<Column>
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
{
return (
<LayoutGridItem key={ thread.threadId } itemActive={ (activeThread === thread) } onClick={ event => setActiveThreadId(thread.threadId) } className="py-1 px-2">
{ thread.unread && <LayoutItemCountView className="text-black" count={ thread.unreadCount } /> }
<Flex fullWidth gap={ 1 } style={{ minHeight: '50px' }}>
<LayoutAvatarImageView
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
headOnly={ true }
direction={ thread.participant.id > 0 ? 2 : 3 }
style={{ width: '50px', height: '80px', backgroundPosition: 'center 45%', flexShrink: 0, alignSelf: 'flex-end' }}
/>
<Text truncate grow className="self-center">{ thread.participant.name }</Text>
</Flex>
</LayoutGridItem>
);
}) }
</Column>
</Column>
</Column>
<Column overflow="hidden" size={ 8 }>
{ activeThread &&
<>
<Text bold center>{ LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }</Text>
<Flex alignItems="center" gap={ 1 } justifyContent="between">
<NitroCardContentView className="text-black p-0" gap={ 0 } overflow="hidden">
<div className="messenger-card-body">
<div className="messenger-avatar-bar">
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
{
return (
<button key={ thread.threadId } className={ 'messenger-avatar-tab' + ((activeThread === thread) ? ' active' : '') + (thread.unread ? ' unread' : '') } onClick={ event => setActiveThreadId(thread.threadId) }>
<LayoutAvatarImageView
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
headOnly={ true }
direction={ thread.participant.id > 0 ? 2 : 3 }
/>
</button>
);
}) }
</div>
{ activeThread &&
<>
<div className="messenger-thread-header">
<span className="messenger-thread-name">{ LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }</span>
<div className="messenger-actions">
{ (activeThread.participant.id > 0) &&
<div className="flex gap-1">
<div className="relative inline-flex align-middle">
<Button onClick={ followFriend }>
<div className="nitro-friends-spritesheet icon-follow" />
</Button>
<Button onClick={ openProfile }>
<div className="nitro-friends-spritesheet icon-profile-sm" />
</Button>
</div>
<Button variant="danger" onClick={ () => report(ReportType.IM, { reportedUserId: activeThread.participant.id }) }>
<>
<button className="messenger-btn icon-btn" onClick={ followFriend }>
<div className="nitro-friends-spritesheet icon-follow" />
</button>
<button className="messenger-btn icon-btn" onClick={ openProfile }>
<div className="nitro-friends-spritesheet icon-profile-sm" />
</button>
<button className="messenger-btn danger" onClick={ () => report(ReportType.IM, { reportedUserId: activeThread.participant.id }) }>
{ LocalizeText('messenger.window.button.report') }
</Button>
</div> }
<Button onClick={ event => closeThread(activeThread.threadId) }>
<FaTimes className="fa-icon" />
</Button>
</Flex>
<Column fit className="bg-muted p-2 rounded chat-messages">
<Column innerRef={ messagesBox } overflow="auto">
<FriendsMessengerThreadView thread={ activeThread } />
</Column>
</Column>
<div className="flex gap-1">
<NitroInput maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
<Button variant="success" onClick={ send }>
{ LocalizeText('widgets.chatinput.say') }
</Button>
</button>
</> }
<button className="messenger-btn close-btn" onClick={ event => closeThread(activeThread.threadId) }>
<FaTimes />
</button>
</div>
</> }
</Column>
</Grid>
</div>
<div ref={ messagesBox } className="chat-messages">
<FriendsMessengerThreadView thread={ activeThread } />
</div>
<div className="messenger-input-row">
<input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
<button className="messenger-btn send" onClick={ () => void send() }>
{ LocalizeText('widgets.chatinput.say') }
</button>
</div>
</> }
</div>
</NitroCardContentView>
</NitroCardView>
);
@@ -28,14 +28,11 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
<>
{ group.chats.map((chat, index) =>
{
if(chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) return null;
return (
<Flex key={ index } fullWidth gap={ 2 } justifyContent="start">
<Base className="w-full text-break">
{ (chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) &&
<Flex alignItems="center" className="bg-light rounded mb-2 px-2 py-1 small text-muted" gap={ 2 }>
<Base className="nitro-friends-spritesheet icon-warning shrink-0" />
<Base>{ chat.message }</Base>
</Flex> }
{ (chat.type === MessengerThreadChat.ROOM_INVITE) &&
<Flex alignItems="center" className="bg-light rounded mb-2 px-2 py-1 small text-black" gap={ 2 }>
<Base className="messenger-notification-icon shrink-0" />
@@ -50,24 +47,46 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
}
return (
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' }>
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' } className={ 'messenger-message-row ' + (isOwnChat ? 'own' : '') }>
<Base shrink className="message-avatar">
{ ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } /> }
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } headOnly={ true } /> }
{ (groupChatData && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } /> }
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } headOnly={ true } /> }
</Base>
<Base className={ 'bg-light text-black border-radius mb-2 rounded py-1 px-2 messages-group-' + (isOwnChat ? 'right' : 'left') }>
<Base className="font-bold">
<Base className="small text-muted">{ group.chats[0].date.toLocaleTimeString() }</Base>
<Base className="messenger-message-body">
<Base className={ 'messenger-message-name ' + (isOwnChat ? 'text-end' : '') }>
{ isOwnChat && GetSessionDataManager().userName }
{ !isOwnChat && (groupChatData ? groupChatData.username : thread.participant.name) }
:
</Base>
{ group.chats.map((chat, index) => <Base key={ index } className="text-break">{ chat.message }</Base>) }
<Base className={ 'messenger-message-bubble messages-group-' + (isOwnChat ? 'right' : 'left') }>
{ group.chats.map((chat, index) =>
{
if(!chat.showTranslation)
{
return <Base key={ index } className="text-break">{ chat.message }</Base>;
}
return (
<Base key={ index } className="messenger-translation-block">
<Base className="messenger-translation-row">
<span className="messenger-translation-label">original:</span>
<span className="text-break">{ chat.originalMessage || chat.message }</span>
</Base>
<Base className="messenger-translation-row">
<span className="messenger-translation-label">translate:</span>
<span className="text-break">{ chat.translatedMessage || chat.message }</span>
</Base>
</Base>
);
}) }
</Base>
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
</Base>
{ isOwnChat &&
<Base shrink className="message-avatar">
<LayoutAvatarImageView direction={ 4 } figure={ GetSessionDataManager().figure } />
<LayoutAvatarImageView direction={ 4 } figure={ GetSessionDataManager().figure } headOnly={ true } />
</Base> }
</Flex>
);
@@ -1,24 +1,29 @@
import { FC, useEffect, useState } from 'react';
import { FC, useEffect, useMemo, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa';
import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { useInventoryPrefixes, useNotification } from '../../../../hooks';
import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, getPrefixFontStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { Button } from '../../../../common';
import { GetNickIconUrl } from '../../../../assets/images/user_custom/nick_icons';
import { useInventoryNickIcons, useInventoryPrefixes, useNotification } from '../../../../hooks';
import { NitroButton } from '../../../../layout';
const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) =>
type InventoryIdentityTab = 'prefixes' | 'icons';
const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; font?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', font = '', className = '', textSize = 'text-sm' }) =>
{
const colors = parsePrefixColors(text, color);
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF');
const fontStyle = getPrefixFontStyle(font);
return (
<span className={ `font-bold ${ textSize } ${ className }` } style={ fxStyle }>
{ effect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
<span className={ `font-bold ${ textSize } ${ className }` } style={ { ...fontStyle, ...fxStyle } }>
{ !!effect && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ icon && <span className="mr-0.5">{ icon }</span> }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
<span style={ hasMultiColor ? { ...fontStyle, ...fxStyle } : { ...fontStyle, ...fxStyle, color: colors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...text ].map((char, i) => (
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span>
<span key={ i } style={ { ...fontStyle, color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span>
))
: text
}
@@ -40,7 +45,30 @@ const PrefixItemView: FC<{
${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ prefix.active ? 'ring-2 ring-green-400' : '' }` }
onClick={ onClick }>
<PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } icon={ prefix.icon } text={ prefix.text } />
<PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } font={ prefix.font } icon={ prefix.icon } text={ prefix.text } />
</div>
);
};
const NickIconItemView: FC<{
iconKey: string;
displayName: string;
isSelected: boolean;
isActive: boolean;
onClick: () => void;
}> = ({ iconKey, displayName, isSelected, isActive, onClick }) =>
{
return (
<div
className={ `relative flex cursor-pointer items-center justify-center rounded-md border-2 p-2 transition-colors
${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ isActive ? 'ring-2 ring-green-400' : '' }` }
onClick={ onClick }>
{ isActive && <span className="absolute right-1 top-1 rounded bg-[#15954c] px-1 py-0.5 text-[8px] font-bold uppercase text-white">Active</span> }
<div className="flex flex-col items-center gap-1">
<img className="h-auto max-h-[28px] w-auto object-contain" src={ GetNickIconUrl(iconKey) } alt={ displayName || iconKey } />
<span className="max-w-[90px] truncate text-center text-[11px] font-bold">{ displayName || iconKey }</span>
</div>
</div>
);
};
@@ -48,8 +76,13 @@ const PrefixItemView: FC<{
export const InventoryPrefixView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState<InventoryIdentityTab>('prefixes');
const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes();
const { nickIcons = [], activeNickIcon = null, selectedNickIcon = null, setSelectedNickIcon = null, activateNickIcon = null, deactivateNickIcon = null, activate: activateNickIcons = null, deactivate: deactivateNickIcons = null } = useInventoryNickIcons();
const { showConfirm = null } = useNotification();
const hasPrefixes = prefixes && (prefixes.length > 0);
const hasNickIcons = nickIcons && (nickIcons.length > 0);
const selectedIconUrl = useMemo(() => selectedNickIcon ? GetNickIconUrl(selectedNickIcon.iconKey) : '', [ selectedNickIcon ]);
const attemptDeletePrefix = () =>
{
@@ -69,10 +102,15 @@ export const InventoryPrefixView: FC<{}> = () =>
{
if(!isVisible) return;
const id = activate();
const prefixVisibilityId = activate();
const iconVisibilityId = activateNickIcons();
return () => deactivate(id);
}, [ isVisible, activate, deactivate ]);
return () =>
{
deactivate(prefixVisibilityId);
deactivateNickIcons(iconVisibilityId);
};
}, [ isVisible, activate, activateNickIcons, deactivate, deactivateNickIcons ]);
useEffect(() =>
{
@@ -82,55 +120,115 @@ export const InventoryPrefixView: FC<{}> = () =>
}, []);
return (
<div className="grid h-full grid-cols-12 gap-2">
<div className="flex flex-col col-span-7 gap-1 overflow-auto">
<div className="grid grid-cols-3 gap-1">
{ prefixes.map(prefix => (
<PrefixItemView
key={ prefix.id }
isSelected={ selectedPrefix?.id === prefix.id }
prefix={ prefix }
onClick={ () => setSelectedPrefix(prefix) } />
)) }
<div className="flex h-full flex-col gap-2">
<div className="shrink-0 rounded border border-black/10 bg-[#C9C9C9] p-1">
<div className="flex items-center gap-2">
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activeTab === 'prefixes' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActiveTab('prefixes') }>
Prefixes
</button>
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activeTab === 'icons' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActiveTab('icons') }>
Icons
</button>
</div>
{ (!prefixes || prefixes.length === 0) &&
<div className="flex items-center justify-center h-full text-sm opacity-50">
{ LocalizeText('inventory.empty.title') }
</div> }
</div>
<div className="flex flex-col justify-between col-span-5 overflow-auto">
{ activePrefix &&
<div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-green-400 bg-card-grid-item">
<PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" />
{ activeTab === 'prefixes' &&
<div className="grid h-full grid-cols-12 gap-2">
<div className="col-span-7 flex flex-col gap-1 overflow-auto pr-1">
<div className="grid grid-cols-3 gap-1">
{ prefixes.map(prefix => (
<PrefixItemView
key={ prefix.id }
isSelected={ selectedPrefix?.id === prefix.id }
prefix={ prefix }
onClick={ () => setSelectedPrefix(prefix) } />
)) }
</div>
</div> }
{ !activePrefix &&
<div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item opacity-50">
<span className="text-sm">No active prefix</span>
{ !hasPrefixes &&
<div className="flex h-full items-center justify-center text-sm opacity-50">
{ LocalizeText('inventory.empty.title') }
</div> }
</div>
<div className="col-span-5 flex flex-col justify-between overflow-auto">
{ activePrefix &&
<div className="flex flex-col gap-1">
<span className="min-h-[1.25rem] truncate text-sm leading-5">Active prefix</span>
<div className="flex items-center justify-center rounded-md border-2 border-green-400 bg-card-grid-item p-3">
<PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } font={ activePrefix.font } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" />
</div>
</div> }
{ !activePrefix &&
<div className="flex flex-col gap-1">
<span className="min-h-[1.25rem] truncate text-sm leading-5">Active prefix</span>
<div className="flex items-center justify-center rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item p-3 opacity-50">
<span className="text-sm">No active prefix</span>
</div>
</div> }
{ !!selectedPrefix &&
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center justify-center gap-2 rounded bg-card-grid-item p-2">
<PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } font={ selectedPrefix.font } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" />
</div>
<div className="flex items-center gap-2">
<NitroButton
className="grow"
onClick={ () => selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }>
{ selectedPrefix.active ? 'Deactivate' : 'Activate' }
</NitroButton>
{ !selectedPrefix.active &&
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeletePrefix }>
<FaTrashAlt className="fa-icon" />
</NitroButton> }
</div>
</div> }
</div>
</div> }
{ activeTab === 'icons' &&
<div className="grid h-full grid-cols-12 gap-2">
<div className="col-span-7 flex flex-col gap-1 overflow-auto pr-1">
<div className="grid grid-cols-3 gap-1">
{ nickIcons.map(icon => (
<NickIconItemView
key={ icon.id }
displayName={ icon.displayName }
iconKey={ icon.iconKey }
isActive={ !!icon.active }
isSelected={ selectedNickIcon?.id === icon.id }
onClick={ () => setSelectedNickIcon(icon) } />
)) }
</div>
</div> }
{ !!selectedPrefix &&
<div className="flex flex-col gap-2 mt-2">
<div className="flex items-center justify-center gap-2 p-2 rounded bg-card-grid-item">
<PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" />
{ !hasNickIcons &&
<div className="flex h-full items-center justify-center text-sm opacity-50">
No purchased icons yet
</div> }
</div>
<div className="col-span-5 flex flex-col justify-between overflow-auto">
<div className="flex flex-col gap-1">
<span className="min-h-[1.25rem] truncate text-sm leading-5">Active icon</span>
<div className={ `flex min-h-[88px] items-center justify-center rounded-md border-2 bg-card-grid-item p-3 ${ activeNickIcon ? 'border-green-400' : 'border-dashed border-card-grid-item-border opacity-50' }` }>
{ activeNickIcon && <img className="h-auto max-h-[36px] w-auto object-contain" src={ GetNickIconUrl(activeNickIcon.iconKey) } alt={ activeNickIcon.displayName || activeNickIcon.iconKey } /> }
{ !activeNickIcon && <span className="text-sm">No active icon</span> }
</div>
</div>
<div className="flex items-center gap-2">
<NitroButton
className="grow"
onClick={ () => selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }>
{ selectedPrefix.active ? 'Deactivate' : 'Activate' }
</NitroButton>
{ !selectedPrefix.active &&
<NitroButton className="bg-danger! hover:bg-danger/80! p-1" onClick={ attemptDeletePrefix }>
<FaTrashAlt className="fa-icon" />
</NitroButton> }
</div>
</div> }
</div>
{ !!selectedNickIcon &&
<div className="mt-2 flex flex-col gap-2">
<div className="flex min-h-[100px] flex-col items-center justify-center gap-2 rounded bg-card-grid-item p-3 text-center">
<img className="h-auto max-h-[40px] w-auto object-contain" src={ selectedIconUrl } alt={ selectedNickIcon.displayName || selectedNickIcon.iconKey } />
<span className="text-sm font-bold">{ selectedNickIcon.displayName || selectedNickIcon.iconKey }</span>
</div>
<Button disabled={ false } onClick={ () => selectedNickIcon.active ? deactivateNickIcon() : activateNickIcon(selectedNickIcon.id) }>
{ selectedNickIcon.active ? 'Deactivate' : 'Activate' }
</Button>
</div> }
</div>
</div> }
</div>
);
};
+4 -1
View File
@@ -1,6 +1,6 @@
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react';
import { FaChevronDown, FaQuestionCircle } from 'react-icons/fa';
import { FaChevronDown, FaLanguage, FaQuestionCircle } from 'react-icons/fa';
import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
import { usePurse } from '../../hooks';
@@ -91,6 +91,9 @@ export const PurseView: FC<{}> = props => {
</div>
</div> }
<div className="nitro-purse__actions">
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--translate" onClick={ event => { event.stopPropagation(); CreateLinkEvent('translation-settings/toggle'); } } title="Google Translate">
<FaLanguage />
</button>
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--help" onClick={ event => { event.stopPropagation(); CreateLinkEvent('help/show'); } } title={ LocalizeText('help.button.name') }>
<FaQuestionCircle />
</button>
@@ -2,7 +2,7 @@ import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusI
import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, 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';
import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView, UserProfileIconView } from '../../../../../common';
import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView';
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
@@ -29,7 +29,6 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`;
const infostandStandClass = `stand-${standId ?? 'default'}`;
const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`;
const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]);
const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []);
@@ -79,6 +78,12 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
newValue.figure = event.figure;
newValue.motto = event.customInfo;
newValue.achievementScore = event.activityPoints;
newValue.nickIcon = event.nickIcon;
newValue.prefixText = event.prefixText;
newValue.prefixColor = event.prefixColor;
newValue.prefixIcon = event.prefixIcon;
newValue.prefixEffect = event.prefixEffect;
newValue.displayOrder = event.displayOrder;
newValue.backgroundId = event.backgroundId;
newValue.standId = event.standId;
newValue.overlayId = event.overlayId;
@@ -139,7 +144,17 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
<UserProfileIconView userId={avatarInfo.webID} />
<Text small wrap variant="white">{avatarInfo.name}</Text>
<UserIdentityView
className="text-[12px]"
displayOrder={ avatarInfo.displayOrder }
nameClassName="text-white"
nickIcon={ avatarInfo.nickIcon }
prefixColor={ avatarInfo.prefixColor }
prefixEffect={ avatarInfo.prefixEffect }
prefixFont={ avatarInfo.prefixFont }
prefixIcon={ avatarInfo.prefixIcon }
prefixText={ avatarInfo.prefixText }
username={ avatarInfo.name } />
</div>
<FaTimes className="cursor-pointer fa-icon" onClick={onClose} />
</div>
@@ -1,6 +1,7 @@
import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react';
import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { ChatBubbleMessage } from '../../../../api';
import { UserIdentityView } from '../../../../common';
import { useOnClickChat } from '../../../../hooks';
interface ChatWidgetMessageViewProps
@@ -38,11 +39,11 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
useEffect(() =>
{
setIsVisible(false);
const element = elementRef.current;
if(!element) return;
const previousWidth = chat.width;
const previousHeight = chat.height;
const { offsetWidth: width, offsetHeight: height } = element;
chat.width = width;
@@ -62,10 +63,14 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
setIsReady(true);
if(isVisible && ((previousWidth !== width) || (previousHeight !== height)) && makeRoom) makeRoom(chat);
}, [ chat, chat.formattedText, chat.originalFormattedText, chat.showTranslation, chat.translatedFormattedText, isVisible, makeRoom ]);
useEffect(() =>
{
return () =>
{
chat.elementRef = null;
setIsReady(false);
};
}, [ chat ]);
@@ -77,6 +82,8 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
setIsVisible(true);
}, [ chat, isReady, isVisible, makeRoom ]);
const messageClassName = `message [overflow-wrap:anywhere] break-words${ chat.type === 1 ? ' italic text-[#595959]' : '' }${ chat.type === 2 ? ' font-bold' : '' }`;
return (
<div ref={ elementRef } className={ `bubble-container newbubblehe ${ isVisible ? 'visible' : 'invisible' } w-max absolute select-none pointer-events-auto` }
onClick={ () => GetRoomEngine().selectRoomObject(chat.roomId, chat.senderId, RoomObjectCategory.UNIT) }>
@@ -90,29 +97,33 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
) }
</div>
<div className="chat-content py-[5px] px-[6px] ml-[27px] leading-none min-h-[25px]">
{ chat.prefixEffect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ chat.prefixText && (() => {
const colors = parsePrefixColors(chat.prefixText, chat.prefixColor);
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF');
return (
<span className="prefix font-bold mr-1" style={ fxStyle }>
{ chat.prefixIcon && <span className="mr-0.5 text-[13px]">{ chat.prefixIcon }</span> }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...chat.prefixText ].map((char, i) => (
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(chat.prefixEffect, colors[i]) } }>{ char }</span>
))
: chat.prefixText
}
{'}'}
</span>
</span>
);
})() }
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.username }: ` } } />
<span className={ `message${ chat.type === 1 ? ' italic text-[#595959]' : '' }` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } />
<UserIdentityView
className="mr-1 align-middle"
displayOrder={ chat.displayOrder }
iconClassName="inline-block w-auto h-auto align-[-1px]"
nameClassName="username font-bold"
nickIcon={ chat.nickIcon }
prefixClassName=""
prefixColor={ chat.prefixColor }
prefixEffect={ chat.prefixEffect }
prefixFont={ chat.prefixFont }
prefixIcon={ chat.prefixIcon }
prefixText={ chat.prefixText }
showColon={ true }
username={ chat.username } />
{ !chat.showTranslation &&
<span className={ `${ messageClassName } align-middle` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalFormattedText || chat.formattedText }` } } />
</div>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedFormattedText || chat.formattedText }` } } />
</div>
</div> }
</div>
<div className="pointer absolute left-[50%] translate-x-[-50%] w-[9px] h-[6px] bottom-[-5px]" />
</div>
@@ -2,7 +2,7 @@ import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer';
import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChatEntryType, LocalizeText } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useChatHistory, useChatWindow } from '../../../../hooks';
import { useChatHistory, useChatWindow, useOnClickChat } from '../../../../hooks';
import { useRoom } from '../../../../hooks/rooms';
const BOTTOM_SCROLL_THRESHOLD = 20;
@@ -19,6 +19,7 @@ export const ChatWidgetWindowView: FC<{}> = () =>
const { chatHistory = [], clearChatHistory = null } = useChatHistory();
const [ , setChatWindowEnabled ] = useChatWindow();
const { roomSession = null } = useRoom();
const { onClickChat } = useOnClickChat();
const ownUserId = (GetSessionDataManager()?.userId || -1);
const roomChatHistory = useMemo(() =>
@@ -33,7 +34,7 @@ export const ChatWidgetWindowView: FC<{}> = () =>
if(!normalizedSearch.length) return true;
return (`${ chat.name } ${ chat.message }`.toLowerCase().includes(normalizedSearch));
return (`${ chat.name } ${ chat.message || '' } ${ chat.originalMessage || '' } ${ chat.translatedMessage || '' }`.toLowerCase().includes(normalizedSearch));
});
}, [ chatHistory, roomSession?.roomId, hidePets, search ]);
@@ -125,14 +126,27 @@ export const ChatWidgetWindowView: FC<{}> = () =>
{
const isOwnMessage = (chat.webId === ownUserId);
const rowClassName = `mb-1 flex items-start gap-1 break-words ${ isOwnMessage ? 'justify-end' : '' }`;
const messageClassName = `message${ chat.chatType === 1 ? ' italic text-[#595959]' : '' }${ chat.chatType === 2 ? ' font-bold' : '' }`;
return (
<div key={ `${ chat.timestamp }-${ chat.id }` } className={ rowClassName }>
{ hideBalloons && !hideAvatars && <div className={ `w-[65px] h-[55px] shrink-0 mt-[-18px] rounded-sm bg-no-repeat bg-center scale-70 ${ isOwnMessage ? 'order-2' : '' }` } style={ chat.imageUrl ? { backgroundImage: `url(${ chat.imageUrl })` } : undefined } /> }
{ hideBalloons && (
<div>
<div onClick={ onClickChat }>
<b dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
<span dangerouslySetInnerHTML={ { __html: chat.message } } />
{ !chat.showTranslation &&
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.message } } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]">
<div className="flex items-start gap-1 leading-[1.15]">
<span className="inline-block min-w-[52px] font-bold opacity-75">original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.originalMessage || chat.message || '' } } />
</div>
<div className="flex items-start gap-1 leading-[1.15]">
<span className="inline-block min-w-[52px] font-bold opacity-75">translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.translatedMessage || chat.message || '' } } />
</div>
</div> }
</div>
) }
{ !hideBalloons && (
@@ -148,7 +162,19 @@ export const ChatWidgetWindowView: FC<{}> = () =>
</div>
<div className={ `chat-content py-[5px] px-[6px] leading-none min-h-[25px] ${ !hideAvatars ? (isOwnMessage ? 'mr-[27px]' : 'ml-[27px]') : '' }` }>
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
<span className="message" dangerouslySetInnerHTML={ { __html: `${ chat.message }` } } />
{ !chat.showTranslation &&
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.message }` } } onClick={ onClickChat } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalMessage || chat.message || '' }` } } />
</div>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedMessage || chat.message || '' }` } } />
</div>
</div> }
</div>
</div>
</div>
@@ -0,0 +1,9 @@
import { FC } from 'react';
import { useTranslation } from '../../hooks';
export const TranslationBootstrap: FC<{}> = () =>
{
useTranslation();
return null;
};
@@ -0,0 +1,138 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useTranslation } from '../../hooks';
export const TranslationSettingsView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const {
settings,
supportedLanguages = [],
availableTextLocales = [],
languagesLoading = false,
localizationTextsLoading = false,
lastIncomingLanguage = '',
lastOutgoingLanguage = '',
lastError = '',
updateSettings,
ensureSupportedLanguagesLoaded,
getLanguageName
} = useTranslation();
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
}
},
eventUrlPrefix: 'translation-settings/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(!isVisible) return;
ensureSupportedLanguagesLoaded();
}, [ ensureSupportedLanguagesLoaded, isVisible ]);
if(!isVisible) return null;
return (
<NitroCardView className="translation-settings-window w-[360px]" theme="primary-slim" uniqueKey="translation-settings">
<NitroCardHeaderView headerText="Google Translate" onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView className="flex flex-col gap-3 text-black">
<div className="flex items-center gap-2">
<input checked={ settings.enabled } className="form-check-input" type="checkbox" onChange={ event => updateSettings({ enabled: event.target.checked }) } />
<Text>Enable automatic translation</Text>
</div>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
When enabled, chat bubbles always show two lines: <strong>original:</strong> and <strong>translate:</strong>.
</div>
<div className="flex flex-col gap-2">
<Text bold>Interface texts</Text>
<div className="flex flex-col gap-1">
<label className="flex flex-col gap-1 text-[12px]">
<span>Localized text pack</span>
<select
className="rounded border border-black/20 bg-white px-2 py-1"
disabled={ localizationTextsLoading }
value={ settings.uiTextLanguage || '' }
onChange={ event => updateSettings({ uiTextLanguage: event.target.value }) }>
<option value="">Default (gamedata)</option>
{ availableTextLocales.map(locale => <option key={ locale.code } value={ locale.code }>{ locale.name }</option>) }
</select>
</label>
</div>
</div>
<div className="flex flex-col gap-2">
<Text bold>Incoming messages</Text>
<div className="flex flex-col gap-1">
<Text>Detected language (auto): { getLanguageName(lastIncomingLanguage) }</Text>
<label className="flex flex-col gap-1 text-[12px]">
<span>Translate into</span>
<select
className="rounded border border-black/20 bg-white px-2 py-1"
disabled={ languagesLoading || !supportedLanguages.length }
value={ settings.incomingTargetLanguage }
onChange={ event => updateSettings({ incomingTargetLanguage: event.target.value }) }>
{ supportedLanguages.map(language => <option key={ language.code } value={ language.code }>{ language.name }</option>) }
</select>
</label>
</div>
</div>
<div className="flex flex-col gap-2">
<Text bold>Outgoing messages</Text>
<div className="flex flex-col gap-1">
<Text>Detected writing language (auto): { getLanguageName(lastOutgoingLanguage) }</Text>
<label className="flex flex-col gap-1 text-[12px]">
<span>Send text as</span>
<select
className="rounded border border-black/20 bg-white px-2 py-1"
disabled={ languagesLoading || !supportedLanguages.length }
value={ settings.outgoingTargetLanguage }
onChange={ event => updateSettings({ outgoingTargetLanguage: event.target.value }) }>
{ supportedLanguages.map(language => <option key={ language.code } value={ language.code }>{ language.name }</option>) }
</select>
</label>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-black/60">
<span>{ languagesLoading ? 'Loading languages...' : `${ supportedLanguages.length } languages available` }</span>
<button className="rounded border border-black/15 bg-white px-2 py-1 text-[11px] text-black hover:bg-black/5" type="button" onClick={ () => ensureSupportedLanguagesLoaded(true) }>
Refresh
</button>
</div>
{ localizationTextsLoading &&
<div className="rounded border border-blue-300 bg-blue-50 px-2 py-1 text-[11px] leading-4 text-blue-700">
Loading localized interface texts...
</div> }
{ lastError.length > 0 &&
<div className="rounded border border-red-300 bg-red-50 px-2 py-1 text-[11px] leading-4 text-red-700">
{ lastError }
</div> }
</NitroCardContentView>
</NitroCardView>
);
};
@@ -1,7 +1,7 @@
import { GetSessionDataManager, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api';
import { LayoutAvatarImageView, Text } from '../../common';
import { LayoutAvatarImageView, Text, UserIdentityView } from '../../common';
export const UserContainerView: FC<{
userProfile: UserProfileParser;
@@ -18,7 +18,6 @@ export const UserContainerView: FC<{
const infostandBackgroundClass = `background-${userProfile.backgroundId ?? 'default'}`;
const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`;
const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`;
const addFriend = () =>
{
setRequestSent(true);
@@ -41,7 +40,16 @@ export const UserContainerView: FC<{
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0">
<p className="leading-tight font-bold">{ userProfile.username }</p>
<UserIdentityView
className="leading-tight"
displayOrder={ userProfile.displayOrder }
nickIcon={ userProfile.nickIcon }
prefixColor={ userProfile.prefixColor }
prefixEffect={ userProfile.prefixEffect }
prefixFont={ userProfile.prefixFont }
prefixIcon={ userProfile.prefixIcon }
prefixText={ userProfile.prefixText }
username={ userProfile.username } />
<p className="text-sm italic leading-tight">{ userProfile.motto }</p>
</div>
@@ -115,4 +123,4 @@ export const UserContainerView: FC<{
</div>
</div>
);
};
};
@@ -1,13 +1,13 @@
import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer';
import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, GetStage, GetTicker, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer';
import { WiredMonitorDataEvent, WiredMonitorRequestComposer } from '@nitrots/nitro-renderer';
import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import furniInspectionIcon from '../../assets/images/wiredtools/furni.png';
import globalInspectionIcon from '../../assets/images/wiredtools/global.png';
import userInspectionIcon from '../../assets/images/wiredtools/user.png';
import contextInspectionIcon from '../../assets/images/wiredtools/context.png';
import wiredGlobalPlaceholderImage from '../../assets/images/wiredtools/wired_global_placeholder.png';
import wiredMonitorImage from '../../assets/images/wiredtools/wired_monitor.png';
import { AvatarInfoFurni, AvatarInfoUtilities, LocalizeText, NotificationAlertType, SendMessageComposer } from '../../api';
import { AvatarInfoFurni, AvatarInfoUtilities, GetRoomObjectBounds, GetRoomObjectScreenLocation, LocalizeText, NotificationAlertType, SendMessageComposer, WiredSelectionVisualizer } from '../../api';
import { Button, DraggableWindowPosition, LayoutAvatarImageView, LayoutPetImageView, LayoutRoomObjectImageView, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedEvent, useRoom, useWiredTools } from '../../hooks';
import { WiredToolsSettingsTabView } from './WiredToolsSettingsTabView';
@@ -184,6 +184,21 @@ interface VariableManageEntry
value: number | null;
}
interface VariableHighlightTarget
{
category: number;
hasValue: boolean;
objectId: number;
value: number | null;
}
interface VariableHighlightOverlay extends VariableHighlightTarget
{
key: string;
x: number;
y: number;
}
interface ManagedHolderVariableEntry
{
availability: string;
@@ -631,6 +646,9 @@ export const WiredCreatorToolsView: FC<{}> = () =>
const [ isManagedGiveOpen, setIsManagedGiveOpen ] = useState(false);
const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0);
const [ managedGiveValue, setManagedGiveValue ] = useState('0');
const [ isVariableHighlightActive, setIsVariableHighlightActive ] = useState(false);
const [ variableHighlightOverlays, setVariableHighlightOverlays ] = useState<VariableHighlightOverlay[]>([]);
const variableHighlightObjectsRef = useRef<Array<{ category: number; objectId: number; }>>([]);
const shouldPauseVariableSnapshotRefresh = (!!editingVariable || !!editingManagedHolderVariableId || isInspectionGiveOpen || isManagedGiveOpen);
const [ selectedVariableKeys, setSelectedVariableKeys ] = useState<Record<VariablesElementType, string>>({
furni: VARIABLE_DEFINITIONS.furni[0].key,
@@ -2400,6 +2418,155 @@ export const WiredCreatorToolsView: FC<{}> = () =>
manageLabel: 'Manage'
} ];
}, [ selectedVariableDefinition, variablesType, roomSession, userVariableAssignments, furniVariableAssignments, roomVariableAssignmentMap ]);
const canVariableHighlight = !!selectedVariableDefinition?.itemId
&& (selectedVariableDefinition.type === 'Custom')
&& ((variablesType === 'user') || (variablesType === 'furni'))
&& !!roomSession;
const variableHighlightTargets = useMemo((): VariableHighlightTarget[] =>
{
if(!isVariableHighlightActive || !canVariableHighlight || !roomSession || !selectedVariableDefinition?.itemId) return [];
if(variablesType === 'user')
{
const targets: VariableHighlightTarget[] = [];
for(const [ userIdString, assignments ] of Object.entries(userVariableAssignments))
{
const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId));
if(!assignment) continue;
const userId = Number(userIdString);
const userData = roomSession.userDataManager.getUserData(userId)
?? roomSession.userDataManager.getBotData(userId)
?? roomSession.userDataManager.getRentableBotData(userId)
?? roomSession.userDataManager.getPetData(userId);
const roomIndex = Number(userData?.roomIndex ?? -1);
if(roomIndex < 0) continue;
targets.push({
category: RoomObjectCategory.UNIT,
objectId: roomIndex,
hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined),
value: assignment.value
});
}
return targets;
}
if(variablesType === 'furni')
{
const targets: VariableHighlightTarget[] = [];
for(const [ furniIdString, assignments ] of Object.entries(furniVariableAssignments))
{
const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId));
if(!assignment) continue;
const furniId = Number(furniIdString);
const floorObject = GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.FLOOR);
const wallObject = floorObject ? null : GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.WALL);
const category = floorObject ? RoomObjectCategory.FLOOR : (wallObject ? RoomObjectCategory.WALL : -1);
if(category < 0) continue;
targets.push({
category,
objectId: furniId,
hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined),
value: assignment.value
});
}
return targets;
}
return [];
}, [ canVariableHighlight, furniVariableAssignments, isVariableHighlightActive, roomSession, selectedVariableDefinition, userVariableAssignments, variablesType ]);
useEffect(() =>
{
if(isVisible && (activeTab === 'variables') && canVariableHighlight) return;
setIsVariableHighlightActive(false);
}, [ activeTab, canVariableHighlight, isVisible ]);
useEffect(() =>
{
if(variableHighlightObjectsRef.current.length)
{
WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current);
variableHighlightObjectsRef.current = [];
}
if(!isVariableHighlightActive || !variableHighlightTargets.length)
{
setVariableHighlightOverlays([]);
return;
}
const objects = variableHighlightTargets.map(target => ({
category: target.category,
objectId: target.objectId
}));
WiredSelectionVisualizer.applyVariableHighlightToObjects(objects);
variableHighlightObjectsRef.current = objects;
return () =>
{
if(!variableHighlightObjectsRef.current.length) return;
WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current);
variableHighlightObjectsRef.current = [];
};
}, [ isVariableHighlightActive, variableHighlightTargets ]);
useEffect(() =>
{
if(!isVariableHighlightActive || !roomSession?.roomId || !variableHighlightTargets.length)
{
setVariableHighlightOverlays([]);
return;
}
const updateOverlays = () =>
{
const stage = GetStage();
const nextOverlays: VariableHighlightOverlay[] = [];
for(const target of variableHighlightTargets)
{
const bounds = GetRoomObjectBounds(roomSession.roomId, target.objectId, target.category);
const location = GetRoomObjectScreenLocation(roomSession.roomId, target.objectId, target.category);
if(!bounds || !location) continue;
const x = Math.max(8, Math.min(Math.round(location.x), (stage.width - 8)));
const y = Math.max(8, Math.min(Math.round(bounds.top), (stage.height - 40)));
nextOverlays.push({
...target,
key: `${ target.category }:${ target.objectId }`,
x,
y
});
}
setVariableHighlightOverlays(nextOverlays);
};
updateOverlays();
const ticker = GetTicker();
ticker.add(updateOverlays);
return () => ticker.remove(updateOverlays);
}, [ isVariableHighlightActive, roomSession?.roomId, variableHighlightTargets ]);
const variableManageTypeOptions = useMemo(() =>
{
switch(variablesType)
@@ -3465,6 +3632,27 @@ export const WiredCreatorToolsView: FC<{}> = () =>
return (
<>
{ isVariableHighlightActive && !!variableHighlightOverlays.length &&
<div className="pointer-events-none absolute left-0 top-0 z-30">
{ variableHighlightOverlays.map(overlay => (
<div
key={ overlay.key }
className="pointer-events-none absolute"
style={ {
left: overlay.x,
top: overlay.y,
transform: 'translateX(-50%)'
} }>
{ overlay.hasValue &&
<div className="absolute left-1/2 top-[-30px] -translate-x-1/2">
<div className="relative min-w-[24px] rounded-[8px] bg-[#86aebccc] px-[8px] py-[4px] text-center text-[12px] font-bold leading-none text-white shadow-[inset_0_0_0_1px_rgba(176,211,225,.7)]">
{ overlay.value ?? 0 }
<span className="absolute left-1/2 bottom-[-7px] z-10 h-0 w-0 -translate-x-1/2 border-x-[6px] border-t-[7px] border-x-transparent border-t-[#86aebccc]" />
</div>
</div> }
</div>
)) }
</div> }
<NitroCardView className="min-w-[520px] max-w-[520px]" theme="primary-slim" uniqueKey="wired-creator-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText="Wired Creator Tools (:wired)" onCloseClick={ () => setIsVisible(false) } />
<NitroCardTabsView justifyContent="start">
@@ -3830,7 +4018,12 @@ export const WiredCreatorToolsView: FC<{}> = () =>
</div>
</div>
<div className="flex gap-2">
<Button disabled variant="secondary">Highlight</Button>
<Button
disabled={ !canVariableHighlight }
variant="secondary"
onClick={ () => setIsVariableHighlightActive(value => !value) }>
{ isVariableHighlightActive ? 'Undo' : 'Highlight' }
</Button>
<Button
disabled={ !variableManageCanOpen }
variant="secondary"
@@ -5,7 +5,14 @@ import { useWired } from '../../../../hooks';
import { WiredSourcesSelector } from '../WiredSourcesSelector';
import { WiredActionBaseView } from './WiredActionBaseView';
const COUNTER_INTERACTION_TYPES = [ 'game_upcounter' ];
const COUNTER_INTERACTION_TYPES = [
'game_upcounter',
'game_timer',
'wf_game_upcounter*',
'fball_counter',
'bb_counter',
'es_counter'
];
const CONTROL_OPTIONS = [
{ value: 0, label: 'wiredfurni.params.clock_control.0' },
@@ -1,30 +1,40 @@
import { FC, useEffect, useMemo, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { LocalizeText, WiredFurniType } from '../../../../api';
import sourceFurniIcon from '../../../../assets/images/wired/source_furni.png';
import sourceUserIcon from '../../../../assets/images/wired/source_user.png';
import { Button, Slider, Text } from '../../../../common';
import { useWired } from '../../../../hooks';
import { WiredFurniSelectionSourceRow } from '../WiredFurniSelectionSourceRow';
import { sortWiredSourceOptions, useAvailableUserSources } from '../WiredSourcesSelector';
import { WiredConditionBaseView } from './WiredConditionBaseView';
const SOURCE_GROUP_USERS = 0;
const SOURCE_GROUP_FURNI = 1;
const SOURCE_TRIGGER = 0;
const SOURCE_SELECTED = 100;
const COMPARISON_OPTIONS = [ 0, 1, 2 ];
const MIN_QUANTITY = 0;
const MAX_QUANTITY = 100;
const QUANTITY_PATTERN = /^\d*$/;
const USER_SOURCES = [
const USER_SOURCES = sortWiredSourceOptions([
{ value: 0, label: 'wiredfurni.params.sources.users.0' },
{ value: 200, label: 'wiredfurni.params.sources.users.200' },
{ value: 201, label: 'wiredfurni.params.sources.users.201' }
];
], 'users');
const FURNI_SOURCES = sortWiredSourceOptions([
{ value: 0, label: 'wiredfurni.params.sources.furni.0' },
{ value: 100, label: 'wiredfurni.params.sources.furni.100' },
{ value: 200, label: 'wiredfurni.params.sources.furni.200' },
{ value: 201, label: 'wiredfurni.params.sources.furni.201' }
{ value: 201, label: 'wiredfurni.params.sources.furni.201' },
{ value: 0, label: 'wiredfurni.params.sources.furni.0' }
], 'furni');
const SOURCE_GROUP_BUTTONS = [
{ key: 'user', icon: sourceUserIcon, isUserGroup: true },
{ key: 'furni', icon: sourceFurniIcon, isUserGroup: false }
] as const;
const clampQuantity = (value: number) =>
{
if(isNaN(value)) return MIN_QUANTITY;
@@ -32,21 +42,22 @@ const clampQuantity = (value: number) =>
return Math.max(MIN_QUANTITY, Math.min(MAX_QUANTITY, Math.floor(value)));
};
const normalizeSource = (value: number, allowed: number[]) =>
const normalizeSourceType = (value: number, allowed: number[]) =>
{
return allowed.includes(value) ? value : 0;
return allowed.includes(value) ? value : SOURCE_TRIGGER;
};
export const WiredConditionSelectionQuantityView: FC<{}> = () =>
{
const { trigger = null, setIntParams = null, setStringParam = null } = useWired();
const availableUserSources = sortWiredSourceOptions(useAvailableUserSources(trigger, USER_SOURCES), 'users');
const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null, setStringParam = null } = useWired();
const rawAvailableUserSources = useAvailableUserSources(trigger, USER_SOURCES);
const availableUserSources = useMemo(() => sortWiredSourceOptions(rawAvailableUserSources, 'users'), [ rawAvailableUserSources ]);
const [ comparison, setComparison ] = useState(1);
const [ quantity, setQuantity ] = useState(0);
const [ quantityInput, setQuantityInput ] = useState('0');
const [ sourceGroup, setSourceGroup ] = useState(SOURCE_GROUP_USERS);
const [ userSource, setUserSource ] = useState(0);
const [ furniSource, setFurniSource ] = useState(0);
const [ userSource, setUserSource ] = useState(SOURCE_TRIGGER);
const [ furniSource, setFurniSource ] = useState(SOURCE_TRIGGER);
useEffect(() =>
{
@@ -55,19 +66,46 @@ export const WiredConditionSelectionQuantityView: FC<{}> = () =>
const nextComparison = (trigger.intData.length > 0) ? trigger.intData[0] : 1;
const nextQuantity = clampQuantity((trigger.intData.length > 1) ? trigger.intData[1] : 0);
const nextSourceGroup = (trigger.intData.length > 2 && trigger.intData[2] === SOURCE_GROUP_FURNI) ? SOURCE_GROUP_FURNI : SOURCE_GROUP_USERS;
const nextSourceType = (trigger.intData.length > 3) ? trigger.intData[3] : 0;
const nextSourceType = (trigger.intData.length > 3) ? trigger.intData[3] : SOURCE_TRIGGER;
setComparison(COMPARISON_OPTIONS.includes(nextComparison) ? nextComparison : 1);
setQuantity(nextQuantity);
setQuantityInput(nextQuantity.toString());
setSourceGroup(nextSourceGroup);
setUserSource(nextSourceGroup === SOURCE_GROUP_USERS ? normalizeSource(nextSourceType, availableUserSources.map(source => source.value)) : 0);
setFurniSource(nextSourceGroup === SOURCE_GROUP_FURNI ? normalizeSource(nextSourceType, FURNI_SOURCES.map(source => source.value)) : 0);
setUserSource(nextSourceGroup === SOURCE_GROUP_USERS ? normalizeSourceType(nextSourceType, availableUserSources.map(source => source.value)) : SOURCE_TRIGGER);
setFurniSource(nextSourceGroup === SOURCE_GROUP_FURNI ? normalizeSourceType(nextSourceType, FURNI_SOURCES.map(source => source.value)) : SOURCE_TRIGGER);
}, [ availableUserSources, trigger ]);
const activeSources = useMemo(() => (sourceGroup === SOURCE_GROUP_FURNI) ? FURNI_SOURCES : availableUserSources, [ availableUserSources, sourceGroup ]);
const activeSource = (sourceGroup === SOURCE_GROUP_FURNI) ? furniSource : userSource;
const isUserGroup = sourceGroup === SOURCE_GROUP_USERS;
const activeSources = useMemo(() => isUserGroup ? availableUserSources : FURNI_SOURCES, [ availableUserSources, isUserGroup ]);
const activeSource = isUserGroup ? userSource : furniSource;
const activeSourceIndex = Math.max(0, activeSources.findIndex(source => source.value === activeSource));
const currentSourceType = activeSources[activeSourceIndex]?.value ?? SOURCE_TRIGGER;
const requiresFurni = (!isUserGroup && furniSource === SOURCE_SELECTED) ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID : WiredFurniType.STUFF_SELECTION_OPTION_NONE;
useEffect(() =>
{
if(currentSourceType === activeSource) return;
if(isUserGroup) setUserSource(currentSourceType);
else setFurniSource(currentSourceType);
}, [ activeSource, currentSourceType, isUserGroup ]);
const changeGroup = (nextIsUserGroup: boolean) =>
{
if(nextIsUserGroup === isUserGroup) return;
const nextOptions = nextIsUserGroup ? availableUserSources : FURNI_SOURCES;
const nextIndex = Math.min(activeSourceIndex, Math.max(0, nextOptions.length - 1));
const nextOption = nextOptions[nextIndex] ?? nextOptions[0];
setSourceGroup(nextIsUserGroup ? SOURCE_GROUP_USERS : SOURCE_GROUP_FURNI);
if(!nextOption) return;
if(nextIsUserGroup) setUserSource(nextOption.value);
else setFurniSource(nextOption.value);
};
const updateQuantity = (value: number) =>
{
@@ -92,30 +130,23 @@ export const WiredConditionSelectionQuantityView: FC<{}> = () =>
updateQuantity(parseInt(value));
};
const cycleSource = (direction: -1 | 1) =>
{
const nextIndex = (activeSourceIndex + direction + activeSources.length) % activeSources.length;
const nextValue = activeSources[nextIndex].value;
if(sourceGroup === SOURCE_GROUP_FURNI) setFurniSource(nextValue);
else setUserSource(nextValue);
};
const save = () =>
{
setIntParams([
comparison,
clampQuantity(quantity),
sourceGroup,
(sourceGroup === SOURCE_GROUP_FURNI) ? furniSource : userSource
isUserGroup ? userSource : furniSource
]);
setStringParam('');
if(requiresFurni <= WiredFurniType.STUFF_SELECTION_OPTION_NONE) setFurniIds([]);
};
return (
<WiredConditionBaseView
hasSpecialInput={ true }
requiresFurni={ WiredFurniType.STUFF_SELECTION_OPTION_NONE }
requiresFurni={ requiresFurni }
save={ save }>
<div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('wiredfurni.params.comparison_selection') }</Text>
@@ -147,34 +178,34 @@ export const WiredConditionSelectionQuantityView: FC<{}> = () =>
onChange={ event => updateQuantity(event as number) } />
<Text small>{ quantity }</Text>
</div>
<div className="flex flex-col gap-2">
<Text bold>{ LocalizeText('wiredfurni.params.sources.merged.title') }</Text>
<div className="flex gap-1">
<Button
fullWidth
variant={ (sourceGroup === SOURCE_GROUP_USERS) ? 'primary' : 'secondary' }
onClick={ () => setSourceGroup(SOURCE_GROUP_USERS) }>
{ LocalizeText('wiredfurni.params.sources.users.title') }
</Button>
<Button
fullWidth
variant={ (sourceGroup === SOURCE_GROUP_FURNI) ? 'primary' : 'secondary' }
onClick={ () => setSourceGroup(SOURCE_GROUP_FURNI) }>
{ LocalizeText('wiredfurni.params.sources.furni.title') }
</Button>
</div>
<div className="flex items-center gap-2">
<Button variant="primary" className="px-2 py-1" onClick={ () => cycleSource(-1) }>
<FaChevronLeft />
</Button>
<div className="flex flex-1 items-center justify-center">
<Text small>{ LocalizeText(activeSources[activeSourceIndex].label) }</Text>
<WiredFurniSelectionSourceRow
title="wiredfurni.params.sources.merged.title"
options={ activeSources }
value={ activeSource }
selectionKind={ isUserGroup ? 'primary' : 'secondary' }
selectionActive={ !isUserGroup && furniSource === SOURCE_SELECTED }
selectionCount={ furniIds.length }
selectionLimit={ trigger?.maximumItemSelectionCount ?? 20 }
selectionEnabledValues={ [ SOURCE_SELECTED ] }
showSelectionToggle={ false }
headerContent={
<div className="nitro-wired__give-var-targets">
{ SOURCE_GROUP_BUTTONS.map(button => (
<button
key={ button.key }
type="button"
className={ `nitro-wired__give-var-target nitro-wired__give-var-target--${ button.key } ${ isUserGroup === button.isUserGroup ? 'is-active' : '' }` }
onClick={ () => changeGroup(button.isUserGroup) }>
<img src={ button.icon } alt={ button.key } />
</button>
)) }
</div>
<Button variant="primary" className="px-2 py-1" onClick={ () => cycleSource(1) }>
<FaChevronRight />
</Button>
</div>
</div>
}
onChange={ value =>
{
if(isUserGroup) setUserSource(value);
else setFurniSource(value);
} } />
</WiredConditionBaseView>
);
};
@@ -2,6 +2,7 @@ import { FC, useEffect, useState } from 'react';
import { LocalizeText, WiredFurniType } from '../../../../api';
import { Text } from '../../../../common';
import { useWired } from '../../../../hooks';
import { WiredTextFormattingHelp } from '../common/WiredTextFormattingHelp';
import { WiredExtraBaseView } from './WiredExtraBaseView';
const DEFAULT_CONNECTOR_PLACEHOLDER = '0=text 1\n1=text 2\n2 = text 3';
@@ -70,6 +71,7 @@ export const WiredExtraVariableTextConnectorView: FC<{}> = () =>
value={ mappingsText }
onChange={ event => handleTextChange(event.target.value) } />
<Text small>{ `${ lineCount }/${ MAX_CONNECTOR_LINES } righe - ${ characterCount }/${ MAX_CONNECTOR_CHARACTERS } caratteri` }</Text>
<WiredTextFormattingHelp />
</div>
</WiredExtraBaseView>
);
@@ -15,7 +15,6 @@ const normalizeFurniSource = (value: number) => (FURNI_SOURCE_OPTIONS.some(optio
export const WiredTriggerReceiveSignalView: FC<{}> = () =>
{
const [ senderCount, setSenderCount ] = useState(0);
const [ maxSenders, setMaxSenders ] = useState(5);
const [ channel, setChannel ] = useState(0);
const [ furniSource, setFurniSource ] = useState(100);
@@ -30,7 +29,6 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () =>
const p = trigger.intData;
if(p.length >= 1) setChannel(p[0]);
if(p.length >= 2) setSenderCount(p[1]);
if(p.length >= 3) setMaxSenders(p[2]);
if(p.length >= 4) setFurniSource(normalizeFurniSource(p[3]));
else setFurniSource(100);
}, [ trigger ]);
@@ -43,7 +41,7 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () =>
footer={ <WiredSourcesSelector showFurni={ true } furniSource={ furniSource } furniSources={ FURNI_SOURCE_OPTIONS } onChangeFurni={ setFurniSource } /> }>
<div className="flex items-center justify-between">
<Text small>{ LocalizeText('wiredfurni.params.signal.senders_connected') }</Text>
<Text bold small>{ senderCount }/{ maxSenders }</Text>
<Text bold small>{ senderCount }</Text>
</div>
</WiredTriggerBaseView>
);