mirror of
https://github.com/duckietm/Nitro-V3.git
synced 2026-06-20 07:26:19 +00:00
WIP preserve local changes before duckie merge
This commit is contained in:
+130
-14
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export * from './useInventoryBadges';
|
||||
export * from './useInventoryBots';
|
||||
export * from './useInventoryFurni';
|
||||
export * from './useInventoryNickIcons';
|
||||
export * from './useInventoryPets';
|
||||
export * from './useInventoryPrefixes';
|
||||
export * from './useInventoryTrade';
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './useTranslation';
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user