WIP preserve local changes before duckie merge

This commit is contained in:
Lorenzune
2026-04-21 11:13:32 +02:00
parent e0174e450c
commit 9b36513def
74 changed files with 4419 additions and 408 deletions
+437
View File
@@ -0,0 +1,437 @@
<!doctype html>
<html lang="it">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Nitro Current Messenger Mockup</title>
<style>
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #27313a;
font-family: Arial, Helvetica, sans-serif;
color: #000;
}
.nitro-card-shell {
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
border: 2px solid #000;
border-radius: 10px;
background: #f2f2eb;
box-shadow: 0 8px 22px rgba(0, 0, 0, .28);
}
.nitro-card-header-shell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 31px;
max-height: 31px;
border: 2px solid #3c88a6;
border-bottom-color: #000;
border-radius: 8px 8px 0 0;
background: #30728c;
padding: 5px;
}
.nitro-card-title {
margin: 0 auto;
color: #fff;
font-family: Arial, Helvetica, sans-serif;
font-size: 14px;
font-weight: 400;
line-height: 1;
}
.nitro-card-close-button {
position: absolute;
right: 8px;
width: 20px;
height: 20px;
min-width: 20px;
border: 2px solid #000;
border-radius: 4px;
background: #bf2c2c;
cursor: pointer;
}
.nitro-card-close-button::before,
.nitro-card-close-button::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
width: 10px;
height: 2px;
border-radius: 2px;
background: #fff;
transform-origin: center;
}
.nitro-card-close-button::before {
transform: translate(-50%, -50%) rotate(45deg);
}
.nitro-card-close-button::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
.nitro-card-content-shell {
height: 100%;
padding: 10px;
overflow: auto;
}
.nitro-friends-messenger {
width: 800px;
height: 720px;
}
.messenger-grid {
display: grid;
grid-template-columns: 4fr 8fr;
gap: 8px;
height: 100%;
overflow: hidden;
}
.messenger-column {
display: flex;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: hidden;
}
.messenger-list {
display: flex;
flex-direction: column;
gap: 8px;
overflow: auto;
}
.layout-grid-item {
position: relative;
min-height: 50px;
padding: 4px 8px;
border: 1px solid #7f8b94;
border-radius: 4px;
background: #d9e4ea;
cursor: pointer;
}
.layout-grid-item.active {
background: #f7fbff;
box-shadow: inset 0 0 0 2px #4d9fc7;
}
.layout-item-count {
position: absolute;
right: 4px;
top: 4px;
min-width: 18px;
padding: 1px 4px;
border-radius: 9px;
background: #f2d64b;
color: #000;
font-size: 11px;
font-weight: 700;
text-align: center;
}
.friend-row {
display: flex;
width: 100%;
gap: 4px;
min-height: 50px;
}
.avatar-head {
position: relative;
width: 50px;
height: 80px;
flex-shrink: 0;
align-self: flex-end;
overflow: hidden;
}
.avatar-head img {
position: absolute;
left: -16px;
top: -13px;
width: 64px;
height: auto;
image-rendering: auto;
}
.friend-name {
align-self: center;
min-width: 0;
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 13px;
}
.section-title {
font-weight: 700;
}
.active-thread-title {
text-align: center;
font-weight: 700;
}
.actions-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
}
.actions-left {
display: flex;
gap: 4px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 28px;
padding: 4px 8px;
border: 1px solid #1e7295;
border-radius: 4px;
background: #1e7295;
color: #fff;
font-size: 12px;
cursor: pointer;
box-shadow: inset 0 2px rgba(255,255,255,.15), inset 0 -2px rgba(0,0,0,.10), 0 1px rgba(0,0,0,.10);
}
.button.danger {
border-color: #a81a12;
background: #a81a12;
}
.button.success {
border-color: #00800b;
background: #00800b;
}
.mini-icon {
width: 18px;
height: 18px;
border-radius: 3px;
background: rgba(255,255,255,.25);
}
.chat-messages {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 8px;
border-radius: 4px;
background: #cfd7dd;
}
.thread-group {
display: flex;
width: 100%;
gap: 8px;
margin-bottom: 8px;
}
.thread-group.own {
justify-content: flex-end;
}
.message-avatar {
position: relative;
width: 50px;
height: 50px;
flex-shrink: 0;
overflow: hidden;
}
.message-avatar img {
position: absolute;
left: -19px;
top: -22px;
width: 72px;
height: auto;
}
.message-bubble {
position: relative;
max-width: 420px;
margin-bottom: 8px;
padding: 4px 8px;
border-radius: 4px;
background: #dfdfdf;
color: #000;
}
.message-bubble.left::before {
content: "";
position: absolute;
top: 10px;
left: -8px;
width: 0;
height: 0;
border-right: 8px solid #dfdfdf;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
.message-bubble.right::before {
content: "";
position: absolute;
top: 10px;
right: -8px;
width: 0;
height: 0;
border-left: 8px solid #dfdfdf;
border-top: 8px solid transparent;
border-bottom: 8px solid transparent;
}
.message-header {
font-weight: 700;
}
.message-time {
color: #6b7280;
font-size: 11px;
}
.message-line {
word-break: break-word;
}
.input-row {
display: flex;
gap: 4px;
}
.input-row input {
flex: 1;
min-height: 28px;
padding: 4px 8px;
border: 1px solid #9aa6ad;
border-radius: 4px;
outline: none;
font-size: 13px;
}
</style>
</head>
<body>
<div class="nitro-card-shell nitro-friends-messenger">
<div class="nitro-card-header-shell">
<span class="nitro-card-title">Le tue chat aperte (2)</span>
<div class="nitro-card-close-button"></div>
</div>
<div class="nitro-card-content-shell">
<div class="messenger-grid">
<div class="messenger-column">
<div class="section-title">Messenger</div>
<div class="messenger-list">
<div class="layout-grid-item active">
<div class="layout-item-count">1</div>
<div class="friend-row">
<div class="avatar-head">
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&headonly=1&size=l">
</div>
<div class="friend-name">Jarchy</div>
</div>
</div>
<div class="layout-grid-item">
<div class="friend-row">
<div class="avatar-head">
<img alt=",Homy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=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&direction=2&head_direction=2&headonly=1&size=l">
</div>
<div class="friend-name">,Homy</div>
</div>
</div>
</div>
</div>
<div class="messenger-column">
<div class="active-thread-title">Tu + Jarchy</div>
<div class="actions-row">
<div class="actions-left">
<button class="button"><span class="mini-icon"></span></button>
<button class="button"><span class="mini-icon"></span></button>
<button class="button danger">Denuncia</button>
</div>
<button class="button">×</button>
</div>
<div class="chat-messages">
<div class="thread-group">
<div class="message-avatar">
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l">
</div>
<div>
<div class="message-bubble left">
<div class="message-header">Jarchy</div>
<div class="message-line">dddove sei?</div>
</div>
<div class="message-time">7 ore fa</div>
</div>
</div>
<div class="thread-group own">
<div>
<div class="message-bubble right">
<div class="message-header">Tu</div>
<div class="message-line">su</div>
<div class="message-line">slogga</div>
<div class="message-line">vieni li</div>
</div>
<div class="message-time">6 ore fa</div>
</div>
<div class="message-avatar own">
<img alt="Tu" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=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&direction=4&head_direction=4&size=l">
</div>
</div>
<div class="thread-group">
<div class="message-avatar">
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l">
</div>
<div>
<div class="message-bubble left">
<div class="message-header">Jarchy</div>
<div class="message-line">arrivo</div>
</div>
<div class="message-time">6 ore fa</div>
</div>
</div>
</div>
<div class="input-row">
<input value="" placeholder="Fai clic qui per scrivere a Jarchy">
<button class="button success">Parla</button>
</div>
</div>
</div>
</div>
</div>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
<style>
*{box-sizing:border-box;margin:0;padding:0;}
.root{display:flex;justify-content:center;padding:1.5rem 0;font-family:Arial,Helvetica,sans-serif;}
.card{width:332px;height:445px;display:flex;flex-direction:column;border:2px solid #000;border-radius:10px;overflow:hidden;background:#f2f2eb;box-shadow:0 6px 20px rgba(0,0,0,.25);}
.hdr{display:flex;align-items:center;justify-content:space-between;padding:0 8px;min-height:30px;background:#30728c;border-bottom:1px solid #000;flex-shrink:0;}
.hdr-title{color:#fff;font-size:13px;}
.hdr-min{width:18px;height:18px;border:2px solid #000;border-radius:3px;background:#bbb;cursor:pointer;display:flex;align-items:center;justify-content:center;}
.hdr-min::after{content:"";width:8px;height:2px;background:#555;display:block;}
.avatar-bar{display:flex;gap:4px;padding:6px 8px;border-bottom:1px solid #000;background:#d9e4ea;flex-shrink:0;overflow-x:auto;}
.av-item{width:36px;height:36px;border:2px solid #7f8b94;border-radius:4px;background:#c0cdd5;overflow:hidden;position:relative;cursor:pointer;flex-shrink:0;}
.av-item.active{border-color:#1e7295;box-shadow:0 0 0 1px #1e7295;}
.av-item img{position:absolute;left:50%;top:50%;transform:translate(-50%,-62%) scale(0.65);width:64px;}
.av-badge{position:absolute;top:-3px;right:-3px;min-width:12px;height:12px;border-radius:6px;background:#f2d64b;border:1px solid #000;font-size:8px;font-weight:700;color:#000;display:flex;align-items:center;justify-content:center;padding:0 2px;}
.thread-hdr{display:flex;align-items:center;justify-content:space-between;padding:4px 8px;border-bottom:1px solid #000;flex-shrink:0;background:#e8eef2;}
.thread-name{font-size:12px;font-weight:700;color:#000;}
.acts{display:flex;gap:4px;align-items:center;}
.btn{display:inline-flex;align-items:center;justify-content:center;height:22px;padding:0 7px;border:1px solid #1e7295;border-radius:3px;background:#1e7295;color:#fff;font-size:11px;cursor:pointer;}
.btn.danger{border-color:#a81a12;background:#a81a12;}
.btn.close-btn{border-color:#888;background:#ccc;color:#000;font-size:13px;padding:0 5px;}
.btn.icon-btn{width:22px;padding:0;}
.icon-sq{width:10px;height:10px;background:rgba(255,255,255,.35);border-radius:1px;display:block;}
.messages{flex:1;min-height:0;overflow-y:auto;padding:8px;background:#cfd7dd;display:flex;flex-direction:column;gap:8px;}
.msg-row{display:flex;gap:6px;align-items:flex-start;}
.msg-row.own{flex-direction:row-reverse;}
.msg-av{width:40px;height:52px;flex-shrink:0;position:relative;overflow:hidden;}
.msg-av img{position:absolute;left:50%;top:0;transform:translateX(-50%);width:64px;}
.msg-body{display:flex;flex-direction:column;gap:2px;max-width:200px;}
.bubble{background:#dfdfdf;border:1px solid #bbb;border-radius:3px;padding:4px 7px;font-size:12px;line-height:1.4;color:#000;position:relative;}
.bubble.left::before{content:"";position:absolute;top:10px;left:-7px;border:6px solid transparent;border-right-color:#bbb;}
.bubble.left::after{content:"";position:absolute;top:10px;left:-5px;border:5px solid transparent;border-right-color:#dfdfdf;}
.bubble.right::before{content:"";position:absolute;top:10px;right:-7px;border:6px solid transparent;border-left-color:#bbb;}
.bubble.right::after{content:"";position:absolute;top:10px;right:-5px;border:5px solid transparent;border-left-color:#dfdfdf;}
.msg-time{font-size:10px;color:#666;}
.msg-row.own .msg-time{text-align:right;}
.input-row{display:flex;gap:5px;padding:6px 8px;border-top:1px solid #000;background:#e8eef2;flex-shrink:0;align-items:center;}
.input-row input{flex:1;height:26px;border:1px solid #9aa6ad;border-radius:3px;padding:0 7px;font-size:12px;background:#fff;outline:none;}
.btn.send{border-color:#00800b;background:#00800b;}
</style>
<div class="root">
<div class="card">
<div class="hdr">
<span class="hdr-title">Le tue chat aperte (7)</span>
<div class="hdr-min"></div>
</div>
<div class="avatar-bar">
<div class="av-item active" style="position:relative;">
<img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l">
<span class="av-badge">1</span>
</div>
<div class="av-item">
<img alt=",Homy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=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&direction=2&head_direction=2&size=l">
</div>
<div class="av-item">
<img alt="u3" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-180-1.ch-215-62.lg-280-110&direction=2&head_direction=2&size=l">
</div>
<div class="av-item">
<img alt="u4" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-3096-1370.ch-3030-110.lg-3023-110&direction=2&head_direction=2&size=l">
</div>
<div class="av-item">
<img alt="u5" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-180-3.ch-210-66.lg-270-82&direction=2&head_direction=2&size=l">
</div>
<div class="av-item">
<img alt="u6" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-185-10.ch-220-1338.lg-275-110&direction=2&head_direction=2&size=l">
</div>
<div class="av-item">
<img alt="u7" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hd-180-2.ch-230-62.lg-285-110&direction=2&head_direction=2&size=l">
</div>
</div>
<div class="thread-hdr">
<span class="thread-name">Tu + Jarchy</span>
<div class="acts">
<button class="btn icon-btn"><span class="icon-sq"></span></button>
<button class="btn icon-btn"><span class="icon-sq"></span></button>
<button class="btn danger">Denuncia</button>
<button class="btn close-btn">×</button>
</div>
</div>
<div class="messages">
<div class="msg-row">
<div class="msg-av"><img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l"></div>
<div class="msg-body">
<div style="font-size:11px;font-weight:700;color:#000;">Jarchy:</div>
<div class="bubble left">dddove sei?</div>
<div class="msg-time">7 ore fa</div>
</div>
</div>
<div class="msg-row own">
<div class="msg-av"><img alt="Tu" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=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&direction=4&head_direction=4&size=l"></div>
<div class="msg-body">
<div style="font-size:11px;font-weight:700;color:#000;text-align:right;">,Homy:</div>
<div class="bubble right">su<br>slogga<br>vieni li</div>
<div class="msg-time">7 ore fa</div>
</div>
</div>
<div class="msg-row">
<div class="msg-av"><img alt="Jarchy" src="https://www.habbo.com/habbo-imaging/avatarimage?figure=hr-831-45.hd-180-1.ch-255-92.lg-275-82.sh-290-92&direction=2&head_direction=2&size=l"></div>
<div class="msg-body">
<div style="font-size:11px;font-weight:700;color:#000;">Jarchy:</div>
<div class="bubble left">arrivo</div>
<div class="msg-time">7 ore fa</div>
</div>
</div>
</div>
<div class="input-row">
<input placeholder="Fai clic qui per scrivere a Jarchy" type="text">
<button class="btn send">Parla</button>
</div>
</div>
</div>
+8 -6
View File
@@ -10,13 +10,15 @@
"${gamedata.url}/ExternalTexts.json", "${gamedata.url}/ExternalTexts.json",
"${gamedata.url}/UITexts.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", "external.samples.url": "${hof.furni.url}/mp3/sound_machine_sample_%sample%.mp3",
"furnidata.url": "${gamedata.url}/FurnitureData.json?v=2", "furnidata.url": "${gamedata.url}/FurnitureData.json?t=%timestamp%",
"productdata.url": "${gamedata.url}/ProductData.json?v=2", "furnidata.translation.url": "${gamedata.url}/furniture_translate/FurnitureData_%locale%.json?t=%timestamp%",
"avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?v=2", "productdata.url": "${gamedata.url}/ProductData.json?t=%timestamp%",
"avatar.figuredata.url": "${gamedata.url}/FigureData.json?v=2", "avatar.actions.url": "${gamedata.url}/HabboAvatarActions.json?t=%timestamp%",
"avatar.figuremap.url": "${gamedata.url}/FigureMap.json?v=2", "avatar.figuredata.url": "${gamedata.url}/FigureData.json?t=%timestamp%",
"avatar.effectmap.url": "${gamedata.url}/EffectMap.json?v=2", "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.url": "${asset.url}/figure/%libname%.nitro",
"avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro", "avatar.asset.effect.url": "${asset.url}/effect/%libname%.nitro",
"furni.asset.url": "${asset.url}/furniture/%libname%.nitro", "furni.asset.url": "${asset.url}/furniture/%libname%.nitro",
+13
View File
@@ -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.
+8
View File
@@ -148,6 +148,10 @@ export class Offer implements IPurchasableOffer
public get localizationName(): string public get localizationName(): string
{ {
const furnitureProduct = this.product;
if(furnitureProduct?.furnitureData?.name?.length) return furnitureProduct.furnitureData.name;
const productData = GetProductDataForLocalization(this._localizationId); const productData = GetProductDataForLocalization(this._localizationId);
if(productData) return productData.name; if(productData) return productData.name;
@@ -157,6 +161,10 @@ export class Offer implements IPurchasableOffer
public get localizationDescription(): string public get localizationDescription(): string
{ {
const furnitureProduct = this.product;
if(furnitureProduct?.furnitureData?.description?.length) return furnitureProduct.furnitureData.description;
const productData = GetProductDataForLocalization(this._localizationId); const productData = GetProductDataForLocalization(this._localizationId);
if(productData) return productData.description; if(productData) return productData.description;
+5
View File
@@ -11,6 +11,11 @@ export interface IChatEntry
chatType?: number; chatType?: number;
imageUrl?: string; imageUrl?: string;
color?: string; color?: string;
showTranslation?: boolean;
originalMessage?: string;
translatedMessage?: string;
detectedLanguage?: string;
targetLanguage?: string;
roomId: number; roomId: number;
timestamp: string; timestamp: string;
type: number; type: number;
+12
View File
@@ -48,6 +48,18 @@ export class MessengerThread
return chat; 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 private pruneChats(): void
{ {
let totalChats = this._groups.reduce((total, current) => (total + current.chats.length), 0); let totalChats = this._groups.reduce((total, current) => (total + current.chats.length), 0);
+52
View File
@@ -4,22 +4,49 @@ export class MessengerThreadChat
public static ROOM_INVITE: number = 1; public static ROOM_INVITE: number = 1;
public static STATUS_NOTIFICATION: number = 2; public static STATUS_NOTIFICATION: number = 2;
public static SECURITY_NOTIFICATION: number = 3; public static SECURITY_NOTIFICATION: number = 3;
private static CHAT_ID: number = 0;
private _id: number;
private _type: number; private _type: number;
private _senderId: number; private _senderId: number;
private _message: string; private _message: string;
private _secondsSinceSent: number; private _secondsSinceSent: number;
private _extraData: string; private _extraData: string;
private _date: Date; 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) constructor(senderId: number, message: string, secondsSinceSent: number = 0, extraData: string = null, type: number = 0)
{ {
this._id = ++MessengerThreadChat.CHAT_ID;
this._type = type; this._type = type;
this._senderId = senderId; this._senderId = senderId;
this._message = message; this._message = message;
this._secondsSinceSent = secondsSinceSent; this._secondsSinceSent = secondsSinceSent;
this._extraData = extraData; this._extraData = extraData;
this._date = new Date(); 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 public get type(): number
@@ -51,4 +78,29 @@ export class MessengerThreadChat
{ {
return this._date; 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;
}
} }
+6
View File
@@ -65,6 +65,12 @@ export class GroupItem
this.setDescription(); this.setDescription();
} }
public refreshLocalization(): void
{
this.setName();
this.setDescription();
}
public dispose(): void public dispose(): void
{ {
+10
View File
@@ -0,0 +1,10 @@
export interface INickIconItem
{
id: number;
iconKey: string;
displayName: string;
points: number;
pointsType: number;
owned: boolean;
active: boolean;
}
+6
View File
@@ -1,9 +1,15 @@
export interface IPrefixItem export interface IPrefixItem
{ {
id: number; id: number;
displayName?: string;
text: string; text: string;
color: string; color: string;
icon: string; icon: string;
effect: string; effect: string;
font?: string;
active: boolean; active: boolean;
isCustom?: boolean;
points?: number;
pointsType?: number;
catalogPrefixId?: number;
} }
+1
View File
@@ -4,6 +4,7 @@ export * from './FurnitureUtilities';
export * from './GroupItem'; export * from './GroupItem';
export * from './IBotItem'; export * from './IBotItem';
export * from './IFurnitureItem'; export * from './IFurnitureItem';
export * from './INickIconItem';
export * from './IPetItem'; export * from './IPetItem';
export * from './IPrefixItem'; export * from './IPrefixItem';
export * from './IUnseenItemTracker'; export * from './IUnseenItemTracker';
+7
View File
@@ -12,6 +12,13 @@ export class AvatarInfoUser implements IAvatarInfo
public name: string = ''; public name: string = '';
public motto: 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 achievementScore: number = 0;
public backgroundId: number = 0; public backgroundId: number = 0;
public standId: number = 0; public standId: number = 0;
+13 -8
View File
@@ -32,17 +32,16 @@ export class AvatarInfoUtilities
else else
{ {
let furniData: IFurnitureData = null; let furniData: IFurnitureData = null;
const className = roomObject.type;
const typeId = roomObject.model.getValue<number>(RoomObjectVariable.FURNITURE_TYPE_ID);
if(category === RoomObjectCategory.FLOOR) if(category === RoomObjectCategory.FLOOR)
{ {
furniData = GetSessionDataManager().getFloorItemData(typeId); furniData = GetSessionDataManager().getFloorItemDataByName(className);
} }
else if(category === RoomObjectCategory.WALL) else if(category === RoomObjectCategory.WALL)
{ {
furniData = GetSessionDataManager().getWallItemData(typeId); furniData = GetSessionDataManager().getWallItemDataByName(className);
} }
if(!furniData) break; if(!furniData) break;
@@ -102,18 +101,17 @@ export class AvatarInfoUtilities
} }
else else
{ {
const typeId = model.getValue<number>(RoomObjectVariable.FURNITURE_TYPE_ID);
let furnitureData: IFurnitureData = null; let furnitureData: IFurnitureData = null;
const className = roomObject.type;
if(category === RoomObjectCategory.FLOOR) if(category === RoomObjectCategory.FLOOR)
{ {
furnitureData = GetSessionDataManager().getFloorItemData(typeId); furnitureData = GetSessionDataManager().getFloorItemDataByName(className);
} }
else if(category === RoomObjectCategory.WALL) else if(category === RoomObjectCategory.WALL)
{ {
furnitureData = GetSessionDataManager().getWallItemData(typeId); furnitureData = GetSessionDataManager().getWallItemDataByName(className);
} }
if(furnitureData) if(furnitureData)
@@ -183,6 +181,13 @@ export class AvatarInfoUtilities
userInfo.isSpectatorMode = roomSession.isSpectator; userInfo.isSpectatorMode = roomSession.isSpectator;
userInfo.name = userData.name; userInfo.name = userData.name;
userInfo.motto = userData.custom; 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.backgroundId = userData.background;
userInfo.standId = userData.stand; userInfo.standId = userData.stand;
userInfo.overlayId = userData.overlay; userInfo.overlayId = userData.overlay;
+12
View File
@@ -11,6 +11,16 @@ export class ChatBubbleMessage
public prefixColor: string = ''; public prefixColor: string = '';
public prefixIcon: string = ''; public prefixIcon: string = '';
public prefixEffect: 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 _top: number = 0;
private _left: number = 0; private _left: number = 0;
@@ -30,6 +40,8 @@ export class ChatBubbleMessage
) )
{ {
this.id = ++ChatBubbleMessage.BUBBLE_COUNTER; this.id = ++ChatBubbleMessage.BUBBLE_COUNTER;
this.originalText = text;
this.originalFormattedText = formattedText;
} }
public get top(): number public get top(): number
+1
View File
@@ -3,4 +3,5 @@ export class LocalStorageKeys
public static CATALOG_PLACE_MULTIPLE_OBJECTS: string = 'catalogPlaceMultipleObjects'; public static CATALOG_PLACE_MULTIPLE_OBJECTS: string = 'catalogPlaceMultipleObjects';
public static CATALOG_SKIP_PURCHASE_CONFIRMATION: string = 'catalogSkipPurchaseConfirmation'; public static CATALOG_SKIP_PURCHASE_CONFIRMATION: string = 'catalogSkipPurchaseConfirmation';
public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled'; public static CHAT_WINDOW_ENABLED: string = 'chatWindowEnabled';
public static CHAT_TRANSLATION_SETTINGS: string = 'chatTranslationSettings';
} }
+182 -8
View File
@@ -1,11 +1,41 @@
export const PRESET_PREFIX_EFFECTS: { id: string; label: string; icon: string }[] = [ export type PrefixFontTier = 'basic' | 'premium';
{ id: '', label: 'None', icon: '—' }, export type PrefixFontOption = {
{ id: 'glow', label: 'Glow', icon: '✨' }, id: string;
{ id: 'shadow', label: 'Shadow', icon: '🌑' }, label: string;
{ id: 'italic', label: 'Italic', icon: '𝑰' }, family: string;
{ id: 'outline', label: 'Outline', icon: '🔲' }, tier: PrefixFontTier;
{ id: 'pulse', label: 'Pulse', icon: '💫' }, };
{ id: 'bold-glow', label: 'Neon', icon: '💡' },
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[] => 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)]); return [ ...text ].map((_, i) => colors[Math.min(i, colors.length - 1)]);
}; };
export const getPrefixFontStyle = (font: string): Record<string, string> =>
{
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<string, string | number> => export const getPrefixEffectStyle = (effect: string, color?: string): Record<string, string | number> =>
{ {
const baseColor = color || '#FFFFFF'; const baseColor = color || '#FFFFFF';
@@ -33,13 +72,95 @@ export const getPrefixEffectStyle = (effect: string, color?: string): Record<str
WebkitTextStroke: '0.5px rgba(0,0,0,0.6)', WebkitTextStroke: '0.5px rgba(0,0,0,0.6)',
textShadow: '1px 1px 0 rgba(0,0,0,0.3), -1px -1px 0 rgba(0,0,0,0.3), 1px -1px 0 rgba(0,0,0,0.3), -1px 1px 0 rgba(0,0,0,0.3)' textShadow: '1px 1px 0 rgba(0,0,0,0.3), -1px -1px 0 rgba(0,0,0,0.3), 1px -1px 0 rgba(0,0,0,0.3), -1px 1px 0 rgba(0,0,0,0.3)'
}; };
case 'underline':
return {
textDecoration: 'underline',
textDecorationThickness: '2px',
textUnderlineOffset: '2px'
};
case 'pulse': case 'pulse':
return { animation: 'prefix-pulse 1.5s ease-in-out infinite' }; return { animation: 'prefix-pulse 1.5s ease-in-out infinite' };
case 'bounce':
return {
animation: 'prefix-bounce 1.2s ease-in-out infinite',
display: 'inline-block'
};
case 'wave':
return {
animation: 'prefix-wave 1.6s ease-in-out infinite',
display: 'inline-block',
transformOrigin: 'center bottom'
};
case 'shake':
return {
animation: 'prefix-shake 0.9s ease-in-out infinite',
display: 'inline-block'
};
case 'discord-neon':
return {
textShadow: `0 0 5px ${ baseColor }, 0 0 10px ${ baseColor }, 0 0 18px ${ baseColor }90`,
fontWeight: 900,
letterSpacing: '0.2px'
};
case 'cartoon':
return {
WebkitTextStroke: '1px rgba(0,0,0,0.75)',
textShadow: '2px 2px 0 rgba(0,0,0,0.55)',
fontWeight: 900
};
case 'toon':
return {
WebkitTextStroke: '0.8px rgba(0,0,0,0.65)',
textShadow: '1px 2px 0 rgba(0,0,0,0.45)',
fontWeight: 900,
transform: 'skew(-4deg)'
};
case 'pop':
return {
textShadow: '0 2px 0 rgba(0,0,0,0.28), 0 4px 8px rgba(0,0,0,0.2)',
fontWeight: 900,
letterSpacing: '0.3px'
};
case 'bold-glow': case 'bold-glow':
return { return {
textShadow: `0 0 4px ${ baseColor }, 0 0 8px ${ baseColor }, 0 0 16px ${ baseColor }60`, textShadow: `0 0 4px ${ baseColor }, 0 0 8px ${ baseColor }, 0 0 16px ${ baseColor }60`,
fontWeight: 900 fontWeight: 900
}; };
case 'rainbow':
return {
animation: 'prefix-rainbow 2.6s linear infinite',
textShadow: '0 0 8px rgba(255,255,255,0.35)'
};
case 'frost':
return {
textShadow: '0 0 4px rgba(255,255,255,0.75), 0 0 10px rgba(125,211,252,0.45)',
filter: 'drop-shadow(0 0 2px rgba(191,219,254,0.75))'
};
case 'gold':
return {
animation: 'prefix-gold 2s ease-in-out infinite',
textShadow: '0 0 6px rgba(255,215,0,0.45), 0 0 14px rgba(255,193,7,0.35)'
};
case 'glitch':
return {
animation: 'prefix-glitch 0.8s steps(2, end) infinite',
textShadow: '-1px 0 rgba(255,0,102,0.75), 1px 0 rgba(0,255,255,0.75)'
};
case 'fire':
return {
animation: 'prefix-fire 1.1s ease-in-out infinite',
textShadow: '0 0 5px rgba(255,120,0,0.7), 0 -1px 8px rgba(255,200,0,0.55), 0 -2px 12px rgba(255,60,0,0.45)'
};
case 'matrix':
return {
animation: 'prefix-matrix 1.8s linear infinite',
textShadow: '0 0 6px rgba(57,255,20,0.65), 0 0 12px rgba(57,255,20,0.35)'
};
case 'sparkle':
return {
animation: 'prefix-sparkle 1.4s ease-in-out infinite',
textShadow: `0 0 4px ${ baseColor }, 0 0 10px ${ baseColor }80, 0 0 16px rgba(255,255,255,0.45)`
};
default: default:
return {}; return {};
} }
@@ -50,4 +171,57 @@ export const PREFIX_EFFECT_KEYFRAMES = `
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.5; } 50% { opacity: 0.5; }
} }
@keyframes prefix-bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-2px); }
}
@keyframes prefix-wave {
0%, 100% { transform: rotate(0deg); }
25% { transform: rotate(-5deg); }
75% { transform: rotate(5deg); }
}
@keyframes prefix-shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-1px); }
40% { transform: translateX(1px); }
60% { transform: translateX(-1px); }
80% { transform: translateX(1px); }
}
@keyframes prefix-rainbow {
0% { filter: hue-rotate(0deg); }
100% { filter: hue-rotate(360deg); }
}
@keyframes prefix-gold {
0%, 100% { filter: brightness(1); }
50% { filter: brightness(1.25) saturate(1.2); }
}
@keyframes prefix-glitch {
0%, 100% { transform: translate(0, 0); }
20% { transform: translate(-1px, 0); }
40% { transform: translate(1px, 0); }
60% { transform: translate(-1px, 1px); }
80% { transform: translate(1px, -1px); }
}
@keyframes prefix-fire {
0%, 100% { transform: translateY(0); filter: brightness(1); }
50% { transform: translateY(-1px); filter: brightness(1.15); }
}
@keyframes prefix-matrix {
0% { opacity: 0.85; letter-spacing: 0; }
50% { opacity: 1; letter-spacing: 0.4px; }
100% { opacity: 0.85; letter-spacing: 0; }
}
@keyframes prefix-sparkle {
0%, 100% { opacity: 1; filter: brightness(1); }
50% { opacity: 0.92; filter: brightness(1.35); }
}
`; `;
+37 -1
View File
@@ -10,6 +10,10 @@ export class WiredSelectionVisualizer
lineColor: [ 0.45, 0.78, 1 ], lineColor: [ 0.45, 0.78, 1 ],
color: [ 0.20, 0.52, 0.95 ] color: [ 0.20, 0.52, 0.95 ]
}); });
private static _variableHighlightShader: WiredFilter = new WiredFilter({
lineColor: [ 0.52, 0.92, 1 ],
color: [ 0.20, 0.70, 1 ]
});
public static show(furniId: number): void public static show(furniId: number): void
{ {
@@ -73,12 +77,37 @@ export class WiredSelectionVisualizer
if(roomId < 0) return; if(roomId < 0) return;
const roomObjects = roomEngine.getRoomObjects(roomId, RoomObjectCategory.FLOOR); const roomObjects = [
...roomEngine.getRoomObjects(roomId, RoomObjectCategory.FLOOR),
...roomEngine.getRoomObjects(roomId, RoomObjectCategory.WALL),
...roomEngine.getRoomObjects(roomId, RoomObjectCategory.UNIT)
];
for(const roomObject of roomObjects) for(const roomObject of roomObjects)
{ {
WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._selectionShader); WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._selectionShader);
WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._secondarySelectionShader); WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._secondarySelectionShader);
WiredSelectionVisualizer.clearSelectionShader(roomObject, WiredSelectionVisualizer._variableHighlightShader);
}
}
public static applyVariableHighlightToObjects(objects: Array<{ category: number; objectId: number; }>): 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); 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 private static applySelectionShader(roomObject: IRoomObject, filter: WiredFilter): void
{ {
if(!roomObject) return; if(!roomObject) return;
Binary file not shown.

After

Width:  |  Height:  |  Size: 427 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 B

@@ -0,0 +1,19 @@
const rawNickIcons = import.meta.glob('./*.gif', { eager: true, import: 'default' }) as Record<string, string>;
export const NICK_ICON_URLS: Record<string, string> = 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<string, string>);
export const GetNickIconUrl = (iconKey: string) =>
{
if(!iconKey) return '';
return (NICK_ICON_URLS[iconKey] || NICK_ICON_URLS[iconKey.toLowerCase()] || '');
};
+102
View File
@@ -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<UserIdentityViewProps> = ({
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 <img key="identity-icon" className={ `${ iconClassName } mr-1` } src={ nickIconUrl } alt="" />;
case 'prefix':
if(!prefixText?.length) return null;
return (
<span key="identity-prefix" className={ `prefix inline-block whitespace-nowrap font-bold mr-1 ${ prefixClassName }` } style={ { ...prefixFontStyle, ...prefixStyle } }>
{ prefixIcon && <span className="mr-0.5 text-[13px] leading-none">{ prefixIcon }</span> }
<span style={ hasMultiColor ? { ...prefixFontStyle, ...prefixStyle } : { ...prefixFontStyle, ...prefixStyle, color: prefixColors[0] || '#FFFFFF' } }>
{'{'}
{ hasMultiColor
? [ ...prefixText ].map((char, index) => (
<span key={ index } style={ { ...prefixFontStyle, color: prefixColors[index] || prefixColors[prefixColors.length - 1], ...getPrefixEffectStyle(prefixEffect, prefixColors[index]) } }>{ char }</span>
))
: prefixText }
{'}'}
</span>
</span>
);
case 'name':
return <span key="identity-name" className={ `${ nameClassName } whitespace-nowrap` }>{ username }{ showColon ? ':' : '' }{ showColon ? ' ' : '' }</span>;
default:
return null;
}
}).filter(Boolean);
return (
<span className={ `inline-flex items-center whitespace-nowrap align-middle ${ className }` }>
{ !!prefixEffect && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ parts }
</span>
);
};
+1
View File
@@ -11,6 +11,7 @@ export * from './GridContext';
export * from './HorizontalRule'; export * from './HorizontalRule';
export * from './InfiniteScroll'; export * from './InfiniteScroll';
export * from './Text'; export * from './Text';
export * from './UserIdentityView';
export * from './card'; export * from './card';
export * from './card/accordion'; export * from './card/accordion';
export * from './card/tabs'; export * from './card/tabs';
+17
View File
@@ -8,6 +8,7 @@ import { CameraWidgetView } from './camera/CameraWidgetView';
import { CampaignView } from './campaign/CampaignView'; import { CampaignView } from './campaign/CampaignView';
import { CatalogView } from './catalog/CatalogView'; import { CatalogView } from './catalog/CatalogView';
import { ChatHistoryView } from './chat-history/ChatHistoryView'; import { ChatHistoryView } from './chat-history/ChatHistoryView';
import { CustomizeNickIconView } from './customize/CustomizeNickIconView';
import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView'; import { FloorplanEditorView } from './floorplan-editor/FloorplanEditorView';
import { FurniEditorView } from './furni-editor/FurniEditorView'; import { FurniEditorView } from './furni-editor/FurniEditorView';
import { FriendsView } from './friends/FriendsView'; import { FriendsView } from './friends/FriendsView';
@@ -27,6 +28,8 @@ import { ExternalPluginLoader } from './plugins/ExternalPluginLoader';
import { RightSideView } from './right-side/RightSideView'; import { RightSideView } from './right-side/RightSideView';
import { RoomView } from './room/RoomView'; import { RoomView } from './room/RoomView';
import { ToolbarView } from './toolbar/ToolbarView'; import { ToolbarView } from './toolbar/ToolbarView';
import { TranslationBootstrap } from './translation/TranslationBootstrap';
import { TranslationSettingsView } from './translation/TranslationSettingsView';
import { UserProfileView } from './user-profile/UserProfileView'; import { UserProfileView } from './user-profile/UserProfileView';
import { UserSettingsView } from './user-settings/UserSettingsView'; import { UserSettingsView } from './user-settings/UserSettingsView';
import { WiredView } from './wired/WiredView'; import { WiredView } from './wired/WiredView';
@@ -37,6 +40,7 @@ export const MainView: FC<{}> = props =>
{ {
const [ isReady, setIsReady ] = useState(false); const [ isReady, setIsReady ] = useState(false);
const [ landingViewVisible, setLandingViewVisible ] = useState(true); const [ landingViewVisible, setLandingViewVisible ] = useState(true);
const [ localizationVersion, setLocalizationVersion ] = useState(0);
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false)); useNitroEvent<RoomSessionEvent>(RoomSessionEvent.CREATED, event => setLandingViewVisible(false));
useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView)); useNitroEvent<RoomSessionEvent>(RoomSessionEvent.ENDED, event => setLandingViewVisible(event.openLandingView));
@@ -86,8 +90,18 @@ export const MainView: FC<{}> = props =>
return () => RemoveLinkEventTracker(linkTracker); return () => RemoveLinkEventTracker(linkTracker);
}, []); }, []);
useEffect(() =>
{
const refreshLocalization = () => setLocalizationVersion(value => (value + 1));
window.addEventListener('nitro-localization-updated', refreshLocalization);
return () => window.removeEventListener('nitro-localization-updated', refreshLocalization);
}, []);
return ( return (
<> <>
<div className="hidden" data-localization-version={ localizationVersion } />
<AnimatePresence> <AnimatePresence>
{ landingViewVisible && { landingViewVisible &&
<motion.div <motion.div
@@ -98,10 +112,12 @@ export const MainView: FC<{}> = props =>
</motion.div> } </motion.div> }
</AnimatePresence> </AnimatePresence>
<ToolbarView isInRoom={ !landingViewVisible } /> <ToolbarView isInRoom={ !landingViewVisible } />
<TranslationBootstrap />
<ModToolsView /> <ModToolsView />
<WiredCreatorToolsView /> <WiredCreatorToolsView />
<RoomView /> <RoomView />
<ChatHistoryView /> <ChatHistoryView />
<CustomizeNickIconView />
<WiredView /> <WiredView />
<AvatarEditorView /> <AvatarEditorView />
<AchievementsView /> <AchievementsView />
@@ -112,6 +128,7 @@ export const MainView: FC<{}> = props =>
<FriendsView /> <FriendsView />
<RightSideView /> <RightSideView />
<UserSettingsView /> <UserSettingsView />
<TranslationSettingsView />
<UserProfileView /> <UserProfileView />
<GroupsView /> <GroupsView />
<GroupForumView /> <GroupForumView />
+14 -2
View File
@@ -1,13 +1,25 @@
import { FC } from 'react'; import { FC } from 'react';
import { GetConfigurationValue } from '../../api'; import { GetConfigurationValue } from '../../api';
import { useCatalog } from '../../hooks';
import { CatalogClassicView } from './CatalogClassicView'; import { CatalogClassicView } from './CatalogClassicView';
import { CatalogModernView } from './CatalogModernView'; import { CatalogModernView } from './CatalogModernView';
export const CatalogView: FC<{}> = () => export const CatalogView: FC<{}> = () =>
{ {
const { catalogLocalizationVersion = 0 } = useCatalog();
const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false); const useNewStyle = GetConfigurationValue<boolean>('catalog.style.new', false);
if(useNewStyle) return <CatalogModernView />; if(useNewStyle) return (
<>
<div className="hidden" data-catalog-localization-version={ catalogLocalizationVersion } />
<CatalogModernView />
</>
);
return <CatalogClassicView />; return (
<>
<div className="hidden" data-catalog-localization-version={ catalogLocalizationVersion } />
<CatalogClassicView />
</>
);
}; };
@@ -1,17 +1,24 @@
import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer'; import { GetSessionDataManager, IFurnitureData } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { FaSearch, FaTimes } from 'react-icons/fa'; 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'; import { useCatalog } from '../../../../../hooks';
export const CatalogSearchView: FC<{}> = () => export const CatalogSearchView: FC<{}> = () =>
{ {
const [ searchValue, setSearchValue ] = useState(''); 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(() => useEffect(() =>
{ {
let search = searchValue?.toLocaleLowerCase().replace(' ', ''); const search = normalizeSearchText(searchValue);
if(!search || !search.length) if(!search || !search.length)
{ {
@@ -22,7 +29,7 @@ export const CatalogSearchView: FC<{}> = () =>
const timeout = setTimeout(() => const timeout = setTimeout(() =>
{ {
if(!offersToNodes || !rootNode) return; if(!rootNode) return;
const furnitureDatas = GetSessionDataManager().getAllFurnitureData(); const furnitureDatas = GetSessionDataManager().getAllFurnitureData();
@@ -39,34 +46,35 @@ export const CatalogSearchView: FC<{}> = () =>
if((currentType === CatalogType.NORMAL) && furniture.excludeDynamic) continue; 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((currentType === CatalogType.BUILDER) && (furniture.purchaseOfferId === -1) && (furniture.rentOfferId === -1))
{ {
if((furniture.furniLine !== '') && (foundFurniLines.indexOf(furniture.furniLine) < 0)) 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 = [ foundFurniture.push(furniture);
...GetOfferNodes(offersToNodes, furniture.purchaseOfferId),
...GetOfferNodes(offersToNodes, furniture.rentOfferId)
];
if(foundNodes.length) if(furniture.furniLine && furniture.furniLine.length && (foundFurniLines.indexOf(furniture.furniLine) < 0))
{ {
if(searchValues.indexOf(search) >= 0) foundFurniture.push(furniture); foundFurniLines.push(furniture.furniLine);
}
if(foundFurniture.length === 250) break; if(foundFurniture.length === 250) break;
} }
} }
}
const offers: IPurchasableOffer[] = []; 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[] = []; let nodes: ICatalogNode[] = [];
@@ -77,7 +85,7 @@ export const CatalogSearchView: FC<{}> = () =>
}, 300); }, 300);
return () => clearTimeout(timeout); return () => clearTimeout(timeout);
}, [ offersToNodes, currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]); }, [ currentType, rootNode, searchValue, setCurrentPage, setSearchResult ]);
return ( return (
<div className="relative w-full"> <div className="relative w-full">
@@ -1,5 +1,5 @@
import { FC, useCallback, useEffect, useRef, useState } from 'react'; import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { IPurchasableOffer, ProductTypeEnum } from '../../../../../api'; import { IPurchasableOffer } from '../../../../../api';
import { AutoGrid, AutoGridProps } from '../../../../../common'; import { AutoGrid, AutoGridProps } from '../../../../../common';
import { useCatalog } from '../../../../../hooks'; import { useCatalog } from '../../../../../hooks';
import { useCatalogAdmin } from '../../../CatalogAdminContext'; import { useCatalogAdmin } from '../../../CatalogAdminContext';
@@ -13,7 +13,7 @@ interface CatalogItemGridWidgetViewProps extends AutoGridProps
export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = props => export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = props =>
{ {
const { columnCount = 5, children = null, ...rest } = 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 catalogAdmin = useCatalogAdmin();
const adminMode = catalogAdmin?.adminMode ?? false; const adminMode = catalogAdmin?.adminMode ?? false;
const elementRef = useRef<HTMLDivElement>(); const elementRef = useRef<HTMLDivElement>();
@@ -29,23 +29,7 @@ export const CatalogItemGridWidgetView: FC<CatalogItemGridWidgetViewProps> = pro
const selectOffer = (offer: IPurchasableOffer) => const selectOffer = (offer: IPurchasableOffer) =>
{ {
offer.activate(); selectCatalogOffer(offer);
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;
});
}
}; };
const handleDragStart = useCallback((index: number) => const handleDragStart = useCallback((index: number) =>
@@ -0,0 +1,584 @@
import { AddLinkEventTracker, ILinkEventTracker, PurchaseCatalogPrefixComposer, PurchaseNickIconComposer, PurchasePrefixComposer, RemoveLinkEventTracker, RequestNickIconsComposer, SetActiveNickIconComposer, SetActivePrefixComposer, SetDisplayOrderComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer';
import data from '@emoji-mart/data';
import Picker from '@emoji-mart/react';
import { FC, useEffect, useMemo, useState } from 'react';
import { INickIconItem, IPrefixItem, PRESET_PREFIX_EFFECTS, PRESET_PREFIX_FONTS, SendMessageComposer, getPrefixEffectStyle, getPrefixFontStyle, parsePrefixColors } from '../../api';
import { GetNickIconUrl } from '../../assets/images/user_custom/nick_icons';
import { Button, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text, UserIdentityView } from '../../common';
import { LayoutCurrencyIcon } from '../../common/layout/LayoutCurrencyIcon';
import { useMessageEvent } from '../../hooks';
type CustomizeTab = 'icons' | 'prefix' | 'settings';
type PrefixSubTab = 'library' | 'custom';
interface ICatalogPrefixItem extends IPrefixItem
{
points: number;
pointsType: number;
owned: boolean;
ownedPrefixId: number;
}
interface ICombinedPrefixItem extends IPrefixItem
{
points: number;
pointsType: number;
owned: boolean;
ownedPrefixId: number;
}
const ORDER_LABELS: Record<string, string> = {
'icon-prefix-name': 'Icon / Prefix / Name',
'prefix-icon-name': 'Prefix / Icon / Name',
'name-icon-prefix': 'Name / Icon / Prefix',
'name-prefix-icon': 'Name / Prefix / Icon',
'icon-name-prefix': 'Icon / Name / Prefix',
'prefix-name-icon': 'Prefix / Name / Icon'
};
const PRESET_COLORS: string[] = [
'#D62828', '#E85D04', '#F77F00', '#2A9D8F',
'#0077B6', '#4361EE', '#6A4C93', '#C1121F',
'#B5179E', '#3A86FF', '#3F8E00', '#8D5524'
];
export const CustomizeNickIconView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const [ isLoading, setIsLoading ] = useState(false);
const [ activeTab, setActiveTab ] = useState<CustomizeTab>('icons');
const [ activePrefixSubTab, setActivePrefixSubTab ] = useState<PrefixSubTab>('library');
const [ iconItems, setIconItems ] = useState<INickIconItem[]>([]);
const [ prefixItems, setPrefixItems ] = useState<IPrefixItem[]>([]);
const [ catalogPrefixes, setCatalogPrefixes ] = useState<ICatalogPrefixItem[]>([]);
const [ displayOrder, setDisplayOrder ] = useState('icon-prefix-name');
const [ customPrefixMaxLength, setCustomPrefixMaxLength ] = useState(15);
const [ customPrefixPriceCredits, setCustomPrefixPriceCredits ] = useState(0);
const [ customPrefixPricePoints, setCustomPrefixPricePoints ] = useState(0);
const [ customPrefixPointsType, setCustomPrefixPointsType ] = useState(0);
const [ customPrefixFontPriceCredits, setCustomPrefixFontPriceCredits ] = useState(0);
const [ customPrefixFontPricePoints, setCustomPrefixFontPricePoints ] = useState(0);
const [ customPrefixFontPointsType, setCustomPrefixFontPointsType ] = useState(0);
const [ customPrefixText, setCustomPrefixText ] = useState('');
const [ customPrefixColor, setCustomPrefixColor ] = useState('#FFFFFF');
const [ customPrefixIcon, setCustomPrefixIcon ] = useState('');
const [ customPrefixEffect, setCustomPrefixEffect ] = useState('');
const [ customPrefixFont, setCustomPrefixFont ] = useState('');
const [ showEmojiPicker, setShowEmojiPicker ] = useState(false);
useMessageEvent<UserNickIconsEvent>(UserNickIconsEvent, event =>
{
const parser = event.getParser();
setIconItems(parser.nickIcons.map(icon => ({
id: icon.id,
iconKey: icon.iconKey,
displayName: icon.displayName,
points: icon.points,
pointsType: icon.pointsType,
owned: icon.owned,
active: icon.active
})));
setPrefixItems(parser.ownedPrefixes.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon || '',
effect: prefix.effect || '',
font: prefix.font || '',
active: prefix.active,
isCustom: prefix.isCustom,
points: prefix.points,
pointsType: prefix.pointsType,
catalogPrefixId: prefix.catalogPrefixId
})));
setCatalogPrefixes(parser.prefixCatalog.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon || '',
effect: prefix.effect || '',
font: prefix.font || '',
active: prefix.active,
points: prefix.points,
pointsType: prefix.pointsType,
owned: prefix.owned,
ownedPrefixId: prefix.ownedPrefixId
})));
setDisplayOrder(parser.displayOrder || 'icon-prefix-name');
setCustomPrefixMaxLength(parser.customPrefixMaxLength || 15);
setCustomPrefixPriceCredits(parser.customPrefixPriceCredits || 0);
setCustomPrefixPricePoints(parser.customPrefixPricePoints || 0);
setCustomPrefixPointsType(parser.customPrefixPointsType || 0);
setCustomPrefixFontPriceCredits(parser.customPrefixFontPriceCredits || 0);
setCustomPrefixFontPricePoints(parser.customPrefixFontPricePoints || 0);
setCustomPrefixFontPointsType(parser.customPrefixFontPointsType || 0);
setIsLoading(false);
});
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(previousValue => !previousValue);
return;
}
},
eventUrlPrefix: 'customize/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(!isVisible) return;
setIsLoading(true);
SendMessageComposer(new RequestNickIconsComposer());
}, [ isVisible ]);
const activeIcon = useMemo(() => iconItems.find(item => item.active) || null, [ iconItems ]);
const activePrefix = useMemo(() => prefixItems.find(item => item.active) || null, [ prefixItems ]);
const combinedPrefixes = useMemo(() =>
{
const ownedByCatalogId = new Map<number, IPrefixItem>();
for(const prefix of prefixItems)
{
if(prefix.catalogPrefixId && (prefix.catalogPrefixId > 0)) ownedByCatalogId.set(prefix.catalogPrefixId, prefix);
}
const catalogEntries: ICombinedPrefixItem[] = catalogPrefixes.map(prefix =>
{
const ownedPrefix = ownedByCatalogId.get(prefix.id);
return {
id: ownedPrefix?.id || prefix.id,
displayName: ownedPrefix?.displayName || prefix.displayName,
text: ownedPrefix?.text || prefix.text,
color: ownedPrefix?.color || prefix.color,
icon: ownedPrefix?.icon || prefix.icon,
effect: ownedPrefix?.effect || prefix.effect,
font: ownedPrefix?.font || prefix.font,
active: ownedPrefix?.active || prefix.active,
isCustom: false,
points: prefix.points,
pointsType: prefix.pointsType,
catalogPrefixId: prefix.id,
owned: prefix.owned || !!ownedPrefix,
ownedPrefixId: prefix.ownedPrefixId || ownedPrefix?.id || 0
};
});
const customEntries: ICombinedPrefixItem[] = prefixItems
.filter(prefix => !prefix.catalogPrefixId || (prefix.catalogPrefixId <= 0))
.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon,
effect: prefix.effect,
font: prefix.font || '',
active: prefix.active,
isCustom: true,
points: prefix.points || customPrefixPricePoints,
pointsType: prefix.pointsType || customPrefixPointsType,
catalogPrefixId: 0,
owned: true,
ownedPrefixId: prefix.id
}));
return [ ...catalogEntries, ...customEntries ];
}, [ catalogPrefixes, customPrefixPointsType, customPrefixPricePoints, prefixItems ]);
const selectedEffectOption = useMemo(() => PRESET_PREFIX_EFFECTS.find(effect => effect.id === customPrefixEffect) || PRESET_PREFIX_EFFECTS[0], [ customPrefixEffect ]);
const selectedFontOption = useMemo(() => PRESET_PREFIX_FONTS.find(font => font.id === customPrefixFont) || PRESET_PREFIX_FONTS[0], [ customPrefixFont ]);
const basicEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'basic'), []);
const premiumEffects = useMemo(() => PRESET_PREFIX_EFFECTS.filter(effect => effect.tier === 'premium'), []);
const basicFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'basic'), []);
const premiumFonts = useMemo(() => PRESET_PREFIX_FONTS.filter(font => font.tier === 'premium'), []);
const prefixPreviewColors = useMemo(() => parsePrefixColors(customPrefixText || 'Preview', customPrefixColor || '#FFFFFF'), [ customPrefixText, customPrefixColor ]);
const customPrefixPreviewStyle = useMemo(() => getPrefixEffectStyle(customPrefixEffect, prefixPreviewColors[0] || '#FFFFFF'), [ customPrefixEffect, prefixPreviewColors ]);
const customPrefixFontStyle = useMemo(() => getPrefixFontStyle(customPrefixFont), [ customPrefixFont ]);
const customPrefixTotalCredits = useMemo(() => customPrefixPriceCredits + (customPrefixFont ? customPrefixFontPriceCredits : 0), [ customPrefixFont, customPrefixFontPriceCredits, customPrefixPriceCredits ]);
const customPrefixTotalPoints = useMemo(() => customPrefixPricePoints + ((customPrefixFont && (customPrefixFontPointsType === customPrefixPointsType)) ? customPrefixFontPricePoints : 0), [ customPrefixFont, customPrefixFontPointsType, customPrefixFontPricePoints, customPrefixPointsType, customPrefixPricePoints ]);
const customPrefixIsValid = useMemo(() =>
{
const trimmed = customPrefixText.trim();
if(!trimmed.length || (trimmed.length > customPrefixMaxLength)) return false;
return customPrefixColor.split(',').every(color => /^#[0-9A-Fa-f]{6}$/.test(color));
}, [ customPrefixColor, customPrefixMaxLength, customPrefixText ]);
const refreshCustomizeData = () =>
{
setIsLoading(true);
SendMessageComposer(new RequestNickIconsComposer());
};
const handleIconAction = (item: INickIconItem) =>
{
setIsLoading(true);
if(!item.owned)
{
SendMessageComposer(new PurchaseNickIconComposer(item.iconKey));
return;
}
SendMessageComposer(new SetActiveNickIconComposer(item.active ? 0 : item.id));
};
const handleCombinedPrefixAction = (item: ICombinedPrefixItem) =>
{
setIsLoading(true);
if(item.owned)
{
SendMessageComposer(new SetActivePrefixComposer(item.active ? 0 : item.ownedPrefixId));
return;
}
SendMessageComposer(new PurchaseCatalogPrefixComposer(item.catalogPrefixId || item.id));
};
const handleCustomPrefixPurchase = () =>
{
if(!customPrefixIsValid) return;
setIsLoading(true);
SendMessageComposer(new PurchasePrefixComposer(customPrefixText.trim(), customPrefixColor, customPrefixIcon, customPrefixEffect, customPrefixFont));
};
const handleDisplayOrderChange = (nextDisplayOrder: string) =>
{
if(nextDisplayOrder === displayOrder) return;
setDisplayOrder(nextDisplayOrder);
setIsLoading(true);
SendMessageComposer(new SetDisplayOrderComposer(nextDisplayOrder));
};
if(!isVisible) return null;
return (
<NitroCardView className="customize-nick-icon-window w-[680px] max-w-[95vw]" theme="primary-slim" uniqueKey="customize-nick-icons">
<NitroCardHeaderView headerText="Customize Bubble Identity" onCloseClick={ () => setIsVisible(false) } />
<NitroCardTabsView>
<NitroCardTabsItemView isActive={ activeTab === 'icons' } onClick={ () => setActiveTab('icons') }>
<Text>Icons</Text>
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === 'prefix' } onClick={ () => setActiveTab('prefix') }>
<Text>Prefix</Text>
</NitroCardTabsItemView>
<NitroCardTabsItemView isActive={ activeTab === 'settings' } onClick={ () => setActiveTab('settings') }>
<Text>Settings</Text>
</NitroCardTabsItemView>
</NitroCardTabsView>
<NitroCardContentView className="flex max-h-[78vh] flex-col gap-3 overflow-y-auto text-black">
<div className="rounded border border-black/10 bg-black/5 p-3">
<Text bold>Live preview</Text>
<div className="mt-2 flex min-h-[54px] items-center justify-center rounded border border-black/10 bg-[#1f2937] px-3 py-2 text-white">
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ activePrefix?.color || customPrefixColor }
prefixEffect={ activePrefix?.effect || customPrefixEffect }
prefixFont={ activePrefix?.font || customPrefixFont }
prefixIcon={ activePrefix?.icon || customPrefixIcon }
prefixText={ activePrefix?.text || customPrefixText }
username="Username" />
</div>
</div>
{ activeTab === 'icons' &&
<>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
Choose the icon shown in your bubble identity.
</div>
<div className="grid grid-cols-3 gap-2">
{ iconItems.map(item =>
{
const iconUrl = GetNickIconUrl(item.iconKey);
return (
<div
key={ item.iconKey }
className={ `relative flex min-h-[126px] flex-col items-center justify-between gap-2 rounded border p-3 transition-colors ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<img className="h-auto max-h-[28px] w-auto object-contain" src={ iconUrl } alt={ item.iconKey } />
<div className="flex flex-col items-center gap-1 text-center text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="max-w-[140px] truncate">{ item.displayName || `Icon #${ item.iconKey }` }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleIconAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
);
}) }
</div>
</> }
{ activeTab === 'prefix' &&
<div className="flex flex-col gap-3">
<div className="rounded border border-black/10 bg-black/5 p-1">
<div className="flex items-center gap-2">
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activePrefixSubTab === 'library' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActivePrefixSubTab('library') }>
Library
</button>
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activePrefixSubTab === 'custom' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActivePrefixSubTab('custom') }>
Custom
</button>
</div>
</div>
{ activePrefixSubTab === 'library' &&
<>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
Choose a preset or custom prefix for your bubble identity.
</div>
<div className="grid grid-cols-2 gap-2">
{ combinedPrefixes.map(item => (
<div key={ `${ item.catalogPrefixId || 'custom' }-${ item.ownedPrefixId || item.id }` } className={ `relative flex min-h-[96px] flex-col gap-2 rounded border p-2.5 ${ item.active ? 'border-[#1e7295] bg-[#dff3fb]' : 'border-black/10 bg-black/5' }` }>
{ item.active && <span className="absolute right-1 top-1 rounded bg-[#1e7295] px-1.5 py-0.5 text-[9px] font-bold uppercase text-white">Active</span> }
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ item.color }
prefixEffect={ item.effect }
prefixFont={ item.font || '' }
prefixIcon={ item.icon }
prefixText={ item.text }
username="Username" />
<div className="flex flex-col gap-1 text-[11px]">
<span>{ item.owned ? (item.active ? 'Owned - Active' : 'Owned') : 'Locked' }</span>
<span className="truncate">{ item.displayName || item.text }{ item.isCustom ? ' - Custom' : '' }</span>
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ item.pointsType } />
{ item.points }
</span>
</div>
<Button disabled={ isLoading } onClick={ () => handleCombinedPrefixAction(item) }>
{ !item.owned && 'Buy' }
{ item.owned && !item.active && 'Activate' }
{ item.owned && item.active && 'Deactivate' }
</Button>
</div>
)) }
</div>
</> }
{ activePrefixSubTab === 'custom' &&
<div className="rounded border border-black/10 bg-black/5 p-3">
<div className="mb-2 flex items-center justify-between">
<Text bold>Custom prefix</Text>
<Button disabled={ isLoading } onClick={ refreshCustomizeData }>Refresh</Button>
</div>
<div className="mt-2 flex flex-col gap-2">
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-black/10 bg-white px-3 py-2 text-sm"
maxLength={ customPrefixMaxLength }
placeholder="Enter your prefix"
type="text"
value={ customPrefixText }
onChange={ event => setCustomPrefixText(event.target.value) } />
<span className="text-[11px] text-black/60">{ customPrefixText.length }/{ customPrefixMaxLength }</span>
</div>
<div className="flex flex-wrap items-center gap-2">
<button className="rounded border border-black/10 bg-white px-3 py-2 text-sm" type="button" onClick={ () => setShowEmojiPicker(true) }>
{ customPrefixIcon || 'Emoji' }
</button>
{ !!customPrefixIcon && <Button onClick={ () => setCustomPrefixIcon('') }>Clear</Button> }
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Safe colors only, chosen to stay readable on both light and dark backgrounds.
</div>
<div className="grid grid-cols-6 gap-2">
{ PRESET_COLORS.map(color => (
<button
key={ color }
className={ `flex h-[28px] items-center justify-center rounded border text-[10px] font-bold uppercase ${ customPrefixColor === color ? 'border-[#1e7295] ring-1 ring-[#1e7295]' : 'border-black/10' }` }
style={ { backgroundColor: color } }
type="button"
onClick={ () => setCustomPrefixColor(color) }>
{ customPrefixColor === color ? 'ON' : '' }
</button>
)) }
</div>
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Effect
</div>
<div className="flex items-center gap-2">
<select
className="flex-1 rounded border border-black/10 bg-white px-2 py-2 text-sm"
value={ customPrefixEffect }
onChange={ event => setCustomPrefixEffect(event.target.value) }>
<optgroup label="Basic">
{ basicEffects.map(effect => (
<option key={ effect.id || 'none' } value={ effect.id }>
{ effect.label }
</option>
)) }
</optgroup>
<optgroup label="Premium">
{ premiumEffects.map(effect => (
<option key={ effect.id } value={ effect.id }>
{ effect.label }
</option>
)) }
</optgroup>
</select>
<div className="min-w-[130px] rounded border border-black/10 bg-black/5 px-2 py-2 text-center text-[11px] font-bold">
{ selectedEffectOption.icon } { selectedEffectOption.label }
<div className="mt-1 text-[9px] uppercase text-black/60">
{ selectedEffectOption.tier }
</div>
</div>
</div>
</div>
<div className="rounded border border-black/10 bg-white p-2">
<div className="mb-2 text-[11px] leading-4 text-black/70">
Font
</div>
<div className="flex items-center gap-2">
<select
className="flex-1 rounded border border-black/10 bg-white px-2 py-2 text-sm"
value={ customPrefixFont }
onChange={ event => setCustomPrefixFont(event.target.value) }>
<optgroup label="Basic">
{ basicFonts.map(font => (
<option key={ font.id || 'default' } value={ font.id }>
{ font.label }
</option>
)) }
</optgroup>
<optgroup label="Premium">
{ premiumFonts.map(font => (
<option key={ font.id } value={ font.id }>
{ font.label }
</option>
)) }
</optgroup>
</select>
<div className="min-w-[130px] rounded border border-black/10 bg-black/5 px-2 py-2 text-center text-[11px] font-bold">
<span style={ customPrefixFontStyle }>{ selectedFontOption.label }</span>
<div className="mt-1 text-[9px] uppercase text-black/60">
{ selectedFontOption.tier }
</div>
</div>
</div>
{ !!customPrefixFont &&
<div className="mt-2 text-[10px] leading-4 text-black/60">
Premium fonts add an extra price on top of the custom prefix.
</div> }
</div>
<div className="rounded border border-black/10 bg-[#1f2937] px-3 py-2 text-white" style={ customPrefixPreviewStyle }>
<UserIdentityView
displayOrder={ displayOrder }
nickIcon={ activeIcon?.iconKey || '' }
prefixColor={ customPrefixColor }
prefixEffect={ customPrefixEffect }
prefixFont={ customPrefixFont }
prefixIcon={ customPrefixIcon }
prefixText={ customPrefixText || 'Preview' }
username="Username" />
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[12px]">
{ customPrefixTotalCredits > 0 && <span>{ customPrefixTotalCredits } credits</span> }
{ customPrefixTotalPoints > 0 &&
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ customPrefixPointsType } />
{ customPrefixTotalPoints }
</span> }
{ !!customPrefixFont && (customPrefixFontPointsType !== customPrefixPointsType) && (customPrefixFontPricePoints > 0) &&
<span className="inline-flex items-center gap-1">
<LayoutCurrencyIcon type={ customPrefixFontPointsType } />
{ customPrefixFontPricePoints }
</span> }
</div>
<Button disabled={ !customPrefixIsValid || isLoading } onClick={ handleCustomPrefixPurchase }>
Buy custom prefix
</Button>
</div>
</div>
</div> }
</div> }
{ activeTab === 'settings' &&
<div className="flex flex-col gap-3">
<div className="rounded border border-black/10 bg-black/5 p-3">
<Text bold>Display order</Text>
<div className="mt-2 grid grid-cols-2 gap-2">
{ Object.entries(ORDER_LABELS).map(([ key, label ]) => (
<Button key={ key } disabled={ isLoading && (displayOrder === key) } onClick={ () => handleDisplayOrderChange(key) }>
{ displayOrder === key ? '* ' : '' }{ label }
</Button>
)) }
</div>
</div>
<div className="rounded border border-black/10 bg-black/5 p-3">
<div className="mb-2 flex items-center justify-between">
<Text bold>Refresh data</Text>
<Button disabled={ isLoading } onClick={ refreshCustomizeData }>Refresh</Button>
</div>
<div className="text-[11px] leading-4 text-black/70">
Use this tab to control how your icon, prefix and username are ordered in bubbles, profile and infostand.
</div>
</div>
</div> }
</NitroCardContentView>
{ showEmojiPicker &&
<>
<div className="fixed inset-0 z-[999]" onClick={ () => setShowEmojiPicker(false) } />
<div className="fixed left-1/2 top-1/2 z-[1000] -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-xl shadow-2xl">
<Picker
data={ data }
locale="en"
onEmojiSelect={ (emoji: { native: string }) => { setCustomPrefixIcon(emoji.native); setShowEmojiPicker(false); } }
previewPosition="none"
set="native"
theme="dark" />
</div>
</> }
</NitroCardView>
);
};
@@ -13,13 +13,19 @@ interface FriendsRemoveConfirmationViewProps
export const FriendsRemoveConfirmationView: FC<FriendsRemoveConfirmationViewProps> = props => export const FriendsRemoveConfirmationView: FC<FriendsRemoveConfirmationViewProps> = props =>
{ {
const { selectedFriendsIds = null, removeFriendsText = null, removeSelectedFriends = null, onCloseClick = null } = 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 ( return (
<NitroCardView className="nitro-friends-remove-confirmation" theme="primary-slim"> <NitroCardView className="nitro-friends-remove-confirmation" theme="primary-slim" isResizable={ false } style={ { width: 270, height: 225, minWidth: 270, minHeight: 225, maxWidth: 270, maxHeight: 225 } }>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.removefriendconfirm.title') } onCloseClick={ onCloseClick } /> <NitroCardHeaderView headerText={ LocalizeText('friendlist.removefriendconfirm.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="nitro-friends-remove-confirmation-content text-black">
<div>{ removeFriendsText }</div> <div className="nitro-friends-remove-confirmation-text">
<div className="flex gap-1"> <div>{ removeFriendsLeadText }</div>
{ removeFriendsNamesText.length > 0 && <div className="nitro-friends-remove-confirmation-names">{ removeFriendsNamesText }</div> }
</div>
<div className="nitro-friends-remove-confirmation-actions">
<Button fullWidth disabled={ (selectedFriendsIds.length === 0) } variant="danger" onClick={ removeSelectedFriends }>{ LocalizeText('generic.ok') }</Button> <Button fullWidth disabled={ (selectedFriendsIds.length === 0) } variant="danger" onClick={ removeSelectedFriends }>{ LocalizeText('generic.ok') }</Button>
<Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button> <Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button>
</div> </div>
@@ -15,13 +15,13 @@ export const FriendsRoomInviteView: FC<FriendsRoomInviteViewProps> = props =>
const [ roomInviteMessage, setRoomInviteMessage ] = useState<string>(''); const [ roomInviteMessage, setRoomInviteMessage ] = useState<string>('');
return ( return (
<NitroCardView className="nitro-friends-room-invite" theme="primary-slim" uniqueKey="nitro-friends-room-invite"> <NitroCardView className="nitro-friends-room-invite" theme="primary-slim" uniqueKey="nitro-friends-room-invite" isResizable={ false } style={ { width: 270, height: 225, minWidth: 270, minHeight: 225, maxWidth: 270, maxHeight: 225 } }>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.invite.title') } onCloseClick={ onCloseClick } /> <NitroCardHeaderView headerText={ LocalizeText('friendlist.invite.title') } onCloseClick={ onCloseClick } />
<NitroCardContentView className="text-black"> <NitroCardContentView className="nitro-friends-room-invite-content text-black" gap={ 2 }>
{ LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) } <Text className="nitro-friends-room-invite-summary">{ LocalizeText('friendlist.invite.summary', [ 'count' ], [ selectedFriendsIds.length.toString() ]) }</Text>
<textarea className="min-h-[calc(1.5em+ .5rem+2px)] px-[.5rem] py-[.25rem] rounded-[.2rem]" maxLength={ 255 } value={ roomInviteMessage } onChange={ event => setRoomInviteMessage(event.target.value) }></textarea> <textarea className="nitro-friends-room-invite-textarea" maxLength={ 255 } value={ roomInviteMessage } onChange={ event => setRoomInviteMessage(event.target.value) }></textarea>
<Text center className="bg-muted rounded p-1">{ LocalizeText('friendlist.invite.note') }</Text> <Text center className="nitro-friends-room-invite-note">{ LocalizeText('friendlist.invite.note') }</Text>
<div className="flex gap-1"> <div className="nitro-friends-room-invite-actions">
<Button fullWidth disabled={ ((roomInviteMessage.length === 0) || (selectedFriendsIds.length === 0)) } variant="success" onClick={ () => sendRoomInvite(roomInviteMessage) }>{ LocalizeText('friendlist.invite.send') }</Button> <Button fullWidth disabled={ ((roomInviteMessage.length === 0) || (selectedFriendsIds.length === 0)) } variant="success" onClick={ () => sendRoomInvite(roomInviteMessage) }>{ LocalizeText('friendlist.invite.send') }</Button>
<Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button> <Button fullWidth onClick={ onCloseClick }>{ LocalizeText('generic.cancel') }</Button>
</div> </div>
@@ -1,8 +1,10 @@
import { HabboSearchComposer, HabboSearchResultData, HabboSearchResultEvent } from '@nitrots/nitro-renderer'; import { HabboSearchComposer, HabboSearchResultData, HabboSearchResultEvent } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { LocalizeText, OpenMessengerChat, SendMessageComposer } from '../../../../api'; 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 { useFriends, useMessageEvent } from '../../../../hooks';
import { resolveAvatarFigure } from './resolveAvatarFigure';
import { resolveAvatarGender } from './resolveAvatarGender';
interface FriendsSearchViewProps extends NitroCardAccordionSetViewProps interface FriendsSearchViewProps extends NitroCardAccordionSetViewProps
{ {
@@ -17,6 +19,22 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
const [ otherResults, setOtherResults ] = useState<HabboSearchResultData[]>(null); const [ otherResults, setOtherResults ] = useState<HabboSearchResultData[]>(null);
const { canRequestFriend = null, requestFriend = null } = useFriends(); const { canRequestFriend = null, requestFriend = null } = useFriends();
const getSearchResultFigure = (result: HabboSearchResultData) =>
{
if(!result) return null;
const typedResult = (result as HabboSearchResultData & { figureString?: string; avatarFigure?: string; figure?: string; avatarFigureString?: string });
return typedResult.figureString || typedResult.avatarFigure || typedResult.figure || typedResult.avatarFigureString || null;
};
const getSearchResultGender = (result: HabboSearchResultData) =>
{
const typedResult = (result as HabboSearchResultData & { gender?: string | number; avatarGender?: string | number });
return resolveAvatarGender(typedResult.avatarGender ?? typedResult.gender);
};
useMessageEvent<HabboSearchResultEvent>(HabboSearchResultEvent, event => useMessageEvent<HabboSearchResultEvent>(HabboSearchResultEvent, event =>
{ {
const parser = event.getParser(); const parser = event.getParser();
@@ -55,10 +73,15 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
{ friendResults.map(result => { friendResults.map(result =>
{ {
return ( return (
<NitroCardAccordionItemView key={ result.avatarId } className="px-2 py-1" justifyContent="between"> <NitroCardAccordionItemView key={ result.avatarId } className="friends-list-item px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1"> <div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(getSearchResultFigure(result), getSearchResultGender(result)) } gender={ getSearchResultGender(result) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ result.avatarId } /> <UserProfileIconView userId={ result.avatarId } />
<div>{ result.avatarName }</div> </div>
<div className="friends-list-name">{ result.avatarName }</div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{ result.isAvatarOnline && { result.isAvatarOnline &&
@@ -82,10 +105,15 @@ export const FriendsSearchView: FC<FriendsSearchViewProps> = props =>
{ otherResults.map(result => { otherResults.map(result =>
{ {
return ( return (
<NitroCardAccordionItemView key={ result.avatarId } className="px-2 py-1" justifyContent="between"> <NitroCardAccordionItemView key={ result.avatarId } className="friends-list-item px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1"> <div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(getSearchResultFigure(result), getSearchResultGender(result)) } gender={ getSearchResultGender(result) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ result.avatarId } /> <UserProfileIconView userId={ result.avatarId } />
<div>{ result.avatarName }</div> </div>
<div className="friends-list-name">{ result.avatarName }</div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{ canRequestFriend(result.avatarId) && { canRequestFriend(result.avatarId) &&
@@ -34,7 +34,7 @@ export const FriendsListView: FC<{}> = props =>
userNames.push(existingFriend.name); 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 ]); }, [ offlineFriends, onlineFriends, selectedFriendsIds ]);
const selectFriend = useCallback((userId: number) => const selectFriend = useCallback((userId: number) =>
@@ -60,6 +60,27 @@ export const FriendsListView: FC<{}> = props =>
}); });
}, [ setSelectedFriendsIds ]); }, [ 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) => const sendRoomInvite = (message: string) =>
{ {
if(!selectedFriendsIds.length || !message || !message.length || (message.length > 255)) return; if(!selectedFriendsIds.length || !message || !message.length || (message.length > 255)) return;
@@ -125,10 +146,24 @@ export const FriendsListView: FC<{}> = props =>
<NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ event => setIsVisible(false) } /> <NitroCardHeaderView headerText={ LocalizeText('friendlist.friends') } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView className="text-black p-0" gap={ 1 } overflow="hidden"> <NitroCardContentView className="text-black p-0" gap={ 1 } overflow="hidden">
<NitroCardAccordionView fullHeight overflow="hidden"> <NitroCardAccordionView fullHeight overflow="hidden">
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }> <NitroCardAccordionSetView className="friends-list-section" headerText={ LocalizeText('friendlist.friends') + ` (${ onlineFriends.length })` } isExpanded={ true }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(onlineFriends.map(friend => friend.id)); } }>
{ onlineFriends.length && onlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ onlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } /> <FriendsListGroupView list={ onlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView> </NitroCardAccordionSetView>
<NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }> <NitroCardAccordionSetView headerText={ LocalizeText('friendlist.friends.offlinecaption') + ` (${ offlineFriends.length })` }>
<Flex className="friends-list-toolbar px-2 py-1" justifyContent="end">
<span className="friends-list-toolbar-link" onClick={ event => { event.stopPropagation(); toggleSelectFriends(offlineFriends.map(friend => friend.id)); } }>
{ offlineFriends.length && offlineFriends.every(friend => (selectedFriendsIds.indexOf(friend.id) >= 0))
? LocalizeText('friendlist.unselect_all')
: LocalizeText('friendlist.select_all') }
</span>
</Flex>
<FriendsListGroupView list={ offlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } /> <FriendsListGroupView list={ offlineFriends } selectedFriendsIds={ selectedFriendsIds } selectFriend={ selectFriend } />
</NitroCardAccordionSetView> </NitroCardAccordionSetView>
<FriendsListRequestView headerText={ LocalizeText('friendlist.tab.friendrequests') + ` (${ requests.length })` } isExpanded={ true } /> <FriendsListRequestView headerText={ LocalizeText('friendlist.tab.friendrequests') + ` (${ requests.length })` } isExpanded={ true } />
@@ -1,7 +1,9 @@
import { FC, MouseEvent, useState } from 'react'; import { FC, MouseEvent, useState } from 'react';
import { LocalizeText, MessengerFriend, OpenMessengerChat } from '../../../../../api'; import { LocalizeText, MessengerFriend, OpenMessengerChat } from '../../../../../api';
import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { useFriends } from '../../../../../hooks'; 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 => 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; if(!friend) return null;
return ( return (
<NitroCardAccordionItemView className={ `px-2 py-1 ${ selected && 'bg-primary text-white' }` } justifyContent="between" onClick={ event => selectFriend(friend.id) }> <NitroCardAccordionItemView className={ `friends-list-item ${ selected ? 'selected' : '' }` } justifyContent="between" onClick={ event => selectFriend(friend.id) }>
<div className="flex items-center gap-1"> <div className="friends-list-user">
<div className="friends-list-avatar">
<LayoutAvatarImageView figure={ resolveAvatarFigure(friend.figure, friend.gender) } gender={ resolveAvatarGender(friend.gender) } headOnly={ true } direction={ 2 } />
</div>
<div onClick={ event => event.stopPropagation() }> <div onClick={ event => event.stopPropagation() }>
<UserProfileIconView userId={ friend.id } /> <UserProfileIconView userId={ friend.id } />
</div> </div>
<div>{ friend.name }</div> <div className="friends-list-name">{ friend.name }</div>
</div> </div>
<div className="flex items-center gap-1"> <div className="friends-list-actions">
{ !isRelationshipOpen && { !isRelationshipOpen &&
<> <>
{ friend.online && { friend.online &&
@@ -1,7 +1,9 @@
import { FC } from 'react'; import { FC } from 'react';
import { MessengerRequest } from '../../../../../api'; import { LocalizeText, MessengerRequest } from '../../../../../api';
import { NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common'; import { Button, LayoutAvatarImageView, NitroCardAccordionItemView, UserProfileIconView } from '../../../../../common';
import { useFriends } from '../../../../../hooks'; import { useFriends } from '../../../../../hooks';
import { resolveAvatarFigure } from '../resolveAvatarFigure';
import { resolveAvatarGender } from '../resolveAvatarGender';
export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = props => export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = props =>
{ {
@@ -11,14 +13,23 @@ export const FriendsListRequestItemView: FC<{ request: MessengerRequest }> = pro
if(!request) return null; if(!request) return null;
return ( return (
<NitroCardAccordionItemView className="px-2 py-1" justifyContent="between"> <NitroCardAccordionItemView className="friends-list-item px-2 py-1" justifyContent="between">
<div className="flex items-center gap-1"> <div className="friends-list-user">
<UserProfileIconView userId={ request.id } /> <div className="friends-list-avatar">
<div>{ request.name }</div> <LayoutAvatarImageView figure={ resolveAvatarFigure(request.figureString) } gender={ resolveAvatarGender(undefined) } headOnly={ true } direction={ 2 } />
</div>
<div>
<UserProfileIconView userId={ request.requesterUserId } />
</div>
<div className="friends-list-name">{ request.name }</div>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="nitro-friends-spritesheet icon-accept cursor-pointer" onClick={ event => requestResponse(request.id, true) } /> <Button size="sm" onClick={ event => requestResponse(request.id, true) }>
<div className="nitro-friends-spritesheet icon-deny cursor-pointer" onClick={ event => requestResponse(request.id, false) } /> { LocalizeText('friendlist.request_accept') }
</Button>
<Button size="sm" variant="danger" onClick={ event => requestResponse(request.id, false) }>
{ LocalizeText('friendlist.request_decline') }
</Button>
</div> </div>
</NitroCardAccordionItemView> </NitroCardAccordionItemView>
); );
@@ -17,8 +17,11 @@ export const FriendsListRequestView: FC<NitroCardAccordionSetViewProps> = props
<Column gap={ 0 }> <Column gap={ 0 }>
{ requests.map((request, index) => <FriendsListRequestItemView key={ index } request={ request } />) } { requests.map((request, index) => <FriendsListRequestItemView key={ index } request={ request } />) }
</Column> </Column>
<div className="flex justify-center px-2 py-1"> <div className="flex justify-center gap-2 px-2 py-1">
<Button onClick={ event => requestResponse(-1, false) }> <Button onClick={ event => requests.forEach(request => requestResponse(request.id, true)) }>
{ LocalizeText('friendlist.requests.acceptall') }
</Button>
<Button variant="danger" onClick={ event => requestResponse(-1, false) }>
{ LocalizeText('friendlist.requests.dismissall') } { LocalizeText('friendlist.requests.dismissall') }
</Button> </Button>
</div> </div>
@@ -0,0 +1,15 @@
import { resolveAvatarGender } from './resolveAvatarGender';
const DEFAULT_AVATAR_FIGURES: Record<string, string> = {
M: 'hd-180-1.ch-210-66.lg-270-82.sh-290-80',
F: 'hd-600-1.ch-630-66.lg-695-82.sh-725-80'
};
export const resolveAvatarFigure = (figure: string | null | undefined, gender?: string | number | null) =>
{
const normalizedFigure = (figure || '').trim();
if(normalizedFigure.length && normalizedFigure.includes('hd-')) return normalizedFigure;
return DEFAULT_AVATAR_FIGURES[resolveAvatarGender(gender)] || DEFAULT_AVATAR_FIGURES.M;
};
@@ -0,0 +1,20 @@
export const resolveAvatarGender = (value: string | number | null | undefined) =>
{
if(typeof value === 'string')
{
const normalized = value.trim().toUpperCase();
if(normalized === 'F') return 'F';
if(normalized === 'M') return 'M';
if(normalized === 'FEMALE') return 'F';
if(normalized === 'MALE') return 'M';
}
if(typeof value === 'number')
{
if(value === 2) return 'F';
if(value === 1) return 'M';
}
return 'M';
};
@@ -2,9 +2,8 @@ import { AddLinkEventTracker, FollowFriendMessageComposer, GetSessionDataManager
import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react'; import { FC, KeyboardEvent, useEffect, useRef, useState } from 'react';
import { FaTimes } from 'react-icons/fa'; import { FaTimes } from 'react-icons/fa';
import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api'; import { GetUserProfile, LocalizeText, ReportType, SendMessageComposer } from '../../../../api';
import { Button, Column, Flex, Grid, LayoutAvatarImageView, LayoutGridItem, LayoutItemCountView, NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../../../common'; import { DraggableWindowPosition, LayoutAvatarImageView, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useHelp, useMessenger } from '../../../../hooks'; import { useHelp, useMessenger, useTranslation } from '../../../../hooks';
import { NitroInput } from '../../../../layout';
import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView'; import { FriendsMessengerThreadView } from './messenger-thread/FriendsMessengerThreadView';
export const FriendsMessengerView: FC<{}> = props => export const FriendsMessengerView: FC<{}> = props =>
@@ -14,15 +13,35 @@ export const FriendsMessengerView: FC<{}> = props =>
const [ messageText, setMessageText ] = useState(''); const [ messageText, setMessageText ] = useState('');
const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger(); const { visibleThreads = [], activeThread = null, getMessageThread = null, sendMessage = null, setActiveThreadId = null, closeThread = null } = useMessenger();
const { report = null } = useHelp(); const { report = null } = useHelp();
const { settings, translateOutgoing } = useTranslation();
const messagesBox = useRef<HTMLDivElement>(); const messagesBox = useRef<HTMLDivElement>();
const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id))); const followFriend = () => (activeThread && activeThread.participant && SendMessageComposer(new FollowFriendMessageComposer(activeThread.participant.id)));
const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id)); const openProfile = () => (activeThread && activeThread.participant && GetUserProfile(activeThread.participant.id));
const send = () => const send = async () =>
{ {
if(!activeThread || !messageText.length) return; 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); sendMessage(activeThread, GetSessionDataManager().userId, messageText);
setMessageText(''); setMessageText('');
@@ -32,7 +51,7 @@ export const FriendsMessengerView: FC<{}> = props =>
{ {
if(event.key !== 'Enter') return; if(event.key !== 'Enter') return;
send(); void send();
}; };
useEffect(() => useEffect(() =>
@@ -107,71 +126,60 @@ export const FriendsMessengerView: FC<{}> = props =>
if(!isVisible) return null; if(!isVisible) return null;
return ( return (
<NitroCardView className="nitro-friends-messenger w-[800px] h-[720px]" theme="primary-slim" uniqueKey="nitro-friends-messenger"> <NitroCardView className="messenger-card" theme="primary-slim" uniqueKey={ null } windowPosition={ DraggableWindowPosition.TOP_CENTER } offsetTop={ 8 } isResizable={ false }>
<NitroCardHeaderView headerText={ LocalizeText('messenger.window.title', [ 'OPEN_CHAT_COUNT' ], [ visibleThreads.length.toString() ]) } onCloseClick={ event => setIsVisible(false) } /> <NitroCardHeaderView headerText={ LocalizeText('messenger.window.title', [ 'OPEN_CHAT_COUNT' ], [ visibleThreads.length.toString() ]) } onCloseClick={ event => setIsVisible(false) } />
<NitroCardContentView> <NitroCardContentView className="text-black p-0" gap={ 0 } overflow="hidden">
<Grid overflow="hidden"> <div className="messenger-card-body">
<Column overflow="hidden" size={ 4 }> <div className="messenger-avatar-bar">
<Text bold>{ LocalizeText('toolbar.icon.label.messenger') }</Text>
<Column fit overflow="auto">
<Column>
{ visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread => { visibleThreads && (visibleThreads.length > 0) && visibleThreads.map(thread =>
{ {
return ( return (
<LayoutGridItem key={ thread.threadId } itemActive={ (activeThread === thread) } onClick={ event => setActiveThreadId(thread.threadId) } className="py-1 px-2"> <button key={ thread.threadId } className={ 'messenger-avatar-tab' + ((activeThread === thread) ? ' active' : '') + (thread.unread ? ' unread' : '') } onClick={ event => setActiveThreadId(thread.threadId) }>
{ thread.unread && <LayoutItemCountView className="text-black" count={ thread.unreadCount } /> }
<Flex fullWidth gap={ 1 } style={{ minHeight: '50px' }}>
<LayoutAvatarImageView <LayoutAvatarImageView
figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure } figure={ thread.participant.id > 0 ? thread.participant.figure : thread.participant.figure === 'ADM' ? 'ha-3409-1413-70.lg-285-89.ch-3032-1334-109.sh-3016-110.hd-185-1359.ca-3225-110-62.wa-3264-62-62.fa-1206-90.hr-3322-1403' : thread.participant.figure }
headOnly={ true } headOnly={ true }
direction={ thread.participant.id > 0 ? 2 : 3 } direction={ thread.participant.id > 0 ? 2 : 3 }
style={{ width: '50px', height: '80px', backgroundPosition: 'center 45%', flexShrink: 0, alignSelf: 'flex-end' }}
/> />
<Text truncate grow className="self-center">{ thread.participant.name }</Text> </button>
</Flex>
</LayoutGridItem>
); );
}) } }) }
</Column> </div>
</Column>
</Column>
<Column overflow="hidden" size={ 8 }>
{ activeThread && { activeThread &&
<> <>
<Text bold center>{ LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }</Text> <div className="messenger-thread-header">
<Flex alignItems="center" gap={ 1 } justifyContent="between"> <span className="messenger-thread-name">{ LocalizeText('messenger.window.separator', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) }</span>
<div className="messenger-actions">
{ (activeThread.participant.id > 0) && { (activeThread.participant.id > 0) &&
<div className="flex gap-1"> <>
<div className="relative inline-flex align-middle"> <button className="messenger-btn icon-btn" onClick={ followFriend }>
<Button onClick={ followFriend }>
<div className="nitro-friends-spritesheet icon-follow" /> <div className="nitro-friends-spritesheet icon-follow" />
</Button> </button>
<Button onClick={ openProfile }> <button className="messenger-btn icon-btn" onClick={ openProfile }>
<div className="nitro-friends-spritesheet icon-profile-sm" /> <div className="nitro-friends-spritesheet icon-profile-sm" />
</Button> </button>
</div> <button className="messenger-btn danger" onClick={ () => report(ReportType.IM, { reportedUserId: activeThread.participant.id }) }>
<Button variant="danger" onClick={ () => report(ReportType.IM, { reportedUserId: activeThread.participant.id }) }>
{ LocalizeText('messenger.window.button.report') } { LocalizeText('messenger.window.button.report') }
</Button> </button>
</div> } </> }
<Button onClick={ event => closeThread(activeThread.threadId) }> <button className="messenger-btn close-btn" onClick={ event => closeThread(activeThread.threadId) }>
<FaTimes className="fa-icon" /> <FaTimes />
</Button> </button>
</Flex> </div>
<Column fit className="bg-muted p-2 rounded chat-messages"> </div>
<Column innerRef={ messagesBox } overflow="auto">
<div ref={ messagesBox } className="chat-messages">
<FriendsMessengerThreadView thread={ activeThread } /> <FriendsMessengerThreadView thread={ activeThread } />
</Column> </div>
</Column>
<div className="flex gap-1"> <div className="messenger-input-row">
<NitroInput maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } /> <input maxLength={ 255 } placeholder={ LocalizeText('messenger.window.input.default', [ 'FRIEND_NAME' ], [ activeThread.participant.name ]) } type="text" value={ messageText } onChange={ event => setMessageText(event.target.value) } onKeyDown={ onKeyDown } />
<Button variant="success" onClick={ send }> <button className="messenger-btn send" onClick={ () => void send() }>
{ LocalizeText('widgets.chatinput.say') } { LocalizeText('widgets.chatinput.say') }
</Button> </button>
</div> </div>
</> } </> }
</Column> </div>
</Grid>
</NitroCardContentView> </NitroCardContentView>
</NitroCardView> </NitroCardView>
); );
@@ -28,14 +28,11 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
<> <>
{ group.chats.map((chat, index) => { group.chats.map((chat, index) =>
{ {
if(chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) return null;
return ( return (
<Flex key={ index } fullWidth gap={ 2 } justifyContent="start"> <Flex key={ index } fullWidth gap={ 2 } justifyContent="start">
<Base className="w-full text-break"> <Base className="w-full text-break">
{ (chat.type === MessengerThreadChat.SECURITY_NOTIFICATION) &&
<Flex alignItems="center" className="bg-light rounded mb-2 px-2 py-1 small text-muted" gap={ 2 }>
<Base className="nitro-friends-spritesheet icon-warning shrink-0" />
<Base>{ chat.message }</Base>
</Flex> }
{ (chat.type === MessengerThreadChat.ROOM_INVITE) && { (chat.type === MessengerThreadChat.ROOM_INVITE) &&
<Flex alignItems="center" className="bg-light rounded mb-2 px-2 py-1 small text-black" gap={ 2 }> <Flex alignItems="center" className="bg-light rounded mb-2 px-2 py-1 small text-black" gap={ 2 }>
<Base className="messenger-notification-icon shrink-0" /> <Base className="messenger-notification-icon shrink-0" />
@@ -50,24 +47,46 @@ export const FriendsMessengerThreadGroup: FC<{ thread: MessengerThread, group: M
} }
return ( return (
<Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' }> <Flex fullWidth gap={ 2 } justifyContent={ isOwnChat ? 'end' : 'start' } className={ 'messenger-message-row ' + (isOwnChat ? 'own' : '') }>
<Base shrink className="message-avatar"> <Base shrink className="message-avatar">
{ ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) && { ((group.type === MessengerGroupType.PRIVATE_CHAT) && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } /> } <LayoutAvatarImageView direction={ 2 } figure={ thread.participant.figure } headOnly={ true } /> }
{ (groupChatData && !isOwnChat) && { (groupChatData && !isOwnChat) &&
<LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } /> } <LayoutAvatarImageView direction={ 2 } figure={ groupChatData.figure } headOnly={ true } /> }
</Base> </Base>
<Base className={ 'bg-light text-black border-radius mb-2 rounded py-1 px-2 messages-group-' + (isOwnChat ? 'right' : 'left') }> <Base className="messenger-message-body">
<Base className="font-bold"> <Base className={ 'messenger-message-name ' + (isOwnChat ? 'text-end' : '') }>
<Base className="small text-muted">{ group.chats[0].date.toLocaleTimeString() }</Base>
{ isOwnChat && GetSessionDataManager().userName } { isOwnChat && GetSessionDataManager().userName }
{ !isOwnChat && (groupChatData ? groupChatData.username : thread.participant.name) } { !isOwnChat && (groupChatData ? groupChatData.username : thread.participant.name) }
:
</Base> </Base>
{ group.chats.map((chat, index) => <Base key={ index } className="text-break">{ chat.message }</Base>) } <Base className={ 'messenger-message-bubble messages-group-' + (isOwnChat ? 'right' : 'left') }>
{ group.chats.map((chat, index) =>
{
if(!chat.showTranslation)
{
return <Base key={ index } className="text-break">{ chat.message }</Base>;
}
return (
<Base key={ index } className="messenger-translation-block">
<Base className="messenger-translation-row">
<span className="messenger-translation-label">original:</span>
<span className="text-break">{ chat.originalMessage || chat.message }</span>
</Base>
<Base className="messenger-translation-row">
<span className="messenger-translation-label">translate:</span>
<span className="text-break">{ chat.translatedMessage || chat.message }</span>
</Base>
</Base>
);
}) }
</Base>
<Base className="messenger-message-time">{ group.chats[0].date.toLocaleTimeString() }</Base>
</Base> </Base>
{ isOwnChat && { isOwnChat &&
<Base shrink className="message-avatar"> <Base shrink className="message-avatar">
<LayoutAvatarImageView direction={ 4 } figure={ GetSessionDataManager().figure } /> <LayoutAvatarImageView direction={ 4 } figure={ GetSessionDataManager().figure } headOnly={ true } />
</Base> } </Base> }
</Flex> </Flex>
); );
@@ -1,24 +1,29 @@
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useMemo, useState } from 'react';
import { FaTrashAlt } from 'react-icons/fa'; import { FaTrashAlt } from 'react-icons/fa';
import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api'; import { IPrefixItem, LocalizeText, parsePrefixColors, getPrefixEffectStyle, getPrefixFontStyle, PREFIX_EFFECT_KEYFRAMES } from '../../../../api';
import { useInventoryPrefixes, useNotification } from '../../../../hooks'; import { Button } from '../../../../common';
import { GetNickIconUrl } from '../../../../assets/images/user_custom/nick_icons';
import { useInventoryNickIcons, useInventoryPrefixes, useNotification } from '../../../../hooks';
import { NitroButton } from '../../../../layout'; 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 colors = parsePrefixColors(text, color);
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; const hasMultiColor = colors.length > 1 && new Set(colors).size > 1;
const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF'); const fxStyle = getPrefixEffectStyle(effect, colors[0] || '#FFFFFF');
const fontStyle = getPrefixFontStyle(font);
return ( return (
<span className={ `font-bold ${ textSize } ${ className }` } style={ fxStyle }> <span className={ `font-bold ${ textSize } ${ className }` } style={ { ...fontStyle, ...fxStyle } }>
{ effect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> } { !!effect && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> }
{ icon && <span className="mr-0.5">{ icon }</span> } { icon && <span className="mr-0.5">{ icon }</span> }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }> <span style={ hasMultiColor ? { ...fontStyle, ...fxStyle } : { ...fontStyle, ...fxStyle, color: colors[0] || '#FFFFFF' } }>
{'{'} {'{'}
{ hasMultiColor { hasMultiColor
? [ ...text ].map((char, i) => ( ? [ ...text ].map((char, i) => (
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span> <span key={ i } style={ { ...fontStyle, color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(effect, colors[i]) } }>{ char }</span>
)) ))
: text : 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' } ${ 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' : '' }` } ${ prefix.active ? 'ring-2 ring-green-400' : '' }` }
onClick={ onClick }> onClick={ onClick }>
<PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } icon={ prefix.icon } text={ prefix.text } /> <PrefixPreview className="truncate" color={ prefix.color } effect={ prefix.effect } font={ prefix.font } icon={ prefix.icon } text={ prefix.text } />
</div>
);
};
const NickIconItemView: FC<{
iconKey: string;
displayName: string;
isSelected: boolean;
isActive: boolean;
onClick: () => void;
}> = ({ iconKey, displayName, isSelected, isActive, onClick }) =>
{
return (
<div
className={ `relative flex cursor-pointer items-center justify-center rounded-md border-2 p-2 transition-colors
${ isSelected ? 'border-card-grid-item-active bg-card-grid-item-active' : 'border-card-grid-item-border bg-card-grid-item' }
${ isActive ? 'ring-2 ring-green-400' : '' }` }
onClick={ onClick }>
{ isActive && <span className="absolute right-1 top-1 rounded bg-[#15954c] px-1 py-0.5 text-[8px] font-bold uppercase text-white">Active</span> }
<div className="flex flex-col items-center gap-1">
<img className="h-auto max-h-[28px] w-auto object-contain" src={ GetNickIconUrl(iconKey) } alt={ displayName || iconKey } />
<span className="max-w-[90px] truncate text-center text-[11px] font-bold">{ displayName || iconKey }</span>
</div>
</div> </div>
); );
}; };
@@ -48,8 +76,13 @@ const PrefixItemView: FC<{
export const InventoryPrefixView: FC<{}> = () => export const InventoryPrefixView: FC<{}> = () =>
{ {
const [ isVisible, setIsVisible ] = useState(false); const [ isVisible, setIsVisible ] = useState(false);
const [ activeTab, setActiveTab ] = useState<InventoryIdentityTab>('prefixes');
const { prefixes = [], activePrefix = null, selectedPrefix = null, setSelectedPrefix = null, activatePrefix = null, deactivatePrefix = null, deletePrefix = null, activate = null, deactivate = null } = useInventoryPrefixes(); const { 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 { 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 = () => const attemptDeletePrefix = () =>
{ {
@@ -69,10 +102,15 @@ export const InventoryPrefixView: FC<{}> = () =>
{ {
if(!isVisible) return; if(!isVisible) return;
const id = activate(); const prefixVisibilityId = activate();
const iconVisibilityId = activateNickIcons();
return () => deactivate(id); return () =>
}, [ isVisible, activate, deactivate ]); {
deactivate(prefixVisibilityId);
deactivateNickIcons(iconVisibilityId);
};
}, [ isVisible, activate, activateNickIcons, deactivate, deactivateNickIcons ]);
useEffect(() => useEffect(() =>
{ {
@@ -82,8 +120,27 @@ export const InventoryPrefixView: FC<{}> = () =>
}, []); }, []);
return ( return (
<div className="flex h-full flex-col gap-2">
<div className="shrink-0 rounded border border-black/10 bg-[#C9C9C9] p-1">
<div className="flex items-center gap-2">
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activeTab === 'prefixes' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActiveTab('prefixes') }>
Prefixes
</button>
<button
className={ `rounded px-3 py-1.5 text-[11px] font-bold transition-colors ${ activeTab === 'icons' ? 'bg-[#1e7295] text-white' : 'bg-white text-black' }` }
type="button"
onClick={ () => setActiveTab('icons') }>
Icons
</button>
</div>
</div>
{ activeTab === 'prefixes' &&
<div className="grid h-full grid-cols-12 gap-2"> <div className="grid h-full grid-cols-12 gap-2">
<div className="flex flex-col col-span-7 gap-1 overflow-auto"> <div className="col-span-7 flex flex-col gap-1 overflow-auto pr-1">
<div className="grid grid-cols-3 gap-1"> <div className="grid grid-cols-3 gap-1">
{ prefixes.map(prefix => ( { prefixes.map(prefix => (
<PrefixItemView <PrefixItemView
@@ -93,30 +150,30 @@ export const InventoryPrefixView: FC<{}> = () =>
onClick={ () => setSelectedPrefix(prefix) } /> onClick={ () => setSelectedPrefix(prefix) } />
)) } )) }
</div> </div>
{ (!prefixes || prefixes.length === 0) && { !hasPrefixes &&
<div className="flex items-center justify-center h-full text-sm opacity-50"> <div className="flex h-full items-center justify-center text-sm opacity-50">
{ LocalizeText('inventory.empty.title') } { LocalizeText('inventory.empty.title') }
</div> } </div> }
</div> </div>
<div className="flex flex-col justify-between col-span-5 overflow-auto"> <div className="col-span-5 flex flex-col justify-between overflow-auto">
{ activePrefix && { activePrefix &&
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span> <span className="min-h-[1.25rem] truncate text-sm leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-green-400 bg-card-grid-item"> <div className="flex items-center justify-center rounded-md border-2 border-green-400 bg-card-grid-item p-3">
<PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" /> <PrefixPreview color={ activePrefix.color } effect={ activePrefix.effect } font={ activePrefix.font } icon={ activePrefix.icon } text={ activePrefix.text } textSize="text-lg" />
</div> </div>
</div> } </div> }
{ !activePrefix && { !activePrefix &&
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-sm truncate min-h-[1.25rem] leading-5">Active prefix</span> <span className="min-h-[1.25rem] truncate text-sm leading-5">Active prefix</span>
<div className="flex items-center justify-center p-3 rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item opacity-50"> <div className="flex items-center justify-center rounded-md border-2 border-dashed border-card-grid-item-border bg-card-grid-item p-3 opacity-50">
<span className="text-sm">No active prefix</span> <span className="text-sm">No active prefix</span>
</div> </div>
</div> } </div> }
{ !!selectedPrefix && { !!selectedPrefix &&
<div className="flex flex-col gap-2 mt-2"> <div className="mt-2 flex flex-col gap-2">
<div className="flex items-center justify-center gap-2 p-2 rounded bg-card-grid-item"> <div className="flex items-center justify-center gap-2 rounded bg-card-grid-item p-2">
<PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" /> <PrefixPreview color={ selectedPrefix.color } effect={ selectedPrefix.effect } font={ selectedPrefix.font } icon={ selectedPrefix.icon } text={ selectedPrefix.text } textSize="text-lg" />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<NitroButton <NitroButton
@@ -131,6 +188,47 @@ export const InventoryPrefixView: FC<{}> = () =>
</div> </div>
</div> } </div> }
</div> </div>
</div> }
{ activeTab === 'icons' &&
<div className="grid h-full grid-cols-12 gap-2">
<div className="col-span-7 flex flex-col gap-1 overflow-auto pr-1">
<div className="grid grid-cols-3 gap-1">
{ nickIcons.map(icon => (
<NickIconItemView
key={ icon.id }
displayName={ icon.displayName }
iconKey={ icon.iconKey }
isActive={ !!icon.active }
isSelected={ selectedNickIcon?.id === icon.id }
onClick={ () => setSelectedNickIcon(icon) } />
)) }
</div>
{ !hasNickIcons &&
<div className="flex h-full items-center justify-center text-sm opacity-50">
No purchased icons yet
</div> }
</div>
<div className="col-span-5 flex flex-col justify-between overflow-auto">
<div className="flex flex-col gap-1">
<span className="min-h-[1.25rem] truncate text-sm leading-5">Active icon</span>
<div className={ `flex min-h-[88px] items-center justify-center rounded-md border-2 bg-card-grid-item p-3 ${ activeNickIcon ? 'border-green-400' : 'border-dashed border-card-grid-item-border opacity-50' }` }>
{ activeNickIcon && <img className="h-auto max-h-[36px] w-auto object-contain" src={ GetNickIconUrl(activeNickIcon.iconKey) } alt={ activeNickIcon.displayName || activeNickIcon.iconKey } /> }
{ !activeNickIcon && <span className="text-sm">No active icon</span> }
</div>
</div>
{ !!selectedNickIcon &&
<div className="mt-2 flex flex-col gap-2">
<div className="flex min-h-[100px] flex-col items-center justify-center gap-2 rounded bg-card-grid-item p-3 text-center">
<img className="h-auto max-h-[40px] w-auto object-contain" src={ selectedIconUrl } alt={ selectedNickIcon.displayName || selectedNickIcon.iconKey } />
<span className="text-sm font-bold">{ selectedNickIcon.displayName || selectedNickIcon.iconKey }</span>
</div>
<Button disabled={ false } onClick={ () => selectedNickIcon.active ? deactivateNickIcon() : activateNickIcon(selectedNickIcon.id) }>
{ selectedNickIcon.active ? 'Deactivate' : 'Activate' }
</Button>
</div> }
</div>
</div> }
</div> </div>
); );
}; };
+4 -1
View File
@@ -1,6 +1,6 @@
import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer'; import { CreateLinkEvent, HabboClubLevelEnum } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useState } from 'react'; 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 { FriendlyTime, GetConfigurationValue, LocalizeText } from '../../api';
import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common'; import { Column, Flex, LayoutCurrencyIcon, Text } from '../../common';
import { usePurse } from '../../hooks'; import { usePurse } from '../../hooks';
@@ -91,6 +91,9 @@ export const PurseView: FC<{}> = props => {
</div> </div>
</div> } </div> }
<div className="nitro-purse__actions"> <div className="nitro-purse__actions">
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--translate" onClick={ event => { event.stopPropagation(); CreateLinkEvent('translation-settings/toggle'); } } title="Google Translate">
<FaLanguage />
</button>
<button type="button" className="nitro-purse__action-button nitro-purse__action-button--help" onClick={ event => { event.stopPropagation(); CreateLinkEvent('help/show'); } } title={ LocalizeText('help.button.name') }> <button type="button" className="nitro-purse__action-button nitro-purse__action-button--help" onClick={ event => { event.stopPropagation(); CreateLinkEvent('help/show'); } } title={ LocalizeText('help.button.name') }>
<FaQuestionCircle /> <FaQuestionCircle />
</button> </button>
@@ -2,7 +2,7 @@ import { GetSessionDataManager, RelationshipStatusInfoEvent, RelationshipStatusI
import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react'; import { Dispatch, FC, FocusEvent, KeyboardEvent, SetStateAction, useCallback, useEffect, useState } from 'react';
import { FaPencilAlt, FaTimes } from 'react-icons/fa'; import { FaPencilAlt, FaTimes } from 'react-icons/fa';
import { AvatarInfoUser, CloneObject, GetConfigurationValue, GetGroupInformation, GetUserProfile, LocalizeText, SendMessageComposer } from '../../../../../api'; 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 { useMessageEvent, useNitroEvent, useRoom } from '../../../../../hooks';
import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView'; import { InfoStandBadgeSlotView } from './InfoStandBadgeSlotView';
import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView'; import { InfoStandWidgetUserRelationshipsView } from './InfoStandWidgetUserRelationshipsView';
@@ -29,7 +29,6 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`; const infostandBackgroundClass = `background-${backgroundId ?? 'default'}`;
const infostandStandClass = `stand-${standId ?? 'default'}`; const infostandStandClass = `stand-${standId ?? 'default'}`;
const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`; const infostandOverlayClass = `overlay-${overlayId ?? 'default'}`;
const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]); const handleProfileClick = useCallback(() => { GetUserProfile(avatarInfo.webID); }, [avatarInfo.webID]);
const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []); const handleEditClick = useCallback((event: React.MouseEvent) => { event.stopPropagation(); setIsVisible(prev => !prev); }, []);
@@ -79,6 +78,12 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
newValue.figure = event.figure; newValue.figure = event.figure;
newValue.motto = event.customInfo; newValue.motto = event.customInfo;
newValue.achievementScore = event.activityPoints; 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.backgroundId = event.backgroundId;
newValue.standId = event.standId; newValue.standId = event.standId;
newValue.overlayId = event.overlayId; newValue.overlayId = event.overlayId;
@@ -139,7 +144,17 @@ export const InfoStandWidgetUserView: FC<InfoStandWidgetUserViewProps> = props =
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<UserProfileIconView userId={avatarInfo.webID} /> <UserProfileIconView userId={avatarInfo.webID} />
<Text small wrap variant="white">{avatarInfo.name}</Text> <UserIdentityView
className="text-[12px]"
displayOrder={ avatarInfo.displayOrder }
nameClassName="text-white"
nickIcon={ avatarInfo.nickIcon }
prefixColor={ avatarInfo.prefixColor }
prefixEffect={ avatarInfo.prefixEffect }
prefixFont={ avatarInfo.prefixFont }
prefixIcon={ avatarInfo.prefixIcon }
prefixText={ avatarInfo.prefixText }
username={ avatarInfo.name } />
</div> </div>
<FaTimes className="cursor-pointer fa-icon" onClick={onClose} /> <FaTimes className="cursor-pointer fa-icon" onClick={onClose} />
</div> </div>
@@ -1,6 +1,7 @@
import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer'; import { GetRoomEngine, RoomChatSettings, RoomObjectCategory } from '@nitrots/nitro-renderer';
import { FC, useEffect, useMemo, useRef, useState } from 'react'; 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'; import { useOnClickChat } from '../../../../hooks';
interface ChatWidgetMessageViewProps interface ChatWidgetMessageViewProps
@@ -38,11 +39,11 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
useEffect(() => useEffect(() =>
{ {
setIsVisible(false);
const element = elementRef.current; const element = elementRef.current;
if(!element) return; if(!element) return;
const previousWidth = chat.width;
const previousHeight = chat.height;
const { offsetWidth: width, offsetHeight: height } = element; const { offsetWidth: width, offsetHeight: height } = element;
chat.width = width; chat.width = width;
@@ -62,10 +63,14 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
setIsReady(true); setIsReady(true);
if(isVisible && ((previousWidth !== width) || (previousHeight !== height)) && makeRoom) makeRoom(chat);
}, [ chat, chat.formattedText, chat.originalFormattedText, chat.showTranslation, chat.translatedFormattedText, isVisible, makeRoom ]);
useEffect(() =>
{
return () => return () =>
{ {
chat.elementRef = null; chat.elementRef = null;
setIsReady(false);
}; };
}, [ chat ]); }, [ chat ]);
@@ -77,6 +82,8 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
setIsVisible(true); setIsVisible(true);
}, [ chat, isReady, isVisible, makeRoom ]); }, [ chat, isReady, isVisible, makeRoom ]);
const messageClassName = `message [overflow-wrap:anywhere] break-words${ chat.type === 1 ? ' italic text-[#595959]' : '' }${ chat.type === 2 ? ' font-bold' : '' }`;
return ( return (
<div ref={ elementRef } className={ `bubble-container newbubblehe ${ isVisible ? 'visible' : 'invisible' } w-max absolute select-none pointer-events-auto` } <div ref={ elementRef } className={ `bubble-container newbubblehe ${ isVisible ? 'visible' : 'invisible' } w-max absolute select-none pointer-events-auto` }
onClick={ () => GetRoomEngine().selectRoomObject(chat.roomId, chat.senderId, RoomObjectCategory.UNIT) }> onClick={ () => GetRoomEngine().selectRoomObject(chat.roomId, chat.senderId, RoomObjectCategory.UNIT) }>
@@ -90,29 +97,33 @@ export const ChatWidgetMessageView: FC<ChatWidgetMessageViewProps> = ({
) } ) }
</div> </div>
<div className="chat-content py-[5px] px-[6px] ml-[27px] leading-none min-h-[25px]"> <div className="chat-content py-[5px] px-[6px] ml-[27px] leading-none min-h-[25px]">
{ chat.prefixEffect === 'pulse' && <style>{ PREFIX_EFFECT_KEYFRAMES }</style> } <UserIdentityView
{ chat.prefixText && (() => { className="mr-1 align-middle"
const colors = parsePrefixColors(chat.prefixText, chat.prefixColor); displayOrder={ chat.displayOrder }
const hasMultiColor = colors.length > 1 && new Set(colors).size > 1; iconClassName="inline-block w-auto h-auto align-[-1px]"
const fxStyle = getPrefixEffectStyle(chat.prefixEffect, colors[0] || '#FFFFFF'); nameClassName="username font-bold"
return ( nickIcon={ chat.nickIcon }
<span className="prefix font-bold mr-1" style={ fxStyle }> prefixClassName=""
{ chat.prefixIcon && <span className="mr-0.5 text-[13px]">{ chat.prefixIcon }</span> } prefixColor={ chat.prefixColor }
<span style={ hasMultiColor ? fxStyle : { ...fxStyle, color: colors[0] || '#FFFFFF' } }> prefixEffect={ chat.prefixEffect }
{'{'} prefixFont={ chat.prefixFont }
{ hasMultiColor prefixIcon={ chat.prefixIcon }
? [ ...chat.prefixText ].map((char, i) => ( prefixText={ chat.prefixText }
<span key={ i } style={ { color: colors[i] || colors[colors.length - 1], ...getPrefixEffectStyle(chat.prefixEffect, colors[i]) } }>{ char }</span> showColon={ true }
)) username={ chat.username } />
: chat.prefixText { !chat.showTranslation &&
} <span className={ `${ messageClassName } align-middle` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } /> }
{'}'} { chat.showTranslation &&
</span> <div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
</span> <div className="flex items-start gap-1 leading-[1.1]">
); <span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
})() } <span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalFormattedText || chat.formattedText }` } } />
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.username }: ` } } /> </div>
<span className={ `message${ chat.type === 1 ? ' italic text-[#595959]' : '' }` } dangerouslySetInnerHTML={ { __html: `${ chat.formattedText }` } } onClick={ onClickChat } /> <div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedFormattedText || chat.formattedText }` } } />
</div>
</div> }
</div> </div>
<div className="pointer absolute left-[50%] translate-x-[-50%] w-[9px] h-[6px] bottom-[-5px]" /> <div className="pointer absolute left-[50%] translate-x-[-50%] w-[9px] h-[6px] bottom-[-5px]" />
</div> </div>
@@ -2,7 +2,7 @@ import { GetSessionDataManager, RoomObjectType } from '@nitrots/nitro-renderer';
import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FC, UIEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChatEntryType, LocalizeText } from '../../../../api'; import { ChatEntryType, LocalizeText } from '../../../../api';
import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common'; import { DraggableWindowPosition, NitroCardContentView, NitroCardHeaderView, NitroCardView } from '../../../../common';
import { useChatHistory, useChatWindow } from '../../../../hooks'; import { useChatHistory, useChatWindow, useOnClickChat } from '../../../../hooks';
import { useRoom } from '../../../../hooks/rooms'; import { useRoom } from '../../../../hooks/rooms';
const BOTTOM_SCROLL_THRESHOLD = 20; const BOTTOM_SCROLL_THRESHOLD = 20;
@@ -19,6 +19,7 @@ export const ChatWidgetWindowView: FC<{}> = () =>
const { chatHistory = [], clearChatHistory = null } = useChatHistory(); const { chatHistory = [], clearChatHistory = null } = useChatHistory();
const [ , setChatWindowEnabled ] = useChatWindow(); const [ , setChatWindowEnabled ] = useChatWindow();
const { roomSession = null } = useRoom(); const { roomSession = null } = useRoom();
const { onClickChat } = useOnClickChat();
const ownUserId = (GetSessionDataManager()?.userId || -1); const ownUserId = (GetSessionDataManager()?.userId || -1);
const roomChatHistory = useMemo(() => const roomChatHistory = useMemo(() =>
@@ -33,7 +34,7 @@ export const ChatWidgetWindowView: FC<{}> = () =>
if(!normalizedSearch.length) return true; 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 ]); }, [ chatHistory, roomSession?.roomId, hidePets, search ]);
@@ -125,14 +126,27 @@ export const ChatWidgetWindowView: FC<{}> = () =>
{ {
const isOwnMessage = (chat.webId === ownUserId); const isOwnMessage = (chat.webId === ownUserId);
const rowClassName = `mb-1 flex items-start gap-1 break-words ${ isOwnMessage ? 'justify-end' : '' }`; 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 ( return (
<div key={ `${ chat.timestamp }-${ chat.id }` } className={ rowClassName }> <div key={ `${ chat.timestamp }-${ chat.id }` } className={ rowClassName }>
{ hideBalloons && !hideAvatars && <div className={ `w-[65px] h-[55px] shrink-0 mt-[-18px] rounded-sm bg-no-repeat bg-center scale-70 ${ isOwnMessage ? 'order-2' : '' }` } style={ chat.imageUrl ? { backgroundImage: `url(${ chat.imageUrl })` } : undefined } /> } { hideBalloons && !hideAvatars && <div className={ `w-[65px] h-[55px] shrink-0 mt-[-18px] rounded-sm bg-no-repeat bg-center scale-70 ${ isOwnMessage ? 'order-2' : '' }` } style={ chat.imageUrl ? { backgroundImage: `url(${ chat.imageUrl })` } : undefined } /> }
{ hideBalloons && ( { hideBalloons && (
<div> <div onClick={ onClickChat }>
<b dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } /> <b dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
<span dangerouslySetInnerHTML={ { __html: chat.message } } /> { !chat.showTranslation &&
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.message } } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]">
<div className="flex items-start gap-1 leading-[1.15]">
<span className="inline-block min-w-[52px] font-bold opacity-75">original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.originalMessage || chat.message || '' } } />
</div>
<div className="flex items-start gap-1 leading-[1.15]">
<span className="inline-block min-w-[52px] font-bold opacity-75">translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: chat.translatedMessage || chat.message || '' } } />
</div>
</div> }
</div> </div>
) } ) }
{ !hideBalloons && ( { !hideBalloons && (
@@ -148,7 +162,19 @@ export const ChatWidgetWindowView: FC<{}> = () =>
</div> </div>
<div className={ `chat-content py-[5px] px-[6px] leading-none min-h-[25px] ${ !hideAvatars ? (isOwnMessage ? 'mr-[27px]' : 'ml-[27px]') : '' }` }> <div className={ `chat-content py-[5px] px-[6px] leading-none min-h-[25px] ${ !hideAvatars ? (isOwnMessage ? 'mr-[27px]' : 'ml-[27px]') : '' }` }>
<b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } /> <b className="username" dangerouslySetInnerHTML={ { __html: `${ chat.name }: ` } } />
<span className="message" dangerouslySetInnerHTML={ { __html: `${ chat.message }` } } /> { !chat.showTranslation &&
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.message }` } } onClick={ onClickChat } /> }
{ chat.showTranslation &&
<div className="mt-[2px] flex flex-col gap-[2px]" onClick={ onClickChat }>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>original:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.originalMessage || chat.message || '' }` } } />
</div>
<div className="flex items-start gap-1 leading-[1.1]">
<span className="inline-block min-w-[52px] font-bold" style={ { opacity: 0.75 } }>translate:</span>
<span className={ messageClassName } dangerouslySetInnerHTML={ { __html: `${ chat.translatedMessage || chat.message || '' }` } } />
</div>
</div> }
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,9 @@
import { FC } from 'react';
import { useTranslation } from '../../hooks';
export const TranslationBootstrap: FC<{}> = () =>
{
useTranslation();
return null;
};
@@ -0,0 +1,138 @@
import { AddLinkEventTracker, ILinkEventTracker, RemoveLinkEventTracker } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react';
import { NitroCardContentView, NitroCardHeaderView, NitroCardView, Text } from '../../common';
import { useTranslation } from '../../hooks';
export const TranslationSettingsView: FC<{}> = () =>
{
const [ isVisible, setIsVisible ] = useState(false);
const {
settings,
supportedLanguages = [],
availableTextLocales = [],
languagesLoading = false,
localizationTextsLoading = false,
lastIncomingLanguage = '',
lastOutgoingLanguage = '',
lastError = '',
updateSettings,
ensureSupportedLanguagesLoaded,
getLanguageName
} = useTranslation();
useEffect(() =>
{
const linkTracker: ILinkEventTracker = {
linkReceived: (url: string) =>
{
const parts = url.split('/');
if(parts.length < 2) return;
switch(parts[1])
{
case 'show':
setIsVisible(true);
return;
case 'hide':
setIsVisible(false);
return;
case 'toggle':
setIsVisible(prevValue => !prevValue);
return;
}
},
eventUrlPrefix: 'translation-settings/'
};
AddLinkEventTracker(linkTracker);
return () => RemoveLinkEventTracker(linkTracker);
}, []);
useEffect(() =>
{
if(!isVisible) return;
ensureSupportedLanguagesLoaded();
}, [ ensureSupportedLanguagesLoaded, isVisible ]);
if(!isVisible) return null;
return (
<NitroCardView className="translation-settings-window w-[360px]" theme="primary-slim" uniqueKey="translation-settings">
<NitroCardHeaderView headerText="Google Translate" onCloseClick={ () => setIsVisible(false) } />
<NitroCardContentView className="flex flex-col gap-3 text-black">
<div className="flex items-center gap-2">
<input checked={ settings.enabled } className="form-check-input" type="checkbox" onChange={ event => updateSettings({ enabled: event.target.checked }) } />
<Text>Enable automatic translation</Text>
</div>
<div className="rounded border border-black/10 bg-black/5 p-2 text-[11px] leading-4">
When enabled, chat bubbles always show two lines: <strong>original:</strong> and <strong>translate:</strong>.
</div>
<div className="flex flex-col gap-2">
<Text bold>Interface texts</Text>
<div className="flex flex-col gap-1">
<label className="flex flex-col gap-1 text-[12px]">
<span>Localized text pack</span>
<select
className="rounded border border-black/20 bg-white px-2 py-1"
disabled={ localizationTextsLoading }
value={ settings.uiTextLanguage || '' }
onChange={ event => updateSettings({ uiTextLanguage: event.target.value }) }>
<option value="">Default (gamedata)</option>
{ availableTextLocales.map(locale => <option key={ locale.code } value={ locale.code }>{ locale.name }</option>) }
</select>
</label>
</div>
</div>
<div className="flex flex-col gap-2">
<Text bold>Incoming messages</Text>
<div className="flex flex-col gap-1">
<Text>Detected language (auto): { getLanguageName(lastIncomingLanguage) }</Text>
<label className="flex flex-col gap-1 text-[12px]">
<span>Translate into</span>
<select
className="rounded border border-black/20 bg-white px-2 py-1"
disabled={ languagesLoading || !supportedLanguages.length }
value={ settings.incomingTargetLanguage }
onChange={ event => updateSettings({ incomingTargetLanguage: event.target.value }) }>
{ supportedLanguages.map(language => <option key={ language.code } value={ language.code }>{ language.name }</option>) }
</select>
</label>
</div>
</div>
<div className="flex flex-col gap-2">
<Text bold>Outgoing messages</Text>
<div className="flex flex-col gap-1">
<Text>Detected writing language (auto): { getLanguageName(lastOutgoingLanguage) }</Text>
<label className="flex flex-col gap-1 text-[12px]">
<span>Send text as</span>
<select
className="rounded border border-black/20 bg-white px-2 py-1"
disabled={ languagesLoading || !supportedLanguages.length }
value={ settings.outgoingTargetLanguage }
onChange={ event => updateSettings({ outgoingTargetLanguage: event.target.value }) }>
{ supportedLanguages.map(language => <option key={ language.code } value={ language.code }>{ language.name }</option>) }
</select>
</label>
</div>
</div>
<div className="flex items-center justify-between text-[11px] text-black/60">
<span>{ languagesLoading ? 'Loading languages...' : `${ supportedLanguages.length } languages available` }</span>
<button className="rounded border border-black/15 bg-white px-2 py-1 text-[11px] text-black hover:bg-black/5" type="button" onClick={ () => ensureSupportedLanguagesLoaded(true) }>
Refresh
</button>
</div>
{ localizationTextsLoading &&
<div className="rounded border border-blue-300 bg-blue-50 px-2 py-1 text-[11px] leading-4 text-blue-700">
Loading localized interface texts...
</div> }
{ lastError.length > 0 &&
<div className="rounded border border-red-300 bg-red-50 px-2 py-1 text-[11px] leading-4 text-red-700">
{ lastError }
</div> }
</NitroCardContentView>
</NitroCardView>
);
};
@@ -1,7 +1,7 @@
import { GetSessionDataManager, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer'; import { GetSessionDataManager, RequestFriendComposer, UserProfileParser } from '@nitrots/nitro-renderer';
import { FC, useEffect, useState } from 'react'; import { FC, useEffect, useState } from 'react';
import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api'; import { FriendlyTime, LocalizeText, SendMessageComposer } from '../../api';
import { LayoutAvatarImageView, Text } from '../../common'; import { LayoutAvatarImageView, Text, UserIdentityView } from '../../common';
export const UserContainerView: FC<{ export const UserContainerView: FC<{
userProfile: UserProfileParser; userProfile: UserProfileParser;
@@ -18,7 +18,6 @@ export const UserContainerView: FC<{
const infostandBackgroundClass = `background-${userProfile.backgroundId ?? 'default'}`; const infostandBackgroundClass = `background-${userProfile.backgroundId ?? 'default'}`;
const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`; const infostandStandClass = `stand-${userProfile.standId ?? 'default'}`;
const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`; const infostandOverlayClass = `overlay-${userProfile.overlayId ?? 'default'}`;
const addFriend = () => const addFriend = () =>
{ {
setRequestSent(true); setRequestSent(true);
@@ -41,7 +40,16 @@ export const UserContainerView: FC<{
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<p className="leading-tight font-bold">{ userProfile.username }</p> <UserIdentityView
className="leading-tight"
displayOrder={ userProfile.displayOrder }
nickIcon={ userProfile.nickIcon }
prefixColor={ userProfile.prefixColor }
prefixEffect={ userProfile.prefixEffect }
prefixFont={ userProfile.prefixFont }
prefixIcon={ userProfile.prefixIcon }
prefixText={ userProfile.prefixText }
username={ userProfile.username } />
<p className="text-sm italic leading-tight">{ userProfile.motto }</p> <p className="text-sm italic leading-tight">{ userProfile.motto }</p>
</div> </div>
@@ -1,13 +1,13 @@
import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer'; import { AddLinkEventTracker, AvatarExpressionEnum, FigureUpdateEvent, FurnitureFloorUpdateEvent, FurnitureMultiStateComposer, FurnitureWallMultiStateComposer, FurnitureWallUpdateComposer, FurnitureWallUpdateEvent, GetLocalizationManager, GetRoomEngine, GetSessionDataManager, GetStage, GetTicker, ILinkEventTracker, RemoveLinkEventTracker, RoomControllerLevel, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomUnitDanceEvent, RoomUnitEffectEvent, RoomUnitExpressionEvent, RoomUnitHandItemEvent, RoomUnitInfoEvent, RoomUnitStatusEvent, UpdateFurniturePositionComposer, Vector3d, WiredUserInspectMoveComposer } from '@nitrots/nitro-renderer';
import { WiredMonitorDataEvent, WiredMonitorRequestComposer } from '@nitrots/nitro-renderer'; import { 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 furniInspectionIcon from '../../assets/images/wiredtools/furni.png';
import globalInspectionIcon from '../../assets/images/wiredtools/global.png'; import globalInspectionIcon from '../../assets/images/wiredtools/global.png';
import userInspectionIcon from '../../assets/images/wiredtools/user.png'; import userInspectionIcon from '../../assets/images/wiredtools/user.png';
import contextInspectionIcon from '../../assets/images/wiredtools/context.png'; import contextInspectionIcon from '../../assets/images/wiredtools/context.png';
import wiredGlobalPlaceholderImage from '../../assets/images/wiredtools/wired_global_placeholder.png'; import wiredGlobalPlaceholderImage from '../../assets/images/wiredtools/wired_global_placeholder.png';
import wiredMonitorImage from '../../assets/images/wiredtools/wired_monitor.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 { Button, DraggableWindowPosition, LayoutAvatarImageView, LayoutPetImageView, LayoutRoomObjectImageView, NitroCardContentView, NitroCardHeaderView, NitroCardTabsItemView, NitroCardTabsView, NitroCardView, Text } from '../../common';
import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedEvent, useRoom, useWiredTools } from '../../hooks'; import { useInventoryTrade, useMessageEvent, useNotification, useObjectSelectedEvent, useRoom, useWiredTools } from '../../hooks';
import { WiredToolsSettingsTabView } from './WiredToolsSettingsTabView'; import { WiredToolsSettingsTabView } from './WiredToolsSettingsTabView';
@@ -184,6 +184,21 @@ interface VariableManageEntry
value: number | null; 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 interface ManagedHolderVariableEntry
{ {
availability: string; availability: string;
@@ -631,6 +646,9 @@ export const WiredCreatorToolsView: FC<{}> = () =>
const [ isManagedGiveOpen, setIsManagedGiveOpen ] = useState(false); const [ isManagedGiveOpen, setIsManagedGiveOpen ] = useState(false);
const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0); const [ managedGiveVariableItemId, setManagedGiveVariableItemId ] = useState(0);
const [ managedGiveValue, setManagedGiveValue ] = useState('0'); const [ managedGiveValue, setManagedGiveValue ] = useState('0');
const [ isVariableHighlightActive, setIsVariableHighlightActive ] = useState(false);
const [ variableHighlightOverlays, setVariableHighlightOverlays ] = useState<VariableHighlightOverlay[]>([]);
const variableHighlightObjectsRef = useRef<Array<{ category: number; objectId: number; }>>([]);
const shouldPauseVariableSnapshotRefresh = (!!editingVariable || !!editingManagedHolderVariableId || isInspectionGiveOpen || isManagedGiveOpen); const shouldPauseVariableSnapshotRefresh = (!!editingVariable || !!editingManagedHolderVariableId || isInspectionGiveOpen || isManagedGiveOpen);
const [ selectedVariableKeys, setSelectedVariableKeys ] = useState<Record<VariablesElementType, string>>({ const [ selectedVariableKeys, setSelectedVariableKeys ] = useState<Record<VariablesElementType, string>>({
furni: VARIABLE_DEFINITIONS.furni[0].key, furni: VARIABLE_DEFINITIONS.furni[0].key,
@@ -2400,6 +2418,155 @@ export const WiredCreatorToolsView: FC<{}> = () =>
manageLabel: 'Manage' manageLabel: 'Manage'
} ]; } ];
}, [ selectedVariableDefinition, variablesType, roomSession, userVariableAssignments, furniVariableAssignments, roomVariableAssignmentMap ]); }, [ 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(() => const variableManageTypeOptions = useMemo(() =>
{ {
switch(variablesType) switch(variablesType)
@@ -3465,6 +3632,27 @@ export const WiredCreatorToolsView: FC<{}> = () =>
return ( return (
<> <>
{ isVariableHighlightActive && !!variableHighlightOverlays.length &&
<div className="pointer-events-none absolute left-0 top-0 z-30">
{ variableHighlightOverlays.map(overlay => (
<div
key={ overlay.key }
className="pointer-events-none absolute"
style={ {
left: overlay.x,
top: overlay.y,
transform: 'translateX(-50%)'
} }>
{ overlay.hasValue &&
<div className="absolute left-1/2 top-[-30px] -translate-x-1/2">
<div className="relative min-w-[24px] rounded-[8px] bg-[#86aebccc] px-[8px] py-[4px] text-center text-[12px] font-bold leading-none text-white shadow-[inset_0_0_0_1px_rgba(176,211,225,.7)]">
{ overlay.value ?? 0 }
<span className="absolute left-1/2 bottom-[-7px] z-10 h-0 w-0 -translate-x-1/2 border-x-[6px] border-t-[7px] border-x-transparent border-t-[#86aebccc]" />
</div>
</div> }
</div>
)) }
</div> }
<NitroCardView className="min-w-[520px] max-w-[520px]" theme="primary-slim" uniqueKey="wired-creator-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT }> <NitroCardView className="min-w-[520px] max-w-[520px]" theme="primary-slim" uniqueKey="wired-creator-tools" windowPosition={ DraggableWindowPosition.TOP_LEFT }>
<NitroCardHeaderView headerText="Wired Creator Tools (:wired)" onCloseClick={ () => setIsVisible(false) } /> <NitroCardHeaderView headerText="Wired Creator Tools (:wired)" onCloseClick={ () => setIsVisible(false) } />
<NitroCardTabsView justifyContent="start"> <NitroCardTabsView justifyContent="start">
@@ -3830,7 +4018,12 @@ export const WiredCreatorToolsView: FC<{}> = () =>
</div> </div>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button disabled variant="secondary">Highlight</Button> <Button
disabled={ !canVariableHighlight }
variant="secondary"
onClick={ () => setIsVariableHighlightActive(value => !value) }>
{ isVariableHighlightActive ? 'Undo' : 'Highlight' }
</Button>
<Button <Button
disabled={ !variableManageCanOpen } disabled={ !variableManageCanOpen }
variant="secondary" variant="secondary"
@@ -5,7 +5,14 @@ import { useWired } from '../../../../hooks';
import { WiredSourcesSelector } from '../WiredSourcesSelector'; import { WiredSourcesSelector } from '../WiredSourcesSelector';
import { WiredActionBaseView } from './WiredActionBaseView'; import { WiredActionBaseView } from './WiredActionBaseView';
const COUNTER_INTERACTION_TYPES = [ 'game_upcounter' ]; const COUNTER_INTERACTION_TYPES = [
'game_upcounter',
'game_timer',
'wf_game_upcounter*',
'fball_counter',
'bb_counter',
'es_counter'
];
const CONTROL_OPTIONS = [ const CONTROL_OPTIONS = [
{ value: 0, label: 'wiredfurni.params.clock_control.0' }, { value: 0, label: 'wiredfurni.params.clock_control.0' },
@@ -1,30 +1,40 @@
import { FC, useEffect, useMemo, useState } from 'react'; import { FC, useEffect, useMemo, useState } from 'react';
import { FaChevronLeft, FaChevronRight } from 'react-icons/fa';
import { LocalizeText, WiredFurniType } from '../../../../api'; import { LocalizeText, WiredFurniType } from '../../../../api';
import sourceFurniIcon from '../../../../assets/images/wired/source_furni.png';
import sourceUserIcon from '../../../../assets/images/wired/source_user.png';
import { Button, Slider, Text } from '../../../../common'; import { Button, Slider, Text } from '../../../../common';
import { useWired } from '../../../../hooks'; import { useWired } from '../../../../hooks';
import { WiredFurniSelectionSourceRow } from '../WiredFurniSelectionSourceRow';
import { sortWiredSourceOptions, useAvailableUserSources } from '../WiredSourcesSelector'; import { sortWiredSourceOptions, useAvailableUserSources } from '../WiredSourcesSelector';
import { WiredConditionBaseView } from './WiredConditionBaseView'; import { WiredConditionBaseView } from './WiredConditionBaseView';
const SOURCE_GROUP_USERS = 0; const SOURCE_GROUP_USERS = 0;
const SOURCE_GROUP_FURNI = 1; const SOURCE_GROUP_FURNI = 1;
const SOURCE_TRIGGER = 0;
const SOURCE_SELECTED = 100;
const COMPARISON_OPTIONS = [ 0, 1, 2 ]; const COMPARISON_OPTIONS = [ 0, 1, 2 ];
const MIN_QUANTITY = 0; const MIN_QUANTITY = 0;
const MAX_QUANTITY = 100; const MAX_QUANTITY = 100;
const QUANTITY_PATTERN = /^\d*$/; const QUANTITY_PATTERN = /^\d*$/;
const USER_SOURCES = [ const USER_SOURCES = sortWiredSourceOptions([
{ value: 0, label: 'wiredfurni.params.sources.users.0' }, { value: 0, label: 'wiredfurni.params.sources.users.0' },
{ value: 200, label: 'wiredfurni.params.sources.users.200' }, { value: 200, label: 'wiredfurni.params.sources.users.200' },
{ value: 201, label: 'wiredfurni.params.sources.users.201' } { value: 201, label: 'wiredfurni.params.sources.users.201' }
]; ], 'users');
const FURNI_SOURCES = sortWiredSourceOptions([ const FURNI_SOURCES = sortWiredSourceOptions([
{ value: 0, label: 'wiredfurni.params.sources.furni.0' }, { value: 100, label: 'wiredfurni.params.sources.furni.100' },
{ value: 200, label: 'wiredfurni.params.sources.furni.200' }, { value: 200, label: 'wiredfurni.params.sources.furni.200' },
{ value: 201, label: 'wiredfurni.params.sources.furni.201' } { value: 201, label: 'wiredfurni.params.sources.furni.201' },
{ value: 0, label: 'wiredfurni.params.sources.furni.0' }
], 'furni'); ], 'furni');
const SOURCE_GROUP_BUTTONS = [
{ key: 'user', icon: sourceUserIcon, isUserGroup: true },
{ key: 'furni', icon: sourceFurniIcon, isUserGroup: false }
] as const;
const clampQuantity = (value: number) => const clampQuantity = (value: number) =>
{ {
if(isNaN(value)) return MIN_QUANTITY; if(isNaN(value)) return MIN_QUANTITY;
@@ -32,21 +42,22 @@ const clampQuantity = (value: number) =>
return Math.max(MIN_QUANTITY, Math.min(MAX_QUANTITY, Math.floor(value))); return Math.max(MIN_QUANTITY, Math.min(MAX_QUANTITY, Math.floor(value)));
}; };
const normalizeSource = (value: number, allowed: number[]) => const normalizeSourceType = (value: number, allowed: number[]) =>
{ {
return allowed.includes(value) ? value : 0; return allowed.includes(value) ? value : SOURCE_TRIGGER;
}; };
export const WiredConditionSelectionQuantityView: FC<{}> = () => export const WiredConditionSelectionQuantityView: FC<{}> = () =>
{ {
const { trigger = null, setIntParams = null, setStringParam = null } = useWired(); const { trigger = null, furniIds = [], setFurniIds = null, setIntParams = null, setStringParam = null } = useWired();
const availableUserSources = sortWiredSourceOptions(useAvailableUserSources(trigger, USER_SOURCES), 'users'); const rawAvailableUserSources = useAvailableUserSources(trigger, USER_SOURCES);
const availableUserSources = useMemo(() => sortWiredSourceOptions(rawAvailableUserSources, 'users'), [ rawAvailableUserSources ]);
const [ comparison, setComparison ] = useState(1); const [ comparison, setComparison ] = useState(1);
const [ quantity, setQuantity ] = useState(0); const [ quantity, setQuantity ] = useState(0);
const [ quantityInput, setQuantityInput ] = useState('0'); const [ quantityInput, setQuantityInput ] = useState('0');
const [ sourceGroup, setSourceGroup ] = useState(SOURCE_GROUP_USERS); const [ sourceGroup, setSourceGroup ] = useState(SOURCE_GROUP_USERS);
const [ userSource, setUserSource ] = useState(0); const [ userSource, setUserSource ] = useState(SOURCE_TRIGGER);
const [ furniSource, setFurniSource ] = useState(0); const [ furniSource, setFurniSource ] = useState(SOURCE_TRIGGER);
useEffect(() => useEffect(() =>
{ {
@@ -55,19 +66,46 @@ export const WiredConditionSelectionQuantityView: FC<{}> = () =>
const nextComparison = (trigger.intData.length > 0) ? trigger.intData[0] : 1; const nextComparison = (trigger.intData.length > 0) ? trigger.intData[0] : 1;
const nextQuantity = clampQuantity((trigger.intData.length > 1) ? trigger.intData[1] : 0); const nextQuantity = clampQuantity((trigger.intData.length > 1) ? trigger.intData[1] : 0);
const nextSourceGroup = (trigger.intData.length > 2 && trigger.intData[2] === SOURCE_GROUP_FURNI) ? SOURCE_GROUP_FURNI : SOURCE_GROUP_USERS; const nextSourceGroup = (trigger.intData.length > 2 && trigger.intData[2] === SOURCE_GROUP_FURNI) ? SOURCE_GROUP_FURNI : SOURCE_GROUP_USERS;
const nextSourceType = (trigger.intData.length > 3) ? trigger.intData[3] : 0; const nextSourceType = (trigger.intData.length > 3) ? trigger.intData[3] : SOURCE_TRIGGER;
setComparison(COMPARISON_OPTIONS.includes(nextComparison) ? nextComparison : 1); setComparison(COMPARISON_OPTIONS.includes(nextComparison) ? nextComparison : 1);
setQuantity(nextQuantity); setQuantity(nextQuantity);
setQuantityInput(nextQuantity.toString()); setQuantityInput(nextQuantity.toString());
setSourceGroup(nextSourceGroup); setSourceGroup(nextSourceGroup);
setUserSource(nextSourceGroup === SOURCE_GROUP_USERS ? normalizeSource(nextSourceType, availableUserSources.map(source => source.value)) : 0); setUserSource(nextSourceGroup === SOURCE_GROUP_USERS ? normalizeSourceType(nextSourceType, availableUserSources.map(source => source.value)) : SOURCE_TRIGGER);
setFurniSource(nextSourceGroup === SOURCE_GROUP_FURNI ? normalizeSource(nextSourceType, FURNI_SOURCES.map(source => source.value)) : 0); setFurniSource(nextSourceGroup === SOURCE_GROUP_FURNI ? normalizeSourceType(nextSourceType, FURNI_SOURCES.map(source => source.value)) : SOURCE_TRIGGER);
}, [ availableUserSources, trigger ]); }, [ availableUserSources, trigger ]);
const activeSources = useMemo(() => (sourceGroup === SOURCE_GROUP_FURNI) ? FURNI_SOURCES : availableUserSources, [ availableUserSources, sourceGroup ]); const isUserGroup = sourceGroup === SOURCE_GROUP_USERS;
const activeSource = (sourceGroup === SOURCE_GROUP_FURNI) ? furniSource : userSource; const activeSources = useMemo(() => isUserGroup ? availableUserSources : FURNI_SOURCES, [ availableUserSources, isUserGroup ]);
const activeSource = isUserGroup ? userSource : furniSource;
const activeSourceIndex = Math.max(0, activeSources.findIndex(source => source.value === activeSource)); const activeSourceIndex = Math.max(0, activeSources.findIndex(source => source.value === activeSource));
const currentSourceType = activeSources[activeSourceIndex]?.value ?? SOURCE_TRIGGER;
const requiresFurni = (!isUserGroup && furniSource === SOURCE_SELECTED) ? WiredFurniType.STUFF_SELECTION_OPTION_BY_ID : WiredFurniType.STUFF_SELECTION_OPTION_NONE;
useEffect(() =>
{
if(currentSourceType === activeSource) return;
if(isUserGroup) setUserSource(currentSourceType);
else setFurniSource(currentSourceType);
}, [ activeSource, currentSourceType, isUserGroup ]);
const changeGroup = (nextIsUserGroup: boolean) =>
{
if(nextIsUserGroup === isUserGroup) return;
const nextOptions = nextIsUserGroup ? availableUserSources : FURNI_SOURCES;
const nextIndex = Math.min(activeSourceIndex, Math.max(0, nextOptions.length - 1));
const nextOption = nextOptions[nextIndex] ?? nextOptions[0];
setSourceGroup(nextIsUserGroup ? SOURCE_GROUP_USERS : SOURCE_GROUP_FURNI);
if(!nextOption) return;
if(nextIsUserGroup) setUserSource(nextOption.value);
else setFurniSource(nextOption.value);
};
const updateQuantity = (value: number) => const updateQuantity = (value: number) =>
{ {
@@ -92,30 +130,23 @@ export const WiredConditionSelectionQuantityView: FC<{}> = () =>
updateQuantity(parseInt(value)); updateQuantity(parseInt(value));
}; };
const cycleSource = (direction: -1 | 1) =>
{
const nextIndex = (activeSourceIndex + direction + activeSources.length) % activeSources.length;
const nextValue = activeSources[nextIndex].value;
if(sourceGroup === SOURCE_GROUP_FURNI) setFurniSource(nextValue);
else setUserSource(nextValue);
};
const save = () => const save = () =>
{ {
setIntParams([ setIntParams([
comparison, comparison,
clampQuantity(quantity), clampQuantity(quantity),
sourceGroup, sourceGroup,
(sourceGroup === SOURCE_GROUP_FURNI) ? furniSource : userSource isUserGroup ? userSource : furniSource
]); ]);
setStringParam(''); setStringParam('');
if(requiresFurni <= WiredFurniType.STUFF_SELECTION_OPTION_NONE) setFurniIds([]);
}; };
return ( return (
<WiredConditionBaseView <WiredConditionBaseView
hasSpecialInput={ true } hasSpecialInput={ true }
requiresFurni={ WiredFurniType.STUFF_SELECTION_OPTION_NONE } requiresFurni={ requiresFurni }
save={ save }> save={ save }>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Text bold>{ LocalizeText('wiredfurni.params.comparison_selection') }</Text> <Text bold>{ LocalizeText('wiredfurni.params.comparison_selection') }</Text>
@@ -147,34 +178,34 @@ export const WiredConditionSelectionQuantityView: FC<{}> = () =>
onChange={ event => updateQuantity(event as number) } /> onChange={ event => updateQuantity(event as number) } />
<Text small>{ quantity }</Text> <Text small>{ quantity }</Text>
</div> </div>
<div className="flex flex-col gap-2"> <WiredFurniSelectionSourceRow
<Text bold>{ LocalizeText('wiredfurni.params.sources.merged.title') }</Text> title="wiredfurni.params.sources.merged.title"
<div className="flex gap-1"> options={ activeSources }
<Button value={ activeSource }
fullWidth selectionKind={ isUserGroup ? 'primary' : 'secondary' }
variant={ (sourceGroup === SOURCE_GROUP_USERS) ? 'primary' : 'secondary' } selectionActive={ !isUserGroup && furniSource === SOURCE_SELECTED }
onClick={ () => setSourceGroup(SOURCE_GROUP_USERS) }> selectionCount={ furniIds.length }
{ LocalizeText('wiredfurni.params.sources.users.title') } selectionLimit={ trigger?.maximumItemSelectionCount ?? 20 }
</Button> selectionEnabledValues={ [ SOURCE_SELECTED ] }
<Button showSelectionToggle={ false }
fullWidth headerContent={
variant={ (sourceGroup === SOURCE_GROUP_FURNI) ? 'primary' : 'secondary' } <div className="nitro-wired__give-var-targets">
onClick={ () => setSourceGroup(SOURCE_GROUP_FURNI) }> { SOURCE_GROUP_BUTTONS.map(button => (
{ LocalizeText('wiredfurni.params.sources.furni.title') } <button
</Button> key={ button.key }
</div> type="button"
<div className="flex items-center gap-2"> className={ `nitro-wired__give-var-target nitro-wired__give-var-target--${ button.key } ${ isUserGroup === button.isUserGroup ? 'is-active' : '' }` }
<Button variant="primary" className="px-2 py-1" onClick={ () => cycleSource(-1) }> onClick={ () => changeGroup(button.isUserGroup) }>
<FaChevronLeft /> <img src={ button.icon } alt={ button.key } />
</Button> </button>
<div className="flex flex-1 items-center justify-center"> )) }
<Text small>{ LocalizeText(activeSources[activeSourceIndex].label) }</Text>
</div>
<Button variant="primary" className="px-2 py-1" onClick={ () => cycleSource(1) }>
<FaChevronRight />
</Button>
</div>
</div> </div>
}
onChange={ value =>
{
if(isUserGroup) setUserSource(value);
else setFurniSource(value);
} } />
</WiredConditionBaseView> </WiredConditionBaseView>
); );
}; };
@@ -2,6 +2,7 @@ import { FC, useEffect, useState } from 'react';
import { LocalizeText, WiredFurniType } from '../../../../api'; import { LocalizeText, WiredFurniType } from '../../../../api';
import { Text } from '../../../../common'; import { Text } from '../../../../common';
import { useWired } from '../../../../hooks'; import { useWired } from '../../../../hooks';
import { WiredTextFormattingHelp } from '../common/WiredTextFormattingHelp';
import { WiredExtraBaseView } from './WiredExtraBaseView'; import { WiredExtraBaseView } from './WiredExtraBaseView';
const DEFAULT_CONNECTOR_PLACEHOLDER = '0=text 1\n1=text 2\n2 = text 3'; const DEFAULT_CONNECTOR_PLACEHOLDER = '0=text 1\n1=text 2\n2 = text 3';
@@ -70,6 +71,7 @@ export const WiredExtraVariableTextConnectorView: FC<{}> = () =>
value={ mappingsText } value={ mappingsText }
onChange={ event => handleTextChange(event.target.value) } /> onChange={ event => handleTextChange(event.target.value) } />
<Text small>{ `${ lineCount }/${ MAX_CONNECTOR_LINES } righe - ${ characterCount }/${ MAX_CONNECTOR_CHARACTERS } caratteri` }</Text> <Text small>{ `${ lineCount }/${ MAX_CONNECTOR_LINES } righe - ${ characterCount }/${ MAX_CONNECTOR_CHARACTERS } caratteri` }</Text>
<WiredTextFormattingHelp />
</div> </div>
</WiredExtraBaseView> </WiredExtraBaseView>
); );
@@ -15,7 +15,6 @@ const normalizeFurniSource = (value: number) => (FURNI_SOURCE_OPTIONS.some(optio
export const WiredTriggerReceiveSignalView: FC<{}> = () => export const WiredTriggerReceiveSignalView: FC<{}> = () =>
{ {
const [ senderCount, setSenderCount ] = useState(0); const [ senderCount, setSenderCount ] = useState(0);
const [ maxSenders, setMaxSenders ] = useState(5);
const [ channel, setChannel ] = useState(0); const [ channel, setChannel ] = useState(0);
const [ furniSource, setFurniSource ] = useState(100); const [ furniSource, setFurniSource ] = useState(100);
@@ -30,7 +29,6 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () =>
const p = trigger.intData; const p = trigger.intData;
if(p.length >= 1) setChannel(p[0]); if(p.length >= 1) setChannel(p[0]);
if(p.length >= 2) setSenderCount(p[1]); if(p.length >= 2) setSenderCount(p[1]);
if(p.length >= 3) setMaxSenders(p[2]);
if(p.length >= 4) setFurniSource(normalizeFurniSource(p[3])); if(p.length >= 4) setFurniSource(normalizeFurniSource(p[3]));
else setFurniSource(100); else setFurniSource(100);
}, [ trigger ]); }, [ trigger ]);
@@ -43,7 +41,7 @@ export const WiredTriggerReceiveSignalView: FC<{}> = () =>
footer={ <WiredSourcesSelector showFurni={ true } furniSource={ furniSource } furniSources={ FURNI_SOURCE_OPTIONS } onChangeFurni={ setFurniSource } /> }> footer={ <WiredSourcesSelector showFurni={ true } furniSource={ furniSource } furniSources={ FURNI_SOURCE_OPTIONS } onChangeFurni={ setFurniSource } /> }>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Text small>{ LocalizeText('wiredfurni.params.signal.senders_connected') }</Text> <Text small>{ LocalizeText('wiredfurni.params.signal.senders_connected') }</Text>
<Text bold small>{ senderCount }/{ maxSenders }</Text> <Text bold small>{ senderCount }</Text>
</div> </div>
</WiredTriggerBaseView> </WiredTriggerBaseView>
); );
+623 -19
View File
@@ -106,21 +106,302 @@
} }
.nitro-friends { .nitro-friends {
width: 250px; width: 332px;
height: 300px; 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 { & .search-input {
border: 0; min-height: 28px;
border-bottom: 1px solid rgba(0, 0, 0, 0.2); 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 { .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 { .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 { .friend-bar {
@@ -142,27 +423,298 @@
} }
} }
.nitro-friends-messenger { .messenger-card {
& .layout-grid-item { width: 332px;
min-height: 50px; min-width: 332px;
max-width: 332px;
height: 445px;
min-height: 445px;
max-height: 445px;
resize: none;
pointer-events: all;
} }
& .chat-messages { @media (max-width: 380px), (max-height: 470px) {
overflow-y: auto; .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);
}
}
& .message-avatar { .messenger-card {
position: relative; & 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; overflow: hidden;
width: 50px; padding: 6px 8px;
height: 50px; 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 { & .avatar-image {
position: absolute; position: absolute;
margin-left: -22px; left: 50% !important;
margin-top: -25px; 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;
flex-shrink: 0;
overflow: visible;
width: 36px;
height: 36px;
& .avatar-image {
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 { & .messages-group-left {
position: relative; position: relative;
@@ -171,12 +723,24 @@
content: ' '; content: ' ';
width: 0; width: 0;
height: 0; height: 0;
border-right: 8px solid #DFDFDF; border-right: 8px solid #fff;
border-top: 8px solid transparent; border-top: 8px solid transparent;
border-bottom: 8px solid transparent; border-bottom: 8px solid transparent;
top: 10px; top: 10px;
left: -8px; 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 { & .messages-group-right {
@@ -187,12 +751,52 @@
content: ' '; content: ' ';
width: 0; width: 0;
height: 0; height: 0;
border-left: 8px solid #DFDFDF; border-left: 8px solid #fff;
border-top: 8px solid transparent; border-top: 8px solid transparent;
border-bottom: 8px solid transparent; border-bottom: 8px solid transparent;
top: 10px; top: 10px;
right: -8px; 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;
}
} }
} }
} }
+51 -18
View File
@@ -1,4 +1,5 @@
@import 'tailwindcss'; @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"; @config "../../tailwind.config.js";
@@ -22,6 +23,7 @@ body {
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #6d7b84 #c8d0d4;
} }
.image-rendering-pixelated { .image-rendering-pixelated {
@@ -35,45 +37,76 @@ body {
} }
::-webkit-scrollbar { ::-webkit-scrollbar {
width: .625rem; width: .875rem;
} }
::-webkit-scrollbar:horizontal { ::-webkit-scrollbar:horizontal {
height: .625rem; height: .875rem;
} }
::-webkit-scrollbar:not(:horizontal) { ::-webkit-scrollbar:not(:horizontal) {
width: .625rem; width: .875rem;
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: rgba(0, 0, 0, .08); background: linear-gradient(180deg, #dfe5e8 0%, #c9d1d5 100%);
border-radius: .5rem; border-left: 1px solid #7a858b;
border-right: 1px solid #eef3f5;
border-radius: 0;
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: rgba(30, 114, 149, .35); background: linear-gradient(180deg, #8fb5c7 0%, #5d8ea5 100%);
border-radius: .5rem; border: 1px solid #446879;
border: 2px solid transparent; border-radius: 2px;
background-clip: padding-box; box-shadow: inset 1px 1px 0 rgba(255, 255, 255, 0.28);
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: rgba(30, 114, 149, .6); background: linear-gradient(180deg, #99c2d5 0%, #689ab0 100%);
border-radius: .5rem;
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-thumb:active { ::-webkit-scrollbar-thumb:active {
background: #185D79; background: linear-gradient(180deg, #5c889d 0%, #436977 100%);
border-radius: .5rem;
border: 2px solid transparent;
background-clip: padding-box;
} }
::-webkit-scrollbar-corner { ::-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 { @layer components {
+5 -2
View File
@@ -113,6 +113,7 @@
max-height: 280px; max-height: 280px;
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
background: transparent;
} }
.nitro-purse__content.is-closed { .nitro-purse__content.is-closed {
@@ -200,6 +201,7 @@
font-weight: 700; font-weight: 700;
line-height: 1; line-height: 1;
letter-spacing: 0.01em; letter-spacing: 0.01em;
color: rgba(255, 255, 255, 0.88) !important;
} }
.nitro-purse .nitro-purse-button.currency--1 .text-white { .nitro-purse .nitro-purse-button.currency--1 .text-white {
@@ -270,10 +272,11 @@
justify-content: center; justify-content: center;
min-height: 20px; min-height: 20px;
padding: 0; padding: 0;
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(7, 23, 31, 0.82);
border-radius: 7px; border-radius: 7px;
color: rgba(255, 255, 255, 0.88); color: rgba(255, 255, 255, 0.88);
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
box-shadow: none;
transition: background-color 0.18s ease, transform 0.18s ease; transition: background-color 0.18s ease, transform 0.18s ease;
} }
@@ -335,7 +338,7 @@
.seasonal-image { .seasonal-image {
display: block; display: block;
width: auto; width: auto;
height: 13px; height: 14px;
object-fit: contain; object-fit: contain;
} }
+130 -14
View File
@@ -40,9 +40,11 @@ const useCatalogState = () =>
const [ secondsLeft, setSecondsLeft ] = useState(0); const [ secondsLeft, setSecondsLeft ] = useState(0);
const [ updateTime, setUpdateTime ] = useState(0); const [ updateTime, setUpdateTime ] = useState(0);
const [ secondsLeftWithGrace, setSecondsLeftWithGrace ] = useState(0); const [ secondsLeftWithGrace, setSecondsLeftWithGrace ] = useState(0);
const [ catalogLocalizationVersion, setCatalogLocalizationVersion ] = useState(0);
const [ builderPlacementBlockedByVisitors, setBuilderPlacementBlockedByVisitors ] = useState(false); const [ builderPlacementBlockedByVisitors, setBuilderPlacementBlockedByVisitors ] = useState(false);
const [ builderPlacementAllowedInCurrentRoom, setBuilderPlacementAllowedInCurrentRoom ] = useState(false); const [ builderPlacementAllowedInCurrentRoom, setBuilderPlacementAllowedInCurrentRoom ] = useState(false);
const [ builderTrialRoomHideConfirmed, setBuilderTrialRoomHideConfirmed ] = useState(false); const [ builderTrialRoomHideConfirmed, setBuilderTrialRoomHideConfirmed ] = useState(false);
const resolvedOffersByProductKey = useRef<Map<string, IPurchasableOffer>>(new Map());
const { simpleAlert = null, showConfirm = null } = useNotification(); const { simpleAlert = null, showConfirm = null } = useNotification();
const requestedPage = useRef(new RequestedPage()); const requestedPage = useRef(new RequestedPage());
@@ -54,6 +56,7 @@ const useCatalogState = () =>
setOffersToNodes(null); setOffersToNodes(null);
setCurrentPage(null); setCurrentPage(null);
setCurrentOffer(null); setCurrentOffer(null);
resolvedOffersByProductKey.current.clear();
setActiveNodes([]); setActiveNodes([]);
setSearchResult(null); setSearchResult(null);
setFrontPageItems([]); setFrontPageItems([]);
@@ -77,6 +80,7 @@ const useCatalogState = () =>
setOffersToNodes(null); setOffersToNodes(null);
setCurrentPage(null); setCurrentPage(null);
setCurrentOffer(null); setCurrentOffer(null);
resolvedOffersByProductKey.current.clear();
setActiveNodes([]); setActiveNodes([]);
setSearchResult(null); setSearchResult(null);
setFrontPageItems([]); setFrontPageItems([]);
@@ -336,6 +340,53 @@ const useCatalogState = () =>
return offersToNodes.get(offerId); return offersToNodes.get(offerId);
}, [ offersToNodes ]); }, [ 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) => const loadCatalogPage = useCallback((pageId: number, offerId: number) =>
{ {
if(pageId < 0) return; if(pageId < 0) return;
@@ -485,6 +536,22 @@ const useCatalogState = () =>
} }
}, [ isVisible, getNodesByOfferId, activateNode ]); }, [ 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(() => 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); 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); 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); if(parser.frontPageItems && parser.frontPageItems.length) setFrontPageItems(parser.frontPageItems);
setIsBusy(false); setIsBusy(false);
if(pageId === parser.pageId) 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); 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; if(!((currentType === CatalogType.NORMAL) || ((offer.pricingModel !== Offer.PRICING_MODEL_BUNDLE) && (offer.pricingModel !== Offer.PRICING_MODEL_MULTI)))) return;
offer.page = currentPage; if(matchingNodes?.length)
setCurrentOffer(offer);
if(offer.product && (offer.product.productType === ProductTypeEnum.WALL))
{ {
setPurchaseOptions(prevValue => const referencePage = currentPage;
{
const newValue = { ...prevValue };
newValue.extraData =( offer.product.extraParam || null); offer.page = new CatalogPage(
matchingNodes[0].pageId,
return newValue; 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) // (this._isObjectMoverRequested) && (this._purchasableOffer)
}); });
@@ -976,6 +1054,44 @@ const useCatalogState = () =>
if(!searchResult && currentPage && (currentPage.pageId === -1)) openPageById(previousPageId); if(!searchResult && currentPage && (currentPage.pageId === -1)) openPageById(previousPageId);
}, [ searchResult, currentPage, previousPageId, openPageById ]); }, [ 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(() => useEffect(() =>
{ {
if(!currentOffer) return; 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); export const useCatalog = () => useBetween(useCatalogState);
+21 -1
View File
@@ -33,6 +33,26 @@ const useChatHistoryState = () =>
return newValue; return newValue;
}); });
return entry.id;
};
const updateChatEntry = (entryId: number, partial: Partial<IChatEntry>) =>
{
if(entryId < 0) return;
setChatHistory(prevValue =>
{
const index = prevValue.findIndex(entry => (entry.id === entryId));
if(index === -1) return prevValue;
const newValue = [ ...prevValue ];
newValue[index] = { ...newValue[index], ...partial };
return newValue;
});
}; };
const clearChatHistory = () => setChatHistory([]); 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 }); 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); export const useChatHistory = () => useBetween(useChatHistoryState);
+40 -2
View File
@@ -4,6 +4,7 @@ import { useBetween } from 'use-between';
import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api'; import { CloneObject, LocalizeText, MessengerIconState, MessengerThread, MessengerThreadChat, NotificationAlertType, PlaySound, SendMessageComposer, SoundNames } from '../../api';
import { useMessageEvent } from '../events'; import { useMessageEvent } from '../events';
import { useNotification } from '../notification'; import { useNotification } from '../notification';
import { IResolvedTranslation, useTranslation } from '../translation';
import { useFriends } from './useFriends'; import { useFriends } from './useFriends';
const useMessengerState = () => const useMessengerState = () =>
@@ -14,6 +15,7 @@ const useMessengerState = () =>
const [iconState, setIconState] = useState<number>(MessengerIconState.HIDDEN); const [iconState, setIconState] = useState<number>(MessengerIconState.HIDDEN);
const { getFriend = null } = useFriends(); const { getFriend = null } = useFriends();
const { simpleAlert = null } = useNotification(); const { simpleAlert = null } = useNotification();
const { settings, translateIncoming } = useTranslation();
const visibleThreads = useMemo(() => messageThreads.filter(thread => (hiddenThreadIds.indexOf(thread.threadId) === -1)), [messageThreads, hiddenThreadIds]); 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]); 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); 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; if (!thread || !messageText || !messageText.length) return;
@@ -87,6 +89,8 @@ const useMessengerState = () =>
if (ownMessage && (messageText.length <= 255)) SendMessageComposer(new SendMessageComposerPacket(thread.participant.id, messageText)); if (ownMessage && (messageText.length <= 255)) SendMessageComposer(new SendMessageComposerPacket(thread.participant.id, messageText));
let addedChatId = -1;
setMessageThreads(prevValue => setMessageThreads(prevValue =>
{ {
const newValue = [...prevValue]; const newValue = [...prevValue];
@@ -98,7 +102,11 @@ const useMessengerState = () =>
if (ownMessage && (thread.groups.length === 1)) PlaySound(SoundNames.MESSENGER_NEW_THREAD); 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(); if (activeThreadId === thread.threadId) thread.setRead();
@@ -108,6 +116,36 @@ const useMessengerState = () =>
return newValue; return newValue;
}); });
const canTranslateMessage = !translation
&& settings.enabled
&& (messageType === MessengerThreadChat.CHAT)
&& !!messageText?.trim().length;
if(!canTranslateMessage || (addedChatId <= 0)) return;
void translateIncoming(messageText).then(translation =>
{
if(!translation) return;
setMessageThreads(prevValue =>
{
const newValue = [ ...prevValue ];
const index = newValue.findIndex(newThread => (newThread.threadId === thread.threadId));
if(index === -1) return prevValue;
const clonedThread = CloneObject(newValue[index]);
const chat = clonedThread.getChat(addedChatId);
if(!chat) return prevValue;
chat.setTranslation(translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage);
newValue[index] = clonedThread;
return newValue;
});
});
}; };
useMessageEvent<NewConsoleMessageEvent>(NewConsoleMessageEvent, event => useMessageEvent<NewConsoleMessageEvent>(NewConsoleMessageEvent, event =>
+1
View File
@@ -19,6 +19,7 @@ export * from './rooms/promotes';
export * from './rooms/widgets'; export * from './rooms/widgets';
export * from './rooms/widgets/furniture'; export * from './rooms/widgets/furniture';
export * from './session'; export * from './session';
export * from './translation';
export * from './useLocalStorage'; export * from './useLocalStorage';
export * from './useSharedVisibility'; export * from './useSharedVisibility';
export * from './wired'; export * from './wired';
+1
View File
@@ -1,6 +1,7 @@
export * from './useInventoryBadges'; export * from './useInventoryBadges';
export * from './useInventoryBots'; export * from './useInventoryBots';
export * from './useInventoryFurni'; export * from './useInventoryFurni';
export * from './useInventoryNickIcons';
export * from './useInventoryPets'; export * from './useInventoryPets';
export * from './useInventoryPrefixes'; export * from './useInventoryPrefixes';
export * from './useInventoryTrade'; export * from './useInventoryTrade';
+35
View File
@@ -292,6 +292,41 @@ const useInventoryFurniState = () =>
setNeedsUpdate(false); setNeedsUpdate(false);
}, [ isVisible, needsUpdate ]); }, [ 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 }; return { isVisible, groupItems, setGroupItems, selectedItem, setSelectedItem, activate, deactivate, getWallItemById, getFloorItemById, getItemsByType };
}; };
@@ -0,0 +1,80 @@
import { RequestNickIconsComposer, SetActiveNickIconComposer, UserNickIconsEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react';
import { useBetween } from 'use-between';
import { INickIconItem, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useSharedVisibility } from '../useSharedVisibility';
const useInventoryNickIconsState = () =>
{
const [ needsUpdate, setNeedsUpdate ] = useState(true);
const [ nickIcons, setNickIcons ] = useState<INickIconItem[]>([]);
const [ activeNickIcon, setActiveNickIcon ] = useState<INickIconItem | null>(null);
const [ selectedNickIcon, setSelectedNickIcon ] = useState<INickIconItem | null>(null);
const { isVisible = false, activate = null, deactivate = null } = useSharedVisibility();
useMessageEvent<UserNickIconsEvent>(UserNickIconsEvent, event =>
{
const parser = event.getParser();
const ownedNickIcons = parser.nickIcons
.filter(icon => icon.owned)
.map(icon => ({
id: icon.id,
iconKey: icon.iconKey,
displayName: icon.displayName,
points: icon.points,
pointsType: icon.pointsType,
owned: true,
active: icon.active
}));
setNickIcons(ownedNickIcons);
setActiveNickIcon(ownedNickIcons.find(icon => icon.active) || null);
});
const activateNickIcon = (nickIconId: number) =>
{
SendMessageComposer(new SetActiveNickIconComposer(nickIconId));
};
const deactivateNickIcon = () =>
{
SendMessageComposer(new SetActiveNickIconComposer(0));
};
useEffect(() =>
{
if(!nickIcons.length)
{
setSelectedNickIcon(null);
return;
}
setSelectedNickIcon(prevValue =>
{
if(prevValue && nickIcons.find(icon => icon.id === prevValue.id)) return prevValue;
return nickIcons[0];
});
}, [ nickIcons ]);
useEffect(() =>
{
if(!isVisible || !needsUpdate) return;
SendMessageComposer(new RequestNickIconsComposer());
setNeedsUpdate(false);
}, [ isVisible, needsUpdate ]);
return {
nickIcons,
activeNickIcon,
selectedNickIcon,
setSelectedNickIcon,
activateNickIcon,
deactivateNickIcon,
activate,
deactivate
};
};
export const useInventoryNickIcons = () => useBetween(useInventoryNickIconsState);
+27 -3
View File
@@ -1,4 +1,4 @@
import { ActivePrefixUpdatedEvent, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, DeletePrefixComposer, UserPrefixesEvent } from '@nitrots/nitro-renderer'; import { ActivePrefixUpdatedEvent, DeletePrefixComposer, PrefixReceivedEvent, RequestPrefixesComposer, SetActivePrefixComposer, UserNickIconsEvent, UserPrefixesEvent } from '@nitrots/nitro-renderer';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useBetween } from 'use-between'; import { useBetween } from 'use-between';
import { IPrefixItem, SendMessageComposer, UnseenItemCategory } from '../../api'; import { IPrefixItem, SendMessageComposer, UnseenItemCategory } from '../../api';
@@ -24,6 +24,7 @@ const useInventoryPrefixesState = () =>
color: p.color, color: p.color,
icon: p.icon || '', icon: p.icon || '',
effect: p.effect || '', effect: p.effect || '',
font: p.font || '',
active: p.active active: p.active
})); }));
@@ -33,6 +34,28 @@ const useInventoryPrefixesState = () =>
setActivePrefix(active); setActivePrefix(active);
}); });
useMessageEvent<UserNickIconsEvent>(UserNickIconsEvent, event =>
{
const parser = event.getParser();
const newPrefixes: IPrefixItem[] = parser.ownedPrefixes.map(prefix => ({
id: prefix.id,
displayName: prefix.displayName,
text: prefix.text,
color: prefix.color,
icon: prefix.icon || '',
effect: prefix.effect || '',
font: prefix.font || '',
active: prefix.active,
isCustom: prefix.isCustom,
points: prefix.points,
pointsType: prefix.pointsType,
catalogPrefixId: prefix.catalogPrefixId
}));
setPrefixes(newPrefixes);
setActivePrefix(newPrefixes.find(prefix => prefix.active) || null);
});
useMessageEvent<PrefixReceivedEvent>(PrefixReceivedEvent, event => useMessageEvent<PrefixReceivedEvent>(PrefixReceivedEvent, event =>
{ {
const parser = event.getParser(); const parser = event.getParser();
@@ -42,6 +65,7 @@ const useInventoryPrefixesState = () =>
color: parser.color, color: parser.color,
icon: parser.icon || '', icon: parser.icon || '',
effect: parser.effect || '', effect: parser.effect || '',
font: parser.font || '',
active: false active: false
}; };
@@ -69,8 +93,8 @@ const useInventoryPrefixesState = () =>
setActivePrefix(prev => setActivePrefix(prev =>
{ {
const found = prefixes.find(p => p.id === parser.prefixId); const found = prefixes.find(p => p.id === parser.prefixId);
if(found) return { ...found, 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 || '', active: true }; return { id: parser.prefixId, text: parser.text, color: parser.color, icon: parser.icon || '', effect: parser.effect || '', font: parser.font || '', active: true };
}); });
} }
}); });
@@ -382,6 +382,23 @@ const useAvatarInfoWidgetState = () =>
return () => clearPendingAvatarInfo(); 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(() => useEffect(() =>
{ {
if(!roomSession) return; if(!roomSession) return;
+43 -6
View File
@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react';
import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../api'; import { ChatMessageTypeEnum, GetClubMemberLevel, GetConfigurationValue, LocalizeText, SendMessageComposer } from '../../../api';
import { useNitroEvent } from '../../events'; import { useNitroEvent } from '../../events';
import { useNotification } from '../../notification'; import { useNotification } from '../../notification';
import { useTranslation } from '../../translation';
import { useObjectSelectedEvent } from '../engine'; import { useObjectSelectedEvent } from '../engine';
import { useRoom } from '../useRoom'; import { useRoom } from '../useRoom';
@@ -15,6 +16,7 @@ const useChatInputWidgetState = () =>
const [ floodBlocked, setFloodBlocked ] = useState(false); const [ floodBlocked, setFloodBlocked ] = useState(false);
const [ floodBlockedSeconds, setFloodBlockedSeconds ] = useState(0); const [ floodBlockedSeconds, setFloodBlockedSeconds ] = useState(0);
const { showNitroAlert = null, showConfirm = null } = useNotification(); const { showNitroAlert = null, showConfirm = null } = useNotification();
const { settings, translateOutgoing, enqueueOutgoingTranslation } = useTranslation();
const { roomSession = null } = useRoom(); const { roomSession = null } = useRoom();
const sendChat = (text: string, chatType: number, recipientName: string = '', styleId: number = 0) => const sendChat = (text: string, chatType: number, recipientName: string = '', styleId: number = 0) =>
@@ -183,24 +185,59 @@ const useChatInputWidgetState = () =>
SendMessageComposer(new RoomSettingsComposer(roomSession.roomId)); SendMessageComposer(new RoomSettingsComposer(roomSession.roomId));
} }
return null;
case ':customize':
CreateLinkEvent('customize/show');
return null; return null;
} }
} }
const preserveTrailingSpaces = (message: string) => message.replace(/ +$/g, match => '\u00A0'.repeat(match.length));
const dispatchChatMessage = (message: string) =>
{
const preservedMessage = preserveTrailingSpaces(message);
switch(chatType) switch(chatType)
{ {
case ChatMessageTypeEnum.CHAT_DEFAULT: case ChatMessageTypeEnum.CHAT_DEFAULT:
roomSession.sendChatMessage(text, styleId); roomSession.sendChatMessage(preservedMessage, styleId);
break; return;
case ChatMessageTypeEnum.CHAT_SHOUT: case ChatMessageTypeEnum.CHAT_SHOUT:
roomSession.sendShoutMessage(text, styleId); roomSession.sendShoutMessage(preservedMessage, styleId);
break; return;
case ChatMessageTypeEnum.CHAT_WHISPER: case ChatMessageTypeEnum.CHAT_WHISPER:
roomSession.sendWhisperMessage(recipientName, text, styleId); roomSession.sendWhisperMessage(recipientName, preservedMessage, styleId);
break; return;
} }
}; };
const trimmedText = text.trimStart();
const shouldTranslateOutgoing = settings.enabled && !!trimmedText.length && (trimmedText.charAt(0) !== ':');
if(!shouldTranslateOutgoing)
{
dispatchChatMessage(text);
return null;
}
void (async () =>
{
const translation = await translateOutgoing(text);
if(translation)
{
enqueueOutgoingTranslation(translation);
dispatchChatMessage(translation.translatedText);
return;
}
dispatchChatMessage(text);
})();
return null;
};
useNitroEvent<RoomSessionChatEvent>(RoomSessionChatEvent.FLOOD_EVENT, event => useNitroEvent<RoomSessionChatEvent>(RoomSessionChatEvent.FLOOD_EVENT, event =>
{ {
setFloodBlocked(true); setFloodBlocked(true);
+92 -6
View File
@@ -1,7 +1,8 @@
import { GetGuestRoomResultEvent, GetRoomEngine, PetFigureData, RoomChatSettings, RoomChatSettingsEvent, RoomDragEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionChatEvent, RoomUserData, SystemChatStyleEnum } from '@nitrots/nitro-renderer'; import { GetGuestRoomResultEvent, GetRoomEngine, GetSessionDataManager, PetFigureData, RoomChatSettings, RoomChatSettingsEvent, RoomDragEvent, RoomObjectCategory, RoomObjectType, RoomObjectVariable, RoomSessionChatEvent, RoomUserData, SystemChatStyleEnum } from '@nitrots/nitro-renderer';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { ChatBubbleMessage, ChatBubbleUtilities, ChatEntryType, ChatHistoryCurrentDate, GetConfigurationValue, GetRoomObjectScreenLocation, IRoomChatSettings, LocalizeText, PlaySound, RoomChatFormatter } from '../../../api'; import { ChatBubbleMessage, ChatBubbleUtilities, ChatEntryType, ChatHistoryCurrentDate, GetConfigurationValue, GetRoomObjectScreenLocation, IRoomChatSettings, LocalizeText, PlaySound, RoomChatFormatter } from '../../../api';
import { useMessageEvent, useNitroEvent } from '../../events'; import { useMessageEvent, useNitroEvent } from '../../events';
import { useTranslation } from '../../translation';
import { useRoom } from '../useRoom'; import { useRoom } from '../useRoom';
import { useChatHistory } from './../../chat-history'; import { useChatHistory } from './../../chat-history';
@@ -18,8 +19,58 @@ const useChatWidgetState = () =>
protection: RoomChatSettings.FLOOD_FILTER_NORMAL protection: RoomChatSettings.FLOOD_FILTER_NORMAL
}); });
const { roomSession = null } = useRoom(); const { roomSession = null } = useRoom();
const { addChatEntry } = useChatHistory(); const { addChatEntry, updateChatEntry } = useChatHistory();
const { settings, translateIncoming, consumeOutgoingTranslation } = useTranslation();
const isDisposed = useRef(false); 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(() => 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 color = (avatarColor && (('#' + (avatarColor.toString(16).padStart(6, '0'))) || null));
const chatMessage = new ChatBubbleMessage( const chatMessage = new ChatBubbleMessage(
userData.roomIndex, userData.roomIndex,
RoomObjectCategory.UNIT, RoomObjectCategory.UNIT,
roomSession.roomId, roomSession.roomId,
text, originalText,
formattedText, formattedText,
username, username,
{ x: bubbleLocation.x, y: bubbleLocation.y }, { x: bubbleLocation.x, y: bubbleLocation.y },
@@ -149,10 +203,18 @@ const useChatWidgetState = () =>
imageUrl, imageUrl,
color); color);
if(outgoingTranslation)
{
applyTranslationToBubble(chatMessage, outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage);
}
chatMessage.prefixText = event.prefixText || ''; chatMessage.prefixText = event.prefixText || '';
chatMessage.prefixColor = event.prefixColor || ''; chatMessage.prefixColor = event.prefixColor || '';
chatMessage.prefixIcon = event.prefixIcon || ''; chatMessage.prefixIcon = event.prefixIcon || '';
chatMessage.prefixEffect = event.prefixEffect || ''; chatMessage.prefixEffect = event.prefixEffect || '';
chatMessage.prefixFont = event.prefixFont || '';
chatMessage.nickIcon = event.nickIcon || '';
chatMessage.displayOrder = event.displayOrder || 'icon-prefix-name';
setChatMessages(prevValue => setChatMessages(prevValue =>
{ {
@@ -162,7 +224,31 @@ const useChatWidgetState = () =>
return newValue; return newValue;
}); });
addChatEntry({ id: -1, webId: userData.webID, entityId: userData.roomIndex, name: username, imageUrl, style: styleId, chatType: chatType, entityType: userData.type, message: formattedText, timestamp: ChatHistoryCurrentDate(), type: ChatEntryType.TYPE_CHAT, roomId: roomSession.roomId, color }); const chatEntryId = addChatEntry({
id: -1,
webId: userData.webID,
entityId: userData.roomIndex,
name: username,
imageUrl,
style: styleId,
chatType: chatType,
entityType: userData.type,
message: formattedText,
timestamp: ChatHistoryCurrentDate(),
type: ChatEntryType.TYPE_CHAT,
roomId: roomSession.roomId,
color,
...(outgoingTranslation ? buildTranslatedEntryPatch(outgoingTranslation.originalText, outgoingTranslation.translatedText, outgoingTranslation.detectedLanguage, outgoingTranslation.targetLanguage) : {})
});
if(!settings.enabled || outgoingTranslation || !isTranslatableChatType || !text.trim().length) return;
void translateIncoming(text).then(translation =>
{
if(!translation || isDisposed.current) return;
applyAsyncTranslation(chatMessage.id, chatEntryId, translation.originalText, translation.translatedText, translation.detectedLanguage, translation.targetLanguage);
});
}); });
useNitroEvent<RoomDragEvent>(RoomDragEvent.ROOM_DRAG, event => useNitroEvent<RoomDragEvent>(RoomDragEvent.ROOM_DRAG, event =>
+1
View File
@@ -0,0 +1 @@
export * from './useTranslation';
+589
View File
@@ -0,0 +1,589 @@
import { GetConfiguration, GetLocalizationManager, GetSessionDataManager, TranslationLanguagesEvent, TranslationLanguagesRequestComposer, TranslationResultEvent, TranslationTextRequestComposer } from '@nitrots/nitro-renderer';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useBetween } from 'use-between';
import { LocalStorageKeys, SendMessageComposer } from '../../api';
import { useMessageEvent } from '../events';
import { useLocalStorage } from '../useLocalStorage';
const REQUEST_TIMEOUT_MS = 8000;
const OUTGOING_QUEUE_TTL_MS = 30000;
export interface ITranslationSettings
{
enabled: boolean;
incomingTargetLanguage: string;
outgoingTargetLanguage: string;
uiTextLanguage: string;
}
export interface ITranslationLanguage
{
code: string;
name: string;
}
export interface ITranslationTextLocale extends ITranslationLanguage
{
file: string;
}
export interface IResolvedTranslation
{
originalText: string;
translatedText: string;
detectedLanguage: string;
targetLanguage: string;
}
interface IPendingTranslationRequest
{
resolve: (translation: IResolvedTranslation) => void;
reject: (error: Error) => void;
timeoutId: number;
}
interface IQueuedOutgoingTranslation extends IResolvedTranslation
{
expiresAt: number;
}
const normalizeLanguageCode = (value: string) =>
{
if(!value || !value.trim().length) return '';
const normalized = value.trim().replace('_', '-');
const parts = normalized.split('-');
if(parts.length === 1) return parts[0].toLowerCase();
return `${ parts[0].toLowerCase() }-${ parts[1].toUpperCase() }`;
};
const TEXT_TRANSLATION_LOCALES: ITranslationTextLocale[] = [
{ code: 'pt-BR', name: 'Portuguese (Brazil)', file: 'br' },
{ code: 'en', name: 'English', file: 'com' },
{ code: 'de', name: 'German', file: 'de' },
{ code: 'es', name: 'Spanish', file: 'es' },
{ code: 'fi', name: 'Finnish', file: 'fi' },
{ code: 'fr', name: 'French', file: 'fr' },
{ code: 'it', name: 'Italian', file: 'it' },
{ code: 'nl', name: 'Dutch', file: 'nl' },
{ code: 'tr', name: 'Turkish', file: 'tr' }
];
const resolveTextTranslationLocale = (value: string) =>
{
const normalizedValue = normalizeLanguageCode(value);
if(!normalizedValue.length) return null;
const exactMatch = TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code) === normalizedValue));
if(exactMatch) return exactMatch;
const normalizedBase = normalizedValue.split('-')[0];
if(normalizedBase === 'pt') return TEXT_TRANSLATION_LOCALES.find(locale => (locale.file === 'br')) || null;
return TEXT_TRANSLATION_LOCALES.find(locale => (normalizeLanguageCode(locale.code).split('-')[0] === normalizedBase)) || null;
};
const interpolateTranslationUrl = (template: string, file: string) =>
{
if(!template || !template.length) return '';
return GetConfiguration().interpolate(
template
.replace(/%locale%/gi, file)
.replace(/%timestamp%/gi, Date.now().toString()));
};
const getTextTranslationUrl = (file: string) =>
{
const configuredTranslationUrl = GetConfiguration().getValue<string>('external.texts.translation.url') || '';
if(configuredTranslationUrl.length)
{
return interpolateTranslationUrl(configuredTranslationUrl, file);
}
const externalTextUrls = GetConfiguration().getValue<string[]>('external.texts.url') || [];
const externalTextsUrl = externalTextUrls.length ? GetConfiguration().interpolate(externalTextUrls[0]) : '';
if(!externalTextsUrl.length) return `/text_translate/ExternalTexts_${ file }.json`;
const lastSlashIndex = externalTextsUrl.lastIndexOf('/');
if(lastSlashIndex === -1) return `text_translate/ExternalTexts_${ file }.json`;
const basePath = externalTextsUrl.substring(0, lastSlashIndex);
return `${ basePath }/text_translate/ExternalTexts_${ file }.json`;
};
const getFurnitureTranslationUrl = (file: string) =>
{
const configuredTranslationUrl = GetConfiguration().getValue<string>('furnidata.translation.url') || '';
if(configuredTranslationUrl.length)
{
return interpolateTranslationUrl(configuredTranslationUrl, file);
}
const furnidataUrl = GetConfiguration().interpolate(GetConfiguration().getValue<string>('furnidata.url') || '');
if(!furnidataUrl.length) return `/furniture_translate/FurnitureData_${ file }.json`;
const lastSlashIndex = furnidataUrl.lastIndexOf('/');
if(lastSlashIndex === -1) return `furniture_translate/FurnitureData_${ file }.json`;
const basePath = furnidataUrl.substring(0, lastSlashIndex);
return `${ basePath }/furniture_translate/FurnitureData_${ file }.json`;
};
const dispatchLocalizationUpdated = () =>
{
if(typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent('nitro-localization-updated'));
};
const getBrowserLanguageCode = () =>
{
if(typeof navigator === 'undefined') return 'en';
return normalizeLanguageCode(navigator.language || 'en').split('-')[0] || 'en';
};
const decodeHtmlEntities = (value: string) =>
{
if(!value || (typeof window === 'undefined')) return value;
const textarea = document.createElement('textarea');
textarea.innerHTML = value;
return textarea.value;
};
const resolveSupportedLanguage = (value: string, languages: ITranslationLanguage[]) =>
{
const normalizedValue = normalizeLanguageCode(value);
if(!languages.length) return normalizedValue || 'en';
const exactMatch = languages.find(language => (normalizeLanguageCode(language.code) === normalizedValue));
if(exactMatch) return exactMatch.code;
const normalizedBase = normalizedValue.split('-')[0];
const baseMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase));
if(baseMatch) return baseMatch.code;
const englishMatch = languages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === 'en'));
if(englishMatch) return englishMatch.code;
return languages[0].code;
};
const useTranslationState = () =>
{
const defaultTargetLanguage = getBrowserLanguageCode();
const [ settings, setSettings ] = useLocalStorage<ITranslationSettings>(LocalStorageKeys.CHAT_TRANSLATION_SETTINGS, {
enabled: false,
incomingTargetLanguage: defaultTargetLanguage,
outgoingTargetLanguage: defaultTargetLanguage,
uiTextLanguage: ''
});
const [ supportedLanguages, setSupportedLanguages ] = useState<ITranslationLanguage[]>([]);
const [ availableTextLocales ] = useState<ITranslationTextLocale[]>(TEXT_TRANSLATION_LOCALES);
const [ languagesLoading, setLanguagesLoading ] = useState(false);
const [ languagesLoaded, setLanguagesLoaded ] = useState(false);
const [ localizationTextsLoading, setLocalizationTextsLoading ] = useState(false);
const [ lastIncomingLanguage, setLastIncomingLanguage ] = useState('');
const [ lastOutgoingLanguage, setLastOutgoingLanguage ] = useState('');
const [ lastError, setLastError ] = useState('');
const requestIdRef = useRef(0);
const languagesTimeoutRef = useRef(0);
const pendingRequestsRef = useRef(new Map<number, IPendingTranslationRequest>());
const translationCacheRef = useRef(new Map<string, IResolvedTranslation>());
const outgoingQueueRef = useRef(new Map<string, IQueuedOutgoingTranslation[]>());
const localizationRequestRef = useRef(0);
const clearLanguagesTimeout = useCallback(() =>
{
if(!languagesTimeoutRef.current) return;
window.clearTimeout(languagesTimeoutRef.current);
languagesTimeoutRef.current = 0;
}, []);
const pruneOutgoingQueue = useCallback(() =>
{
const now = Date.now();
outgoingQueueRef.current.forEach((entries, key) =>
{
const activeEntries = entries.filter(entry => (entry.expiresAt > now));
if(activeEntries.length)
{
outgoingQueueRef.current.set(key, activeEntries);
return;
}
outgoingQueueRef.current.delete(key);
});
}, []);
const updateSettings = useCallback((partial: Partial<ITranslationSettings>) =>
{
setSettings(prevValue => ({ ...prevValue, ...partial }));
}, [ setSettings ]);
const getLanguageName = useCallback((languageCode: string) =>
{
const normalizedLanguageCode = normalizeLanguageCode(languageCode);
if(!normalizedLanguageCode.length) return 'auto';
const exactMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code) === normalizedLanguageCode));
if(exactMatch) return exactMatch.name;
const normalizedBase = normalizedLanguageCode.split('-')[0];
const baseMatch = supportedLanguages.find(language => (normalizeLanguageCode(language.code).split('-')[0] === normalizedBase));
return baseMatch?.name || normalizedLanguageCode;
}, [ supportedLanguages ]);
const handleLanguagesEvent = useCallback((event: TranslationLanguagesEvent) =>
{
const parser = event.getParser();
clearLanguagesTimeout();
setLanguagesLoading(false);
if(!parser.success)
{
setLanguagesLoaded(false);
setLastError(parser.errorMessage || 'Unable to load Google Translate languages.');
return;
}
const nextLanguages = parser.languages.map(language => ({
code: normalizeLanguageCode(language.code),
name: language.name
}));
setSupportedLanguages(nextLanguages);
setLanguagesLoaded(true);
setLastError('');
}, [ clearLanguagesTimeout ]);
const handleTranslationResult = useCallback((event: TranslationResultEvent) =>
{
const parser = event.getParser();
const pendingRequest = pendingRequestsRef.current.get(parser.requestId);
if(!pendingRequest) return;
window.clearTimeout(pendingRequest.timeoutId);
pendingRequestsRef.current.delete(parser.requestId);
if(!parser.success)
{
pendingRequest.reject(new Error(parser.errorMessage || 'Unable to translate text.'));
return;
}
pendingRequest.resolve({
originalText: decodeHtmlEntities(parser.originalText || ''),
translatedText: decodeHtmlEntities(parser.translatedText || ''),
detectedLanguage: normalizeLanguageCode(parser.detectedLanguage || ''),
targetLanguage: normalizeLanguageCode(parser.targetLanguage || '')
});
}, []);
useMessageEvent<TranslationLanguagesEvent>(TranslationLanguagesEvent, handleLanguagesEvent);
useMessageEvent<TranslationResultEvent>(TranslationResultEvent, handleTranslationResult);
const ensureSupportedLanguagesLoaded = useCallback((force: boolean = false) =>
{
if(languagesLoading) return;
if(languagesLoaded && !force) return;
setLanguagesLoading(true);
setLastError('');
clearLanguagesTimeout();
languagesTimeoutRef.current = window.setTimeout(() =>
{
setLanguagesLoading(false);
setLastError('Google Translate did not respond while loading languages.');
}, REQUEST_TIMEOUT_MS);
SendMessageComposer(new TranslationLanguagesRequestComposer(getBrowserLanguageCode()));
}, [ clearLanguagesTimeout, languagesLoaded, languagesLoading ]);
const translateText = useCallback((text: string, targetLanguage: string) =>
{
const safeText = (text || '');
const normalizedTargetLanguage = normalizeLanguageCode(targetLanguage || defaultTargetLanguage) || defaultTargetLanguage;
if(!safeText.trim().length)
{
return Promise.resolve({
originalText: safeText,
translatedText: safeText,
detectedLanguage: '',
targetLanguage: normalizedTargetLanguage
});
}
const cacheKey = `${ normalizedTargetLanguage }\u0000${ safeText }`;
const cachedValue = translationCacheRef.current.get(cacheKey);
if(cachedValue) return Promise.resolve(cachedValue);
return new Promise<IResolvedTranslation>((resolve, reject) =>
{
const requestId = ++requestIdRef.current;
const timeoutId = window.setTimeout(() =>
{
pendingRequestsRef.current.delete(requestId);
reject(new Error('Google Translate did not respond in time.'));
}, REQUEST_TIMEOUT_MS);
pendingRequestsRef.current.set(requestId, { resolve, reject, timeoutId });
SendMessageComposer(new TranslationTextRequestComposer(requestId, safeText, normalizedTargetLanguage));
}).then(result =>
{
translationCacheRef.current.set(cacheKey, result);
return result;
});
}, [ defaultTargetLanguage ]);
const translateIncoming = useCallback(async (text: string) =>
{
if(!settings.enabled) return null;
try
{
const result = await translateText(text, settings.incomingTargetLanguage || defaultTargetLanguage);
setLastIncomingLanguage(result.detectedLanguage || '');
setLastError('');
return result;
}
catch(error)
{
setLastError((error as Error)?.message || 'Unable to translate incoming text.');
return null;
}
}, [ defaultTargetLanguage, settings.enabled, settings.incomingTargetLanguage, translateText ]);
const translateOutgoing = useCallback(async (text: string) =>
{
if(!settings.enabled) return null;
try
{
const result = await translateText(text, settings.outgoingTargetLanguage || defaultTargetLanguage);
setLastOutgoingLanguage(result.detectedLanguage || '');
setLastError('');
return result;
}
catch(error)
{
setLastError((error as Error)?.message || 'Unable to translate outgoing text.');
return null;
}
}, [ defaultTargetLanguage, settings.enabled, settings.outgoingTargetLanguage, translateText ]);
const enqueueOutgoingTranslation = useCallback((translation: IResolvedTranslation) =>
{
if(!translation) return;
pruneOutgoingQueue();
const queueKey = translation.translatedText || translation.originalText;
const currentEntries = outgoingQueueRef.current.get(queueKey) || [];
currentEntries.push({
...translation,
expiresAt: (Date.now() + OUTGOING_QUEUE_TTL_MS)
});
outgoingQueueRef.current.set(queueKey, currentEntries);
setLastOutgoingLanguage(translation.detectedLanguage || '');
}, [ pruneOutgoingQueue ]);
const consumeOutgoingTranslation = useCallback((translatedText: string) =>
{
pruneOutgoingQueue();
const queueKey = translatedText || '';
const currentEntries = outgoingQueueRef.current.get(queueKey);
if(!currentEntries?.length) return null;
const entry = currentEntries.shift();
if(currentEntries.length) outgoingQueueRef.current.set(queueKey, currentEntries);
else outgoingQueueRef.current.delete(queueKey);
if(entry?.detectedLanguage) setLastOutgoingLanguage(entry.detectedLanguage);
return entry || null;
}, [ pruneOutgoingQueue ]);
useEffect(() =>
{
if(!settings.enabled) return;
ensureSupportedLanguagesLoaded();
}, [ ensureSupportedLanguagesLoaded, settings.enabled ]);
useEffect(() =>
{
if(!supportedLanguages.length) return;
const resolvedIncomingTargetLanguage = resolveSupportedLanguage(settings.incomingTargetLanguage || defaultTargetLanguage, supportedLanguages);
const resolvedOutgoingTargetLanguage = resolveSupportedLanguage(settings.outgoingTargetLanguage || defaultTargetLanguage, supportedLanguages);
if((resolvedIncomingTargetLanguage === settings.incomingTargetLanguage) && (resolvedOutgoingTargetLanguage === settings.outgoingTargetLanguage)) return;
setSettings(prevValue => ({
...prevValue,
incomingTargetLanguage: resolvedIncomingTargetLanguage,
outgoingTargetLanguage: resolvedOutgoingTargetLanguage
}));
}, [ defaultTargetLanguage, setSettings, settings.incomingTargetLanguage, settings.outgoingTargetLanguage, supportedLanguages ]);
useEffect(() =>
{
let disposed = false;
const requestId = ++localizationRequestRef.current;
const localizationManager = GetLocalizationManager();
const sessionDataManager = GetSessionDataManager();
const selectedLocale = resolveTextTranslationLocale(settings.uiTextLanguage || '');
const applyLocalizationOverride = async () =>
{
if(!selectedLocale)
{
localizationManager.clearOverrideValues();
sessionDataManager.clearFurnitureDataOverrides();
dispatchLocalizationUpdated();
if((localizationRequestRef.current === requestId) && !disposed)
{
setLocalizationTextsLoading(false);
setLastError('');
}
return;
}
if(!disposed) setLocalizationTextsLoading(true);
try
{
const textUrl = getTextTranslationUrl(selectedLocale.file);
const furnitureUrl = getFurnitureTranslationUrl(selectedLocale.file);
const response = await fetch(textUrl);
if(response.status !== 200) throw new Error(`Unable to load ${ textUrl }`);
const data = await response.json();
const overrideValues = new Map<string, string>();
Object.keys(data || {}).forEach(key => overrideValues.set(key, data[key]));
if(disposed || (localizationRequestRef.current !== requestId)) return;
localizationManager.setOverrideValues(overrideValues);
try
{
await sessionDataManager.applyFurnitureDataOverrides(furnitureUrl);
}
catch
{
if(disposed || (localizationRequestRef.current !== requestId)) return;
sessionDataManager.clearFurnitureDataOverrides();
}
dispatchLocalizationUpdated();
setLastError('');
}
catch(error)
{
if(disposed || (localizationRequestRef.current !== requestId)) return;
localizationManager.clearOverrideValues();
sessionDataManager.clearFurnitureDataOverrides();
dispatchLocalizationUpdated();
setLastError((error as Error)?.message || 'Unable to load translated UI texts.');
}
finally
{
if(disposed || (localizationRequestRef.current !== requestId)) return;
setLocalizationTextsLoading(false);
}
};
applyLocalizationOverride();
return () =>
{
disposed = true;
};
}, [ settings.uiTextLanguage ]);
useEffect(() =>
{
return () =>
{
clearLanguagesTimeout();
pendingRequestsRef.current.forEach(pendingRequest => window.clearTimeout(pendingRequest.timeoutId));
pendingRequestsRef.current.clear();
outgoingQueueRef.current.clear();
};
}, [ clearLanguagesTimeout ]);
return {
settings,
supportedLanguages,
availableTextLocales,
languagesLoading,
languagesLoaded,
localizationTextsLoading,
lastIncomingLanguage,
lastOutgoingLanguage,
lastError,
updateSettings,
ensureSupportedLanguagesLoaded,
translateIncoming,
translateOutgoing,
enqueueOutgoingTranslation,
consumeOutgoingTranslation,
getLanguageName
};
};
export const useTranslation = () => useBetween(useTranslationState);
+2
View File
@@ -12,6 +12,8 @@ import './css/common/Buttons.css';
import './css/forms/form_select.css'; import './css/forms/form_select.css';
import './css/friends/FriendsView.css';
import './css/hotelview/HotelView.css'; import './css/hotelview/HotelView.css';
import './css/icons/icons.css'; import './css/icons/icons.css';
+5
View File
@@ -8,3 +8,8 @@ declare module '*.gif' {
const src: string; const src: string;
export default src; export default src;
} }
interface ImportMeta
{
glob: (pattern: string, options?: { eager?: boolean; import?: string }) => Record<string, string>;
}