diff --git a/public/messenger-current-component.html b/public/messenger-current-component.html new file mode 100644 index 0000000..be8380d --- /dev/null +++ b/public/messenger-current-component.html @@ -0,0 +1,437 @@ + + + + + + Nitro Current Messenger Mockup + + + +
+
+ Le tue chat aperte (2) +
+
+ +
+
+
+
Messenger
+
+
+
1
+
+
+ Jarchy +
+
Jarchy
+
+
+ +
+
+
+ ,Homy +
+
,Homy
+
+
+
+
+ +
+
Tu + Jarchy
+ +
+
+ + + +
+ +
+ +
+
+
+ Jarchy +
+
+
+
Jarchy
+
dddove sei?
+
+
7 ore fa
+
+
+ +
+
+
+
Tu
+
su
+
slogga
+
vieni li
+
+
6 ore fa
+
+
+ Tu +
+
+ +
+
+ Jarchy +
+
+
+
Jarchy
+
arrivo
+
+
6 ore fa
+
+
+
+ +
+ + +
+
+
+
+
+ + diff --git a/public/nitro_messenger_v2.html b/public/nitro_messenger_v2.html new file mode 100644 index 0000000..81c3155 --- /dev/null +++ b/public/nitro_messenger_v2.html @@ -0,0 +1,116 @@ + + +
+
+
+ Le tue chat aperte (7) +
+
+ +
+
+ Jarchy + 1 +
+
+ ,Homy +
+
+ u3 +
+
+ u4 +
+
+ u5 +
+
+ u6 +
+
+ u7 +
+
+ +
+ Tu + Jarchy +
+ + + + +
+
+ +
+
+
Jarchy
+
+
Jarchy:
+
dddove sei?
+
7 ore fa
+
+
+ +
+
Tu
+
+
,Homy:
+
su
slogga
vieni li
+
7 ore fa
+
+
+ +
+
Jarchy
+
+
Jarchy:
+
arrivo
+
7 ore fa
+
+
+
+ +
+ + +
+
+
diff --git a/public/renderer-config.json b/public/renderer-config.json index b6657aa..42f4e11 100644 --- a/public/renderer-config.json +++ b/public/renderer-config.json @@ -10,13 +10,15 @@ "${gamedata.url}/ExternalTexts.json", "${gamedata.url}/UITexts.json" ], + "external.texts.translation.url": "${gamedata.url}/text_translate/ExternalTexts_%locale%.json?t=%timestamp%", "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3", - "furnidata.url": "${gamedata.url}/FurnitureData.json?v=2", - "productdata.url": "${gamedata.url}/ProductData.json?v=2", - "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?v=2", - "avatar.figuredata.url": "${gamedata.url}/FigureData.json?v=2", - "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?v=2", - "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?v=2", + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%", + "furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%", + "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%", + "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%", + "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%", + "avatar.figuremap.url": "${gamedata.url}/FigureMap.json?t=%timestamp%", + "avatar.effectmap.url": "${gamedata.url}/EffectMap.json?t=%timestamp%", "avatar.asset.url": "${asset.url}/figure/%libname%.nitro", "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", "furni.asset.url": "${asset.url}/furniture/%libname%.nitro", @@ -584,4 +586,4 @@ "${images.url}/clear_icon.png", "${images.url}/big_arrow.png" ] -} \ No newline at end of file +} diff --git a/public/text_translate/README.txt b/public/text_translate/README.txt new file mode 100644 index 0000000..4c24749 --- /dev/null +++ b/public/text_translate/README.txt @@ -0,0 +1,13 @@ +Place localized external text files here, for example: + +- `ExternalTexts_br.json` +- `ExternalTexts_com.json` +- `ExternalTexts_de.json` +- `ExternalTexts_es.json` +- `ExternalTexts_fi.json` +- `ExternalTexts_fr.json` +- `ExternalTexts_it.json` +- `ExternalTexts_nl.json` +- `ExternalTexts_tr.json` + +The client loads them from `/text_translate/` when selected in the Google Translate panel. diff --git a/src/api/catalog/Offer.ts b/src/api/catalog/Offer.ts index 9182c03..0f84b00 100644 --- a/src/api/catalog/Offer.ts +++ b/src/api/catalog/Offer.ts @@ -148,6 +148,10 @@ export class Offer implements IPurchasableOffer public get localizationName(): string { + const furnitureProduct = this.product; + + if(furnitureProduct?.furnitureData?.name?.length) return furnitureProduct.furnitureData.name; + const productData = GetProductDataForLocalization(this._localizationId); if(productData) return productData.name; @@ -157,6 +161,10 @@ export class Offer implements IPurchasableOffer public get localizationDescription(): string { + const furnitureProduct = this.product; + + if(furnitureProduct?.furnitureData?.description?.length) return furnitureProduct.furnitureData.description; + const productData = GetProductDataForLocalization(this._localizationId); if(productData) return productData.description; diff --git a/src/api/chat-history/IChatEntry.ts b/src/api/chat-history/IChatEntry.ts index 1bf7a52..b6be72d 100644 --- a/src/api/chat-history/IChatEntry.ts +++ b/src/api/chat-history/IChatEntry.ts @@ -11,6 +11,11 @@ export interface IChatEntry chatType?: number; imageUrl?: string; color?: string; + showTranslation?: boolean; + originalMessage?: string; + translatedMessage?: string; + detectedLanguage?: string; + targetLanguage?: string; roomId: number; timestamp: string; type: number; diff --git a/src/api/friends/MessengerThread.ts b/src/api/friends/MessengerThread.ts index 5309f36..a8cdb3c 100644 --- a/src/api/friends/MessengerThread.ts +++ b/src/api/friends/MessengerThread.ts @@ -48,6 +48,18 @@ export class MessengerThread return chat; } + public getChat(chatId: number): MessengerThreadChat + { + for(const group of this._groups) + { + const chat = group.chats.find(existingChat => (existingChat.id === chatId)); + + if(chat) return chat; + } + + return null; + } + private pruneChats(): void { let totalChats = this._groups.reduce((total, current) => (total + current.chats.length), 0); diff --git a/src/api/friends/MessengerThreadChat.ts b/src/api/friends/MessengerThreadChat.ts index 2927fec..5e37167 100644 --- a/src/api/friends/MessengerThreadChat.ts +++ b/src/api/friends/MessengerThreadChat.ts @@ -4,22 +4,49 @@ export class MessengerThreadChat public static ROOM_INVITE: number = 1; public static STATUS_NOTIFICATION: number = 2; public static SECURITY_NOTIFICATION: number = 3; + private static CHAT_ID: number = 0; + private _id: number; private _type: number; private _senderId: number; private _message: string; private _secondsSinceSent: number; private _extraData: string; private _date: Date; + private _showTranslation: boolean; + private _originalMessage: string; + private _translatedMessage: string; + private _detectedLanguage: string; + private _targetLanguage: string; constructor(senderId: number, message: string, secondsSinceSent: number = 0, extraData: string = null, type: number = 0) { + this._id = ++MessengerThreadChat.CHAT_ID; this._type = type; this._senderId = senderId; this._message = message; this._secondsSinceSent = secondsSinceSent; this._extraData = extraData; this._date = new Date(); + this._showTranslation = false; + this._originalMessage = message; + this._translatedMessage = ''; + this._detectedLanguage = ''; + this._targetLanguage = ''; + } + + public setTranslation(originalMessage: string, translatedMessage: string, detectedLanguage: string, targetLanguage: string): void + { + this._showTranslation = true; + this._originalMessage = originalMessage || this._message || ''; + this._translatedMessage = translatedMessage || this._originalMessage; + this._detectedLanguage = detectedLanguage || ''; + this._targetLanguage = targetLanguage || ''; + } + + public get id(): number + { + return this._id; } public get type(): number @@ -51,4 +78,29 @@ export class MessengerThreadChat { return this._date; } + + public get showTranslation(): boolean + { + return this._showTranslation; + } + + public get originalMessage(): string + { + return this._originalMessage; + } + + public get translatedMessage(): string + { + return this._translatedMessage; + } + + public get detectedLanguage(): string + { + return this._detectedLanguage; + } + + public get targetLanguage(): string + { + return this._targetLanguage; + } } diff --git a/src/api/inventory/GroupItem.ts b/src/api/inventory/GroupItem.ts index 8569321..9c14d6c 100644 --- a/src/api/inventory/GroupItem.ts +++ b/src/api/inventory/GroupItem.ts @@ -65,6 +65,12 @@ export class GroupItem this.setDescription(); } + public refreshLocalization(): void + { + this.setName(); + this.setDescription(); + } + public dispose(): void { diff --git a/src/api/inventory/INickIconItem.ts b/src/api/inventory/INickIconItem.ts new file mode 100644 index 0000000..ead1c34 --- /dev/null +++ b/src/api/inventory/INickIconItem.ts @@ -0,0 +1,10 @@ +export interface INickIconItem +{ + id: number; + iconKey: string; + displayName: string; + points: number; + pointsType: number; + owned: boolean; + active: boolean; +} diff --git a/src/api/inventory/IPrefixItem.ts b/src/api/inventory/IPrefixItem.ts index b65981f..c04d8bb 100644 --- a/src/api/inventory/IPrefixItem.ts +++ b/src/api/inventory/IPrefixItem.ts @@ -1,9 +1,15 @@ export interface IPrefixItem { id: number; + displayName?: string; text: string; color: string; icon: string; effect: string; + font?: string; active: boolean; + isCustom?: boolean; + points?: number; + pointsType?: number; + catalogPrefixId?: number; } diff --git a/src/api/inventory/index.ts b/src/api/inventory/index.ts index 4e6ca21..bde1a41 100644 --- a/src/api/inventory/index.ts +++ b/src/api/inventory/index.ts @@ -4,6 +4,7 @@ export * from './FurnitureUtilities'; export * from './GroupItem'; export * from './IBotItem'; export * from './IFurnitureItem'; +export * from './INickIconItem'; export * from './IPetItem'; export * from './IPrefixItem'; export * from './IUnseenItemTracker'; diff --git a/src/api/room/widgets/AvatarInfoUser.ts b/src/api/room/widgets/AvatarInfoUser.ts index fa3fc1a..6c2b3e8 100644 --- a/src/api/room/widgets/AvatarInfoUser.ts +++ b/src/api/room/widgets/AvatarInfoUser.ts @@ -12,6 +12,13 @@ export class AvatarInfoUser implements IAvatarInfo public name: string = ''; public motto: string = ''; + public nickIcon: string = ''; + public prefixText: string = ''; + public prefixColor: string = ''; + public prefixIcon: string = ''; + public prefixEffect: string = ''; + public prefixFont: string = ''; + public displayOrder: string = 'icon-prefix-name'; public achievementScore: number = 0; public backgroundId: number = 0; public standId: number = 0; diff --git a/src/api/room/widgets/AvatarInfoUtilities.ts b/src/api/room/widgets/AvatarInfoUtilities.ts index 0def77a..96403bf 100644 --- a/src/api/room/widgets/AvatarInfoUtilities.ts +++ b/src/api/room/widgets/AvatarInfoUtilities.ts @@ -32,17 +32,16 @@ export class AvatarInfoUtilities else { let furniData: IFurnitureData = null; - - const typeId = roomObject.model.getValue(RoomObjectVariable.FURNITURE_TYPE_ID); + const className = roomObject.type; if(category === RoomObjectCategory.FLOOR) { - furniData = GetSessionDataManager().getFloorItemData(typeId); + furniData = GetSessionDataManager().getFloorItemDataByName(className); } else if(category === RoomObjectCategory.WALL) { - furniData = GetSessionDataManager().getWallItemData(typeId); + furniData = GetSessionDataManager().getWallItemDataByName(className); } if(!furniData) break; @@ -102,18 +101,17 @@ export class AvatarInfoUtilities } else { - const typeId = model.getValue(RoomObjectVariable.FURNITURE_TYPE_ID); - let furnitureData: IFurnitureData = null; + const className = roomObject.type; if(category === RoomObjectCategory.FLOOR) { - furnitureData = GetSessionDataManager().getFloorItemData(typeId); + furnitureData = GetSessionDataManager().getFloorItemDataByName(className); } else if(category === RoomObjectCategory.WALL) { - furnitureData = GetSessionDataManager().getWallItemData(typeId); + furnitureData = GetSessionDataManager().getWallItemDataByName(className); } if(furnitureData) @@ -183,6 +181,13 @@ export class AvatarInfoUtilities userInfo.isSpectatorMode = roomSession.isSpectator; userInfo.name = userData.name; userInfo.motto = userData.custom; + userInfo.nickIcon = userData.nickIcon; + userInfo.prefixText = userData.prefixText; + userInfo.prefixColor = userData.prefixColor; + userInfo.prefixIcon = userData.prefixIcon; + userInfo.prefixEffect = userData.prefixEffect; + userInfo.prefixFont = userData.prefixFont; + userInfo.displayOrder = userData.displayOrder; userInfo.backgroundId = userData.background; userInfo.standId = userData.stand; userInfo.overlayId = userData.overlay; diff --git a/src/api/room/widgets/ChatBubbleMessage.ts b/src/api/room/widgets/ChatBubbleMessage.ts index 3fc6719..2ff145d 100644 --- a/src/api/room/widgets/ChatBubbleMessage.ts +++ b/src/api/room/widgets/ChatBubbleMessage.ts @@ -11,6 +11,16 @@ export class ChatBubbleMessage public prefixColor: string = ''; public prefixIcon: string = ''; public prefixEffect: string = ''; + public prefixFont: string = ''; + public nickIcon: string = ''; + public displayOrder: string = 'icon-prefix-name'; + public originalText: string = ''; + public originalFormattedText: string = ''; + public translatedText: string = ''; + public translatedFormattedText: string = ''; + public showTranslation: boolean = false; + public translationDetectedLanguage: string = ''; + public translationTargetLanguage: string = ''; private _top: number = 0; private _left: number = 0; @@ -30,6 +40,8 @@ export class ChatBubbleMessage ) { this.id = ++ChatBubbleMessage.BUBBLE_COUNTER; + this.originalText = text; + this.originalFormattedText = formattedText; } public get top(): number diff --git a/src/api/utils/LocalStorageKeys.ts b/src/api/utils/LocalStorageKeys.ts index dd74db5..847f3aa 100644 --- a/src/api/utils/LocalStorageKeys.ts +++ b/src/api/utils/LocalStorageKeys.ts @@ -3,4 +3,5 @@ export class LocalStorageKeys public static CATALOG_PLACE_MULTIPLE_OBJECTS: string = 'catalogPlaceMultipleObjects'; public static CATALOG_SKIP_PURCHASE_CONFIRMATION: string = 'catalogSkipPurchaseConfirmation'; public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; + public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings'; } diff --git a/src/api/utils/PrefixUtils.ts b/src/api/utils/PrefixUtils.ts index 5da5133..b57bc3f 100644 --- a/src/api/utils/PrefixUtils.ts +++ b/src/api/utils/PrefixUtils.ts @@ -1,11 +1,41 @@ -export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[] = [ - { id: '', label: 'None', icon: '—' }, - { id: 'glow', label: 'Glow', icon: '✨' }, - { id: 'shadow', label: 'Shadow', icon: '🌑' }, - { id: 'italic', label: 'Italic', icon: '𝑰' }, - { id: 'outline', label: 'Outline', icon: '🔲' }, - { id: 'pulse', label: 'Pulse', icon: '💫' }, - { id: 'bold-glow', label: 'Neon', icon: '💡' }, +export type PrefixFontTier = 'basic' | 'premium'; +export type PrefixFontOption = { + id: string; + label: string; + family: string; + tier: PrefixFontTier; +}; + +export const PRESET_PREFIX_FONTS: PrefixFontOption[] = [ + { id: '', label: 'Default', family: 'Ubuntu, sans-serif', tier: 'basic' }, + { id: 'pixel', label: 'Pixelify Sans', family: '"Pixelify Sans", cursive', tier: 'premium' }, + { id: 'cherry', label: 'Cherry Bomb One', family: '"Cherry Bomb One", cursive', tier: 'premium' }, + { id: 'vampiro', label: 'Vampiro One', family: '"Vampiro One", cursive', tier: 'premium' } +]; + +export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string; tier: 'basic' | 'premium' }[] = [ + { id: '', label: 'None', icon: '-', tier: 'basic' }, + { id: 'glow', label: 'Glow', icon: '*', tier: 'basic' }, + { id: 'shadow', label: 'Shadow', icon: 'S', tier: 'basic' }, + { id: 'italic', label: 'Italic', icon: 'I', tier: 'basic' }, + { id: 'outline', label: 'Outline', icon: 'O', tier: 'basic' }, + { id: 'underline', label: 'Underline', icon: 'U', tier: 'basic' }, + { id: 'pulse', label: 'Pulse', icon: 'P', tier: 'basic' }, + { id: 'bounce', label: 'Bounce', icon: 'B', tier: 'basic' }, + { id: 'wave', label: 'Wave', icon: 'W', tier: 'basic' }, + { id: 'shake', label: 'Shake', icon: '!', tier: 'basic' }, + { id: 'discord-neon', label: 'Discord Neon', icon: 'D', tier: 'premium' }, + { id: 'cartoon', label: 'Cartoon', icon: 'C', tier: 'premium' }, + { id: 'toon', label: 'Toon', icon: 'T', tier: 'premium' }, + { id: 'pop', label: 'Pop', icon: 'P+', tier: 'premium' }, + { id: 'bold-glow', label: 'Neon', icon: 'N', tier: 'premium' }, + { id: 'rainbow', label: 'Rainbow', icon: 'R', tier: 'premium' }, + { id: 'frost', label: 'Frost', icon: 'F', tier: 'premium' }, + { id: 'gold', label: 'Gold Shine', icon: 'G', tier: 'premium' }, + { id: 'glitch', label: 'Glitch', icon: 'X', tier: 'premium' }, + { id: 'fire', label: 'Fire', icon: 'H', tier: 'premium' }, + { id: 'matrix', label: 'Matrix', icon: 'M', tier: 'premium' }, + { id: 'sparkle', label: 'Sparkle', icon: '+', tier: 'premium' } ]; export const parsePrefixColors = (text: string, colorStr: string): string[] => @@ -16,6 +46,15 @@ export const parsePrefixColors = (text: string, colorStr: string): string[] => return [ ...text ].map((_, i) => colors[Math.min(i, colors.length - 1)]); }; +export const getPrefixFontStyle = (font: string): Record => +{ + const option = PRESET_PREFIX_FONTS.find(entry => entry.id === font); + + if(!option || !option.id.length) return {}; + + return { fontFamily: option.family }; +}; + export const getPrefixEffectStyle = (effect: string, color?: string): Record => { const baseColor = color || '#FFFFFF'; @@ -33,13 +72,95 @@ export const getPrefixEffectStyle = (effect: string, color?: string): Record): void + { + for(const object of objects) + { + WiredSelectionVisualizer.applySelectionShader( + WiredSelectionVisualizer.getRoomObjectByCategory(object.objectId, object.category), + WiredSelectionVisualizer._variableHighlightShader); + } + } + + public static clearVariableHighlightFromObjects(objects: Array<{ category: number; objectId: number; }>): void + { + for(const object of objects) + { + WiredSelectionVisualizer.clearSelectionShader( + WiredSelectionVisualizer.getRoomObjectByCategory(object.objectId, object.category), + WiredSelectionVisualizer._variableHighlightShader); } } @@ -89,6 +118,13 @@ export class WiredSelectionVisualizer return roomEngine.getRoomObject(roomEngine.activeRoomId, objectId, RoomObjectCategory.FLOOR); } + private static getRoomObjectByCategory(objectId: number, category: number): IRoomObject + { + const roomEngine = GetRoomEngine(); + + return roomEngine.getRoomObject(roomEngine.activeRoomId, objectId, category); + } + private static applySelectionShader(roomObject: IRoomObject, filter: WiredFilter): void { if(!roomObject) return; diff --git a/src/assets/images/user_custom/nick_icons/1.gif b/src/assets/images/user_custom/nick_icons/1.gif new file mode 100644 index 0000000..76663d8 Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/1.gif differ diff --git a/src/assets/images/user_custom/nick_icons/2.gif b/src/assets/images/user_custom/nick_icons/2.gif new file mode 100644 index 0000000..1d5a924 Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/2.gif differ diff --git a/src/assets/images/user_custom/nick_icons/3.gif b/src/assets/images/user_custom/nick_icons/3.gif new file mode 100644 index 0000000..57a8bdf Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/3.gif differ diff --git a/src/assets/images/user_custom/nick_icons/4.gif b/src/assets/images/user_custom/nick_icons/4.gif new file mode 100644 index 0000000..bff0af1 Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/4.gif differ diff --git a/src/assets/images/user_custom/nick_icons/5.gif b/src/assets/images/user_custom/nick_icons/5.gif new file mode 100644 index 0000000..f5feefa Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/5.gif differ diff --git a/src/assets/images/user_custom/nick_icons/6.gif b/src/assets/images/user_custom/nick_icons/6.gif new file mode 100644 index 0000000..3a1d07d Binary files /dev/null and b/src/assets/images/user_custom/nick_icons/6.gif differ diff --git a/src/assets/images/user_custom/nick_icons/index.ts b/src/assets/images/user_custom/nick_icons/index.ts new file mode 100644 index 0000000..5881930 --- /dev/null +++ b/src/assets/images/user_custom/nick_icons/index.ts @@ -0,0 +1,19 @@ +const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }) as Record; + +export const NICK_ICON_URLS: Record = Object.entries(rawNickIcons).reduce((accumulator, [ path, url ]) => +{ + const filename = path.split('/').pop() || ''; + const stem = filename.replace(/\.gif$/i, ''); + + if(stem) accumulator[stem] = url; + if(filename) accumulator[filename] = url; + + return accumulator; +}, {} as Record); + +export const GetNickIconUrl = (iconKey: string) => +{ + if(!iconKey) return ''; + + return (NICK_ICON_URLS[iconKey] || NICK_ICON_URLS[iconKey.toLowerCase()] || ''); +}; diff --git a/src/common/UserIdentityView.tsx b/src/common/UserIdentityView.tsx new file mode 100644 index 0000000..fac5e9a --- /dev/null +++ b/src/common/UserIdentityView.tsx @@ -0,0 +1,102 @@ +import { FC, useMemo } from 'react'; +import { GetNickIconUrl } from '../assets/images/user_custom/nick_icons'; +import { PREFIX_EFFECT_KEYFRAMES, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../api'; + +interface UserIdentityViewProps +{ + username: string; + nickIcon?: string; + prefixText?: string; + prefixColor?: string; + prefixIcon?: string; + prefixEffect?: string; + prefixFont?: string; + displayOrder?: string; + showColon?: boolean; + className?: string; + iconClassName?: string; + nameClassName?: string; + prefixClassName?: string; +} + +const sanitizeDisplayOrder = (displayOrder?: string) => +{ + const fallback = [ 'icon', 'prefix', 'name' ]; + + if(!displayOrder?.length) return fallback; + + const parts = displayOrder.toLowerCase().split('-'); + + if(parts.length !== 3) return fallback; + + const unique = new Set(parts); + + if(unique.size !== 3) return fallback; + + if(parts.some(part => !fallback.includes(part))) return fallback; + + return parts; +}; + +export const UserIdentityView: FC = ({ + username = '', + nickIcon = '', + prefixText = '', + prefixColor = '', + prefixIcon = '', + prefixEffect = '', + prefixFont = '', + displayOrder = 'icon-prefix-name', + showColon = false, + className = '', + iconClassName = 'inline-block w-auto h-auto align-[-1px]', + nameClassName = 'username font-bold', + prefixClassName = '' +}) => +{ + const nickIconUrl = GetNickIconUrl(nickIcon); + const prefixColors = useMemo(() => parsePrefixColors(prefixText, prefixColor), [ prefixText, prefixColor ]); + const hasMultiColor = (prefixColors.length > 1) && (new Set(prefixColors).size > 1); + const prefixStyle = getPrefixEffectStyle(prefixEffect, prefixColors[0] || '#FFFFFF'); + const prefixFontStyle = getPrefixFontStyle(prefixFont); + const displayParts = sanitizeDisplayOrder(displayOrder); + + const parts = displayParts.map(part => + { + switch(part) + { + case 'icon': + if(!nickIconUrl) return null; + + return ; + case 'prefix': + if(!prefixText?.length) return null; + + return ( + + { prefixIcon && { prefixIcon } } + + {'{'} + { hasMultiColor + ? [ ...prefixText ].map((char, index) => ( + { char } + )) + : prefixText } + {'}'} + + + ); + case 'name': + return { username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }; + default: + return null; + } + }).filter(Boolean); + + return ( + + { !!prefixEffect && } + { parts } + + ); +}; diff --git a/src/common/index.ts b/src/common/index.ts index d8c47ae..6802959 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -11,6 +11,7 @@ export * from './GridContext'; export * from './HorizontalRule'; export * from './InfiniteScroll'; export * from './Text'; +export * from './UserIdentityView'; export * from './card'; export * from './card/accordion'; export * from './card/tabs'; diff --git a/src/components/MainView.tsx b/src/components/MainView.tsx index 799654f..02a4044 100644 --- a/src/components/MainView.tsx +++ b/src/components/MainView.tsx @@ -8,6 +8,7 @@ import { CameraWidgetView } from './camera/CameraWidgetView'; import { CampaignView } from './campaign/CampaignView'; import { CatalogView } from './catalog/CatalogView'; import { ChatHistoryView } from './chat-history/ChatHistoryView'; +import { CustomizeNickIconView } from './customize/CustomizeNickIconView'; import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView'; import { FurniEditorView } from './furni-editor/FurniEditorView'; import { FriendsView } from './friends/FriendsView'; @@ -27,6 +28,8 @@ import { ExternalPluginLoader } from './plugins/ExternalPluginLoader'; import { RightSideView } from './right-side/RightSideView'; import { RoomView } from './room/RoomView'; import { ToolbarView } from './toolbar/ToolbarView'; +import { TranslationBootstrap } from './translation/TranslationBootstrap'; +import { TranslationSettingsView } from './translation/TranslationSettingsView'; import { UserProfileView } from './user-profile/UserProfileView'; import { UserSettingsView } from './user-settings/UserSettingsView'; import { WiredView } from './wired/WiredView'; @@ -37,6 +40,7 @@ export const MainView: FC<{}> = props => { const [ isReady, setIsReady ] = useState(false); const [ landingViewVisible, setLandingViewVisible ] = useState(true); + const [ localizationVersion, setLocalizationVersion ] = useState(0); useNitroEvent(RoomSessionEvent.CREATED, event => setLandingViewVisible(false)); useNitroEvent(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView)); @@ -86,8 +90,18 @@ export const MainView: FC<{}> = props => return () => RemoveLinkEventTracker(linkTracker); }, []); + useEffect(() => + { + const refreshLocalization = () => setLocalizationVersion(value => (value + 1)); + + window.addEventListener('nitro-localization-updated', refreshLocalization); + + return () => window.removeEventListener('nitro-localization-updated', refreshLocalization); + }, []); + return ( <> +
{ landingViewVisible && = props => } + + @@ -112,6 +128,7 @@ export const MainView: FC<{}> = props => + diff --git a/src/components/catalog/CatalogView.tsx b/src/components/catalog/CatalogView.tsx index 9e4650f..a7ea8f1 100644 --- a/src/components/catalog/CatalogView.tsx +++ b/src/components/catalog/CatalogView.tsx @@ -1,13 +1,25 @@ import { FC } from 'react'; import { GetConfigurationValue } from '../../api'; +import { useCatalog } from '../../hooks'; import { CatalogClassicView } from './CatalogClassicView'; import { CatalogModernView } from './CatalogModernView'; export const CatalogView: FC<{}> = () => { + const { catalogLocalizationVersion = 0 } = useCatalog(); const useNewStyle = GetConfigurationValue('catalog.style.new', false); - if(useNewStyle) return ; + if(useNewStyle) return ( + <> +
+ + + ); - return ; + return ( + <> +
+ + + ); }; diff --git a/src/components/catalog/views/page/common/CatalogSearchView.tsx b/src/components/catalog/views/page/common/CatalogSearchView.tsx index 295abe3..e2c63cd 100644 --- a/src/components/catalog/views/page/common/CatalogSearchView.tsx +++ b/src/components/catalog/views/page/common/CatalogSearchView.tsx @@ -1,17 +1,24 @@ import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FaSearch, FaTimes } from 'react-icons/fa'; -import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, GetOfferNodes, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; +import { CatalogPage, CatalogType, FilterCatalogNode, FurnitureOffer, ICatalogNode, ICatalogPage, IPurchasableOffer, LocalizeText, PageLocalization, SearchResult } from '../../../../../api'; import { useCatalog } from '../../../../../hooks'; export const CatalogSearchView: FC<{}> = () => { const [ searchValue, setSearchValue ] = useState(''); - const { currentType = null, rootNode = null, offersToNodes = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog(); + const { currentType = null, rootNode = null, searchResult = null, setSearchResult = null, setCurrentPage = null } = useCatalog(); + + const normalizeSearchText = (value: string) => (value || '') + .toLocaleLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/\s+/g, ' ') + .trim(); useEffect(() => { - let search = searchValue?.toLocaleLowerCase().replace(' ', ''); + const search = normalizeSearchText(searchValue); if(!search || !search.length) { @@ -22,7 +29,7 @@ export const CatalogSearchView: FC<{}> = () => const timeout = setTimeout(() => { - if(!offersToNodes || !rootNode) return; + if(!rootNode) return; const furnitureDatas = GetSessionDataManager().getAllFurnitureData(); @@ -39,34 +46,35 @@ export const CatalogSearchView: FC<{}> = () => if((currentType === CatalogType.NORMAL) && furniture.excludeDynamic) continue; - const searchValues = [ furniture.className || '', furniture.name || '', furniture.description || '' ].join(' ').replace(/ /gi, '').toLowerCase(); + const name = normalizeSearchText(furniture.name || ''); + const matchesSearch = name.includes(search); if((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1)) { if((furniture.furniLine !== '') && (foundFurniLines.indexOf(furniture.furniLine) < 0)) { - if(searchValues.indexOf(search) >= 0) foundFurniLines.push(furniture.furniLine); + if(matchesSearch) foundFurniLines.push(furniture.furniLine); } } - else + else if(matchesSearch) { - const foundNodes = [ - ...GetOfferNodes(offersToNodes, furniture.purchaseOfferId), - ...GetOfferNodes(offersToNodes, furniture.rentOfferId) - ]; + foundFurniture.push(furniture); - if(foundNodes.length) + if(furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0)) { - if(searchValues.indexOf(search) >= 0) foundFurniture.push(furniture); - - if(foundFurniture.length === 250) break; + foundFurniLines.push(furniture.furniLine); } + + if(foundFurniture.length === 250) break; } } const offers: IPurchasableOffer[] = []; - for(const furniture of foundFurniture) offers.push(new FurnitureOffer(furniture)); + for(const furniture of foundFurniture) + { + offers.push(new FurnitureOffer(furniture)); + } let nodes: ICatalogNode[] = []; @@ -77,7 +85,7 @@ export const CatalogSearchView: FC<{}> = () => }, 300); return () => clearTimeout(timeout); - }, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]); + }, [ currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]); return (
diff --git a/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx b/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx index 6554a64..e875791 100644 --- a/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx +++ b/src/components/catalog/views/page/widgets/CatalogItemGridWidgetView.tsx @@ -1,5 +1,5 @@ import { FC, useCallback, useEffect, useRef, useState } from 'react'; -import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api'; +import { IPurchasableOffer } from '../../../../../api'; import { AutoGrid, AutoGridProps } from '../../../../../common'; import { useCatalog } from '../../../../../hooks'; import { useCatalogAdmin } from '../../../CatalogAdminContext'; @@ -13,7 +13,7 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps export const CatalogItemGridWidgetView: FC = props => { const { columnCount = 5, children = null, ...rest } = props; - const { currentOffer = null, setCurrentOffer = null, currentPage = null, setPurchaseOptions = null } = useCatalog(); + const { currentOffer = null, currentPage = null, selectCatalogOffer = null } = useCatalog(); const catalogAdmin = useCatalogAdmin(); const adminMode = catalogAdmin?.adminMode ?? false; const elementRef = useRef(); @@ -29,23 +29,7 @@ export const CatalogItemGridWidgetView: FC = pro const selectOffer = (offer: IPurchasableOffer) => { - offer.activate(); - - if(offer.isLazy) return; - - setCurrentOffer(offer); - - if(offer.product && (offer.product.productType === ProductTypeEnum.WALL)) - { - setPurchaseOptions(prevValue => - { - const newValue = { ...prevValue }; - - newValue.extraData = (offer.product.extraParam || null); - - return newValue; - }); - } + selectCatalogOffer(offer); }; const handleDragStart = useCallback((index: number) => diff --git a/src/components/customize/CustomizeNickIconView.tsx b/src/components/customize/CustomizeNickIconView.tsx new file mode 100644 index 0000000..d91f0fa --- /dev/null +++ b/src/components/customize/CustomizeNickIconView.tsx @@ -0,0 +1,584 @@ +import { AddLinkEventTracker, ILinkEventTracker, PurchaseCatalogPrefixComposer, PurchaseNickIconComposer, PurchasePrefixComposer, RemoveLinkEventTracker, RequestNickIconsComposer, SetActiveNickIconComposer, SetActivePrefixComposer, SetDisplayOrderComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer'; +import data from '@emoji-mart/data'; +import Picker from '@emoji-mart/react'; +import { FC, useEffect, useMemo, useState } from 'react'; +import { INickIconItem, IPrefixItem, PRESET_PREFIX_EFFECTS, PRESET_PREFIX_FONTS, SendMessageComposer, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../../api'; +import { GetNickIconUrl } from '../../assets/images/user_custom/nick_icons'; +import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text, UserIdentityView } from '../../common'; +import { LayoutCurrencyIcon } from '../../common/layout/LayoutCurrencyIcon'; +import { useMessageEvent } from '../../hooks'; + +type CustomizeTab = 'icons' | 'prefix' | 'settings'; +type PrefixSubTab = 'library' | 'custom'; + +interface ICatalogPrefixItem extends IPrefixItem +{ + points: number; + pointsType: number; + owned: boolean; + ownedPrefixId: number; +} + +interface ICombinedPrefixItem extends IPrefixItem +{ + points: number; + pointsType: number; + owned: boolean; + ownedPrefixId: number; +} + +const ORDER_LABELS: Record = { + 'icon-prefix-name': 'Icon / Prefix / Name', + 'prefix-icon-name': 'Prefix / Icon / Name', + 'name-icon-prefix': 'Name / Icon / Prefix', + 'name-prefix-icon': 'Name / Prefix / Icon', + 'icon-name-prefix': 'Icon / Name / Prefix', + 'prefix-name-icon': 'Prefix / Name / Icon' +}; + +const PRESET_COLORS: string[] = [ + '#D62828', '#E85D04', '#F77F00', '#2A9D8F', + '#0077B6', '#4361EE', '#6A4C93', '#C1121F', + '#B5179E', '#3A86FF', '#3F8E00', '#8D5524' +]; + +export const CustomizeNickIconView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const [ activeTab, setActiveTab ] = useState('icons'); + const [ activePrefixSubTab, setActivePrefixSubTab ] = useState('library'); + const [ iconItems, setIconItems ] = useState([]); + const [ prefixItems, setPrefixItems ] = useState([]); + const [ catalogPrefixes, setCatalogPrefixes ] = useState([]); + const [ displayOrder, setDisplayOrder ] = useState('icon-prefix-name'); + const [ customPrefixMaxLength, setCustomPrefixMaxLength ] = useState(15); + const [ customPrefixPriceCredits, setCustomPrefixPriceCredits ] = useState(0); + const [ customPrefixPricePoints, setCustomPrefixPricePoints ] = useState(0); + const [ customPrefixPointsType, setCustomPrefixPointsType ] = useState(0); + const [ customPrefixFontPriceCredits, setCustomPrefixFontPriceCredits ] = useState(0); + const [ customPrefixFontPricePoints, setCustomPrefixFontPricePoints ] = useState(0); + const [ customPrefixFontPointsType, setCustomPrefixFontPointsType ] = useState(0); + const [ customPrefixText, setCustomPrefixText ] = useState(''); + const [ customPrefixColor, setCustomPrefixColor ] = useState('#FFFFFF'); + const [ customPrefixIcon, setCustomPrefixIcon ] = useState(''); + const [ customPrefixEffect, setCustomPrefixEffect ] = useState(''); + const [ customPrefixFont, setCustomPrefixFont ] = useState(''); + const [ showEmojiPicker, setShowEmojiPicker ] = useState(false); + + useMessageEvent(UserNickIconsEvent, event => + { + const parser = event.getParser(); + + setIconItems(parser.nickIcons.map(icon => ({ + id: icon.id, + iconKey: icon.iconKey, + displayName: icon.displayName, + points: icon.points, + pointsType: icon.pointsType, + owned: icon.owned, + active: icon.active + }))); + setPrefixItems(parser.ownedPrefixes.map(prefix => ({ + id: prefix.id, + displayName: prefix.displayName, + text: prefix.text, + color: prefix.color, + icon: prefix.icon || '', + effect: prefix.effect || '', + font: prefix.font || '', + active: prefix.active, + isCustom: prefix.isCustom, + points: prefix.points, + pointsType: prefix.pointsType, + catalogPrefixId: prefix.catalogPrefixId + }))); + setCatalogPrefixes(parser.prefixCatalog.map(prefix => ({ + id: prefix.id, + displayName: prefix.displayName, + text: prefix.text, + color: prefix.color, + icon: prefix.icon || '', + effect: prefix.effect || '', + font: prefix.font || '', + active: prefix.active, + points: prefix.points, + pointsType: prefix.pointsType, + owned: prefix.owned, + ownedPrefixId: prefix.ownedPrefixId + }))); + setDisplayOrder(parser.displayOrder || 'icon-prefix-name'); + setCustomPrefixMaxLength(parser.customPrefixMaxLength || 15); + setCustomPrefixPriceCredits(parser.customPrefixPriceCredits || 0); + setCustomPrefixPricePoints(parser.customPrefixPricePoints || 0); + setCustomPrefixPointsType(parser.customPrefixPointsType || 0); + setCustomPrefixFontPriceCredits(parser.customPrefixFontPriceCredits || 0); + setCustomPrefixFontPricePoints(parser.customPrefixFontPricePoints || 0); + setCustomPrefixFontPointsType(parser.customPrefixFontPointsType || 0); + setIsLoading(false); + }); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(previousValue => !previousValue); + return; + } + }, + eventUrlPrefix: 'customize/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(!isVisible) return; + + setIsLoading(true); + SendMessageComposer(new RequestNickIconsComposer()); + }, [ isVisible ]); + + const activeIcon = useMemo(() => iconItems.find(item => item.active) || null, [ iconItems ]); + const activePrefix = useMemo(() => prefixItems.find(item => item.active) || null, [ prefixItems ]); + const combinedPrefixes = useMemo(() => + { + const ownedByCatalogId = new Map(); + + for(const prefix of prefixItems) + { + if(prefix.catalogPrefixId && (prefix.catalogPrefixId > 0)) ownedByCatalogId.set(prefix.catalogPrefixId, prefix); + } + + const catalogEntries: ICombinedPrefixItem[] = catalogPrefixes.map(prefix => + { + const ownedPrefix = ownedByCatalogId.get(prefix.id); + + return { + id: ownedPrefix?.id || prefix.id, + displayName: ownedPrefix?.displayName || prefix.displayName, + text: ownedPrefix?.text || prefix.text, + color: ownedPrefix?.color || prefix.color, + icon: ownedPrefix?.icon || prefix.icon, + effect: ownedPrefix?.effect || prefix.effect, + font: ownedPrefix?.font || prefix.font, + active: ownedPrefix?.active || prefix.active, + isCustom: false, + points: prefix.points, + pointsType: prefix.pointsType, + catalogPrefixId: prefix.id, + owned: prefix.owned || !!ownedPrefix, + ownedPrefixId: prefix.ownedPrefixId || ownedPrefix?.id || 0 + }; + }); + + const customEntries: ICombinedPrefixItem[] = prefixItems + .filter(prefix => !prefix.catalogPrefixId || (prefix.catalogPrefixId <= 0)) + .map(prefix => ({ + id: prefix.id, + displayName: prefix.displayName, + text: prefix.text, + color: prefix.color, + icon: prefix.icon, + effect: prefix.effect, + font: prefix.font || '', + active: prefix.active, + isCustom: true, + points: prefix.points || customPrefixPricePoints, + pointsType: prefix.pointsType || customPrefixPointsType, + catalogPrefixId: 0, + owned: true, + ownedPrefixId: prefix.id + })); + + return [ ...catalogEntries, ...customEntries ]; + }, [ catalogPrefixes, customPrefixPointsType, customPrefixPricePoints, prefixItems ]); + const selectedEffectOption = useMemo(() => PRESET_PREFIX_EFFECTS.find(effect => effect.id === customPrefixEffect) || PRESET_PREFIX_EFFECTS[0], [ customPrefixEffect ]); + const selectedFontOption = useMemo(() => PRESET_PREFIX_FONTS.find(font => font.id === customPrefixFont) || PRESET_PREFIX_FONTS[0], [ customPrefixFont ]); + const basicEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'basic'), []); + const premiumEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'premium'), []); + const basicFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'basic'), []); + const premiumFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'premium'), []); + const prefixPreviewColors = useMemo(() => parsePrefixColors(customPrefixText || 'Preview', customPrefixColor || '#FFFFFF'), [ customPrefixText, customPrefixColor ]); + const customPrefixPreviewStyle = useMemo(() => getPrefixEffectStyle(customPrefixEffect, prefixPreviewColors[0] || '#FFFFFF'), [ customPrefixEffect, prefixPreviewColors ]); + const customPrefixFontStyle = useMemo(() => getPrefixFontStyle(customPrefixFont), [ customPrefixFont ]); + const customPrefixTotalCredits = useMemo(() => customPrefixPriceCredits + (customPrefixFont ? customPrefixFontPriceCredits : 0), [ customPrefixFont, customPrefixFontPriceCredits, customPrefixPriceCredits ]); + const customPrefixTotalPoints = useMemo(() => customPrefixPricePoints + ((customPrefixFont && (customPrefixFontPointsType === customPrefixPointsType)) ? customPrefixFontPricePoints : 0), [ customPrefixFont, customPrefixFontPointsType, customPrefixFontPricePoints, customPrefixPointsType, customPrefixPricePoints ]); + const customPrefixIsValid = useMemo(() => + { + const trimmed = customPrefixText.trim(); + + if(!trimmed.length || (trimmed.length > customPrefixMaxLength)) return false; + + return customPrefixColor.split(',').every(color => /^#[0-9A-Fa-f]{6}$/.test(color)); + }, [ customPrefixColor, customPrefixMaxLength, customPrefixText ]); + + const refreshCustomizeData = () => + { + setIsLoading(true); + SendMessageComposer(new RequestNickIconsComposer()); + }; + + const handleIconAction = (item: INickIconItem) => + { + setIsLoading(true); + + if(!item.owned) + { + SendMessageComposer(new PurchaseNickIconComposer(item.iconKey)); + return; + } + + SendMessageComposer(new SetActiveNickIconComposer(item.active ? 0 : item.id)); + }; + + const handleCombinedPrefixAction = (item: ICombinedPrefixItem) => + { + setIsLoading(true); + + if(item.owned) + { + SendMessageComposer(new SetActivePrefixComposer(item.active ? 0 : item.ownedPrefixId)); + return; + } + + SendMessageComposer(new PurchaseCatalogPrefixComposer(item.catalogPrefixId || item.id)); + }; + + const handleCustomPrefixPurchase = () => + { + if(!customPrefixIsValid) return; + + setIsLoading(true); + SendMessageComposer(new PurchasePrefixComposer(customPrefixText.trim(), customPrefixColor, customPrefixIcon, customPrefixEffect, customPrefixFont)); + }; + + const handleDisplayOrderChange = (nextDisplayOrder: string) => + { + if(nextDisplayOrder === displayOrder) return; + + setDisplayOrder(nextDisplayOrder); + setIsLoading(true); + SendMessageComposer(new SetDisplayOrderComposer(nextDisplayOrder)); + }; + + if(!isVisible) return null; + + return ( + + setIsVisible(false) } /> + + setActiveTab('icons') }> + Icons + + setActiveTab('prefix') }> + Prefix + + setActiveTab('settings') }> + Settings + + + +
+ Live preview +
+ +
+
+ + { activeTab === 'icons' && + <> +
+ Choose the icon shown in your bubble identity. +
+
+ { iconItems.map(item => + { + const iconUrl = GetNickIconUrl(item.iconKey); + + return ( +
+ { item.active && Active } + { +
+ { item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' } + { item.displayName || `Icon #${ item.iconKey }` } + + + { item.points } + +
+ +
+ ); + }) } +
+ } + + { activeTab === 'prefix' && +
+
+
+ + +
+
+ + { activePrefixSubTab === 'library' && + <> +
+ Choose a preset or custom prefix for your bubble identity. +
+
+ { combinedPrefixes.map(item => ( +
+ { item.active && Active } + +
+ { item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' } + { item.displayName || item.text }{ item.isCustom ? ' - Custom' : '' } + + + { item.points } + +
+ +
+ )) } +
+ } + + { activePrefixSubTab === 'custom' && +
+
+ Custom prefix + +
+
+
+ setCustomPrefixText(event.target.value) } /> + { customPrefixText.length }/{ customPrefixMaxLength } +
+
+ + { !!customPrefixIcon && } +
+
+
+ Safe colors only, chosen to stay readable on both light and dark backgrounds. +
+
+ { PRESET_COLORS.map(color => ( + + )) } +
+
+
+
+ Effect +
+
+ +
+ { selectedEffectOption.icon } { selectedEffectOption.label } +
+ { selectedEffectOption.tier } +
+
+
+
+
+
+ Font +
+
+ +
+ { selectedFontOption.label } +
+ { selectedFontOption.tier } +
+
+
+ { !!customPrefixFont && +
+ Premium fonts add an extra price on top of the custom prefix. +
} +
+
+ +
+
+
+ { customPrefixTotalCredits > 0 && { customPrefixTotalCredits } credits } + { customPrefixTotalPoints > 0 && + + + { customPrefixTotalPoints } + } + { !!customPrefixFont && (customPrefixFontPointsType !== customPrefixPointsType) && (customPrefixFontPricePoints > 0) && + + + { customPrefixFontPricePoints } + } +
+ +
+
+
} +
} + + { activeTab === 'settings' && +
+
+ Display order +
+ { Object.entries(ORDER_LABELS).map(([ key, label ]) => ( + + )) } +
+
+
+
+ Refresh data + +
+
+ Use this tab to control how your icon, prefix and username are ordered in bubbles, profile and infostand. +
+
+
} +
+ { showEmojiPicker && + <> +
setShowEmojiPicker(false) } /> +
+ { setCustomPrefixIcon(emoji.native); setShowEmojiPicker(false); } } + previewPosition="none" + set="native" + theme="dark" /> +
+ } + + ); +}; + diff --git a/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx b/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx index 3237277..b2a6edf 100644 --- a/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx +++ b/src/components/friends/views/friends-list/FriendsListRemoveConfirmationView.tsx @@ -13,13 +13,19 @@ interface FriendsRemoveConfirmationViewProps export const FriendsRemoveConfirmationView: FC = props => { const { selectedFriendsIds = null, removeFriendsText = null, removeSelectedFriends = null, onCloseClick = null } = props; + const separatorIndex = removeFriendsText.indexOf(':'); + const removeFriendsLeadText = (separatorIndex >= 0) ? removeFriendsText.substring(0, separatorIndex + 1) : removeFriendsText; + const removeFriendsNamesText = (separatorIndex >= 0) ? removeFriendsText.substring(separatorIndex + 1).trimStart() : ''; return ( - + - -
{ removeFriendsText }
-
+ +
+
{ removeFriendsLeadText }
+ { removeFriendsNamesText.length > 0 &&
{ removeFriendsNamesText }
} +
+
diff --git a/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx b/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx index ad6a94a..cdff603 100644 --- a/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx +++ b/src/components/friends/views/friends-list/FriendsListRoomInviteView.tsx @@ -15,13 +15,13 @@ export const FriendsRoomInviteView: FC = props => const [ roomInviteMessage, setRoomInviteMessage ] = useState(''); return ( - + - - { LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) } - - { LocalizeText('friendlist.invite.note') } -
+ + { LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) } + + { LocalizeText('friendlist.invite.note') } +
diff --git a/src/components/friends/views/friends-list/FriendsListSearchView.tsx b/src/components/friends/views/friends-list/FriendsListSearchView.tsx index fd53481..c9a15b8 100644 --- a/src/components/friends/views/friends-list/FriendsListSearchView.tsx +++ b/src/components/friends/views/friends-list/FriendsListSearchView.tsx @@ -1,8 +1,10 @@ import { HabboSearchComposer, HabboSearchResultData, HabboSearchResultEvent } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { LocalizeText, OpenMessengerChat, SendMessageComposer } from '../../../../api'; -import { Column, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common'; +import { Column, LayoutAvatarImageView, NitroCardAccordionItemView, NitroCardAccordionSetView, NitroCardAccordionSetViewProps, Text, UserProfileIconView } from '../../../../common'; import { useFriends, useMessageEvent } from '../../../../hooks'; +import { resolveAvatarFigure } from './resolveAvatarFigure'; +import { resolveAvatarGender } from './resolveAvatarGender'; interface FriendsSearchViewProps extends NitroCardAccordionSetViewProps { @@ -17,6 +19,22 @@ export const FriendsSearchView: FC = props => const [ otherResults, setOtherResults ] = useState(null); const { canRequestFriend = null, requestFriend = null } = useFriends(); + const getSearchResultFigure = (result: HabboSearchResultData) => + { + if(!result) return null; + + const typedResult = (result as HabboSearchResultData & { figureString?: string; avatarFigure?: string; figure?: string; avatarFigureString?: string }); + + return typedResult.figureString || typedResult.avatarFigure || typedResult.figure || typedResult.avatarFigureString || null; + }; + + const getSearchResultGender = (result: HabboSearchResultData) => + { + const typedResult = (result as HabboSearchResultData & { gender?: string | number; avatarGender?: string | number }); + + return resolveAvatarGender(typedResult.avatarGender ?? typedResult.gender); + }; + useMessageEvent(HabboSearchResultEvent, event => { const parser = event.getParser(); @@ -55,10 +73,15 @@ export const FriendsSearchView: FC = props => { friendResults.map(result => { return ( - -
- -
{ result.avatarName }
+ +
+
+ +
+
+ +
+
{ result.avatarName }
{ result.isAvatarOnline && @@ -82,10 +105,15 @@ export const FriendsSearchView: FC = props => { otherResults.map(result => { return ( - -
- -
{ result.avatarName }
+ +
+
+ +
+
+ +
+
{ result.avatarName }
{ canRequestFriend(result.avatarId) && diff --git a/src/components/friends/views/friends-list/FriendsListView.tsx b/src/components/friends/views/friends-list/FriendsListView.tsx index ef30f23..b9ad497 100644 --- a/src/components/friends/views/friends-list/FriendsListView.tsx +++ b/src/components/friends/views/friends-list/FriendsListView.tsx @@ -34,7 +34,7 @@ export const FriendsListView: FC<{}> = props => userNames.push(existingFriend.name); } - return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join(', ') ]); + return LocalizeText('friendlist.removefriendconfirm.userlist', [ 'user_names' ], [ userNames.join('\n') ]); }, [ offlineFriends, onlineFriends, selectedFriendsIds ]); const selectFriend = useCallback((userId: number) => @@ -60,6 +60,27 @@ export const FriendsListView: FC<{}> = props => }); }, [ setSelectedFriendsIds ]); + const toggleSelectFriends = useCallback((friendIds: number[]) => + { + if(!friendIds.length) return; + + setSelectedFriendsIds(prevValue => + { + const allSelected = friendIds.every(friendId => (prevValue.indexOf(friendId) >= 0)); + + if(allSelected) return prevValue.filter(friendId => (friendIds.indexOf(friendId) === -1)); + + const nextValue = [ ...prevValue ]; + + for(const friendId of friendIds) + { + if(nextValue.indexOf(friendId) === -1) nextValue.push(friendId); + } + + return nextValue; + }); + }, []); + const sendRoomInvite = (message: string) => { if(!selectedFriendsIds.length || !message || !message.length || (message.length > 255)) return; @@ -125,10 +146,24 @@ export const FriendsListView: FC<{}> = props => setIsVisible(false) } /> - + + + { event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); } }> + { onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + ? LocalizeText('friendlist.unselect_all') + : LocalizeText('friendlist.select_all') } + + + + { event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); } }> + { offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0)) + ? LocalizeText('friendlist.unselect_all') + : LocalizeText('friendlist.select_all') } + + diff --git a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx index 0b75b86..dc23a3b 100644 --- a/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx +++ b/src/components/friends/views/friends-list/friends-list-group/FriendsListGroupItemView.tsx @@ -1,7 +1,9 @@ import { FC, MouseEvent, useState } from 'react'; import { LocalizeText, MessengerFriend, OpenMessengerChat } from '../../../../../api'; -import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; +import { LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { useFriends } from '../../../../../hooks'; +import { resolveAvatarFigure } from '../resolveAvatarFigure'; +import { resolveAvatarGender } from '../resolveAvatarGender'; export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: boolean, selectFriend: (userId: number) => void }> = props => { @@ -55,14 +57,17 @@ export const FriendsListGroupItemView: FC<{ friend: MessengerFriend, selected: b if(!friend) return null; return ( - selectFriend(friend.id) }> -
+ selectFriend(friend.id) }> +
+
+ +
event.stopPropagation() }>
-
{ friend.name }
+
{ friend.name }
-
+
{ !isRelationshipOpen && <> { friend.online && diff --git a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx index c06840e..e3ff05b 100644 --- a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx +++ b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestItemView.tsx @@ -1,7 +1,9 @@ import { FC } from 'react'; -import { MessengerRequest } from '../../../../../api'; -import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; +import { LocalizeText, MessengerRequest } from '../../../../../api'; +import { Button, LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { useFriends } from '../../../../../hooks'; +import { resolveAvatarFigure } from '../resolveAvatarFigure'; +import { resolveAvatarGender } from '../resolveAvatarGender'; export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = props => { @@ -11,14 +13,23 @@ export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = pro if(!request) return null; return ( - -
- -
{ request.name }
+ +
+
+ +
+
+ +
+
{ request.name }
-
requestResponse(request.id, true) } /> -
requestResponse(request.id, false) } /> + +
); diff --git a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx index 686b32d..eb002c0 100644 --- a/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx +++ b/src/components/friends/views/friends-list/friends-list-request/FriendsListRequestView.tsx @@ -17,8 +17,11 @@ export const FriendsListRequestView: FC = props { requests.map((request, index) => ) } -
- +
diff --git a/src/components/friends/views/friends-list/resolveAvatarFigure.ts b/src/components/friends/views/friends-list/resolveAvatarFigure.ts new file mode 100644 index 0000000..98079b1 --- /dev/null +++ b/src/components/friends/views/friends-list/resolveAvatarFigure.ts @@ -0,0 +1,15 @@ +import { resolveAvatarGender } from './resolveAvatarGender'; + +const DEFAULT_AVATAR_FIGURES: Record = { + M: 'hd-180-1.ch-210-66.lg-270-82.sh-290-80', + F: 'hd-600-1.ch-630-66.lg-695-82.sh-725-80' +}; + +export const resolveAvatarFigure = (figure: string | null | undefined, gender?: string | number | null) => +{ + const normalizedFigure = (figure || '').trim(); + + if(normalizedFigure.length && normalizedFigure.includes('hd-')) return normalizedFigure; + + return DEFAULT_AVATAR_FIGURES[resolveAvatarGender(gender)] || DEFAULT_AVATAR_FIGURES.M; +}; diff --git a/src/components/friends/views/friends-list/resolveAvatarGender.ts b/src/components/friends/views/friends-list/resolveAvatarGender.ts new file mode 100644 index 0000000..730e1fe --- /dev/null +++ b/src/components/friends/views/friends-list/resolveAvatarGender.ts @@ -0,0 +1,20 @@ +export const resolveAvatarGender = (value: string | number | null | undefined) => +{ + if(typeof value === 'string') + { + const normalized = value.trim().toUpperCase(); + + if(normalized === 'F') return 'F'; + if(normalized === 'M') return 'M'; + if(normalized === 'FEMALE') return 'F'; + if(normalized === 'MALE') return 'M'; + } + + if(typeof value === 'number') + { + if(value === 2) return 'F'; + if(value === 1) return 'M'; + } + + return 'M'; +}; diff --git a/src/components/friends/views/messenger/FriendsMessengerView.tsx b/src/components/friends/views/messenger/FriendsMessengerView.tsx index bbb5222..6b3e761 100644 --- a/src/components/friends/views/messenger/FriendsMessengerView.tsx +++ b/src/components/friends/views/messenger/FriendsMessengerView.tsx @@ -2,9 +2,8 @@ import { AddLinkEventTracker, FollowFriendMessageComposer, GetSessionDataManager import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; import { FaTimes } from 'react-icons/fa'; import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api'; -import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutGridItem, LayoutItemCountView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; -import { useHelp, useMessenger } from '../../../../hooks'; -import { NitroInput } from '../../../../layout'; +import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; +import { useHelp, useMessenger, useTranslation } from '../../../../hooks'; import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView'; export const FriendsMessengerView: FC<{}> = props => @@ -14,15 +13,35 @@ export const FriendsMessengerView: FC<{}> = props => const [ messageText, setMessageText ] = useState(''); const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger(); const { report = null } = useHelp(); + const { settings, translateOutgoing } = useTranslation(); const messagesBox = useRef(); const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id))); const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id)); - const send = () => + const send = async () => { if(!activeThread || !messageText.length) return; + const trimmedText = messageText.trimStart(); + const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':'); + + if(!shouldTranslateOutgoing) + { + sendMessage(activeThread, GetSessionDataManager().userId, messageText); + setMessageText(''); + return; + } + + const translation = await translateOutgoing(messageText); + + if(translation && translation.translatedText?.length && (translation.translatedText.length <= 255)) + { + sendMessage(activeThread, GetSessionDataManager().userId, translation.translatedText, 0, null, undefined, translation); + setMessageText(''); + return; + } + sendMessage(activeThread, GetSessionDataManager().userId, messageText); setMessageText(''); @@ -32,7 +51,7 @@ export const FriendsMessengerView: FC<{}> = props => { if(event.key !== 'Enter') return; - send(); + void send(); }; useEffect(() => @@ -107,71 +126,60 @@ export const FriendsMessengerView: FC<{}> = props => if(!isVisible) return null; return ( - + setIsVisible(false) } /> - - - - { LocalizeText('toolbar.icon.label.messenger') } - - - { visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread => - { - return ( - setActiveThreadId(thread.threadId) } className="py-1 px-2"> - { thread.unread && } - - 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure } - headOnly={ true } - direction={ thread.participant.id > 0 ? 2 : 3 } - style={{ width: '50px', height: '80px', backgroundPosition: 'center 45%', flexShrink: 0, alignSelf: 'flex-end' }} - /> - { thread.participant.name } - - - ); - }) } - - - - - { activeThread && - <> - { LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } - + +
+
+ { visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread => + { + return ( + + ); + }) } +
+ + { activeThread && + <> +
+ { LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } +
{ (activeThread.participant.id > 0) && -
-
- - -
- + + -
} - - - - - - - -
- setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> - + + } +
- } - - +
+ +
+ +
+ +
+ setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> + +
+ } +
); diff --git a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx index b9ace6c..97d74bf 100644 --- a/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx +++ b/src/components/friends/views/messenger/messenger-thread/FriendsMessengerThreadGroup.tsx @@ -28,14 +28,11 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M <> { group.chats.map((chat, index) => { + if(chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) return null; + return ( - { (chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) && - - - { chat.message } - } { (chat.type === MessengerThreadChat.ROOM_INVITE) && @@ -50,24 +47,46 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M } return ( - + { ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) && - } + } { (groupChatData && !isOwnChat) && - } + } - - - { group.chats[0].date.toLocaleTimeString() } + + { isOwnChat && GetSessionDataManager().userName } { !isOwnChat && (groupChatData ? groupChatData.username : thread.participant.name) } + : - { group.chats.map((chat, index) => { chat.message }) } + + { group.chats.map((chat, index) => + { + if(!chat.showTranslation) + { + return { chat.message }; + } + + return ( + + + original: + { chat.originalMessage || chat.message } + + + translate: + { chat.translatedMessage || chat.message } + + + ); + }) } + + { group.chats[0].date.toLocaleTimeString() } { isOwnChat && - + } ); diff --git a/src/components/inventory/views/prefix/InventoryPrefixView.tsx b/src/components/inventory/views/prefix/InventoryPrefixView.tsx index d959546..f956e13 100644 --- a/src/components/inventory/views/prefix/InventoryPrefixView.tsx +++ b/src/components/inventory/views/prefix/InventoryPrefixView.tsx @@ -1,24 +1,29 @@ -import { FC, useEffect, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { FaTrashAlt } from 'react-icons/fa'; -import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; -import { useInventoryPrefixes, useNotification } from '../../../../hooks'; +import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, getPrefixFontStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { Button } from '../../../../common'; +import { GetNickIconUrl } from '../../../../assets/images/user_custom/nick_icons'; +import { useInventoryNickIcons, useInventoryPrefixes, useNotification } from '../../../../hooks'; import { NitroButton } from '../../../../layout'; -const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', className = '', textSize = 'text-sm' }) => +type InventoryIdentityTab = 'prefixes' | 'icons'; + +const PrefixPreview: FC<{ text: string; color: string; icon: string; effect?: string; font?: string; className?: string; textSize?: string }> = ({ text, color, icon, effect = '', font = '', className = '', textSize = 'text-sm' }) => { const colors = parsePrefixColors(text, color); const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF'); + const fontStyle = getPrefixFontStyle(font); return ( - - { effect === 'pulse' && } + + { !!effect && } { icon && { icon } } - + {'{'} { hasMultiColor ? [ ...text ].map((char, i) => ( - { char } + { char } )) : text } @@ -40,7 +45,30 @@ const PrefixItemView: FC<{ ${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' } ${ prefix.active ? 'ring-2 ring-green-400' : '' }` } onClick={ onClick }> - + +
+ ); +}; + +const NickIconItemView: FC<{ + iconKey: string; + displayName: string; + isSelected: boolean; + isActive: boolean; + onClick: () => void; +}> = ({ iconKey, displayName, isSelected, isActive, onClick }) => +{ + return ( +
+ { isActive && Active } +
+ { + { displayName || iconKey } +
); }; @@ -48,8 +76,13 @@ const PrefixItemView: FC<{ export const InventoryPrefixView: FC<{}> = () => { const [ isVisible, setIsVisible ] = useState(false); + const [ activeTab, setActiveTab ] = useState('prefixes'); const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes(); + const { nickIcons = [], activeNickIcon = null, selectedNickIcon = null, setSelectedNickIcon = null, activateNickIcon = null, deactivateNickIcon = null, activate: activateNickIcons = null, deactivate: deactivateNickIcons = null } = useInventoryNickIcons(); const { showConfirm = null } = useNotification(); + const hasPrefixes = prefixes && (prefixes.length > 0); + const hasNickIcons = nickIcons && (nickIcons.length > 0); + const selectedIconUrl = useMemo(() => selectedNickIcon ? GetNickIconUrl(selectedNickIcon.iconKey) : '', [ selectedNickIcon ]); const attemptDeletePrefix = () => { @@ -69,10 +102,15 @@ export const InventoryPrefixView: FC<{}> = () => { if(!isVisible) return; - const id = activate(); + const prefixVisibilityId = activate(); + const iconVisibilityId = activateNickIcons(); - return () => deactivate(id); - }, [ isVisible, activate, deactivate ]); + return () => + { + deactivate(prefixVisibilityId); + deactivateNickIcons(iconVisibilityId); + }; + }, [ isVisible, activate, activateNickIcons, deactivate, deactivateNickIcons ]); useEffect(() => { @@ -82,55 +120,115 @@ export const InventoryPrefixView: FC<{}> = () => }, []); return ( -
-
-
- { prefixes.map(prefix => ( - setSelectedPrefix(prefix) } /> - )) } +
+
+
+ +
- { (!prefixes || prefixes.length === 0) && -
- { LocalizeText('inventory.empty.title') } -
}
-
- { activePrefix && -
- Active prefix -
- + + { activeTab === 'prefixes' && +
+
+
+ { prefixes.map(prefix => ( + setSelectedPrefix(prefix) } /> + )) }
-
} - { !activePrefix && -
- Active prefix -
- No active prefix + { !hasPrefixes && +
+ { LocalizeText('inventory.empty.title') } +
} +
+
+ { activePrefix && +
+ Active prefix +
+ +
+
} + { !activePrefix && +
+ Active prefix +
+ No active prefix +
+
} + { !!selectedPrefix && +
+
+ +
+
+ selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }> + { selectedPrefix.active ? 'Deactivate' : 'Activate' } + + { !selectedPrefix.active && + + + } +
+
} +
+
} + + { activeTab === 'icons' && +
+
+
+ { nickIcons.map(icon => ( + setSelectedNickIcon(icon) } /> + )) }
-
} - { !!selectedPrefix && -
-
- + { !hasNickIcons && +
+ No purchased icons yet +
} +
+
+
+ Active icon +
+ { activeNickIcon && { } + { !activeNickIcon && No active icon } +
-
- selectedPrefix.active ? deactivatePrefix() : activatePrefix(selectedPrefix.id) }> - { selectedPrefix.active ? 'Deactivate' : 'Activate' } - - { !selectedPrefix.active && - - - } -
-
} -
+ { !!selectedNickIcon && +
+
+ { + { selectedNickIcon.displayName || selectedNickIcon.iconKey } +
+ +
} +
+
}
); }; diff --git a/src/components/purse/PurseView.tsx b/src/components/purse/PurseView.tsx index 4f6bb8b..6bda43e 100644 --- a/src/components/purse/PurseView.tsx +++ b/src/components/purse/PurseView.tsx @@ -1,6 +1,6 @@ import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useState } from 'react'; -import { FaChevronDown, FaQuestionCircle } from 'react-icons/fa'; +import { FaChevronDown, FaLanguage, FaQuestionCircle } from 'react-icons/fa'; import { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { usePurse } from '../../hooks'; @@ -91,6 +91,9 @@ export const PurseView: FC<{}> = props => {
}
+ diff --git a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx index 12aca46..24a7f7e 100644 --- a/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx +++ b/src/components/room/widgets/avatar-info/infostand/InfoStandWidgetUserView.tsx @@ -2,7 +2,7 @@ import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusI import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react'; import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; -import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserProfileIconView } from '../../../../../common'; +import { Base, Column, Flex, LayoutAvatarImageView, LayoutBadgeImageView, Text, UserIdentityView, UserProfileIconView } from '../../../../../common'; import { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks'; import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; @@ -29,7 +29,6 @@ export const InfoStandWidgetUserView: FC = props = const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`; const infostandStandClass = `stand-${standId ?? 'default'}`; const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`; - const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]); const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []); @@ -79,6 +78,12 @@ export const InfoStandWidgetUserView: FC = props = newValue.figure = event.figure; newValue.motto = event.customInfo; newValue.achievementScore = event.activityPoints; + newValue.nickIcon = event.nickIcon; + newValue.prefixText = event.prefixText; + newValue.prefixColor = event.prefixColor; + newValue.prefixIcon = event.prefixIcon; + newValue.prefixEffect = event.prefixEffect; + newValue.displayOrder = event.displayOrder; newValue.backgroundId = event.backgroundId; newValue.standId = event.standId; newValue.overlayId = event.overlayId; @@ -139,7 +144,17 @@ export const InfoStandWidgetUserView: FC = props =
- {avatarInfo.name} +
diff --git a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx index 1dba1a9..c791599 100644 --- a/src/components/room/widgets/chat/ChatWidgetMessageView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetMessageView.tsx @@ -1,6 +1,7 @@ import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { FC, useEffect, useMemo, useRef, useState } from 'react'; -import { ChatBubbleMessage, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; +import { ChatBubbleMessage } from '../../../../api'; +import { UserIdentityView } from '../../../../common'; import { useOnClickChat } from '../../../../hooks'; interface ChatWidgetMessageViewProps @@ -38,11 +39,11 @@ export const ChatWidgetMessageView: FC = ({ useEffect(() => { - setIsVisible(false); - const element = elementRef.current; if(!element) return; + const previousWidth = chat.width; + const previousHeight = chat.height; const { offsetWidth: width, offsetHeight: height } = element; chat.width = width; @@ -62,10 +63,14 @@ export const ChatWidgetMessageView: FC = ({ setIsReady(true); + if(isVisible && ((previousWidth !== width) || (previousHeight !== height)) && makeRoom) makeRoom(chat); + }, [ chat, chat.formattedText, chat.originalFormattedText, chat.showTranslation, chat.translatedFormattedText, isVisible, makeRoom ]); + + useEffect(() => + { return () => { chat.elementRef = null; - setIsReady(false); }; }, [ chat ]); @@ -77,6 +82,8 @@ export const ChatWidgetMessageView: FC = ({ setIsVisible(true); }, [ chat, isReady, isVisible, makeRoom ]); + const messageClassName = `message [overflow-wrap:anywhere] break-words${ chat.type === 1 ? ' italic text-[#595959]' : '' }${ chat.type === 2 ? ' font-bold' : '' }`; + return (
GetRoomEngine().selectRoomObject(chat.roomId, chat.senderId, RoomObjectCategory.UNIT) }> @@ -90,29 +97,33 @@ export const ChatWidgetMessageView: FC = ({ ) }
- { chat.prefixEffect === 'pulse' && } - { chat.prefixText && (() => { - const colors = parsePrefixColors(chat.prefixText, chat.prefixColor); - const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; - const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF'); - return ( - - { chat.prefixIcon && { chat.prefixIcon } } - - {'{'} - { hasMultiColor - ? [ ...chat.prefixText ].map((char, i) => ( - { char } - )) - : chat.prefixText - } - {'}'} - - - ); - })() } - - + + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
diff --git a/src/components/room/widgets/chat/ChatWidgetWindowView.tsx b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx index 7a6118a..7734bd8 100644 --- a/src/components/room/widgets/chat/ChatWidgetWindowView.tsx +++ b/src/components/room/widgets/chat/ChatWidgetWindowView.tsx @@ -2,7 +2,7 @@ import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer'; import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ChatEntryType, LocalizeText } from '../../../../api'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; -import { useChatHistory, useChatWindow } from '../../../../hooks'; +import { useChatHistory, useChatWindow, useOnClickChat } from '../../../../hooks'; import { useRoom } from '../../../../hooks/rooms'; const BOTTOM_SCROLL_THRESHOLD = 20; @@ -19,6 +19,7 @@ export const ChatWidgetWindowView: FC<{}> = () => const { chatHistory = [], clearChatHistory = null } = useChatHistory(); const [ , setChatWindowEnabled ] = useChatWindow(); const { roomSession = null } = useRoom(); + const { onClickChat } = useOnClickChat(); const ownUserId = (GetSessionDataManager()?.userId || -1); const roomChatHistory = useMemo(() => @@ -33,7 +34,7 @@ export const ChatWidgetWindowView: FC<{}> = () => if(!normalizedSearch.length) return true; - return (`${ chat.name } ${ chat.message }`.toLowerCase().includes(normalizedSearch)); + return (`${ chat.name } ${ chat.message || '' } ${ chat.originalMessage || '' } ${ chat.translatedMessage || '' }`.toLowerCase().includes(normalizedSearch)); }); }, [ chatHistory, roomSession?.roomId, hidePets, search ]); @@ -125,14 +126,27 @@ export const ChatWidgetWindowView: FC<{}> = () => { const isOwnMessage = (chat.webId === ownUserId); const rowClassName = `mb-1 flex items-start gap-1 break-words ${ isOwnMessage ? 'justify-end' : '' }`; + const messageClassName = `message${ chat.chatType === 1 ? ' italic text-[#595959]' : '' }${ chat.chatType === 2 ? ' font-bold' : '' }`; return (
{ hideBalloons && !hideAvatars &&
} { hideBalloons && ( -
+
- + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
) } { !hideBalloons && ( @@ -148,7 +162,19 @@ export const ChatWidgetWindowView: FC<{}> = () =>
- + { !chat.showTranslation && + } + { chat.showTranslation && +
+
+ original: + +
+
+ translate: + +
+
}
diff --git a/src/components/translation/TranslationBootstrap.tsx b/src/components/translation/TranslationBootstrap.tsx new file mode 100644 index 0000000..389f566 --- /dev/null +++ b/src/components/translation/TranslationBootstrap.tsx @@ -0,0 +1,9 @@ +import { FC } from 'react'; +import { useTranslation } from '../../hooks'; + +export const TranslationBootstrap: FC<{}> = () => +{ + useTranslation(); + + return null; +}; diff --git a/src/components/translation/TranslationSettingsView.tsx b/src/components/translation/TranslationSettingsView.tsx new file mode 100644 index 0000000..efe4571 --- /dev/null +++ b/src/components/translation/TranslationSettingsView.tsx @@ -0,0 +1,138 @@ +import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer'; +import { FC, useEffect, useState } from 'react'; +import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common'; +import { useTranslation } from '../../hooks'; + +export const TranslationSettingsView: FC<{}> = () => +{ + const [ isVisible, setIsVisible ] = useState(false); + const { + settings, + supportedLanguages = [], + availableTextLocales = [], + languagesLoading = false, + localizationTextsLoading = false, + lastIncomingLanguage = '', + lastOutgoingLanguage = '', + lastError = '', + updateSettings, + ensureSupportedLanguagesLoaded, + getLanguageName + } = useTranslation(); + + useEffect(() => + { + const linkTracker: ILinkEventTracker = { + linkReceived: (url: string) => + { + const parts = url.split('/'); + + if(parts.length < 2) return; + + switch(parts[1]) + { + case 'show': + setIsVisible(true); + return; + case 'hide': + setIsVisible(false); + return; + case 'toggle': + setIsVisible(prevValue => !prevValue); + return; + } + }, + eventUrlPrefix: 'translation-settings/' + }; + + AddLinkEventTracker(linkTracker); + + return () => RemoveLinkEventTracker(linkTracker); + }, []); + + useEffect(() => + { + if(!isVisible) return; + + ensureSupportedLanguagesLoaded(); + }, [ ensureSupportedLanguagesLoaded, isVisible ]); + + if(!isVisible) return null; + + return ( + + setIsVisible(false) } /> + +
+ updateSettings({ enabled: event.target.checked }) } /> + Enable automatic translation +
+
+ When enabled, chat bubbles always show two lines: original: and translate:. +
+
+ Interface texts +
+ +
+
+
+ Incoming messages +
+ Detected language (auto): { getLanguageName(lastIncomingLanguage) } + +
+
+
+ Outgoing messages +
+ Detected writing language (auto): { getLanguageName(lastOutgoingLanguage) } + +
+
+
+ { languagesLoading ? 'Loading languages...' : `${ supportedLanguages.length } languages available` } + +
+ { localizationTextsLoading && +
+ Loading localized interface texts... +
} + { lastError.length > 0 && +
+ { lastError } +
} +
+
+ ); +}; diff --git a/src/components/user-profile/UserContainerView.tsx b/src/components/user-profile/UserContainerView.tsx index 88d3150..0425e2f 100644 --- a/src/components/user-profile/UserContainerView.tsx +++ b/src/components/user-profile/UserContainerView.tsx @@ -1,7 +1,7 @@ import { GetSessionDataManager, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer'; import { FC, useEffect, useState } from 'react'; import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api'; -import { LayoutAvatarImageView, Text } from '../../common'; +import { LayoutAvatarImageView, Text, UserIdentityView } from '../../common'; export const UserContainerView: FC<{ userProfile: UserProfileParser; @@ -18,7 +18,6 @@ export const UserContainerView: FC<{ const infostandBackgroundClass = `background-${userProfile.backgroundId ?? 'default'}`; const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`; const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`; - const addFriend = () => { setRequestSent(true); @@ -41,7 +40,16 @@ export const UserContainerView: FC<{
-

{ userProfile.username }

+

{ userProfile.motto }

@@ -115,4 +123,4 @@ export const UserContainerView: FC<{
); -}; \ No newline at end of file +}; diff --git a/src/components/wired-tools/WiredCreatorToolsView.tsx b/src/components/wired-tools/WiredCreatorToolsView.tsx index 7d1f539..b2279db 100644 --- a/src/components/wired-tools/WiredCreatorToolsView.tsx +++ b/src/components/wired-tools/WiredCreatorToolsView.tsx @@ -1,13 +1,13 @@ -import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer'; +import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, GetStage, GetTicker, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer'; import { WiredMonitorDataEvent, WiredMonitorRequestComposer } from '@nitrots/nitro-renderer'; -import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import furniInspectionIcon from '../../assets/images/wiredtools/furni.png'; import globalInspectionIcon from '../../assets/images/wiredtools/global.png'; import userInspectionIcon from '../../assets/images/wiredtools/user.png'; import contextInspectionIcon from '../../assets/images/wiredtools/context.png'; import wiredGlobalPlaceholderImage from '../../assets/images/wiredtools/wired_global_placeholder.png'; import wiredMonitorImage from '../../assets/images/wiredtools/wired_monitor.png'; -import { AvatarInfoFurni, AvatarInfoUtilities, LocalizeText, NotificationAlertType, SendMessageComposer } from '../../api'; +import { AvatarInfoFurni, AvatarInfoUtilities, GetRoomObjectBounds, GetRoomObjectScreenLocation, LocalizeText, NotificationAlertType, SendMessageComposer, WiredSelectionVisualizer } from '../../api'; import { Button, DraggableWindowPosition, LayoutAvatarImageView, LayoutPetImageView, LayoutRoomObjectImageView, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common'; import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedEvent, useRoom, useWiredTools } from '../../hooks'; import { WiredToolsSettingsTabView } from './WiredToolsSettingsTabView'; @@ -184,6 +184,21 @@ interface VariableManageEntry value: number | null; } +interface VariableHighlightTarget +{ + category: number; + hasValue: boolean; + objectId: number; + value: number | null; +} + +interface VariableHighlightOverlay extends VariableHighlightTarget +{ + key: string; + x: number; + y: number; +} + interface ManagedHolderVariableEntry { availability: string; @@ -631,6 +646,9 @@ export const WiredCreatorToolsView: FC<{}> = () => const [ isManagedGiveOpen, setIsManagedGiveOpen ] = useState(false); const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0); const [ managedGiveValue, setManagedGiveValue ] = useState('0'); + const [ isVariableHighlightActive, setIsVariableHighlightActive ] = useState(false); + const [ variableHighlightOverlays, setVariableHighlightOverlays ] = useState([]); + const variableHighlightObjectsRef = useRef>([]); const shouldPauseVariableSnapshotRefresh = (!!editingVariable || !!editingManagedHolderVariableId || isInspectionGiveOpen || isManagedGiveOpen); const [ selectedVariableKeys, setSelectedVariableKeys ] = useState>({ furni: VARIABLE_DEFINITIONS.furni[0].key, @@ -2400,6 +2418,155 @@ export const WiredCreatorToolsView: FC<{}> = () => manageLabel: 'Manage' } ]; }, [ selectedVariableDefinition, variablesType, roomSession, userVariableAssignments, furniVariableAssignments, roomVariableAssignmentMap ]); + const canVariableHighlight = !!selectedVariableDefinition?.itemId + && (selectedVariableDefinition.type === 'Custom') + && ((variablesType === 'user') || (variablesType === 'furni')) + && !!roomSession; + const variableHighlightTargets = useMemo((): VariableHighlightTarget[] => + { + if(!isVariableHighlightActive || !canVariableHighlight || !roomSession || !selectedVariableDefinition?.itemId) return []; + + if(variablesType === 'user') + { + const targets: VariableHighlightTarget[] = []; + + for(const [ userIdString, assignments ] of Object.entries(userVariableAssignments)) + { + const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId)); + + if(!assignment) continue; + + const userId = Number(userIdString); + const userData = roomSession.userDataManager.getUserData(userId) + ?? roomSession.userDataManager.getBotData(userId) + ?? roomSession.userDataManager.getRentableBotData(userId) + ?? roomSession.userDataManager.getPetData(userId); + const roomIndex = Number(userData?.roomIndex ?? -1); + + if(roomIndex < 0) continue; + + targets.push({ + category: RoomObjectCategory.UNIT, + objectId: roomIndex, + hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined), + value: assignment.value + }); + } + + return targets; + } + + if(variablesType === 'furni') + { + const targets: VariableHighlightTarget[] = []; + + for(const [ furniIdString, assignments ] of Object.entries(furniVariableAssignments)) + { + const assignment = assignments.find(entry => (entry.variableItemId === selectedVariableDefinition.itemId)); + + if(!assignment) continue; + + const furniId = Number(furniIdString); + const floorObject = GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.FLOOR); + const wallObject = floorObject ? null : GetRoomEngine().getRoomObject(roomSession.roomId, furniId, RoomObjectCategory.WALL); + const category = floorObject ? RoomObjectCategory.FLOOR : (wallObject ? RoomObjectCategory.WALL : -1); + + if(category < 0) continue; + + targets.push({ + category, + objectId: furniId, + hasValue: !!assignment.hasValue && !!selectedVariableDefinition.hasValue && (assignment.value !== null) && (assignment.value !== undefined), + value: assignment.value + }); + } + + return targets; + } + + return []; + }, [ canVariableHighlight, furniVariableAssignments, isVariableHighlightActive, roomSession, selectedVariableDefinition, userVariableAssignments, variablesType ]); + useEffect(() => + { + if(isVisible && (activeTab === 'variables') && canVariableHighlight) return; + + setIsVariableHighlightActive(false); + }, [ activeTab, canVariableHighlight, isVisible ]); + useEffect(() => + { + if(variableHighlightObjectsRef.current.length) + { + WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current); + variableHighlightObjectsRef.current = []; + } + + if(!isVariableHighlightActive || !variableHighlightTargets.length) + { + + setVariableHighlightOverlays([]); + + return; + } + + const objects = variableHighlightTargets.map(target => ({ + category: target.category, + objectId: target.objectId + })); + + WiredSelectionVisualizer.applyVariableHighlightToObjects(objects); + variableHighlightObjectsRef.current = objects; + + return () => + { + if(!variableHighlightObjectsRef.current.length) return; + + WiredSelectionVisualizer.clearVariableHighlightFromObjects(variableHighlightObjectsRef.current); + variableHighlightObjectsRef.current = []; + }; + }, [ isVariableHighlightActive, variableHighlightTargets ]); + useEffect(() => + { + if(!isVariableHighlightActive || !roomSession?.roomId || !variableHighlightTargets.length) + { + setVariableHighlightOverlays([]); + + return; + } + + const updateOverlays = () => + { + const stage = GetStage(); + const nextOverlays: VariableHighlightOverlay[] = []; + + for(const target of variableHighlightTargets) + { + const bounds = GetRoomObjectBounds(roomSession.roomId, target.objectId, target.category); + const location = GetRoomObjectScreenLocation(roomSession.roomId, target.objectId, target.category); + + if(!bounds || !location) continue; + + const x = Math.max(8, Math.min(Math.round(location.x), (stage.width - 8))); + const y = Math.max(8, Math.min(Math.round(bounds.top), (stage.height - 40))); + + nextOverlays.push({ + ...target, + key: `${ target.category }:${ target.objectId }`, + x, + y + }); + } + + setVariableHighlightOverlays(nextOverlays); + }; + + updateOverlays(); + + const ticker = GetTicker(); + + ticker.add(updateOverlays); + + return () => ticker.remove(updateOverlays); + }, [ isVariableHighlightActive, roomSession?.roomId, variableHighlightTargets ]); const variableManageTypeOptions = useMemo(() => { switch(variablesType) @@ -3465,6 +3632,27 @@ export const WiredCreatorToolsView: FC<{}> = () => return ( <> + { isVariableHighlightActive && !!variableHighlightOverlays.length && +
+ { variableHighlightOverlays.map(overlay => ( +
+ { overlay.hasValue && +
+
+ { overlay.value ?? 0 } + +
+
} +
+ )) } +
} setIsVisible(false) } /> @@ -3830,7 +4018,12 @@ export const WiredCreatorToolsView: FC<{}> = () =>
- + - -
-
- -
- { LocalizeText(activeSources[activeSourceIndex].label) } + + { SOURCE_GROUP_BUTTONS.map(button => ( + + )) }
- -
-
+ } + onChange={ value => + { + if(isUserGroup) setUserSource(value); + else setFurniSource(value); + } } /> ); }; diff --git a/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx b/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx index f84035a..8715af4 100644 --- a/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx +++ b/src/components/wired/views/extras/WiredExtraVariableTextConnectorView.tsx @@ -2,6 +2,7 @@ import { FC, useEffect, useState } from 'react'; import { LocalizeText, WiredFurniType } from '../../../../api'; import { Text } from '../../../../common'; import { useWired } from '../../../../hooks'; +import { WiredTextFormattingHelp } from '../common/WiredTextFormattingHelp'; import { WiredExtraBaseView } from './WiredExtraBaseView'; const DEFAULT_CONNECTOR_PLACEHOLDER = '0=text 1\n1=text 2\n2 = text 3'; @@ -70,6 +71,7 @@ export const WiredExtraVariableTextConnectorView: FC<{}> = () => value={ mappingsText } onChange={ event => handleTextChange(event.target.value) } /> { `${ lineCount }/${ MAX_CONNECTOR_LINES } righe - ${ characterCount }/${ MAX_CONNECTOR_CHARACTERS } caratteri` } +
); diff --git a/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx b/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx index 621151d..8a4c682 100644 --- a/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx +++ b/src/components/wired/views/triggers/WiredTriggerReceiveSignalView.tsx @@ -15,7 +15,6 @@ const normalizeFurniSource = (value: number) => (FURNI_SOURCE_OPTIONS.some(optio export const WiredTriggerReceiveSignalView: FC<{}> = () => { const [ senderCount, setSenderCount ] = useState(0); - const [ maxSenders, setMaxSenders ] = useState(5); const [ channel, setChannel ] = useState(0); const [ furniSource, setFurniSource ] = useState(100); @@ -30,7 +29,6 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () => const p = trigger.intData; if(p.length >= 1) setChannel(p[0]); if(p.length >= 2) setSenderCount(p[1]); - if(p.length >= 3) setMaxSenders(p[2]); if(p.length >= 4) setFurniSource(normalizeFurniSource(p[3])); else setFurniSource(100); }, [ trigger ]); @@ -43,7 +41,7 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () => footer={ }>
{ LocalizeText('wiredfurni.params.signal.senders_connected') } - { senderCount }/{ maxSenders } + { senderCount }
); diff --git a/src/css/friends/FriendsView.css b/src/css/friends/FriendsView.css index aad4844..2b2ea17 100644 --- a/src/css/friends/FriendsView.css +++ b/src/css/friends/FriendsView.css @@ -106,21 +106,302 @@ } .nitro-friends { - width: 250px; - height: 300px; + width: 332px; + height: 445px; + min-width: 332px; + min-height: 445px; + max-width: 332px; + max-height: calc(100vh - 16px); + resize: none !important; + font-family: Ubuntu, sans-serif; + color: #111; + + & span, + & input, + & button { + font-family: Ubuntu, sans-serif !important; + } + + & .nitro-card-title { + font-family: UbuntuCondensed, Ubuntu, sans-serif !important; + font-size: 15px !important; + } + + & .nitro-card-header { + border-bottom: 1px solid rgba(0, 0, 0, .18); + } + + & .nitro-card-content-shell { + padding: 0 !important; + gap: 0 !important; + background: #f3f3ef; + } + + & .nitro-card-accordion-set-content, + & .nitro-card-content-shell { + scrollbar-width: thin; + scrollbar-color: #6d7b84 #cdd4d8; + } + + & .nitro-card-accordion-set-content::-webkit-scrollbar, + & .nitro-card-content-shell::-webkit-scrollbar { + width: 13px; + height: 13px; + } + + & .nitro-card-accordion-set-content::-webkit-scrollbar-track, + & .nitro-card-content-shell::-webkit-scrollbar-track { + background: linear-gradient(180deg, #e1e5e8 0%, #cad1d5 100%); + border-left: 1px solid #818a8f; + } + + & .nitro-card-accordion-set-content::-webkit-scrollbar-thumb, + & .nitro-card-content-shell::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #93b6c6 0%, #688fa2 100%); + border: 1px solid #476a7a; + border-radius: 2px; + } + + & .nitro-card-accordion-set { + background: transparent; + border: 0; + } + + & .nitro-card-accordion-set-header { + min-height: 24px; + padding: 3px 7px !important; + color: #111; + font-size: 12px; + font-weight: 700; + border: 0 !important; + } + + & .nitro-card-accordion-set-header span { + font-size: 12px; + color: #111 !important; + } + + & .nitro-card-accordion-set-header .fa-icon { + width: 10px; + height: 10px; + color: #000; + } + + & .nitro-card-accordion-set.active { + min-height: 0; + border: 0 !important; + } + + & .nitro-card-accordion-set-content { + overflow-y: auto !important; + } + + & .friends-list-item { + min-height: 34px; + padding: 0 4px !important; + color: #111; + font-size: 13px; + background: #f7f7f7; + border: 0 !important; + gap: 3px !important; + } + + & .friends-list-toolbar { + min-height: 22px; + font-size: 12px; + background: #efefef; + border-bottom: 1px solid rgba(0, 0, 0, .12); + } + + & .friends-list-toolbar-link { + color: #111; + font-size: 12px; + font-weight: 700; + cursor: pointer; + text-decoration: underline; + } + + & .friends-list-item:nth-child(even) { + background: #e6e6e6; + } + + & .friends-list-item.selected { + color: #000 !important; + background: #bfe7f6 !important; + } + + & .friends-list-user { + display: flex; + align-items: center; + min-width: 0; + gap: 3px; + } + + & .friends-list-user > div:nth-child(2) { + margin-left: 3px; + } + + & .friends-list-avatar { + position: relative; + width: 22px; + height: 34px; + flex-shrink: 0; + overflow: visible; + } + + & .friends-list-avatar .avatar-image { + position: absolute; + left: 50%; + top: -24px; + width: 90px; + height: 130px; + margin: 0; + transform: translateX(-50%); + background-position: center -8px !important; + } + + & .friends-list-name { + min-width: 0; + margin-left: 3px; + overflow: hidden; + color: #111; + font-size: 13px; + line-height: 1.15; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + } + + & .friends-list-actions { + display: flex; + align-items: center; + flex-shrink: 0; + gap: 3px; + } + + & .nitro-friends-spritesheet.icon-follow { + width: 15px; + height: 14px; + } + + & .nitro-friends-spritesheet.icon-chat { + width: 17px; + height: 16px; + } & .search-input { - border: 0; - border-bottom: 1px solid rgba(0, 0, 0, 0.2); + min-height: 28px; + padding: 5px 8px; + color: #111 !important; + font-size: 13px !important; + line-height: 1.2; + background: #fff; + border: 1px solid #79858c; + border-radius: 2px; + box-shadow: inset 1px 1px 0 #ececec; + outline: none; + + &::placeholder { + color: #666; + opacity: 1; + } } } .nitro-friends-room-invite { - width: 250px; + width: 270px; + height: 225px; + min-width: 270px; + min-height: 225px; + max-width: 270px; + max-height: 225px; + resize: none !important; + + & .nitro-card-content-shell { + padding: 10px !important; + gap: 8px !important; + } +} + +.nitro-friends-room-invite-content { + height: 100%; +} + +.nitro-friends-room-invite-summary { + padding: 6px 8px; + color: #111; + font-size: 13px; + line-height: 1.3; + font-weight: 700; + background: #fff; + border: 1px solid rgba(0, 0, 0, .35); + border-radius: 8px; +} + +.nitro-friends-room-invite-textarea { + width: 100%; + height: 92px; + padding: 7px 8px; + color: #111; + font-size: 13px; + line-height: 1.3; + background: #fff; + border: 1px solid #888; + border-radius: 2px; + resize: none; + outline: none; + + &::placeholder { + color: #666; + opacity: 1; + } +} + +.nitro-friends-room-invite-note { + padding: 4px 6px; + color: #111 !important; + font-size: 12px !important; + line-height: 1.3; + background: rgba(255, 255, 255, .75); + border-radius: 8px; +} + +.nitro-friends-room-invite-actions { + display: flex; + gap: 8px; + margin-top: auto; } .nitro-friends-remove-confirmation { - width: 250px; + width: 270px; + height: 225px; + min-width: 270px; + min-height: 225px; + max-width: 270px; + max-height: 225px; + resize: none !important; +} + +.nitro-friends-remove-confirmation-text { + color: #111; + font-size: 13px; + line-height: 1.35; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.nitro-friends-remove-confirmation-content { + height: 100%; +} + +.nitro-friends-remove-confirmation-names { + margin-top: 4px; +} + +.nitro-friends-remove-confirmation-actions { + display: flex; + gap: 8px; + margin-top: auto; } .friend-bar { @@ -142,27 +423,298 @@ } } -.nitro-friends-messenger { - & .layout-grid-item { - min-height: 50px; +.messenger-card { + width: 332px; + min-width: 332px; + max-width: 332px; + height: 445px; + min-height: 445px; + max-height: 445px; + resize: none; + pointer-events: all; +} + +@media (max-width: 380px), (max-height: 470px) { + .messenger-card { + width: calc(100vw - 16px); + min-width: 0; + max-width: calc(100vw - 16px); + height: min(445px, calc(100vh - 16px)); + min-height: 0; + max-height: calc(100vh - 16px); + } +} + +.messenger-card { + & span, + & input, + & button { + box-sizing: border-box; + font-family: Ubuntu, sans-serif !important; + } + + & .nitro-card-content-shell { + padding: 0 !important; + gap: 0 !important; + background: #f3f3ef; + } + + & .nitro-card-content-shell, + & .chat-messages { + scrollbar-width: thin; + scrollbar-color: #6d7b84 #cdd4d8; + } + + & .nitro-card-content-shell::-webkit-scrollbar, + & .chat-messages::-webkit-scrollbar { + width: 13px; + height: 13px; + } + + & .nitro-card-content-shell::-webkit-scrollbar-track, + & .chat-messages::-webkit-scrollbar-track { + background: linear-gradient(180deg, #e1e5e8 0%, #cad1d5 100%); + border-left: 1px solid #818a8f; + } + + & .nitro-card-content-shell::-webkit-scrollbar-thumb, + & .chat-messages::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, #93b6c6 0%, #688fa2 100%); + border: 1px solid #476a7a; + border-radius: 2px; + } + + & .nitro-card-header { + border-bottom: 1px solid rgba(0, 0, 0, .18); + } + + & .nitro-card-title { + font-family: UbuntuCondensed, Ubuntu, sans-serif !important; + font-size: 15px !important; + } + + & .messenger-card-body { + height: 100%; + display: flex; + flex-direction: column; + } + + & .messenger-avatar-bar { + display: flex; + gap: 4px; + align-items: center; + flex-shrink: 0; + overflow: hidden; + padding: 6px 8px; + background: #efefef; + border-bottom: 1px solid rgba(0, 0, 0, .12); + scrollbar-width: none; + } + + & .messenger-avatar-tab { + position: relative; + display: flex; + align-items: center; + justify-content: center; + width: 36px; + min-width: 36px; + height: 36px; + min-height: 36px; + flex-shrink: 0; + overflow: hidden; + cursor: pointer; + background: #d7d7d7; + border: 0; + border-radius: 4px; + padding: 0; + + &.active { + background: #bfe7f6; + } + + &.unread { + background: #7dca73; + } + + & .avatar-image { + position: absolute; + left: 50% !important; + top: -31px !important; + width: 90px !important; + height: 130px !important; + margin: 0 !important; + background-position: center -8px !important; + transform: translateX(-50%) !important; + } + } + + & .messenger-thread-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + padding: 4px 8px; + background: #efefef; + border-bottom: 1px solid rgba(0, 0, 0, .12); + } + + & .messenger-thread-name { + color: #111 !important; + font-size: 13px !important; + font-weight: 700; + } + + & .messenger-actions { + display: flex; + gap: 4px; + align-items: center; + } + + & .messenger-btn { + min-height: 24px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 7px; + color: #fff !important; + font-size: 12px !important; + font-weight: 700; + line-height: 1; + cursor: pointer; + background: #2f84aa; + border: 0; + border-radius: 3px; + + &.danger { + background: #a81a12; + border-color: #a81a12; + } + + &.close-btn { + width: 22px; + padding: 0 5px; + color: #000 !important; + font-size: 13px; + background: #d7d7d7; + } + + &.icon-btn { + width: 22px; + padding: 0; + } + + &.send { + background: #00800b; + border-color: #00800b; + } } & .chat-messages { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; overflow-y: auto; + padding: 8px; + background: + linear-gradient(#f7f7f7, #f7f7f7) padding-box, + repeating-linear-gradient(180deg, #f7f7f7 0 34px, #ececec 34px 68px); + + & .messenger-message-row { + display: flex; + gap: 6px; + align-items: flex-start; + + &.own > .message-avatar:first-child { + display: none; + } + } & .message-avatar { position: relative; - overflow: hidden; - width: 50px; - height: 50px; + flex-shrink: 0; + overflow: visible; + width: 36px; + height: 36px; & .avatar-image { - position: absolute; - margin-left: -22px; - margin-top: -25px; + position: absolute; + left: 50%; + top: 56%; + width: 54px; + height: 54px; + margin: 0; + background-position: center center !important; + transform: translate(-50%, -50%) scale(.95); } } + & .messenger-message-body { + display: flex; + flex-direction: column; + gap: 2px; + max-width: 200px; + } + + & .messenger-message-name { + color: #111 !important; + font-size: 12px !important; + font-weight: 700; + } + + & .messenger-message-bubble { + position: relative; + padding: 4px 7px; + color: #111 !important; + font-size: 13px !important; + line-height: 1.35; + max-width: 200px; + overflow-wrap: anywhere; + word-break: break-word; + white-space: pre-wrap; + background: #fff; + border: 1px solid rgba(0, 0, 0, .08); + border-radius: 3px; + box-shadow: inset 1px 1px 0 rgba(255, 255, 255, .7); + } + + & .messenger-message-bubble .text-break { + overflow-wrap: anywhere; + word-break: break-word; + white-space: pre-wrap; + } + + & .messenger-translation-block { + display: flex; + flex-direction: column; + gap: 2px; + } + + & .messenger-translation-row { + display: flex; + align-items: flex-start; + gap: 4px; + line-height: 1.2; + } + + & .messenger-translation-label { + min-width: 48px; + flex-shrink: 0; + opacity: .7; + font-size: 12px; + font-weight: 700; + } + + & .messenger-message-time { + color: #666 !important; + font-size: 11px !important; + } + + & .messenger-message-row.own .messenger-message-time { + text-align: right; + } + & .messages-group-left { position: relative; @@ -171,12 +723,24 @@ content: ' '; width: 0; height: 0; - border-right: 8px solid #DFDFDF; + border-right: 8px solid #fff; border-top: 8px solid transparent; border-bottom: 8px solid transparent; top: 10px; left: -8px; } + + &:after { + position: absolute; + content: ' '; + width: 0; + height: 0; + border-right: 7px solid #fff; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + top: 11px; + left: -6px; + } } & .messages-group-right { @@ -187,12 +751,52 @@ content: ' '; width: 0; height: 0; - border-left: 8px solid #DFDFDF; + border-left: 8px solid #fff; border-top: 8px solid transparent; border-bottom: 8px solid transparent; top: 10px; right: -8px; } + + &:after { + position: absolute; + content: ' '; + width: 0; + height: 0; + border-left: 7px solid #fff; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + top: 11px; + right: -6px; + } + } + } + + & .messenger-input-row { + display: flex; + gap: 5px; + align-items: center; + flex-shrink: 0; + padding: 6px 8px; + background: #efefef; + border-top: 1px solid rgba(0, 0, 0, .12); + + & input { + flex: 1; + min-height: 28px; + padding: 0 8px; + color: #111 !important; + font-size: 13px !important; + line-height: 1.2; + background: #fff; + border: 1px solid #888; + border-radius: 3px; + outline: none; + + &::placeholder { + color: #666; + opacity: 1; + } } } } diff --git a/src/css/index.css b/src/css/index.css index dc2e809..ccf11b3 100644 --- a/src/css/index.css +++ b/src/css/index.css @@ -1,4 +1,5 @@ @import 'tailwindcss'; +@import url('https://fonts.googleapis.com/css2?family=Cherry+Bomb+One&family=Pixelify+Sans:wght@400..700&family=Vampiro+One&display=swap'); @config "../../tailwind.config.js"; @@ -22,6 +23,7 @@ body { -webkit-user-select: none; user-select: none; scrollbar-width: thin; + scrollbar-color: #6d7b84 #c8d0d4; } .image-rendering-pixelated { @@ -35,45 +37,76 @@ body { } ::-webkit-scrollbar { - width: .625rem; + width: .875rem; } ::-webkit-scrollbar:horizontal { - height: .625rem; + height: .875rem; } ::-webkit-scrollbar:not(:horizontal) { - width: .625rem; + width: .875rem; } ::-webkit-scrollbar-track { - background: rgba(0, 0, 0, .08); - border-radius: .5rem; + background: linear-gradient(180deg, #dfe5e8 0%, #c9d1d5 100%); + border-left: 1px solid #7a858b; + border-right: 1px solid #eef3f5; + border-radius: 0; } ::-webkit-scrollbar-thumb { - background: rgba(30, 114, 149, .35); - border-radius: .5rem; - border: 2px solid transparent; - background-clip: padding-box; + background: linear-gradient(180deg, #8fb5c7 0%, #5d8ea5 100%); + border: 1px solid #446879; + border-radius: 2px; + box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.28); } ::-webkit-scrollbar-thumb:hover { - background: rgba(30, 114, 149, .6); - border-radius: .5rem; - border: 2px solid transparent; - background-clip: padding-box; + background: linear-gradient(180deg, #99c2d5 0%, #689ab0 100%); } ::-webkit-scrollbar-thumb:active { - background: #185D79; - border-radius: .5rem; - border: 2px solid transparent; - background-clip: padding-box; + background: linear-gradient(180deg, #5c889d 0%, #436977 100%); } ::-webkit-scrollbar-corner { - background: rgba(0, 0, 0, .08); + background: #c9d1d5; +} + +::-webkit-scrollbar-button:single-button { + display: block; + width: .875rem; + height: .875rem; + background-color: #d8dfe3; + background-repeat: no-repeat; + background-position: center; + border-left: 1px solid #7a858b; + border-right: 1px solid #eef3f5; +} + +::-webkit-scrollbar-button:single-button:vertical:decrement { + background-image: linear-gradient(135deg, transparent 50%, #35586a 50%), linear-gradient(225deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: calc(50% - 3px) 55%, calc(50% + 3px) 55%; +} + +::-webkit-scrollbar-button:single-button:vertical:increment { + background-image: linear-gradient(315deg, transparent 50%, #35586a 50%), linear-gradient(45deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: calc(50% - 3px) 45%, calc(50% + 3px) 45%; +} + +::-webkit-scrollbar-button:single-button:horizontal:decrement { + background-image: linear-gradient(45deg, transparent 50%, #35586a 50%), linear-gradient(135deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: 58% calc(50% - 3px), 58% calc(50% + 3px); +} + +::-webkit-scrollbar-button:single-button:horizontal:increment { + background-image: linear-gradient(225deg, transparent 50%, #35586a 50%), linear-gradient(315deg, transparent 50%, #35586a 50%); + background-size: 6px 6px; + background-position: 42% calc(50% - 3px), 42% calc(50% + 3px); } @layer components { diff --git a/src/css/purse/PurseView.css b/src/css/purse/PurseView.css index 099ab99..ebbe69e 100644 --- a/src/css/purse/PurseView.css +++ b/src/css/purse/PurseView.css @@ -113,6 +113,7 @@ max-height: 280px; opacity: 1; transform: translateY(0); + background: transparent; } .nitro-purse__content.is-closed { @@ -200,6 +201,7 @@ font-weight: 700; line-height: 1; letter-spacing: 0.01em; + color: rgba(255, 255, 255, 0.88) !important; } .nitro-purse .nitro-purse-button.currency--1 .text-white { @@ -270,10 +272,11 @@ justify-content: center; min-height: 20px; padding: 0; - border: 1px solid rgba(255, 255, 255, 0.08); + border: 1px solid rgba(7, 23, 31, 0.82); border-radius: 7px; color: rgba(255, 255, 255, 0.88); background: rgba(255, 255, 255, 0.05); + box-shadow: none; transition: background-color 0.18s ease, transform 0.18s ease; } @@ -335,7 +338,7 @@ .seasonal-image { display: block; width: auto; - height: 13px; + height: 14px; object-fit: contain; } diff --git a/src/hooks/catalog/useCatalog.ts b/src/hooks/catalog/useCatalog.ts index 65af759..5b75c6b 100644 --- a/src/hooks/catalog/useCatalog.ts +++ b/src/hooks/catalog/useCatalog.ts @@ -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>(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); diff --git a/src/hooks/chat-history/useChatHistory.ts b/src/hooks/chat-history/useChatHistory.ts index 9897880..2cd021f 100644 --- a/src/hooks/chat-history/useChatHistory.ts +++ b/src/hooks/chat-history/useChatHistory.ts @@ -33,6 +33,26 @@ const useChatHistoryState = () => return newValue; }); + + return entry.id; + }; + + const updateChatEntry = (entryId: number, partial: Partial) => + { + 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); diff --git a/src/hooks/friends/useMessenger.ts b/src/hooks/friends/useMessenger.ts index d65af9e..7e54076 100644 --- a/src/hooks/friends/useMessenger.ts +++ b/src/hooks/friends/useMessenger.ts @@ -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(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, event => diff --git a/src/hooks/index.ts b/src/hooks/index.ts index d898ed2..4d753c3 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -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'; diff --git a/src/hooks/inventory/index.ts b/src/hooks/inventory/index.ts index ea39265..20f0f60 100644 --- a/src/hooks/inventory/index.ts +++ b/src/hooks/inventory/index.ts @@ -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'; diff --git a/src/hooks/inventory/useInventoryFurni.ts b/src/hooks/inventory/useInventoryFurni.ts index 7dd16ad..520b9e4 100644 --- a/src/hooks/inventory/useInventoryFurni.ts +++ b/src/hooks/inventory/useInventoryFurni.ts @@ -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 }; }; diff --git a/src/hooks/inventory/useInventoryNickIcons.ts b/src/hooks/inventory/useInventoryNickIcons.ts new file mode 100644 index 0000000..3643eed --- /dev/null +++ b/src/hooks/inventory/useInventoryNickIcons.ts @@ -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([]); + const [ activeNickIcon, setActiveNickIcon ] = useState(null); + const [ selectedNickIcon, setSelectedNickIcon ] = useState(null); + const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility(); + + useMessageEvent(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); diff --git a/src/hooks/inventory/useInventoryPrefixes.ts b/src/hooks/inventory/useInventoryPrefixes.ts index 1d761c1..81702d3 100644 --- a/src/hooks/inventory/useInventoryPrefixes.ts +++ b/src/hooks/inventory/useInventoryPrefixes.ts @@ -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, 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, 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 }; }); } }); diff --git a/src/hooks/rooms/widgets/useAvatarInfoWidget.ts b/src/hooks/rooms/widgets/useAvatarInfoWidget.ts index 0259f20..0cf0238 100644 --- a/src/hooks/rooms/widgets/useAvatarInfoWidget.ts +++ b/src/hooks/rooms/widgets/useAvatarInfoWidget.ts @@ -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; diff --git a/src/hooks/rooms/widgets/useChatInputWidget.ts b/src/hooks/rooms/widgets/useChatInputWidget.ts index b21efab..89451d9 100644 --- a/src/hooks/rooms/widgets/useChatInputWidget.ts +++ b/src/hooks/rooms/widgets/useChatInputWidget.ts @@ -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.FLOOD_EVENT, event => diff --git a/src/hooks/rooms/widgets/useChatWidget.ts b/src/hooks/rooms/widgets/useChatWidget.ts index 0ff6f24..f278659 100644 --- a/src/hooks/rooms/widgets/useChatWidget.ts +++ b/src/hooks/rooms/widgets/useChatWidget.ts @@ -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.ROOM_DRAG, event => diff --git a/src/hooks/translation/index.ts b/src/hooks/translation/index.ts new file mode 100644 index 0000000..cfd635c --- /dev/null +++ b/src/hooks/translation/index.ts @@ -0,0 +1 @@ +export * from './useTranslation'; diff --git a/src/hooks/translation/useTranslation.ts b/src/hooks/translation/useTranslation.ts new file mode 100644 index 0000000..9a575f3 --- /dev/null +++ b/src/hooks/translation/useTranslation.ts @@ -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('external.texts.translation.url') || ''; + + if(configuredTranslationUrl.length) + { + return interpolateTranslationUrl(configuredTranslationUrl, file); + } + + const externalTextUrls = GetConfiguration().getValue('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('furnidata.translation.url') || ''; + + if(configuredTranslationUrl.length) + { + return interpolateTranslationUrl(configuredTranslationUrl, file); + } + + const furnidataUrl = GetConfiguration().interpolate(GetConfiguration().getValue('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(LocalStorageKeys.CHAT_TRANSLATION_SETTINGS, { + enabled: false, + incomingTargetLanguage: defaultTargetLanguage, + outgoingTargetLanguage: defaultTargetLanguage, + uiTextLanguage: '' + }); + const [ supportedLanguages, setSupportedLanguages ] = useState([]); + const [ availableTextLocales ] = useState(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()); + const translationCacheRef = useRef(new Map()); + const outgoingQueueRef = useRef(new Map()); + 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) => + { + 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, handleLanguagesEvent); + useMessageEvent(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((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(); + + 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); diff --git a/src/index.tsx b/src/index.tsx index 5ce3538..52d6e2a 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -12,6 +12,8 @@ import './css/common/Buttons.css'; import './css/forms/form_select.css'; +import './css/friends/FriendsView.css'; + import './css/hotelview/HotelView.css'; import './css/icons/icons.css'; diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 1f06e77..b92790a 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -8,3 +8,8 @@ declare module '*.gif' { const src: string; export default src; } + +interface ImportMeta +{ + glob: (pattern: string, options?: { eager?: boolean; import?: string }) => Record; +}