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
+130 -14
View File
@@ -40,9 +40,11 @@ const useCatalogState = () =>
const [ secondsLeft, setSecondsLeft ] = useState(0);
const [ updateTime, setUpdateTime ] = useState(0);
const [ secondsLeftWithGrace, setSecondsLeftWithGrace ] = useState(0);
const [ catalogLocalizationVersion, setCatalogLocalizationVersion ] = useState(0);
const [ builderPlacementBlockedByVisitors, setBuilderPlacementBlockedByVisitors ] = useState(false);
const [ builderPlacementAllowedInCurrentRoom, setBuilderPlacementAllowedInCurrentRoom ] = useState(false);
const [ builderTrialRoomHideConfirmed, setBuilderTrialRoomHideConfirmed ] = useState(false);
const resolvedOffersByProductKey = useRef<Map<string, IPurchasableOffer>>(new Map());
const { simpleAlert = null, showConfirm = null } = useNotification();
const requestedPage = useRef(new RequestedPage());
@@ -54,6 +56,7 @@ const useCatalogState = () =>
setOffersToNodes(null);
setCurrentPage(null);
setCurrentOffer(null);
resolvedOffersByProductKey.current.clear();
setActiveNodes([]);
setSearchResult(null);
setFrontPageItems([]);
@@ -77,6 +80,7 @@ const useCatalogState = () =>
setOffersToNodes(null);
setCurrentPage(null);
setCurrentOffer(null);
resolvedOffersByProductKey.current.clear();
setActiveNodes([]);
setSearchResult(null);
setFrontPageItems([]);
@@ -336,6 +340,53 @@ const useCatalogState = () =>
return offersToNodes.get(offerId);
}, [ offersToNodes ]);
const getOfferProductKeys = useCallback((offer: IPurchasableOffer) =>
{
const product = offer?.product;
const keys: string[] = [];
if(!product) return keys;
if(product.productType && (product.productClassId >= 0))
{
keys.push(`${ product.productType }:id:${ product.productClassId }`);
}
if(product.productType && product.furnitureData?.className?.length)
{
keys.push(`${ product.productType }:class:${ product.furnitureData.className }`);
}
return keys;
}, []);
const cacheResolvedOffer = useCallback((offer: IPurchasableOffer) =>
{
for(const key of getOfferProductKeys(offer))
{
resolvedOffersByProductKey.current.set(key, offer);
}
}, [ getOfferProductKeys ]);
const applySelectedOffer = useCallback((offer: IPurchasableOffer) =>
{
if(!offer) return;
setCurrentOffer(offer);
if(offer.product && (offer.product.productType === ProductTypeEnum.WALL))
{
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
newValue.extraData = (offer.product.extraParam || null);
return newValue;
});
}
}, []);
const loadCatalogPage = useCallback((pageId: number, offerId: number) =>
{
if(pageId < 0) return;
@@ -485,6 +536,22 @@ const useCatalogState = () =>
}
}, [ isVisible, getNodesByOfferId, activateNode ]);
const selectCatalogOffer = useCallback((offer: IPurchasableOffer) =>
{
if(!offer) return;
if(!offer.isLazy)
{
applySelectedOffer(offer);
return;
}
if(offer.offerId > -1)
{
offer.activate();
}
}, [ applySelectedOffer ]);
const refreshBuilderStatus = useCallback(() =>
{
@@ -544,16 +611,20 @@ const useCatalogState = () =>
const purchasableOffer = new Offer(offer.offerId, offer.localizationId, offer.rent, offer.priceCredits, offer.priceActivityPoints, offer.priceActivityPointsType, offer.giftable, offer.clubLevel, products, offer.bundlePurchaseAllowed);
cacheResolvedOffer(purchasableOffer);
if((currentType === CatalogType.NORMAL) || ((purchasableOffer.pricingModel !== Offer.PRICING_MODEL_BUNDLE) && (purchasableOffer.pricingModel !== Offer.PRICING_MODEL_MULTI))) purchasableOffers.push(purchasableOffer);
}
const parsedCatalogPage = new CatalogPage(parser.pageId, parser.layoutCode, new PageLocalization(parser.localization.images.concat(), parser.localization.texts.concat()), purchasableOffers, parser.acceptSeasonCurrencyAsCredits);
if(parser.frontPageItems && parser.frontPageItems.length) setFrontPageItems(parser.frontPageItems);
setIsBusy(false);
if(pageId === parser.pageId)
{
showCatalogPage(parser.pageId, parser.layoutCode, new PageLocalization(parser.localization.images.concat(), parser.localization.texts.concat()), purchasableOffers, parser.offerId, parser.acceptSeasonCurrencyAsCredits);
showCatalogPage(parsedCatalogPage.pageId, parsedCatalogPage.layoutCode, parsedCatalogPage.localization, parsedCatalogPage.offers, parser.offerId, parsedCatalogPage.acceptSeasonCurrencyAsCredits);
}
});
@@ -610,24 +681,31 @@ const useCatalogState = () =>
}
const offer = new Offer(offerData.offerId, offerData.localizationId, offerData.rent, offerData.priceCredits, offerData.priceActivityPoints, offerData.priceActivityPointsType, offerData.giftable, offerData.clubLevel, products, offerData.bundlePurchaseAllowed);
cacheResolvedOffer(offer);
const matchingNodes = getNodesByOfferId(offer.offerId, true) || getNodesByOfferId(offer.offerId);
if(!((currentType === CatalogType.NORMAL) || ((offer.pricingModel !== Offer.PRICING_MODEL_BUNDLE) && (offer.pricingModel !== Offer.PRICING_MODEL_MULTI)))) return;
offer.page = currentPage;
setCurrentOffer(offer);
if(offer.product && (offer.product.productType === ProductTypeEnum.WALL))
if(matchingNodes?.length)
{
setPurchaseOptions(prevValue =>
{
const newValue = { ...prevValue };
const referencePage = currentPage;
newValue.extraData =( offer.product.extraParam || null);
return newValue;
});
offer.page = new CatalogPage(
matchingNodes[0].pageId,
referencePage?.layoutCode || 'default_3x3',
referencePage?.localization || new PageLocalization([], []),
[],
referencePage?.acceptSeasonCurrencyAsCredits || false,
referencePage?.mode ?? CatalogPage.MODE_NORMAL
);
}
else
{
offer.page = currentPage;
}
applySelectedOffer(offer);
// (this._isObjectMoverRequested) && (this._purchasableOffer)
});
@@ -976,6 +1054,44 @@ const useCatalogState = () =>
if(!searchResult && currentPage && (currentPage.pageId === -1)) openPageById(previousPageId);
}, [ searchResult, currentPage, previousPageId, openPageById ]);
useEffect(() =>
{
const refreshCatalogLocalization = () =>
{
setCatalogLocalizationVersion(value => (value + 1));
setCurrentOffer(prevValue => (prevValue?.clone ? prevValue.clone() : prevValue));
setCurrentPage(prevValue =>
{
if(!prevValue) return prevValue;
const offers = prevValue.offers?.map(offer => (offer?.clone ? offer.clone() : offer)) || [];
return new CatalogPage(prevValue.pageId, prevValue.layoutCode, prevValue.localization, offers, prevValue.acceptSeasonCurrencyAsCredits, prevValue.mode);
});
setCatalogOptions(prevValue =>
{
if(!prevValue) return prevValue;
const clubOffersByWindowId = { ...(prevValue.clubOffersByWindowId || {}) };
Object.keys(clubOffersByWindowId).forEach(key =>
{
const offers = clubOffersByWindowId[key];
if(Array.isArray(offers)) clubOffersByWindowId[key] = [ ...offers ];
});
const clubOffers = Array.isArray(prevValue.clubOffers) ? [ ...prevValue.clubOffers ] : prevValue.clubOffers;
return { ...prevValue, clubOffers, clubOffersByWindowId };
});
};
window.addEventListener('nitro-localization-updated', refreshCatalogLocalization);
return () => window.removeEventListener('nitro-localization-updated', refreshCatalogLocalization);
}, []);
useEffect(() =>
{
if(!currentOffer) return;
@@ -1013,7 +1129,7 @@ const useCatalogState = () =>
};
}, []);
return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogOptions, setCatalogOptions, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus };
return { isVisible, setIsVisible, isBusy, pageId, previousPageId, currentType, rootNode, offersToNodes, currentPage, setCurrentPage, currentOffer, setCurrentOffer, activeNodes, searchResult, setSearchResult, frontPageItems, roomPreviewer, navigationHidden, setNavigationHidden, purchaseOptions, setPurchaseOptions, catalogOptions, setCatalogOptions, catalogLocalizationVersion, getNodeById, getNodeByName, activateNode, openPageById, openPageByName, openPageByOfferId, requestOfferToMover, openCatalogByType, toggleCatalogByType, furniCount, furniLimit, maxFurniLimit, secondsLeft, secondsLeftWithGrace, updateTime, catalogPlaceMultipleObjects, setCatalogPlaceMultipleObjects, getBuilderFurniPlaceableStatus, selectCatalogOffer };
};
export const useCatalog = () => useBetween(useCatalogState);
+21 -1
View File
@@ -33,6 +33,26 @@ const useChatHistoryState = () =>
return newValue;
});
return entry.id;
};
const updateChatEntry = (entryId: number, partial: Partial<IChatEntry>) =>
{
if(entryId < 0) return;
setChatHistory(prevValue =>
{
const index = prevValue.findIndex(entry => (entry.id === entryId));
if(index === -1) return prevValue;
const newValue = [ ...prevValue ];
newValue[index] = { ...newValue[index], ...partial };
return newValue;
});
};
const clearChatHistory = () => setChatHistory([]);
@@ -101,7 +121,7 @@ const useChatHistoryState = () =>
addMessengerEntry({ id: -1, webId: parser.senderId, entityId: -1, name: '', message: parser.messageText, roomId: -1, timestamp: MessengerHistoryCurrentDate(), type: ChatEntryType.TYPE_IM });
});
return { addChatEntry, clearChatHistory, chatHistory, roomHistory, messengerHistory };
return { addChatEntry, updateChatEntry, clearChatHistory, chatHistory, roomHistory, messengerHistory };
};
export const useChatHistory = () => useBetween(useChatHistoryState);
+40 -2
View File
@@ -4,6 +4,7 @@ import { useBetween } from 'use-between';
import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api';
import { useMessageEvent } from '../events';
import { useNotification } from '../notification';
import { IResolvedTranslation, useTranslation } from '../translation';
import { useFriends } from './useFriends';
const useMessengerState = () =>
@@ -14,6 +15,7 @@ const useMessengerState = () =>
const [iconState, setIconState] = useState<number>(MessengerIconState.HIDDEN);
const { getFriend = null } = useFriends();
const { simpleAlert = null } = useNotification();
const { settings, translateIncoming } = useTranslation();
const visibleThreads = useMemo(() => messageThreads.filter(thread => (hiddenThreadIds.indexOf(thread.threadId) === -1)), [messageThreads, hiddenThreadIds]);
const activeThread = useMemo(() => ((activeThreadId > 0) && visibleThreads.find(thread => (thread.threadId === activeThreadId) || null)), [activeThreadId, visibleThreads]);
@@ -79,7 +81,7 @@ const useMessengerState = () =>
if (activeThreadId === threadId) setActiveThreadId(-1);
};
const sendMessage = (thread: MessengerThread, senderId: number, messageText: string, secondsSinceSent: number = 0, extraData: string = null, messageType: number = MessengerThreadChat.CHAT) =>
const sendMessage = (thread: MessengerThread, senderId: number, messageText: string, secondsSinceSent: number = 0, extraData: string = null, messageType: number = MessengerThreadChat.CHAT, translation: IResolvedTranslation = null) =>
{
if (!thread || !messageText || !messageText.length) return;
@@ -87,6 +89,8 @@ const useMessengerState = () =>
if (ownMessage && (messageText.length <= 255)) SendMessageComposer(new SendMessageComposerPacket(thread.participant.id, messageText));
let addedChatId = -1;
setMessageThreads(prevValue =>
{
const newValue = [...prevValue];
@@ -98,7 +102,11 @@ const useMessengerState = () =>
if (ownMessage && (thread.groups.length === 1)) PlaySound(SoundNames.MESSENGER_NEW_THREAD);
thread.addMessage(((messageType === MessengerThreadChat.ROOM_INVITE) ? null : senderId), messageText, secondsSinceSent, extraData, messageType);
const addedChat = thread.addMessage(((messageType === MessengerThreadChat.ROOM_INVITE) ? null : senderId), messageText, secondsSinceSent, extraData, messageType);
addedChatId = addedChat?.id || -1;
if(translation && (messageType === MessengerThreadChat.CHAT)) addedChat?.setTranslation(translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage);
if (activeThreadId === thread.threadId) thread.setRead();
@@ -108,6 +116,36 @@ const useMessengerState = () =>
return newValue;
});
const canTranslateMessage = !translation
&& settings.enabled
&& (messageType === MessengerThreadChat.CHAT)
&& !!messageText?.trim().length;
if(!canTranslateMessage || (addedChatId <= 0)) return;
void translateIncoming(messageText).then(translation =>
{
if(!translation) return;
setMessageThreads(prevValue =>
{
const newValue = [ ...prevValue ];
const index = newValue.findIndex(newThread => (newThread.threadId === thread.threadId));
if(index === -1) return prevValue;
const clonedThread = CloneObject(newValue[index]);
const chat = clonedThread.getChat(addedChatId);
if(!chat) return prevValue;
chat.setTranslation(translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage);
newValue[index] = clonedThread;
return newValue;
});
});
};
useMessageEvent<NewConsoleMessageEvent>(NewConsoleMessageEvent, event =>
+1
View File
@@ -19,6 +19,7 @@ export * from './rooms/promotes';
export * from './rooms/widgets';
export * from './rooms/widgets/furniture';
export * from './session';
export * from './translation';
export * from './useLocalStorage';
export * from './useSharedVisibility';
export * from './wired';
+1
View File
@@ -1,6 +1,7 @@
export * from './useInventoryBadges';
export * from './useInventoryBots';
export * from './useInventoryFurni';
export * from './useInventoryNickIcons';
export * from './useInventoryPets';
export * from './useInventoryPrefixes';
export * from './useInventoryTrade';
+35
View File
@@ -292,6 +292,41 @@ const useInventoryFurniState = () =>
setNeedsUpdate(false);
}, [ isVisible, needsUpdate ]);
useEffect(() =>
{
const refreshFurnitureLocalization = () =>
{
setGroupItems(prevValue =>
{
if(!prevValue?.length) return prevValue;
return prevValue.map(groupItem =>
{
const nextGroupItem = groupItem.clone();
nextGroupItem.refreshLocalization();
return nextGroupItem;
});
});
setSelectedItem(prevValue =>
{
if(!prevValue) return prevValue;
const nextGroupItem = prevValue.clone();
nextGroupItem.refreshLocalization();
return nextGroupItem;
});
};
window.addEventListener('nitro-localization-updated', refreshFurnitureLocalization);
return () => window.removeEventListener('nitro-localization-updated', refreshFurnitureLocalization);
}, []);
return { isVisible, groupItems, setGroupItems, selectedItem, setSelectedItem, activate, deactivate, getWallItemById, getFloorItemById, getItemsByType };
};
@@ -0,0 +1,80 @@
import { RequestNickIconsComposer, SetActiveNickIconComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { INickIconItem, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useSharedVisibility } from '../useSharedVisibility';
const useInventoryNickIconsState = () =>
{
const [ needsUpdate, setNeedsUpdate ] = useState(true);
const [ nickIcons, setNickIcons ] = useState<INickIconItem[]>([]);
const [ activeNickIcon, setActiveNickIcon ] = useState<INickIconItem | null>(null);
const [ selectedNickIcon, setSelectedNickIcon ] = useState<INickIconItem | null>(null);
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
useMessageEvent<UserNickIconsEvent>(UserNickIconsEvent, event =>
{
const parser = event.getParser();
const ownedNickIcons = parser.nickIcons
.filter(icon => icon.owned)
.map(icon => ({
id: icon.id,
iconKey: icon.iconKey,
displayName: icon.displayName,
points: icon.points,
pointsType: icon.pointsType,
owned: true,
active: icon.active
}));
setNickIcons(ownedNickIcons);
setActiveNickIcon(ownedNickIcons.find(icon => icon.active) || null);
});
const activateNickIcon = (nickIconId: number) =>
{
SendMessageComposer(new SetActiveNickIconComposer(nickIconId));
};
const deactivateNickIcon = () =>
{
SendMessageComposer(new SetActiveNickIconComposer(0));
};
useEffect(() =>
{
if(!nickIcons.length)
{
setSelectedNickIcon(null);
return;
}
setSelectedNickIcon(prevValue =>
{
if(prevValue && nickIcons.find(icon => icon.id === prevValue.id)) return prevValue;
return nickIcons[0];
});
}, [ nickIcons ]);
useEffect(() =>
{
if(!isVisible || !needsUpdate) return;
SendMessageComposer(new RequestNickIconsComposer());
setNeedsUpdate(false);
}, [ isVisible, needsUpdate ]);
return {
nickIcons,
activeNickIcon,
selectedNickIcon,
setSelectedNickIcon,
activateNickIcon,
deactivateNickIcon,
activate,
deactivate
};
};
export const useInventoryNickIcons = () => useBetween(useInventoryNickIconsState);
+27 -3
View File
@@ -1,4 +1,4 @@
import { ActivePrefixUpdatedEvent, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, DeletePrefixComposer, UserPrefixesEvent } from '@nitrots/nitro-renderer';
import { ActivePrefixUpdatedEvent, DeletePrefixComposer, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, UserNickIconsEvent, UserPrefixesEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { IPrefixItem, SendMessageComposer, UnseenItemCategory } from '../../api';
@@ -24,6 +24,7 @@ const useInventoryPrefixesState = () =>
color: p.color,
icon: p.icon || '',
effect: p.effect || '',
font: p.font || '',
active: p.active
}));
@@ -33,6 +34,28 @@ const useInventoryPrefixesState = () =>
setActivePrefix(active);
});
useMessageEvent<UserNickIconsEvent>(UserNickIconsEvent, event =>
{
const parser = event.getParser();
const newPrefixes: IPrefixItem[] = 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
}));
setPrefixes(newPrefixes);
setActivePrefix(newPrefixes.find(prefix => prefix.active) || null);
});
useMessageEvent<PrefixReceivedEvent>(PrefixReceivedEvent, event =>
{
const parser = event.getParser();
@@ -42,6 +65,7 @@ const useInventoryPrefixesState = () =>
color: parser.color,
icon: parser.icon || '',
effect: parser.effect || '',
font: parser.font || '',
active: false
};
@@ -69,8 +93,8 @@ const useInventoryPrefixesState = () =>
setActivePrefix(prev =>
{
const found = prefixes.find(p => p.id === parser.prefixId);
if(found) return { ...found, active: true };
return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', active: true };
if(found) return { ...found, active: true, font: parser.font || found.font || '' };
return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', font: parser.font || '', active: true };
});
}
});
@@ -382,6 +382,23 @@ const useAvatarInfoWidgetState = () =>
return () => clearPendingAvatarInfo();
}, []);
useEffect(() =>
{
const refreshFurnitureInfo = () =>
{
setAvatarInfo(prevValue =>
{
if(!(prevValue instanceof AvatarInfoFurni)) return prevValue;
return AvatarInfoUtilities.getFurniInfo(prevValue.id, prevValue.category) || prevValue;
});
};
window.addEventListener('nitro-localization-updated', refreshFurnitureInfo);
return () => window.removeEventListener('nitro-localization-updated', refreshFurnitureInfo);
}, []);
useEffect(() =>
{
if(!roomSession) return;
+47 -10
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../api';
import { useNitroEvent } from '../../events';
import { useNotification } from '../../notification';
import { useTranslation } from '../../translation';
import { useObjectSelectedEvent } from '../engine';
import { useRoom } from '../useRoom';
@@ -15,6 +16,7 @@ const useChatInputWidgetState = () =>
const [ floodBlocked, setFloodBlocked ] = useState(false);
const [ floodBlockedSeconds, setFloodBlockedSeconds ] = useState(0);
const { showNitroAlert = null, showConfirm = null } = useNotification();
const { settings, translateOutgoing, enqueueOutgoingTranslation } = useTranslation();
const { roomSession = null } = useRoom();
const sendChat = (text: string, chatType: number, recipientName: string = '', styleId: number = 0) =>
@@ -183,22 +185,57 @@ const useChatInputWidgetState = () =>
SendMessageComposer(new RoomSettingsComposer(roomSession.roomId));
}
return null;
case ':customize':
CreateLinkEvent('customize/show');
return null;
}
}
switch(chatType)
const preserveTrailingSpaces = (message: string) => message.replace(/ +$/g, match => '\u00A0'.repeat(match.length));
const dispatchChatMessage = (message: string) =>
{
case ChatMessageTypeEnum.CHAT_DEFAULT:
roomSession.sendChatMessage(text, styleId);
break;
case ChatMessageTypeEnum.CHAT_SHOUT:
roomSession.sendShoutMessage(text, styleId);
break;
case ChatMessageTypeEnum.CHAT_WHISPER:
roomSession.sendWhisperMessage(recipientName, text, styleId);
break;
const preservedMessage = preserveTrailingSpaces(message);
switch(chatType)
{
case ChatMessageTypeEnum.CHAT_DEFAULT:
roomSession.sendChatMessage(preservedMessage, styleId);
return;
case ChatMessageTypeEnum.CHAT_SHOUT:
roomSession.sendShoutMessage(preservedMessage, styleId);
return;
case ChatMessageTypeEnum.CHAT_WHISPER:
roomSession.sendWhisperMessage(recipientName, preservedMessage, styleId);
return;
}
};
const trimmedText = text.trimStart();
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
if(!shouldTranslateOutgoing)
{
dispatchChatMessage(text);
return null;
}
void (async () =>
{
const translation = await translateOutgoing(text);
if(translation)
{
enqueueOutgoingTranslation(translation);
dispatchChatMessage(translation.translatedText);
return;
}
dispatchChatMessage(text);
})();
return null;
};
useNitroEvent<RoomSessionChatEvent>(RoomSessionChatEvent.FLOOD_EVENT, event =>
+92 -6
View File
@@ -1,7 +1,8 @@
import { GetGuestRoomResultEvent, GetRoomEngine, PetFigureData, RoomChatSettings, RoomChatSettingsEvent, RoomDragEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionChatEvent, RoomUserData, SystemChatStyleEnum } from '@nitrots/nitro-renderer';
import { useEffect, useMemo, useRef, useState } from 'react';
import { GetGuestRoomResultEvent, GetRoomEngine, GetSessionDataManager, PetFigureData, RoomChatSettings, RoomChatSettingsEvent, RoomDragEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionChatEvent, RoomUserData, SystemChatStyleEnum } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChatBubbleMessage, ChatBubbleUtilities, ChatEntryType, ChatHistoryCurrentDate, GetConfigurationValue, GetRoomObjectScreenLocation, IRoomChatSettings, LocalizeText, PlaySound, RoomChatFormatter } from '../../../api';
import { useMessageEvent, useNitroEvent } from '../../events';
import { useTranslation } from '../../translation';
import { useRoom } from '../useRoom';
import { useChatHistory } from './../../chat-history';
@@ -18,8 +19,58 @@ const useChatWidgetState = () =>
protection: RoomChatSettings.FLOOD_FILTER_NORMAL
});
const { roomSession = null } = useRoom();
const { addChatEntry } = useChatHistory();
const { addChatEntry, updateChatEntry } = useChatHistory();
const { settings, translateIncoming, consumeOutgoingTranslation } = useTranslation();
const isDisposed = useRef(false);
const ownUserId = (GetSessionDataManager()?.userId || -1);
const applyTranslationToBubble = useCallback((chatMessage: ChatBubbleMessage, originalText: string, translatedText: string, detectedLanguage: string, targetLanguage: string) =>
{
const resolvedOriginalText = (originalText || chatMessage.text || '');
const resolvedTranslatedText = (translatedText || resolvedOriginalText);
const originalFormattedText = RoomChatFormatter(resolvedOriginalText);
const translatedFormattedText = RoomChatFormatter(resolvedTranslatedText);
chatMessage.text = resolvedOriginalText;
chatMessage.formattedText = originalFormattedText;
chatMessage.originalText = resolvedOriginalText;
chatMessage.originalFormattedText = originalFormattedText;
chatMessage.translatedText = resolvedTranslatedText;
chatMessage.translatedFormattedText = translatedFormattedText;
chatMessage.translationDetectedLanguage = detectedLanguage || '';
chatMessage.translationTargetLanguage = targetLanguage || '';
chatMessage.showTranslation = true;
}, []);
const buildTranslatedEntryPatch = useCallback((originalText: string, translatedText: string, detectedLanguage: string, targetLanguage: string) =>
{
const resolvedOriginalText = (originalText || '');
const resolvedTranslatedText = (translatedText || resolvedOriginalText);
return {
showTranslation: true,
message: RoomChatFormatter(resolvedOriginalText),
originalMessage: RoomChatFormatter(resolvedOriginalText),
translatedMessage: RoomChatFormatter(resolvedTranslatedText),
detectedLanguage: detectedLanguage || '',
targetLanguage: targetLanguage || ''
};
}, []);
const applyAsyncTranslation = useCallback((bubbleId: number, chatEntryId: number, originalText: string, translatedText: string, detectedLanguage: string, targetLanguage: string) =>
{
setChatMessages(prevValue =>
{
const newValue = [ ...prevValue ];
const bubble = newValue.find(chat => (chat.id === bubbleId));
if(bubble) applyTranslationToBubble(bubble, originalText, translatedText, detectedLanguage, targetLanguage);
return newValue;
});
updateChatEntry(chatEntryId, buildTranslatedEntryPatch(originalText, translatedText, detectedLanguage, targetLanguage));
}, [ applyTranslationToBubble, buildTranslatedEntryPatch, updateChatEntry ]);
const getScrollSpeed = useMemo(() =>
{
@@ -133,14 +184,17 @@ const useChatWidgetState = () =>
}
}
const formattedText = RoomChatFormatter(text);
const isTranslatableChatType = ((chatType === RoomSessionChatEvent.CHAT_TYPE_SPEAK) || (chatType === RoomSessionChatEvent.CHAT_TYPE_WHISPER) || (chatType === RoomSessionChatEvent.CHAT_TYPE_SHOUT));
const outgoingTranslation = (isTranslatableChatType && (userData.webID === ownUserId)) ? consumeOutgoingTranslation(text) : null;
const originalText = outgoingTranslation?.originalText || text;
const formattedText = RoomChatFormatter(originalText);
const color = (avatarColor && (('#' + (avatarColor.toString(16).padStart(6, '0'))) || null));
const chatMessage = new ChatBubbleMessage(
userData.roomIndex,
RoomObjectCategory.UNIT,
roomSession.roomId,
text,
originalText,
formattedText,
username,
{ x: bubbleLocation.x, y: bubbleLocation.y },
@@ -149,10 +203,18 @@ const useChatWidgetState = () =>
imageUrl,
color);
if(outgoingTranslation)
{
applyTranslationToBubble(chatMessage, outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage);
}
chatMessage.prefixText = event.prefixText || '';
chatMessage.prefixColor = event.prefixColor || '';
chatMessage.prefixIcon = event.prefixIcon || '';
chatMessage.prefixEffect = event.prefixEffect || '';
chatMessage.prefixFont = event.prefixFont || '';
chatMessage.nickIcon = event.nickIcon || '';
chatMessage.displayOrder = event.displayOrder || 'icon-prefix-name';
setChatMessages(prevValue =>
{
@@ -162,7 +224,31 @@ const useChatWidgetState = () =>
return newValue;
});
addChatEntry({ id: -1, webId: userData.webID, entityId: userData.roomIndex, name: username, imageUrl, style: styleId, chatType: chatType, entityType: userData.type, message: formattedText, timestamp: ChatHistoryCurrentDate(), type: ChatEntryType.TYPE_CHAT, roomId: roomSession.roomId, color });
const chatEntryId = addChatEntry({
id: -1,
webId: userData.webID,
entityId: userData.roomIndex,
name: username,
imageUrl,
style: styleId,
chatType: chatType,
entityType: userData.type,
message: formattedText,
timestamp: ChatHistoryCurrentDate(),
type: ChatEntryType.TYPE_CHAT,
roomId: roomSession.roomId,
color,
...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {})
});
if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return;
void translateIncoming(text).then(translation =>
{
if(!translation || isDisposed.current) return;
applyAsyncTranslation(chatMessage.id, chatEntryId, translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage);
});
});
useNitroEvent<RoomDragEvent>(RoomDragEvent.ROOM_DRAG, event =>
+1
View File
@@ -0,0 +1 @@
export * from './useTranslation';
+589
View File
@@ -0,0 +1,589 @@
import { GetConfiguration, GetLocalizationManager, GetSessionDataManager, TranslationLanguagesEvent, TranslationLanguagesRequestComposer, TranslationResultEvent, TranslationTextRequestComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { LocalStorageKeys, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useLocalStorage } from '../useLocalStorage';
const REQUEST_TIMEOUT_MS = 8000;
const OUTGOING_QUEUE_TTL_MS = 30000;
export interface ITranslationSettings
{
enabled: boolean;
incomingTargetLanguage: string;
outgoingTargetLanguage: string;
uiTextLanguage: string;
}
export interface ITranslationLanguage
{
code: string;
name: string;
}
export interface ITranslationTextLocale extends ITranslationLanguage
{
file: string;
}
export interface IResolvedTranslation
{
originalText: string;
translatedText: string;
detectedLanguage: string;
targetLanguage: string;
}
interface IPendingTranslationRequest
{
resolve: (translation: IResolvedTranslation) => void;
reject: (error: Error) => void;
timeoutId: number;
}
interface IQueuedOutgoingTranslation extends IResolvedTranslation
{
expiresAt: number;
}
const normalizeLanguageCode = (value: string) =>
{
if(!value || !value.trim().length) return '';
const normalized = value.trim().replace('_', '-');
const parts = normalized.split('-');
if(parts.length === 1) return parts[0].toLowerCase();
return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`;
};
const TEXT_TRANSLATION_LOCALES: ITranslationTextLocale[] = [
{ code: 'pt-BR', name: 'Portuguese (Brazil)', file: 'br' },
{ code: 'en', name: 'English', file: 'com' },
{ code: 'de', name: 'German', file: 'de' },
{ code: 'es', name: 'Spanish', file: 'es' },
{ code: 'fi', name: 'Finnish', file: 'fi' },
{ code: 'fr', name: 'French', file: 'fr' },
{ code: 'it', name: 'Italian', file: 'it' },
{ code: 'nl', name: 'Dutch', file: 'nl' },
{ code: 'tr', name: 'Turkish', file: 'tr' }
];
const resolveTextTranslationLocale = (value: string) =>
{
const normalizedValue = normalizeLanguageCode(value);
if(!normalizedValue.length) return null;
const exactMatch = TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code) === normalizedValue));
if(exactMatch) return exactMatch;
const normalizedBase = normalizedValue.split('-')[0];
if(normalizedBase === 'pt') return TEXT_TRANSLATION_LOCALES.find(locale => (locale.file === 'br')) || null;
return TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code).split('-')[0] === normalizedBase)) || null;
};
const interpolateTranslationUrl = (template: string, file: string) =>
{
if(!template || !template.length) return '';
return GetConfiguration().interpolate(
template
.replace(/%locale%/gi, file)
.replace(/%timestamp%/gi, Date.now().toString()));
};
const getTextTranslationUrl = (file: string) =>
{
const configuredTranslationUrl = GetConfiguration().getValue<string>('external.texts.translation.url') || '';
if(configuredTranslationUrl.length)
{
return interpolateTranslationUrl(configuredTranslationUrl, file);
}
const externalTextUrls = GetConfiguration().getValue<string[]>('external.texts.url') || [];
const externalTextsUrl = externalTextUrls.length ? GetConfiguration().interpolate(externalTextUrls[0]) : '';
if(!externalTextsUrl.length) return `/text_translate/ExternalTexts_${ file }.json`;
const lastSlashIndex = externalTextsUrl.lastIndexOf('/');
if(lastSlashIndex === -1) return `text_translate/ExternalTexts_${ file }.json`;
const basePath = externalTextsUrl.substring(0, lastSlashIndex);
return `${ basePath }/text_translate/ExternalTexts_${ file }.json`;
};
const getFurnitureTranslationUrl = (file: string) =>
{
const configuredTranslationUrl = GetConfiguration().getValue<string>('furnidata.translation.url') || '';
if(configuredTranslationUrl.length)
{
return interpolateTranslationUrl(configuredTranslationUrl, file);
}
const furnidataUrl = GetConfiguration().interpolate(GetConfiguration().getValue<string>('furnidata.url') || '');
if(!furnidataUrl.length) return `/furniture_translate/FurnitureData_${ file }.json`;
const lastSlashIndex = furnidataUrl.lastIndexOf('/');
if(lastSlashIndex === -1) return `furniture_translate/FurnitureData_${ file }.json`;
const basePath = furnidataUrl.substring(0, lastSlashIndex);
return `${ basePath }/furniture_translate/FurnitureData_${ file }.json`;
};
const dispatchLocalizationUpdated = () =>
{
if(typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('nitro-localization-updated'));
};
const getBrowserLanguageCode = () =>
{
if(typeof navigator === 'undefined') return 'en';
return normalizeLanguageCode(navigator.language || 'en').split('-')[0] || 'en';
};
const decodeHtmlEntities = (value: string) =>
{
if(!value || (typeof window === 'undefined')) return value;
const textarea = document.createElement('textarea');
textarea.innerHTML = value;
return textarea.value;
};
const resolveSupportedLanguage = (value: string, languages: ITranslationLanguage[]) =>
{
const normalizedValue = normalizeLanguageCode(value);
if(!languages.length) return normalizedValue || 'en';
const exactMatch = languages.find(language => (normalizeLanguageCode(language.code) === normalizedValue));
if(exactMatch) return exactMatch.code;
const normalizedBase = normalizedValue.split('-')[0];
const baseMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase));
if(baseMatch) return baseMatch.code;
const englishMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === 'en'));
if(englishMatch) return englishMatch.code;
return languages[0].code;
};
const useTranslationState = () =>
{
const defaultTargetLanguage = getBrowserLanguageCode();
const [ settings, setSettings ] = useLocalStorage<ITranslationSettings>(LocalStorageKeys.CHAT_TRANSLATION_SETTINGS, {
enabled: false,
incomingTargetLanguage: defaultTargetLanguage,
outgoingTargetLanguage: defaultTargetLanguage,
uiTextLanguage: ''
});
const [ supportedLanguages, setSupportedLanguages ] = useState<ITranslationLanguage[]>([]);
const [ availableTextLocales ] = useState<ITranslationTextLocale[]>(TEXT_TRANSLATION_LOCALES);
const [ languagesLoading, setLanguagesLoading ] = useState(false);
const [ languagesLoaded, setLanguagesLoaded ] = useState(false);
const [ localizationTextsLoading, setLocalizationTextsLoading ] = useState(false);
const [ lastIncomingLanguage, setLastIncomingLanguage ] = useState('');
const [ lastOutgoingLanguage, setLastOutgoingLanguage ] = useState('');
const [ lastError, setLastError ] = useState('');
const requestIdRef = useRef(0);
const languagesTimeoutRef = useRef(0);
const pendingRequestsRef = useRef(new Map<number, IPendingTranslationRequest>());
const translationCacheRef = useRef(new Map<string, IResolvedTranslation>());
const outgoingQueueRef = useRef(new Map<string, IQueuedOutgoingTranslation[]>());
const localizationRequestRef = useRef(0);
const clearLanguagesTimeout = useCallback(() =>
{
if(!languagesTimeoutRef.current) return;
window.clearTimeout(languagesTimeoutRef.current);
languagesTimeoutRef.current = 0;
}, []);
const pruneOutgoingQueue = useCallback(() =>
{
const now = Date.now();
outgoingQueueRef.current.forEach((entries, key) =>
{
const activeEntries = entries.filter(entry => (entry.expiresAt > now));
if(activeEntries.length)
{
outgoingQueueRef.current.set(key, activeEntries);
return;
}
outgoingQueueRef.current.delete(key);
});
}, []);
const updateSettings = useCallback((partial: Partial<ITranslationSettings>) =>
{
setSettings(prevValue => ({ ...prevValue, ...partial }));
}, [ setSettings ]);
const getLanguageName = useCallback((languageCode: string) =>
{
const normalizedLanguageCode = normalizeLanguageCode(languageCode);
if(!normalizedLanguageCode.length) return 'auto';
const exactMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code) === normalizedLanguageCode));
if(exactMatch) return exactMatch.name;
const normalizedBase = normalizedLanguageCode.split('-')[0];
const baseMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase));
return baseMatch?.name || normalizedLanguageCode;
}, [ supportedLanguages ]);
const handleLanguagesEvent = useCallback((event: TranslationLanguagesEvent) =>
{
const parser = event.getParser();
clearLanguagesTimeout();
setLanguagesLoading(false);
if(!parser.success)
{
setLanguagesLoaded(false);
setLastError(parser.errorMessage || 'Unable to load Google Translate languages.');
return;
}
const nextLanguages = parser.languages.map(language => ({
code: normalizeLanguageCode(language.code),
name: language.name
}));
setSupportedLanguages(nextLanguages);
setLanguagesLoaded(true);
setLastError('');
}, [ clearLanguagesTimeout ]);
const handleTranslationResult = useCallback((event: TranslationResultEvent) =>
{
const parser = event.getParser();
const pendingRequest = pendingRequestsRef.current.get(parser.requestId);
if(!pendingRequest) return;
window.clearTimeout(pendingRequest.timeoutId);
pendingRequestsRef.current.delete(parser.requestId);
if(!parser.success)
{
pendingRequest.reject(new Error(parser.errorMessage || 'Unable to translate text.'));
return;
}
pendingRequest.resolve({
originalText: decodeHtmlEntities(parser.originalText || ''),
translatedText: decodeHtmlEntities(parser.translatedText || ''),
detectedLanguage: normalizeLanguageCode(parser.detectedLanguage || ''),
targetLanguage: normalizeLanguageCode(parser.targetLanguage || '')
});
}, []);
useMessageEvent<TranslationLanguagesEvent>(TranslationLanguagesEvent, handleLanguagesEvent);
useMessageEvent<TranslationResultEvent>(TranslationResultEvent, handleTranslationResult);
const ensureSupportedLanguagesLoaded = useCallback((force: boolean = false) =>
{
if(languagesLoading) return;
if(languagesLoaded && !force) return;
setLanguagesLoading(true);
setLastError('');
clearLanguagesTimeout();
languagesTimeoutRef.current = window.setTimeout(() =>
{
setLanguagesLoading(false);
setLastError('Google Translate did not respond while loading languages.');
}, REQUEST_TIMEOUT_MS);
SendMessageComposer(new TranslationLanguagesRequestComposer(getBrowserLanguageCode()));
}, [ clearLanguagesTimeout, languagesLoaded, languagesLoading ]);
const translateText = useCallback((text: string, targetLanguage: string) =>
{
const safeText = (text || '');
const normalizedTargetLanguage = normalizeLanguageCode(targetLanguage || defaultTargetLanguage) || defaultTargetLanguage;
if(!safeText.trim().length)
{
return Promise.resolve({
originalText: safeText,
translatedText: safeText,
detectedLanguage: '',
targetLanguage: normalizedTargetLanguage
});
}
const cacheKey = `${ normalizedTargetLanguage }\u0000${ safeText }`;
const cachedValue = translationCacheRef.current.get(cacheKey);
if(cachedValue) return Promise.resolve(cachedValue);
return new Promise<IResolvedTranslation>((resolve, reject) =>
{
const requestId = ++requestIdRef.current;
const timeoutId = window.setTimeout(() =>
{
pendingRequestsRef.current.delete(requestId);
reject(new Error('Google Translate did not respond in time.'));
}, REQUEST_TIMEOUT_MS);
pendingRequestsRef.current.set(requestId, { resolve, reject, timeoutId });
SendMessageComposer(new TranslationTextRequestComposer(requestId, safeText, normalizedTargetLanguage));
}).then(result =>
{
translationCacheRef.current.set(cacheKey, result);
return result;
});
}, [ defaultTargetLanguage ]);
const translateIncoming = useCallback(async (text: string) =>
{
if(!settings.enabled) return null;
try
{
const result = await translateText(text, settings.incomingTargetLanguage || defaultTargetLanguage);
setLastIncomingLanguage(result.detectedLanguage || '');
setLastError('');
return result;
}
catch(error)
{
setLastError((error as Error)?.message || 'Unable to translate incoming text.');
return null;
}
}, [ defaultTargetLanguage, settings.enabled, settings.incomingTargetLanguage, translateText ]);
const translateOutgoing = useCallback(async (text: string) =>
{
if(!settings.enabled) return null;
try
{
const result = await translateText(text, settings.outgoingTargetLanguage || defaultTargetLanguage);
setLastOutgoingLanguage(result.detectedLanguage || '');
setLastError('');
return result;
}
catch(error)
{
setLastError((error as Error)?.message || 'Unable to translate outgoing text.');
return null;
}
}, [ defaultTargetLanguage, settings.enabled, settings.outgoingTargetLanguage, translateText ]);
const enqueueOutgoingTranslation = useCallback((translation: IResolvedTranslation) =>
{
if(!translation) return;
pruneOutgoingQueue();
const queueKey = translation.translatedText || translation.originalText;
const currentEntries = outgoingQueueRef.current.get(queueKey) || [];
currentEntries.push({
...translation,
expiresAt: (Date.now() + OUTGOING_QUEUE_TTL_MS)
});
outgoingQueueRef.current.set(queueKey, currentEntries);
setLastOutgoingLanguage(translation.detectedLanguage || '');
}, [ pruneOutgoingQueue ]);
const consumeOutgoingTranslation = useCallback((translatedText: string) =>
{
pruneOutgoingQueue();
const queueKey = translatedText || '';
const currentEntries = outgoingQueueRef.current.get(queueKey);
if(!currentEntries?.length) return null;
const entry = currentEntries.shift();
if(currentEntries.length) outgoingQueueRef.current.set(queueKey, currentEntries);
else outgoingQueueRef.current.delete(queueKey);
if(entry?.detectedLanguage) setLastOutgoingLanguage(entry.detectedLanguage);
return entry || null;
}, [ pruneOutgoingQueue ]);
useEffect(() =>
{
if(!settings.enabled) return;
ensureSupportedLanguagesLoaded();
}, [ ensureSupportedLanguagesLoaded, settings.enabled ]);
useEffect(() =>
{
if(!supportedLanguages.length) return;
const resolvedIncomingTargetLanguage = resolveSupportedLanguage(settings.incomingTargetLanguage || defaultTargetLanguage, supportedLanguages);
const resolvedOutgoingTargetLanguage = resolveSupportedLanguage(settings.outgoingTargetLanguage || defaultTargetLanguage, supportedLanguages);
if((resolvedIncomingTargetLanguage === settings.incomingTargetLanguage) && (resolvedOutgoingTargetLanguage === settings.outgoingTargetLanguage)) return;
setSettings(prevValue => ({
...prevValue,
incomingTargetLanguage: resolvedIncomingTargetLanguage,
outgoingTargetLanguage: resolvedOutgoingTargetLanguage
}));
}, [ defaultTargetLanguage, setSettings, settings.incomingTargetLanguage, settings.outgoingTargetLanguage, supportedLanguages ]);
useEffect(() =>
{
let disposed = false;
const requestId = ++localizationRequestRef.current;
const localizationManager = GetLocalizationManager();
const sessionDataManager = GetSessionDataManager();
const selectedLocale = resolveTextTranslationLocale(settings.uiTextLanguage || '');
const applyLocalizationOverride = async () =>
{
if(!selectedLocale)
{
localizationManager.clearOverrideValues();
sessionDataManager.clearFurnitureDataOverrides();
dispatchLocalizationUpdated();
if((localizationRequestRef.current === requestId) && !disposed)
{
setLocalizationTextsLoading(false);
setLastError('');
}
return;
}
if(!disposed) setLocalizationTextsLoading(true);
try
{
const textUrl = getTextTranslationUrl(selectedLocale.file);
const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file);
const response = await fetch(textUrl);
if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`);
const data = await response.json();
const overrideValues = new Map<string, string>();
Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key]));
if(disposed || (localizationRequestRef.current !== requestId)) return;
localizationManager.setOverrideValues(overrideValues);
try
{
await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl);
}
catch
{
if(disposed || (localizationRequestRef.current !== requestId)) return;
sessionDataManager.clearFurnitureDataOverrides();
}
dispatchLocalizationUpdated();
setLastError('');
}
catch(error)
{
if(disposed || (localizationRequestRef.current !== requestId)) return;
localizationManager.clearOverrideValues();
sessionDataManager.clearFurnitureDataOverrides();
dispatchLocalizationUpdated();
setLastError((error as Error)?.message || 'Unable to load translated UI texts.');
}
finally
{
if(disposed || (localizationRequestRef.current !== requestId)) return;
setLocalizationTextsLoading(false);
}
};
applyLocalizationOverride();
return () =>
{
disposed = true;
};
}, [ settings.uiTextLanguage ]);
useEffect(() =>
{
return () =>
{
clearLanguagesTimeout();
pendingRequestsRef.current.forEach(pendingRequest => window.clearTimeout(pendingRequest.timeoutId));
pendingRequestsRef.current.clear();
outgoingQueueRef.current.clear();
};
}, [ clearLanguagesTimeout ]);
return {
settings,
supportedLanguages,
availableTextLocales,
languagesLoading,
languagesLoaded,
localizationTextsLoading,
lastIncomingLanguage,
lastOutgoingLanguage,
lastError,
updateSettings,
ensureSupportedLanguagesLoaded,
translateIncoming,
translateOutgoing,
enqueueOutgoingTranslation,
consumeOutgoingTranslation,
getLanguageName
};
};
export const useTranslation = () => useBetween(useTranslationState);