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